Files
biggus-dickus/docs/superpowers/plans/2026-06-08-design-kit-consistency.md

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.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 <h1> 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:
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 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 <Link> 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:

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 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 <h1 className="text-2xl font-semibold">{app_name}</h1> to <PageTitle>{app_name}</PageTitle>.

  • Step 3: fields/field-list.tsx — type-tag → Badge. Add import { 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-4 in 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 … → 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 <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: add import { Button } from "@/components/ui/button"; and render the DrawerClose AS 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:colors stays green — segmentClass/rowStateClass and 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).