StoryLark
← All guides

Build Your Own Theme

A theme in StoryLark is the branding layer: identity, colors, fonts, and icons. It does not change the app's structure or UX — that's the presentation layer (see build-your-own-presentation.md), and it does not carry addresses or keys — that's deployment config, set once at install and never shared. Before building from scratch, browse the ready-made themes in the theme & template gallery — each is an installable theme package (below) you can import as-is or retune.

Three concerns, three files, each with its own owner:

brands/<your-id>/
  brand.json     identity: name, appName, tagline, colors, fonts
  theme.css      the CSS custom-property (token) contract — light + dark
  assets/icons/  PWA icons (and favicons/logo)
  assets/covers/ (optional) per-book cover art shipped with the brand

presentation/<your-id>/
  presentation.json   layout, nouns, nav, and every other shape/UX choice
                       — see build-your-own-presentation.md

deployment/<your-id>/
  deployment.json      appOrigin, contentOrigin, vapidPublicKey, tts — set
                        once at install, per install, never portable

This split matters for the same reason theme packages are portable: brand.json carries only what's genuinely identity — nothing that differs by where a brand is deployed, and nothing that would leak one install's origins or keys into a theme someone else imports. Both platforms read all three at runtime, from files the deployment serves — not baked into the build — so changing your brand, presentation, or theme takes effect immediately, with no rebuild.

theme.css — the token contract

theme.css defines CSS custom properties on :root (light, the default) and mirrors every token under :root[data-theme="dark"]. The app switches themes by stamping data-theme on the root element (Settings → theme: light / dark / auto). Retune these values; keep the names.

Token Role
color-scheme light or dark — makes native controls (selects, checkboxes, range sliders, scrollbars) match the theme instead of flashing the OS default. Set per block.
--bg Page background ("paper"). Should match themeColor/backgroundColor in brand.json.
--bg-raised Raised surfaces — cards, sheets, the surface above the page.
--bg-sunken Recessed surfaces — wells, inset areas.
--text Primary body text ("ink").
--text-muted Secondary text — labels, metadata.
--text-faint Tertiary text — faint captions, disabled hints.
--accent Primary interactive color — buttons, active states, links.
--accent-strong A stronger/darker accent for hovers/pressed states and emphasis.
--rule Hairline dividers and card borders (list/card chrome).
--link Link color (often equal to --accent).
--font-display Display / hero type (e.g. big titles).
--font-headers Headings.
--font-body Body / reading type.
--font-mono Monospace.
--highlight-word Read-along word highlight fill (the "sung" word). Deliberately a warm, distinct color so it reads against the interactive accent.
--highlight-block Read-along block/paragraph highlight fill (paragraph-level read-along and the active-block wash).

Minimum shape:

:root {
  color-scheme: light;
  --bg: #FBF8F2;
  --bg-raised: #FFFFFF;
  --bg-sunken: #F1EADD;
  --text: #232020;
  --text-muted: #635C54;
  --text-faint: #A69D8F;
  --accent: #0E7C7B;
  --accent-strong: #0A5F5E;
  --rule: #E6DFD2;
  --link: #0E7C7B;
  --font-display: "Newsreader", Georgia, serif;
  --font-headers: "Newsreader", Georgia, serif;
  --font-body:    "Newsreader", Georgia, serif;
  --font-mono:    "Inter", system-ui, sans-serif;
  --highlight-word:  rgba(224, 164, 35, 0.22);
  --highlight-block: rgba(224, 164, 35, 0.5);
}

:root[data-theme="dark"] {
  color-scheme: dark;
  /* mirror EVERY token above with dark values */
}

Fonts

