Skip to contentAccountingby Cynco

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 pointWhat it exports
@cynco/themingcreateThemeController, createThemeCatalog + defaultCatalog, applyThemeToElement / connectThemeController, and the types (ThemeController, ThemeCatalog, ThemePersistence, …)
@cynco/theming/reactThe 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 subscribers

setTheme 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’s themeToCSSVariables) and pins the element’s inline color-scheme to the resolved scheme — the pin lives in the outer tree, so it beats the components’ shadow :host rule.
  • 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:

bglight · dark
  • editor#ffffff#0a0a0a
  • window#f5f5f5#171717
  • inset#ededed#1d1d1d
  • elevated#f7f7f7#101010
fglight · dark
  • base#0a0a0a#fafafa
  • fg1#404040#d4d4d4
  • fg2#525252#a3a3a3
  • fg3#737373#737373
  • fg4#8a8a8a#636363
borderlight · dark
  • window#e5e5e5#0a0a0a
  • editor#d4d4d4#1d1d1d
  • indentGuide#e5e5e5#1d1d1d
  • indentGuideActive#d4d4d4#262626
  • inset#d4d4d4#1d1d1d
  • elevated#e5e5e5#1d1d1d
accentlight · dark
  • primary#009fff#009fff
  • link#009fff#009fff
  • subtle#dfebff#19283c
  • contrastOnAccent#ffffff#0a0a0a
stateslight · dark
  • match#693acf#7b43f8
  • success#18a46c#07c480
  • danger#d52c36#ff2e3f
  • warn#d5a910#ffca00
  • info#1ca1c7#08c0ef
ledgerlight · dark
  • 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 creation

Catalog 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

Base rolessimulated deuteranopia

light ΔE₀₀ 4.8, contrast 3.66 · dark ΔE₀₀ 4.5, contrast 8.00

Protan/deutan-safesimulated deuteranopia

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 slot

The 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 ratio

SSR

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:

OptionTypeDefaultDescription
catalogThemeCatalogrequiredThe validated set of selectable themes.
initialMode'light' | 'dark' | 'system''system'Starting mode when nothing is persisted.
initialTheme{ light?: string; dark?: string }catalog defaultsStarting theme name per scheme slot; unknown names fall back instead of throwing.
persistenceThemePersistenceCustom load / save adapter; takes precedence over storageKey.
storageKeystringEnables the built-in localStorage adapter (one JSON entry of selection names).

applyThemeToElement options

applyThemeToElement(element, snapshot, options?) and connectThemeController(controller, element, options?) share ApplyThemeOptions:

OptionTypeDefaultDescription
prefixesreadonly string[]['journals', 'accounts']Component variable namespaces to write (--<prefix>-theme-*).

Utilities

ExportPurpose
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.
defaultCatalogEvery 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 pluggable load / save adapter contract.
  • ThemeCatalogEntry{ name, label, scheme, roles }.
  • ThemeCatalogget (null for unknown names), list, and defaultFor (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:

ExportPurpose
light / darkThe base role sets (Roles): bg, fg, border, accent, states, and ledger colors.
lightSoft / darkSoftContrast-compressed variants.
lightCvd / darkCvdProtanopia/deuteranopia-safe variants (blue ↔ orange debit/credit axis).
lightTritan / darkTritanTritanopia-safe variants (teal ↔ vermillion axis).
RolesThe role-set shape every theme conforms to.
palettesThe 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.