@aria-framework/theme 0.4.1 → 0.6.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
@@ -76,10 +76,12 @@ belongs here; if it knows about your domain, it stays in the app.
76
76
 
77
77
  ### Component: sidebar rail
78
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
79
+ A nav rail themed **per scheme** — light rail in light mode, dark rail in dark
80
+ mode (driven by the `--side-*` tokens, including `--side-strong` for emphasis
81
+ text and `--side-accent` for the active bar / icon / focus ring). Mobile-first:
82
+ off-canvas overlay under 768px (hamburger + backdrop + Esc/backdrop-tap close,
83
+ focus trapped via `inert`); an in-flow rail on desktop with a persisted collapse
84
+ to a 64px icon rail. The package ships the **CSS** (in `theme.css`) and the
83
85
  **behavior** (`sidebar.js`); the app ships the **markup** against this contract:
84
86
 
85
87
  ```html
@@ -114,12 +116,35 @@ trapped via `inert`); an in-flow rail on desktop with a persisted collapse to a
114
116
  Notes:
115
117
  - `data-storage-key` namespaces the persisted collapsed state per app (default
116
118
  `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
+ - To avoid a flash of the expanded rail on load, load `theme-init.js` (below)
120
+ synchronously in `<head>` it pre-applies the class before first paint.
119
121
  - State classes live on `<html>`: `.sidebar-open` (mobile overlay),
120
122
  `.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
+ - Sidebar tokens are themeable in `tokens.json` under `"sidebar"` (per-scheme
124
+ `light`/`dark` sub-objects, emitted as `--side-*` in `:root` and the dark
125
+ block). Icons in the examples are Bootstrap Icons, but any icon font works.
126
+ - If your app paints something with `var(--side-*)` that should IGNORE the
127
+ scheme (e.g. a permanently-dark login hero panel), use fixed values there —
128
+ since 0.5.0 these tokens follow the active scheme.
129
+
130
+ ## Pre-paint init (`theme-init.js`, since 0.6.0)
131
+
132
+ Resolves the color mode BEFORE first paint (no FOUC) and pre-applies the
133
+ collapsed-sidebar class. Load it synchronously in `<head>` — NOT `defer`red:
134
+
135
+ ```html
136
+ <html data-theme-pref="system"> <!-- server stamps light|dark|system -->
137
+ <head>
138
+ <script src="/theme/theme-init.js" data-storage-key="myapp-sidebar-collapsed"></script>
139
+ ...
140
+ ```
141
+
142
+ - `data-theme-pref="system"` resolves from the OS color scheme and live-updates
143
+ if the OS theme changes; `light`/`dark` are forced modes. The result lands on
144
+ `<html data-bs-theme="...">` (Bootstrap 5.3 convention, what `theme.css` keys on).
145
+ - `data-storage-key` MUST match the key given to `sidebar.js` (same default:
146
+ `aria-sidebar-collapsed`).
147
+ - CSP-safe: external + self-hosted, works under `script-src 'self'`.
123
148
 
124
149
  ## Mobile (Expo / React Native)
125
150
 
@@ -168,6 +193,15 @@ Token change → edit `tokens.json`, run `npm run build`, commit the regenerated
168
193
  `src/components/*.css`. Sidebar carries its own focus-visible contrast rule.
169
194
  - **v0.4.1** — `sidebar.js` double-include guard (a second script tag no longer
170
195
  registers duplicate listeners); this changelog added.
196
+ - **v0.5.0** — sidebar rail is now **per-scheme** (light rail in light mode).
197
+ `tokens.json` `"sidebar"` gained `light`/`dark` sub-objects and two new tokens:
198
+ `strong` (emphasis text, replaces hardcoded `#fff`) and `accent` (active bar /
199
+ icon / focus ring). Apps that used `--side-*` for deliberately-always-dark
200
+ surfaces must pin fixed colors instead.
201
+ - **v0.6.0** — added **`web/theme-init.js`**: pre-paint color-mode resolver
202
+ (`data-theme-pref` light/dark/system + live OS-change sync) and
203
+ collapsed-sidebar pre-apply, parameterized by the same `data-storage-key` as
204
+ `sidebar.js`. Apps can delete their local copies.
171
205
 
172
206
  > **0.x semver reminder:** `^0.4.0` matches `<0.5.0` only — consumers pick up
173
207
  > patches via `npm update`, but every **minor** bump (0.4 → 0.5) requires
package/build.js CHANGED
@@ -22,6 +22,9 @@ const hexToRgb = (hex) => {
22
22
  for (const key of ["palettes", "sidebar", "status", "priority", "space"]) {
23
23
  if (!T[key]) throw new Error(`tokens.json is missing the "${key}" section (required since theme 0.4.0)`);
24
24
  }
25
+ if (!T.sidebar.light || !T.sidebar.dark) {
26
+ throw new Error('tokens.json "sidebar" must have "light" and "dark" sub-objects (per-scheme since theme 0.5.0)');
27
+ }
25
28
 
26
29
  /* ── web/theme.css ────────────────────────────────────────────────────────── */
