From 91716e628a06811c243993fa70a794a6a02cd27e Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 22:32:34 +0200 Subject: [PATCH 1/5] =?UTF-8?q?docs(specs):=20design-kit=20consistency=20?= =?UTF-8?q?=E2=80=94=20useLang,=20class=20recipes,=20kit=20adoption=20(#66?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-08-design-kit-consistency-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-design-kit-consistency-design.md diff --git a/docs/superpowers/specs/2026-06-08-design-kit-consistency-design.md b/docs/superpowers/specs/2026-06-08-design-kit-consistency-design.md new file mode 100644 index 0000000..6ca201a --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-design-kit-consistency-design.md @@ -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`** — `

{app_name}

` → + `{app_name}` (`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 `` → `` (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 `` (import `Button`). + - `objects/object-detail-drawer.tsx:33` (Base UI ``) → keep `DrawerClose` for its + close-on-click semantics, render it AS the kit Button via the render prop: + `}>` (mirrors the `AlertDialogTrigger render={` to: +```tsx + +``` + - `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: +```tsx + } + > + +``` + (This mirrors the `AlertDialogTrigger render={ @@ -248,9 +249,7 @@ export function ObjectsTable() { navigate(`/objects/${object.id}?${params}`)} - className={`cursor-pointer border-b text-sm ${ - selected ? "bg-primary/10" : "hover:bg-muted" - }`} + className={`cursor-pointer border-b text-sm ${rowStateClass(selected)}`} > setVisibility(value)} - className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")} + className={segmentClass(active, "px-2 py-0.5")} > {value === "all" ? t("search.all") : t(`visibility.${value}`)} diff --git a/web/src/search/search-result-row.tsx b/web/src/search/search-result-row.tsx index 5f54d56..99ae43f 100644 --- a/web/src/search/search-result-row.tsx +++ b/web/src/search/search-result-row.tsx @@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom"; import type { components } from "../api/schema"; import { VisibilityBadge } from "../objects/visibility-badge"; +import { rowStateClass } from "../lib/class-recipes"; import { Highlight } from "./highlight"; type SearchHitView = components["schemas"]["SearchHitView"]; @@ -12,7 +13,7 @@ export function SearchResultRow({ hit }: { hit: SearchHitView }) { - `block border-b px-3 py-2 ${isActive ? "bg-primary/10" : "hover:bg-muted"}` + `block border-b px-3 py-2 ${rowStateClass(isActive)}` } >
{hit.object_name}
diff --git a/web/src/vocab/vocabulary-list.tsx b/web/src/vocab/vocabulary-list.tsx index 581d09b..9f0ecec 100644 --- a/web/src/vocab/vocabulary-list.tsx +++ b/web/src/vocab/vocabulary-list.tsx @@ -3,6 +3,8 @@ import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries"; +import { useLang } from "../lib/use-lang"; +import { rowStateClass } from "../lib/class-recipes"; import { byKey } from "../lib/sort"; import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; import { MutationError } from "../components/mutation-error"; @@ -12,9 +14,9 @@ import { Label } from "@/components/ui/label"; import { ListSkeleton } from "@/components/ui/skeletons"; export function VocabularyList() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); - const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + const lang = useLang(); const { data, isLoading, isError } = useVocabularies(); @@ -110,7 +112,7 @@ export function VocabularyList() { - `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-primary/10" : "hover:bg-muted"}` + `block flex-1 px-3 py-2 text-sm ${rowStateClass(isActive)}` } > {v.key} diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index 402379e..d8705e8 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -2,15 +2,16 @@ import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useTerms, useAddTerm, useVocabularies } from "../api/queries"; +import { useLang } from "../lib/use-lang"; import { useBreadcrumb } from "../shell/use-breadcrumb"; import { FilteredRecordList } from "../components/filtered-record-list"; import { LabelledRecordCreateForm } from "../components/labelled-record-create-form"; import { TermRow } from "./term-row"; export function VocabularyTerms() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const { id } = useParams(); - const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + const lang = useLang(); const { data: terms, isLoading, isError } = useTerms(id); const addTerm = useAddTerm(); From 74cde67a54ccee38ad636470a71a80df3ec31947 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 23:49:35 +0200 Subject: [PATCH 5/5] =?UTF-8?q?refactor(web):=20kit=20consistency=20?= =?UTF-8?q?=E2=80=94=20focusRing,=20PageTitle,=20Badge,=20size-4,=20icon?= =?UTF-8?q?=20buttons=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/auth/login-page.tsx | 3 ++- web/src/fields/field-list.tsx | 4 ++-- web/src/objects/object-detail-drawer.tsx | 3 ++- web/src/objects/objects-page.tsx | 9 +++++---- web/src/shell/header-search.tsx | 2 +- web/src/shell/sidebar.tsx | 6 ++++-- web/src/shell/theme-switch.tsx | 2 +- web/src/shell/user-menu.tsx | 2 +- 8 files changed, 18 insertions(+), 13 deletions(-) diff --git a/web/src/auth/login-page.tsx b/web/src/auth/login-page.tsx index 1eedfeb..f658cb6 100644 --- a/web/src/auth/login-page.tsx +++ b/web/src/auth/login-page.tsx @@ -7,6 +7,7 @@ import { useConfig } from "../config/config-context"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { PageTitle } from "@/components/ui/page-title"; /** Accept only a single-leading-slash local path; reject protocol-relative * ("//host") and absolute URLs to avoid an open redirect. */ @@ -46,7 +47,7 @@ export function LoginPage() { return (
-

{app_name}

+ {app_name} {sessionExpired && (

{t("auth.sessionExpired")}

)} diff --git a/web/src/fields/field-list.tsx b/web/src/fields/field-list.tsx index 9888734..c97e239 100644 --- a/web/src/fields/field-list.tsx +++ b/web/src/fields/field-list.tsx @@ -96,9 +96,9 @@ export function FieldList({ > {labelText(def.labels, lang)} {def.key} - + {t(`fields.types.${def.data_type}`)} - + {def.required && ( inside a Base UI Drawer that @@ -30,7 +31,7 @@ export function ObjectDetailDrawer({
} > diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index 5894ec6..ad823e6 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -7,6 +7,7 @@ import { ObjectsTable } from "./objects-table"; import { useMediaQuery } from "../lib/use-media-query"; import { useDocumentTitle } from "../lib/use-document-title"; import { useBreadcrumb } from "../shell/use-breadcrumb"; +import { Button } from "@/components/ui/button"; import { PageTitle } from "@/components/ui/page-title"; const ObjectDetailDrawer = lazy(() => @@ -47,14 +48,14 @@ export function ObjectsPage() { {open && (
- +
diff --git a/web/src/shell/header-search.tsx b/web/src/shell/header-search.tsx index c0b50ca..afdf27d 100644 --- a/web/src/shell/header-search.tsx +++ b/web/src/shell/header-search.tsx @@ -20,7 +20,7 @@ export function HeaderSearch() {
cn( "flex items-center gap-2 rounded-md px-2 py-1 outline-none", - "focus-visible:ring-3 focus-visible:ring-ring/50", + focusRing, collapsed && "justify-center", isActive && "bg-accent font-medium", ); @@ -85,7 +86,8 @@ export function Sidebar() { title={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")} className={cn( "flex items-center justify-center rounded-md p-1 outline-none", - "hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50", + "hover:bg-accent", + focusRing, "disabled:pointer-events-none disabled:opacity-50", )} > diff --git a/web/src/shell/theme-switch.tsx b/web/src/shell/theme-switch.tsx index 266751b..50bded4 100644 --- a/web/src/shell/theme-switch.tsx +++ b/web/src/shell/theme-switch.tsx @@ -36,7 +36,7 @@ export function ThemeSwitch() { : "text-muted-foreground hover:text-foreground", )} > - + ); })} diff --git a/web/src/shell/user-menu.tsx b/web/src/shell/user-menu.tsx index 643feb1..6d7cf0f 100644 --- a/web/src/shell/user-menu.tsx +++ b/web/src/shell/user-menu.tsx @@ -24,7 +24,7 @@ export function UserMenu() { - + {me.email} }