@aria-framework/theme 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,113 +1,161 @@
1
- # field-ops-theme
2
-
3
- Shared **Field Ops Console** design system for apps on the Bootstrap 5.3 (web) +
4
- Expo / React Native (mobile) stack. One source of record for the brand tokens,
5
- the web `theme.css`, and the React Native palette / theme provider / UI primitives.
6
- Light + dark.
7
-
8
- > **`tokens.json` is the single source of record.** A tiny generator (`build.js`)
9
- > emits BOTH `web/theme.css` (CSS custom properties) and `mobile/tokens.ts` (the RN
10
- > palette) from it, so the two platforms can never drift. Edit `tokens.json`, run
11
- > `npm run build`, commit the regenerated outputs, and bump the package version
12
- > (semver). Consuming apps pin a version and `npm update` to adopt.
13
-
14
- ## Editing tokens (the generator)
15
-
16
- 1. Edit values in **`tokens.json`** (palettes.light / palettes.dark, plus status /
17
- priority / space). Hex is stored uppercase; `--brand-rgb` is derived automatically.
18
- 2. Run **`npm run build`** (`node build.js`). It regenerates:
19
- - `web/theme.css` — the `:root` + `[data-bs-theme="dark"]` token blocks (the rest
20
- of the file — Bootstrap mapping, edge-clarity, buttons, a11y — is static template).
21
- - `mobile/tokens.ts` `Palette`, `palettes`, `colors`, `status`, `priority`,
22
- `space`, `shadow`.
23
- 3. **Do not hand-edit** `web/theme.css` or `mobile/tokens.ts` (both carry an
24
- AUTO-GENERATED banner). The static CSS (fonts/mapping/buttons/a11y) lives in the
25
- template inside `build.js`; structural CSS changes go there.
26
- 4. Commit the regenerated files (consumers don't run the generator) and bump the version.
27
-
28
- > Note: the generator covers tokens. The RN `fonts.ts` / `ThemeContext.tsx` / `ui.tsx`
29
- > are hand-written (they're code, not values). Consuming the package's RAW `.tsx` on
30
- > mobile out-of-tree needs Metro `watchFolders` (and breaks `tsc` unless the package
31
- > ships compiled JS+`.d.ts`) see the Metro note below.
32
-
33
- ## Install
34
-
35
- Pin by git tag (recommended) or local path:
36
-
37
- ```jsonc
38
- // package.json of the consuming app
39
- "dependencies": {
40
- "field-ops-theme": "github:<you>/field-ops-theme#v0.1.0"
41
- // or, for local dev: "field-ops-theme": "file:../field-ops-theme"
42
- }
43
- ```
44
-
45
- ## Web (Bootstrap 5.3)
46
-
47
- Load order in `<head>` matters — Bootstrap first, then this theme (so its `--bs-*`
48
- overrides win), then your app-specific component CSS:
49
-
50
- ```html
51
- <link rel="stylesheet" href="/vendor/bootstrap.min.css">
52
- <link rel="stylesheet" href="/theme/theme.css"> <!-- field-ops-theme -->
53
- <link rel="stylesheet" href="/css/app.css"> <!-- your components -->
54
- ```
55
-
56
- **Fonts + CSP.** `theme.css` ships self-hosted woff2 in `web/fonts/` and references
57
- them with **relative** URLs (`./fonts/…`). Serve the package's `web/` folder
58
- **same-origin** so they satisfy a strict `Content-Security-Policy: font-src 'self'`
59
- (no CDN). With Express:
60
-
61
- ```js
62
- const themeDir = path.dirname(require.resolve('field-ops-theme/theme.css'));
63
- app.use('/theme', express.static(themeDir)); // → /theme/theme.css + /theme/fonts/*
64
- ```
65
-
66
- Dark mode is driven by a single attribute: `<html data-bs-theme="dark">`.
67
-
68
- ### What's in `theme.css` (generic only)
69
- Design tokens (Ops Light `:root` + Ops Dark `[data-bs-theme="dark"]`), the
70
- `--bs-*` mapping, `--shadow`/`--shadow-lg`, brand buttons (`.btn-primary`,
71
- `.btn-outline-primary`), edge-clarity (cards/inputs use `--line-strong` + shadow;
72
- tables keep the faint `--line` for row dividers), `.mono` helper, focus-visible
73
- and reduced-motion. **App-specific component styles do not belong here** — keep
74
- sidebars, dashboards, page layouts etc. in your app's own CSS.
75
-
76
- ## Mobile (Expo / React Native)
77
-
78
- ```tsx
79
- import { ThemeProvider, useTheme, Card, Btn, Field, palettes } from 'field-ops-theme/mobile';
80
-
81
- export default function App() {
82
- return (
83
- <ThemeProvider storageKey="myapp.theme">{/* namespace per app */}
84
- <Screens />
85
- </ThemeProvider>
86
- );
87
- }
88
-
89
- function Screen() {
90
- const { colors, scheme } = useTheme();
91
- return <Card><Text style={{ color: colors.text }}>Hello</Text></Card>;
92
- }
93
- ```
94
-
95
- Fonts: load `@expo-google-fonts/hanken-grotesk` + `@expo-google-fonts/ibm-plex-mono`
96
- in your app entry; the family names in `mobile/fonts.ts` match those packages.
97
-
98
- Peer deps (provided by the app): `react`, `react-native`,
99
- `@react-native-async-storage/async-storage`.
100
-
101
- > **Metro note:** consuming a `file:` dependency requires Metro to watch the
102
- > package folder. Add it to `watchFolders` in `metro.config.js`, or use a published
103
- > git version to avoid symlink/transpile quirks.
104
-
105
- ## Versioning
106
-
107
- Token change edit `tokens.json`, run `npm run build`, commit the regenerated
108
- `web/theme.css` + `mobile/tokens.ts`, then bump the `version` in `package.json`.
109
-
110
- - **v0.1.0** — initial extraction (refined light borders + defined elevation tokens),
111
- hand-maintained web/mobile.
112
- - **v0.2.0** — added `tokens.json` + `build.js` generator (single source of record;
113
- web + mobile generated from one file). Unified the drifted dark `--line-strong`.
1
+ # @aria-framework/theme
2
+
3
+ Shared **Field Ops Console** design system for apps on the Bootstrap 5.3 (web) +
4
+ Expo / React Native (mobile) stack. One source of record for the brand tokens,
5
+ the web `theme.css` + shell components (sidebar), and the React Native palette /
6
+ theme provider / UI primitives. Light + dark.
7
+
8
+ > **`tokens.json` is the single source of record.** A tiny generator (`build.js`)
9
+ > emits BOTH `web/theme.css` (CSS custom properties) and `mobile/tokens.ts` (the RN
10
+ > palette) from it, so the two platforms can never drift. Edit `tokens.json`, run
11
+ > `npm run build`, commit the regenerated outputs, and bump the package version
12
+ > (semver). Consuming apps pin a version and `npm update` to adopt.
13
+
14
+ ## Editing tokens (the generator)
15
+
16
+ 1. Edit values in **`tokens.json`** (palettes.light / palettes.dark, plus status /
17
+ priority / space). Hex is stored uppercase; `--brand-rgb` is derived automatically.
18
+ 2. Run **`npm run build`** (`node build.js`). It regenerates:
19
+ - `web/theme.css` — the `:root` + `[data-bs-theme="dark"]` token blocks (the rest
20
+ of the file — Bootstrap mapping, edge-clarity, buttons, a11y — is static
21
+ template), **plus every component partial in `src/components/*.css`** appended
22
+ in filename order.
23
+ - `mobile/tokens.ts` `Palette`, `palettes`, `colors`, `status`, `priority`,
24
+ `space`, `shadow`.
25
+ 3. **Do not hand-edit** `web/theme.css` or `mobile/tokens.ts` (both carry an
26
+ AUTO-GENERATED banner). The static base CSS (fonts/mapping/buttons/a11y) lives in
27
+ `src/base.css`; component CSS lives in `src/components/*.css` — build.js emits
28
+ generated tokens base components.
29
+ 4. Commit the regenerated files (consumers don't run the generator) and bump the version.
30
+
31
+ > Note: the generator covers tokens. The RN `fonts.ts` / `ThemeContext.tsx` / `ui.tsx`
32
+ > are hand-written (they're code, not values). Consuming the package's RAW `.tsx` on
33
+ > mobile out-of-tree needs Metro `watchFolders` (and breaks `tsc` unless the package
34
+ > ships compiled JS+`.d.ts`) — see the Metro note below.
35
+
36
+ ## Install
37
+
38
+ Published on the public npm registry:
39
+
40
+ ```bash
41
+ npm install @aria-framework/theme
42
+ ```
43
+
44
+ ## Web (Bootstrap 5.3)
45
+
46
+ Load order in `<head>` matters — Bootstrap first, then this theme (so its `--bs-*`
47
+ overrides win), then your app-specific component CSS:
48
+
49
+ ```html
50
+ <link rel="stylesheet" href="/vendor/bootstrap.min.css">
51
+ <link rel="stylesheet" href="/theme/theme.css"> <!-- @aria-framework/theme -->
52
+ <link rel="stylesheet" href="/css/app.css"> <!-- your components -->
53
+ ```
54
+
55
+ **Fonts + CSP.** `theme.css` ships self-hosted woff2 in `web/fonts/` and references
56
+ them with **relative** URLs (`./fonts/…`). Serve the package's `web/` folder
57
+ **same-origin** so they satisfy a strict `Content-Security-Policy: font-src 'self'`
58
+ (no CDN). With Express:
59
+
60
+ ```js
61
+ const themeDir = path.dirname(require.resolve('@aria-framework/theme/theme.css'));
62
+ app.use('/theme', express.static(themeDir)); // /theme/theme.css + /theme/fonts/*
63
+ ```
64
+
65
+ Dark mode is driven by a single attribute: `<html data-bs-theme="dark">`.
66
+
67
+ ### What's in `theme.css`
68
+ Design tokens (Ops Light `:root` + Ops Dark `[data-bs-theme="dark"]`), the
69
+ `--bs-*` mapping, `--shadow`/`--shadow-lg`, brand buttons (`.btn-primary`,
70
+ `.btn-outline-primary`), edge-clarity (cards/inputs use `--line-strong` + shadow;
71
+ tables keep the faint `--line` for row dividers), `.mono` helper, focus-visible
72
+ and reduced-motion plus the **shell components** below. **Domain-specific styles
73
+ do not belong here** — keep dashboards, page layouts etc. in your app's own CSS.
74
+ Rule of thumb: if a second app would want it unchanged except for content, it
75
+ belongs here; if it knows about your domain, it stays in the app.
76
+
77
+ ### Component: sidebar rail
78
+
79
+ A dark nav rail, identical in light + dark modes. Mobile-first: off-canvas
80
+ overlay under 768px (hamburger + backdrop + Esc/backdrop-tap close, focus
81
+ trapped via `inert`); an in-flow rail on desktop with a persisted collapse to a
82
+ 64px icon rail. The package ships the **CSS** (in `theme.css`) and the
83
+ **behavior** (`sidebar.js`); the app ships the **markup** against this contract:
84
+
85
+ ```html
86
+ <div id="wrapper">
87
+ <nav class="s-side">
88
+ <div class="s-brand">
89
+ <a class="s-brand-link" href="/">
90
+ <span class="s-mark">S</span>
91
+ <span><span class="s-name">MyApp</span><span class="s-ver">v1.0.0</span></span>
92
+ </a>
93
+ <button class="s-collapse" data-sidebar-toggle aria-label="Collapse sidebar"><i class="bi bi-chevron-double-left"></i></button>
94
+ </div>
95
+ <div class="s-nav">
96
+ <div class="s-grp">Section</div>
97
+ <a class="s-link active" href="/x"><i class="bi bi-..."></i><span>Item</span></a>
98
+ </div>
99
+ <div class="s-me">
100
+ <span class="s-av">AB</span>
101
+ <span class="s-me-who"><b>Ann Bee</b><span>ADMIN</span></span>
102
+ <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>
103
+ </div>
104
+ </nav>
105
+ <div id="page-content-wrapper">
106
+ <!-- your topbar should include the mobile hamburger: -->
107
+ <button class="topbar-toggle" data-sidebar-toggle aria-label="Open menu"><i class="bi bi-list"></i></button>
108
+ ...
109
+ </div>
110
+ </div>
111
+ <script src="/theme/sidebar.js" data-storage-key="myapp-sidebar-collapsed"></script>
112
+ ```
113
+
114
+ Notes:
115
+ - `data-storage-key` namespaces the persisted collapsed state per app (default
116
+ `aria-sidebar-collapsed`). Keep an app's historical key across migrations.
117
+ - To avoid a flash of the expanded rail on load, pre-apply the class in an early
118
+ script: `if (localStorage.getItem('<key>') === '1') document.documentElement.classList.add('sidebar-collapsed')`.
119
+ - State classes live on `<html>`: `.sidebar-open` (mobile overlay),
120
+ `.sidebar-collapsed` (desktop icon rail).
121
+ - Sidebar tokens are themeable in `tokens.json` under `"sidebar"` (emitted as
122
+ `--side-*`). Icons in the examples are Bootstrap Icons, but any icon font works.
123
+
124
+ ## Mobile (Expo / React Native)
125
+
126
+ ```tsx
127
+ import { ThemeProvider, useTheme, Card, Btn, Field, palettes } from '@aria-framework/theme/mobile';
128
+
129
+ export default function App() {
130
+ return (
131
+ <ThemeProvider storageKey="myapp.theme">{/* namespace per app */}
132
+ <Screens />
133
+ </ThemeProvider>
134
+ );
135
+ }
136
+
137
+ function Screen() {
138
+ const { colors, scheme } = useTheme();
139
+ return <Card><Text style={{ color: colors.text }}>Hello</Text></Card>;
140
+ }
141
+ ```
142
+
143
+ Fonts: load `@expo-google-fonts/hanken-grotesk` + `@expo-google-fonts/ibm-plex-mono`
144
+ in your app entry; the family names in `mobile/fonts.ts` match those packages.
145
+
146
+ Peer deps (provided by the app): `react`, `react-native`,
147
+ `@react-native-async-storage/async-storage`.
148
+
149
+ > **Metro note:** consuming a `file:` dependency requires Metro to watch the
150
+ > package folder. Add it to `watchFolders` in `metro.config.js`, or use a published
151
+ > git version to avoid symlink/transpile quirks.
152
+
153
+ ## Versioning
154
+
155
+ Token change → edit `tokens.json`, run `npm run build`, commit the regenerated
156
+ `web/theme.css` + `mobile/tokens.ts`, then bump the `version` in `package.json`.
157
+
158
+ - **v0.1.0** — initial extraction (refined light borders + defined elevation tokens),
159
+ hand-maintained web/mobile.
160
+ - **v0.2.0** — added `tokens.json` + `build.js` generator (single source of record;
161
+ web + mobile generated from one file). Unified the drifted dark `--line-strong`.
package/build.js CHANGED
@@ -1,173 +1,133 @@
1
- #!/usr/bin/env node
2
- /* field-ops-theme generator. Reads tokens.json (single source of record) and emits
3
- * - web/theme.css (Bootstrap 5.3 token layer + static mapping/edge/buttons/a11y)
4
- * - mobile/tokens.ts (RN Palette + palettes/colors/status/priority/space/shadow)
5
- * Run: `node build.js`. The two outputs are committed artifacts (consumers don't
6
- * need to run this); regenerate whenever tokens.json changes, then bump the version. */
7
- const fs = require("fs");
8
- const path = require("path");
9
- const T = require("./tokens.json");
10
-
11
- const GEN_CSS = "/* AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`. */";
12
- const GEN_TS = "// AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`.";
13
- const FONT_SANS = `'Hanken Grotesk', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
14
- const FONT_MONO = `'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace`;
15
-
16
- const hexToRgb = (hex) => {
17
- const n = parseInt(hex.replace("#", ""), 16);
18
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255].join(",");
19
- };
20
-
21
- /* ── web/theme.css ────────────────────────────────────────────────────────── */
22
- const cssTokens = (p) => ` --brand:${p.brand}; --brand-deep:${p.brandDeep}; --brand-bright:${p.brandBright}; --brand-rgb:${hexToRgb(p.brand)};
23
- --signal:${p.signal};
24
- --paper:${p.paper}; --surface:${p.surface}; --surface-2:${p.surface2}; --line:${p.line}; --line-strong:${p.lineStrong};
25
- --text:${p.text}; --text-soft:${p.textSoft}; --text-faint:${p.textFaint};
26
- --shadow:${p.shadowWeb};
27
- --shadow-lg:${p.shadowLgWeb};`;
28
-
29
- const themeCss = `${GEN_CSS}
30
- /* field-ops-theme — Field Ops Console design system (web).
31
- Drop-in token layer for any Bootstrap 5.3 app. Load order in <head>:
32
- bootstrap.min.css → field-ops-theme/theme.css → app.css
33
- Serve web/ same-origin (e.g. mount at /theme) so fonts satisfy CSP font-src 'self'. */
34
-
35
- /* ── Self-hosted fonts (relative URLs resolve under the mount, no CDN) ─────*/
36
- @font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
37
- src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
38
- @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
39
- src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
40
- @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
41
- src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
42
- @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
43
- src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
44
-
45
- /* ── Design tokens Ops Light in :root; Ops Dark keyed on [data-bs-theme] ───*/
46
- :root{
47
- --font-sans:${FONT_SANS};
48
- --font-mono:${FONT_MONO};
49
- ${cssTokens(T.palettes.light)}
50
- }
51
- [data-bs-theme="dark"]{
52
- ${cssTokens(T.palettes.dark)}
53
- }
54
-
55
- /* ── Map tokens onto Bootstrap (load AFTER bootstrap.min.css so these win) ───*/
56
- :root{
57
- --bs-body-font-family:var(--font-sans);
58
- --bs-primary:var(--brand); --bs-primary-rgb:var(--brand-rgb);
59
- --bs-link-color:var(--brand); --bs-link-color-rgb:var(--brand-rgb); --bs-link-hover-color:var(--brand-deep);
60
- --bs-body-bg:var(--paper); --bs-body-color:var(--text);
61
- --bs-border-color:var(--line);
62
- }
63
- [data-bs-theme="dark"]{
64
- --bs-primary:var(--brand); --bs-primary-rgb:var(--brand-rgb);
65
- --bs-link-color:var(--brand); --bs-link-color-rgb:var(--brand-rgb); --bs-link-hover-color:var(--brand-bright);
66
- --bs-body-bg:var(--paper); --bs-body-color:var(--text);
67
- --bs-border-color:var(--line);
68
- }
69
- body{font-family:var(--font-sans)}
70
- .mono,.tracking-id,code,kbd,.font-monospace{font-family:var(--font-mono)}
71
-
72
- /* ── Edge clarity — faint --line for internal dividers; --line-strong + shadow
73
- for container edges, so cards/inputs read as raised on white/paper. ────────*/
74
- .card{ border-color:var(--line-strong); box-shadow:var(--shadow); }
75
- .form-control,.form-select{ border-color:var(--line-strong); }
76
- .input-group-text{ border-color:var(--line-strong); }
77
- .table{ --bs-table-border-color:var(--line); }
78
- .list-group{ --bs-list-group-border-color:var(--line-strong); }
79
-
80
- /* ── Brand buttons (component vars compile into Bootstrap; set explicitly) ───*/
81
- .btn-primary{
82
- --bs-btn-bg:var(--brand); --bs-btn-border-color:var(--brand);
83
- --bs-btn-hover-bg:var(--brand-deep); --bs-btn-hover-border-color:var(--brand-deep);
84
- --bs-btn-active-bg:var(--brand-deep); --bs-btn-active-border-color:var(--brand-deep);
85
- --bs-btn-disabled-bg:var(--brand); --bs-btn-disabled-border-color:var(--brand);
86
- }
87
- .btn-outline-primary{
88
- --bs-btn-color:var(--brand); --bs-btn-border-color:var(--brand);
89
- --bs-btn-hover-bg:var(--brand); --bs-btn-hover-border-color:var(--brand);
90
- --bs-btn-active-bg:var(--brand); --bs-btn-active-border-color:var(--brand);
91
- }
92
-
93
- /* ── Accessibility ──────────────────────────────────────────────────────────*/
94
- a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
95
- textarea:focus-visible, [tabindex]:focus-visible {
96
- outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 6px;
97
- }
98
- @media (prefers-reduced-motion: reduce) {
99
- *, *::before, *::after {
100
- animation-duration: .001ms !important; animation-iteration-count: 1 !important;
101
- transition-duration: .001ms !important; scroll-behavior: auto !important;
102
- }
103
- }
104
- `;
105
-
106
- /* ── mobile/tokens.ts ─────────────────────────────────────────────────────── */
107
- const lc = (h) => h.toLowerCase();
108
- const rnPalette = (p) => `{
109
- bg: "${lc(p.paper)}",
110
- surface: "${lc(p.surface)}",
111
- surface2: "${lc(p.surface2)}",
112
- border: "${lc(p.line)}",
113
- lineStrong: "${lc(p.lineStrong)}",
114
- text: "${lc(p.text)}",
115
- muted: "${lc(p.textSoft)}",
116
- textFaint: "${lc(p.textFaint)}",
117
- primary: "${lc(p.brand)}",
118
- primaryText: "${lc(p.primaryText)}",
119
- brandDeep: "${lc(p.brandDeep)}",
120
- brandBright: "${lc(p.brandBright)}",
121
- danger: "${lc(p.danger)}",
122
- success: "${lc(p.success)}",
123
- signal: "${lc(p.signal)}",
124
- warnBg: "${lc(p.warnBg)}",
125
- warnText: "${lc(p.warnText)}",
126
- brandTint: "${p.brandTint}",
127
- signalTint: "${p.signalTint}",
128
- }`;
129
- const rnShadow = (s) => `{ shadowColor: "${lc(s.shadowColor)}", shadowOpacity: ${s.shadowOpacity}, shadowRadius: ${s.shadowRadius}, shadowOffset: { width: ${s.shadowOffset.width}, height: ${s.shadowOffset.height} }, elevation: ${s.elevation} }`;
130
- const rec = (o) => "{\n" + Object.entries(o).map(([k, v]) => ` ${k}: "${v}",`).join("\n") + "\n}";
131
-
132
- const tokensTs = `${GEN_TS}
133
- /**
134
- * Field Ops Console — shared visual tokens (React Native side).
135
- * \`palettes.light|dark\` are consumed by ThemeContext; \`colors\` is the light
136
- * default kept for back-compat with screens that import it directly.
137
- */
138
- export type Palette = {
139
- bg: string; surface: string; surface2: string; border: string; lineStrong: string;
140
- text: string; muted: string; textFaint: string;
141
- primary: string; primaryText: string; brandDeep: string; brandBright: string;
142
- danger: string; success: string; signal: string;
143
- warnBg: string; warnText: string;
144
- brandTint: string; signalTint: string;
145
- };
146
-
147
- const light: Palette = ${rnPalette(T.palettes.light)};
148
-
149
- const dark: Palette = ${rnPalette(T.palettes.dark)};
150
-
151
- export const palettes = { light, dark };
152
-
153
- /** Back-compat default (light). */
154
- export const colors = light;
155
-
156
- export const status: Record<string, string> = ${rec(T.status)};
157
-
158
- export const priority: Record<string, string> = ${rec(T.priority)};
159
-
160
- export const space = { ${Object.entries(T.space).map(([k, v]) => `${k}: ${v}`).join(", ")} };
161
-
162
- /** Subtle card elevation per scheme — RN has no box-shadow string, so this mirrors
163
- * the web \`--shadow\` token as style props (iOS shadow*; Android elevation). */
164
- export const shadow = {
165
- light: ${rnShadow(T.palettes.light.shadowRn)},
166
- dark: ${rnShadow(T.palettes.dark.shadowRn)},
167
- } as const;
168
- `;
169
-
170
- /* ── write ────────────────────────────────────────────────────────────────── */
171
- fs.writeFileSync(path.join(__dirname, "web", "theme.css"), themeCss);
172
- fs.writeFileSync(path.join(__dirname, "mobile", "tokens.ts"), tokensTs);
173
- console.log("field-ops-theme: generated web/theme.css + mobile/tokens.ts from tokens.json");
1
+ #!/usr/bin/env node
2
+ /* @aria-framework/theme generator. Reads tokens.json (single source of record) and emits
3
+ * - web/theme.css (Bootstrap 5.3 token layer + static mapping/edge/buttons/a11y)
4
+ * - mobile/tokens.ts (RN Palette + palettes/colors/status/priority/space/shadow)
5
+ * Run: `node build.js`. The two outputs are committed artifacts (consumers don't
6
+ * need to run this); regenerate whenever tokens.json changes, then bump the version. */
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const T = require("./tokens.json");
10
+
11
+ const GEN_CSS = "/* AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`. */";
12
+ const GEN_TS = "// AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`.";
13
+ const FONT_SANS = `'Hanken Grotesk', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
14
+ const FONT_MONO = `'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace`;
15
+
16
+ const hexToRgb = (hex) => {
17
+ const n = parseInt(hex.replace("#", ""), 16);
18
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255].join(",");
19
+ };
20
+
21
+ // Fail fast with a real message rather than a TypeError from inside a template.
22
+ for (const key of ["palettes", "sidebar", "status", "priority", "space"]) {
23
+ if (!T[key]) throw new Error(`tokens.json is missing the "${key}" section (required since theme 0.4.0)`);
24
+ }
25
+
26
+ /* ── web/theme.css ────────────────────────────────────────────────────────── */
27
+ const cssTokens = (p) => ` --brand:${p.brand}; --brand-deep:${p.brandDeep}; --brand-bright:${p.brandBright}; --brand-rgb:${hexToRgb(p.brand)};
28
+ --signal:${p.signal};
29
+ --paper:${p.paper}; --surface:${p.surface}; --surface-2:${p.surface2}; --line:${p.line}; --line-strong:${p.lineStrong};
30
+ --text:${p.text}; --text-soft:${p.textSoft}; --text-faint:${p.textFaint};
31
+ --shadow:${p.shadowWeb};
32
+ --shadow-lg:${p.shadowLgWeb};`;
33
+
34
+ const themeCss = `${GEN_CSS}
35
+ /* @aria-framework/theme Field Ops Console design system (web).
36
+ Drop-in token layer for any Bootstrap 5.3 app. Load order in <head>:
37
+ bootstrap.min.css → theme.css → app.css
38
+ Serve web/ same-origin (e.g. mount at /theme) so fonts satisfy CSP font-src 'self'. */
39
+
40
+ /* ── Design tokens — Ops Light in :root; Ops Dark keyed on [data-bs-theme] ───*/
41
+ :root{
42
+ --font-sans:${FONT_SANS};
43
+ --font-mono:${FONT_MONO};
44
+ ${cssTokens(T.palettes.light)}
45
+ /* Sidebar rail (scheme-independent: dark in both modes) */
46
+ --side-bg1:${T.sidebar.bg1}; --side-bg2:${T.sidebar.bg2}; --side-tx:${T.sidebar.tx}; --side-muted:${T.sidebar.muted};
47
+ --side-active:${T.sidebar.active}; --side-line:${T.sidebar.line}; --side-card:${T.sidebar.card};
48
+ }
49
+ [data-bs-theme="dark"]{
50
+ ${cssTokens(T.palettes.dark)}
51
+ }`;
52
+
53
+ /* ── mobile/tokens.ts ─────────────────────────────────────────────────────── */
54
+ const lc = (h) => h.toLowerCase();
55
+ const rnPalette = (p) => `{
56
+ bg: "${lc(p.paper)}",
57
+ surface: "${lc(p.surface)}",
58
+ surface2: "${lc(p.surface2)}",
59
+ border: "${lc(p.line)}",
60
+ lineStrong: "${lc(p.lineStrong)}",
61
+ text: "${lc(p.text)}",
62
+ muted: "${lc(p.textSoft)}",
63
+ textFaint: "${lc(p.textFaint)}",
64
+ primary: "${lc(p.brand)}",
65
+ primaryText: "${lc(p.primaryText)}",
66
+ brandDeep: "${lc(p.brandDeep)}",
67
+ brandBright: "${lc(p.brandBright)}",
68
+ danger: "${lc(p.danger)}",
69
+ success: "${lc(p.success)}",
70
+ signal: "${lc(p.signal)}",
71
+ warnBg: "${lc(p.warnBg)}",
72
+ warnText: "${lc(p.warnText)}",
73
+ brandTint: "${p.brandTint}",
74
+ signalTint: "${p.signalTint}",
75
+ }`;
76
+ const rnShadow = (s) => `{ shadowColor: "${lc(s.shadowColor)}", shadowOpacity: ${s.shadowOpacity}, shadowRadius: ${s.shadowRadius}, shadowOffset: { width: ${s.shadowOffset.width}, height: ${s.shadowOffset.height} }, elevation: ${s.elevation} }`;
77
+ const rec = (o) => "{\n" + Object.entries(o).map(([k, v]) => ` ${k}: "${v}",`).join("\n") + "\n}";
78
+
79
+ const tokensTs = `${GEN_TS}
80
+ /**
81
+ * Field Ops Console — shared visual tokens (React Native side).
82
+ * \`palettes.light|dark\` are consumed by ThemeContext; \`colors\` is the light
83
+ * default kept for back-compat with screens that import it directly.
84
+ */
85
+ export type Palette = {
86
+ bg: string; surface: string; surface2: string; border: string; lineStrong: string;
87
+ text: string; muted: string; textFaint: string;
88
+ primary: string; primaryText: string; brandDeep: string; brandBright: string;
89
+ danger: string; success: string; signal: string;
90
+ warnBg: string; warnText: string;
91
+ brandTint: string; signalTint: string;
92
+ };
93
+
94
+ const light: Palette = ${rnPalette(T.palettes.light)};
95
+
96
+ const dark: Palette = ${rnPalette(T.palettes.dark)};
97
+
98
+ export const palettes = { light, dark };
99
+
100
+ /** Back-compat default (light). */
101
+ export const colors = light;
102
+
103
+ export const status: Record<string, string> = ${rec(T.status)};
104
+
105
+ export const priority: Record<string, string> = ${rec(T.priority)};
106
+
107
+ export const space = { ${Object.entries(T.space).map(([k, v]) => `${k}: ${v}`).join(", ")} };
108
+
109
+ /** Subtle card elevation per scheme — RN has no box-shadow string, so this mirrors
110
+ * the web \`--shadow\` token as style props (iOS shadow*; Android elevation). */
111
+ export const shadow = {
112
+ light: ${rnShadow(T.palettes.light.shadowRn)},
113
+ dark: ${rnShadow(T.palettes.dark.shadowRn)},
114
+ } as const;
115
+ `;
116
+
117
+ /* ── assemble theme.css: generated tokens → src/base.css → src/components/* ──
118
+ base.css is the static layer (fonts, Bootstrap map, edge-clarity, buttons,
119
+ a11y). Each src/components/*.css is a self-contained component (markup
120
+ contract in the file header + README), appended in filename order so output
121
+ is deterministic. Everything references the generated tokens above. */
122
+ const baseCss = fs.readFileSync(path.join(__dirname, "src", "base.css"), "utf8").trimEnd();
123
+ const compDir = path.join(__dirname, "src", "components");
124
+ const components = fs.existsSync(compDir)
125
+ ? fs.readdirSync(compDir).filter((f) => f.endsWith(".css")).sort()
126
+ .map((f) => fs.readFileSync(path.join(compDir, f), "utf8").trimEnd())
127
+ : [];
128
+ const fullCss = [themeCss.trimEnd(), baseCss, ...components].join("\n\n") + "\n";
129
+
130
+ /* ── write ────────────────────────────────────────────────────────────────── */
131
+ fs.writeFileSync(path.join(__dirname, "web", "theme.css"), fullCss);
132
+ fs.writeFileSync(path.join(__dirname, "mobile", "tokens.ts"), tokensTs);
133
+ console.log(`@aria-framework/theme: generated web/theme.css (+${components.length} component(s)) + mobile/tokens.ts from tokens.json`);
@@ -16,7 +16,7 @@ const react_native_1 = require("react-native");
16
16
  const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
17
17
  const tokens_1 = require("./tokens");
18
18
  /** Default AsyncStorage key. Override per-app via <ThemeProvider storageKey="myapp.theme">
19
- * so multiple field-ops-theme apps on a device don't share one preference. */
19
+ * so multiple aria-framework apps on a device don't share one preference. */
20
20
  const DEFAULT_KEY = "field-ops.theme";
21
21
  const Ctx = (0, react_1.createContext)(undefined);
22
22
  function ThemeProvider({ children, storageKey = DEFAULT_KEY }) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** field-ops-theme (mobile) — barrel re-export for React Native consumers. */
1
+ /** @aria-framework/theme (mobile) — barrel re-export for React Native consumers. */
2
2
  export * from "./tokens";
3
3
  export * from "./fonts";
4
4
  export * from "./ThemeContext";
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- /** field-ops-theme (mobile) — barrel re-export for React Native consumers. */
17
+ /** @aria-framework/theme (mobile) — barrel re-export for React Native consumers. */
18
18
  __exportStar(require("./tokens"), exports);
19
19
  __exportStar(require("./fonts"), exports);
20
20
  __exportStar(require("./ThemeContext"), exports);
@@ -1,59 +1,59 @@
1
- /**
2
- * Theme provider — resolves the active palette (Ops Light / Ops Dark) from the
3
- * user's preference (light | dark | system) and the OS appearance, and persists
4
- * the choice in AsyncStorage. Screens read `const { colors } = useTheme()`.
5
- */
6
- import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
7
- import { Appearance } from "react-native";
8
- import AsyncStorage from "@react-native-async-storage/async-storage";
9
- import { palettes, type Palette } from "./tokens";
10
-
11
- export type ThemePref = "light" | "dark" | "system";
12
- type Scheme = "light" | "dark";
13
-
14
- /** Default AsyncStorage key. Override per-app via <ThemeProvider storageKey="myapp.theme">
15
- * so multiple field-ops-theme apps on a device don't share one preference. */
16
- const DEFAULT_KEY = "field-ops.theme";
17
-
18
- interface ThemeState {
19
- colors: Palette;
20
- scheme: Scheme; // resolved (system → actual OS scheme)
21
- pref: ThemePref; // user's stored choice
22
- setPref: (p: ThemePref) => void;
23
- }
24
-
25
- const Ctx = createContext<ThemeState | undefined>(undefined);
26
-
27
- export function ThemeProvider({ children, storageKey = DEFAULT_KEY }: { children: React.ReactNode; storageKey?: string }) {
28
- const [pref, setPrefState] = useState<ThemePref>("system");
29
- const [sysScheme, setSysScheme] = useState<Scheme>(Appearance.getColorScheme() === "dark" ? "dark" : "light");
30
-
31
- // OS appearance listener — independent of storageKey, so subscribe exactly once.
32
- useEffect(() => {
33
- const sub = Appearance.addChangeListener(({ colorScheme }) => setSysScheme(colorScheme === "dark" ? "dark" : "light"));
34
- return () => sub.remove();
35
- }, []);
36
-
37
- // Load the persisted preference (re-reads only if the storage key changes).
38
- useEffect(() => {
39
- AsyncStorage.getItem(storageKey).then((v) => {
40
- if (v === "light" || v === "dark" || v === "system") setPrefState(v);
41
- }).catch(() => {});
42
- }, [storageKey]);
43
-
44
- const setPref = useCallback((p: ThemePref) => {
45
- setPrefState(p);
46
- AsyncStorage.setItem(storageKey, p).catch(() => {});
47
- }, [storageKey]);
48
-
49
- const scheme: Scheme = pref === "system" ? sysScheme : pref;
50
- const value = useMemo<ThemeState>(() => ({ colors: palettes[scheme], scheme, pref, setPref }), [scheme, pref, setPref]);
51
-
52
- return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
53
- }
54
-
55
- export function useTheme(): ThemeState {
56
- const v = useContext(Ctx);
57
- if (!v) throw new Error("useTheme must be used within ThemeProvider");
58
- return v;
59
- }
1
+ /**
2
+ * Theme provider — resolves the active palette (Ops Light / Ops Dark) from the
3
+ * user's preference (light | dark | system) and the OS appearance, and persists
4
+ * the choice in AsyncStorage. Screens read `const { colors } = useTheme()`.
5
+ */
6
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
7
+ import { Appearance } from "react-native";
8
+ import AsyncStorage from "@react-native-async-storage/async-storage";
9
+ import { palettes, type Palette } from "./tokens";
10
+
11
+ export type ThemePref = "light" | "dark" | "system";
12
+ type Scheme = "light" | "dark";
13
+
14
+ /** Default AsyncStorage key. Override per-app via <ThemeProvider storageKey="myapp.theme">
15
+ * so multiple aria-framework apps on a device don't share one preference. */
16
+ const DEFAULT_KEY = "field-ops.theme";
17
+
18
+ interface ThemeState {
19
+ colors: Palette;
20
+ scheme: Scheme; // resolved (system → actual OS scheme)
21
+ pref: ThemePref; // user's stored choice
22
+ setPref: (p: ThemePref) => void;
23
+ }
24
+
25
+ const Ctx = createContext<ThemeState | undefined>(undefined);
26
+
27
+ export function ThemeProvider({ children, storageKey = DEFAULT_KEY }: { children: React.ReactNode; storageKey?: string }) {
28
+ const [pref, setPrefState] = useState<ThemePref>("system");
29
+ const [sysScheme, setSysScheme] = useState<Scheme>(Appearance.getColorScheme() === "dark" ? "dark" : "light");
30
+
31
+ // OS appearance listener — independent of storageKey, so subscribe exactly once.
32
+ useEffect(() => {
33
+ const sub = Appearance.addChangeListener(({ colorScheme }) => setSysScheme(colorScheme === "dark" ? "dark" : "light"));
34
+ return () => sub.remove();
35
+ }, []);
36
+
37
+ // Load the persisted preference (re-reads only if the storage key changes).
38
+ useEffect(() => {
39
+ AsyncStorage.getItem(storageKey).then((v) => {
40
+ if (v === "light" || v === "dark" || v === "system") setPrefState(v);
41
+ }).catch(() => {});
42
+ }, [storageKey]);
43
+
44
+ const setPref = useCallback((p: ThemePref) => {
45
+ setPrefState(p);
46
+ AsyncStorage.setItem(storageKey, p).catch(() => {});
47
+ }, [storageKey]);
48
+
49
+ const scheme: Scheme = pref === "system" ? sysScheme : pref;
50
+ const value = useMemo<ThemeState>(() => ({ colors: palettes[scheme], scheme, pref, setPref }), [scheme, pref, setPref]);
51
+
52
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
53
+ }
54
+
55
+ export function useTheme(): ThemeState {
56
+ const v = useContext(Ctx);
57
+ if (!v) throw new Error("useTheme must be used within ThemeProvider");
58
+ return v;
59
+ }
package/mobile/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- /** field-ops-theme (mobile) — barrel re-export for React Native consumers. */
2
- export * from "./tokens";
3
- export * from "./fonts";
4
- export * from "./ThemeContext";
5
- export * from "./ui";
1
+ /** @aria-framework/theme (mobile) — barrel re-export for React Native consumers. */
2
+ export * from "./tokens";
3
+ export * from "./fonts";
4
+ export * from "./ThemeContext";
5
+ export * from "./ui";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aria-framework/theme",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "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.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,6 +17,7 @@
17
17
  "web",
18
18
  "dist",
19
19
  "mobile",
20
+ "src",
20
21
  "tokens.json",
21
22
  "build.js",
22
23
  "tsconfig.json",
@@ -24,6 +25,7 @@
24
25
  ],
25
26
  "exports": {
26
27
  "./theme.css": "./web/theme.css",
28
+ "./sidebar.js": "./web/sidebar.js",
27
29
  "./mobile": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
28
30
  "./tokens": { "types": "./dist/tokens.d.ts", "default": "./dist/tokens.js" },
29
31
  "./fonts": { "types": "./dist/fonts.d.ts", "default": "./dist/fonts.js" },
package/src/base.css ADDED
@@ -0,0 +1,63 @@
1
+ /* ── Base — static layer (fonts, Bootstrap mapping, edge-clarity, buttons, a11y).
2
+ Concatenated by build.js AFTER the generated token blocks and BEFORE the
3
+ component partials in src/components/. Token values come from tokens.json. */
4
+
5
+ /* ── Self-hosted fonts (relative URLs → resolve under the mount, no CDN) ─────*/
6
+ @font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
7
+ src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
8
+ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
9
+ src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
10
+ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
11
+ src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
12
+ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
13
+ src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
14
+
15
+ /* ── Map tokens onto Bootstrap (load AFTER bootstrap.min.css so these win) ───*/
16
+ :root{
17
+ --bs-body-font-family:var(--font-sans);
18
+ --bs-primary:var(--brand); --bs-primary-rgb:var(--brand-rgb);
19
+ --bs-link-color:var(--brand); --bs-link-color-rgb:var(--brand-rgb); --bs-link-hover-color:var(--brand-deep);
20
+ --bs-body-bg:var(--paper); --bs-body-color:var(--text);
21
+ --bs-border-color:var(--line);
22
+ }
23
+ [data-bs-theme="dark"]{
24
+ --bs-primary:var(--brand); --bs-primary-rgb:var(--brand-rgb);
25
+ --bs-link-color:var(--brand); --bs-link-color-rgb:var(--brand-rgb); --bs-link-hover-color:var(--brand-bright);
26
+ --bs-body-bg:var(--paper); --bs-body-color:var(--text);
27
+ --bs-border-color:var(--line);
28
+ }
29
+ body{font-family:var(--font-sans)}
30
+ .mono,.tracking-id,code,kbd,.font-monospace{font-family:var(--font-mono)}
31
+
32
+ /* ── Edge clarity — faint --line for internal dividers; --line-strong + shadow
33
+ for container edges, so cards/inputs read as raised on white/paper. ────────*/
34
+ .card{ border-color:var(--line-strong); box-shadow:var(--shadow); }
35
+ .form-control,.form-select{ border-color:var(--line-strong); }
36
+ .input-group-text{ border-color:var(--line-strong); }
37
+ .table{ --bs-table-border-color:var(--line); }
38
+ .list-group{ --bs-list-group-border-color:var(--line-strong); }
39
+
40
+ /* ── Brand buttons (component vars compile into Bootstrap; set explicitly) ───*/
41
+ .btn-primary{
42
+ --bs-btn-bg:var(--brand); --bs-btn-border-color:var(--brand);
43
+ --bs-btn-hover-bg:var(--brand-deep); --bs-btn-hover-border-color:var(--brand-deep);
44
+ --bs-btn-active-bg:var(--brand-deep); --bs-btn-active-border-color:var(--brand-deep);
45
+ --bs-btn-disabled-bg:var(--brand); --bs-btn-disabled-border-color:var(--brand);
46
+ }
47
+ .btn-outline-primary{
48
+ --bs-btn-color:var(--brand); --bs-btn-border-color:var(--brand);
49
+ --bs-btn-hover-bg:var(--brand); --bs-btn-hover-border-color:var(--brand);
50
+ --bs-btn-active-bg:var(--brand); --bs-btn-active-border-color:var(--brand);
51
+ }
52
+
53
+ /* ── Accessibility ──────────────────────────────────────────────────────────*/
54
+ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
55
+ textarea:focus-visible, [tabindex]:focus-visible {
56
+ outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 6px;
57
+ }
58
+ @media (prefers-reduced-motion: reduce) {
59
+ *, *::before, *::after {
60
+ animation-duration: .001ms !important; animation-iteration-count: 1 !important;
61
+ transition-duration: .001ms !important; scroll-behavior: auto !important;
62
+ }
63
+ }
@@ -0,0 +1,116 @@
1
+ /* ── Component: sidebar rail (.s-side) ───────────────────────────────────────
2
+ Dark nav rail, mobile-first: off-canvas overlay by default; the ≥768px block
3
+ puts it back in flow as the desktop rail with a collapsible icon-rail mode.
4
+ Behavior lives in sidebar.js (same package, served beside theme.css).
5
+
6
+ MARKUP CONTRACT (the app renders this; see package README):
7
+ #wrapper > .s-side + #page-content-wrapper
8
+ .s-side > .s-brand (.s-brand-link > .s-mark + span(.s-name+.s-ver), .s-collapse[data-sidebar-toggle])
9
+ + .s-nav (.s-grp headings, .s-link[.active] items: <i> icon + <span> label)
10
+ + .s-me (.s-av, .s-me-who > b+span, .s-out-form > .s-out)
11
+ Topbar hamburger (mobile): .topbar-toggle[data-sidebar-toggle]
12
+ State classes on <html>: .sidebar-open (mobile overlay), .sidebar-collapsed
13
+ (desktop rail; persist + pre-apply before paint to avoid a flash). */
14
+
15
+ /* Body itself doesn't scroll; sidebar nav + content scroll independently. */
16
+ #wrapper { height: 100vh; overflow: hidden; }
17
+ #page-content-wrapper { height: 100vh; overflow-y: auto; }
18
+
19
+ /* Mobile-first: the sidebar is an off-canvas overlay by default; the
20
+ @media(min-width:768px) block below puts it back in flow as the desktop rail.
21
+ One breakpoint, mobile-default + desktop-override — the two layers meet with
22
+ no gap. sidebar.js reads the current mode from CSS (backdrop display). */
23
+ .s-side{
24
+ position:fixed; top:0; left:0; z-index:1050;
25
+ min-width:236px; width:236px; height:100vh; display:flex; flex-direction:column; overflow:hidden;
26
+ background:linear-gradient(180deg, var(--side-bg1), var(--side-bg2));
27
+ color:var(--side-tx); border-right:1px solid var(--side-line);
28
+ transform:translateX(-100%); transition:transform .2s ease;
29
+ }
30
+ html.sidebar-open .s-side{ transform:translateX(0); box-shadow:0 24px 60px rgba(0,0,0,.5); }
31
+ #sidebar-backdrop{ position:fixed; inset:0; z-index:1040; background:rgba(0,0,0,.45); opacity:0; visibility:hidden; transition:opacity .2s ease; }
32
+ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
33
+ .s-brand{ display:flex; align-items:center; padding:16px 14px 12px; }
34
+ .s-brand-link{ display:flex; align-items:center; gap:11px; text-decoration:none; }
35
+ .s-mark{
36
+ width:34px; height:34px; border-radius:9px; flex:0 0 34px; display:grid; place-items:center;
37
+ background:linear-gradient(140deg, var(--brand-bright), var(--brand-deep)); color:#fff; font-weight:800; font-size:17px;
38
+ box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 6px 16px -6px var(--brand);
39
+ }
40
+ .s-name{ display:block; color:#fff; font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
41
+ .s-ver{ display:block; font-family:var(--font-mono); font-size:11px; color:var(--side-muted); margin-top:3px; }
42
+
43
+ .s-nav{ flex:1; overflow-y:auto; padding:4px 12px 12px; }
44
+ .s-nav::-webkit-scrollbar{ width:7px; } .s-nav::-webkit-scrollbar-thumb{ background:var(--side-card); border-radius:8px; }
45
+ .s-grp{
46
+ font-family:var(--font-mono); font-size:10.5px; letter-spacing:.14em; text-transform:uppercase;
47
+ color:var(--side-muted); padding:16px 12px 7px;
48
+ }
49
+ .s-link{
50
+ display:flex; align-items:center; gap:10px; padding:8px 11px; border-radius:9px; position:relative;
51
+ color:var(--side-tx); text-decoration:none; font-weight:500; font-size:14px; transition:background .14s, color .14s;
52
+ white-space:nowrap;
53
+ }
54
+ .s-link i{ font-size:16px; width:18px; text-align:center; opacity:.85; }
55
+ .s-link:hover{ background:var(--side-card); color:#fff; }
56
+ .s-link.active{ background:var(--side-active); color:#fff; }
57
+ .s-link.active::before{ content:""; position:absolute; left:-12px; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:var(--brand-bright); }
58
+ .s-link.active i{ opacity:1; color:var(--brand-bright); }
59
+
60
+ .s-me{
61
+ margin:10px 12px 14px; padding:10px 12px; border-radius:11px; display:flex; align-items:center; gap:10px;
62
+ background:var(--side-card); border:1px solid var(--side-line);
63
+ }
64
+ .s-av{
65
+ width:34px; height:34px; border-radius:8px; flex:0 0 34px; display:grid; place-items:center;
66
+ background:linear-gradient(140deg, var(--brand), var(--brand-deep)); color:#fff; font-weight:700; font-size:13px;
67
+ }
68
+ .s-me-who{ min-width:0; line-height:1.25; flex:1; }
69
+ .s-me-who b{ display:block; color:#fff; font-size:13.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
70
+ .s-me-who span{ font-family:var(--font-mono); font-size:10.5px; color:var(--side-muted); text-transform:uppercase; letter-spacing:.06em; }
71
+ .s-out-form{ margin:0; }
72
+ .s-out{ border:0; background:transparent; color:var(--side-muted); font-size:16px; line-height:1; padding:4px; cursor:pointer; }
73
+ .s-out:hover{ color:#fff; }
74
+
75
+ /* Collapse toggle — the sidebar-header chevron (shown on desktop) and the
76
+ topbar hamburger (shown on mobile, opens the overlay). */
77
+ .s-collapse{
78
+ flex:0 0 auto; margin-left:auto; width:28px; height:28px; padding:0; border:0; cursor:pointer;
79
+ background:transparent; color:var(--side-muted); border-radius:7px; display:none; place-items:center; font-size:15px;
80
+ }
81
+ .s-collapse:hover{ background:var(--side-card); color:#fff; }
82
+ .s-collapse i{ display:block; transition:transform .18s ease; } /* rotate the <i> (centre origin) — no SVG-pivot bug */
83
+ html.sidebar-collapsed .s-collapse i{ transform:rotate(180deg); } /* points right = expand */
84
+
85
+ /* Brighter keyboard-focus ring inside the dark rail — the base a11y rule uses
86
+ var(--brand), which is near-invisible against the rail background. */
87
+ .s-link:focus-visible, .s-out:focus-visible, .s-brand-link:focus-visible, .s-collapse:focus-visible { outline-color: var(--brand-bright); }
88
+
89
+ .topbar-toggle{
90
+ flex:0 0 auto; width:38px; height:38px; border-radius:10px; border:1px solid var(--line);
91
+ background:var(--surface); color:var(--text-soft); display:grid; place-items:center;
92
+ font-size:18px; cursor:pointer; padding:0;
93
+ }
94
+ .topbar-toggle:hover{ color:var(--text); }
95
+
96
+ /* ── Desktop (≥768px): in-flow sidebar, header chevron, collapsed icon rail ──*/
97
+ @media (min-width: 768px){
98
+ .s-side{ position:static; transform:none; box-shadow:none; transition:width .18s ease, min-width .18s ease; }
99
+ #sidebar-backdrop{ display:none; }
100
+ .topbar-toggle{ display:none; } /* desktop uses the header chevron */
101
+ .s-collapse{ display:grid; } /* chevron shown only on desktop */
102
+
103
+ html.sidebar-collapsed .s-side{ min-width:64px; width:64px; }
104
+ /* Stack the logo + collapse chevron so the chevron stays reachable to expand. */
105
+ html.sidebar-collapsed .s-brand{ flex-direction:column; gap:9px; padding:16px 0 12px; }
106
+ html.sidebar-collapsed .s-brand-link{ justify-content:center; gap:0; }
107
+ html.sidebar-collapsed .s-brand-link > span:last-child{ display:none; }
108
+ html.sidebar-collapsed .s-collapse{ margin-left:0; }
109
+ html.sidebar-collapsed .s-nav{ padding:4px 8px 12px; }
110
+ html.sidebar-collapsed .s-grp{ display:none; }
111
+ html.sidebar-collapsed .s-link{ justify-content:center; gap:0; padding-left:0; padding-right:0; }
112
+ html.sidebar-collapsed .s-link span{ display:none; }
113
+ html.sidebar-collapsed .s-link.active::before{ display:none; }
114
+ html.sidebar-collapsed .s-me{ flex-direction:column; gap:8px; padding:10px 6px; margin:10px 8px 14px; }
115
+ html.sidebar-collapsed .s-me-who{ display:none; }
116
+ }
package/tokens.json CHANGED
@@ -32,5 +32,13 @@
32
32
  },
33
33
  "status": { "new": "#2563eb", "open": "#0e8fa8", "pending": "#d9870b", "resolved": "#0e9e6e", "closed": "#7a848b" },
34
34
  "priority": { "low": "#9aa6ae", "medium": "#2563eb", "high": "#ea580c", "critical": "#dc2626" },
35
- "space": { "xs": 4, "sm": 8, "md": 12, "lg": 16, "xl": 24 }
35
+ "space": { "xs": 4, "sm": 8, "md": 12, "lg": 16, "xl": 24 },
36
+ "$sidebarComment": "Sidebar rail tokens (web sidebar component) — scheme-INDEPENDENT: the rail is dark in both light and dark modes, so these emit into :root only, as --side-*.",
37
+ "sidebar": {
38
+ "bg1": "#0E1417", "bg2": "#141C21",
39
+ "tx": "#BAC3C8", "muted": "#69767D",
40
+ "active": "rgba(21,184,166,.13)",
41
+ "line": "rgba(255,255,255,.07)",
42
+ "card": "rgba(255,255,255,.045)"
43
+ }
36
44
  }
package/web/sidebar.js ADDED
@@ -0,0 +1,109 @@
1
+ /*
2
+ * @aria-framework/theme — sidebar.js: collapse / expand the app sidebar rail.
3
+ *
4
+ * Desktop: the sidebar-header chevron collapses the sidebar to an icon rail and
5
+ * persists the choice (apps should pre-apply the class before paint — read the
6
+ * same key in an early-loaded script — so a collapsed sidebar doesn't flash
7
+ * open on load). Mobile: the topbar hamburger slides the off-canvas sidebar in
8
+ * as an overlay (transient).
9
+ *
10
+ * Storage key is configurable per app via the script tag:
11
+ * <script src="/theme/sidebar.js" data-storage-key="myapp-sidebar-collapsed"></script>
12
+ * (default "aria-sidebar-collapsed"). Pass your app's historical key to keep
13
+ * users' persisted collapsed state across a migration to this package.
14
+ *
15
+ * Safe to include anywhere (head or body, with or without defer): init waits
16
+ * for DOMContentLoaded when needed, and clicks are handled by delegation, so
17
+ * toggles/nav links added after load still work.
18
+ *
19
+ * Which layout we're in is read FROM CSS (the backdrop's computed display), so
20
+ * the breakpoint lives only in the stylesheet — JS never restates 768px and the
21
+ * two layers can't drift. CSP-safe: external, self-hosted, no inline script.
22
+ */
23
+ (function () {
24
+ // currentScript is only set during initial execution — capture before deferring.
25
+ var script = document.currentScript;
26
+ var KEY = (script && script.dataset && script.dataset.storageKey) || 'aria-sidebar-collapsed';
27
+
28
+ function init() {
29
+ var root = document.documentElement;
30
+
31
+ // Backdrop for the mobile overlay (created once). Its computed display is
32
+ // our single source of truth for "mobile layout" — `block` only inside the
33
+ // mobile media query, `none` on desktop.
34
+ var backdrop = document.getElementById('sidebar-backdrop');
35
+ if (!backdrop) {
36
+ backdrop = document.createElement('div');
37
+ backdrop.id = 'sidebar-backdrop';
38
+ document.body.appendChild(backdrop);
39
+ }
40
+ function isMobile() { return getComputedStyle(backdrop).display !== 'none'; }
41
+
42
+ var content = document.getElementById('page-content-wrapper'); // inert while overlay is open
43
+ var lastFocus = null;
44
+
45
+ // Reflect state to assistive tech: the header chevron announces rail
46
+ // expanded/collapsed; the topbar hamburger announces overlay open/closed.
47
+ // Buttons are queried live so late-added toggles stay in sync.
48
+ function syncAria() {
49
+ var collapsed = root.classList.contains('sidebar-collapsed');
50
+ var open = root.classList.contains('sidebar-open');
51
+ document.querySelectorAll('[data-sidebar-toggle]').forEach(function (b) {
52
+ b.setAttribute('aria-expanded', String(b.classList.contains('s-collapse') ? !collapsed : open));
53
+ });
54
+ }
55
+
56
+ // `opener` is the button that opened the overlay — restore focus to it on
57
+ // close (activeElement isn't reliable: some browsers don't focus a button
58
+ // on mouse click). Make the background inert so Tab can't escape the overlay.
59
+ function openOverlay(opener) {
60
+ lastFocus = opener || document.activeElement;
61
+ root.classList.add('sidebar-open');
62
+ if (content) content.inert = true;
63
+ var first = document.querySelector('.s-nav .s-link');
64
+ if (first) first.focus(); // move focus into the opened menu
65
+ syncAria();
66
+ }
67
+ function closeOverlay(restoreFocus) {
68
+ root.classList.remove('sidebar-open');
69
+ if (content) content.inert = false; // un-inert before restoring focus into it
70
+ if (restoreFocus && lastFocus && lastFocus.focus) lastFocus.focus();
71
+ lastFocus = null;
72
+ syncAria();
73
+ }
74
+
75
+ function toggle(opener) {
76
+ if (isMobile()) {
77
+ if (root.classList.contains('sidebar-open')) closeOverlay(true); else openOverlay(opener);
78
+ } else {
79
+ var collapsed = root.classList.toggle('sidebar-collapsed');
80
+ try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* storage off */ }
81
+ syncAria();
82
+ }
83
+ }
84
+
85
+ // Delegated clicks: toggles work wherever/whenever they're added; tapping a
86
+ // nav link on mobile closes the overlay (it also navigates — no focus to
87
+ // restore). Backdrop tap and Escape close it too.
88
+ document.addEventListener('click', function (e) {
89
+ var t = e.target.closest && e.target.closest('[data-sidebar-toggle]');
90
+ if (t) { toggle(t); return; }
91
+ if (e.target === backdrop) { closeOverlay(true); return; }
92
+ if (isMobile() && e.target.closest && e.target.closest('.s-nav .s-link')) closeOverlay(false);
93
+ });
94
+ document.addEventListener('keydown', function (e) {
95
+ if (e.key === 'Escape' && root.classList.contains('sidebar-open')) closeOverlay(true);
96
+ });
97
+
98
+ // Growing back to desktop clears any transient mobile-open state. Cheap
99
+ // guard first, so getComputedStyle only runs while an overlay is open.
100
+ window.addEventListener('resize', function () {
101
+ if (root.classList.contains('sidebar-open') && !isMobile()) closeOverlay(false);
102
+ });
103
+
104
+ syncAria(); // initial state (the app's early boot script may pre-set sidebar-collapsed)
105
+ }
106
+
107
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
108
+ else init();
109
+ })();
package/web/theme.css CHANGED
@@ -1,19 +1,9 @@
1
1
  /* AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`. */
2
- /* field-ops-theme — Field Ops Console design system (web).
2
+ /* @aria-framework/theme — Field Ops Console design system (web).
3
3
  Drop-in token layer for any Bootstrap 5.3 app. Load order in <head>:
4
- bootstrap.min.css → field-ops-theme/theme.css → app.css
4
+ bootstrap.min.css → theme.css → app.css
5
5
  Serve web/ same-origin (e.g. mount at /theme) so fonts satisfy CSP font-src 'self'. */
6
6
 
7
- /* ── Self-hosted fonts (relative URLs → resolve under the mount, no CDN) ─────*/
8
- @font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
9
- src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
10
- @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
11
- src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
12
- @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
13
- src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
14
- @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
15
- src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
16
-
17
7
  /* ── Design tokens — Ops Light in :root; Ops Dark keyed on [data-bs-theme] ───*/
18
8
  :root{
19
9
  --font-sans:'Hanken Grotesk', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -24,6 +14,9 @@
24
14
  --text:#18222A; --text-soft:#5C6770; --text-faint:#929BA1;
25
15
  --shadow:0 1px 2px rgba(16,24,28,.05), 0 4px 14px -6px rgba(16,24,28,.12);
26
16
  --shadow-lg:0 18px 50px -18px rgba(16,24,28,.30);
17
+ /* Sidebar rail (scheme-independent: dark in both modes) */
18
+ --side-bg1:#0E1417; --side-bg2:#141C21; --side-tx:#BAC3C8; --side-muted:#69767D;
19
+ --side-active:rgba(21,184,166,.13); --side-line:rgba(255,255,255,.07); --side-card:rgba(255,255,255,.045);
27
20
  }
28
21
  [data-bs-theme="dark"]{
29
22
  --brand:#16B5A3; --brand-deep:#0E7A6E; --brand-bright:#2FE0CC; --brand-rgb:22,181,163;
@@ -34,6 +27,20 @@
34
27
  --shadow-lg:0 24px 60px -18px rgba(0,0,0,.8);
35
28
  }
36
29
 
30
+ /* ── Base — static layer (fonts, Bootstrap mapping, edge-clarity, buttons, a11y).
31
+ Concatenated by build.js AFTER the generated token blocks and BEFORE the
32
+ component partials in src/components/. Token values come from tokens.json. */
33
+
34
+ /* ── Self-hosted fonts (relative URLs → resolve under the mount, no CDN) ─────*/
35
+ @font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
36
+ src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
37
+ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
38
+ src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
39
+ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
40
+ src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
41
+ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
42
+ src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
43
+
37
44
  /* ── Map tokens onto Bootstrap (load AFTER bootstrap.min.css so these win) ───*/
38
45
  :root{
39
46
  --bs-body-font-family:var(--font-sans);
@@ -83,3 +90,120 @@ textarea:focus-visible, [tabindex]:focus-visible {
83
90
  transition-duration: .001ms !important; scroll-behavior: auto !important;
84
91
  }
85
92
  }
93
+
94
+ /* ── Component: sidebar rail (.s-side) ───────────────────────────────────────
95
+ Dark nav rail, mobile-first: off-canvas overlay by default; the ≥768px block
96
+ puts it back in flow as the desktop rail with a collapsible icon-rail mode.
97
+ Behavior lives in sidebar.js (same package, served beside theme.css).
98
+
99
+ MARKUP CONTRACT (the app renders this; see package README):
100
+ #wrapper > .s-side + #page-content-wrapper
101
+ .s-side > .s-brand (.s-brand-link > .s-mark + span(.s-name+.s-ver), .s-collapse[data-sidebar-toggle])
102
+ + .s-nav (.s-grp headings, .s-link[.active] items: <i> icon + <span> label)
103
+ + .s-me (.s-av, .s-me-who > b+span, .s-out-form > .s-out)
104
+ Topbar hamburger (mobile): .topbar-toggle[data-sidebar-toggle]
105
+ State classes on <html>: .sidebar-open (mobile overlay), .sidebar-collapsed
106
+ (desktop rail; persist + pre-apply before paint to avoid a flash). */
107
+
108
+ /* Body itself doesn't scroll; sidebar nav + content scroll independently. */
109
+ #wrapper { height: 100vh; overflow: hidden; }
110
+ #page-content-wrapper { height: 100vh; overflow-y: auto; }
111
+
112
+ /* Mobile-first: the sidebar is an off-canvas overlay by default; the
113
+ @media(min-width:768px) block below puts it back in flow as the desktop rail.
114
+ One breakpoint, mobile-default + desktop-override — the two layers meet with
115
+ no gap. sidebar.js reads the current mode from CSS (backdrop display). */
116
+ .s-side{
117
+ position:fixed; top:0; left:0; z-index:1050;
118
+ min-width:236px; width:236px; height:100vh; display:flex; flex-direction:column; overflow:hidden;
119
+ background:linear-gradient(180deg, var(--side-bg1), var(--side-bg2));
120
+ color:var(--side-tx); border-right:1px solid var(--side-line);
121
+ transform:translateX(-100%); transition:transform .2s ease;
122
+ }
123
+ html.sidebar-open .s-side{ transform:translateX(0); box-shadow:0 24px 60px rgba(0,0,0,.5); }
124
+ #sidebar-backdrop{ position:fixed; inset:0; z-index:1040; background:rgba(0,0,0,.45); opacity:0; visibility:hidden; transition:opacity .2s ease; }
125
+ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
126
+ .s-brand{ display:flex; align-items:center; padding:16px 14px 12px; }
127
+ .s-brand-link{ display:flex; align-items:center; gap:11px; text-decoration:none; }
128
+ .s-mark{
129
+ width:34px; height:34px; border-radius:9px; flex:0 0 34px; display:grid; place-items:center;
130
+ background:linear-gradient(140deg, var(--brand-bright), var(--brand-deep)); color:#fff; font-weight:800; font-size:17px;
131
+ box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 6px 16px -6px var(--brand);
132
+ }
133
+ .s-name{ display:block; color:#fff; font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
134
+ .s-ver{ display:block; font-family:var(--font-mono); font-size:11px; color:var(--side-muted); margin-top:3px; }
135
+
136
+ .s-nav{ flex:1; overflow-y:auto; padding:4px 12px 12px; }
137
+ .s-nav::-webkit-scrollbar{ width:7px; } .s-nav::-webkit-scrollbar-thumb{ background:var(--side-card); border-radius:8px; }
138
+ .s-grp{
139
+ font-family:var(--font-mono); font-size:10.5px; letter-spacing:.14em; text-transform:uppercase;
140
+ color:var(--side-muted); padding:16px 12px 7px;
141
+ }
142
+ .s-link{
143
+ display:flex; align-items:center; gap:10px; padding:8px 11px; border-radius:9px; position:relative;
144
+ color:var(--side-tx); text-decoration:none; font-weight:500; font-size:14px; transition:background .14s, color .14s;
145
+ white-space:nowrap;
146
+ }
147
+ .s-link i{ font-size:16px; width:18px; text-align:center; opacity:.85; }
148
+ .s-link:hover{ background:var(--side-card); color:#fff; }
149
+ .s-link.active{ background:var(--side-active); color:#fff; }
150
+ .s-link.active::before{ content:""; position:absolute; left:-12px; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:var(--brand-bright); }
151
+ .s-link.active i{ opacity:1; color:var(--brand-bright); }
152
+
153
+ .s-me{
154
+ margin:10px 12px 14px; padding:10px 12px; border-radius:11px; display:flex; align-items:center; gap:10px;
155
+ background:var(--side-card); border:1px solid var(--side-line);
156
+ }
157
+ .s-av{
158
+ width:34px; height:34px; border-radius:8px; flex:0 0 34px; display:grid; place-items:center;
159
+ background:linear-gradient(140deg, var(--brand), var(--brand-deep)); color:#fff; font-weight:700; font-size:13px;
160
+ }
161
+ .s-me-who{ min-width:0; line-height:1.25; flex:1; }
162
+ .s-me-who b{ display:block; color:#fff; font-size:13.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
163
+ .s-me-who span{ font-family:var(--font-mono); font-size:10.5px; color:var(--side-muted); text-transform:uppercase; letter-spacing:.06em; }
164
+ .s-out-form{ margin:0; }
165
+ .s-out{ border:0; background:transparent; color:var(--side-muted); font-size:16px; line-height:1; padding:4px; cursor:pointer; }
166
+ .s-out:hover{ color:#fff; }
167
+
168
+ /* Collapse toggle — the sidebar-header chevron (shown on desktop) and the
169
+ topbar hamburger (shown on mobile, opens the overlay). */
170
+ .s-collapse{
171
+ flex:0 0 auto; margin-left:auto; width:28px; height:28px; padding:0; border:0; cursor:pointer;
172
+ background:transparent; color:var(--side-muted); border-radius:7px; display:none; place-items:center; font-size:15px;
173
+ }
174
+ .s-collapse:hover{ background:var(--side-card); color:#fff; }
175
+ .s-collapse i{ display:block; transition:transform .18s ease; } /* rotate the <i> (centre origin) — no SVG-pivot bug */
176
+ html.sidebar-collapsed .s-collapse i{ transform:rotate(180deg); } /* points right = expand */
177
+
178
+ /* Brighter keyboard-focus ring inside the dark rail — the base a11y rule uses
179
+ var(--brand), which is near-invisible against the rail background. */
180
+ .s-link:focus-visible, .s-out:focus-visible, .s-brand-link:focus-visible, .s-collapse:focus-visible { outline-color: var(--brand-bright); }
181
+
182
+ .topbar-toggle{
183
+ flex:0 0 auto; width:38px; height:38px; border-radius:10px; border:1px solid var(--line);
184
+ background:var(--surface); color:var(--text-soft); display:grid; place-items:center;
185
+ font-size:18px; cursor:pointer; padding:0;
186
+ }
187
+ .topbar-toggle:hover{ color:var(--text); }
188
+
189
+ /* ── Desktop (≥768px): in-flow sidebar, header chevron, collapsed icon rail ──*/
190
+ @media (min-width: 768px){
191
+ .s-side{ position:static; transform:none; box-shadow:none; transition:width .18s ease, min-width .18s ease; }
192
+ #sidebar-backdrop{ display:none; }
193
+ .topbar-toggle{ display:none; } /* desktop uses the header chevron */
194
+ .s-collapse{ display:grid; } /* chevron shown only on desktop */
195
+
196
+ html.sidebar-collapsed .s-side{ min-width:64px; width:64px; }
197
+ /* Stack the logo + collapse chevron so the chevron stays reachable to expand. */
198
+ html.sidebar-collapsed .s-brand{ flex-direction:column; gap:9px; padding:16px 0 12px; }
199
+ html.sidebar-collapsed .s-brand-link{ justify-content:center; gap:0; }
200
+ html.sidebar-collapsed .s-brand-link > span:last-child{ display:none; }
201
+ html.sidebar-collapsed .s-collapse{ margin-left:0; }
202
+ html.sidebar-collapsed .s-nav{ padding:4px 8px 12px; }
203
+ html.sidebar-collapsed .s-grp{ display:none; }
204
+ html.sidebar-collapsed .s-link{ justify-content:center; gap:0; padding-left:0; padding-right:0; }
205
+ html.sidebar-collapsed .s-link span{ display:none; }
206
+ html.sidebar-collapsed .s-link.active::before{ display:none; }
207
+ html.sidebar-collapsed .s-me{ flex-direction:column; gap:8px; padding:10px 6px; margin:10px 8px 14px; }
208
+ html.sidebar-collapsed .s-me-who{ display:none; }
209
+ }