16 KiB
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.tsexportsfocusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50".cnis@/lib/utils.Button(@/components/ui/button) has sizes incl.icon-sm.Badge(@/components/ui/badge) has asecondaryvariant.PageTitle(@/components/ui/page-title) is an<h1>styledtext-2xl font-semibold tracking-tight.components/ui/card.tsxhas ZERO importers and nocard.stories.useLangsites (each currentlyconst 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.segmentClasssites: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")).rowStateClasssites: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:
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:
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):
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:
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:
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
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 ofobjects/object-detail.tsx,objects/field-input.tsx,vocab/vocabulary-terms.tsx,vocab/vocabulary-list.tsx,fields/field-list.tsx,authorities/authorities-page.tsx: addimport { useLang } from "../lib/use-lang";and replaceconst lang = i18n.language.startsWith("sv") ? "sv" : "en";withconst lang = useLang();. Then, ifi18nis no longer referenced anywhere else in that component, changeconst { t, i18n } = useTranslation();toconst { t } = useTranslation();(thenoUnusedLocalstypecheck will fail otherwise — so this removal is required whereveri18nbecomes unused). Noteauthorities/authorities-page.tsxalso importsfocusRingand usescn— leave those. -
Step 2: Adopt
segmentClassat the 3 segmented sites.objects/objects-table.tsx: addimport { segmentClass } from "../lib/class-recipes";; change the pillclassName(currently`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`) toclassName={segmentClass(active, "px-2 py-1")}. IffocusRingis now unused in this file, remove its import. (The object-number<Link>also usesfocusRing— if so, KEEP the import.)search/search-panel.tsx: add the import; changeclassName={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}toclassName={segmentClass(active, "px-2 py-0.5")}. Remove now-unusedfocusRing/cnimports if they're unused elsewhere in the file.authorities/authorities-page.tsx: add the import; change the NavLink className callback bodycn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")tosegmentClass(isActive, "px-3 py-1 text-sm"). Remove now-unusedfocusRing/cnimports if unused elsewhere.
-
Step 3: Adopt
rowStateClassat the 4 selected-row sites. Addimport { rowStateClass } from "…/lib/class-recipes";(or extend the existing class-recipes import) to each:objects/objects-table.tsx: in the rowclassName, 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 thehover:bg-mutedidle hover the others have — an intended consistency fix).
-
Step 4: Verify (vitest ONCE for the affected suites), typecheck, lint:
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
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 thefocusRingconstant. Addimport { focusRing } from "../lib/focus-ring";(if not already imported). At the twocn(...)sites (lines ~46 and ~88) replace the literal"focus-visible:ring-3 focus-visible:ring-ring/50"entry withfocusRing. (Both are insidecn(...)lists, so just swap the string for the constant.) -
Step 2:
auth/login-page.tsx— usePageTitle. Addimport { PageTitle } from "@/components/ui/page-title";and change<h1 className="text-2xl font-semibold">{app_name}</h1>to<PageTitle>{app_name}</PageTitle>. -
Step 3:
fields/field-list.tsx— type-tag →Badge. Addimport { Badge } from "@/components/ui/badge";and change the type-tag<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{…}</span>(line ~97) to<Badge variant="secondary">{…}</Badge>(keep the inner expression/children unchanged). -
Step 4: Icon sizing →
size-4in the 3 app-source sites:shell/theme-switch.tsx:39(<Icon className="h-4 w-4" …>→className="size-4"),shell/user-menu.tsx:27(<CircleUser className="h-4 w-4" …>→size-4),shell/header-search.tsx:23(the search icon's… h-4 w-4 …→ replaceh-4 w-4withsize-4, keeping the other classes). Do NOT touchcomponents/ui/select.tsx. -
Step 5: Icon dismiss buttons → kit Button.
objects/objects-page.tsx:54: addimport { Button } from "@/components/ui/button";(if absent) and change the<button type="button" onClick={closeDetail} aria-label={t("actions.closeDetail")} className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"><X className="size-4" aria-hidden="true" /></button>to:
<Button
variant="ghost"
size="icon-sm"
onClick={closeDetail}
aria-label={t("actions.closeDetail")}
>
<X className="size-4" aria-hidden="true" />
</Button>
objects/object-detail-drawer.tsx:31-36: addimport { Button } from "@/components/ui/button";and render theDrawerCloseAS the kit Button via the render prop:
<DrawerClose
aria-label={t("actions.closeDetail")}
render={<Button variant="ghost" size="icon-sm" />}
>
<X className="size-4" aria-hidden="true" />
</DrawerClose>
(This mirrors the AlertDialogTrigger render={<Button … />} pattern in components/delete-confirm-dialog.tsx; the DrawerClose keeps its close-on-click behaviour and the aria-label.)
- Step 6: FULL FRONTEND GATE (run tests EXACTLY ONCE):
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
All green. Report test totals, largest chunk (gz) from check:size (should be ≤ the prior ~216.5 KB — the Card delete only removes dead code), and the check:colors line. The existing user-menu, objects-table, object-detail/drawer, login-page, sidebar, field-list, search tests must pass unchanged (the icon buttons keep their aria-labels; the drawer still closes; login still renders an <h1> via PageTitle).
- Step 7: Codename + status:
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
Expected: no matches (codename-exit=1).
-
Step 8: Manual smoke (recommended).
pnpm dev: the visibility pills / authority tabs / search facets look unchanged and keep their focus rings; the selected list rows (objects, vocab, search, fields) highlight identically and field rows now have a hover; the object-detail close buttons (wide pane + drawer) work; the login title and field-list type tag look right. -
Step 9: Commit
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/fields/field-list.tsx web/src/shell/theme-switch.tsx web/src/shell/user-menu.tsx web/src/shell/header-search.tsx web/src/objects/objects-page.tsx web/src/objects/object-detail-drawer.tsx
git commit -m "refactor(web): kit consistency — focusRing, PageTitle, Badge, size-4, icon buttons (#66)"
Self-Review (completed)
Spec coverage: AC1 useLang + 6 sites (T1 S1, T2 S1); AC2 segmentClass/rowStateClass + adoption + field-list hover fix (T1 S2-S3, T2 S2-S3); AC3 Card deleted (T1 S4); AC4 one-offs — sidebar focusRing, login PageTitle, field-list Badge, size-4, icon buttons (T3 S1-S5); AC5 gate/check:size/codename (T3 S6-S7). ✓
Placeholder scan: every edit gives the exact before string + after code; helper bodies are complete; the test has concrete assertions. The "remove i18n if unused" instructions are concrete (driven by noUnusedLocals). No TBD. ✓
Type/consistency: useLang() (T1) returns "sv" | "en" consumed as const lang (T2 S1); segmentClass(active, className?) / rowStateClass(active) (T1) called with the exact args in T2 S2-S3; Button size="icon-sm", Badge variant="secondary", PageTitle all confirmed to exist. ✓
Notes
- No new dependency, no new i18n keys.
check:colorsstays green —segmentClass/rowStateClassand all edits use tokens (bg-primary,border,ring-ring,bg-muted). Card deletion only removes dead code. cn()(tailwind-merge) makes class ordering irrelevant, so the helper outputs are visually identical to the prior inline strings (except field-list's intended added hover).- The
<SegmentedControl>component and the form-spacing scale are deferred (out of scope).