27
30
  const cssTokens = (p) => ` --brand:${p.brand}; --brand-deep:${p.brandDeep}; --brand-bright:${p.brandBright}; --brand-rgb:${hexToRgb(p.brand)};
@@ -31,6 +34,9 @@ const cssTokens = (p) => ` --brand:${p.brand}; --brand-deep:${p.brandDeep}; --b
31
34
  --shadow:${p.shadowWeb};
32
35
  --shadow-lg:${p.shadowLgWeb};`;
33
36
 
37
+ const sideTokens = (s) => ` --side-bg1:${s.bg1}; --side-bg2:${s.bg2}; --side-tx:${s.tx}; --side-muted:${s.muted}; --side-strong:${s.strong};
38
+ --side-active:${s.active}; --side-line:${s.line}; --side-card:${s.card}; --side-accent:${s.accent};`;
39
+
34
40
  const themeCss = `${GEN_CSS}
35
41
  /* @aria-framework/theme — Field Ops Console design system (web).
36
42
  Drop-in token layer for any Bootstrap 5.3 app. Load order in <head>:
@@ -42,12 +48,13 @@ const themeCss = `${GEN_CSS}
42
48
  --font-sans:${FONT_SANS};
43
49
  --font-mono:${FONT_MONO};
44
50
  ${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};
51
+ /* Sidebar rail (light) */
52
+ ${sideTokens(T.sidebar.light)}
48
53
  }
