@aria-framework/theme
v0.7.0
Published
Aria App Framework — theme module. Field Ops Console design system: tokens.json single source of record + a generator that emits a Bootstrap 5.3 web theme (theme.css + self-hosted fonts) and a React Native palette/provider/primitives. Light + dark.
Readme
@aria-framework/theme
Shared Field Ops Console design system for apps on the Bootstrap 5.3 (web) +
Expo / React Native (mobile) stack. One source of record for the brand tokens,
the web theme.css + shell components (sidebar), and the React Native palette /
theme provider / UI primitives. Light + dark.
tokens.jsonis the single source of record. A tiny generator (build.js) emits BOTHweb/theme.css(CSS custom properties) andmobile/tokens.ts(the RN palette) from it, so the two platforms can never drift. Edittokens.json, runnpm run build, commit the regenerated outputs, and bump the package version (semver). Consuming apps pin a version andnpm updateto adopt.
Editing tokens (the generator)
- Edit values in
tokens.json(palettes.light / palettes.dark, plus status / priority / space). Hex is stored uppercase;--brand-rgbis derived automatically. - Run
npm run build(node build.js). It regenerates:web/theme.css— the:root+[data-bs-theme="dark"]token blocks (the rest of the file — Bootstrap mapping, edge-clarity, buttons, a11y — is static template), plus every component partial insrc/components/*.cssappended in filename order.mobile/tokens.ts—Palette,palettes,colors,status,priority,space,shadow.
- Do not hand-edit
web/theme.cssormobile/tokens.ts(both carry an AUTO-GENERATED banner). The static base CSS (fonts/mapping/buttons/a11y) lives insrc/base.css; component CSS lives insrc/components/*.css— build.js emits generated tokens → base → components. - Commit the regenerated files (consumers don't run the generator) and bump the version.
Note: the generator covers tokens. The RN
fonts.ts/ThemeContext.tsx/ui.tsxare hand-written (they're code, not values). Consuming the package's RAW.tsxon mobile out-of-tree needs MetrowatchFolders(and breakstscunless the package ships compiled JS+.d.ts) — see the Metro note below.
Install
Published on the public npm registry:
npm install @aria-framework/themeWeb (Bootstrap 5.3)
Load order in <head> matters — Bootstrap first, then this theme (so its --bs-*
overrides win), then your app-specific component CSS:
<link rel="stylesheet" href="/vendor/bootstrap.min.css">
<link rel="stylesheet" href="/theme/theme.css"> <!-- @aria-framework/theme -->
<link rel="stylesheet" href="/css/app.css"> <!-- your components -->Fonts + CSP. theme.css ships self-hosted woff2 in web/fonts/ and references
them with relative URLs (./fonts/…). Serve the package's web/ folder
same-origin so they satisfy a strict Content-Security-Policy: font-src 'self'
(no CDN). With Express:
const themeDir = path.dirname(require.resolve('@aria-framework/theme/theme.css'));
app.use('/theme', express.static(themeDir)); // → /theme/theme.css + /theme/fonts/*Font licensing. This package's license field is UNLICENSED, and that covers the CODE.
The fonts are not ours and carry their own terms: Hanken Grotesk and IBM Plex Mono are both
SIL OFL 1.1, whose clause 2 requires the licence text to travel with the files. It now does
— web/fonts/OFL-HankenGrotesk.txt, web/fonts/OFL-IBMPlexMono.txt, and
web/fonts/README.md saying which applies to what.
It did not before 0.6.3: four .woff2 files shipped with no licence of any kind, in every
release, to every consuming app. Serving web/ statically as above republishes those files, so
the condition is met by the same line that already serves them.
Dark mode is driven by a single attribute: <html data-bs-theme="dark">.
What's in theme.css
Design tokens (Ops Light :root + Ops Dark [data-bs-theme="dark"]), the
--bs-* mapping, --shadow/--shadow-lg, brand buttons (.btn-primary,
.btn-outline-primary), edge-clarity (cards/inputs use --line-strong + shadow;
tables keep the faint --line for row dividers), .mono helper, focus-visible
and reduced-motion — plus the shell components below. Domain-specific styles
do not belong here — keep dashboards, page layouts etc. in your app's own CSS.
Rule of thumb: if a second app would want it unchanged except for content, it
belongs here; if it knows about your domain, it stays in the app.
Component: sidebar rail
A nav rail themed per scheme — light rail in light mode, dark rail in dark
mode (driven by the --side-* tokens, including --side-strong for emphasis
text and --side-accent for the active bar / icon / focus ring). Mobile-first:
off-canvas overlay under 768px (hamburger + backdrop + Esc/backdrop-tap close,
focus trapped via inert); an in-flow rail on desktop with a persisted collapse
to a 64px icon rail. The package ships the CSS (in theme.css) and the
behavior (sidebar.js); the app ships the markup against this contract:
<div id="wrapper">
<nav class="s-side">
<div class="s-brand">
<a class="s-brand-link" href="/">
<span class="s-mark">S</span>
<span><span class="s-name">MyApp</span><span class="s-ver">v1.0.0</span></span>
</a>
<button class="s-collapse" data-sidebar-toggle aria-label="Collapse sidebar"><i class="bi bi-chevron-double-left"></i></button>
</div>
<div class="s-nav">
<div class="s-grp">Section</div>
<a class="s-link active" href="/x"><i class="bi bi-..."></i><span>Item</span></a>
</div>
<div class="s-me">
<span class="s-av">AB</span>
<span class="s-me-who"><b>Ann Bee</b><span>ADMIN</span></span>
<form class="s-out-form" method="post" action="/logout"><button class="s-out" aria-label="Sign out"><i class="bi bi-box-arrow-right"></i></button></form>
</div>
</nav>
<div id="page-content-wrapper">
<!-- your topbar should include the mobile hamburger: -->
<button class="topbar-toggle" data-sidebar-toggle aria-label="Open menu"><i class="bi bi-list"></i></button>
...
</div>
</div>
<script src="/theme/sidebar.js" data-storage-key="myapp-sidebar-collapsed"></script>Notes:
data-storage-keynamespaces the persisted collapsed state per app (defaultaria-sidebar-collapsed). Keep an app's historical key across migrations.- To avoid a flash of the expanded rail on load, load
theme-init.js(below) synchronously in<head>— it pre-applies the class before first paint. - State classes live on
<html>:.sidebar-open(mobile overlay),.sidebar-collapsed(desktop icon rail). - Sidebar tokens are themeable in
tokens.jsonunder"sidebar"(per-schemelight/darksub-objects, emitted as--side-*in:rootand the dark block). Icons in the examples are Bootstrap Icons, but any icon font works. - If your app paints something with
var(--side-*)that should IGNORE the scheme (e.g. a permanently-dark login hero panel), use fixed values there — since 0.5.0 these tokens follow the active scheme.
Pre-paint init (theme-init.js, since 0.6.0)
Resolves the color mode BEFORE first paint (no FOUC) and pre-applies the
collapsed-sidebar class. Load it synchronously in <head> — NOT deferred:
<html data-theme-pref="system"> <!-- server stamps light|dark|system -->
<head>
<script src="/theme/theme-init.js" data-storage-key="myapp-sidebar-collapsed"></script>
...data-theme-pref="system"resolves from the OS color scheme and live-updates if the OS theme changes;light/darkare forced modes. The result lands on<html data-bs-theme="...">(Bootstrap 5.3 convention, whattheme.csskeys on).data-storage-keyMUST match the key given tosidebar.js(same default:aria-sidebar-collapsed).- CSP-safe: external + self-hosted, works under
script-src 'self'.
Mobile (Expo / React Native)
import { ThemeProvider, useTheme, Card, Btn, Field, palettes } from '@aria-framework/theme/mobile';
export default function App() {
return (
<ThemeProvider storageKey="myapp.theme">{/* namespace per app */}
<Screens />
</ThemeProvider>
);
}
function Screen() {
const { colors, scheme } = useTheme();
return <Card><Text style={{ color: colors.text }}>Hello</Text></Card>;
}Fonts: load @expo-google-fonts/hanken-grotesk + @expo-google-fonts/ibm-plex-mono
in your app entry; the family names in mobile/fonts.ts match those packages.
Peer deps (provided by the app): react, react-native,
@react-native-async-storage/async-storage.
Metro note: consuming a
file:dependency requires Metro to watch the package folder. Add it towatchFoldersinmetro.config.js, or use a published git version to avoid symlink/transpile quirks.
Versioning
Token change → edit tokens.json, run npm run build, commit the regenerated
web/theme.css + mobile/tokens.ts, then bump the version in package.json.
- v0.1.0 — initial extraction (refined light borders + defined elevation tokens), hand-maintained web/mobile.
- v0.2.0 — added
tokens.json+build.jsgenerator (single source of record; web + mobile generated from one file). Unified the drifted dark--line-strong. - v0.3.0 — renamed to
@aria-framework/themeand published to npm. Mobile sources now compile todist/(.js +.d.ts, via apreparescript) so RN apps can import the package out-of-tree;exportspoint at the built files. - v0.4.0 — first shell component: the sidebar rail (CSS +
--side-*tokens +sidebar.jsbehavior with a per-appdata-storage-key; markup contract above). Build now assembles generated tokens →src/base.css→src/components/*.css. Sidebar carries its own focus-visible contrast rule. - v0.4.1 —
sidebar.jsdouble-include guard (a second script tag no longer registers duplicate listeners); this changelog added. - v0.5.0 — sidebar rail is now per-scheme (light rail in light mode).
tokens.json"sidebar"gainedlight/darksub-objects and two new tokens:strong(emphasis text, replaces hardcoded#fff) andaccent(active bar / icon / focus ring). Apps that used--side-*for deliberately-always-dark surfaces must pin fixed colors instead. - v0.6.0 — added
web/theme-init.js: pre-paint color-mode resolver (data-theme-preflight/dark/system + live OS-change sync) and collapsed-sidebar pre-apply, parameterized by the samedata-storage-keyassidebar.js. Apps can delete their local copies.
0.x semver reminder:
^0.4.0matches<0.5.0only — consumers pick up patches vianpm update, but every minor bump (0.4 → 0.5) requires editing the range in each consuming app.
