Theming
A template’s look lives in one file: assets/theme.css. It layers over the platform base
stylesheet, which owns the mechanics, layout plumbing, form controls, island internals, and
declares a contract of CSS custom properties for everything brandable.
Your stylesheet is applied after the platform base, on the live site and in every preview, so
when a rule of yours and a platform rule have equal specificity, yours wins. Token overrides still
use the doubled :root:root selector below, because the base declares them on :root and you
want to win by specificity as well as by order.
Layer 1: override the tokens
Section titled “Layer 1: override the tokens”The fastest way to a distinct look is re-pointing the palette. Use doubled :root:root
specificity so your values win over the base:
:root:root { --bg: #0e1614; --surface: #16231f; --ink: #eef6f2; --muted: #93a8a0; --primary: #2dd4a7; --accent: #f2b23e; --radius: 10px;}Because islands and platform chrome consume the same tokens, this restyles everything, the donation widget’s tabs, buttons and cards follow your palette with zero island-specific CSS.
Tokens are additive-only within a contract major: new ones can appear (with working defaults), existing ones are never removed or repurposed. Styles written against them don’t rot.
Layer 2: style your own markup
Section titled “Layer 2: style your own markup”Your section renderers emit your own class names, namespace them (the starter uses lq-) so they
can never collide with platform classes. Typography, spacing, section composition: all yours.
.lq-kicker { display: inline-block; border: 1px solid var(--line-strong); color: var(--primary); letter-spacing: 0.14em; text-transform: uppercase;}The content container is shared, never namespace it
Section titled “The content container is shared, never namespace it”There is one deliberate exception to “namespace everything”: the content column. Platform pages
(donate, services, events, campaigns, the members area) render inside .container, the shared content
seam, with .full for edge-to-edge. Your own layout and sections must use the same .container,
so your chrome and every platform page sit in one column. Set its width with the --container token and
its gutter by styling .container:
:root:root { --container: min(1200px, 92vw); } /* your content width */.container { padding-inline: 1.5rem; } /* your gutter */Never build your own content container. A namespaced class sized off --container, e.g.
.mytheme-container { max-width: var(--container) }, is a parallel container: platform pages stay on
.container and drift from your sections. The publish validator rejects it before review, naming the
offending selector and telling you to style .container instead. For a full-width bar with a centred
inner, compose the seam (put .container on the inner element, keep your class only for the flourish).
Full detail: Layout & the content seam.
Layer 3 (optional): island styling APIs
Section titled “Layer 3 (optional): island styling APIs”Where the token layer isn’t enough, each island publishes stable class names you may target, see Placing islands. Anything not in an island’s published API is internal; don’t reach for it.
Template settings: your own knobs
Section titled “Template settings: your own knobs”Your manifest may declare settings, theming knobs the charity edits in their admin (Appearance) and the get-started flow:
{ "settings": { "schema": [ { "key": "scheme", "kind": "select", "label": "Colour scheme", "options": ["Midnight", "Daylight"], "default": "Midnight" }, { "key": "accent", "kind": "color", "label": "Accent colour", "default": "#f2b23e" }, { "key": "roundedCorners", "kind": "toggle", "label": "Rounded corners", "default": true } ] }}Three kinds: color, select, toggle. The charity edits them in Appearance; their choices are
stamped onto the public page for your CSS to key off:
-
select/toggle→ adata-p60s-{key}="{value}"attribute on<body>:body[data-p60s-scheme="Ink"] {--bg: #14110d;--ink: #f3ede2;} -
color→ a--p60s-{key}CSS variable on:root:.ed-btn-ink { background: var(--p60s-accent, var(--accent)); }
Always style the default scheme unconditionally and treat the attributes as overrides, a tenant who never opens the options must get your canonical look. Keep knobs few and opinionated: charities want “Paper or Ink”, not thirty sliders.
Fonts arrive two ways, both loaded by the platform in <head> from Bunny Fonts, the privacy-first
mirror of the Google catalogue. Never @import a stylesheet from theme.css: the file is inlined
into the page and the preview is network-dead, so an import fails silently.
- Font knobs. Declare a
fontknob inmanifest.settings.schemawith the default family and the weights your type system uses. The charity picks a family from the platform catalogue; the platform loads one stylesheet at your weights and sets--p60s-<key>to the family plus an honest fallback stack. Use that variable wherever you setfont-family. manifest.fonts. Google Fonts css2 URLs for families you fix rather than offer as knobs. The platform rewrites them to the mirror and loads them with preconnects.
The local dev preview loads both from the mirror, so type is judged for real; the studio review
render stays network-dead and shows the fallback stacks.
Hero photographs & image treatment
Section titled “Hero photographs & image treatment”Charities upload their own hero photographs (homeHero.images, phone photos, mixed lighting,
any colour cast). Never place them raw. A raw photo fights your palette; the professional
move is a treatment layer built from your own variables, so every upload is toned into the
template, and changing scheme re-tones every photo for free:
/* A scrim made of the palette itself, not a hard-coded black wash. */.my-hero--photo::before { content: ''; position: absolute; inset: 0; background: linear-gradient( 180deg, color-mix(in srgb, var(--bg) 65%, transparent) 0%, color-mix(in srgb, var(--bg) 35%, transparent) 50%, color-mix(in srgb, var(--bg-tint) 70%, transparent) 100% );}Other tools in the same spirit: background-blend-mode: multiply | soft-light against a palette
colour (the duotone/tint look), and gentle filter: saturate(.85) contrast(.95) to stop uploads
shouting. The starter’s .lq-homehero is the reference implementation.
The rules, enforced at validation when you declare supports.heroImagery:
- One photo must render directly (
section.images.first.imageUrl), as a treated backdrop, a split layout, whatever your design language says. - Two or more: place the platform’s
hero_carouselisland; it owns the motion (auto-advance, swipe, reduced-motion) and every slide carries an empty.hero-slide-scrimelement for your treatment; style it exactly like your single-photo scrim. - No photos means your designed no-photo state, a gradient, a colour field, typography. Never a placeholder image (and artifacts can’t ship binaries anyway).
- Don’t declare
supports.heroImageryif your hero ignores the photos, the validator checks behaviourally, and the choosers warn charities that their photos won’t show.
Photographs also carry the charity’s framing choice (homeHero.photoFraming), honour both:
fill (default) covers your designed hero shape, with per-photo focus picking the surviving
slice; whole is the vista, size the hero toward the photo’s own aspect (an in-flow
<img> with width: 100%; height: auto; max-height: ~74vh is the trick; no stored dimensions
needed) so a wide landscape stays entirely visible, and stack or overlay your copy accordingly.
Declare your hero’s ideal in the manifest, imagery.hero: { idealAspect, minWidth, note }, and the charity’s editor measures their actual upload against it, advising in your words
(“much wider than this template’s ideal, show the whole photo keeps the full scene”). Entity
images (events, courses, articles) are platform-scoped instead: they render on several surfaces
at once, so their envelopes live in the contract’s imagery.json, not per-template.

