@net-advantage/jo-ui-theme
v2.1.0
Published
Runtime colour tokens, radius tokens, the Inter and Caveat font tokens, and self-hosted Inter and Caveat faces.
Readme
@net-advantage/jo-ui-theme
Runtime colour tokens, radius tokens, the Inter and Caveat font tokens, and self-hosted Inter and Caveat faces.
The theme follows a strict MVVM split. The model names the theme and the token catalogue. The view-model projects the theme class. The view renders that class around the host children. The stylesheet is the runtime source for every --jo-color-* and --jo-radius-* value.
MVVM
flowchart LR
Props["ThemeProps"] --> ViewModel["useThemeViewModel"]
Model["jo-ui-theme.model.ts<br/>theme name and token catalogue"] --> ViewModel
ViewModel --> View["Theme.tsx"]
Styles["jo-ui-theme.css<br/>tokens and font faces"] --> Viewflowchart LR
PrefModel["theme-preference.model.ts<br/>preference, resolve, apply, boot script"] --> PrefViewModel["theme-preference.view-model.ts<br/>Observable preference and theme"]
PrefViewModel --> PrefHook["use-theme-preference.ts<br/>useThemePreference"]
ToggleProps["ThemeToggleProps"] --> ToggleViewModel["theme-toggle.view-model.ts<br/>Observable toggle view-model"]
ToggleModel["theme-toggle.model.ts<br/>labels, classes, keys"] --> ToggleViewModel
ToggleViewModel --> ToggleHook["theme-toggle.use-view-model.ts<br/>useThemeToggleViewModel"]
ToggleHook --> ToggleView["ThemeToggle.tsx"]
PrefHook --> ToggleView
Styles2["jo-ui-theme.css<br/>toggle tokens"] --> ToggleView| Layer | File | Responsibility | | --- | --- | --- | | Contract | jo-ui-theme.types.ts | Theme name, props, and view-model | | Model | jo-ui-theme.model.ts | Normalize light or dark, and publish the token and font catalogues | | View-model | jo-ui-theme.view-model.ts | Project the theme class | | View | Theme.tsx | Render the theme root and import the runtime stylesheet | | Contract | theme-toggle.types.ts | ThemeToggle props, option, and view-model | | Model | theme-preference.model.ts | Normalize and resolve the preference, apply it to a root, and the boot script | | Model | theme-toggle.model.ts | Toggle labels, class names, and radio-group keys | | View-model | theme-preference.view-model.ts | Observable preference and theme, storage, and the system scheme | | View-model | theme-toggle.view-model.ts | Observable toggle view-model, clicks, and keys | | Hook | use-theme-preference.ts | Connect the preference after mount and subscribe | | Hook | theme-toggle.use-view-model.ts | Pass the latest props and subscribe | | View | ThemeToggle.tsx | Render the radio group or the compact button, controlled or stored | | Styles | jo-ui-theme.css | Define light and dark tokens and the self-hosted font faces |
Theme preference and ThemeToggle
useThemePreference()returnspreference(light,dark, orsystem), the resolvedtheme, andsetPreference. The default issystem, which followsprefers-color-schemeand updates when it changes. The first render issystemandlight, the same as the server; the stored choice is read after mount.- The choice is stored in
localStorageunderjo-theme. Choosingsystemremoves the key. - The resolved theme is applied to
<html>: thetheme-lightortheme-darkclass,data-theme, andcolor-scheme. Passthemeto<Theme>so the slice root matches. themeBootScriptis the same logic as an inline script. Put it in a<script>in the document head, before the stylesheet, so the stored theme is on<html>before first paint. If storage throws, it applies the system theme.ThemeToggleis a radio group of Light, Dark, and System. Arrow keys, Home, and End move and select. Withoutpreferenceit callsuseThemePreferenceitself. WithpreferenceandonPreferenceChangeit is controlled.<ThemeToggle compact />is one native icon button showing the current mode's icon. Each click moves light → dark → system → light; the view-model owns the cycle. Itsaria-labelnames the current and next mode, for exampleTheme: Dark. Switch to System. It keeps the focus ring,disabled, and a 44px hit area around the 36px face.- The selected option is
--jo-ui-theme-toggle-selected-bg(--jo-color-selected-strong,#14224a/#9db6ef) with--jo-ui-theme-toggle-selected-fg(--jo-color-selected-strong-text,#ffffff/#121419). Label and icon contrast is 15.47:1 in light and 9.10:1 in dark. Against the toggle surface the selected pill is 15.47:1 in light and 8.01:1 in dark; the old dark#1b2436on#13203awas 1.04:1. - Compact matches the header theme button in Anlien's mocks (
journey-of-sarah.html#themeBtn). Tokens, set on.jo-ui-theme-toggle--compact:
| Token | Value | From the mock |
| --- | --- | --- |
| --jo-ui-theme-toggle-compact-size | 36px | width/height: 36px |
| --jo-ui-theme-toggle-compact-touch-size | --jo-ui-theme-toggle-option-height (44px) | not in the mock; touch target |
| --jo-ui-theme-toggle-compact-radius | 9px | border-radius: 9px |
| --jo-ui-theme-toggle-compact-border-width | 1px | 1px solid |
| --jo-ui-theme-toggle-compact-border-color | --jo-color-year-bar-border (#e5e7eb / #3a4150) | #e5e7eb, dark #3a4150 |
| --jo-ui-theme-toggle-compact-bg | --jo-color-year-bar-surface (#ffffff / #1c212b) | #fff, dark #1c212b |
| --jo-ui-theme-toggle-compact-hover-bg | --jo-color-hover (#f0f2f6 / #242938) | none on the button; shell control hover |
| --jo-ui-theme-toggle-compact-icon-size | 15px | svg 15×15, stroke 2 |
| --jo-ui-theme-toggle-compact-icon-color | --jo-color-icon-strong (#1c2333 / #e9e6de) | sun #1c2333, dark #e9e6de |
| --jo-ui-theme-toggle-compact-moon-color | --jo-color-theme-toggle-moon: --jo-color-literal-6495ed in dark (5.43:1), --jo-color-icon-strong in light (15.70:1), since #6495ed is 2.97:1 on white | moon stroke #6495ed |
| --jo-ui-theme-toggle-compact-focus-width / -offset | 2px / 2px, --jo-color-focus | 2px #6495ed ring on the other journey pages |
const { preference, theme, setPreference } = useThemePreference();
<Theme theme={theme}>
<ThemeToggle preference={preference} onPreferenceChange={setPreference} />
<ThemeToggle compact preference={preference} onPreferenceChange={setPreference} />
</Theme>Models
flowchart TD
Props["ThemeProps"] --> Name["ThemeName"]
Name --> ClassName["theme class"]
Catalogue["JO_COLOR_TOKENS, JO_SHADOW_TOKENS, and JO_RADIUS_TOKENS"] --> Styles["runtime stylesheet"]
Fallback["TEXT_MUTED_FALLBACK"] --> Styles
Fonts["Inter and Caveat"] --> Styles- A missing theme is
light. theme="dark"addstheme-dark. Otherwise the root istheme-light.- The official fallback for
--jo-color-text-mutedis#667085. The light theme defines that value. The dark theme defines#9ca8bf. --jo-color-textis the Sarah mock's reading colour. Light is#1c2333(body). Dark is#e9e6de(html.dark body). Both meet WCAG AA for normal text on--jo-color-surfaceand--jo-color-surface-alt.--jo-color-text-secondaryis the supporting gray. Light is#4a5565, the computed colour of the mock note'stext-gray-600(rgb(74, 85, 101))..invite-roleand.ja-selstill write#4b5563, and that hex is not the token. Dark is#c3c9d4, fromhtml.dark .text-gray-600. It meets WCAG AA for normal text on--jo-color-surfaceand--jo-color-surface-altin both themes. Ratios and the light-theme miss on--jo-color-surface-darkare in docs/text-secondary.md.--jo-color-surface-darkis a dark surface in both themes (#13203alight,#0b1220dark), alongside the theme's own--jo-color-surfaceand--jo-color-surface-alt.--jo-color-scrimand--jo-color-on-scrimpaint overlays and the Buttonoverlayvariant.--jo-color-text-mutedand--jo-color-surfaceare the muted text and surface colours. Light muted is#667085. Light surface is#ffffff.- Badge success is
--jo-color-badge-success-bgand--jo-color-badge-success-text: light#e6f6ee/#0d8a54, dark#1e3b25/#9ce2a5. Badge danger background is--jo-color-badge-danger-bg(light#c55262, dark#ff8c98, the same values as--jo-color-danger). Badge danger text is--jo-color-badge-danger-text(#ffffffin both themes). --jo-color-on-primaryis text on a primary action. Light is#ffffff. Dark is#0d1838.--jo-color-shell-primaryis the shell primary action fill. Light is#172750. Dark is#9bbaff. Dark shell chrome is charcoal:--jo-color-shell-bgand--jo-color-shell-surfaceare#191c24, and--jo-color-shell-content-bgis#121419.--jo-color-shell-navyis#14224ain both themes. The other--jo-color-shell-*tokens are the shell chrome colours from the shell stylesheet, including#f5f7ff,#16483f, and#a7f3d0, the wordmark, the demo cards, and the mobile dark palette.--jo-color-shell-on-navystays#ffffffin both themes, so avatar and missing-page text do not follow--jo-color-on-primary(dark#0d1838). Footer shadow is split into--jo-shadow-shell-footer-aand--jo-shadow-shell-footer-bso each layer can keep a comma-free fallback.--jo-color-button-primary-modal-hoveris#0f1a38in both themes.--jo-color-button-ghost-textis#1c2333in light and#e7ecf8in dark.--jo-color-icon-erroris#b42318in both themes.- Button and badge colours that were still hard-coded now have tokens with the same values. Primary hover is
#162448light and#b3c6ffdark. Secondary hover background is#eef3fa/#223552, and the border is#c8d2e3/#3a507a. Modal fill is#14224a. Text on a solid button is#ffffff(--jo-color-button-on-solid). Modal muted text isoklch(0.551 0.027 264.364)in light and the dark text-muted override is#b9c4da. Destructive fill is#d94a5a, hover#c43f4e. Ghost hover is#f1f5f9(the oldslate-100) in light and#23314din dark. Chip success border is#b7ebc9/#2f6e32. Chip danger fill is#fff1f3/#3b1d27and its border is#f3c3c9/#6e3140. Badge success and danger read the badge tokens, including the dark success pair#1e3b25/#9ce2a5. - Colour-picker gradient stops are
--jo-color-picker-stop-white(#ffffff),--jo-color-picker-stop-white-fade(rgb(255 255 255 / 0)),--jo-color-picker-stop-black(#000000), and--jo-color-picker-stop-black-fade(rgb(0 0 0 / 0)). Both themes use the same stops. The hue stops (--jo-color-picker-hue-redthrough--jo-color-picker-hue-magenta) are the same in both themes. Picker chrome is--jo-color-picker-border#d9dee8/#3b4c70,--jo-color-picker-border-strong#c9d2e2/#51638a,--jo-color-picker-text#1f2a44/#ecf2ff,--jo-color-picker-muted#7c8699/#a9b8d4,--jo-color-picker-focus#2f9eca/#57b4da(this is not--jo-color-focus), and--jo-color-picker-field-bg#ffffff/#132547. The thumb ring is--jo-shadow-picker-thumb. - Role tokens from the canonical shell palette, defined here and not yet wired through the controls:
--jo-color-control-border#e5e7eb/#3a4150,--jo-color-hover(and--jo-color-ghost-hover,--jo-color-secondary-hover)#f0f2f6/#242938,--jo-color-navy#14224ain both themes,--jo-color-link#14224a/#8eb4f2, selected background and foreground--jo-color-selected-bg#eef1fa/#1b2436and--jo-color-selected-fg#14224a/#9db6ef,--jo-color-selected-strong#14224a/#9db6efwith--jo-color-selected-strong-text#ffffff/#121419, icon--jo-color-icon-fg#8891a3in both themes,--jo-color-icon-strong#1c2333/#e9e6de,--jo-color-icon-active#14224a/#9db6ef,--jo-color-unread#14224a/#9db6ef,--jo-color-count-badge#b42318in both themes,--jo-color-today-ring#8e9bb3/#6a7b99, success--jo-color-success#166534/#86efacand--jo-color-success-bg#eef6f1/#1a2c26, and the switch specimen--jo-color-switch-off#d1d5db/#6a7b99,--jo-color-switch-on#14224a/#9db6ef,--jo-color-switch-knob#ffffff/#b7bec9. The role table is in docs/shell-colours.md. Existing shell, year-bar, and button tokens keep their current values. - Swatch chrome is
--jo-color-swatch-border#d9dee8/#3f5278,--jo-color-swatch-bg#ffffff/#0f1c38,--jo-color-swatch-selected#1a2550/#dce6ff,--jo-color-swatch-disabled#c3cad8/#5a6d91,--jo-color-swatch-add#667085in both themes, and--jo-color-swatch-apply-bg#f8fafe/#172744. The add control does not use--jo-color-text-muted, because that token is#9ca8bfin dark. Icon glyph is--jo-color-icon-glyph#667085/#cfd9ec. Icon selected is--jo-color-icon-selected#c5d8ff/#5d7fca. Icon border, well, and disabled reuse the swatch tokens that already have those exact pairs. - Overlay shadows, the same in both themes:
--jo-shadow-dialogis0 24px 60px rgb(0 0 0 / 0.35),--jo-shadow-confirm-dialogis0 16px 40px rgb(15 23 42 / 0.28),--jo-shadow-menuis0 12px 32px rgb(15 23 42 / 0.16),--jo-shadow-side-panelis0 12px 40px rgb(15 23 42 / 0.28), and--jo-shadow-chooseris0 8px 24px rgb(15 23 42 / 0.15). - Dialog, confirm dialog, dropdown menu, and side panel read the overlay shadow tokens, with the previous shadow as the fallback. The other tokens above are the replacements for colours that are still hard-coded.
- Year-bar tokens paint the timeline bar and its controls. Light values come from the Sarah mock: track
#ffffff, surface#ffffff, border#e5e7eb, text#374151, muted#9ca3af, active#14224a, active text#ffffff. Dark values: track#191c24, surface#1c212b, border#3a4150, text#e9e6de, muted#8b93a3, hover#242938, active#9db6ef, active text#121419. - Empty years use
--jo-color-year-bar-muted. Years with moments use--jo-color-year-bar-text. Light muted#9ca3afon the white track is about 2.54:1, below WCAG AA. Anlien's palette treats muted as decoration, so the token keeps the mock's gray. - Unselected span labels use
--jo-color-year-bar-span-text:#6b7280in light and#e9e6dein dark, matching the mock's mode buttons. They do not use the muted token. - The mock does not define a light hover for the year bar. Light
--jo-color-year-bar-hoveris#f3f4f6, the mock's existing hover gray. - Navy and white for a selected year are the existing year-bar active tokens: light active
#14224awith active text#ffffff. Dark active is#9db6efwith active text#121419. - Year-bar metrics are the same in both themes: inline padding
12px, group gap12px, year gap4px, pill padding3px 6px, pill min width3rem, pill radius20px, pill type11pxat line-height1.5. Block padding is--jo-year-bar-padding-block(6px, a 38px bar) below 1500px, and--jo-year-bar-padding-block-wide(8px, a 42px bar) from 1500px. The bar radius is--jo-radius-control(12px). A pinned bar reads--jo-year-bar-sticky-top(0px) and--jo-year-bar-z-index(2). - Compact year-bar metrics, also the same in both themes, follow the Sarah mock's phone and tablet year strip:
--jo-year-bar-compact-max768px,--jo-year-bar-hit-min44px,--jo-year-bar-pill-min-width-compact58px,--jo-year-bar-pill-font-size-compact12.5px,--jo-year-bar-pill-hit-border6px,--jo-year-bar-pill-radius-compact999px,--jo-year-bar-year-gap-compact0px,--jo-year-bar-year-padding-inline-compact8px, and--jo-year-bar-year-fade18px. --jo-font-familyis Inter, thenui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif. Inter is the Sarah mock's body face. The longer stack is the fallback when Inter is not installed. The mock's own fallback is onlysystem-ui, sans-serif.:root,.theme-light,.theme-dark, and.jo-ui-themesetfont-familyto the token.--jo-ui-control-font-familypoints at the same token so controls follow it.--jo-font-family-handwrittenisCaveat, cursive. Caveat is the mock's logo accent and note-card face.cursiveis the fallback when Caveat is not installed. The logo accent uses the token.- How Inter is loaded, which weights ship, and why this package does not add
@fontsource-variable/interare in docs/font-family.md. --jo-radius-smis6px.--jo-radius-controlis12px.--jo-radius-cardis16px.- Moment-card metrics shipped in 1.4.5 and are the same in both themes. They match the Journey Details track:
--jo-moment-card-max-widthis196pxand--jo-moment-card-min-widthis150px. Cards share the row up to the maximum and shrink to the minimum before the track scrolls. From 1500px the minimum becomes the maximum, so each card holds 196px. A consumer repeats both values as the fallback invar(--jo-moment-card-*, <value>), so a 1.4.4 stylesheet still lays the cards out. - Date-picker metrics shipped in 1.4.4 and are the same in both themes. There is no separate date-picker palette: the control reads the shared surface, text, accent, focus, and danger tokens, and dialog surface tokens when it is inside a dialog.
--jo-date-picker-hit-minis44px.--jo-date-picker-control-heightis2.5rem, matching a text input, and grows to the hit minimum from--jo-date-picker-compact-max(420px) down so a 375px screen keeps 44px targets. Popover padding and gutter are0.5rem.--jo-date-picker-z-indexis30. The date picker repeats each of these values as the fallback invar(--jo-date-picker-*, <value>).
One-line import
Load the stylesheet once at the application root:
import '@net-advantage/jo-ui-theme/style.css';That import defines the tokens for :root (light) and .theme-dark, sets --jo-font-family and --jo-font-family-handwritten, and registers Inter 400, 500, 600, 700, and 800 plus Caveat 500, 600, and 700 with font-display: swap. Those are the same weights the Sarah mock requests. Do not load these faces from Google Fonts, and do not add a second Inter from @fontsource-variable/inter. Details are in docs/font-family.md.
Inter and Caveat are licensed under the SIL Open Font License. The license texts ship in licenses/OFL-Inter.txt and licenses/OFL-Caveat.txt.
View-model observable
Observable<T> is the jo-ui view-model state primitive. It is framework-free: value plus subscribe(listener), which returns an unsubscribe function. createObservable(initial) adds set. useObservable(observable) reads it with useSyncExternalStore.
Packages should import the subpaths so they do not load this stylesheet:
import { createObservable } from '@net-advantage/jo-ui-theme/observable';
import { useObservable } from '@net-advantage/jo-ui-theme/use-observable';The package root exports the same two functions for callers who already load the theme.
Publish footprint
This package publishes dist, README.md, and licenses.
