# 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"`
```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";
}
```
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
```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";
}
```
- **`segmentClass`** is 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.
- **`rowStateClass`** is 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 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 sizing** — `h-4 w-4` → `size-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-label`s; 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).
