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.ts — useLang(): "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";
}
segmentClassis adopted at the 3 segmented sites, each keeping its contextual padding:objects/objects-table.tsx:174→segmentClass(active, "px-2 py-1")search/search-panel.tsx:76→segmentClass(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.
rowStateClassis adopted at the 4 selected-row sites:objects/objects-table.tsx:252→rowStateClass(selected)vocab/vocabulary-list.tsx:113→rowStateClass(isActive)search/search-result-row.tsx:15→rowStateClass(isActive)fields/field-list.tsx:86→rowStateClass(def.key === selectedKey)— fixes this site, which currently uses… ? "bg-primary/10" : ""(dropping thehover:bg-mutedidle hover the others have).
One-off cleanups
- Delete
components/ui/card.tsx— zero importers (no app/test/story references; nocard.stories). shell/sidebar.tsx:46,88— replace the rawfocus-visible:ring-3 focus-visible:ring-ring/50string (inside the existingcn(...)) with the importedfocusRingconstant (addsoutline-none, matching every other call site).auth/login-page.tsx:49—<h1 className="text-2xl font-semibold">{app_name}</h1>→<PageTitle>{app_name}</PageTitle>(PageTitleistext-2xl font-semibold tracking-tight, restoring the missingtracking-tight). ImportPageTitlefrom@/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 sizing —
h-4 w-4→size-4in the 3 app-source sites:shell/theme-switch.tsx:39,shell/user-menu.tsx:27,shell/header-search.tsx:23. (Leavecomponents/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>(importButton).objects/object-detail-drawer.tsx:33(Base UI<DrawerClose>) → keepDrawerClosefor 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 theAlertDialogTrigger render={<Button/>}pattern indelete-confirm-dialog.tsx; importButton).
Error handling / edges
segmentClass/rowStateClassare pure string builders — no runtime concerns.cn()(tailwind-merge) resolves any padding/utility overlap predictably.<Badge variant="secondary">shifts the type-tag frombg-muted/text-muted-foregroundto thesecondarytoken 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); thearia-labelstays onDrawerClose. useLangreturns the same"sv" | "en"the inline code produced — no behaviour change.
Testing
lib/class-recipes.test.ts(new):segmentClass(true)containsbg-primary+text-primary-foregroundand the focus-ring utility;segmentClass(false)containsborder(notbg-primary); both contain a passedclassName;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/searchtests,login-page.test.tsx,breadcrumb/sidebar,object-detail/drawer,user-menu.test.tsx. (The loginPageTitlestill renders an<h1>; the icon buttons keep theiraria-labels; the drawer close still closes.) - Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; no new dependency; no new i18n keys; no codename.check:sizeunchanged-or-smaller (Card deletion removes dead code).
Acceptance criteria
useLang()exists and replaces the inline lang derivation in the 6 listed components;lang-switchandi18n/index.tsare untouched.segmentClass/rowStateClassexist (unit-tested) and are adopted at the 3 segmented + 4 selected-row sites;field-list's selected row gains thehover:bg-mutedidle hover.components/ui/card.tsxis deleted (no remaining references).- The one-offs are applied: sidebar uses
focusRing; login usesPageTitle; field-list type-tag usesBadge; the 3 app-source icons usesize-4; both icon dismiss buttons useButton variant="ghost" size="icon-sm". - All existing tests pass unchanged;
typecheck/lint/build/check:colorsgreen;check:sizeunchanged-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 formspace-y-*scale (too subjective — churn risk). - Standardizing icon sizing inside
components/ui/*(kit-internal style, separate from app-source).