brand.json names the font families in fonts (display, headers, body, mono), and theme.css references those families in the --font-* tokens. The font files are bundled automatically: at build time the storylark-core Vite preset turns each family name into the matching @fontsource/* imports (e.g. Newsreader@fontsource/newsreader), with sensible weights per role (body text gets italics; mono doesn't). Families bundled with core today: Newsreader, Inter, Lora, Cinzel, Cormorant Garamond, IBM Plex Mono.

To ship a typeface outside that set, add your own self-hosted @font-face CSS to theme.css and reference the family in the --font-* tokens — unknown family names simply produce no @fontsource import. Keep fonts self-hosted so the offline app shell works without a network.

brand.json — fields

Identity only. Every field here is safe to share in a theme package — none of it differs by install.

Field Type Purpose
contractVersion integer Schema version. Core validates against it; a missing key takes the core default, an unknown key is ignored with a warning, and only a wrong major version hard-fails.
id string Brand id. Must equal the folder name and the build --mode.
name string Library name (shown as the manifest/app subtitle, e.g. "StoryLark: Story Library").
appName string App name — document <title> and manifest name.
shortName string PWA short_name (home-screen label); falls back to appName.
tagline string Manifest description / marketing line.
themeColor string (hex) Manifest theme_color — match --bg.
backgroundColor string (hex) Manifest background_color (splash) — match --bg.
defaultTheme "light" | "dark" Initial theme before the user overrides it in Settings.
author string Author/publisher label.
fonts object { display, headers, body, mono } family names referenced by theme.css.

presentation.json — shape and UX

Lives in presentation/<your-id>/presentation.json, not the brand folder — it's swappable independently of identity. Full field reference, including layout and the nouns object below, is in build-your-own-presentation.md.

"nouns": {
  "unit": "story", "unitPlural": "stories",
  "Unit": "Story", "UnitPlural": "Stories",
  "collection": null, "Collection": null
}

unit/Unit are singular (lower/capitalized), *Plural the plurals; collection/Collection name the grouping level (e.g. "book", "series") or are null for a flat library.

deployment.json — per-install config, never portable

Lives in deployment/<your-id>/deployment.json and is set once at install time — it's deliberately excluded from theme packages, because shipping one install's origins or keys to everyone who imports your theme would be a real, structural mistake, not a preference. npm run migrate-brand splits an older, unsplit brand.json into all three files automatically.

Field Type Purpose
contractVersion integer Same rules as brand.json's.
appOrigin string (URL) Where the app is served. Also the base for admin publish notify, and (with the app. label dropped) the marketing origin used to resolve root-relative image srcs.
contentOrigin string (URL) Where content is served (contentUrl() builds asset URLs from this).
vapidPublicKey string Web-push VAPID public key (base64url). Empty disables the push toggle. Generate with packages/pipeline/gen-vapid.mjs.
tts object { voice, rate, outputFormat } for Azure Speech at publish time. voice is an Azure neural voice id; outputFormat an Azure output-format enum.

Swapping icons

Replace the three PNGs in brands/<your-id>/assets/icons/ (icon-192.png, icon-512.png, icon-maskable-512.png — the manifest references exactly these). The storylark-core build preset copies the whole assets/icons/ folder to dist/icons/ at build time, so any additional files (favicons, logo) are shipped too. To generate neutral placeholders in your accent color:

node packages/pipeline/gen-icons.mjs --brand <your-id>

Theme packages — installable, no repo clone required

A theme travels as a single file: <id>.storylark-theme.zip, containing package.json (id, name, version, contractVersion), brand.json, theme.css, icons/, and optionally presentation.json. Building one from a brand folder in this repo:

npm run package-theme -- <your-id>

This validates before it packages — a missing icon size, a theme.css missing a required token, or a dark-first theme with no distinct light alternate is caught here, not after upload.

Two ways to install a package on a live deployment, same underlying route:

Either way, the deployment keeps five versions with one-click rollback, and importing a bad package is a pure no-op — validation runs before anything is written, so a rejected upload never leaves the live site half-changed.

Built something worth sharing? The gallery indexes community themes — see the submission guide to get yours listed, or just hand someone your .storylark-theme.zip directly — it's a portable file, not a repo dependency.


Found a gap? StoryLark is open source — improve these docs on GitHub.