From 71dee23028cd9df47c6aeb74dd45a5441227bfec Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 23:31:35 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20design-kit=20consistency=20?= =?UTF-8?q?=E2=80=94=203-task=20plan=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-08-design-kit-consistency.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-design-kit-consistency.md diff --git a/docs/superpowers/plans/2026-06-08-design-kit-consistency.md b/docs/superpowers/plans/2026-06-08-design-kit-consistency.md new file mode 100644 index 0000000..6b0890c --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-design-kit-consistency.md @@ -0,0 +1,211 @@ +# Design-Kit Consistency — 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:** Add three shared helpers (`useLang`, `segmentClass`, `rowStateClass`), adopt them across the duplicated sites, and apply behavior-preserving kit one-offs (delete dead Card, sidebar focusRing, login PageTitle, field-list Badge, size-4, icon dismiss buttons). + +**Architecture:** Task 1 creates the helpers + deletes Card (additive/safe). Task 2 adopts the 3 helpers across 6 + 3 + 4 sites. Task 3 applies the one-off cleanups + full gate. Behavior-preserving throughout; `check:colors`/`check:size`/existing component tests are the guards. + +**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (token classes + `cn`), react-i18next, Base UI, Vitest 4 + RTL. + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon; token classes only (`check:colors`). `tsconfig` has `noUnusedLocals`, so remove any destructure that becomes unused. + +**Spec:** `docs/superpowers/specs/2026-06-08-design-kit-consistency-design.md` + +**Key facts (verified current):** +- `lib/focus-ring.ts` exports `focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50"`. `cn` is `@/lib/utils`. +- `Button` (`@/components/ui/button`) has sizes incl. `icon-sm`. `Badge` (`@/components/ui/badge`) has a `secondary` variant. `PageTitle` (`@/components/ui/page-title`) is an `