49
54
  [data-bs-theme="dark"]{
50
55
  ${cssTokens(T.palettes.dark)}
56
+ /* Sidebar rail (dark) */
57
+ ${sideTokens(T.sidebar.dark)}
51
58
  }`;
52
59
 
53
60
  /* ── mobile/tokens.ts ─────────────────────────────────────────────────────── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aria-framework/theme",
3
- "version": "0.4.1",
3
+ "version": "0.6.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,
@@ -26,6 +26,7 @@
26
26
  "exports": {
27
27
  "./theme.css": "./web/theme.css",
28
28
  "./sidebar.js": "./web/sidebar.js",
29
+ "./theme-init.js": "./web/theme-init.js",
29
30
  "./mobile": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
30
31
  "./tokens": { "types": "./dist/tokens.d.ts", "default": "./dist/tokens.js" },
31
32
  "./fonts": { "types": "./dist/fonts.d.ts", "default": "./dist/fonts.js" },
@@ -1,7 +1,8 @@
1
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).
2
+ Nav rail, themed per scheme via the --side-* tokens (light rail in light
3
+ mode, dark rail in dark mode). Mobile-first: off-canvas overlay by default;
4
+ the ≥768px block puts it back in flow as the desktop rail with a collapsible
5
+ icon-rail mode. Behavior lives in sidebar.js (served beside theme.css).
5
6
 
6
7
  MARKUP CONTRACT (the app renders this; see package README):
7
8
  #wrapper > .s-side + #page-content-wrapper
@@ -37,7 +38,7 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
37
38
  background:linear-gradient(140deg, var(--brand-bright), var(--brand-deep)); color:#fff; font-weight:800; font-size:17px;
38
39
  box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 6px 16px -6px var(--brand);
39
40
  }
40
- .s-name{ display:block; color:#fff; font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
41
+ .s-name{ display:block; color:var(--side-strong); font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
41
42
  .s-ver{ display:block; font-family:var(--font-mono); font-size:11px; color:var(--side-muted); margin-top:3px; }
42
43
 
43
44
  .s-nav{ flex:1; overflow-y:auto; padding:4px 12px 12px; }
@@ -52,10 +53,10 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
52
53
  white-space:nowrap;
53
54
  }
54
55
  .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); }
56
+ .s-link:hover{ background:var(--side-card); color:var(--side-strong); }
57
+ .s-link.active{ background:var(--side-active); color:var(--side-strong); }
58
+ .s-link.active::before{ content:""; position:absolute; left:-12px; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:var(--side-accent); }
59
+ .s-link.active i{ opacity:1; color:var(--side-accent); }
59
60
 
60
61
  .s-me{
61
62
  margin:10px 12px 14px; padding:10px 12px; border-radius:11px; display:flex; align-items:center; gap:10px;
@@ -66,11 +67,11 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
66
67
  background:linear-gradient(140deg, var(--brand), var(--brand-deep)); color:#fff; font-weight:700; font-size:13px;
67
68
  }
68
69
  .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 b{ display:block; color:var(--side-strong); font-size:13.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
70
71
  .s-me-who span{ font-family:var(--font-mono); font-size:10.5px; color:var(--side-muted); text-transform:uppercase; letter-spacing:.06em; }
71
72
  .s-out-form{ margin:0; }
72
73
  .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
+ .s-out:hover{ color:var(--side-strong); }
74
75
 
75
76
  /* Collapse toggle — the sidebar-header chevron (shown on desktop) and the
76
77
  topbar hamburger (shown on mobile, opens the overlay). */
@@ -78,13 +79,13 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
78
79
  flex:0 0 auto; margin-left:auto; width:28px; height:28px; padding:0; border:0; cursor:pointer;
79
80
  background:transparent; color:var(--side-muted); border-radius:7px; display:none; place-items:center; font-size:15px;
80
81
  }
81
- .s-collapse:hover{ background:var(--side-card); color:#fff; }
82
+ .s-collapse:hover{ background:var(--side-card); color:var(--side-strong); }
82
83
  .s-collapse i{ display:block; transition:transform .18s ease; } /* rotate the <i> (centre origin) — no SVG-pivot bug */
83
84
  html.sidebar-collapsed .s-collapse i{ transform:rotate(180deg); } /* points right = expand */
84
85
 
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); }
86
+ /* Keyboard-focus ring in the rail's own accent — the base a11y rule uses
87
+ var(--brand), which can vanish against the rail background. */
88
+ .s-link:focus-visible, .s-out:focus-visible, .s-brand-link:focus-visible, .s-collapse:focus-visible { outline-color: var(--side-accent); }
88
89
 
89
90
  .topbar-toggle{
90
91
  flex:0 0 auto; width:38px; height:38px; border-radius:10px; border:1px solid var(--line);
package/tokens.json CHANGED
@@ -33,12 +33,23 @@
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
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-*.",
36
+ "$sidebarComment": "Sidebar rail tokens (web sidebar component), PER-SCHEME (--side-* in :root = light, overridden in [data-bs-theme=dark]). 'strong' = emphasis text (brand name, active/hover labels); 'accent' = active bar / active icon / focus ring — brand-bright pops on the dark rail, plain brand reads better on the light one.",
37
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)"
38
+ "light": {
39
+ "bg1": "#FBFCFA", "bg2": "#F3F4F0",
40
+ "tx": "#4A555E", "muted": "#8C969D", "strong": "#18222A",
41
+ "active": "rgba(14,122,110,.10)",
42
+ "line": "rgba(24,34,42,.08)",
43
+ "card": "rgba(24,34,42,.05)",
44
+ "accent": "#0E7A6E"
45
+ },
46
+ "dark": {
47
+ "bg1": "#0E1417", "bg2": "#141C21",
48
+ "tx": "#BAC3C8", "muted": "#69767D", "strong": "#FFFFFF",
49
+ "active": "rgba(21,184,166,.13)",
50
+ "line": "rgba(255,255,255,.07)",
51
+ "card": "rgba(255,255,255,.045)",
52
+ "accent": "#15B8A6"
53
+ }
43
54
  }
44
55
  }
@@ -0,0 +1,38 @@
1
+ /*
2
+ * theme-init.js — resolve the active color mode BEFORE first paint (no FOUC).
3
+ *
4
+ * Load synchronously in <head> (NOT deferred) so it runs before the body
5
+ * renders. The server stamps <html data-theme-pref="light|dark|system"> from
6
+ * the signed-in user's saved preference and a best-guess data-bs-theme. When
7
+ * the preference is "system" we resolve it here from the OS color scheme and
8
+ * keep it in sync if the OS theme changes while the app is open. CSP-safe:
9
+ * external, self-hosted, no inline script.
10
+ *
11
+ * Also pre-applies the collapsed-sidebar preference so a collapsed rail does
12
+ * not flash open on load. The localStorage key comes from this script tag's
13
+ * data-storage-key attribute and MUST match the one passed to sidebar.js:
14
+ *
15
+ * <script src="/theme/theme-init.js" data-storage-key="myapp-sidebar-collapsed"></script>
16
+ */
17
+ (function () {
18
+ var el = document.documentElement;
19
+ var script = document.currentScript;
20
+ var sidebarKey = (script && script.dataset && script.dataset.storageKey) || 'aria-sidebar-collapsed';
21
+
22
+ var pref = el.getAttribute('data-theme-pref') || 'system';
23
+ function resolve() {
24
+ var mode = pref === 'dark' ? 'dark'
25
+ : pref === 'light' ? 'light'
26
+ : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
27
+ el.setAttribute('data-bs-theme', mode);
28
+ }
29
+ resolve();
30
+ if (pref === 'system' && window.matchMedia) {
31
+ try { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', resolve); }
32
+ catch (e) { /* older browsers: no live OS-change updates */ }
33
+ }
34
+
35
+ try {
36
+ if (localStorage.getItem(sidebarKey) === '1') el.classList.add('sidebar-collapsed');
37
+ } catch (e) { /* private mode / storage disabled */ }
38
+ })();
package/web/theme.css CHANGED
@@ -14,9 +14,9 @@
14
14
  --text:#18222A; --text-soft:#5C6770; --text-faint:#929BA1;
15
15
  --shadow:0 1px 2px rgba(16,24,28,.05), 0 4px 14px -6px rgba(16,24,28,.12);
16
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);
17
+ /* Sidebar rail (light) */
18
+ --side-bg1:#FBFCFA; --side-bg2:#F3F4F0; --side-tx:#4A555E; --side-muted:#8C969D; --side-strong:#18222A;
19
+ --side-active:rgba(14,122,110,.10); --side-line:rgba(24,34,42,.08); --side-card:rgba(24,34,42,.05); --side-accent:#0E7A6E;
20
20
  }
21
21
  [data-bs-theme="dark"]{
22
22
  --brand:#16B5A3; --brand-deep:#0E7A6E; --brand-bright:#2FE0CC; --brand-rgb:22,181,163;
@@ -25,6 +25,9 @@
25
25
  --text:#E7EDEF; --text-soft:#9BA7AE; --text-faint:#69767D;
26
26
  --shadow:0 1px 2px rgba(0,0,0,.3), 0 8px 24px -10px rgba(0,0,0,.6);
27
27
  --shadow-lg:0 24px 60px -18px rgba(0,0,0,.8);
28
+ /* Sidebar rail (dark) */
29
+ --side-bg1:#0E1417; --side-bg2:#141C21; --side-tx:#BAC3C8; --side-muted:#69767D; --side-strong:#FFFFFF;
30
+ --side-active:rgba(21,184,166,.13); --side-line:rgba(255,255,255,.07); --side-card:rgba(255,255,255,.045); --side-accent:#15B8A6;
28
31
  }
29
32
 
30
33
  /* ── Base — static layer (fonts, Bootstrap mapping, edge-clarity, buttons, a11y).
@@ -92,9 +95,10 @@ textarea:focus-visible, [tabindex]:focus-visible {
92
95
  }
93
96
 
94
97
  /* ── 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
+ Nav rail, themed per scheme via the --side-* tokens (light rail in light
99
+ mode, dark rail in dark mode). Mobile-first: off-canvas overlay by default;
100
+ the ≥768px block puts it back in flow as the desktop rail with a collapsible
101
+ icon-rail mode. Behavior lives in sidebar.js (served beside theme.css).
98
102
 
99
103
  MARKUP CONTRACT (the app renders this; see package README):
100
104
  #wrapper > .s-side + #page-content-wrapper
@@ -130,7 +134,7 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
130
134
  background:linear-gradient(140deg, var(--brand-bright), var(--brand-deep)); color:#fff; font-weight:800; font-size:17px;
131
135
  box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 6px 16px -6px var(--brand);
132
136
  }
133
- .s-name{ display:block; color:#fff; font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
137
+ .s-name{ display:block; color:var(--side-strong); font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
134
138
  .s-ver{ display:block; font-family:var(--font-mono); font-size:11px; color:var(--side-muted); margin-top:3px; }
135
139
 
136
140
  .s-nav{ flex:1; overflow-y:auto; padding:4px 12px 12px; }
@@ -145,10 +149,10 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
145
149
  white-space:nowrap;
146
150
  }
147
151
  .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
+ .s-link:hover{ background:var(--side-card); color:var(--side-strong); }
153
+ .s-link.active{ background:var(--side-active); color:var(--side-strong); }
154
+ .s-link.active::before{ content:""; position:absolute; left:-12px; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:var(--side-accent); }
155
+ .s-link.active i{ opacity:1; color:var(--side-accent); }
152
156
 
153
157
  .s-me{
154
158
  margin:10px 12px 14px; padding:10px 12px; border-radius:11px; display:flex; align-items:center; gap:10px;
@@ -159,11 +163,11 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
159
163
  background:linear-gradient(140deg, var(--brand), var(--brand-deep)); color:#fff; font-weight:700; font-size:13px;
160
164
  }
161
165
  .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; }
166
+ .s-me-who b{ display:block; color:var(--side-strong); font-size:13.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
163
167
  .s-me-who span{ font-family:var(--font-mono); font-size:10.5px; color:var(--side-muted); text-transform:uppercase; letter-spacing:.06em; }
164
168
  .s-out-form{ margin:0; }
165
169
  .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; }
170
+ .s-out:hover{ color:var(--side-strong); }
167
171
 
168
172
  /* Collapse toggle — the sidebar-header chevron (shown on desktop) and the
169
173
  topbar hamburger (shown on mobile, opens the overlay). */
@@ -171,13 +175,13 @@ html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
171
175
  flex:0 0 auto; margin-left:auto; width:28px; height:28px; padding:0; border:0; cursor:pointer;
172
176
  background:transparent; color:var(--side-muted); border-radius:7px; display:none; place-items:center; font-size:15px;
173
177
  }
174
- .s-collapse:hover{ background:var(--side-card); color:#fff; }
178
+ .s-collapse:hover{ background:var(--side-card); color:var(--side-strong); }
175
179
  .s-collapse i{ display:block; transition:transform .18s ease; } /* rotate the <i> (centre origin) — no SVG-pivot bug */
176
180
  html.sidebar-collapsed .s-collapse i{ transform:rotate(180deg); } /* points right = expand */
177
181
 
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); }
182
+ /* Keyboard-focus ring in the rail's own accent — the base a11y rule uses
183
+ var(--brand), which can vanish against the rail background. */
184
+ .s-link:focus-visible, .s-out:focus-visible, .s-brand-link:focus-visible, .s-collapse:focus-visible { outline-color: var(--side-accent); }
181
185
 
182
186
  .topbar-toggle{
183
187
  flex:0 0 auto; width:38px; height:38px; border-radius:10px; border:1px solid var(--line);