# Port60 template development, the complete reference for AI agents Port60 site templates are versioned artifacts of Liquid renderers + CSS rendered by the platform engine. Scaffold one with `p60-template-kit create`, iterate with `p60-template-kit validate --json` (the machine feedback loop), preview with `dev`, and `package` for studio upload. Every scaffold contains AGENTS.md with the working rules. ━━━ GUIDES ━━━ ━━ guides/ai-quickstart.md ━━ Port60 templates are unusually good territory for AI coding agents: the whole job is a handful of Liquid files against a **machine-readable contract**, with a validator that returns structured errors the agent can iterate against, and hard walls (the dialect whitelist, escape-by-default, render budgets, human review) that make the worst an agent can produce a *rejected upload*, never a broken charity site. ## The setup ```bash npx @port60/template-kit create riverside-warm cd riverside-warm && npm install ``` The scaffold ships **`AGENTS.md`** (and an identical `CLAUDE.md`), the briefing every serious coding agent reads on entry: the file layout, the contract rules, and the iteration loop. You don't need to explain the platform to your agent; the scaffold already has. ## The loop your agent should run 1. Edit `sections/*.liquid`, `layout.liquid`, `assets/theme.css`, `manifest.json`. 2. **`npm run validate:json`**, machine-readable: `{ok, errors[], warnings[], provenSupports}`. The agent fixes every error and re-runs until `ok: true`. This is the platform's *exact* upload check, so a clean local pass is a guaranteed clean upload. 3. You watch **`npm run dev`** at `http://localhost:4400`, the same fixtures render reviewers see. Refresh after edits; validation re-runs in the terminal on every save. 4. **`npm run package`**, the uploadable `-.zip`. ## A kickoff prompt that works > Read AGENTS.md first. Build me a warm, editorial template for a food-bank charity: generous > type, cream background, a photography-led hero. Support the hero, values, cta and whatsOn > sections, a layout with the member menu island in the header, and ship two looks, "Warm" and > "Evening". After every change run `npm run validate:json` and fix all errors. Tell me when it > validates clean. ## For the agent itself Everything on this site is available as one plain-text file at [`/llms-full.txt`](/llms-full.txt), the guides plus the generated contract reference (sections, islands, context variables, dialect, tokens, manifest schema). Fetch it once into context and build against it. ## What the agent cannot do, by design Supports flags are **proven, not claimed**: declaring `supports.worship` without rendering the worship fixture is a validation error, as is the reverse. Islands are placed, never implemented. Uploads only exist for approved developers, and every version passes human review before it can reach a charity's site. The agent gets you to a validated artifact fast; the trust chain stays. ━━ guides/artifact-anatomy.md ━━ A template artifact is a directory published to object storage under `{name}/{version}/`. Once published, that prefix is **immutable**; the platform caches artifacts aggressively (in-memory and at the CDN) precisely because a version can never change underneath anyone. ```text {name}/{version}/ ├── manifest.json # REQUIRED: identity, format, what you support ├── layout.liquid # OPTIONAL: the page chrome, when supports.layout is true ├── sections/ # REQUIRED: one {type}.liquid per supported section type │ └── *.liquid ├── pages/ # OPTIONAL: display bodies for pages in supports.pageTemplates │ └── *.liquid └── assets/ └── theme.css # REQUIRED: the template's entire look ``` ## manifest.json The manifest declares what your template **is** and what it **supports**. It's validated against [a JSON Schema](/reference/manifest/) at publish time; the important fields: | Field | What it means | | --- | --- | | `name` | Permanent identity. Lowercase, hyphenated, stable across every version. | | `version` | Semver. Immutable once published, a fix is a new version. | | `format` | The contract major you're written against, currently `port60-liquid@1`. | | `supports.pages` | Section based page bodies you render: currently `home` and `about`. | | `supports.sections` | Which section types you ship renderers for. | | `supports.islands` | Every island your renderers place. | | `supports.layout` | True when you ship `layout.liquid` and own the chrome, see [Layouts](/guides/layouts/). | | `supports.pageTemplates` | Route specific data views you render. Listings are `events` and `articles`; details are `course` and `article`. See [Page templates](/guides/page-templates/). | | `compositions` | Your preferred composition per page: the ordered section types the design is built around, each with a role. `core` carries the design (an organisation removing one is warned, never stopped), `recommended` is on by default, `optional` is offered. A page an organisation never edited renders this composition, the kit preview renders it, and the Pages editor offers it as the order to reset to. Every type must be in `supports.sections` and belong to that page. | | `requiresCapabilities` | Capabilities the design needs to work as intended. This is checked against the artifact and used for catalogue matching. It never grants an entitlement. | | `suitsProfiles` | Organisation profiles the design was composed for. This improves recommendations but never hides or unlocks a feature. | | `fonts` | Webfont stylesheet URLs the platform loads in ``. | | `settings.schema` | Your own theming knobs (colour schemes etc.), see [Theming](/guides/theming/). | Two support rules matter: 1. **Sections you don't support are omitted, never an error.** If a charity authors a section your template has no renderer for, that section silently doesn't render. Support the catalogue broadly, but you'll never crash for missing one. 2. **Every island you place must be declared** in `supports.islands`; the validator enforces placed ⊆ declared ⊆ platform registry. 3. **Every required capability needs a visible surface.** The validator rejects a capability claim that has no matching section, page template, island or context declaration in the artifact. ## sections/*.liquid One file per supported section type, named exactly after it (`hero.liquid` renders `hero` sections). Each is a self-contained fragment written in the [Port60 dialect](/reference/dialect/), there is no include mechanism and no partials in contract v1, and sections never contain chrome (that's [the layout's job](/guides/layouts/)). Each renderer receives the [render context](/reference/context/): the `section` object (typed per the [section catalogue](/reference/sections/)) and `brand`. Renderers must survive the **minimal fixture** of every type they support, the least content a charity can author. The validator renders both fixtures; if your hero falls over when `eyebrow` is absent, you don't publish. ## assets/theme.css Your whole look, one file. It layers on top of the platform base stylesheet, which owns mechanics (layout plumbing, form controls, island internals) and exposes [CSS tokens](/reference/tokens/) for everything brandable. Your CSS: - overrides tokens under `:root:root` (doubled specificity so the theme wins), - styles your own section markup (class names are yours, the starter uses an `lq-` prefix), - may restyle islands **only** through their published [styling API](/reference/islands/). ## What an artifact may NOT contain - **No JavaScript.** Behaviour is platform-owned; templates place [islands](/guides/islands/). - **No network calls, no data fetching.** The platform feeds renderers everything they get. - **No unsanitised HTML output.** `| raw` is for the contract's richtext fields only. - **No file inclusion**, `include`/`render`/`layout` are excluded from the dialect and fail at parse time. ━━ guides/capabilities-and-profiles.md ━━ Port60 separates content, reusable capabilities, organisation profiles and visual templates. A template can present a capability, but it cannot enable that capability or grant an entitlement. ## Declare requirements honestly Use `requiresCapabilities` for capabilities the design needs in order to work as intended. The validator requires each declaration to have a corresponding section, page template or island: ```json { "requiresCapabilities": ["worship", "giving", "newsletter"], "suitsProfiles": ["mosque"] } ``` If a decorative region merely looks good when a capability exists, but the template still works without it, do not call that capability required. Optional contexts are always sparse, so branch and omit when their data is absent. `requiresCapabilities` is catalogue metadata. The Appearance picker compares it with the tenant's enabled features, labels missing requirements and prevents a misleading recommendation. It never turns a feature on. ## Profile recommendations `suitsProfiles` accepts `general`, `mosque`, `church`, `appeal-charity`, `pta`, `community` and `training`. An empty list means general purpose. An exact profile match sorts ahead of a general template in the picker, but profile metadata never hides a template and never changes permissions. Profiles describe design intent, not fixed content. A mosque template can use worship and giving surfaces without inventing prayer times or appeals. The organisation supplies the data. ## Capability surfaces | Capability | Template-facing surface | Platform boundary | | --- | --- | --- | | Giving and appeals | `donation_widget`; `appealGrid` and `emergency` with `causes[]` | Payment, Gift Aid and processor data stay in the platform | | Worship | `worship` layout context and `next_prayer` | The platform supplies times and owns the ticking clock | | Events and courses | Listing data or page templates; transaction islands | RSVP, tickets, enrolment and payment remain islands | | Articles | `articles`, `article`, categories and engagement islands | Comments and engagement behaviour remain platform owned | | Services | `programmes` with `services[]` | Templates receive display projections, not service internals | | Forms | `form` island | Field rendering, human checks and submissions remain platform owned | | Resources | `resources` with `resources[]` | Only public documents are exposed | | Locations | `locations` with `locations[]` | Provider references and private notes are excluded | | Newsletter | `newsletter_signup` | Explicit consent, evidence and unsubscribe handling stay in the platform | | Internationalisation | `locale` context and `language_switch` | The platform owns locale switching and document direction | | Search | `search` | Searches public article, service, event and resource projections only | ## Current module depth The first capability release is intentionally narrow at its public boundary. Forms provide text, email, long text, choice and checkbox fields with privacy-recorded submissions. Form responses contain personal data. Organisations should collect only what they need and apply their own retention policy; the initial inbox does not claim automated retention, export or case-management workflows. Those surfaces do not turn a template into an operational CRM. Forms do not provide workflow automation or payment collection. Template authors should not imply functionality beyond the published contract. ## Design for more than one sector Prefer reusable structures over organisation-specific assumptions. `locations` works for branches, community halls and campuses. `programmes` works for services, classes and projects. `resources` works for policies, lesson materials and community guides. Profile-specific character should come from composition, typography and content hierarchy, while the underlying capability surfaces stay consistent. ━━ guides/first-template.md ━━ This walkthrough takes you from an empty directory to a template zip that the Port60 Studio can accept. You do not need the Port60 platform repository or a platform account to build and validate the template locally. ## Prerequisites - Node 20 or newer - npm Templates contain Liquid and CSS only. The kit supplies the preview renderer, contract fixtures and the same conformance checks used when you upload a template. ## 1. Create the template Run the published kit with `npx`: ```bash npx @port60/template-kit@latest create my-template cd my-template npm install ``` The folder name becomes the template's name, and the label derives from it, pass `--name` or `--label` only when the identity should differ from the folder (the name is your immutable catalogue identity, `name@version`, in the store and marketplace). The scaffold pins the kit you scaffolded with as a devDependency, so `npm install` puts you on the current contract tooling. The scaffold includes npm scripts plus `AGENTS.md` and `CLAUDE.md`, so a coding agent receives the same rules as a human contributor. Your new project contains: ```text my-template/ ├── manifest.json ├── layout.liquid ├── sections/ ├── assets/ │ └── theme.css ├── AGENTS.md └── package.json ``` `manifest.json` is the template's identity and capability declaration. Keep `name` lowercase and hyphenated. Change `label` and `description` to the wording a non profit will see in the picker. ## 2. Preview it ```bash npm run dev ``` Open [http://localhost:4400](http://localhost:4400). The preview renders the contract's sample content, including a worship schedule when the template declares `supports.worship`. Functional islands appear as realistic, non-interactive skeletons using their published styling classes. Payments, identity, consent and other live behaviour remain platform owned, and the preview makes no network requests. Files are read again on refresh. Keep the preview open while you edit `layout.liquid`, `sections/*.liquid` and `assets/theme.css`. ## 3. Make the design yours Most of the design lives in two places: - `assets/theme.css` controls colour, typography, spacing, responsive layout and the documented styling classes for platform islands. - `sections/*.liquid` controls the markup for each supported section type. Keep outputs escaped. Use `| raw` only for a contract field documented as sanitised rich text: ```liquid
{% if section.eyebrow %}