` styled `text-2xl font-semibold tracking-tight`. +- `components/ui/card.tsx` has ZERO importers and no `card.stories`. +- `useLang` sites (each currently `const lang = i18n.language.startsWith("sv") ? "sv" : "en";`): `objects/object-detail.tsx:59`, `objects/field-input.tsx:32`, `vocab/vocabulary-terms.tsx:13`, `vocab/vocabulary-list.tsx:17`, `fields/field-list.tsx:27`, `authorities/authorities-page.tsx:19`. +- `segmentClass` sites: `objects/objects-table.tsx:174` (`` className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`} ``), `search/search-panel.tsx:76` (`className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}`), `authorities/authorities-page.tsx:41` (`cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")`). +- `rowStateClass` sites: `objects/objects-table.tsx:252` (`selected ? "bg-primary/10" : "hover:bg-muted"`), `vocab/vocabulary-list.tsx:113` (`isActive ? "bg-primary/10" : "hover:bg-muted"`), `search/search-result-row.tsx:15` (`isActive ? "bg-primary/10" : "hover:bg-muted"`), `fields/field-list.tsx:86` (`def.key === selectedKey ? "bg-primary/10" : ""` — note the missing idle hover). + +--- + +# Task 1: Create helpers + delete dead Card + +**Files:** Create `web/src/lib/use-lang.ts`, `web/src/lib/class-recipes.ts`, `web/src/lib/class-recipes.test.ts`; Delete `web/src/components/ui/card.tsx`. + +- [ ] **Step 1: `web/src/lib/use-lang.ts`:** +```ts +import { useTranslation } from "react-i18next"; + +/** The instance's active UI language, narrowed to the two supported locales. */ +export function useLang(): "sv" | "en" { + const { i18n } = useTranslation(); + return i18n.language.startsWith("sv") ? "sv" : "en"; +} +``` + +- [ ] **Step 2: `web/src/lib/class-recipes.ts`:** +```ts +import { cn } from "@/lib/utils"; + +import { focusRing } from "./focus-ring"; + +/** Segmented-control / filter-pill item. Unifies the active/inactive token recipe + + * focus ring; callers pass their contextual padding/size via `className`. */ +export function segmentClass(active: boolean, className?: string): string { + return cn("rounded-md", focusRing, active ? "bg-primary text-primary-foreground" : "border", className); +} + +/** Selected vs idle row background for master-detail / list rows. */ +export function rowStateClass(active: boolean): string { + return active ? "bg-primary/10" : "hover:bg-muted"; +} +``` + +- [ ] **Step 3: `web/src/lib/class-recipes.test.ts`** (write + run): +```ts +import { expect, test } from "vitest"; + +import { rowStateClass, segmentClass } from "./class-recipes"; + +test("segmentClass active uses the primary tokens + focus ring", () => { + const cls = segmentClass(true, "px-2 py-1"); + expect(cls).toContain("bg-primary"); + expect(cls).toContain("text-primary-foreground"); + expect(cls).toContain("focus-visible:ring-ring/50"); + expect(cls).toContain("px-2"); +}); + +test("segmentClass inactive uses border, not the primary fill", () => { + const cls = segmentClass(false); + expect(cls).toContain("border"); + expect(cls).not.toContain("bg-primary"); +}); + +test("rowStateClass toggles selected vs idle-hover", () => { + expect(rowStateClass(true)).toBe("bg-primary/10"); + expect(rowStateClass(false)).toBe("hover:bg-muted"); +}); +``` +Run: `cd web && pnpm vitest run src/lib/class-recipes.test.ts` → 3 passing. + +- [ ] **Step 4: Delete the dead Card component:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git rm web/src/components/ui/card.tsx +``` +(Confirm no references first: `git grep -n "components/ui/card\"" web/src` returns nothing.) + +- [ ] **Step 5: Verify + lint:** +```bash +cd web && pnpm vitest run src/lib/class-recipes.test.ts && pnpm typecheck && pnpm lint +``` +Expected: green (Card had no importers, so its deletion can't break typecheck/lint). + +- [ ] **Step 6: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/lib/use-lang.ts web/src/lib/class-recipes.ts web/src/lib/class-recipes.test.ts +git rm -q web/src/components/ui/card.tsx 2>/dev/null; git add -A web/src/components/ui +git commit -m "feat(web): useLang + segmentClass/rowStateClass helpers; delete dead Card (#66)" +``` + +--- + +# Task 2: Adopt the helpers across the duplicated sites + +**Files:** Modify `objects/object-detail.tsx`, `objects/field-input.tsx`, `vocab/vocabulary-terms.tsx`, `vocab/vocabulary-list.tsx`, `fields/field-list.tsx`, `authorities/authorities-page.tsx`, `objects/objects-table.tsx`, `search/search-panel.tsx`, `search/search-result-row.tsx`. + +- [ ] **Step 1: Adopt `useLang()` in the 6 components.** In each of `objects/object-detail.tsx`, `objects/field-input.tsx`, `vocab/vocabulary-terms.tsx`, `vocab/vocabulary-list.tsx`, `fields/field-list.tsx`, `authorities/authorities-page.tsx`: add `import { useLang } from "../lib/use-lang";` and replace `const lang = i18n.language.startsWith("sv") ? "sv" : "en";` with `const lang = useLang();`. Then, if `i18n` is no longer referenced anywhere else in that component, change `const { t, i18n } = useTranslation();` to `const { t } = useTranslation();` (the `noUnusedLocals` typecheck will fail otherwise — so this removal is required wherever `i18n` becomes unused). Note `authorities/authorities-page.tsx` also imports `focusRing` and uses `cn` — leave those. + +- [ ] **Step 2: Adopt `segmentClass` at the 3 segmented sites.** + - `objects/objects-table.tsx`: add `import { segmentClass } from "../lib/class-recipes";`; change the pill `className` (currently `` `${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}` ``) to `className={segmentClass(active, "px-2 py-1")}`. If `focusRing` is now unused in this file, remove its import. (The object-number `` also uses `focusRing` — if so, KEEP the import.) + - `search/search-panel.tsx`: add the import; change `className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}` to `className={segmentClass(active, "px-2 py-0.5")}`. Remove now-unused `focusRing`/`cn` imports if they're unused elsewhere in the file. + - `authorities/authorities-page.tsx`: add the import; change the NavLink className callback body `cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")` to `segmentClass(isActive, "px-3 py-1 text-sm")`. Remove now-unused `focusRing`/`cn` imports if unused elsewhere. + +- [ ] **Step 3: Adopt `rowStateClass` at the 4 selected-row sites.** Add `import { rowStateClass } from "…/lib/class-recipes";` (or extend the existing class-recipes import) to each: + - `objects/objects-table.tsx`: in the row `className`, change `${selected ? "bg-primary/10" : "hover:bg-muted"}` to `${rowStateClass(selected)}`. + - `vocab/vocabulary-list.tsx`: change `${isActive ? "bg-primary/10" : "hover:bg-muted"}` to `${rowStateClass(isActive)}`. + - `search/search-result-row.tsx`: change `${isActive ? "bg-primary/10" : "hover:bg-muted"}` to `${rowStateClass(isActive)}`. + - `fields/field-list.tsx`: change `${def.key === selectedKey ? "bg-primary/10" : ""}` to `${rowStateClass(def.key === selectedKey)}` (this ADDS the `hover:bg-muted` idle hover the others have — an intended consistency fix). + +- [ ] **Step 4: Verify (vitest ONCE for the affected suites), typecheck, lint:** +```bash +cd web && pnpm vitest run src/objects src/vocab src/fields src/authorities src/search && pnpm typecheck && pnpm lint +``` +Expected: green. These are class-string-equivalent changes (segmentClass/rowStateClass produce the same token sets; `cn` ordering is irrelevant to Tailwind), so the existing component tests pass unchanged. `field-list`'s row now also carries `hover:bg-muted` (additive). If a test asserted the exact old className string, update it to match the new equivalent (unlikely — tests query by role/text). + +- [ ] **Step 5: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/objects/object-detail.tsx web/src/objects/field-input.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/vocabulary-list.tsx web/src/fields/field-list.tsx web/src/authorities/authorities-page.tsx web/src/objects/objects-table.tsx web/src/search/search-panel.tsx web/src/search/search-result-row.tsx +git commit -m "refactor(web): adopt useLang + segmentClass/rowStateClass across sites (#66)" +``` + +--- + +# Task 3: One-off kit cleanups + full gate + +**Files:** Modify `shell/sidebar.tsx`, `auth/login-page.tsx`, `fields/field-list.tsx`, `shell/theme-switch.tsx`, `shell/user-menu.tsx`, `shell/header-search.tsx`, `objects/objects-page.tsx`, `objects/object-detail-drawer.tsx`. + +- [ ] **Step 1: `shell/sidebar.tsx`** — use the `focusRing` constant. Add `import { focusRing } from "../lib/focus-ring";` (if not already imported). At the two `cn(...)` sites (lines ~46 and ~88) replace the literal `"focus-visible:ring-3 focus-visible:ring-ring/50"` entry with `focusRing`. (Both are inside `cn(...)` lists, so just swap the string for the constant.) + +- [ ] **Step 2: `auth/login-page.tsx`** — use `PageTitle`. Add `import { PageTitle } from "@/components/ui/page-title";` and change `

{app_name}

` to `{app_name}`. + +- [ ] **Step 3: `fields/field-list.tsx`** — type-tag → `Badge`. Add `import { Badge } from "@/components/ui/badge";` and change the type-tag `{…}` (line ~97) to `{…}` (keep the inner expression/children unchanged). + +- [ ] **Step 4: Icon sizing → `size-4`** in the 3 app-source sites: `shell/theme-switch.tsx:39` (`` → `className="size-4"`), `shell/user-menu.tsx:27` (`` → `size-4`), `shell/header-search.tsx:23` (the search icon's `… h-4 w-4 …` → replace `h-4 w-4` with `size-4`, keeping the other classes). Do NOT touch `components/ui/select.tsx`. + +- [ ] **Step 5: Icon dismiss buttons → kit Button.** + - `objects/objects-page.tsx:54`: add `import { Button } from "@/components/ui/button";` (if absent) and change the `` to: +```tsx + +``` + - `objects/object-detail-drawer.tsx:31-36`: add `import { Button } from "@/components/ui/button";` and render the `DrawerClose` AS the kit Button via the render prop: +```tsx + } + > + +``` + (This mirrors the `AlertDialogTrigger render={