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

7.8 KiB

Design-Kit Consistency — Design

Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #66 (dead Card, duplicated segmented-control + selected-row recipes, useLang/focusRing drift, misc kit one-offs).

Context

A frontend deep audit found subtler design-system inconsistencies the check:colors guard doesn't catch (the app is already hex-free + token-based + dark-mode-clean). These are duplicated class recipes and non-adoption of the ui/* kit. All fixes are behavior-preserving; check:colors/check:size/the existing component tests are the guards. State re-verified against the current code (post #62/#64).

Components

New shared helpers

lib/use-lang.tsuseLang(): "sv" | "en"

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";
}

Replaces the inline const lang = i18n.language.startsWith("sv") ? "sv" : "en"; in 6 components: objects/object-detail.tsx, objects/field-input.tsx, vocab/vocabulary-terms.tsx, vocab/vocabulary-list.tsx, fields/field-list.tsx, authorities/authorities-page.tsx. Each switches to const lang = useLang(); and drops the now-unused i18n from its useTranslation() destructure where i18n is otherwise unused. (Left untouched: shell/lang-switch.tsx — derives from a different locale var; i18n/index.ts — the infra languageChanged handler.)

lib/class-recipes.ts — two shared class helpers

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";
}
  • segmentClass is adopted at the 3 segmented sites, each keeping its contextual padding:
    • objects/objects-table.tsx:174segmentClass(active, "px-2 py-1")
    • search/search-panel.tsx:76segmentClass(active, "px-2 py-0.5")
    • authorities/authorities-page.tsx:41 (NavLink) → segmentClass(isActive, "px-3 py-1 text-sm") This DRYs the bug-prone recipe (the active/inactive token pair + focusRing — the part that drifted and caused the #62 missing-ring bug); contextual sizing is intentionally preserved per site.
  • rowStateClass is adopted at the 4 selected-row sites:
    • objects/objects-table.tsx:252rowStateClass(selected)
    • vocab/vocabulary-list.tsx:113rowStateClass(isActive)
    • search/search-result-row.tsx:15rowStateClass(isActive)
    • fields/field-list.tsx:86rowStateClass(def.key === selectedKey)fixes this site, which currently uses … ? "bg-primary/10" : "" (dropping the hover:bg-muted idle hover the others have).

One-off cleanups

  • Delete components/ui/card.tsx — zero importers (no app/test/story references; no card.stories).
  • shell/sidebar.tsx:46,88 — replace the raw focus-visible:ring-3 focus-visible:ring-ring/50 string (inside the existing cn(...)) with the imported focusRing constant (adds outline-none, matching every other call site).
  • auth/login-page.tsx:49<h1 className="text-2xl font-semibold">{app_name}</h1><PageTitle>{app_name}</PageTitle> (PageTitle is text-2xl font-semibold tracking-tight, restoring the missing tracking-tight). Import PageTitle from @/components/ui/page-title.
  • fields/field-list.tsx:97 — the hand-rolled type-tag <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"><Badge variant="secondary"> (from @/components/ui/badge).
  • Icon sizingh-4 w-4size-4 in the 3 app-source sites: shell/theme-switch.tsx:39, shell/user-menu.tsx:27, shell/header-search.tsx:23. (Leave components/ui/select.tsx — kit-internal.)
  • Icon dismiss buttons → kit Button variant="ghost" size="icon-sm":
    • objects/objects-page.tsx:54 (plain <button onClick={closeDetail} aria-label={…}>) → <Button variant="ghost" size="icon-sm" onClick={closeDetail} aria-label={t("actions.closeDetail")}><X className="size-4" aria-hidden="true" /></Button> (import Button).
    • objects/object-detail-drawer.tsx:33 (Base UI <DrawerClose>) → keep DrawerClose for its close-on-click semantics, render it 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> (mirrors the AlertDialogTrigger render={<Button/>} pattern in delete-confirm-dialog.tsx; import Button).

Error handling / edges

  • segmentClass/rowStateClass are pure string builders — no runtime concerns. cn() (tailwind-merge) resolves any padding/utility overlap predictably.
  • <Badge variant="secondary"> shifts the type-tag from bg-muted/text-muted-foreground to the secondary token pair — a deliberate, minor visual adoption of the kit; still token-based (check:colors clean).
  • The drawer DrawerClose render={<Button/>} keeps Base UI's close behaviour (Base UI merges its props onto the rendered Button); the aria-label stays on DrawerClose.
  • useLang returns the same "sv" | "en" the inline code produced — no behaviour change.

Testing

  • lib/class-recipes.test.ts (new): segmentClass(true) contains bg-primary + text-primary-foreground and the focus-ring utility; segmentClass(false) contains border (not bg-primary); both contain a passed className; rowStateClass(true) === "bg-primary/10", rowStateClass(false) === "hover:bg-muted".
  • Behavior guard: the existing component tests must stay green unchanged — objects-table.test.tsx, authorities.test.tsx, vocabularies.test.tsx, field-list / search tests, login-page.test.tsx, breadcrumb/sidebar, object-detail/drawer, user-menu.test.tsx. (The login PageTitle still renders an <h1>; the icon buttons keep their aria-labels; the drawer close still closes.)
  • Gate: typecheck/lint/test/build/check:size/check:colors green; no new dependency; no new i18n keys; no codename. check:size unchanged-or-smaller (Card deletion removes dead code).

Acceptance criteria

  1. useLang() exists and replaces the inline lang derivation in the 6 listed components; lang-switch and i18n/index.ts are untouched.
  2. segmentClass/rowStateClass exist (unit-tested) and are adopted at the 3 segmented + 4 selected-row sites; field-list's selected row gains the hover:bg-muted idle hover.
  3. components/ui/card.tsx is deleted (no remaining references).
  4. The one-offs are applied: sidebar uses focusRing; login uses PageTitle; field-list type-tag uses Badge; the 3 app-source icons use size-4; both icon dismiss buttons use Button variant="ghost" size="icon-sm".
  5. All existing tests pass unchanged; typecheck/lint/build/check:colors green; check:size unchanged-or-smaller; no new dependency; no new i18n keys; no codename.

Out of scope → follow-ups

  • A full <SegmentedControl>/<ToggleGroup> component (the button-vs-NavLink interaction split makes a class helper the better fit); the form space-y-* scale (too subjective — churn risk).
  • Standardizing icon sizing inside components/ui/* (kit-internal style, separate from app-source).