#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:
- Admin portal → Brand & Themes — upload the
.zipin the browser. No clone, no tool, no terminal. - CLI —
npm run import-theme -- --url <your-site> --key <ADMIN_KEY> themes/<id>.storylark-theme.zip, for scripting or CI.
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.