Layouts & chrome
By default the platform wraps your rendered sections in its own chrome. Declare
supports.layout: true and ship a layout.liquid, and your template owns the body chrome, header, navigation, footer, and where the page content sits. This is what lets a template
restructure a site, not just recolour it.
The division of ownership
Section titled “The division of ownership”| Yours (layout.liquid) | The platform’s (always) |
|---|---|
| Header markup & brand lockup | <head>, title, SEO, social cards, JSON-LD |
Navigation (from nav data) |
Consent banner + consent-gated tags |
| Footer | The Port60 ID identity brand (the member_menu island) |
Where {% content %} goes |
Webfont loading (from your manifest’s fonts) |
A template can never omit compliance chrome, the platform appends it after your layout’s output.
A minimal layout
Section titled “A minimal layout”<header class="site-header"> <div class="container header-inner"> <a class="brand-lockup" href="/"> {% if brand.logoUrl and brand.logoType != 'icon' %} <img class="brand-logo" src="{{ brand.logoUrl }}" alt="{{ brand.name }}"> {% else %} <strong>{{ brand.name }}</strong> {% endif %} </a> <nav class="site-nav" id="site-nav" aria-label="Primary"> {% for item in nav.items %} {% if item.cta %}<a class="nav-cta" href="{{ item.href }}">{{ item.label }}</a> {% else %}<a href="{{ item.href }}">{{ item.label }}</a>{% endif %} {% endfor %} {% island 'member_menu' %} </nav> </div></header>
<main> {% content %}</main>
<footer class="site-footer"> <p>{{ brand.name }}{% if brand.tagline %}, {{ brand.tagline }}{% endif %}</p></footer>Three rules the validator enforces:
- Exactly one
{% content %}, the slot the page’s rendered sections are injected into. {% content %}is layout-only, a section renderer using it fails conformance.- Islands placed in the layout follow the same discipline as everywhere: declared in
supports.islands, present in the registry.
Navigation is data, not markup
Section titled “Navigation is data, not markup”Your layout receives nav.items, the tenant’s primary navigation. By default it’s derived by
the platform from what actually exists (pages, enabled features, published content); when the tenant
has curated a header menu in their admin, nav.items is that menu instead. Either way it’s the
same shape, so you render it identically and never need feature logic. Each item has label, href,
an optional cta flag (style it prominently, the Donate action), an optional external flag (add
target="_blank" rel="noopener"), and optional children for dropdown groups. Render all of it.
Two more menus ride alongside:
nav.footer, the tenant’s curated FOOTER menu (same item shape). It’s empty unless they’ve built one, so branch on it and fall back to whatever footer links you’d normally show:{% if nav.footer.size > 0 %}…{% else %}…{% endif %}. Supporting it is optional, a template that ignores it just keeps its own footer.nav.derived, the platform’s automatic list, always present even when the tenant has curatednav.items. Reach for it only if you want the auto, feature-aware nav somewhere regardless of their header curation; most templates just rendernav.items.
See Render context for the fixture your layout must survive.
Dropdowns and mega navigation
Section titled “Dropdowns and mega navigation”Navigation may contain two levels below the top item. A child can carry group, description and
imageUrl, while a top item can carry megaMenu.columns and an optional megaMenu.promo card.
These presentation fields are tenant-authored. They have different jobs:
groupis a shared presentation heading, such as “Ways to help” or “Events”. Several siblings with the same value belong under one heading; it is not a badge to repeat on every card.childrenis the actual navigation hierarchy. Keep each child’s descendants attached to that child. Grouping siblings must not flatten a category and its article links into unrelated cards.- Article categories organise content. A navigation
groupstring does not assign an article to a category or create a category archive. Use the category links the platform supplies.
The example below reads the current site.nav tree. It renders named groups in their first-seen
order, keeps links within each group in supplied order, then renders ungrouped links in a separate
area without inventing an “Other” heading. Missing, null, empty and whitespace-only group values
are ungrouped. Every supplied child and grandchild remains available.
map, compact, uniq and where are supported by the Liquid whitelist. group_by is not.
Render the supplied structure and omit optional presentation when its data is absent:
<nav id="site-nav" aria-label="Primary" data-p60-nav>{% for item in site.nav.items %} {% if item.children.size > 0 %} <div class="nav-group" data-p60-nav-item> <a class="nav-group-toggle" href="{{ item.href }}" data-p60-nav-toggle aria-haspopup="true" aria-expanded="false" aria-controls="mega-menu-{{ forloop.index }}" {% if item.external %}target="_blank" rel="noopener noreferrer"{% endif %} > {{ item.label }} </a> <div class="mega-menu" id="mega-menu-{{ forloop.index }}" data-p60-nav-menu> <div class="mega-menu-heading"> <strong>{{ item.label }}</strong> <a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener noreferrer"{% endif %}>View all {{ item.label }}</a> </div> <div class="mega-menu-links"{% if item.megaMenu.columns %} style="--mega-columns: {{ item.megaMenu.columns }}"{% endif %}> {% assign groupNames = item.children | map: 'group' | compact | uniq %} {% for groupName in groupNames %} {% assign trimmedGroup = groupName | strip %} {% if trimmedGroup == '' %}{% continue %}{% endif %} {% assign groupedChildren = item.children | where: 'group', groupName %} <section class="mega-menu-group"> <h3 class="mega-menu-group-label">{{ groupName }}</h3> <div class="mega-menu-group-items"> {% for child in groupedChildren %} <div class="mega-menu-card"> <a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener noreferrer"{% endif %}> {% if child.imageUrl != nil and child.imageUrl != '' %}<img src="{{ child.imageUrl }}" alt="">{% endif %} <strong>{{ child.label }}</strong> {% if child.description %}<span>{{ child.description }}</span>{% endif %} </a> {% if child.children.size > 0 %} <ul class="mega-menu-children"> {% for leaf in child.children %} <li><a href="{{ leaf.href }}"{% if leaf.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ leaf.label }}</a></li> {% endfor %} </ul> {% endif %} </div> {% endfor %} </div> </section> {% endfor %} {% assign hasUngrouped = false %} {% for child in item.children %} {% assign childGroup = child.group | default: '' | strip %} {% if childGroup == '' %}{% assign hasUngrouped = true %}{% break %}{% endif %} {% endfor %} {% if hasUngrouped %} <div class="mega-menu-group-items mega-menu-ungrouped"> {% for child in item.children %} {% assign childGroup = child.group | default: '' | strip %} {% unless childGroup == '' %}{% continue %}{% endunless %} <div class="mega-menu-card"> <a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener noreferrer"{% endif %}> {% if child.imageUrl != nil and child.imageUrl != '' %}<img src="{{ child.imageUrl }}" alt="">{% endif %} <strong>{{ child.label }}</strong> {% if child.description %}<span>{{ child.description }}</span>{% endif %} </a> {% if child.children.size > 0 %} <ul class="mega-menu-children"> {% for leaf in child.children %} <li><a href="{{ leaf.href }}"{% if leaf.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ leaf.label }}</a></li> {% endfor %} </ul> {% endif %} </div> {% endfor %} </div> {% endif %} </div> {% if item.megaMenu.promo %} <a class="mega-menu-promo" href="{{ item.megaMenu.promo.href | default: item.href }}"> {% if item.megaMenu.promo.imageUrl %}<img src="{{ item.megaMenu.promo.imageUrl }}" alt="">{% endif %} <strong>{{ item.megaMenu.promo.title }}</strong> {% if item.megaMenu.promo.text %}<span>{{ item.megaMenu.promo.text }}</span>{% endif %} {% if item.megaMenu.promo.label %}<span>{{ item.megaMenu.promo.label }}</span>{% endif %} </a> {% endif %} </div> </div> {% elsif item.cta %} <a class="nav-cta" href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.label }}</a> {% else %} <a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.label }}</a> {% endif %}{% endfor %}</nav>You design the menu; the platform’s nav behaviour makes it work. Under .p60-js show a panel
only when its group carries is-open, which the engine manages with hover intent (the pointer can
cross the gap between the toggle and the panel), keyboard and touch toggling, Escape, outside-click
and focus-away dismissal, and honest aria-expanded. Keep your :hover and :focus-within rules
scoped to html:not(.p60-js) so the no-JS render still opens on hover. Inside the open mobile panel
the same is-open lands on the group when its toggle is tapped. Declare nav in
supports.behaviors; the grammar is in Motion and behaviour.
The older data-nav-drop hooks keep working, but only cover taps inside the mobile panel. Do not
add fixed category descriptions or links that are not present in the navigation data.
The editor limits megaMenu.columns to 2 through 4 and the context limits navigation depth, so a
template can make responsive decisions without handling an unbounded tree. The template decides
whether a named group is one column or spans a row containing several cards. Do not repeat its
heading merely to fill each column.
News, blogs and article categories
Section titled “News, blogs and article categories”Articles and blog posts use the same article content model. “News”, “Blog” and “Latest” can be tenant-chosen navigation labels; they do not identify separate collections. Categories organise those articles, while the navigation tree determines which category and article links a visitor sees.
In automatic navigation, published articles produce an entry using the tenant’s articles label. When at least two categories exist, its children are category archive links followed by “All articles”. With fewer categories it is a plain link. A tenant-curated header menu replaces this automatic menu rather than merging with it. Curated article-topic links therefore stay where the tenant placed them; templates must not append a second set of categories from article content.
The menu builder also offers a top-level “Latest articles in a topic (auto)” item. The platform
resolves its article links at render time; this automatic item is not currently available nested
inside another menu. A supplied tree can otherwise use both child levels, for example a News
parent, category children and article grandchildren. Render those relationships as supplied, using
their existing URLs. A matching group label is only a shared visual heading, not an instruction to
fetch articles, create a category or invent another hierarchy.
Worship schedules
Section titled “Worship schedules”A template that declares supports.worship: true receives the optional worship object in its
layout. It contains tenant supplied labels, dates, a note and up to eight prayer or service times.
Always branch on the object so sites without a configured schedule do not receive empty chrome:
{% if worship %} <aside class="worship-rail" aria-label="Worship times"> <p> {{ worship.today }} {% if worship.todayAlt %}<span>{{ worship.todayAlt }}</span>{% endif %} </p> <div class="worship-scroll"> <table> <thead> <tr> <td></td> {% for time in worship.times %}<th scope="col">{{ time.name }}</th>{% endfor %} </tr> </thead> <tbody> <tr> <th scope="row">{{ worship.beginsLabel }}</th> {% for time in worship.times %}<td>{{ time.begins }}</td>{% endfor %} </tr> <tr> <th scope="row">{{ worship.congregationLabel }}</th> {% for time in worship.times %}<td>{{ time.congregation }}</td>{% endfor %} </tr> </tbody> </table> </div> {% if worship.note %} {% if worship.href %}<a href="{{ worship.href }}">{{ worship.note }}</a> {% else %}<p>{{ worship.note }}</p>{% endif %} {% endif %} </aside>{% endif %}Use horizontal scrolling for the timetable on narrow screens. Do not calculate prayer times in the template. The platform and tenant own those values.
The same object can also contain worship.next, structured worship.jumah, worship.hijri and
worship.observances. Branch on each value. Place {% island 'next_prayer' %} when you want the
platform-owned live countdown instead of a static next time.
Locale and direction
Section titled “Locale and direction”Every layout receives locale.code, locale.direction and locale.languages. The platform sets
the document lang and dir attributes before your layout renders. Use logical CSS properties such
as margin-inline-start, and place {% island 'language_switch' %} when the design needs the
platform-owned language selector. Contract v1 currently supplies English, Welsh and Arabic.
Identity is placed, never rebuilt
Section titled “Identity is placed, never rebuilt”Put {% island 'member_menu' %} in your header’s nav. It renders the branded Port60 ID sign-in
button for guests and the account pill for signed-in members, and renders nothing when the
tenant doesn’t allow member sign-ups. The button’s look and popup behaviour are platform-owned and
identical across every template (it’s a trust anchor, like a “Sign in with Google” button, a
hand-rolled copy would erode it). Omitting it is a validator warning: sign-in becomes unreachable
on member-enabled tenants.
Behaviour attributes
Section titled “Behaviour attributes”The platform tail script binds behaviours to documented attributes, emit them and the behaviour arrives free, platform-maintained:
| Attribute | Behaviour |
|---|---|
data-p60-nav, data-p60-nav-item, data-p60-nav-toggle, data-p60-nav-menu, data-p60-nav-burger |
The nav behaviour: menus and the burger with hover intent, keyboard, touch, Escape and dismissal, state class is-open (declare nav in supports.behaviors) |
data-nav-burger on a button + id="site-nav" on the nav |
Legacy mobile burger open/close (an .open class toggle; links close the panel) |
data-consent-open on any element |
Opens the cookie preference centre |
The platform’s base stylesheet also ships the chrome mechanics for the standard class names
(.site-header, .site-nav, .nav-dropdown, .nav-burger breakpoints…). Reusing them is
optional but saves you the responsive plumbing; your own class names are equally valid, then the
responsive behaviour is yours to style.
Declare webfont stylesheets in the manifest, the platform loads them in <head> with proper
preconnects (never a render-blocking CSS @import):
{ "fonts": ["https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap"] }Google Fonts css2 URLs only (the schema enforces the pattern).
Honesty applies to chrome too
Section titled “Honesty applies to chrome too”The truthfulness rule covers layouts: a footer
derives from brand and nav, it must not ship fabricated mission statements or invented
claims. The starter’s footer is the worked example: brand name, tagline (only when present),
navigation links, legal links. Derive or omit.

