@aria-framework/theme 0.3.0 → 0.4.1

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/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.1",
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,115 @@
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
+ // Double-include guard: a second copy (e.g. two layout partials both adding
25
+ // the script tag) would register a second delegated listener, making every
26
+ // toggle fire twice — a silent, error-free dead sidebar. First copy wins.
27
+ if (window.__ariaSidebar) return;
28
+ window.__ariaSidebar = true;
29
+
30
+ // currentScript is only set during initial execution — capture before deferring.
31
+ var script = document.currentScript;
32
+ var KEY = (script && script.dataset && script.dataset.storageKey) || 'aria-sidebar-collapsed';
33
+
34
+ function init() {
35
+ var root = document.documentElement;
36
+
37
+ // Backdrop for the mobile overlay (created once). Its computed display is
38
+ // our single source of truth for "mobile layout" — `block` only inside the
39
+ // mobile media query, `none` on desktop.
40
+ var backdrop = document.getElementById('sidebar-backdrop');
41
+ if (!backdrop) {
42
+ backdrop = document.createElement('div');
43
+ backdrop.id = 'sidebar-backdrop';
44
+ document.body.appendChild(backdrop);
45
+ }
46
+ function isMobile() { return getComputedStyle(backdrop).display !== 'none'; }
47
+
48
+ var content = document.getElementById('page-content-wrapper'); // inert while overlay is open
49
+ var lastFocus = null;
50
+
51
+ // Reflect state to assistive tech: the header chevron announces rail
52
+ // expanded/collapsed; the topbar hamburger announces overlay open/closed.
53
+ // Buttons are queried live so late-added toggles stay in sync.
54
+ function syncAria() {
55
+ var collapsed = root.classList.contains('sidebar-collapsed');
56
+ var open = root.classList.contains('sidebar-open');
57
+ document.querySelectorAll('[data-sidebar-toggle]').forEach(function (b) {
58
+ b.setAttribute('aria-expanded', String(b.classList.contains('s-collapse') ? !collapsed : open));
59
+ });
60
+ }
61
+
62
+ // `opener` is the button that opened the overlay — restore focus to it on
63
+ // close (activeElement isn't reliable: some browsers don't focus a button
64
+ // on mouse click). Make the background inert so Tab can't escape the overlay.
65
+ function openOverlay(opener) {
66
+ lastFocus = opener || document.activeElement;
67
+ root.classList.add('sidebar-open');
68
+ if (content) content.inert = true;
69
+ var first = document.querySelector('.s-nav .s-link');
70
+ if (first) first.focus(); // move focus into the opened menu
71
+ syncAria();
72
+ }
73
+ function closeOverlay(restoreFocus) {
74
+ root.classList.remove('sidebar-open');
75
+ if (content) content.inert = false; // un-inert before restoring focus into it
76
+ if (restoreFocus && lastFocus && lastFocus.focus) lastFocus.focus();
77
+ lastFocus = null;
78
+ syncAria();
79
+ }
80
+
81
+ function toggle(opener) {
82
+ if (isMobile()) {
83
+ if (root.classList.contains('sidebar-open')) closeOverlay(true); else openOverlay(opener);
84
+ } else {
85
+ var collapsed = root.classList.toggle('sidebar-collapsed');
86
+ try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* storage off */ }
87
+ syncAria();
88
+ }
89
+ }
90
+
91
+ // Delegated clicks: toggles work wherever/whenever they're added; tapping a
92
+ // nav link on mobile closes the overlay (it also navigates — no focus to
93
+ // restore). Backdrop tap and Escape close it too.
94
+ document.addEventListener('click', function (e) {
95
+ var t = e.target.closest && e.target.closest('[data-sidebar-toggle]');
96
+ if (t) { toggle(t); return; }
97
+ if (e.target === backdrop) { closeOverlay(true); return; }
98
+ if (isMobile() && e.target.closest && e.target.closest('.s-nav .s-link')) closeOverlay(false);
99
+ });
100
+ document.addEventListener('keydown', function (e) {
101
+ if (e.key === 'Escape' && root.classList.contains('sidebar-open')) closeOverlay(true);
102
+ });
103
+
104
+ // Growing back to desktop clears any transient mobile-open state. Cheap
105
+ // guard first, so getComputedStyle only runs while an overlay is open.
106
+ window.addEventListener('resize', function () {
107
+ if (root.classList.contains('sidebar-open') && !isMobile()) closeOverlay(false);
108
+ });
109
+
110
+ syncAria(); // initial state (the app's early boot script may pre-set sidebar-collapsed)
111
+ }
112
+
113
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
114
+ else init();
115
+ })();
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
+ }