Theming
@cynco/theming is runtime theming for the ledger components: a
framework-agnostic theme controller (light / dark / system with OS-preference
tracking), pluggable persistence, DOM application helpers, and a React hook. It
pairs with the role sets and CSS variable convention from @cynco/theme that
@cynco/journals and @cynco/accounts consume.
mode system → resolved light → theme light. The override switch sets --journals-accent-override on an ancestor — the top of the override → theme role → built-in default chain, so it beats every theme the controller applies.
Installation
Install with the package manager of your choice. React is an optional peer
dependency required only by the /react entry.
pnpm add @cynco/theming| Entry point | What it exports |
|---|---|
@cynco/theming | createThemeController, createThemeCatalog + defaultCatalog, applyThemeToElement / connectThemeController, and the types (ThemeController, ThemeCatalog, ThemePersistence, …) |
@cynco/theming/react | The useThemeController hook |
Vanilla API
The controller is the store: it owns the mode, the per-scheme theme choice,
persistence, and the prefers-color-scheme subscription (attached only while
the mode is system). connectThemeController binds it to an element — apply
now, re-apply on every change.
import {
connectThemeController,
createThemeController,
defaultCatalog,
} from '@cynco/theming';
const controller = createThemeController({
catalog: defaultCatalog,
storageKey: 'my-app-theme', // persists the selection in localStorage
});
// Applies --journals-theme-* / --accounts-theme-* variables plus a
// color-scheme pin to the element, now and on every change.
const disconnect = connectThemeController(
controller,
document.getElementById('app')!
);
controller.setMode('dark'); // 'light' | 'dark' | 'system'
controller.setTheme('darkSoft'); // assigns to the slot the theme belongs to
// The published state — frozen and reference-stable until the next change:
const { mode, resolvedScheme, themeName, roles } = controller.getSnapshot();
controller.subscribe(() => rerenderPicker(controller.getSnapshot()));
controller.destroy(); // detaches the OS listener, drops subscriberssetTheme records the choice for that theme’s scheme slot — a light theme name
and a dark theme name are kept independently — so switching modes, or an OS flip
while in system mode, always lands on the right theme. Unknown names are a
documented no-op: pickers often feed persisted or user strings, and crashing the
host over a stale name would be worse than keeping the current theme.
import { applyThemeToElement } from '@cynco/theming';
applyThemeToElement(element, controller.getSnapshot(), {
prefixes: ['journals', 'accounts'], // the default
});- Sets
--<prefix>-theme-<group>-<token>inline custom properties for each prefix (via@cynco/theme’sthemeToCSSVariables) and pins the element’s inlinecolor-schemeto the resolved scheme — the pin lives in the outer tree, so it beats the components’ shadow:hostrule. - Tracks the properties it applied per element and removes stale ones on the next apply, so switching themes never leaves old variables behind.
connectThemeController’s disconnect function only unsubscribes — the applied variables are deliberately left in place, because stripping them would flash unthemed UI on teardown.
React API
useThemeController is a pure useSyncExternalStore wrapper with no state of
its own — mode, persistence, and system tracking all live in the controller, so
vanilla hosts share the exact same behavior. The snapshot is frozen and
reference-stable between changes, which is precisely the contract
useSyncExternalStore needs.
import { createThemeController, defaultCatalog } from '@cynco/theming';
import { useThemeController } from '@cynco/theming/react';
const controller = createThemeController({ catalog: defaultCatalog });
function ThemePicker() {
const { mode, themeName, resolvedScheme, catalog } =
useThemeController(controller);
return (
<select
value={themeName}
onChange={(event) => controller.setTheme(event.target.value)}
>
{catalog.list().map((entry) => (
<option key={entry.name} value={entry.name}>
{entry.label}
</option>
))}
</select>
);
}Persistence
Pass storageKey for the built-in adapter: one localStorage JSON entry holding
only the selection names. Corrupt JSON, unknown theme names, or unavailable
storage all degrade to the catalog defaults — nothing throws at runtime.
Persistence is loaded exactly once, at creation; loaded values win over the
initialMode / initialTheme options, and every field degrades independently.
// The persisted shape is names only — { mode, light, dark } — never
// resolved role objects, so stored data can't go stale against a newer
// catalog. A custom ThemePersistence adapter takes precedence over
// storageKey:
const controller = createThemeController({
catalog: defaultCatalog,
persistence: {
load: () => readSelectionFromCookie(), // ThemeSelection | null
save: (selection) => writeSelectionToCookie(selection),
},
});Theme catalogs
Every theme is a Roles object from @cynco/theme. These are the light and
dark sets — the swatches below are read from the exported role data at build
time, not copied:
- editor
#ffffff#0a0a0a - window
#f5f5f5#171717 - inset
#ededed#1d1d1d - elevated
#f7f7f7#101010
- base
#0a0a0a#fafafa - fg1
#404040#d4d4d4 - fg2
#525252#a3a3a3 - fg3
#737373#737373 - fg4
#8a8a8a#636363
- window
#e5e5e5#0a0a0a - editor
#d4d4d4#1d1d1d - indentGuide
#e5e5e5#1d1d1d - indentGuideActive
#d4d4d4#262626 - inset
#d4d4d4#1d1d1d - elevated
#e5e5e5#1d1d1d
- primary
#009fff#009fff - link
#009fff#009fff - subtle
#dfebff#19283c - contrastOnAccent
#ffffff#0a0a0a
- match
#693acf#7b43f8 - success
#18a46c#07c480 - danger
#d52c36#ff2e3f - warn
#d5a910#ffca00 - info
#1ca1c7#08c0ef
- debit
#199f43#5ecc71 - credit
#d52c36#ff6762 - balance
#404040#d4d4d4 - balanceNegative
#d52c36#ff6762 - date
#737373#a3a3a3 - payee
#0a0a0a#fafafa - narration
#737373#a3a3a3 - account
#1a85d4#69b1ff - currency
#8a8a8a#737373 - tag
#1ca1c7#68cdf2 - link
#693acf#9d6afb - reconciled
#18a46c#60d199 - pending
#d5a910#ffd452 - flagged
#d32a61#ff678d - void
#a3a3a3#636363
defaultCatalog wraps every role set @cynco/theme ships — light / dark,
the contrast-compressed lightSoft / darkSoft, and the accessible lightCvd
/ darkCvd / lightTritan / darkTritan variants — with entry names matching
the @cynco/theme export names. Build your own with createThemeCatalog:
import { createThemeCatalog } from '@cynco/theming';
import { dark, light } from '@cynco/theme';
const catalog = createThemeCatalog(
[
{ name: 'day', label: 'Day', scheme: 'light', roles: light },
{ name: 'night', label: 'Night', scheme: 'dark', roles: dark },
],
{ light: 'day', dark: 'night' }
);
catalog.get('day'); // ThemeCatalogEntry | null (graceful for input)
catalog.list(); // readonly ThemeCatalogEntry[]
catalog.defaultFor('dark'); // always resolves — validated at creationCatalog construction is the one deliberate exception to graceful degradation: duplicate names, unknown default names, or a default pointing at the wrong scheme are programmer errors and throw with a clear message at creation time.
CVD-safe themes
light ΔE₀₀ 4.8, contrast 3.66 · dark ΔE₀₀ 4.5, contrast 8.00
light ΔE₀₀ 51.2, contrast 4.31 · dark ΔE₀₀ 54.5, contrast 8.23
Both cards render the shipped role sets — no simulation filter on the pixels. The figures under each card are computed here, at render time, by the package's exported simulateCvd + deltaE2000 + contrastRatio — the same math the permanent test gate asserts. ΔE₀₀ ≈ 2–3 is just noticeable; the gate requires ≥ 20 and simulated contrast ≥ 3.0.
The base themes map debit to green and credit to red — mirror diff semantics —
and that is exactly the axis protanopia and deuteranopia collapse (together the
large majority of color vision deficiency, roughly 8% of men). Measured with the
Machado et al. (2009) severity-1.0 simulation and CIEDE2000: light debit vs
credit is ΔE₀₀ 72.8 normally → 4.8 under deuteranopia; dark is 69.1 →
4.5. ΔE₀₀ ≈ 2–3 is “just noticeable” — a deuteranope reading the base themes
cannot reliably tell a debit from a credit.
The four accessible sets fix that on the axes each deficiency preserves — blue ↔
orange for lightCvd / darkCvd, teal ↔ vermillion for the tritan sets — while
chrome (bg, fg, border, accent) stays identical to the base themes, so
the variants still look like Cynco. A permanent test gate simulates every gated
color at full dichromacy and asserts on the simulated colors: ΔE₀₀(debit,
credit) ≥ 20 (measured 47.4–54.5 for the CVD sets, 59.6–63.8 for the tritan
sets) and simulated debit/credit contrast on the simulated background ≥ 3.0
(WCAG SC 1.4.11, measured 4.16–10.91). See
ACCESSIBILITY.md
for the full numbers.
// defaultCatalog already includes the accessible sets; assigning one is a
// single setTheme call — no custom catalog required:
controller.setTheme('lightCvd'); // protanopia/deuteranopia-safe light slot
controller.setTheme('darkTritan'); // tritanopia-safe dark slotThe science behind the gate is exported from @cynco/theme as pure,
dependency-free helpers:
import { contrastRatio, deltaE2000, parseHex, simulateCvd } from '@cynco/theme';
const debit = parseHex('#199f43')!; // Rgb | null on malformed input
const credit = parseHex('#d5393e')!;
deltaE2000(debit, credit); // CIEDE2000 — ~2-3 is "just noticeable"
deltaE2000(
simulateCvd(debit, 'deuteranopia'), // Machado et al. (2009), severity 1.0
simulateCvd(credit, 'deuteranopia')
);
contrastRatio(debit, parseHex('#ffffff')!); // WCAG 2.x ratioSSR
The controller is SSR-safe: no window, matchMedia, or localStorage access
happens at module scope, and every browser access is guarded. On the server (or
any headless runtime) system mode resolves to light and persistence no-ops;
the client re-resolves against the real OS preference and stored selection on
hydration.
// Server: the controller resolves headlessly ('system' → 'light', no
// persistence), and useThemeController's server snapshot is the same
// getSnapshot. For server-rendered inline styles, serialize the roles:
import { themeToCSSVariables } from '@cynco/theme';
const snapshot = controller.getSnapshot();
const style = themeToCSSVariables('journals', snapshot.roles);
// { '--journals-theme-bg-editor': '#ffffff', ... }API reference
The full export surface of @cynco/theming, plus the @cynco/theme package it
pairs with (role sets, palettes, and the color science behind the CVD gate).
Types are transcribed from the source.
createThemeController options
createThemeController(options) takes a ThemeControllerOptions and returns a
ThemeController:
| Option | Type | Default | Description |
|---|---|---|---|
catalog | ThemeCatalog | required | The validated set of selectable themes. |
initialMode | 'light' | 'dark' | 'system' | 'system' | Starting mode when nothing is persisted. |
initialTheme | { light?: string; dark?: string } | catalog defaults | Starting theme name per scheme slot; unknown names fall back instead of throwing. |
persistence | ThemePersistence | — | Custom load / save adapter; takes precedence over storageKey. |
storageKey | string | — | Enables the built-in localStorage adapter (one JSON entry of selection names). |
applyThemeToElement options
applyThemeToElement(element, snapshot, options?) and
connectThemeController(controller, element, options?) share
ApplyThemeOptions:
| Option | Type | Default | Description |
|---|---|---|---|
prefixes | readonly string[] | ['journals', 'accounts'] | Component variable namespaces to write (--<prefix>-theme-*). |
Utilities
| Export | Purpose |
|---|---|
createThemeController(options) | The store: mode, per-scheme theme choice, persistence, and the OS-preference subscription. |
createThemeCatalog(entries, defaults) | Builds a validated, frozen ThemeCatalog; construction errors throw, lookups degrade. |
defaultCatalog | Every role set @cynco/theme ships, entry names matching the export names. |
applyThemeToElement(element, snapshot, options?) | Writes theme variables + a color-scheme pin inline; removes stale properties on the next apply. |
connectThemeController(controller, element, options?) | Apply now and on every change; the returned disconnect only unsubscribes. |
Types
ColorMode('light' | 'dark' | 'system') — the user-facing mode choice.ResolvedColorScheme('light' | 'dark') — a mode collapsed against the OS preference; always concrete.ThemeSelection— the persisted names:{ mode, light, dark }.ThemePersistence— the pluggableload/saveadapter contract.ThemeCatalogEntry—{ name, label, scheme, roles }.ThemeCatalog—get(null for unknown names),list, anddefaultFor(always resolves; validated at creation).ThemeControllerOptions/ThemeControllerSnapshot— the options bag above and the frozen, reference-stable published state.ThemeController— the controller surface:getSnapshot,setMode,setTheme,subscribe,destroy.
From @cynco/theme
The paired package's full surface — install it directly when you only need role data or the color science:
| Export | Purpose |
|---|---|
light / dark | The base role sets (Roles): bg, fg, border, accent, states, and ledger colors. |
lightSoft / darkSoft | Contrast-compressed variants. |
lightCvd / darkCvd | Protanopia/deuteranopia-safe variants (blue ↔ orange debit/credit axis). |
lightTritan / darkTritan | Tritanopia-safe variants (teal ↔ vermillion axis). |
Roles | The role-set shape every theme conforms to. |
palettes | The raw chromatic scales the role sets draw from. |
themeToCSSVariables(prefix, roles) | Serializes a role set to --<prefix>-theme-<group>-<token> custom properties. |
parseHex(hex) | #rgb / #rrggbb to Rgb ({ r, g, b }); null on malformed input. |
contrastRatio(a, b) | WCAG 2.x contrast ratio between two Rgb colors. |
relativeLuminance(rgb) | WCAG relative luminance of an Rgb color. |
deltaE2000(a, b) / deltaE2000Lab(lab1, lab2) | CIEDE2000 color difference, from Rgb or from Lab ({ l, a, b }) directly. |
simulateCvd(rgb, kind) | Machado et al. (2009) severity-1.0 dichromacy simulation; CvdKind is 'protanopia' | 'deuteranopia' | 'tritanopia'. |
srgbToLinear(channel) / linearToSrgb(linear) | The sRGB transfer function and its inverse, per channel. |