#Build Your Own Theme
A theme in StoryLark is the branding layer: 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). A theme
lives entirely in one brand folder:
brands/<your-id>/
brand.json identity, origins, nouns, layout, TTS, fonts, VAPID public key
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
The build mode selects the folder: vite build --mode <your-id> bakes that
brand's brand.json, theme.css, manifest, and icons into app/dist.
#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. But
the font files are imported in code, in app/src/styles/fonts.ts, which
ships the base brand's Newsreader + Inter via @fontsource/*:
import '@fontsource/newsreader/400.css';
// ...
import '@fontsource/inter/400.css';
To ship different typefaces: install the @fontsource/* package(s) (or add
self-hosted @font-face CSS), swap the imports in fonts.ts, and update the
family names in both brand.json fonts and the --font-* tokens in
theme.css. Keep fonts self-hosted so the offline app shell works without a
network. (Today fonts.ts is shared across brands; per-brand font bundling is a
manual edit, not yet config-driven.)
#brand.json — fields
| Field | Type | Purpose |
|---|---|---|
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. |
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 R2 content is served (contentUrl() builds asset URLs from this). |
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. |
layout |
"flat" | "series" |
Library shape — see the presentation guide. |
nouns |
object | UI content nouns — see below and the presentation guide. |
tts |
object | { voice, rate, outputFormat } for Azure Speech at publish time. voice is an Azure neural voice id; outputFormat an Azure output-format enum. |
vapidPublicKey |
string | Web-push VAPID public key (base64url). Empty disables the push toggle. Generate with tools/gen-vapid.mjs. |
fonts |
object | { display, headers, body, mono } family names referenced by theme.css. |
The nouns object (every user-visible content word comes from here — no
hardcoded "story"/"book"):
"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. Details in
build-your-own-presentation.md.
#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).
app/vite.config.ts 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 tools/gen-icons.mjs --brand <your-id>
#Publishing themes (naming convention — planned)
Today a theme is a folder you copy and edit in-repo. The direction is to make themes installable packages so a brand can consume one without vendoring it:
- Official themes:
@storylark/theme-*(e.g.@storylark/theme-daybreak). - Community themes:
storylark-theme-*.
This packaging/distribution model is planned, not available today — for now,
copy brands/storylark/ and retune it.
Found a gap? StoryLark is open source — improve these docs on GitHub.