@apliteni/apliteni-ui 0.51.0 → 0.53.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
@@ -170,6 +170,9 @@ See Storybook's **Button / Busy Transition** for the live vanilla example.
170
170
 
171
171
  ## Theming
172
172
 
173
+ React pages can use [ThemeToggle and its pre-paint script](react/README.md#themetoggle)
174
+ to save dark, light or auto choices. Auto resolves to dark or light on the root.
175
+
173
176
  Theme is a `data-theme="dark|light"` attribute on `<html>`; accent is an orthogonal
174
177
  `data-accent` on top:
175
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -441,6 +441,40 @@ function SaveButton() {
441
441
  <Toast><SaveButton /></Toast>
442
442
  ```
443
443
 
444
+ ## ThemeToggle
445
+
446
+ ThemeToggle uses the existing topbar button. It shows the current theme and the
447
+ action that will happen next. Add `labelled` to display the current theme beside
448
+ the button.
449
+
450
+ ```tsx
451
+ import { ThemeToggle, THEME_INIT_SCRIPT } from '@apliteni/apliteni-ui/react';
452
+
453
+ <ThemeToggle />
454
+ <ThemeToggle labelled />
455
+ ```
456
+
457
+ ### Contract
458
+
459
+ | Surface | Contract |
460
+ | --- | --- |
461
+ | Props | `ThemeToggleProps` has one optional prop: `labelled?: boolean`, default `false`. There are no controlled-value or callback props. |
462
+ | Choices | Each press cycles `dark` → `light` → `auto` → `dark`. Auto shows a monitor icon and keeps its own label even when the resolved theme changes. |
463
+ | Storage | Explicit choices use localStorage key `apliteni-strategy-theme`. Existing `dark` and `light` values work without migration. `auto`, missing values and unknown values follow the OS; mounting does not rewrite storage. |
464
+ | DOM | On mount, the control sets `data-theme-choice` to dark, light or auto and `data-theme` to dark or light on `html`. Auto resolves to light when `(prefers-color-scheme: light)` matches, otherwise dark. An existing `data-theme-choice` takes precedence on mount; unknown attribute values become auto. |
465
+ | Updates | Mounted controls follow OS changes in auto mode and storage changes from other tabs. A press dispatches a plain `apliteni-theme-choice` event on `window`, with no payload; read the root attributes for the choice and resolved theme. OS and storage updates do not dispatch that event. |
466
+ | Storage failure | The button still works and retains its choice on the current page. Persistence across reloads requires working storage. |
467
+ | Before paint | `THEME_INIT_SCRIPT: string` reads the same stored values and sets only `data-theme`. It neither writes storage nor subscribes to OS changes; mounted controls handle later updates. Server rendering starts with the Auto label and does not set root attributes. |
468
+
469
+ Place `THEME_INIT_SCRIPT` in an inline `<head>` script before styles load so the theme
470
+ is set before the first paint. Allow this script through your Content Security Policy
471
+ with a nonce or hash. The component applies the preference when React mounts, but it
472
+ cannot change an earlier paint.
473
+
474
+ ```tsx
475
+ <script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
476
+ ```
477
+
444
478
  ## AppShell
445
479
 
446
480
  AppShell includes the kit’s rail and top band. Pass the current router pathname so the longest matching section path becomes active. Spread the props from `renderLink` onto your router link. The rail’s folded state is saved in a shared cookie. On phones, the bar shows up to four sections. If there are more, it shows three sections and a More sheet. Provide no more than one primary page action. The palette defaults to section links; pass `palette` to add custom commands. Import both the kit CSS and React CSS.
@@ -593,3 +593,10 @@
593
593
  display: none;
594
594
  }
595
595
  }
596
+
597
+ /* src/ThemeToggle.css */
598
+ .ui-theme-toggle {
599
+ display: inline-flex;
600
+ align-items: center;
601
+ gap: var(--space-3);
602
+ }
@@ -597,7 +597,15 @@ type EmptyStateProps = {
597
597
  };
598
598
  declare function EmptyState({ variant, title, sub, icon, art, actions }: EmptyStateProps): react.JSX.Element;
599
599
 
600
+ /** Place in a head script before styles load to apply the theme before first paint. */
601
+ declare const THEME_INIT_SCRIPT = "(()=>{let t;try{t=localStorage.getItem('apliteni-strategy-theme')}catch{}document.documentElement.setAttribute('data-theme',t==='dark'||t==='light'?t:window.matchMedia?.('(prefers-color-scheme: light)').matches?'light':'dark')})()";
602
+ type ThemeToggleProps = {
603
+ /** Show the current theme beside the icon. */
604
+ labelled?: boolean;
605
+ };
606
+ declare function ThemeToggle({ labelled }: ThemeToggleProps): react.JSX.Element;
607
+
600
608
  declare const PAGE_SIZES: readonly number[];
601
609
  declare const DEFAULT_PAGE_SIZE: number;
602
610
 
603
- export { AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, Timeline, type TimelineEvent, type TimelineProps, Toast, type ToastNotice, type ToastProps, sortTableRows, useToast };
611
+ export { AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, THEME_INIT_SCRIPT, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, ThemeToggle, type ThemeToggleProps, Timeline, type TimelineEvent, type TimelineProps, Toast, type ToastNotice, type ToastProps, sortTableRows, useToast };
@@ -51,7 +51,7 @@ var cx = (...a) => a.filter(Boolean).join(" ");
51
51
  var Button = forwardRef(function Button2({
52
52
  variant = "secondary",
53
53
  size = "md",
54
- icon: icon5,
54
+ icon: icon6,
55
55
  iconRight,
56
56
  iconOnly,
57
57
  block,
@@ -107,7 +107,7 @@ var Button = forwardRef(function Button2({
107
107
  iconOnly && "ui-btn--icon"
108
108
  );
109
109
  const labelled = rest["aria-label"] != null || rest["aria-labelledby"] != null;
110
- const fallback = typeof children === "string" && children.trim() ? children.trim() : icon5;
110
+ const fallback = typeof children === "string" && children.trim() ? children.trim() : icon6;
111
111
  const named = iconOnly && !labelled && fallback ? { "aria-label": fallback, title: rest.title ?? fallback } : {};
112
112
  return /* @__PURE__ */ jsxs(
113
113
  "button",
@@ -141,7 +141,7 @@ var Button = forwardRef(function Button2({
141
141
  if (!blockKey(event)) onKeyUp?.(event);
142
142
  },
143
143
  children: [
144
- icon5 && /* @__PURE__ */ jsx2(Icon, { name: icon5 }),
144
+ icon6 && /* @__PURE__ */ jsx2(Icon, { name: icon6 }),
145
145
  !iconOnly && renderedChildren != null && /* @__PURE__ */ jsx2("span", { className: "ui-btn__label-slot", children: /* @__PURE__ */ jsx2("span", { className: "ui-btn__label", ref: labelRef, children: renderedChildren }) }),
146
146
  iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight }),
147
147
  busy && /* @__PURE__ */ jsxs("span", { className: "ui-btn__dots", "aria-hidden": "true", children: [
@@ -177,9 +177,9 @@ function Card({ title, sub, level = 2, children }) {
177
177
  // src/primitives/Callout.tsx
178
178
  import { calloutIcons, icon as kitIcon } from "@apliteni/apliteni-ui";
179
179
  import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
180
- function Callout({ variant, icon: icon5 = calloutIcons[variant ?? "neutral"], children, actions }) {
180
+ function Callout({ variant, icon: icon6 = calloutIcons[variant ?? "neutral"], children, actions }) {
181
181
  return /* @__PURE__ */ jsxs3("div", { className: `ui-callout${variant && variant !== "neutral" ? ` ui-callout--${variant}` : ""}`, role: variant === "danger" ? "alert" : void 0, children: [
182
- /* @__PURE__ */ jsx5("span", { className: "ui-callout__icon", dangerouslySetInnerHTML: { __html: kitIcon(icon5) } }),
182
+ /* @__PURE__ */ jsx5("span", { className: "ui-callout__icon", dangerouslySetInnerHTML: { __html: kitIcon(icon6) } }),
183
183
  /* @__PURE__ */ jsxs3("div", { className: "ui-callout__body", children: [
184
184
  children,
185
185
  actions != null && /* @__PURE__ */ jsx5("div", { className: "ui-toolbar ui-callout__actions", children: actions })
@@ -1213,12 +1213,12 @@ function Denied({
1213
1213
  title = "You don\u2019t have access",
1214
1214
  sub,
1215
1215
  need,
1216
- icon: icon5 = "lock",
1216
+ icon: icon6 = "lock",
1217
1217
  className,
1218
1218
  children
1219
1219
  }) {
1220
1220
  return /* @__PURE__ */ jsxs11("div", { className: cx4("ui-denied", className), children: [
1221
- /* @__PURE__ */ jsx13("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx13(Icon, { name: icon5 }) }),
1221
+ /* @__PURE__ */ jsx13("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx13(Icon, { name: icon6 }) }),
1222
1222
  /* @__PURE__ */ jsx13("div", { className: "ui-denied__title", children: title }),
1223
1223
  sub && /* @__PURE__ */ jsx13("div", { className: "ui-denied__sub", children: sub }),
1224
1224
  need && /* @__PURE__ */ jsxs11("div", { className: "ui-denied__need", children: [
@@ -2321,11 +2321,11 @@ var cases = {
2321
2321
  "not-found": { title: "We can't find that page", sub: "Check the address or return to the home page.", icon: "folder" },
2322
2322
  "not-yet-built": { title: "This ships later", sub: "This screen is not available yet. Check back later.", icon: "clock" }
2323
2323
  };
2324
- function EmptyState({ variant = "first-run", title, sub, icon: icon5, art, actions }) {
2324
+ function EmptyState({ variant = "first-run", title, sub, icon: icon6, art, actions }) {
2325
2325
  const preset = cases[variant];
2326
2326
  const heading = title ?? preset.title;
2327
2327
  const guidance = sub ?? preset.sub;
2328
- const glyph = icon5 ?? preset.icon;
2328
+ const glyph = icon6 ?? preset.icon;
2329
2329
  return /* @__PURE__ */ jsxs22("div", { className: "ui-empty", children: [
2330
2330
  art ? typeof art === "string" ? /* @__PURE__ */ jsx26("div", { className: "ui-empty__art", dangerouslySetInnerHTML: { __html: /<svg/.test(art) ? art : illo(art) } }) : /* @__PURE__ */ jsx26("div", { className: "ui-empty__art", "aria-hidden": "true", children: art }) : glyph ? /* @__PURE__ */ jsx26("div", { className: "ui-empty__icon", dangerouslySetInnerHTML: { __html: kitIcon2(glyph) } }) : null,
2331
2331
  heading && /* @__PURE__ */ jsx26("div", { className: "ui-empty__title", children: heading }),
@@ -2334,6 +2334,77 @@ function EmptyState({ variant = "first-run", title, sub, icon: icon5, art, actio
2334
2334
  ] });
2335
2335
  }
2336
2336
 
2337
+ // src/ThemeToggle.tsx
2338
+ import { useSyncExternalStore } from "react";
2339
+ import { icon as icon5 } from "@apliteni/apliteni-ui";
2340
+ import { jsx as jsx27, jsxs as jsxs23 } from "react/jsx-runtime";
2341
+ var STORAGE_KEY = "apliteni-strategy-theme";
2342
+ var CHOICE_EVENT = "apliteni-theme-choice";
2343
+ var THEME_INIT_SCRIPT = `(()=>{let t;try{t=localStorage.getItem('apliteni-strategy-theme')}catch{}document.documentElement.setAttribute('data-theme',t==='dark'||t==='light'?t:window.matchMedia?.('(prefers-color-scheme: light)').matches?'light':'dark')})()`;
2344
+ function storedTheme() {
2345
+ try {
2346
+ const value = localStorage.getItem(STORAGE_KEY);
2347
+ return value === "dark" || value === "light" || value === "auto" ? value : null;
2348
+ } catch {
2349
+ return snapshot();
2350
+ }
2351
+ }
2352
+ function snapshot() {
2353
+ const choice = document.documentElement.getAttribute("data-theme-choice");
2354
+ return choice === "dark" || choice === "light" ? choice : "auto";
2355
+ }
2356
+ function subscribe(notify) {
2357
+ const root = document.documentElement;
2358
+ const media = window.matchMedia?.("(prefers-color-scheme: light)");
2359
+ const applyPreference = (choice) => {
2360
+ root.setAttribute("data-theme-choice", choice);
2361
+ root.setAttribute("data-theme", choice === "auto" ? media?.matches ? "light" : "dark" : choice);
2362
+ };
2363
+ const onSystem = () => {
2364
+ if (snapshot() === "auto") applyPreference("auto");
2365
+ };
2366
+ const onChoice = () => notify();
2367
+ const onStorage = (event) => {
2368
+ if (event.key !== null && event.key !== STORAGE_KEY) return;
2369
+ applyPreference(storedTheme() ?? "auto");
2370
+ };
2371
+ const observer = new MutationObserver(notify);
2372
+ observer.observe(root, { attributes: true, attributeFilter: ["data-theme", "data-theme-choice"] });
2373
+ applyPreference(root.hasAttribute("data-theme-choice") ? snapshot() : storedTheme() ?? "auto");
2374
+ media?.addEventListener("change", onSystem);
2375
+ window.addEventListener(CHOICE_EVENT, onChoice);
2376
+ window.addEventListener("storage", onStorage);
2377
+ return () => {
2378
+ observer.disconnect();
2379
+ media?.removeEventListener("change", onSystem);
2380
+ window.removeEventListener(CHOICE_EVENT, onChoice);
2381
+ window.removeEventListener("storage", onStorage);
2382
+ };
2383
+ }
2384
+ function ThemeToggle({ labelled = false }) {
2385
+ const theme = useSyncExternalStore(subscribe, snapshot, () => "auto");
2386
+ const next = theme === "dark" ? "light" : theme === "light" ? "auto" : "dark";
2387
+ const label = theme === "auto" ? "Auto" : theme === "light" ? "Light" : "Dark";
2388
+ const name = `Theme: ${label}. Switch to ${next}.`;
2389
+ const toggle = () => {
2390
+ document.documentElement.setAttribute("data-theme-choice", next);
2391
+ document.documentElement.setAttribute("data-theme", next === "auto" ? window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark" : next);
2392
+ try {
2393
+ localStorage.setItem(STORAGE_KEY, next);
2394
+ } catch {
2395
+ }
2396
+ window.dispatchEvent(new Event(CHOICE_EVENT));
2397
+ };
2398
+ const button = /* @__PURE__ */ jsx27("button", { type: "button", className: "toggle", "aria-label": name, title: name, onClick: toggle, children: /* @__PURE__ */ jsx27("span", { className: "ic", "aria-hidden": "true", dangerouslySetInnerHTML: { __html: icon5(theme === "auto" ? "monitor" : theme === "light" ? "sun" : "moon") } }) });
2399
+ return labelled ? /* @__PURE__ */ jsxs23("span", { className: "ui-theme-toggle", children: [
2400
+ button,
2401
+ /* @__PURE__ */ jsxs23("span", { children: [
2402
+ label,
2403
+ " theme"
2404
+ ] })
2405
+ ] }) : button;
2406
+ }
2407
+
2337
2408
  // src/index.ts
2338
2409
  var PAGE_SIZES = KIT_PAGE_SIZES;
2339
2410
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
@@ -2370,9 +2441,11 @@ export {
2370
2441
  SkeletonTable,
2371
2442
  Snippet,
2372
2443
  StatBand,
2444
+ THEME_INIT_SCRIPT,
2373
2445
  Tabs,
2374
2446
  TextArea,
2375
2447
  TextField,
2448
+ ThemeToggle,
2376
2449
  Timeline,
2377
2450
  Toast,
2378
2451
  sortTableRows,
@@ -8,22 +8,10 @@ import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
8
8
  import { trustedAttr, trustedUrl } from '../html.js';
9
9
  const THEME_KEY = 'apliteni-strategy-theme';
10
10
 
11
- // A stateful control in this kit reports the state it is IN, never the state a
12
- // click would produce — the same reading as segmented()'s aria-pressed, the
13
- // Deck/Text switch's aria-current, and the accent chips. So: moon while dark,
14
- // sun while light. Storybook's own toolbar toggle (.storybook/theme-toggle.jsx)
15
- // reads the same way, and topbar stories render directly beneath it.
16
- export const themeIcon = (t) => (t === 'light' ? sun : moon);
17
-
18
- // The name carries both the state and what the click does, which is why there
19
- // is no aria-pressed and no role="switch": dark and light are peers, not on and
20
- // off, and an ARIA state on top of this name would announce the theme twice.
21
- // The button is icon-only, so `title` is the only sighted reading of the same
22
- // fact and carries the identical string — nothing for WCAG 2.5.3 to disagree
23
- // with. Both are rewritten by applyTheme on every flip; a name that is right
24
- // once and never again tells a screen-reader user nothing.
25
- export const themeName = (t) =>
26
- (t === 'light' ? 'Theme: Light. Switch to dark.' : 'Theme: Dark. Switch to light.');
11
+ // The icon and name show the saved choice, including automatic mode.
12
+ export const themeIcon = (t) => t === 'auto' ? icon('monitor') : t === 'light' ? sun : moon;
13
+ export const themeName = (t) => t === 'auto' ? 'Theme: Auto. Switch to dark.' :
14
+ t === 'light' ? 'Theme: Light. Switch to auto.' : 'Theme: Dark. Switch to light.';
27
15
 
28
16
  export function themeToggle(theme = 'dark') {
29
17
  // Single icon-only switch. Icon and name are pre-filled for `theme` so the
@@ -113,7 +101,9 @@ export function topbar({
113
101
 
114
102
  // ---- Behaviours (call once after markup mounts) --------------------------
115
103
  export function applyTheme(t, root = document.documentElement) {
116
- root.setAttribute('data-theme', t);
104
+ const media = root.ownerDocument.defaultView.matchMedia?.('(prefers-color-scheme: light)');
105
+ root.setAttribute('data-theme-choice', t);
106
+ root.setAttribute('data-theme', t === 'auto' ? (media?.matches ? 'light' : 'dark') : t);
117
107
  const name = themeName(t);
118
108
  root.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
119
109
  const ic = btn.querySelector('[data-theme-icon]');
@@ -124,6 +114,8 @@ export function applyTheme(t, root = document.documentElement) {
124
114
  btn.setAttribute('title', name);
125
115
  });
126
116
  try { localStorage.setItem(THEME_KEY, t); } catch (e) { /* no-op */ }
117
+ const view = root.ownerDocument.defaultView;
118
+ view.dispatchEvent(new view.Event('apliteni-theme-choice'));
127
119
  }
128
120
 
129
121
  const ACCENT_KEY = 'apliteni-strategy-accent';
@@ -135,14 +127,55 @@ export function applyAccent(name, root = document.documentElement) {
135
127
  try { localStorage.setItem(ACCENT_KEY, name || 'default'); } catch (e) { /* no-op */ }
136
128
  }
137
129
 
130
+ const themeListeners = new WeakMap();
131
+
138
132
  export function wireTopbar(root = document) {
139
- // Theme toggle
140
- root.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
141
- const html = document.documentElement;
142
- const cur = html.getAttribute('data-theme') || 'dark';
143
- applyTheme(cur, html);
144
- btn.addEventListener('click', () => applyTheme(html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark', html));
145
- });
133
+ const html = document.documentElement;
134
+ const buttons = root.querySelectorAll('[data-theme-toggle]');
135
+ if (buttons.length) {
136
+ let choice = html.getAttribute('data-theme-choice') || 'auto';
137
+ try { choice = localStorage.getItem(THEME_KEY) || choice; } catch { /* Use the page choice. */ }
138
+ if (!['dark', 'light', 'auto'].includes(choice)) choice = 'auto';
139
+ applyTheme(choice, html);
140
+ const media = html.ownerDocument.defaultView.matchMedia?.('(prefers-color-scheme: light)');
141
+ themeListeners.get(html)?.();
142
+ const onSystem = () => {
143
+ if (html.getAttribute('data-theme-choice') === 'auto') {
144
+ html.setAttribute('data-theme', media?.matches ? 'light' : 'dark');
145
+ }
146
+ };
147
+ media?.addEventListener('change', onSystem);
148
+ const updateButtons = () => {
149
+ const choice = html.getAttribute('data-theme-choice') || 'auto';
150
+ html.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
151
+ btn.setAttribute('aria-label', themeName(choice));
152
+ btn.setAttribute('title', themeName(choice));
153
+ const glyph = btn.querySelector('[data-theme-icon]');
154
+ if (glyph) glyph.innerHTML = themeIcon(choice);
155
+ });
156
+ };
157
+ const observer = new html.ownerDocument.defaultView.MutationObserver(updateButtons);
158
+ observer.observe(html, { attributes: true, attributeFilter: ['data-theme-choice'] });
159
+ const view = html.ownerDocument.defaultView;
160
+ const onStorage = (event) => {
161
+ if (event.key !== null && event.key !== THEME_KEY) return;
162
+ let saved;
163
+ try { saved = localStorage.getItem(THEME_KEY); } catch { return; }
164
+ applyTheme(saved === 'dark' || saved === 'light' ? saved : 'auto', html);
165
+ };
166
+ view.addEventListener('storage', onStorage);
167
+ themeListeners.set(html, () => {
168
+ media?.removeEventListener('change', onSystem);
169
+ view.removeEventListener('storage', onStorage);
170
+ observer.disconnect();
171
+ });
172
+ buttons.forEach((btn) => {
173
+ btn.onclick = () => {
174
+ const current = html.getAttribute('data-theme-choice');
175
+ applyTheme(current === 'dark' ? 'light' : current === 'light' ? 'auto' : 'dark', html);
176
+ };
177
+ });
178
+ }
146
179
  // Deck/Text view switch (.dtsw) — switch the active pill on click
147
180
  root.querySelectorAll('.dtsw').forEach((sw) => {
148
181
  sw.addEventListener('click', (e) => {
@@ -86,7 +86,7 @@
86
86
  border-color var(--dur-fast) var(--ease);
87
87
  }
88
88
  .toggle:hover { transform: translateY(-1px); border-color: var(--border-strong); }
89
- .toggle .ic { width: 17px; height: 17px; display: inline-block; color: var(--accent); }
89
+ .toggle .ic { width: 17px; height: 17px; display: inline-block; color: var(--text); }
90
90
  .toggle .ic svg { display: block; width: 100%; height: 100%; stroke-width: 2.2; }
91
91
 
92
92
  /* Version switcher */