{{ section.eyebrow }}

{% endif %}

{{ section.title }}

{% if section.bodyHtml %}
{{ section.bodyHtml | raw }}
{% endif %}
``` Navigation, worship times, donations and sign in are all supplied by the platform. Render their documented data or place their islands. Do not replace them with fixed sample content. ## 4. Validate it ```bash npm run validate:json ``` The command checks the manifest, restricted Liquid dialect, minimal and sample fixtures, island declarations, layout slot and capability claims. A successful result includes the capabilities the validator has proven: ```json { "ok": true, "errors": [], "warnings": [], "provenSupports": { "layout": true, "worship": true } } ``` Treat every error as an upload blocker. Review warnings too, particularly a missing `member_menu`, because member enabled sites would otherwise lose their sign in route. ## 5. Package and upload it ```bash npm run package ``` The kit validates once more and creates `-.zip` with only the allowed artifact files. Upload that zip in your Studio, open the template, choose **Versions**, then submit the validated version for review. Published versions are immutable. Increase the semantic version in `manifest.json` before you package the next release. Existing sites stay on their pinned version until they deliberately apply an update. ## Where to go next - [Anatomy of an artifact](/guides/artifact-anatomy/) - [Sections and data](/guides/sections-and-data/) - [Layouts and chrome](/guides/layouts/) - [Capabilities and profiles](/guides/capabilities-and-profiles/) - [The template kit](/guides/template-kit/) - [Publishing and conformance](/guides/publishing/) ━━ guides/islands.md ━━ Templates are static by design, no scripts, no SDKs, no API calls. Live functionality (taking a donation, buying a ticket, signing in) comes from **islands**: platform-owned, hydrated components your template *places* with a single tag. ## Why islands are opaque, deliberately You cannot re-lay-out an island's internals, and that's a design decision, not a missing feature: 1. **Compliance is platform-owned.** The donation widget isn't display, it's Strong Customer Authentication, the Gift Aid declaration's legal wording, consent capture. Markup a template could rearrange is markup a template could break in ways that have regulatory consequences. 2. **The upgrade promise depends on it.** The platform ships payment, security and accessibility changes underneath published templates with zero author intervention. That's only possible because transactional internals belong to the platform, your template keeps working precisely *because* it never reached inside. The boundary is drawn at **transactional vs display**. Money movement, identity and consent are islands forever. *Display* of platform data, what an event or article looks like in a listing, is yours to own through [page templates](/guides/page-templates/) and their documented data context. If you're fighting an island for layout control, you're usually on the display side of the line and a page template is the right tool. ```liquid
{% island 'donation_widget' %}
``` At render time the platform splits your output at each island marker and mounts the real, interactive component in that position. Payment logic, compliance (Gift Aid, consent), provider SDKs, accessibility, all platform-side, all upgraded platform-side. **Your template keeps working when we upgrade the internals**; that's the deal. The local kit and Studio preview show non-interactive fixture skeletons instead. They expose the same stable styling classes as the live island, so you can assess spacing, type, colour and common content states while the preview remains network dead. They are visual aids, not simulations of a payment, identity or consent flow. ## The rules 1. **Declare what you place.** Every island name used in your renderers must be listed in `manifest.supports.islands`. The validator enforces placed ⊆ declared ⊆ [platform registry](/reference/islands/). 2. **Unknown names render nothing.** An island that isn't in the registry produces no output, never an error page. 3. **Planned islands are placeable-later.** The registry marks each island `available` or `planned`. Declaring a planned island is a validator warning today; it starts rendering the day the platform ships it. Submission islands such as `newsletter_signup` and `form` keep abuse checks, privacy acceptance and consent evidence in the platform. `search` reads public projections only. `language_switch` and `next_prayer` own client behaviour that Liquid cannot provide without JavaScript. Templates position these islands and style only their published classes. ## The primary action widget Most templates should not place `donation_widget` in the hero at all. Place this instead: ```liquid {% island 'primary_action_widget' %} ``` It is the widget that leads the site, and the charity decides what that is from their Pages editor: the **donation widget** when giving leads, the **volunteer sign-up** when volunteering leads, or nothing when they chose no widget in the hero. `site.focus` tells your template which one it will be (`donate`, `volunteer` or `none`), and `section.action` on the home hero is the button that goes with it: always the action whose widget is *not* on the page, so the leading action never appears twice. To take part, declare both the island and what your template can lead with: ```json "islands": ["primary_action_widget"], "focus": ["donate", "volunteer"] ``` The validator holds you to it: a template that declares `volunteer` must place either this island or `volunteer_signup`. See both while you work: in `p60-template-kit dev` the bar at the top has a **Leads with** switch (giving, volunteering, buttons only), or add `?focus=volunteer` or `?focus=none` to any preview URL. The hero widget becomes the donation widget, the volunteer sign-up, or nothing, and `site.focus`, `site.actions`, the header button and `section.action` follow, exactly as they do on a live site. Place `donation_widget` directly only when your template deliberately owns the switch itself by branching on `section.primary.kind`. ## Styling an island You never restyle island internals, those class names are private and change without notice. Each island publishes a **styling API**: the stable class names your `theme.css` may target. The [donation widget](/reference/islands/#donation_widget), for example, exposes `.donate-card`, `.freq-tabs`, `.amount-grid` and friends. ```css /* Safe: published styling API + platform tokens */ .donate-card { border-radius: var(--radius); box-shadow: var(--shadow); } /* NOT safe: anything not in the styling API is internal */ ``` Most of an island's look follows your [token overrides](/reference/tokens/) automatically, islands are built from the same `--primary`/`--accent`/`--surface` palette as everything else, so a well-themed template usually needs little or no island-specific CSS. ## Layout around islands An island renders into the position of its tag, sized by your surrounding markup. Give it a container you control (like the starter's `.lq-cta-widget`) and do your layout there, grid placement, max-width, spacing. Treat the island itself as an opaque box. ## Islands are batteries, not cages Three home sections, `events`, `whatsOn` and `articles`, used to be island-only. They now carry **data contexts** (`events`, `infoEvents`, `latestArticles`, see [sections & data](/guides/sections-and-data/)), so you choose per section: - **Render the data yourself**, your markup, your voice. A newspaper template might set events as a ruled diary column; a photography-led one as full-bleed cards. Honour derive-or-omit: wrap the section in `{% if events.size > 0 %}` so an empty list renders nothing (the validator proves both directions). - **Or place the island**, `{% island 'events_carousel' %}`, `{% island 'whats_on_strip' %}`, `{% island 'latest_articles' %}` remain the zero-effort defaults and handle empty states for you. The payment boundary is absolute either way: these are listing models. Ticket sales, RSVP and enrolment happen on platform-rendered detail views, link with `detailHref` / `href`, never rebuild those flows. ━━ guides/layout.md ━━ Your template supplies the chrome (header, footer, sections). The platform supplies **pages**, donate, services, events, campaigns, the members area, and they render *inside your template*. For those pages to line up with your own sections, there is exactly one rule. ## The seam: `.container` and `.full` Platform pages render through two layout intents, and your template **styles them**: - **`.container`**, the contained content column (text, forms, cards). This is where the vast majority of content lives. - **`.full`**, edge-to-edge, regardless of the container it sits in (a hero image, a colour band). You control how they look: - Set the **width** with the `--container` token (see [CSS tokens](/reference/tokens/)). - Add your **gutter** and any treatment by styling `.container` (and `.full`) in your `theme.css`. Because your sections and the platform's pages both flow through `.container`; they line up automatically, in every template, on every page, with no per-page work. ```css :root:root { --container: min(1200px, 92vw); /* your content width */ } .container { padding-inline: 1.5rem; /* your gutter */ } ``` Use `.container` in your own layout and sections too, the header inner, the footer inner, each section's content wrapper. One container, everywhere. ## Never build a parallel content container The one thing that breaks alignment is defining your **own** content container, a class sized off `var(--container)`: ```css /* ✗ Rejected at publish. Platform pages stay on .container, so this drifts from them. */ .mytheme-container { max-width: var(--container); margin: 0 auto; padding: 0 1.5rem; } ``` Platform pages never see `.mytheme-container`, so your sections and our pages end up in two different columns with two different gutters. **The publish validator rejects any selector other than `.container`/`.full` that sizes its width off `var(--container)`**, so this is caught the moment you upload, before review, with a message telling you to style `.container` instead. If you need a full-width bar with a centred inner (an announcement rail, a stat strip), compose the seam instead of copying it, put `.container` on the inner element and keep your own class only for the extra styling: ```html
``` ```css /* ✓ .container gives the width + gutter; your class only adds the flourish. */ .mytheme-rail { background: var(--band-deep); } .mytheme-rail-inner { display: flex; justify-content: center; gap: 1.2rem; } ``` That is the whole contract: **customise the seam, never replace it.** Fill it and every platform page falls into your layout, fluidly. ━━ guides/layouts.md ━━ 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 | Yours (layout.liquid) | The platform's (always) | | --- | --- | | Header markup & brand lockup | ``, 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 ```liquid
{% content %}

{{ brand.name }}{% if brand.tagline %}, {{ brand.tagline }}{% endif %}

``` Three rules the validator enforces: 1. **Exactly one `{% content %}`**, the slot the page's rendered sections are injected into. 2. `{% content %}` is **layout-only**, a section renderer using it fails conformance. 3. Islands placed in the layout follow the same discipline as everywhere: declared in `supports.islands`, present in the registry. ## 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 curated `nav.items`. Reach for it only if you want the auto, feature-aware nav somewhere regardless of their header curation; most templates just render `nav.items`. See [Render context](/reference/context/) for the fixture your layout must survive. ## 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: - **`group` is 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. - **`children` is 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 `group` string 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: ```liquid ``` 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](/guides/motion-and-behaviour/#navigation-your-menu-made-robust). 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 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 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: ```liquid {% if worship %} {% 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 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 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 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. ## Fonts Declare webfont stylesheets in the manifest, the platform loads them in `` with proper preconnects (never a render-blocking CSS `@import`): ```json { "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 The [truthfulness rule](/guides/sections-and-data/#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. ━━ guides/motion-and-behaviour.md ━━ Templates never ship JavaScript. That rule is enforced by the validator: a `