docs(specs): design-kit consistency — useLang, class recipes, kit adoption (#66)
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user