Skip to content

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.

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.

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.

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.

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 → a data-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 font knob in manifest.settings.schema with 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 set font-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.

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_carousel island; it owns the motion (auto-advance, swipe, reduced-motion) and every slide carries an empty .hero-slide-scrim element 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.heroImagery if 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.