From 04ed0c50e26b98cdd6ebe666016e107b7b122e30 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 14:08:08 +0200 Subject: [PATCH 1/3] feat(web): indigo brand token + status tokens + Badge success/warning variants (#49) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/components/ui/badge.stories.tsx | 8 +++++ web/src/components/ui/badge.tsx | 2 ++ web/src/index.css | 32 +++++++++++++++++--- web/src/objects/visibility-badge.stories.tsx | 11 ++++--- web/src/objects/visibility-badge.tsx | 12 +++----- web/src/search/highlight.tsx | 2 +- 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/web/src/components/ui/badge.stories.tsx b/web/src/components/ui/badge.stories.tsx index e8676a8..cc2974a 100644 --- a/web/src/components/ui/badge.stories.tsx +++ b/web/src/components/ui/badge.stories.tsx @@ -27,6 +27,14 @@ export const Destructive: Story = { args: { variant: 'destructive', children: 'Error' }, } +export const Success: Story = { + args: { variant: 'success', children: 'Public' }, +} + +export const Warning: Story = { + args: { variant: 'warning', children: 'Internal' }, +} + export const Outline: Story = { args: { variant: 'outline', children: 'Draft' }, } diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index b20959d..c4bdbcd 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -14,6 +14,8 @@ const badgeVariants = cva( "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", destructive: "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + success: "bg-success/10 text-success [a]:hover:bg-success/20", + warning: "bg-warning/10 text-warning [a]:hover:bg-warning/20", outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", ghost: diff --git a/web/src/index.css b/web/src/index.css index 2f5c0c9..3e240c9 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -18,6 +18,12 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-highlight: var(--highlight); + --color-highlight-foreground: var(--highlight-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -35,7 +41,7 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --primary: oklch(0.511 0.262 276.966); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); @@ -44,9 +50,15 @@ --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); + --success: oklch(0.627 0.194 149.214); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.666 0.179 58.318); + --warning-foreground: oklch(0.985 0 0); + --highlight: oklch(0.905 0.182 98.111); + --highlight-foreground: oklch(0.205 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --ring: oklch(0.511 0.262 276.966); --radius: 0.625rem; } @@ -57,7 +69,7 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); + --primary: oklch(0.673 0.182 276.935); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); @@ -66,9 +78,15 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + --success: oklch(0.723 0.192 149.579); + --success-foreground: oklch(0.205 0 0); + --warning: oklch(0.769 0.188 70.08); + --warning-foreground: oklch(0.205 0 0); + --highlight: oklch(0.852 0.199 91.936); + --highlight-foreground: oklch(0.205 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --ring: oklch(0.673 0.182 276.935); } @layer base { @@ -80,3 +98,9 @@ @apply bg-background text-foreground font-sans; } } + +@layer components { + .label-caption { + @apply text-xs font-medium uppercase tracking-wide text-muted-foreground; + } +} diff --git a/web/src/objects/visibility-badge.stories.tsx b/web/src/objects/visibility-badge.stories.tsx index 53606c7..882d751 100644 --- a/web/src/objects/visibility-badge.stories.tsx +++ b/web/src/objects/visibility-badge.stories.tsx @@ -26,16 +26,17 @@ export const Draft: Story = { args: { visibility: 'draft' }, } -// The single project-wide CssCheck. VisibilityBadge applies `bg-green-100` for -// the `public` visibility (see STYLES in visibility-badge.tsx). A concrete -// resolved background colour proves the shared preview actually loaded the app's -// Tailwind stylesheet — an unstyled badge would report a transparent background. +// The single project-wide CssCheck. VisibilityBadge applies the `success` +// variant for the `public` visibility (`bg-success/10`, see VARIANT in +// visibility-badge.tsx). A concrete resolved background colour proves the shared +// preview actually loaded the app's Tailwind stylesheet — an unstyled badge +// would report a transparent background. export const CssCheck: Story = { args: { visibility: 'public' }, play: async ({ canvas }) => { const badge = canvas.getByText('Public') await expect(getComputedStyle(badge).backgroundColor).toBe( - 'oklch(0.962 0.044 156.743)', + 'oklab(0.627 -0.166662 0.0992956 / 0.1)', ) }, } diff --git a/web/src/objects/visibility-badge.tsx b/web/src/objects/visibility-badge.tsx index 40c398f..3afaa20 100644 --- a/web/src/objects/visibility-badge.tsx +++ b/web/src/objects/visibility-badge.tsx @@ -5,18 +5,16 @@ import { Badge } from "@/components/ui/badge"; type Visibility = components["schemas"]["Visibility"]; -const STYLES: Record = { - draft: "bg-neutral-100 text-neutral-600", - internal: "bg-amber-100 text-amber-800", - public: "bg-green-100 text-green-800", +const VARIANT: Record = { + draft: "secondary", + internal: "warning", + public: "success", }; export function VisibilityBadge({ visibility }: { visibility: Visibility }) { const { t } = useTranslation(); return ( - - {t(`visibility.${visibility}`)} - + {t(`visibility.${visibility}`)} ); } diff --git a/web/src/search/highlight.tsx b/web/src/search/highlight.tsx index e8af6d6..aec0762 100644 --- a/web/src/search/highlight.tsx +++ b/web/src/search/highlight.tsx @@ -31,7 +31,7 @@ export function Highlight({ text }: { text: string }) { } nodes.push( - + {rest.slice(start + PRE.length, end)} , ); From cde7be9f2a96eda2c7fd0d86fd88436133844166 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 14:15:54 +0200 Subject: [PATCH 2/3] refactor(web): migrate feature screens to design tokens + radius token (#49) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app.tsx | 2 +- web/src/auth/login-page.tsx | 2 +- web/src/authorities/authorities-page.tsx | 12 +++++----- web/src/components/delete-confirm-dialog.tsx | 4 ++-- web/src/fields/field-form.tsx | 10 ++++---- web/src/fields/field-list.tsx | 14 ++++++------ web/src/objects/delete-object-dialog.tsx | 4 ++-- web/src/objects/flexible-field-value.tsx | 8 +++---- web/src/objects/object-detail-drawer.tsx | 2 +- web/src/objects/object-detail.tsx | 12 +++++----- web/src/objects/object-edit-form.tsx | 2 +- web/src/objects/object-form.tsx | 10 ++++---- web/src/objects/objects-page.tsx | 2 +- web/src/objects/objects-table.tsx | 24 ++++++++++---------- web/src/objects/options-combobox.tsx | 2 +- web/src/objects/publish-control.tsx | 14 ++++++------ web/src/search/search-panel.tsx | 10 ++++---- web/src/search/search-result-row.tsx | 6 ++--- web/src/search/select-search-prompt.tsx | 2 +- web/src/shell/lang-switch.tsx | 2 +- web/src/shell/sidebar.tsx | 10 ++++---- web/src/vocab/select-vocabulary-prompt.tsx | 2 +- web/src/vocab/vocabulary-list.tsx | 12 +++++----- web/src/vocab/vocabulary-terms.tsx | 12 +++++----- 24 files changed, 90 insertions(+), 90 deletions(-) diff --git a/web/src/app.tsx b/web/src/app.tsx index 54205c4..a5e58a9 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -26,7 +26,7 @@ const FieldsPage = lazy(() => ); function FormFallback() { - return
Loading…
; + return
Loading…
; } export function App() { diff --git a/web/src/auth/login-page.tsx b/web/src/auth/login-page.tsx index 32b9e18..6009685 100644 --- a/web/src/auth/login-page.tsx +++ b/web/src/auth/login-page.tsx @@ -53,7 +53,7 @@ export function LoginPage() { /> {errorKey && ( -

+

{t(errorKey)}

)} diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index 676f92c..b0696af 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -53,7 +53,7 @@ export function AuthoritiesPage() { role="tab" aria-selected={k === currentKind} className={({ isActive }) => - `rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}` + `rounded-md px-3 py-1 text-sm ${isActive ? "bg-primary text-primary-foreground" : "border"}` } > {t(`authorities.${k}`)} @@ -63,13 +63,13 @@ export function AuthoritiesPage() {
    {isLoading && ( -
  • +
  • )} {isError && ( -
  • {t("authorities.loadError")}
  • +
  • {t("authorities.loadError")}
  • )} {!isLoading && !isError && authorities?.length === 0 && ( -
  • {t("authorities.empty")}
  • +
  • {t("authorities.empty")}
  • )} {authorities?.map((a) => ( @@ -84,13 +84,13 @@ export function AuthoritiesPage() { {error && ( -

    +

    {t("form.required")}

    )} {create.isError && ( -

    +

    {t("form.rejected")}

    )} diff --git a/web/src/components/delete-confirm-dialog.tsx b/web/src/components/delete-confirm-dialog.tsx index 190d2ff..43dcd62 100644 --- a/web/src/components/delete-confirm-dialog.tsx +++ b/web/src/components/delete-confirm-dialog.tsx @@ -47,7 +47,7 @@ export function DeleteConfirmDialog({ + } @@ -56,7 +56,7 @@ export function DeleteConfirmDialog({ {t("actions.delete")} {description} {message && ( -

    +

    {message}

    )} diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx index c153128..80da315 100644 --- a/web/src/fields/field-form.tsx +++ b/web/src/fields/field-form.tsx @@ -122,7 +122,7 @@ export function FieldForm({ value={dataType} disabled={isEdit} onChange={(e) => setDataType(e.target.value)} - className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60" + className="w-full rounded-md border px-2 py-1 text-sm disabled:opacity-60" > {TYPES.map((type) => ( {vocabularies?.map((vocab) => ( @@ -160,7 +160,7 @@ export function FieldForm({ value={authorityKind} disabled={isEdit} onChange={(e) => setAuthorityKind(e.target.value)} - className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60" + className="w-full rounded-md border px-2 py-1 text-sm disabled:opacity-60" > {KINDS.map((kind) => ( @@ -183,12 +183,12 @@ export function FieldForm({ {error && ( -

    +

    {t("form.required")}

    )} {failed && ( -

    +

    {t("form.rejected")}

    )} diff --git a/web/src/fields/field-list.tsx b/web/src/fields/field-list.tsx index 320cbb8..2c43168 100644 --- a/web/src/fields/field-list.tsx +++ b/web/src/fields/field-list.tsx @@ -30,9 +30,9 @@ export function FieldList({ ); } - if (isError) return

    {t("fields.loadError")}

    ; + if (isError) return

    {t("fields.loadError")}

    ; if (!data || data.length === 0) - return

    {t("fields.empty")}

    ; + return

    {t("fields.empty")}

    ; const groups = new Map(); @@ -53,7 +53,7 @@ export function FieldList({
      {entries.map(([group, defs]) => (
    • -
      +
      {group}
        @@ -61,7 +61,7 @@ export function FieldList({
      • } @@ -49,7 +49,7 @@ export function DeleteObjectDialog({ id }: { id: string }) { {t("actions.delete")} {t("actions.confirmDelete")} {error && ( -

        +

        {t("form.rejected")}

        )} diff --git a/web/src/objects/flexible-field-value.tsx b/web/src/objects/flexible-field-value.tsx index afc6d7d..340b3d1 100644 --- a/web/src/objects/flexible-field-value.tsx +++ b/web/src/objects/flexible-field-value.tsx @@ -49,9 +49,9 @@ function TermValue({ if (typeof value !== "string") return <>—; const term = terms?.find((x) => x.id === value); if (term) return <>{labelText(term.labels, lang)}; - if (isLoading) return ; + if (isLoading) return ; return ( - + {value} {t("objects.unknownRef")} ); @@ -72,9 +72,9 @@ function AuthorityValue({ if (typeof value !== "string") return <>—; const authority = authorities?.find((x) => x.id === value); if (authority) return <>{labelText(authority.labels, lang)}; - if (isLoading) return ; + if (isLoading) return ; return ( - + {value} {t("objects.unknownRef")} ); diff --git a/web/src/objects/object-detail-drawer.tsx b/web/src/objects/object-detail-drawer.tsx index 1d99ab8..6e55b49 100644 --- a/web/src/objects/object-detail-drawer.tsx +++ b/web/src/objects/object-detail-drawer.tsx @@ -30,7 +30,7 @@ export function ObjectDetailDrawer({
        diff --git a/web/src/objects/object-detail.tsx b/web/src/objects/object-detail.tsx index 179a18f..b879ae3 100644 --- a/web/src/objects/object-detail.tsx +++ b/web/src/objects/object-detail.tsx @@ -19,8 +19,8 @@ function Field({ label, value }: { label: string; value: ReactNode }) { return (
        -
        {label}
        -
        {empty ? "—" : value}
        +
        {label}
        +
        {empty ? "—" : value}
        ); } @@ -39,9 +39,9 @@ export function ObjectDetail() { ); } - if (isError) return

        {t("objects.loadError")}

        ; + if (isError) return

        {t("objects.loadError")}

        ; - if (!object) return

        {t("objects.notFound")}

        ; + if (!object) return

        {t("objects.notFound")}

        ; // Prefer the active locale's label, then English, then the raw key. const lang = i18n.language.startsWith("sv") ? "sv" : "en"; @@ -105,7 +105,7 @@ export function ObjectDetail() { /> {groups.map((g) => (
        -
        {g.group}
        +
        {g.group}
        {g.defs.map((d) => ( + {typeof value === "object" ? JSON.stringify(value) : String(value)} } diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx index 4829973..3bb9188 100644 --- a/web/src/objects/object-edit-form.tsx +++ b/web/src/objects/object-edit-form.tsx @@ -29,7 +29,7 @@ export function ObjectEditForm() { if (isLoading) return
        ; - if (!object) return

        {t("objects.notFound")}

        ; + if (!object) return

        {t("objects.notFound")}

        ; const core: ObjectCore = { object_number: object.object_number, diff --git a/web/src/objects/object-form.tsx b/web/src/objects/object-form.tsx index ba16cd0..75cd438 100644 --- a/web/src/objects/object-form.tsx +++ b/web/src/objects/object-form.tsx @@ -117,7 +117,7 @@ export function ObjectForm({ /> {errors.core?.[key] && ( -

        +

        {t("form.required")}

        )} @@ -127,7 +127,7 @@ export function ObjectForm({ return (
        {formError && ( -

        +

        {formError}

        )} @@ -147,7 +147,7 @@ export function ObjectForm({ setLimit(Number(event.target.value))} aria-label={t("objects.pageSize")} - className="rounded border bg-white px-1 py-0.5" + className="rounded-md border bg-white px-1 py-0.5" > {PAGE_SIZES.map((size) => (
        - {!hasQuery &&

        {t("search.prompt")}

        } + {!hasQuery &&

        {t("search.prompt")}

        } {hasQuery && search.isLoading && (
        @@ -92,7 +92,7 @@ export function SearchPanel() { )} {hasQuery && search.isError && ( -

        +

        {search.error instanceof HttpError && search.error.status === 503 ? t("search.unavailable") : t("search.loadError")} @@ -100,12 +100,12 @@ export function SearchPanel() { )} {hasQuery && !search.isLoading && !search.isError && hits.length === 0 && ( -

        {t("search.empty")}

        +

        {t("search.empty")}

        )} {hits.length > 0 && ( <> -

        +

        {t("search.resultCount", { count: total })}

          diff --git a/web/src/search/search-result-row.tsx b/web/src/search/search-result-row.tsx index 372c785..8c6a49e 100644 --- a/web/src/search/search-result-row.tsx +++ b/web/src/search/search-result-row.tsx @@ -12,16 +12,16 @@ export function SearchResultRow({ hit }: { hit: SearchHitView }) { - `block border-b px-3 py-2 ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` + `block border-b px-3 py-2 ${isActive ? "bg-primary/10" : "hover:bg-muted"}` } >
          {hit.object_name}
          -
          +
          {hit.object_number}
          {hit.snippet && ( -

          +

          )} diff --git a/web/src/search/select-search-prompt.tsx b/web/src/search/select-search-prompt.tsx index d5aa715..65874ec 100644 --- a/web/src/search/select-search-prompt.tsx +++ b/web/src/search/select-search-prompt.tsx @@ -4,7 +4,7 @@ export function SelectSearchPrompt() { const { t } = useTranslation(); return ( -
          +
          {t("search.selectPrompt")}
          ); diff --git a/web/src/shell/lang-switch.tsx b/web/src/shell/lang-switch.tsx index 67b9090..0e2e2a2 100644 --- a/web/src/shell/lang-switch.tsx +++ b/web/src/shell/lang-switch.tsx @@ -11,7 +11,7 @@ export function LangSwitch() { key={lng} onClick={() => setLocale(lng)} aria-pressed={base === lng} - className={base === lng ? "font-bold" : "text-neutral-400"} + className={base === lng ? "font-bold" : "text-muted-foreground"} > {lng.toUpperCase()} diff --git a/web/src/shell/sidebar.tsx b/web/src/shell/sidebar.tsx index e395902..25b041b 100644 --- a/web/src/shell/sidebar.tsx +++ b/web/src/shell/sidebar.tsx @@ -41,10 +41,10 @@ function readStored(): boolean { function navLinkClass(collapsed: boolean) { return ({ isActive }: { isActive: boolean }) => cn( - "flex items-center gap-2 rounded px-2 py-1 outline-none", + "flex items-center gap-2 rounded-md px-2 py-1 outline-none", "focus-visible:ring-3 focus-visible:ring-ring/50", collapsed && "justify-center", - isActive && "bg-neutral-200 font-medium", + isActive && "bg-accent font-medium", ); } @@ -68,7 +68,7 @@ export function Sidebar() { return (