Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe627e0a9 | ||
|
|
a08aacfdf6 |
10 changed files with 521 additions and 103 deletions
|
|
@ -17,18 +17,23 @@ reference the **semantic** names and must **not** redeclare them or reach
|
|||
for the raw `--baseNN` slots directly. (`base.css` holds only the shared
|
||||
`body` typography — it references the palette but no longer defines it.)
|
||||
|
||||
`colors.css` ships two rows of the same 16 slots: an unconditional
|
||||
Catppuccin Mocha (dark) default, and a Catppuccin-Latte-derived (light)
|
||||
row gated behind `@media (prefers-color-scheme: light)` — both use the
|
||||
identical slot mapping, so which row is active never changes what a slot
|
||||
means. The light row's `base00`-`base07` (surfaces/foreground) are stock
|
||||
Latte hexes; `base08`-`base0F` (the eight chromatic slots) are darkened
|
||||
from stock Latte to actually clear WCAG AA against how this app uses
|
||||
them (real contrast failures found in review — see the comment above
|
||||
those declarations in `colors.css` for the numbers and why). This is the
|
||||
OS/browser-level default only; it's what a client with no more specific
|
||||
theme decision gets (see "Theme swapping" below for how a stylix
|
||||
deployment or a future per-user override supersede it).
|
||||
`colors.css` declares each palette's 16 hex values exactly once —
|
||||
`--mocha-baseNN` (dark, Catppuccin Mocha) and `--latte-baseNN` (light,
|
||||
Catppuccin-Latte-derived; `base08`-`base0F` are darkened from stock
|
||||
Latte to actually clear WCAG AA against how this app uses them — real
|
||||
contrast failures found in review, see the comment above those
|
||||
declarations in `colors.css` for the numbers and why). Everything else
|
||||
in the file just points the active `--baseNN` slot at one of those two
|
||||
raw palettes via `var()`, never a second copy of a hex value: an
|
||||
unconditional default (Mocha), a row gated behind `@media
|
||||
(prefers-color-scheme: light)` (Latte, the OS/browser-level default),
|
||||
and two `:root[data-theme='light'|'dark']` blocks a per-user override
|
||||
sets (swarm-ui's `SettingsMenu`) — an attribute selector always
|
||||
outranks the plain `:root` inside the media query, so the override wins
|
||||
regardless of the actual OS preference. Absent an override, the media
|
||||
query decides; that's what a client with no more specific theme
|
||||
decision gets (see "Theme swapping" below for how a stylix deployment
|
||||
supersedes all of this at once).
|
||||
|
||||
Both are deliberately standalone, not `@import`ed into the page bundles:
|
||||
each package re-exports them (`src/{colors,theme}.css` →
|
||||
|
|
@ -127,12 +132,13 @@ to `theme.css` (the "Derives from" column above) — a generator never
|
|||
needs to know our var names, and `theme.css` + the page bundles stay
|
||||
untouched.
|
||||
|
||||
**Two rows, one contract.** `colors.css` ships both a dark default
|
||||
**Two palettes, one contract.** `colors.css` ships both a dark default
|
||||
(Catppuccin Mocha, unconditional) and a light default (Catppuccin Latte,
|
||||
behind `@media (prefers-color-scheme: light)`) — a theme generator that
|
||||
overrides the file wholesale (e.g. the stylix path, which writes a single
|
||||
unconditional `:root` block with no media query) supersedes both rows at
|
||||
once, same as today.
|
||||
behind `@media (prefers-color-scheme: light)`), plus a per-user override
|
||||
path (`:root[data-theme]`) — a theme generator that overrides the file
|
||||
wholesale (e.g. the stylix path, which writes a single unconditional
|
||||
`:root` block with no media query, no `--mocha-*`/`--latte-*` vars, no
|
||||
`[data-theme]` blocks) supersedes all of it at once, same as today.
|
||||
|
||||
`colors.css` base16 slot defaults (Catppuccin Mocha, dark default):
|
||||
|
||||
|
|
|
|||
|
|
@ -7,93 +7,174 @@
|
|||
with nothing else to regenerate.
|
||||
|
||||
Kept as its own standalone stylesheet (not @import-ed into theme.css)
|
||||
so a swap replaces just this ~16-line file — `theme.css` and the page
|
||||
bundles stay untouched. Every page links this BEFORE `theme.css`. The
|
||||
hex defaults are Catppuccin Mocha. Standard base16 slot meanings are
|
||||
in the trailing comments. See docs/web-ui/css-vars.md. */
|
||||
so a swap replaces just this file — `theme.css` and the page bundles
|
||||
stay untouched. Every page links this BEFORE `theme.css`. See
|
||||
docs/web-ui/css-vars.md.
|
||||
|
||||
Two raw palettes, one declaration each — `--mocha-baseNN` (dark,
|
||||
Catppuccin Mocha) and `--latte-baseNN` (light, Catppuccin-Latte-
|
||||
derived; base08-0F are darkened from stock Latte, see the comment
|
||||
above those declarations for why). The four blocks below this one
|
||||
never restate a hex value — each just points the active `--baseNN`
|
||||
slot at one of these two raw palettes via `var()`, so "which theme is
|
||||
active" and "what a theme's colours are" can't drift apart the way a
|
||||
hand-duplicated copy could. */
|
||||
:root {
|
||||
--base00: #1e1e2e; /* default bg */
|
||||
--base01: #181825; /* lighter bg (elevated surfaces) */
|
||||
--base02: #313244; /* selection / surface — borders */
|
||||
--base03: #45475a; /* comments / dim surface */
|
||||
--base04: #585b70; /* dark foreground */
|
||||
--base05: #cdd6f4; /* default foreground */
|
||||
--base06: #f5e0dc; /* light foreground */
|
||||
--base07: #b4befe; /* lightest */
|
||||
--base08: #f38ba8; /* red */
|
||||
--base09: #fab387; /* orange / peach */
|
||||
--base0A: #f9e2af; /* yellow */
|
||||
--base0B: #a6e3a1; /* green */
|
||||
--base0C: #89dceb; /* cyan (our sky — note: Catppuccin's base0C is teal) */
|
||||
--base0D: #89b4fa; /* blue */
|
||||
--base0E: #cba6f7; /* magenta / mauve */
|
||||
--base0F: #f5c2e7; /* extra accent — pink */
|
||||
--mocha-base00: #1e1e2e; /* default bg */
|
||||
--mocha-base01: #181825; /* lighter bg (elevated surfaces) */
|
||||
--mocha-base02: #313244; /* selection / surface — borders */
|
||||
--mocha-base03: #45475a; /* comments / dim surface */
|
||||
--mocha-base04: #585b70; /* dark foreground */
|
||||
--mocha-base05: #cdd6f4; /* default foreground */
|
||||
--mocha-base06: #f5e0dc; /* light foreground */
|
||||
--mocha-base07: #b4befe; /* lightest */
|
||||
--mocha-base08: #f38ba8; /* red */
|
||||
--mocha-base09: #fab387; /* orange / peach */
|
||||
--mocha-base0A: #f9e2af; /* yellow */
|
||||
--mocha-base0B: #a6e3a1; /* green */
|
||||
--mocha-base0C: #89dceb; /* cyan (our sky — note: Catppuccin's base0C is teal) */
|
||||
--mocha-base0D: #89b4fa; /* blue */
|
||||
--mocha-base0E: #cba6f7; /* magenta / mauve */
|
||||
--mocha-base0F: #f5c2e7; /* extra accent — pink */
|
||||
|
||||
--latte-base00: #eff1f5; /* default bg */
|
||||
--latte-base01: #e6e9ef; /* lighter bg (elevated surfaces) */
|
||||
--latte-base02: #ccd0da; /* selection / surface — borders */
|
||||
--latte-base03: #bcc0cc; /* comments / dim surface */
|
||||
--latte-base04: #acb0be; /* dark foreground */
|
||||
--latte-base05: #4c4f69; /* default foreground */
|
||||
--latte-base06: #dc8a78; /* light foreground */
|
||||
--latte-base07: #7287fd; /* lightest */
|
||||
/* base08-0F: darkened from stock Catppuccin Latte's own accent hexes
|
||||
(same hue/saturation, lower HSL lightness), not a literal upstream
|
||||
port like base00-07 above. A review round computed real WCAG
|
||||
contrast ratios and found the literal Latte accents fail badly as
|
||||
`StatusChip` fill-text (green/amber/red/yellow on
|
||||
`--purple-dim`/base03: 1.4:1-3:1, need 4.5:1) and even as plain
|
||||
text on `--bg`/base00 in the agent/dashboard packages that also
|
||||
consume this same file (2.3:1-4.8:1). Root cause: Latte's own
|
||||
accent colors are calibrated against Latte's near-white
|
||||
`base`/`crust`, not against a mid-gray `surface1` — the Mocha
|
||||
palette doesn't have this problem because Mocha's pastel accents
|
||||
are already *light*, so they contrast fine against a *dark*
|
||||
surface1; Latte's saturated-but-mid-brightness accents don't have
|
||||
the equivalent headroom against Latte's own (much lighter)
|
||||
surface1. Each value below was picked by holding the stock Latte
|
||||
hue+saturation fixed and binary-searching HSL lightness down to
|
||||
the point real contrast against base03 clears 4.5:1 with a small
|
||||
margin (also verified against base00: all land above 7:1 there) —
|
||||
same hue identity, same base16 role, just dark/saturated enough to
|
||||
actually be legible as filled-chip or plain text either way.
|
||||
Yellow and peach read closer to olive/brown than a bright
|
||||
yellow/orange once darkened this far — an inherent property of
|
||||
darkening a warm hue in sRGB (Bezold-Brücke shift), not a mapping
|
||||
mistake; boosting saturation further didn't rescue it (verified). */
|
||||
--latte-base08: #9c0b2a; /* red */
|
||||
--latte-base09: #883201; /* orange / peach */
|
||||
--latte-base0A: #6d450e; /* yellow */
|
||||
--latte-base0B: #235818; /* green */
|
||||
--latte-base0C: #025374; /* cyan (our sky — note: Catppuccin's base0C is teal) */
|
||||
--latte-base0D: #0843b8; /* blue */
|
||||
--latte-base0E: #6311ce; /* magenta / mauve */
|
||||
--latte-base0F: #8f166e; /* extra accent — pink */
|
||||
|
||||
/* Default activation: Mocha, unless the media query below or a
|
||||
per-user override (further below) says otherwise. */
|
||||
--base00: var(--mocha-base00);
|
||||
--base01: var(--mocha-base01);
|
||||
--base02: var(--mocha-base02);
|
||||
--base03: var(--mocha-base03);
|
||||
--base04: var(--mocha-base04);
|
||||
--base05: var(--mocha-base05);
|
||||
--base06: var(--mocha-base06);
|
||||
--base07: var(--mocha-base07);
|
||||
--base08: var(--mocha-base08);
|
||||
--base09: var(--mocha-base09);
|
||||
--base0A: var(--mocha-base0A);
|
||||
--base0B: var(--mocha-base0B);
|
||||
--base0C: var(--mocha-base0C);
|
||||
--base0D: var(--mocha-base0D);
|
||||
--base0E: var(--mocha-base0E);
|
||||
--base0F: var(--mocha-base0F);
|
||||
}
|
||||
|
||||
/* Light-mode default. base00-07 (surfaces/foreground) are stock
|
||||
Catppuccin Latte — no contrast issue there, they're the ends of the
|
||||
bg/fg scale. base08-0F (the eight chromatic "tone" slots: red through
|
||||
pink) are deliberately NOT stock Latte hexes — see the comment right
|
||||
above those declarations below for why; don't reuse stock Catppuccin
|
||||
Latte's own accent hexes there if this ever gets regenerated.
|
||||
|
||||
This is a *default*, not a user preference store: it only applies
|
||||
when nothing more specific has already decided the palette. A
|
||||
stylix-generated colors.css (nix/host-modules/hive-c0re/theme.nix)
|
||||
replaces this entire file wholesale — a fixed, unconditional `:root`
|
||||
block with no media query in it at all — so a stylix-themed
|
||||
deployment is naturally unaffected by, and never fights, this block.
|
||||
Some people need light for low-vision/contrast reasons, others need
|
||||
dark for photosensitivity, so respect the OS/browser signal by
|
||||
default the same way `prefers-reduced-motion` already is elsewhere —
|
||||
rather than always forcing the bundled dark palette on a client with
|
||||
no stylix session of its own (e.g. a phone browser hitting swarm-ui
|
||||
directly). A future per-user override takes precedence over this
|
||||
media query by simply setting the vars later in the cascade (e.g.
|
||||
via an inline `style` on `:root`, which always outranks a
|
||||
stylesheet rule). */
|
||||
/* OS/browser-level light-mode default. This is a *default*, not a user
|
||||
preference store: it only applies when nothing more specific has
|
||||
already decided the palette. A stylix-generated colors.css
|
||||
(nix/host-modules/hive-c0re/theme.nix) replaces this entire file
|
||||
wholesale — a fixed, unconditional `:root` block with no media query
|
||||
in it at all — so a stylix-themed deployment is naturally unaffected
|
||||
by, and never fights, this block. Some people need light for
|
||||
low-vision/contrast reasons, others need dark for photosensitivity,
|
||||
so respect the OS/browser signal by default the same way
|
||||
`prefers-reduced-motion` already is elsewhere — rather than always
|
||||
forcing the bundled dark palette on a client with no stylix session
|
||||
of its own (e.g. a phone browser hitting swarm-ui directly). */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--base00: #eff1f5; /* default bg */
|
||||
--base01: #e6e9ef; /* lighter bg (elevated surfaces) */
|
||||
--base02: #ccd0da; /* selection / surface — borders */
|
||||
--base03: #bcc0cc; /* comments / dim surface */
|
||||
--base04: #acb0be; /* dark foreground */
|
||||
--base05: #4c4f69; /* default foreground */
|
||||
--base06: #dc8a78; /* light foreground */
|
||||
--base07: #7287fd; /* lightest */
|
||||
/* base08-0F: darkened from stock Catppuccin Latte's own accent
|
||||
hexes (same hue/saturation, lower HSL lightness), not a literal
|
||||
upstream port like base00-07 above. argus's review on this PR
|
||||
computed real WCAG contrast ratios and found the literal Latte
|
||||
accents fail badly as `StatusChip` fill-text (green/amber/red/
|
||||
yellow on `--purple-dim`/base03: 1.4:1-3:1, need 4.5:1) and even
|
||||
as plain text on `--bg`/base00 in the agent/dashboard packages
|
||||
that also consume this same file (2.3:1-4.8:1). Root cause:
|
||||
Latte's own accent colors are calibrated against Latte's
|
||||
near-white `base`/`crust`, not against a mid-gray `surface1` —
|
||||
the Mocha row doesn't have this problem because Mocha's pastel
|
||||
accents are already *light*, so they contrast fine against a
|
||||
*dark* surface1; Latte's saturated-but-mid-brightness accents
|
||||
don't have the equivalent headroom against Latte's own
|
||||
(much lighter) surface1. Each value below was picked by holding
|
||||
the stock Latte hue+saturation fixed and binary-searching HSL
|
||||
lightness down to the point real contrast against base03 clears
|
||||
4.5:1 with a small margin (also verified against base00: all
|
||||
land above 7:1 there) — same hue identity, same base16 role,
|
||||
just dark/saturated enough to actually be legible as filled-chip
|
||||
or plain text either way. Yellow and peach read closer to
|
||||
olive/brown than a bright yellow/orange once darkened this far —
|
||||
an inherent property of darkening a warm hue in sRGB (Bezold-
|
||||
Brücke shift), not a mapping mistake; boosting saturation
|
||||
further didn't rescue it (verified). */
|
||||
--base08: #9c0b2a; /* red */
|
||||
--base09: #883201; /* orange / peach */
|
||||
--base0A: #6d450e; /* yellow */
|
||||
--base0B: #235818; /* green */
|
||||
--base0C: #025374; /* cyan (our sky — note: Catppuccin's base0C is teal) */
|
||||
--base0D: #0843b8; /* blue */
|
||||
--base0E: #6311ce; /* magenta / mauve */
|
||||
--base0F: #8f166e; /* extra accent — pink */
|
||||
--base00: var(--latte-base00);
|
||||
--base01: var(--latte-base01);
|
||||
--base02: var(--latte-base02);
|
||||
--base03: var(--latte-base03);
|
||||
--base04: var(--latte-base04);
|
||||
--base05: var(--latte-base05);
|
||||
--base06: var(--latte-base06);
|
||||
--base07: var(--latte-base07);
|
||||
--base08: var(--latte-base08);
|
||||
--base09: var(--latte-base09);
|
||||
--base0A: var(--latte-base0A);
|
||||
--base0B: var(--latte-base0B);
|
||||
--base0C: var(--latte-base0C);
|
||||
--base0D: var(--latte-base0D);
|
||||
--base0E: var(--latte-base0E);
|
||||
--base0F: var(--latte-base0F);
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-user override (swarm-ui's `SettingsMenu` / `lib/theme-apply.ts`
|
||||
sets `data-theme` on `<html>`; unset/`"system"` removes the
|
||||
attribute and lets the media query above decide, same as before this
|
||||
existed). `:root[data-theme='light']` has higher specificity than
|
||||
the plain `:root` inside the media query above (an attribute
|
||||
selector always outranks none), so this wins regardless of the
|
||||
actual OS preference or source order — no `!important`, no inline
|
||||
style, and critically no second copy of either palette's hex values:
|
||||
both blocks below just re-point at the same `--mocha-baseNN` /
|
||||
`--latte-baseNN` custom properties declared once at the top of this
|
||||
file. */
|
||||
:root[data-theme='light'] {
|
||||
--base00: var(--latte-base00);
|
||||
--base01: var(--latte-base01);
|
||||
--base02: var(--latte-base02);
|
||||
--base03: var(--latte-base03);
|
||||
--base04: var(--latte-base04);
|
||||
--base05: var(--latte-base05);
|
||||
--base06: var(--latte-base06);
|
||||
--base07: var(--latte-base07);
|
||||
--base08: var(--latte-base08);
|
||||
--base09: var(--latte-base09);
|
||||
--base0A: var(--latte-base0A);
|
||||
--base0B: var(--latte-base0B);
|
||||
--base0C: var(--latte-base0C);
|
||||
--base0D: var(--latte-base0D);
|
||||
--base0E: var(--latte-base0E);
|
||||
--base0F: var(--latte-base0F);
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
--base00: var(--mocha-base00);
|
||||
--base01: var(--mocha-base01);
|
||||
--base02: var(--mocha-base02);
|
||||
--base03: var(--mocha-base03);
|
||||
--base04: var(--mocha-base04);
|
||||
--base05: var(--mocha-base05);
|
||||
--base06: var(--mocha-base06);
|
||||
--base07: var(--mocha-base07);
|
||||
--base08: var(--mocha-base08);
|
||||
--base09: var(--mocha-base09);
|
||||
--base0A: var(--mocha-base0A);
|
||||
--base0B: var(--mocha-base0B);
|
||||
--base0C: var(--mocha-base0C);
|
||||
--base0D: var(--mocha-base0D);
|
||||
--base0E: var(--mocha-base0E);
|
||||
--base0F: var(--mocha-base0F);
|
||||
}
|
||||
|
|
|
|||
41
frontend/packages/swarm-ui/src/lib/motion-apply.ts
Normal file
41
frontend/packages/swarm-ui/src/lib/motion-apply.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Applies the stored reduced-motion override (see `settings-storage.ts`)
|
||||
// as a `data-motion="reduce"|"allow"` attribute on `<html>`, cleared
|
||||
// when the override is "system" (letting `prefers-reduced-motion`
|
||||
// alone decide, same as today).
|
||||
//
|
||||
// Currently inert: swarm-ui has zero CSS animations as of this file's
|
||||
// writing (the design guide's "playful whimsy" motion — the matrix-rain
|
||||
// home background — lives in the dashboard package, not swarm-ui). This
|
||||
// hook exists because the settings surface itself was scoped to cover
|
||||
// theme *and* motion together (both named in the same go-ahead), and the
|
||||
// marginal cost of the storage/attribute plumbing is near zero riding
|
||||
// alongside the theme override's real wiring — but there is genuinely
|
||||
// nothing in swarm-ui for `data-motion` to gate yet. The first swarm-ui
|
||||
// animation's own CSS is what makes this do anything, e.g.:
|
||||
// @media (prefers-reduced-motion: reduce) { ... }
|
||||
// :root[data-motion='reduce'] { ... same rule ... }
|
||||
// :root[data-motion='allow'] { /* opt back in despite OS-level reduce */ }
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocalSetting } from './settings-storage.js';
|
||||
|
||||
export type MotionOverride = 'system' | 'reduce' | 'allow';
|
||||
|
||||
export const MOTION_OVERRIDE_KEY = 'swarm-ui:motion-override';
|
||||
|
||||
export function useMotionOverride() {
|
||||
return useLocalSetting<MotionOverride>(MOTION_OVERRIDE_KEY, 'system');
|
||||
}
|
||||
|
||||
// Mounted once alongside `useApplyThemeOverride` — see that module's
|
||||
// comment for why `Shell` is the right single mount point.
|
||||
export function useApplyMotionOverride(): void {
|
||||
const [override] = useMotionOverride();
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (override === 'system') {
|
||||
delete root.dataset.motion;
|
||||
} else {
|
||||
root.dataset.motion = override;
|
||||
}
|
||||
}, [override]);
|
||||
}
|
||||
75
frontend/packages/swarm-ui/src/lib/settings-storage.ts
Normal file
75
frontend/packages/swarm-ui/src/lib/settings-storage.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Generic localStorage-backed setting — the shared plumbing the theme
|
||||
// override and reduced-motion override both need ("read a stored value,
|
||||
// react to it changing, write a new one") rather than each reinventing
|
||||
// its own key handling. Not settings-page-specific: any future
|
||||
// client-local preference reaches for `useLocalSetting` directly.
|
||||
//
|
||||
// `localStorage`'s own `storage` event only fires in *other* tabs, never
|
||||
// the tab that made the write — so two components in the same tab both
|
||||
// watching the same key (e.g. `SettingsMenu` writing, a top-level effect
|
||||
// reading) need their own same-tab signal. The tiny module-level pub/sub
|
||||
// below is that signal; it's deliberately not exported, callers only see
|
||||
// the hook.
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
function subscribe(key: string, onChange: () => void): () => void {
|
||||
let set = listeners.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(key, set);
|
||||
}
|
||||
set.add(onChange);
|
||||
return () => {
|
||||
set!.delete(onChange);
|
||||
if (set!.size === 0) listeners.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
function notify(key: string): void {
|
||||
listeners.get(key)?.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// Reads outside a component (e.g. an early top-level check) can call
|
||||
// this directly; `useLocalSetting` builds on it rather than duplicating
|
||||
// the try/catch.
|
||||
export function readLocalSetting<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : (JSON.parse(raw) as T);
|
||||
} catch {
|
||||
// Private-browsing storage bans, a corrupted value, quota errors —
|
||||
// all the same answer: behave as if nothing were stored.
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalSetting<T>(key: string, value: T): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Best-effort: the setting just won't persist this time, not worth
|
||||
// surfacing an error for a client-local preference.
|
||||
}
|
||||
notify(key);
|
||||
}
|
||||
|
||||
// `[value, setValue]`, same shape as `useState` — deliberately, so a
|
||||
// caller that later needs to swap a plain `useState` for a persisted
|
||||
// setting (or vice versa) changes one line, not its whole call site.
|
||||
//
|
||||
// `setValue` only writes + notifies; it doesn't also call this
|
||||
// component's own `setValue` state setter directly. The subscription
|
||||
// below already fires for a same-instance write (it's in the same
|
||||
// `listeners` set as every other subscriber), so a self-write reaches
|
||||
// this component's state through the identical "react to a change"
|
||||
// path every other subscriber uses — one path, not two that have to
|
||||
// agree.
|
||||
export function useLocalSetting<T>(key: string, fallback: T): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState<T>(() => readLocalSetting(key, fallback));
|
||||
|
||||
useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]);
|
||||
|
||||
return [value, (next: T) => writeLocalSetting(key, next)];
|
||||
}
|
||||
38
frontend/packages/swarm-ui/src/lib/theme-apply.ts
Normal file
38
frontend/packages/swarm-ui/src/lib/theme-apply.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Applies the stored theme override (see `settings-storage.ts`) by
|
||||
// setting `data-theme="light"|"dark"` on `<html>`, cleared when the
|
||||
// override is "system" (letting the `prefers-color-scheme` media
|
||||
// query in `@hive/shared/colors.css` resume control). No palette
|
||||
// values live here — `colors.css`'s own `:root[data-theme='light']` /
|
||||
// `:root[data-theme='dark']` blocks are what actually apply the
|
||||
// colours, by re-pointing at the same `--mocha-baseNN` /
|
||||
// `--latte-baseNN` custom properties its media-query block already
|
||||
// uses. This file only ever toggles the attribute; the values come
|
||||
// from colors.css, not a duplicate copy here.
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocalSetting } from './settings-storage.js';
|
||||
|
||||
export type ThemeOverride = 'system' | 'light' | 'dark';
|
||||
|
||||
export const THEME_OVERRIDE_KEY = 'swarm-ui:theme-override';
|
||||
|
||||
export function useThemeOverride() {
|
||||
return useLocalSetting<ThemeOverride>(THEME_OVERRIDE_KEY, 'system');
|
||||
}
|
||||
|
||||
// Mounted once, high in the tree (`Shell`) — every route renders
|
||||
// through one `<Shell>`, so one mount point applies the override
|
||||
// regardless of which page is showing, and `useLocalSetting`'s
|
||||
// same-tab subscription means `SettingsMenu` changing the value
|
||||
// re-runs this effect without either component needing to know about
|
||||
// the other directly.
|
||||
export function useApplyThemeOverride(): void {
|
||||
const [override] = useThemeOverride();
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (override === 'system') {
|
||||
delete root.dataset.theme;
|
||||
} else {
|
||||
root.dataset.theme = override;
|
||||
}
|
||||
}, [override]);
|
||||
}
|
||||
|
|
@ -4,10 +4,11 @@
|
|||
as the shared `ui/` kit (2.75em ≈ 44px, WCAG 2.5.5) even though it
|
||||
isn't built from that kit — a bespoke icon trigger, not a form
|
||||
control, but the floor applies regardless of which component drew
|
||||
it. */
|
||||
it. No `margin-left: auto` here — `Shell.tsx`'s `.shell-header-actions`
|
||||
wrapper owns pushing the whole action group to the right edge now
|
||||
that `SettingsMenu` sits alongside this one. */
|
||||
.links-menu {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
.links-menu-button {
|
||||
display: flex;
|
||||
|
|
|
|||
61
frontend/packages/swarm-ui/src/shell/SettingsMenu.css
Normal file
61
frontend/packages/swarm-ui/src/shell/SettingsMenu.css
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/* <SettingsMenu> — same header icon-button + popover chrome as
|
||||
`LinksMenu` (quiet until interacted with, 2.75em touch-target floor
|
||||
on the trigger). Kept as its own stylesheet rather than sharing
|
||||
`LinksMenu.css` classes — two small, independently-evolving popovers
|
||||
that happen to look alike today, not one component with two skins. */
|
||||
.settings-menu {
|
||||
position: relative;
|
||||
}
|
||||
.settings-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75em;
|
||||
height: 2.75em;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.4em;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-menu-button:hover,
|
||||
.settings-menu-button[aria-expanded='true'] {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.settings-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4em);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
min-width: 12em;
|
||||
padding: 0.5em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.settings-menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75em;
|
||||
min-height: 2.75em;
|
||||
padding: 0 0.3em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.settings-menu-row select {
|
||||
min-height: 2.75em;
|
||||
padding: 0 0.4em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35em;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
87
frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx
Normal file
87
frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// <SettingsMenu> — a single header icon-button + popover holding the
|
||||
// client-local overrides swarm-ui currently has (theme, reduced
|
||||
// motion). Mirrors `LinksMenu`'s shape (icon button, popover, close on
|
||||
// outside-click/Escape) — same "quiet chrome, not another nav item"
|
||||
// affordance, not a coincidence: the operator, when asked how big this
|
||||
// settings surface should be, said "small thing somewhere" since there
|
||||
// are only two overrides right now — a full `/settings` route + nav
|
||||
// entry would be over-building for two selects. Grows into a real page
|
||||
// only if the setting count outgrows a popover; nothing here assumes
|
||||
// it can't.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useThemeOverride, type ThemeOverride } from '../lib/theme-apply.js';
|
||||
import { useMotionOverride, type MotionOverride } from '../lib/motion-apply.js';
|
||||
import './SettingsMenu.css';
|
||||
|
||||
const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark'];
|
||||
const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce'];
|
||||
|
||||
export function SettingsMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [theme, setTheme] = useThemeOverride();
|
||||
const [motion, setMotion] = useMotionOverride();
|
||||
|
||||
// Close on an outside click or Escape — only listens while open, same
|
||||
// pattern (and same rationale) as `LinksMenu`.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div class="settings-menu" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-menu-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label="settings"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
{open ? (
|
||||
<div class="settings-menu-popover" role="menu">
|
||||
<label class="settings-menu-row">
|
||||
<span>theme</span>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme((e.target as HTMLSelectElement).value as ThemeOverride)}
|
||||
>
|
||||
{THEME_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label class="settings-menu-row">
|
||||
<span>motion</span>
|
||||
<select
|
||||
value={motion}
|
||||
onChange={(e) => setMotion((e.target as HTMLSelectElement).value as MotionOverride)}
|
||||
>
|
||||
{MOTION_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,15 @@
|
|||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
}
|
||||
/* Holds `SettingsMenu` + `LinksMenu` — one `margin-left: auto` on the
|
||||
wrapper, not one on each child (see Shell.tsx's comment for why two
|
||||
adjacent auto margins don't sit flush together). */
|
||||
.shell-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Touch target and active-state underline are deliberately on two
|
||||
different elements. An earlier version put both `min-height: 2.75em`
|
||||
and `border-bottom` on the same box: centering the text within a
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import { useEffect, useState } from 'preact/hooks';
|
|||
import type { ComponentChildren } from 'preact';
|
||||
import { Link, useRoute } from 'wouter-preact';
|
||||
import { LinksMenu } from './LinksMenu.js';
|
||||
import { SettingsMenu } from './SettingsMenu.js';
|
||||
import { useApplyThemeOverride } from '../lib/theme-apply.js';
|
||||
import { useApplyMotionOverride } from '../lib/motion-apply.js';
|
||||
import './Shell.css';
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string }[] = [
|
||||
|
|
@ -47,6 +50,15 @@ function NavLink({ href, label }: { href: string; label: string }) {
|
|||
export function Shell({ children }: { children: ComponentChildren }) {
|
||||
const [swarmName, setSwarmName] = useState<string | null>(null);
|
||||
|
||||
// Applied once here, not inside `SettingsMenu` — every route mounts
|
||||
// through this one `<Shell>`, so the override takes effect regardless
|
||||
// of which page is showing or whether the menu's ever been opened,
|
||||
// and `useLocalSetting`'s same-tab subscription (settings-storage.ts)
|
||||
// means `SettingsMenu` changing the stored value re-runs these
|
||||
// effects without either component needing a reference to the other.
|
||||
useApplyThemeOverride();
|
||||
useApplyMotionOverride();
|
||||
|
||||
// Fetched once here, not per-page: every route mounts inside one
|
||||
// `<Shell>`, and the swarm's name doesn't change within a page
|
||||
// visit. A fetch failure is silently ignored — `swarmName` just stays
|
||||
|
|
@ -78,7 +90,14 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
<NavLink key={item.href} href={item.href} label={item.label} />
|
||||
))}
|
||||
</nav>
|
||||
<LinksMenu />
|
||||
{/* Single `margin-left: auto` on the wrapper, not on each menu
|
||||
individually — two adjacent flex items both set to
|
||||
`margin-left: auto` split the available space between them
|
||||
instead of sitting flush together at the right edge. */}
|
||||
<div class="shell-header-actions">
|
||||
<SettingsMenu />
|
||||
<LinksMenu />
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell-body">{children}</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue