merge: design-token adoption — indigo brand accent, status tokens, check:colors guard (#49)
CI / web (push) Has been cancelled

This commit is contained in:
2026-06-07 14:27:49 +02:00
33 changed files with 184 additions and 107 deletions
+1
View File
@@ -27,3 +27,4 @@ jobs:
- run: pnpm test - run: pnpm test
- run: pnpm build - run: pnpm build
- run: pnpm check:size - run: pnpm check:size
- run: pnpm check:colors
+1
View File
@@ -14,6 +14,7 @@
"lint": "eslint .", "lint": "eslint .",
"gen:api": "openapi-typescript http://localhost:8080/api-docs/openapi.json -o src/api/schema.d.ts", "gen:api": "openapi-typescript http://localhost:8080/api-docs/openapi.json -o src/api/schema.d.ts",
"check:size": "node scripts/check-bundle-size.mjs", "check:size": "node scripts/check-bundle-size.mjs",
"check:colors": "node scripts/check-no-raw-colors.mjs",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
+42
View File
@@ -0,0 +1,42 @@
// Fails if any raw Tailwind color utility appears outside src/components/ui/.
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
const root = "src";
const excludeDir = join("src", "components", "ui");
const RAW_COLOR =
/(?:text|bg|border|ring|fill|stroke|from|to|via|decoration|outline|divide|placeholder)-(?:neutral|gray|slate|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/;
function walk(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (path === excludeDir) continue;
files.push(...walk(path));
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
files.push(path);
}
}
return files;
}
const files = walk(root);
const offenses = [];
for (const file of files) {
const lines = readFileSync(file, "utf8").split("\n");
for (let i = 0; i < lines.length; i++) {
const match = RAW_COLOR.exec(lines[i]);
if (match) offenses.push(`${relative(".", file)}:${i + 1}: ${match[0]}`);
}
}
if (offenses.length > 0) {
console.error(
`raw color utilities found outside components/ui/ (${offenses.length}):`,
);
for (const offense of offenses) console.error(` ${offense}`);
process.exit(1);
}
console.log(`no raw color utilities outside components/ui/ (${files.length} files scanned)`);
+1 -1
View File
@@ -26,7 +26,7 @@ const FieldsPage = lazy(() =>
); );
function FormFallback() { function FormFallback() {
return <div role="status" className="p-4 text-sm text-neutral-400">Loading</div>; return <div role="status" className="p-4 text-sm text-muted-foreground">Loading</div>;
} }
export function App() { export function App() {
+1 -1
View File
@@ -53,7 +53,7 @@ export function LoginPage() {
/> />
</div> </div>
{errorKey && ( {errorKey && (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{t(errorKey)} {t(errorKey)}
</p> </p>
)} )}
+6 -6
View File
@@ -53,7 +53,7 @@ export function AuthoritiesPage() {
role="tab" role="tab"
aria-selected={k === currentKind} aria-selected={k === currentKind}
className={({ isActive }) => 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}`)} {t(`authorities.${k}`)}
@@ -63,13 +63,13 @@ export function AuthoritiesPage() {
<ul className="mb-4"> <ul className="mb-4">
{isLoading && ( {isLoading && (
<li className="text-sm text-neutral-400"></li> <li className="text-sm text-muted-foreground"></li>
)} )}
{isError && ( {isError && (
<li className="text-sm text-red-600">{t("authorities.loadError")}</li> <li className="text-sm text-destructive">{t("authorities.loadError")}</li>
)} )}
{!isLoading && !isError && authorities?.length === 0 && ( {!isLoading && !isError && authorities?.length === 0 && (
<li className="text-sm text-neutral-500">{t("authorities.empty")}</li> <li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
)} )}
{authorities?.map((a) => ( {authorities?.map((a) => (
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} /> <AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
@@ -84,13 +84,13 @@ export function AuthoritiesPage() {
<LabelEditor value={labels} onChange={setLabels} /> <LabelEditor value={labels} onChange={setLabels} />
{error && ( {error && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.required")} {t("form.required")}
</p> </p>
)} )}
{create.isError && ( {create.isError && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}
+2 -2
View File
@@ -47,7 +47,7 @@ export function DeleteConfirmDialog({
<AlertDialog open={open} onOpenChange={setOpen}> <AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger <AlertDialogTrigger
render={ render={
<Button variant="ghost" size="sm" className="text-red-600"> <Button variant="ghost" size="sm" className="text-destructive">
{triggerLabel ?? t("actions.delete")} {triggerLabel ?? t("actions.delete")}
</Button> </Button>
} }
@@ -56,7 +56,7 @@ export function DeleteConfirmDialog({
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle> <AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
{message && ( {message && (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{message} {message}
</p> </p>
)} )}
+8
View File
@@ -27,6 +27,14 @@ export const Destructive: Story = {
args: { variant: 'destructive', children: 'Error' }, 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 = { export const Outline: Story = {
args: { variant: 'outline', children: 'Draft' }, args: { variant: 'outline', children: 'Draft' },
} }
+2
View File
@@ -14,6 +14,8 @@ const badgeVariants = cva(
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: 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", "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: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
+5 -5
View File
@@ -122,7 +122,7 @@ export function FieldForm({
value={dataType} value={dataType}
disabled={isEdit} disabled={isEdit}
onChange={(e) => setDataType(e.target.value)} 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) => ( {TYPES.map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
@@ -140,7 +140,7 @@ export function FieldForm({
value={vocabularyId} value={vocabularyId}
disabled={isEdit} disabled={isEdit}
onChange={(e) => setVocabularyId(e.target.value)} onChange={(e) => setVocabularyId(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"
> >
<option value="">{t("form.selectPlaceholder")}</option> <option value="">{t("form.selectPlaceholder")}</option>
{vocabularies?.map((vocab) => ( {vocabularies?.map((vocab) => (
@@ -160,7 +160,7 @@ export function FieldForm({
value={authorityKind} value={authorityKind}
disabled={isEdit} disabled={isEdit}
onChange={(e) => setAuthorityKind(e.target.value)} 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"
> >
<option value="">{t("fields.anyKind")}</option> <option value="">{t("fields.anyKind")}</option>
{KINDS.map((kind) => ( {KINDS.map((kind) => (
@@ -183,12 +183,12 @@ export function FieldForm({
</label> </label>
{error && ( {error && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.required")} {t("form.required")}
</p> </p>
)} )}
{failed && ( {failed && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}
+7 -7
View File
@@ -30,9 +30,9 @@ export function FieldList({
); );
} }
if (isError) return <p className="p-4 text-sm text-red-600">{t("fields.loadError")}</p>; if (isError) return <p className="p-4 text-sm text-destructive">{t("fields.loadError")}</p>;
if (!data || data.length === 0) if (!data || data.length === 0)
return <p className="p-4 text-sm text-neutral-500">{t("fields.empty")}</p>; return <p className="p-4 text-sm text-muted-foreground">{t("fields.empty")}</p>;
const groups = new Map<string, FieldDefinitionView[]>(); const groups = new Map<string, FieldDefinitionView[]>();
@@ -53,7 +53,7 @@ export function FieldList({
<ul className="overflow-auto"> <ul className="overflow-auto">
{entries.map(([group, defs]) => ( {entries.map(([group, defs]) => (
<li key={group}> <li key={group}>
<div className="border-b bg-neutral-50 px-3 py-1 text-xs font-medium uppercase tracking-wide text-neutral-500"> <div className="border-b bg-muted px-3 py-1 label-caption">
{group} {group}
</div> </div>
<ul> <ul>
@@ -61,7 +61,7 @@ export function FieldList({
<li <li
key={def.key} key={def.key}
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${ className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
def.key === selectedKey ? "bg-indigo-50" : "" def.key === selectedKey ? "bg-primary/10" : ""
}`} }`}
> >
<button <button
@@ -71,13 +71,13 @@ export function FieldList({
onClick={() => onSelect(def)} onClick={() => onSelect(def)}
> >
<span className="font-medium">{labelText(def.labels, lang)}</span> <span className="font-medium">{labelText(def.labels, lang)}</span>
<span className="text-xs text-neutral-400">{def.key}</span> <span className="text-xs text-muted-foreground">{def.key}</span>
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600"> <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{t(`fields.types.${def.data_type}`)} {t(`fields.types.${def.data_type}`)}
</span> </span>
{def.required && ( {def.required && (
<span <span
className="text-xs text-red-600" className="text-xs text-destructive"
title={t("fields.required")} title={t("fields.required")}
aria-label={t("fields.required")} aria-label={t("fields.required")}
> >
+28 -4
View File
@@ -18,6 +18,12 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --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-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@@ -35,7 +41,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 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); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@@ -44,9 +50,15 @@
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --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); --border: oklch(0.922 0 0);
--input: 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; --radius: 0.625rem;
} }
@@ -57,7 +69,7 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 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); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
@@ -66,9 +78,15 @@
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --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%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.673 0.182 276.935);
} }
@layer base { @layer base {
@@ -80,3 +98,9 @@
@apply bg-background text-foreground font-sans; @apply bg-background text-foreground font-sans;
} }
} }
@layer components {
.label-caption {
@apply text-xs font-medium uppercase tracking-wide text-muted-foreground;
}
}
+2 -2
View File
@@ -39,7 +39,7 @@ export function DeleteObjectDialog({ id }: { id: string }) {
<AlertDialog open={open} onOpenChange={setOpen}> <AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger <AlertDialogTrigger
render={ render={
<Button variant="ghost" size="sm" className="text-red-600"> <Button variant="ghost" size="sm" className="text-destructive">
{t("actions.delete")} {t("actions.delete")}
</Button> </Button>
} }
@@ -49,7 +49,7 @@ export function DeleteObjectDialog({ id }: { id: string }) {
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle> <AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{t("actions.confirmDelete")}</AlertDialogDescription> <AlertDialogDescription>{t("actions.confirmDelete")}</AlertDialogDescription>
{error && ( {error && (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}
+4 -4
View File
@@ -49,9 +49,9 @@ function TermValue({
if (typeof value !== "string") return <></>; if (typeof value !== "string") return <></>;
const term = terms?.find((x) => x.id === value); const term = terms?.find((x) => x.id === value);
if (term) return <>{labelText(term.labels, lang)}</>; if (term) return <>{labelText(term.labels, lang)}</>;
if (isLoading) return <span className="text-neutral-400"></span>; if (isLoading) return <span className="text-muted-foreground"></span>;
return ( return (
<span className="text-neutral-400"> <span className="text-muted-foreground">
{value} {t("objects.unknownRef")} {value} {t("objects.unknownRef")}
</span> </span>
); );
@@ -72,9 +72,9 @@ function AuthorityValue({
if (typeof value !== "string") return <></>; if (typeof value !== "string") return <></>;
const authority = authorities?.find((x) => x.id === value); const authority = authorities?.find((x) => x.id === value);
if (authority) return <>{labelText(authority.labels, lang)}</>; if (authority) return <>{labelText(authority.labels, lang)}</>;
if (isLoading) return <span className="text-neutral-400"></span>; if (isLoading) return <span className="text-muted-foreground"></span>;
return ( return (
<span className="text-neutral-400"> <span className="text-muted-foreground">
{value} {t("objects.unknownRef")} {value} {t("objects.unknownRef")}
</span> </span>
); );
+1 -1
View File
@@ -30,7 +30,7 @@ export function ObjectDetailDrawer({
<div className="flex justify-end border-b p-2"> <div className="flex justify-end border-b p-2">
<DrawerClose <DrawerClose
aria-label={t("actions.closeDetail")} aria-label={t("actions.closeDetail")}
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900" className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="size-4" aria-hidden="true" /> <X className="size-4" aria-hidden="true" />
</DrawerClose> </DrawerClose>
+6 -6
View File
@@ -19,8 +19,8 @@ function Field({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<div className="border-b py-2"> <div className="border-b py-2">
<div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div> <div className="label-caption">{label}</div>
<div className="text-sm text-neutral-900">{empty ? "—" : value}</div> <div className="text-sm text-foreground">{empty ? "—" : value}</div>
</div> </div>
); );
} }
@@ -39,9 +39,9 @@ export function ObjectDetail() {
); );
} }
if (isError) return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>; if (isError) return <p className="p-4 text-sm text-destructive">{t("objects.loadError")}</p>;
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>; if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
// Prefer the active locale's label, then English, then the raw key. // Prefer the active locale's label, then English, then the raw key.
const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const lang = i18n.language.startsWith("sv") ? "sv" : "en";
@@ -105,7 +105,7 @@ export function ObjectDetail() {
/> />
{groups.map((g) => ( {groups.map((g) => (
<div key={g.group} className="mt-4"> <div key={g.group} className="mt-4">
<div className="mb-1 text-xs font-medium uppercase text-neutral-500">{g.group}</div> <div className="mb-1 label-caption">{g.group}</div>
{g.defs.map((d) => ( {g.defs.map((d) => (
<Field <Field
key={d.key} key={d.key}
@@ -119,7 +119,7 @@ export function ObjectDetail() {
key={key} key={key}
label={key} label={key}
value={ value={
<span className="text-neutral-400"> <span className="text-muted-foreground">
{typeof value === "object" ? JSON.stringify(value) : String(value)} {typeof value === "object" ? JSON.stringify(value) : String(value)}
</span> </span>
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export function ObjectEditForm() {
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />; if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>; if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
const core: ObjectCore = { const core: ObjectCore = {
object_number: object.object_number, object_number: object.object_number,
+5 -5
View File
@@ -117,7 +117,7 @@ export function ObjectForm({
/> />
{errors.core?.[key] && ( {errors.core?.[key] && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.required")} {t("form.required")}
</p> </p>
)} )}
@@ -127,7 +127,7 @@ export function ObjectForm({
return ( return (
<form onSubmit={submit} className="space-y-4 overflow-auto p-4"> <form onSubmit={submit} className="space-y-4 overflow-auto p-4">
{formError && ( {formError && (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{formError} {formError}
</p> </p>
)} )}
@@ -147,7 +147,7 @@ export function ObjectForm({
<select <select
id="visibility" id="visibility"
className="w-full rounded border px-2 py-1 text-sm" className="w-full rounded-md border px-2 py-1 text-sm"
{...register("visibility")} {...register("visibility")}
> >
<option value="draft">{t("form.draft")}</option> <option value="draft">{t("form.draft")}</option>
@@ -158,7 +158,7 @@ export function ObjectForm({
{definitions && definitions.length > 0 && ( {definitions && definitions.length > 0 && (
<fieldset className="space-y-3 border-t pt-3"> <fieldset className="space-y-3 border-t pt-3">
<legend className="text-xs font-medium uppercase text-neutral-500"> <legend className="label-caption">
{t("form.flexibleHeading")} {t("form.flexibleHeading")}
</legend> </legend>
@@ -167,7 +167,7 @@ export function ObjectForm({
<FieldInput definition={def} form={form} /> <FieldInput definition={def} form={form} />
{errors.fields?.[def.key] && ( {errors.fields?.[def.key] && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{errors.fields[def.key]?.message ?? t("form.required")} {errors.fields[def.key]?.message ?? t("form.required")}
</p> </p>
)} )}
+1 -1
View File
@@ -42,7 +42,7 @@ export function ObjectsPage() {
type="button" type="button"
onClick={closeDetail} onClick={closeDetail}
aria-label={t("actions.closeDetail")} aria-label={t("actions.closeDetail")}
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900" className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="size-4" aria-hidden="true" /> <X className="size-4" aria-hidden="true" />
</button> </button>
+12 -12
View File
@@ -138,10 +138,10 @@ export function ObjectsTable() {
<button <button
type="button" type="button"
onClick={() => toggleSort(col)} onClick={() => toggleSort(col)}
className="flex items-center gap-1 hover:text-neutral-900" className="flex items-center gap-1 hover:text-foreground"
> >
{t(COLUMN_KEYS[col])} {t(COLUMN_KEYS[col])}
<Icon className="size-3.5 text-neutral-400" aria-hidden="true" /> <Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
</button> </button>
</th> </th>
); );
@@ -170,7 +170,7 @@ export function ObjectsTable() {
type="button" type="button"
aria-pressed={active} aria-pressed={active}
onClick={() => setVisibility(value)} onClick={() => setVisibility(value)}
className={`rounded px-2 py-1 ${active ? "bg-indigo-600 text-white" : "border"}`} className={`rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
> >
{value === "all" ? t("search.all") : t(`visibility.${value}`)} {value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button> </button>
@@ -184,7 +184,7 @@ export function ObjectsTable() {
); );
const columns = ( const columns = (
<thead className="border-b bg-neutral-50 text-xs text-neutral-500"> <thead className="border-b bg-muted text-xs text-muted-foreground">
<tr> <tr>
{headerCell("object_number")} {headerCell("object_number")}
{headerCell("object_name")} {headerCell("object_name")}
@@ -220,7 +220,7 @@ export function ObjectsTable() {
body = ( body = (
<tbody> <tbody>
<tr> <tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-red-600"> <td colSpan={6} className="px-3 py-6 text-center text-sm text-destructive">
{t("objects.loadError")} {t("objects.loadError")}
</td> </td>
</tr> </tr>
@@ -230,7 +230,7 @@ export function ObjectsTable() {
body = ( body = (
<tbody> <tbody>
<tr> <tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-neutral-500"> <td colSpan={6} className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("objects.empty")} {t("objects.empty")}
</td> </td>
</tr> </tr>
@@ -254,17 +254,17 @@ export function ObjectsTable() {
if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`); if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`);
}} }}
className={`cursor-pointer border-b text-sm ${ className={`cursor-pointer border-b text-sm ${
selected ? "bg-indigo-50" : "hover:bg-neutral-50" selected ? "bg-primary/10" : "hover:bg-muted"
}`} }`}
> >
<td className="px-3 py-2 text-neutral-500">{object.object_number}</td> <td className="px-3 py-2 text-muted-foreground">{object.object_number}</td>
<td className="px-3 py-2 font-medium">{object.object_name}</td> <td className="px-3 py-2 font-medium">{object.object_name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<VisibilityBadge visibility={object.visibility} /> <VisibilityBadge visibility={object.visibility} />
</td> </td>
<td className="px-3 py-2 text-neutral-600">{object.current_location ?? "—"}</td> <td className="px-3 py-2 text-muted-foreground">{object.current_location ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td> <td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
<td className="px-3 py-2 text-neutral-600">{formatUpdated(object.updated_at)}</td> <td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>
</tr> </tr>
); );
})} })}
@@ -281,14 +281,14 @@ export function ObjectsTable() {
{body} {body}
</table> </table>
</div> </div>
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-neutral-500"> <div className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-muted-foreground">
<label className="flex items-center gap-1"> <label className="flex items-center gap-1">
<span>{t("objects.pageSize")}</span> <span>{t("objects.pageSize")}</span>
<select <select
value={limit} value={limit}
onChange={(event) => setLimit(Number(event.target.value))} onChange={(event) => setLimit(Number(event.target.value))}
aria-label={t("objects.pageSize")} 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) => ( {PAGE_SIZES.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
+1 -1
View File
@@ -53,7 +53,7 @@ export function OptionsCombobox({
<ComboboxList> <ComboboxList>
{(option: Option) => ( {(option: Option) => (
<ComboboxItem key={option.id} value={option}> <ComboboxItem key={option.id} value={option}>
<ComboboxItemIndicator className="text-indigo-600"></ComboboxItemIndicator> <ComboboxItemIndicator className="text-primary"></ComboboxItemIndicator>
{labelText(option.labels, lang)} {labelText(option.labels, lang)}
</ComboboxItem> </ComboboxItem>
)} )}
+7 -7
View File
@@ -48,7 +48,7 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
return ( return (
<section className="border-t p-4"> <section className="border-t p-4">
<div className="mb-2 text-xs font-medium uppercase text-neutral-500"> <div className="mb-2 label-caption">
{t("publish.heading")} {t("publish.heading")}
</div> </div>
@@ -59,10 +59,10 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
aria-current={i === currentIndex ? "step" : undefined} aria-current={i === currentIndex ? "step" : undefined}
className={`flex-1 border px-2 py-1 text-center text-xs ${ className={`flex-1 border px-2 py-1 text-center text-xs ${
i === currentIndex i === currentIndex
? "bg-neutral-800 font-semibold text-white" ? "bg-primary font-semibold text-primary-foreground"
: i < currentIndex : i < currentIndex
? "bg-neutral-100 text-neutral-600" ? "bg-muted text-muted-foreground"
: "text-neutral-400" : "text-muted-foreground"
}`} }`}
> >
{t(`visibility.${step}`)} {t(`visibility.${step}`)}
@@ -117,7 +117,7 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
</div> </div>
{errorKind === "gate" && ( {errorKind === "gate" && (
<p role="alert" className="mt-2 text-sm text-red-600"> <p role="alert" className="mt-2 text-sm text-destructive">
{t("publish.gateError")}{" "} {t("publish.gateError")}{" "}
<Link to={`/objects/${object.id}/edit`} className="underline"> <Link to={`/objects/${object.id}/edit`} className="underline">
{t("publish.editLink")} {t("publish.editLink")}
@@ -125,12 +125,12 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
</p> </p>
)} )}
{errorKind === "illegal" && ( {errorKind === "illegal" && (
<p role="alert" className="mt-2 text-sm text-red-600"> <p role="alert" className="mt-2 text-sm text-destructive">
{t("publish.illegalError")} {t("publish.illegalError")}
</p> </p>
)} )}
{errorKind === "other" && ( {errorKind === "other" && (
<p role="alert" className="mt-2 text-sm text-red-600"> <p role="alert" className="mt-2 text-sm text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}
+6 -5
View File
@@ -26,16 +26,17 @@ export const Draft: Story = {
args: { visibility: 'draft' }, args: { visibility: 'draft' },
} }
// The single project-wide CssCheck. VisibilityBadge applies `bg-green-100` for // The single project-wide CssCheck. VisibilityBadge applies the `success`
// the `public` visibility (see STYLES in visibility-badge.tsx). A concrete // variant for the `public` visibility (`bg-success/10`, see VARIANT in
// resolved background colour proves the shared preview actually loaded the app's // visibility-badge.tsx). A concrete resolved background colour proves the shared
// Tailwind stylesheet — an unstyled badge would report a transparent background. // preview actually loaded the app's Tailwind stylesheet — an unstyled badge
// would report a transparent background.
export const CssCheck: Story = { export const CssCheck: Story = {
args: { visibility: 'public' }, args: { visibility: 'public' },
play: async ({ canvas }) => { play: async ({ canvas }) => {
const badge = canvas.getByText('Public') const badge = canvas.getByText('Public')
await expect(getComputedStyle(badge).backgroundColor).toBe( await expect(getComputedStyle(badge).backgroundColor).toBe(
'oklch(0.962 0.044 156.743)', 'oklab(0.627 -0.166662 0.0992956 / 0.1)',
) )
}, },
} }
+5 -7
View File
@@ -5,18 +5,16 @@ import { Badge } from "@/components/ui/badge";
type Visibility = components["schemas"]["Visibility"]; type Visibility = components["schemas"]["Visibility"];
const STYLES: Record<Visibility, string> = { const VARIANT: Record<Visibility, "secondary" | "warning" | "success"> = {
draft: "bg-neutral-100 text-neutral-600", draft: "secondary",
internal: "bg-amber-100 text-amber-800", internal: "warning",
public: "bg-green-100 text-green-800", public: "success",
}; };
export function VisibilityBadge({ visibility }: { visibility: Visibility }) { export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Badge variant="outline" className={STYLES[visibility]}> <Badge variant={VARIANT[visibility]}>{t(`visibility.${visibility}`)}</Badge>
{t(`visibility.${visibility}`)}
</Badge>
); );
} }
+1 -1
View File
@@ -31,7 +31,7 @@ export function Highlight({ text }: { text: string }) {
} }
nodes.push( nodes.push(
<mark key={key++} className="bg-yellow-200"> <mark key={key++} className="bg-highlight text-highlight-foreground">
{rest.slice(start + PRE.length, end)} {rest.slice(start + PRE.length, end)}
</mark>, </mark>,
); );
+5 -5
View File
@@ -71,7 +71,7 @@ export function SearchPanel() {
type="button" type="button"
aria-pressed={active} aria-pressed={active}
onClick={() => setVisibility(value)} onClick={() => setVisibility(value)}
className={`rounded px-2 py-0.5 ${active ? "bg-indigo-600 text-white" : "border"}`} className={`rounded-md px-2 py-0.5 ${active ? "bg-primary text-primary-foreground" : "border"}`}
> >
{value === "all" ? t("search.all") : t(`visibility.${value}`)} {value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button> </button>
@@ -81,7 +81,7 @@ export function SearchPanel() {
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{!hasQuery && <p className="p-4 text-sm text-neutral-400">{t("search.prompt")}</p>} {!hasQuery && <p className="p-4 text-sm text-muted-foreground">{t("search.prompt")}</p>}
{hasQuery && search.isLoading && ( {hasQuery && search.isLoading && (
<div className="space-y-2 p-3"> <div className="space-y-2 p-3">
@@ -92,7 +92,7 @@ export function SearchPanel() {
)} )}
{hasQuery && search.isError && ( {hasQuery && search.isError && (
<p className="p-4 text-sm text-red-600"> <p className="p-4 text-sm text-destructive">
{search.error instanceof HttpError && search.error.status === 503 {search.error instanceof HttpError && search.error.status === 503
? t("search.unavailable") ? t("search.unavailable")
: t("search.loadError")} : t("search.loadError")}
@@ -100,12 +100,12 @@ export function SearchPanel() {
)} )}
{hasQuery && !search.isLoading && !search.isError && hits.length === 0 && ( {hasQuery && !search.isLoading && !search.isError && hits.length === 0 && (
<p className="p-4 text-sm text-neutral-500">{t("search.empty")}</p> <p className="p-4 text-sm text-muted-foreground">{t("search.empty")}</p>
)} )}
{hits.length > 0 && ( {hits.length > 0 && (
<> <>
<p className="px-3 pt-2 text-xs text-neutral-500"> <p className="px-3 pt-2 text-xs text-muted-foreground">
{t("search.resultCount", { count: total })} {t("search.resultCount", { count: total })}
</p> </p>
<ul> <ul>
+3 -3
View File
@@ -12,16 +12,16 @@ export function SearchResultRow({ hit }: { hit: SearchHitView }) {
<NavLink <NavLink
to={`/search/${hit.id}`} to={`/search/${hit.id}`}
className={({ isActive }) => className={({ isActive }) =>
`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"}`
} }
> >
<div className="text-sm font-semibold">{hit.object_name}</div> <div className="text-sm font-semibold">{hit.object_name}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500"> <div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>{hit.object_number}</span> <span>{hit.object_number}</span>
<VisibilityBadge visibility={hit.visibility} /> <VisibilityBadge visibility={hit.visibility} />
</div> </div>
{hit.snippet && ( {hit.snippet && (
<p className="mt-1 line-clamp-2 text-xs text-neutral-600"> <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<Highlight text={hit.snippet} /> <Highlight text={hit.snippet} />
</p> </p>
)} )}
+1 -1
View File
@@ -4,7 +4,7 @@ export function SelectSearchPrompt() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400"> <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
{t("search.selectPrompt")} {t("search.selectPrompt")}
</div> </div>
); );
+1 -1
View File
@@ -11,7 +11,7 @@ export function LangSwitch() {
key={lng} key={lng}
onClick={() => setLocale(lng)} onClick={() => setLocale(lng)}
aria-pressed={base === lng} aria-pressed={base === lng}
className={base === lng ? "font-bold" : "text-neutral-400"} className={base === lng ? "font-bold" : "text-muted-foreground"}
> >
{lng.toUpperCase()} {lng.toUpperCase()}
</button> </button>
+5 -5
View File
@@ -41,10 +41,10 @@ function readStored(): boolean {
function navLinkClass(collapsed: boolean) { function navLinkClass(collapsed: boolean) {
return ({ isActive }: { isActive: boolean }) => return ({ isActive }: { isActive: boolean }) =>
cn( 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", "focus-visible:ring-3 focus-visible:ring-ring/50",
collapsed && "justify-center", collapsed && "justify-center",
isActive && "bg-neutral-200 font-medium", isActive && "bg-accent font-medium",
); );
} }
@@ -68,7 +68,7 @@ export function Sidebar() {
return ( return (
<aside <aside
className={cn( className={cn(
"flex shrink-0 flex-col border-r bg-neutral-50 p-3 transition-[width]", "flex shrink-0 flex-col border-r bg-muted p-3 transition-[width]",
collapsed ? "w-14" : "w-44", collapsed ? "w-14" : "w-44",
)} )}
> >
@@ -82,8 +82,8 @@ export function Sidebar() {
aria-label={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")} aria-label={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
title={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")} title={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
className={cn( className={cn(
"flex items-center justify-center rounded p-1 outline-none", "flex items-center justify-center rounded-md p-1 outline-none",
"hover:bg-neutral-200 focus-visible:ring-3 focus-visible:ring-ring/50", "hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-50", "disabled:pointer-events-none disabled:opacity-50",
)} )}
> >
+1 -1
View File
@@ -4,7 +4,7 @@ export function SelectVocabularyPrompt() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400"> <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
{t("vocab.selectPrompt")} {t("vocab.selectPrompt")}
</div> </div>
); );
+6 -6
View File
@@ -45,20 +45,20 @@ export function VocabularyList() {
</Button> </Button>
</div> </div>
{create.isError && ( {create.isError && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}
</form> </form>
<ul className="flex-1 overflow-auto"> <ul className="flex-1 overflow-auto">
{isLoading && ( {isLoading && (
<li className="p-3 text-sm text-neutral-400"></li> <li className="p-3 text-sm text-muted-foreground"></li>
)} )}
{isError && ( {isError && (
<li className="p-3 text-sm text-red-600">{t("vocab.loadError")}</li> <li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>
)} )}
{data?.length === 0 && ( {data?.length === 0 && (
<li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li> <li className="p-3 text-sm text-muted-foreground">{t("vocab.empty")}</li>
)} )}
{data?.map((v) => ( {data?.map((v) => (
<li key={v.id} className="flex items-center gap-1 border-b pr-2"> <li key={v.id} className="flex items-center gap-1 border-b pr-2">
@@ -85,7 +85,7 @@ export function VocabularyList() {
{t("form.cancel")} {t("form.cancel")}
</Button> </Button>
{renameVocabulary.isError && ( {renameVocabulary.isError && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}
@@ -95,7 +95,7 @@ export function VocabularyList() {
<NavLink <NavLink
to={`/vocabularies/${v.id}`} to={`/vocabularies/${v.id}`}
className={({ isActive }) => className={({ isActive }) =>
`block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-primary/10" : "hover:bg-muted"}`
} }
> >
{v.key} {v.key}
+6 -6
View File
@@ -49,18 +49,18 @@ export function VocabularyTerms() {
return ( return (
<div className="overflow-auto p-4"> <div className="overflow-auto p-4">
<h3 className="mb-2 text-sm font-medium uppercase text-neutral-500"> <h3 className="mb-2 label-caption">
{t("vocab.terms")} {t("vocab.terms")}
</h3> </h3>
<ul className="mb-4"> <ul className="mb-4">
{isLoading && ( {isLoading && (
<li className="text-sm text-neutral-400"></li> <li className="text-sm text-muted-foreground"></li>
)} )}
{isError && ( {isError && (
<li className="text-sm text-red-600">{t("vocab.loadError")}</li> <li className="text-sm text-destructive">{t("vocab.loadError")}</li>
)} )}
{!isLoading && !isError && terms?.length === 0 && ( {!isLoading && !isError && terms?.length === 0 && (
<li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li> <li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
)} )}
{terms?.map((term) => ( {terms?.map((term) => (
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} /> <TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
@@ -78,12 +78,12 @@ export function VocabularyTerms() {
/> />
</div> </div>
{error && ( {error && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.required")} {t("form.required")}
</p> </p>
)} )}
{addTerm.isError && ( {addTerm.isError && (
<p role="alert" className="text-xs text-red-600"> <p role="alert" className="text-xs text-destructive">
{t("form.rejected")} {t("form.rejected")}
</p> </p>
)} )}