Compare commits
147 Commits
48edb0391e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c63ac25b | |||
| 62c569741f | |||
| 3ad0e56ecd | |||
| ada5d06dad | |||
| 3a57c0a77c | |||
| 9a896bb5f6 | |||
| 78f5afad35 | |||
| 27205c65ef | |||
| 091a1a651d | |||
| ec11c9dc76 | |||
| 1d19ddfd96 | |||
| 79a6567530 | |||
| fe448034ac | |||
| 67c5da57bf | |||
| 53405d7831 | |||
| e615260422 | |||
| 3b6441688f | |||
| a0b7dcdc2d | |||
| 7f9cf9fe60 | |||
| b83149e0bb | |||
| 80c2aad298 | |||
| b5756e16b5 | |||
| b3f061ced7 | |||
| eec3a261b4 | |||
| 390f6897a8 | |||
| 8b881f369b | |||
| aef5000543 | |||
| 878db9a37b | |||
| 0b44bc0855 | |||
| 79ee402b33 | |||
| 64f35e5a57 | |||
| 3aff10557c | |||
| e8fe24f755 | |||
| fc170ccf10 | |||
| 3ae9d87e6e | |||
| 3dbede6bc2 | |||
| ba238ca962 | |||
| 7cabebc338 | |||
| 74cde67a54 | |||
| 900f85f8ac | |||
| 00a7ce772e | |||
| 71dee23028 | |||
| 91716e628a | |||
| 002af9d1f8 | |||
| d8d8035850 | |||
| 704b159d48 | |||
| c1bddb47c4 | |||
| a21ab85576 | |||
| 7ddf6967ce | |||
| 404cf67f35 | |||
| 50d2512123 | |||
| c689b8c0e9 | |||
| acdaf8d07f | |||
| 77c56f7a9d | |||
| 030472c2da | |||
| f1eb6a9ba5 | |||
| 285a1323ad | |||
| da3e078fbc | |||
| 0def81ab42 | |||
| 546680017d | |||
| 3efb7e175d | |||
| 56076c4daa | |||
| aeb1b084d9 | |||
| 6e02ac874f | |||
| dd131ee740 | |||
| cad5a980c5 | |||
| 17bfd3e9d8 | |||
| d90aa75468 | |||
| 7a43f794e5 | |||
| af3f1a5367 | |||
| ec6e90ef5b | |||
| 3c59f47f81 | |||
| 76f65a95dd | |||
| a0aab6571f | |||
| 6e72f24f0a | |||
| d447e2d8a8 | |||
| a9a0c4d477 | |||
| c0c86a5859 | |||
| faca2670a4 | |||
| c68bbb9460 | |||
| 30da072d96 | |||
| 1cdfa21259 | |||
| d37ac821f0 | |||
| 150ca63fc0 | |||
| d082836529 | |||
| 69d3d2be15 | |||
| 57504c941d | |||
| 4530004d87 | |||
| 1948d09d16 | |||
| 4c24f0387c | |||
| 0209638552 | |||
| 2b6ea1b4a4 | |||
| 3575282dc2 | |||
| 882d0c828f | |||
| 75e7cf9047 | |||
| 76b2cbde1d | |||
| 6c2fa63cac | |||
| a4fb05a175 | |||
| 0678cefd13 | |||
| 53c98102d2 | |||
| 0d4026a968 | |||
| d0da77a004 | |||
| 6bce1e6782 | |||
| 506bfd63dd | |||
| f45f1d8807 | |||
| ede32551be | |||
| 71d899cbdc | |||
| 09e9b3f4d4 | |||
| e54ea89b1e | |||
| 3782120b49 | |||
| 28e444c6c5 | |||
| d3ee4365e0 | |||
| e18cad9c6a | |||
| 537b847acb | |||
| 3900bc362c | |||
| ed0c13907c | |||
| f3881e8c7c | |||
| 6ed137f49e | |||
| e005e76f5b | |||
| b7242caf51 | |||
| 6efe09d40c | |||
| 5c8fe3cd81 | |||
| 4b55218c69 | |||
| af6004f731 | |||
| 18cb35beff | |||
| dbaf22500e | |||
| 4fad3c43f0 | |||
| e4badbdefc | |||
| 285d35601b | |||
| 9b3a587eab | |||
| 8511aebb53 | |||
| 6e1f5ea50f | |||
| 70025e1e71 | |||
| 40384d91dd | |||
| d3e88be70f | |||
| 03f6e1d7ed | |||
| aab1bb37dc | |||
| 9323c608ee | |||
| eead013ccd | |||
| 4f3db60ed2 | |||
| 6d17e5f84d | |||
| d452dd9b35 | |||
| e5c03383fe | |||
| 5e7a80e377 | |||
| 5d63f06863 | |||
| d0e3772c34 | |||
| a9e6788b0b |
@@ -7,7 +7,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
web:
|
web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: aceofba-cluster
|
||||||
|
container:
|
||||||
|
image: ghcr.io/catthehacker/ubuntu:act-22.04
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: web
|
working-directory: web
|
||||||
@@ -18,12 +20,13 @@ jobs:
|
|||||||
version: 11
|
version: 11
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/pnpm-lock.yaml
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
|
- run: pnpm exec playwright install --with-deps chromium
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
- run: pnpm check:size
|
- run: pnpm check:size
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ cargo clippy --workspace --all-targets -- -D warnings # lint before committing
|
|||||||
- **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.)
|
- **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.)
|
||||||
- **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand.
|
- **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand.
|
||||||
- **Formatting:** `cargo +nightly fmt` (nightly toolchain required).
|
- **Formatting:** `cargo +nightly fmt` (nightly toolchain required).
|
||||||
|
- **Frontend guardrails:** before touching `web/`, read **[web/GUARDRAILS.md](web/GUARDRAILS.md)** — it covers the CI gate (`check:size` 250 KB-gz budget, `check:colors` design-token enforcement) and the test-harness quirks (MSW `onUnhandledRequest: "error"`, the jsdom/storybook vitest split, RTL accessible-name collisions, Storybook nested-router and portal handling, and the `components/ui/` code-style split).
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub(crate) struct SearchHitView {
|
|||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
#[schema(value_type = domain::Visibility)]
|
#[schema(value_type = domain::Visibility)]
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
pub snippet: Option<String>,
|
pub snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ pub(crate) async fn search_objects(
|
|||||||
object_name: h.object_name,
|
object_name: h.object_name,
|
||||||
brief_description: h.brief_description,
|
brief_description: h.brief_description,
|
||||||
visibility: h.visibility,
|
visibility: h.visibility,
|
||||||
|
recording_date: h.recording_date,
|
||||||
snippet: h.snippet,
|
snippet: h.snippet,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub struct SearchDocument {
|
|||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
pub current_owner: Option<String>,
|
pub current_owner: Option<String>,
|
||||||
pub recorder: Option<String>,
|
pub recorder: Option<String>,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
/// Filterable: "draft" | "internal" | "public".
|
/// Filterable: "draft" | "internal" | "public".
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
/// Flexible field values flattened to searchable text.
|
/// Flexible field values flattened to searchable text.
|
||||||
@@ -55,6 +56,7 @@ pub struct SearchHit {
|
|||||||
pub object_name: String,
|
pub object_name: String,
|
||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
pub snippet: Option<String>,
|
pub snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +235,7 @@ impl SearchClient {
|
|||||||
object_name: doc.object_name,
|
object_name: doc.object_name,
|
||||||
brief_description: doc.brief_description,
|
brief_description: doc.brief_description,
|
||||||
visibility: doc.visibility,
|
visibility: doc.visibility,
|
||||||
|
recording_date: doc.recording_date,
|
||||||
snippet,
|
snippet,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -367,6 +370,7 @@ pub async fn build_document(
|
|||||||
brief_description: object.brief_description.clone(),
|
brief_description: object.brief_description.clone(),
|
||||||
current_owner: object.current_owner.clone(),
|
current_owner: object.current_owner.clone(),
|
||||||
recorder: object.recorder.clone(),
|
recorder: object.recorder.clone(),
|
||||||
|
recording_date: object.recording_date.map(|d| d.to_string()),
|
||||||
visibility: object.visibility.as_str().to_owned(),
|
visibility: object.visibility.as_str().to_owned(),
|
||||||
fields_text,
|
fields_text,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
|
|||||||
brief_description: None,
|
brief_description: None,
|
||||||
current_owner: None,
|
current_owner: None,
|
||||||
recorder: None,
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
visibility: "draft".to_string(),
|
visibility: "draft".to_string(),
|
||||||
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
|
|||||||
&["cast bronze with green patina"],
|
&["cast bronze with green patina"],
|
||||||
);
|
);
|
||||||
bronze_a.visibility = "public".to_string();
|
bronze_a.visibility = "public".to_string();
|
||||||
|
bronze_a.recording_date = Some("1962-04-03".to_string());
|
||||||
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
|
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
|
||||||
bronze_b.visibility = "public".to_string();
|
bronze_b.visibility = "public".to_string();
|
||||||
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
|
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
|
||||||
@@ -87,6 +89,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
|
|||||||
"snippet must mark the match"
|
"snippet must mark the match"
|
||||||
);
|
);
|
||||||
assert!(snippet.contains(search::HL_POST));
|
assert!(snippet.contains(search::HL_POST));
|
||||||
|
assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));
|
||||||
|
|
||||||
let public = client
|
let public = client
|
||||||
.search_objects("bronze", Some("public"), 0, 20)
|
.search_objects("bronze", Some("public"), 0, 20)
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
# Dark-Mode Theme Toggle Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ship a tri-state (Light/Dark/System) theme toggle that activates the existing `.dark` token set, persists to `localStorage`, defaults to System (live-tracking the OS), and never flashes on reload.
|
||||||
|
|
||||||
|
**Architecture:** Client-only theming over CSS custom properties — no new dependency. A framework-free core (`theme.ts`) resolves/reads/applies the theme; a `useTheme` hook mirrors `use-locale`; a synchronous inline script in `index.html` applies the class before first paint; an icon segmented `ThemeSwitch` lives in the header next to `LangSwitch`. The `.dark` class on `<html>` activates the dark tokens migrated in #49.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), lucide-react (already a dep), Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (vitest, single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; source double-quote/semicolon, stories single-quote/no-semicolon; token classes only (no raw colors — `check:colors` must pass); guard DOM globals (`window`/`localStorage`/`matchMedia`/`document`) for jsdom/test safety.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md`
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
- `web/src/theme/theme.ts` (new) — `THEME_KEY`, `Theme`, `resolveTheme`, `readTheme`, `applyTheme`.
|
||||||
|
- `web/src/theme/theme.test.ts` (new) — unit tests for the core.
|
||||||
|
- `web/src/theme/use-theme.ts` (new) — `useTheme()` hook.
|
||||||
|
- `web/src/shell/theme-switch.tsx` (new) — the icon segmented control.
|
||||||
|
- `web/src/shell/theme-switch.test.tsx` (new) — interaction tests.
|
||||||
|
- `web/src/shell/theme-switch.stories.tsx` (new) — Storybook story.
|
||||||
|
- `web/src/shell/app-shell.tsx` (modify) — mount `<ThemeSwitch />`.
|
||||||
|
- `web/src/i18n/en.json`, `web/src/i18n/sv.json` (modify) — `theme.*` keys.
|
||||||
|
- `web/index.html` (modify) — inline FOUC-prevention script.
|
||||||
|
- `web/src/index.css` (modify) — dark `--primary`/`--ring` contrast tweak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Theme core (`theme.ts`) + unit tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/theme/theme.ts`
|
||||||
|
- Create: `web/src/theme/theme.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — `web/src/theme/theme.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { applyTheme, readTheme, resolveTheme, THEME_KEY } from "./theme";
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveTheme returns explicit values verbatim", () => {
|
||||||
|
expect(resolveTheme("light")).toBe("light");
|
||||||
|
expect(resolveTheme("dark")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveTheme maps system via prefers-color-scheme", () => {
|
||||||
|
mockMatchMedia(true);
|
||||||
|
expect(resolveTheme("system")).toBe("dark");
|
||||||
|
mockMatchMedia(false);
|
||||||
|
expect(resolveTheme("system")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readTheme defaults to system when unset or invalid", () => {
|
||||||
|
expect(readTheme()).toBe("system");
|
||||||
|
localStorage.setItem(THEME_KEY, "bogus");
|
||||||
|
expect(readTheme()).toBe("system");
|
||||||
|
localStorage.setItem(THEME_KEY, "dark");
|
||||||
|
expect(readTheme()).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyTheme toggles the dark class on documentElement", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
applyTheme("dark");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
applyTheme("light");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||||
|
mockMatchMedia(true);
|
||||||
|
applyTheme("system");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
|
||||||
|
Expected: FAIL — cannot import from `./theme` (module not found).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — `web/src/theme/theme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const THEME_KEY = "theme";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const THEMES: readonly Theme[] = ["light", "dark", "system"];
|
||||||
|
|
||||||
|
function prefersDark(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(theme: Theme): "light" | "dark" {
|
||||||
|
if (theme === "light" || theme === "dark") return theme;
|
||||||
|
return prefersDark() ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTheme(): Theme {
|
||||||
|
if (typeof localStorage === "undefined") return "system";
|
||||||
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
|
return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/theme/theme.ts web/src/theme/theme.test.ts
|
||||||
|
git commit -m "feat(web): theme core — resolve/read/apply tri-state theme (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: `useTheme` hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/theme/use-theme.ts`
|
||||||
|
|
||||||
|
(No standalone unit test — the hook is exercised by `theme-switch.test.tsx` in Task 3, which drives it through real UI per the project's testing style. `theme.ts` carries the logic and is unit-tested in Task 1.)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement** — `web/src/theme/use-theme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { applyTheme, readTheme, type Theme } from "./theme";
|
||||||
|
|
||||||
|
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(readTheme);
|
||||||
|
|
||||||
|
const setTheme = (next: Theme) => {
|
||||||
|
if (typeof localStorage !== "undefined") localStorage.setItem("theme", next);
|
||||||
|
setThemeState(next);
|
||||||
|
applyTheme(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
if (theme !== "system") return;
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||||
|
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const onChange = () => applyTheme("system");
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return { theme, setTheme };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: import `THEME_KEY` from `./theme` and use it instead of the literal `"theme"` for the
|
||||||
|
`localStorage.setItem` key (DRY with the core). Update the import line to
|
||||||
|
`import { applyTheme, readTheme, THEME_KEY, type Theme } from "./theme";` and use
|
||||||
|
`localStorage.setItem(THEME_KEY, next)`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm typecheck`
|
||||||
|
Expected: PASS (no errors).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/theme/use-theme.ts
|
||||||
|
git commit -m "feat(web): useTheme hook with live system tracking (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: `ThemeSwitch` UI + i18n + tests + story
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/shell/theme-switch.tsx`
|
||||||
|
- Create: `web/src/shell/theme-switch.test.tsx`
|
||||||
|
- Create: `web/src/shell/theme-switch.stories.tsx`
|
||||||
|
- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add i18n keys.** In `web/src/i18n/en.json`, add a top-level `theme` namespace (place after the `labels` entry):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
|
||||||
|
```
|
||||||
|
|
||||||
|
In `web/src/i18n/sv.json`, the matching entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** — `web/src/shell/theme-switch.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ThemeSwitch } from "./theme-switch";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting Dark applies the dark class and persists", async () => {
|
||||||
|
renderApp(<ThemeSwitch />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /dark/i }));
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
expect(localStorage.getItem("theme")).toBe("dark");
|
||||||
|
expect(screen.getByRole("button", { name: /dark/i })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting Light removes the dark class and persists", async () => {
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
renderApp(<ThemeSwitch />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /light/i }));
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||||
|
expect(localStorage.getItem("theme")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting System resolves via prefers-color-scheme", async () => {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: true,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
renderApp(<ThemeSwitch />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /system/i }));
|
||||||
|
expect(localStorage.getItem("theme")).toBe("system");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
|
||||||
|
Expected: FAIL — cannot import `ThemeSwitch`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement** — `web/src/shell/theme-switch.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useTheme } from "../theme/use-theme";
|
||||||
|
import type { Theme } from "../theme/theme";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [
|
||||||
|
{ value: "light", Icon: Sun },
|
||||||
|
{ value: "dark", Icon: Moon },
|
||||||
|
{ value: "system", Icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeSwitch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{OPTIONS.map(({ value, Icon }) => {
|
||||||
|
const active = theme === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={t(`theme.${value}`)}
|
||||||
|
title={t(`theme.${value}`)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md p-1 transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Verify the `cn` import path matches the project — other `ui/*` files import `cn` from `@/lib/utils`. If `lib/utils` is absent, mirror whatever `button.tsx` uses.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write the Storybook story** — `web/src/shell/theme-switch.stories.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect } from 'storybook/test'
|
||||||
|
|
||||||
|
import { ThemeSwitch } from './theme-switch'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: ThemeSwitch,
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
} satisfies Meta<typeof ThemeSwitch>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvas }) => {
|
||||||
|
await expect(canvas.getByRole('button', { name: /light/i })).toBeInTheDocument()
|
||||||
|
await expect(canvas.getByRole('button', { name: /dark/i })).toBeInTheDocument()
|
||||||
|
await expect(canvas.getByRole('button', { name: /system/i })).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note: the story exercises rendering only — it does not click options, to avoid mutating `<html>`
|
||||||
|
globally across the browser-mode test run.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the story as a test + lint**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/theme-switch.stories.tsx && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/theme-switch.tsx web/src/shell/theme-switch.test.tsx web/src/shell/theme-switch.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
|
||||||
|
git commit -m "feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 4: Mount in the header + FOUC inline script
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/shell/app-shell.tsx`
|
||||||
|
- Modify: `web/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mount `ThemeSwitch`.** In `web/src/shell/app-shell.tsx`, add the import:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeSwitch } from "./theme-switch";
|
||||||
|
```
|
||||||
|
|
||||||
|
and render it in the header immediately before `<LangSwitch />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex-1" />
|
||||||
|
<ThemeSwitch />
|
||||||
|
<LangSwitch />
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the existing header's exact JSX; only insert the one line. Do not change other markup.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the FOUC-prevention inline script.** In `web/index.html`, inside `<head>`
|
||||||
|
BEFORE the `<script type="module" src="/src/main.tsx">` tag, add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("theme") || "system";
|
||||||
|
var dark =
|
||||||
|
t === "dark" ||
|
||||||
|
(t === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the app-shell test still passes** (the header now has an extra control):
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/app-shell.test.tsx`
|
||||||
|
Expected: PASS (the existing "language switch" test is unaffected — ThemeSwitch buttons have distinct accessible names).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build to verify `index.html` is valid**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm build`
|
||||||
|
Expected: built successfully (Vite processes the inline script).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/app-shell.tsx web/index.html
|
||||||
|
git commit -m "feat(web): mount ThemeSwitch in header + pre-paint theme init (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 5: Dark `--primary` contrast tweak + final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/index.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Compute the new dark `--primary`.** The dark button label uses `--primary-foreground:
|
||||||
|
oklch(0.205 0 0)` (near-black) on `--primary: oklch(0.673 0.182 276.935)` (~3.21:1). Lower the
|
||||||
|
lightness (and keep it a recognizable indigo) until WCAG contrast vs `oklch(0.205 0 0)` is **≥4.5:1**.
|
||||||
|
A good starting point is `oklch(0.62 0.20 277)`; compute the exact value with a contrast check
|
||||||
|
(convert both to sRGB relative luminance, `(L1+0.05)/(L2+0.05) ≥ 4.5`). In the `.dark` block of
|
||||||
|
`web/src/index.css`, update BOTH `--primary` and `--ring` (they must match) to the chosen value:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--primary: oklch(<chosen-L> <chosen-C> 277);
|
||||||
|
...
|
||||||
|
--ring: oklch(<chosen-L> <chosen-C> 277);
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `--primary-foreground: oklch(0.205 0 0)` and the entire `:root` (light) block unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the contrast.** State the computed ratio in the commit body (must be ≥4.5:1).
|
||||||
|
Sanity-check the value is still visibly indigo (hue ~277, chroma not flattened to gray).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full gate (single test pass).**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||||
|
```
|
||||||
|
Expected: all green. `check:colors` passes (icons are not color utilities). `check:size` within 250 KB
|
||||||
|
gz (three lucide icons are negligible). Tests run exactly ONCE (no concurrent runs).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Codename + status checks.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -in 'biggus\|dickus' -- web/src web/index.html; echo "codename-exit=$?"
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
Expected: no codename matches; working tree shows only intended changes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`, toggle Light/Dark/System; confirm the app
|
||||||
|
switches, a dark reload doesn't flash light, primary buttons are legible in dark, and switching the
|
||||||
|
OS theme while in System updates the app live.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/index.css
|
||||||
|
git commit -m "fix(web): raise dark --primary contrast to AA for button labels (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:** tri-state model + System default (T1 `resolveTheme`/`readTheme`, T3 UI); persisted
|
||||||
|
to localStorage (T2 `setTheme`, T3 tests); `.dark` on `<html>` (T1 `applyTheme`); live system tracking
|
||||||
|
(T2 `useEffect` matchMedia listener); FOUC prevention (T4 inline script); icon segmented control next
|
||||||
|
to LangSwitch (T3 + T4 mount); en/sv `theme.*` (T3); aria-pressed/aria-label (T3); dark `--primary`
|
||||||
|
contrast ≥4.5:1 + `--ring` sync (T5); gate incl. check:colors/check:size + no codename + no new dep
|
||||||
|
(T5). All acceptance criteria 1–6 mapped. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** the only "computed" value is the exact dark `--primary` OKLCH — a genuine WCAG
|
||||||
|
measurement step with a concrete starting point and an explicit acceptance threshold (≥4.5:1), not a
|
||||||
|
TODO. All code blocks are complete. ✓
|
||||||
|
|
||||||
|
**Type consistency:** `Theme` type defined in `theme.ts` (T1), imported by `use-theme.ts` (T2) and
|
||||||
|
`theme-switch.tsx` (T3); `THEME_KEY` from `theme.ts` used in T2's setter; `resolveTheme`/`readTheme`/
|
||||||
|
`applyTheme` signatures consistent across tasks; i18n keys `theme.light/dark/system` defined in T3 and
|
||||||
|
referenced by `t(\`theme.${value}\`)` in T3's component. ✓
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new dependency (lucide-react already present; `.dark` tokens already exist from #49).
|
||||||
|
- The inline FOUC script is intentionally plain ES5-ish + try/catch — it runs before the bundle and
|
||||||
|
must never throw.
|
||||||
|
- Cross-tab sync and per-account/server theme default are explicit follow-ups (not in this plan).
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# App Header Wayfinding Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fill the empty app header with wayfinding — a route-driven breadcrumb (left), a signed-in user menu + compact global search (right) — and render the configured `app_name` for the brand + login.
|
||||||
|
|
||||||
|
**Architecture:** A page-driven breadcrumb (a `BreadcrumbProvider` context + `useBreadcrumb(trail)` hook, parallel to #57's `useDocumentTitle`) that each route sets and the header renders. A reusable `ui/menu.tsx` Base UI Menu wrapper powers a `UserMenu` (email/role + Sign out). A `HeaderSearch` input navigates to `/search?q=`. Brand + login read `useConfig().app_name`. No new dependency.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-router 7, react-i18next, Base UI (`@base-ui/react/menu` — namespace `Menu`), lucide-react, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; **ui/ files = no-semicolon base-nova style** (match `alert-dialog.tsx`); **app source (shell/, lib/, pages) = double-quote + semicolon**; stories = single-quote + no-semicolon; token classes only (`check:colors`); guard DOM globals.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-header-wayfinding-design.md`
|
||||||
|
|
||||||
|
**Key facts (verified):** `useMe()` (`api/queries.ts:30`) → `UserView | null` = `{ email, id, role }`. `useLogout()` (`queries.ts:129`). `useVocabularies()` (`queries.ts:258`) → `VocabularyView[]` with `.key` (the display name). Current logout flow in `app-shell.tsx`: `logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) })`. Base UI render-prop pattern: see `ui/alert-dialog.tsx` (namespace import, `data-slot`, `cn()`).
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
- `web/src/components/ui/menu.tsx` (new) + `menu.stories.tsx` (new)
|
||||||
|
- `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new render component)
|
||||||
|
- `web/src/shell/user-menu.tsx` (new), `header-search.tsx` (new)
|
||||||
|
- Modify: `web/src/shell/app-shell.tsx`, `sidebar.tsx`, `auth/login-page.tsx`, the 9 page/detail components, `i18n/en.json`, `i18n/sv.json`, `shell/app-shell.test.tsx`, `auth/login-page.test.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Render `app_name` for brand + login; remove dead `app.name` key
|
||||||
|
|
||||||
|
**Files:** `web/src/shell/sidebar.tsx`, `web/src/auth/login-page.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/auth/login-page.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Sidebar brand.** In `web/src/shell/sidebar.tsx` add `import { useConfig } from "../config/config-context";`, get `const { app_name } = useConfig();` in the component, and change line ~76:
|
||||||
|
`{!collapsed && <span className="font-semibold">{t("app.name")}</span>}` →
|
||||||
|
`{!collapsed && <span className="font-semibold">{app_name}</span>}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `<h1>` (line ~38) to `{app_name}` and the title effect (line ~18) to `document.title = app_name;` with deps `[app_name]`. Remove the now-unused `t` for that purpose only if `t` is otherwise unused (check — login uses `t` for field labels/errors, so keep the `useTranslation` import).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the dead i18n key.** Delete the `"app": { "name": "..." }` entry from BOTH `web/src/i18n/en.json` and `web/src/i18n/sv.json` (grep first: `grep -rn 'app\.name\|"app"' web/src` — confirm no remaining `t("app.name")` after Steps 1–2). en/sv must stay in parity (remove from both).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update login test if needed.** Read `web/src/auth/login-page.test.tsx`. If it asserts the heading text via `t("app.name")` / "Collection", update it to the config default `"Collection Management System"` (the value `useConfig` returns in tests via `DEFAULTS`). Do NOT weaken; just match the new source.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify (run vitest once for these files).**
|
||||||
|
`cd web && pnpm vitest run src/auth src/shell/app-shell.test.tsx && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS. The sidebar brand + login now show "Collection Management System" (config default) in tests.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/auth/login-page.test.tsx
|
||||||
|
git commit -m "feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: `ui/menu.tsx` Base UI Menu wrapper + story (validate by running)
|
||||||
|
|
||||||
|
**Files:** `web/src/components/ui/menu.tsx` (new), `web/src/components/ui/menu.stories.tsx` (new).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the reference** `web/src/components/ui/alert-dialog.tsx` for the exact house pattern (namespace import, `data-slot`, `cn()`, no semicolons, token classes). The Base UI Menu API is `import { Menu } from "@base-ui/react/menu"` then `Menu.Root`, `Menu.Trigger`, `Menu.Portal`, `Menu.Positioner`, `Menu.Popup`, `Menu.Item`, `Menu.Separator`. **This is novel — you MUST validate the exact part tree by running the story (Step 3).**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement** `web/src/components/ui/menu.tsx` (no-semicolon style). Export: `Menu` (Root re-export with data-slot), `MenuTrigger`, `MenuContent` (composes Portal + Positioner + Popup), `MenuItem`, `MenuSeparator`. Skeleton (adapt class/props to what runs):
|
||||||
|
```tsx
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Menu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 6,
|
||||||
|
align = "end",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner sideOffset={sideOffset} align={align} className="z-50">
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="menu-content"
|
||||||
|
className={cn(
|
||||||
|
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
|
||||||
|
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="menu-item"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator }
|
||||||
|
```
|
||||||
|
IMPORTANT: the exact prop names (`sideOffset`, `align`, `Popup` vs `Popup`+`Positioner` arrangement) MUST be confirmed against the installed `@base-ui/react` types — open `web/node_modules/@base-ui/react/menu/` or check via the editor/types and adjust. Do not guess; if a prop/part errors at typecheck or runtime, fix it to match the real API. No `data-[highlighted]` raw colors — `bg-accent`/`text-accent-foreground` are tokens (OK).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Story** `web/src/components/ui/menu.stories.tsx` (single-quote, no-semicolon). Render a `Menu` with a `MenuTrigger` (a Button via `render` or as child) + `MenuContent` with two `MenuItem`s; a `play` test that opens the menu (click the trigger) and asserts an item is visible:
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect } from 'storybook/test'
|
||||||
|
|
||||||
|
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Menu,
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
} satisfies Meta<typeof Menu>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem>First</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem>Second</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
),
|
||||||
|
play: async ({ canvas, userEvent }) => {
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
|
||||||
|
await expect(await canvas.findByText('First')).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If `MenuTrigger render={<Button/>}` isn't the right composition for Base UI Menu, use the pattern that works (e.g. `<MenuTrigger><Button/></MenuTrigger>` or `render` per the alert-dialog usage). The story passing IS the validation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the story-as-test + typecheck + lint.**
|
||||||
|
`cd web && pnpm vitest run src/components/ui/menu.stories.tsx && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS. If the menu doesn't open / portal isn't found, fix the part tree until the play test passes (this is the validate-by-running step). The portal renders to document.body — `findByText` on the canvas/body should find it; if the addon's `canvas` is scoped, query `within(document.body)` or use the screen — match how other portal-using stories (drawer/combobox/toast) assert.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/components/ui/menu.tsx web/src/components/ui/menu.stories.tsx
|
||||||
|
git commit -m "feat(web): ui/menu Base UI dropdown wrapper + story (#54)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: Breadcrumb infrastructure + mount in header + wire objects-page
|
||||||
|
|
||||||
|
**Files:** `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new), `web/src/shell/app-shell.tsx` (modify), `web/src/objects/objects-page.tsx` (modify), `web/src/shell/breadcrumb.test.tsx` (new).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Context** `web/src/shell/breadcrumb-context.ts`:
|
||||||
|
```ts
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type BreadcrumbItem = { label: string; to?: string };
|
||||||
|
|
||||||
|
type BreadcrumbContextValue = {
|
||||||
|
trail: BreadcrumbItem[];
|
||||||
|
setTrail: (trail: BreadcrumbItem[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||||
|
trail: [],
|
||||||
|
setTrail: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useBreadcrumbTrail(): BreadcrumbItem[] {
|
||||||
|
return useContext(BreadcrumbContext).trail;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Provider** `web/src/shell/breadcrumb-provider.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
|
||||||
|
|
||||||
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
|
||||||
|
return (
|
||||||
|
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
|
||||||
|
{children}
|
||||||
|
</BreadcrumbContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Hook** `web/src/shell/use-breadcrumb.ts`:
|
||||||
|
```ts
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
|
||||||
|
|
||||||
|
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
|
||||||
|
const { setTrail } = useContext(BreadcrumbContext);
|
||||||
|
const key = trail.map((i) => `${i.label} | ||||||