Compare commits
6 Commits
1bfa44a0ed
...
48edb0391e
| Author | SHA1 | Date | |
|---|---|---|---|
| 48edb0391e | |||
| 93234aae29 | |||
| cde7be9f2a | |||
| 04ed0c50e2 | |||
| 67e486df46 | |||
| d408464e91 |
@@ -27,3 +27,4 @@ jobs:
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- run: pnpm check:size
|
||||
- run: pnpm check:colors
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Design-Token Adoption Across Feature Screens — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Route every feature screen through the OKLCH design tokens — one indigo brand accent (`--primary`), token-based status colors (success/warning/highlight), the radius token, and a shared caption utility — and add a guard that keeps raw color utilities out of `src` (outside `components/ui/`).
|
||||
|
||||
**Architecture:** Pure styling refactor. Phase 1 adds/changes tokens + `ui` Badge variants + the visibility badge / highlight / caption helpers. Phase 2 mechanically migrates ~120 raw utilities across 27 files to tokens + the radius token. Phase 3 adds the `check:colors` guard (which can only pass once the migration is complete) and runs the gate. No behavior, layout, routing, API, or data changes.
|
||||
|
||||
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), Base UI, Vitest+RTL+MSW (incl. Storybook browser project).
|
||||
|
||||
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv untouched (no strings); `check:size` budget 250 KB gz (no real change expected). Stories single-quote/no-semicolon; source double-quote/semicolon. **Do not change markup/layout/spacing** — only color/radius utilities + Badge variant selection.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-07-design-token-adoption-design.md`
|
||||
|
||||
**Migration surface (27 files with raw color utilities, outside `components/ui/`):** `app.tsx`, `auth/login-page.tsx`, `authorities/authorities-page.tsx`, `components/delete-confirm-dialog.tsx`, `fields/field-form.tsx`, `fields/field-list.tsx`, `objects/{delete-object-dialog,flexible-field-value,object-detail-drawer,object-detail,object-edit-form,object-form,objects-page,objects-table,options-combobox,publish-control,visibility-badge,visibility-badge.stories}.tsx`, `search/{highlight,search-panel,search-result-row,select-search-prompt}.tsx`, `shell/{lang-switch,sidebar}.tsx`, `vocab/{select-vocabulary-prompt,vocabulary-list,vocabulary-terms}.tsx`.
|
||||
|
||||
---
|
||||
|
||||
# Task 1: Token + component foundation
|
||||
**Files:** `web/src/index.css`, `web/src/components/ui/badge.tsx` (+ `badge.stories.tsx` if present), `web/src/objects/visibility-badge.tsx`, `web/src/objects/visibility-badge.stories.tsx`, `web/src/search/highlight.tsx`.
|
||||
|
||||
- [ ] **Step 1: Indigo primary + status tokens** in `web/src/index.css`. In `:root`:
|
||||
```css
|
||||
--primary: oklch(0.511 0.262 276.966); /* indigo-600 */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.511 0.262 276.966);
|
||||
--success: oklch(0.627 0.194 149.214); /* green-600 — readable as text */
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.666 0.179 58.318); /* amber-700-ish — readable as text */
|
||||
--warning-foreground: oklch(0.985 0 0);
|
||||
--highlight: oklch(0.905 0.182 98.111); /* ~yellow-300 search highlight */
|
||||
--highlight-foreground: oklch(0.205 0 0);
|
||||
```
|
||||
In `.dark` (keep coherent for #59): `--primary: oklch(0.673 0.182 276.935)` (indigo-400), `--primary-foreground: oklch(0.205 0 0)`, `--ring` to match; `--success`/`--warning` slightly lighter for dark; `--highlight` unchanged or darker-text. In `@theme inline` add the `--color-*` mappings: `--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);`. Add a shared caption utility in `@layer components`:
|
||||
```css
|
||||
@layer components {
|
||||
.label-caption { @apply text-xs font-medium uppercase tracking-wide text-muted-foreground; }
|
||||
}
|
||||
```
|
||||
(Implementer may fine-tune the oklch to match exact Tailwind shades; keep `*-foreground` contrast ≥ AA.)
|
||||
|
||||
- [ ] **Step 2: Badge variants.** In `web/src/components/ui/badge.tsx`, add to the `cva` variants (mirror the `destructive` shape):
|
||||
```ts
|
||||
success:
|
||||
"bg-success/10 text-success [a]:hover:bg-success/20",
|
||||
warning:
|
||||
"bg-warning/10 text-warning [a]:hover:bg-warning/20",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: VisibilityBadge → variants.** In `web/src/objects/visibility-badge.tsx`, replace the hardcoded `STYLES` (amber/green/neutral) with variant selection:
|
||||
```tsx
|
||||
const VARIANT: Record<Visibility, "secondary" | "warning" | "success"> = {
|
||||
draft: "secondary",
|
||||
internal: "warning",
|
||||
public: "success",
|
||||
};
|
||||
|
||||
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
|
||||
const { t } = useTranslation();
|
||||
return <Badge variant={VARIANT[visibility]}>{t(`visibility.${visibility}`)}</Badge>;
|
||||
}
|
||||
```
|
||||
(Drop the `variant="outline" className={STYLES[...]}` patching.)
|
||||
|
||||
- [ ] **Step 4: Highlight token.** In `web/src/search/highlight.tsx`, `bg-yellow-200` → `bg-highlight text-highlight-foreground`.
|
||||
|
||||
- [ ] **Step 5: Update stories.** Add `Success`/`Warning` stories to the Badge story file (if `badge.stories.tsx` exists; else create alongside). **Update the `CssCheck` story** in `visibility-badge.stories.tsx`: it asserts the public badge background `oklch(0.962 0.044 156.743)` (old green-100). Public is now the `success` variant (`bg-success/10`). **Run the story, read the new `getComputedStyle(...).backgroundColor`, and pin that value** (keep the CssCheck — it proves Tailwind + tokens load). Update the comment.
|
||||
|
||||
- [ ] **Step 6:** `cd web && pnpm test -- visibility-badge badge && pnpm typecheck && pnpm lint`. The visibility badge renders with token colors; CssCheck passes with the new value. **Commit** `feat(web): indigo brand token + status tokens + Badge success/warning variants (#49)`.
|
||||
|
||||
---
|
||||
|
||||
# Task 2: Migrate feature screens to tokens + radius
|
||||
**Files:** the 27 migration-surface files listed above (excluding `visibility-badge.tsx`/`.stories.tsx` + `highlight.tsx` done in Task 1).
|
||||
|
||||
Apply the migration map mechanically. **Use the guard regex (Task 3) as your completeness checker**: after migrating, `grep -rE "(text|bg|border|ring)-(neutral|gray|slate|red|amber|green|yellow|indigo|…)-[0-9]+" src --include="*.tsx" | grep -v "components/ui/"` must return **nothing**.
|
||||
|
||||
| From | To |
|
||||
|---|---|
|
||||
| `text-red-600` | `text-destructive` |
|
||||
| `text-neutral-400` / `-500` / `-600` | `text-muted-foreground` |
|
||||
| `text-neutral-700` / `-900` | `text-foreground` |
|
||||
| `bg-neutral-50` / `-100` | `bg-muted` |
|
||||
| `bg-neutral-200` (active nav, sidebar) | `bg-accent` |
|
||||
| `bg-indigo-50` (selected row) | `bg-primary/10` |
|
||||
| `bg-indigo-600` / `text-indigo-600` | `bg-primary` / `text-primary` (+ `text-primary-foreground` where on `bg-primary`) |
|
||||
| `bg-neutral-800` (publish stepper / authority tabs active) | `bg-primary text-primary-foreground` |
|
||||
| `border-red-300` (combobox/drawer error) | `border-destructive` (or keep neutral `border` if it's not an error state) |
|
||||
| `border-green-300` | `border-success` (or neutral) |
|
||||
| bare `rounded` (×23) | `rounded-md` |
|
||||
|
||||
- [ ] **Step 1: Migrate by area**, file-by-file, replacing per the map. Also collapse the uppercase-caption recipes (object-detail, object-form, publish-control, field-list, vocabulary-terms) to the shared `label-caption` class (`<div className="label-caption">…`). **Do not change any non-color/radius classes, markup, or layout.** For the few ambiguous one-offs, follow the map's intent (muted captions → `text-muted-foreground`; emphasized values → `text-foreground`; error text → `text-destructive`). Optionally adopt `ui/Card` for an obviously hand-rolled bordered panel (e.g. object-detail) — only if a clean swap; skip otherwise.
|
||||
- [ ] **Step 2: Completeness check** — run the grep above; iterate until **zero** raw color utilities remain outside `components/ui/`. Also confirm no bare `rounded` remains (→ `rounded-md`).
|
||||
- [ ] **Step 3: Verify no regressions** — `cd web && pnpm typecheck && pnpm lint && pnpm test` (all existing tests pass; the styling change shouldn't break behavioral tests — if a test asserts a specific old color/class, update it to the token equivalent). `pnpm build`.
|
||||
- [ ] **Step 4: Commit** `refactor(web): migrate feature screens to design tokens + radius token (#49)`.
|
||||
|
||||
---
|
||||
|
||||
# Task 3: Enforcement guard + final verification
|
||||
**Files:** `web/scripts/check-no-raw-colors.mjs` (new), `web/package.json` (a `check:colors` script), wire into the gate.
|
||||
|
||||
- [ ] **Step 1: Guard script** `web/scripts/check-no-raw-colors.mjs` (mirror `check-bundle-size.mjs` style): recursively scan `web/src/**/*.{ts,tsx}` **excluding `src/components/ui/`**; fail (exit 1, printing each `file:line`) on any match of:
|
||||
```
|
||||
/(?: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/
|
||||
```
|
||||
Skip comments if practical; the goal is to catch real className usages. (It must NOT flag token utilities like `text-foreground`/`bg-primary` or numerics like `gap-2`.)
|
||||
- [ ] **Step 2: Wire it in** — add `"check:colors": "node scripts/check-no-raw-colors.mjs"` to `web/package.json`; include it in the project's check/CI flow (e.g. the `.gitea/workflows` web job, or alongside `check:size`). Run it → it must **pass** now (Task 2 cleared the surface).
|
||||
- [ ] **Step 3: Final verification:**
|
||||
```
|
||||
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||
```
|
||||
All green. `pnpm test -- i18n` (parity unaffected). `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`. `git status --short` clean.
|
||||
- [ ] **Step 4: Manual smoke (recommended):** run the app — buttons/links/selected rows/active nav share the indigo accent; visibility badges (success/warning/neutral) + search highlight use the status tokens; nothing renders an unstyled/transparent element from a removed color.
|
||||
- [ ] **Step 5: Commit** `chore(web): add check:colors guard banning raw color utilities outside ui/ (#49)`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
**Spec coverage:** indigo `--primary`/`--ring` + status tokens + `@theme` + `.dark` (T1 S1); Badge success/warning + VisibilityBadge + highlight + label-caption (T1 S2–S4); ~120-utility migration + radius (T2); guard added last + gate (T3); CssCheck updated (T1 S5); dark-mode toggle out (#59), no behavior/layout change. ✓
|
||||
**Placeholder scan:** concrete token values, badge variants, VisibilityBadge code, guard regex, and the explicit migration map + 27-file list. The CssCheck new value is "run to read" (the original story did the same — a genuine measurement step, not a placeholder). The few "ambiguous one-off" mappings are governed by the map's stated intent.
|
||||
**Type/consistency:** `success`/`warning` Badge variants (T1) consumed by `VisibilityBadge` `VARIANT` map; `--color-success/warning/highlight` tokens (T1) back `bg-success`/`bg-warning`/`bg-highlight`; the guard regex (T3) matches exactly the palette utilities the migration (T2) removes.
|
||||
|
||||
## Notes
|
||||
- No new dependency; CSS token churn only → `check:size` ≈ unchanged.
|
||||
- The guard is the durable win — it makes the consistency self-enforcing (closes the loop that caused #49).
|
||||
- If a behavioral test asserts an old raw class/color, update it to the token equivalent (don't weaken it).
|
||||
@@ -0,0 +1,128 @@
|
||||
# Design-Token Adoption Across Feature Screens — Design
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||
**Issue:** #49.
|
||||
|
||||
## Context
|
||||
|
||||
`web/src/index.css` defines a full semantic OKLCH token set (the shadcn defaults:
|
||||
`--foreground`, `--muted-foreground`, `--primary`, `--destructive`, `--accent`, `--border`,
|
||||
`--radius`, light + `.dark`) and `ui/*` components use it. But the **feature screens bypass it**:
|
||||
~120 hardcoded Tailwind color utilities outside `components/ui/` — `text-red-600` ×27 (not
|
||||
`--destructive`, a *different* red), `text-neutral-400/500/600` ×47 (three shades for "muted
|
||||
text"), `bg-neutral-50/100`, plus **two competing accents** (`bg-indigo-50`/`text-indigo-600`/
|
||||
`bg-indigo-600` for selection/links/chips vs the near-black `--primary` for buttons and
|
||||
`bg-neutral-800` for the publish stepper). Status colors (visibility badge `amber`/`green`,
|
||||
search highlight `yellow`) aren't tokens; `rounded` (0.25rem, ×23) ignores the `--radius` token;
|
||||
`ui/Card` has zero usages; the uppercase caption label appears with 4 different recipes.
|
||||
(Recent table/detail/toast work *did* introduce token usage — `bg-primary` ×6, `bg-destructive`,
|
||||
`text-foreground` — so the codebase is now mixed; this finishes the job.)
|
||||
|
||||
### Decisions (from brainstorming)
|
||||
1. **One brand accent: indigo `--primary`.** Set `--primary`/`--ring` to an indigo so primary
|
||||
buttons, selected rows, links, and chips share one recognizable accent (the existing indigo
|
||||
usages map straight onto `bg-primary`/`text-primary`).
|
||||
2. **Add status tokens** (`--success`/`--warning`/`--highlight`) and route the visibility badge +
|
||||
search highlight through them (via Badge variants).
|
||||
3. **Enforce** with a CI/lint guard banning raw color utilities outside `components/ui/`.
|
||||
4. **Dark-mode toggle stays #59.** This migration makes the `.dark` token set *work* (semantic
|
||||
tokens adapt), unblocking #59; the toggle/persistence is not in scope.
|
||||
5. **One milestone** (the guard can only pass once the migration is complete, so it lands last).
|
||||
|
||||
## Token changes (`web/src/index.css`)
|
||||
- **Indigo primary** in `:root`: `--primary: oklch(0.511 0.262 276.966)` (≈ indigo-600),
|
||||
`--primary-foreground: oklch(0.985 0 0)`, `--ring: oklch(0.511 0.262 276.966)`. In `.dark`: a
|
||||
lighter indigo that reads on dark (≈ indigo-400, `oklch(0.673 0.182 276.966)`) +
|
||||
`--primary-foreground: oklch(0.205 0 0)`, matching `--ring`. (Implementer may fine-tune to a
|
||||
Tailwind indigo shade; keep `--primary-foreground` contrast AA.)
|
||||
- **Status tokens** in `:root` and `.dark`, exposed in `@theme inline` as `--color-success`,
|
||||
`--color-success-foreground`, `--color-warning`, `--color-warning-foreground`,
|
||||
`--color-highlight`, `--color-highlight-foreground`. Light values ≈ success
|
||||
`oklch(0.6 0.13 160)` / warning `oklch(0.72 0.15 75)` / highlight `oklch(0.905 0.16 100)` with
|
||||
readable foregrounds; dark variants adjusted for contrast. `--destructive` unchanged.
|
||||
- `--accent`/`--muted` stay the neutral shadcn grays (hover/subtle surfaces). Selected/active
|
||||
states use `bg-primary/10` (a light indigo tint) rather than repurposing `--accent`.
|
||||
|
||||
## Component updates
|
||||
- **`ui/badge.tsx`:** add `success` and `warning` variants (token-based, with dark variants);
|
||||
**`VisibilityBadge`** (`web/src/objects/visibility-badge.tsx`) selects a variant — `public` →
|
||||
`success`, `internal` → `warning`, `draft` → the default/secondary — instead of patching
|
||||
`bg-amber-100`/`bg-green-100`.
|
||||
- **`search/highlight.tsx`:** `bg-yellow-200` → `bg-highlight` (token).
|
||||
- **Shared caption:** add a single `.label-caption` utility (in `index.css` `@layer
|
||||
components`, or a tiny `ui` helper) = `text-xs font-medium uppercase tracking-wide
|
||||
text-muted-foreground`; replace the 4 ad-hoc recipes (object-detail, object-form,
|
||||
publish-control, field-list, vocabulary-terms).
|
||||
- **`ui/Card`:** adopt for clearly hand-rolled bordered panels where it's a clean swap (e.g. the
|
||||
object-detail container). Not forced onto every bordered div — low priority within scope.
|
||||
|
||||
## Migration map (mechanical, ~120 sites, outside `components/ui/`)
|
||||
| From | To |
|
||||
|---|---|
|
||||
| `text-red-600` | `text-destructive` |
|
||||
| `text-neutral-400` / `-500` / `-600` | `text-muted-foreground` |
|
||||
| `text-neutral-700` / `-900` | `text-foreground` |
|
||||
| `bg-neutral-50` / `-100` | `bg-muted` |
|
||||
| `bg-neutral-200` (active nav) | `bg-accent` |
|
||||
| `bg-indigo-50` (selected row) | `bg-primary/10` |
|
||||
| `bg-indigo-600` / `text-indigo-600` | `bg-primary` / `text-primary` |
|
||||
| `bg-neutral-800` (publish stepper / tabs) | `bg-primary` (with `text-primary-foreground`) |
|
||||
| bare `rounded` (×23) | `rounded-md` |
|
||||
| `bg-amber-*`/`bg-green-*`/`bg-yellow-*` (badge/highlight) | via Badge variants / `--highlight` |
|
||||
Apply judgment on a few one-offs (e.g. `border-red-300`/`border-green-300` on the combobox/drawer
|
||||
→ `border-destructive`/`border-success` or keep neutral `border`). The goal: **zero raw color
|
||||
utilities outside `components/ui/`** after the migration.
|
||||
|
||||
## Enforcement (`web/scripts/check-no-raw-colors.mjs`)
|
||||
A grep-style Node script (mirroring `check-bundle-size.mjs`) that scans `src/**/*.{ts,tsx}`,
|
||||
**excluding `src/components/ui/`**, and fails (exit 1) if it finds a raw palette utility matching
|
||||
`(text|bg|border|ring|fill|stroke|from|to|via)-(neutral|gray|slate|zinc|stone|red|orange|amber|
|
||||
yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}`.
|
||||
Print each offending file:line. Add a `check:colors` script and wire it into the test gate (e.g.
|
||||
the `check` flow / CI). A short inline allowlist (path or `eslint-disable`-style comment) only if
|
||||
a genuine exception arises. **Added last**, after the migration, so the gate is green.
|
||||
|
||||
## Data flow / architecture
|
||||
Pure styling refactor — no behavior, routing, API, or data changes. Tokens in `index.css` →
|
||||
Tailwind utility classes (`bg-primary`, `text-muted-foreground`, …) in feature components →
|
||||
resolved at build. `ui/*` components already consume tokens and gain the new Badge variants.
|
||||
|
||||
## Error handling / edges
|
||||
- The migration must not change layout/spacing — only color/radius utilities (and Badge variant
|
||||
selection). Don't restructure markup.
|
||||
- `bg-primary/10` opacity modifier on a token works in Tailwind v4 — verify it resolves.
|
||||
- A few utilities are genuinely semantic-ambiguous (e.g. `text-neutral-900` for an emphasized
|
||||
value vs body) — map to `text-foreground`; muted captions → `text-muted-foreground`.
|
||||
- The enforcement regex must not flag token utilities (`text-foreground`, `bg-primary`) or
|
||||
non-color numerics (`gap-2`, `w-44`) — scope it to the palette names above.
|
||||
|
||||
## Testing
|
||||
- **Existing `CssCheck` story** (`visibility-badge.stories.tsx`) asserts the public badge's
|
||||
resolved `bg-green-100` oklch — it **must be updated** to the new `--success` token's resolved
|
||||
value (run the story to read the actual computed color, as the original did). Add Badge
|
||||
`success`/`warning` stories.
|
||||
- `pnpm typecheck` / `lint` / `test` / `build` green; the **new `check:colors` passes** (proves
|
||||
the migration is complete — zero raw utilities outside `ui/`); `check:size` ≈ unchanged (CSS
|
||||
token churn only); no codename; en/sv untouched (no strings).
|
||||
- Manual smoke: the app still renders (now indigo-accented); selected rows/links/buttons share
|
||||
the indigo; visibility badges + search highlight use the status tokens; nothing relies on a
|
||||
removed color. (No automated visual-regression in the repo — the guard + updated stories +
|
||||
smoke are the safety net.)
|
||||
|
||||
## Acceptance criteria
|
||||
1. `--primary`/`--ring` are indigo; primary buttons, selected rows, links, chips use it (one
|
||||
accent). Status tokens (`--success`/`--warning`/`--highlight`) exist (+ `@theme` + `.dark`).
|
||||
2. `VisibilityBadge` and the search highlight use token-based Badge variants / `--highlight`
|
||||
(no hardcoded amber/green/yellow); one shared caption utility.
|
||||
3. **No raw color utilities outside `components/ui/`**; bare `rounded` standardized to the radius
|
||||
token; `check:colors` guard added to the gate and passing.
|
||||
4. No behavior/layout change; `CssCheck` story updated; `typecheck`/`lint`/`test`/`build`/
|
||||
`check:size` green; no codename.
|
||||
5. Dark-mode tokens are coherent (light + `.dark`) so #59 (toggle) is unblocked — but the toggle
|
||||
is not added here.
|
||||
|
||||
## Out of scope → follow-ups
|
||||
- The **dark-mode toggle** + persistence (#59) — this only makes the tokens dark-ready.
|
||||
- Per-screen **layout/spacing** redesign, density changes, typography scale (#57).
|
||||
- Forcing `ui/Card` onto every panel (adopt only the clean swaps).
|
||||
@@ -14,6 +14,7 @@
|
||||
"lint": "eslint .",
|
||||
"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:colors": "node scripts/check-no-raw-colors.mjs",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
|
||||
@@ -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
@@ -26,7 +26,7 @@ const FieldsPage = lazy(() =>
|
||||
);
|
||||
|
||||
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() {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
{errorKey && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<ul className="mb-4">
|
||||
{isLoading && (
|
||||
<li className="text-sm text-neutral-400">…</li>
|
||||
<li className="text-sm text-muted-foreground">…</li>
|
||||
)}
|
||||
{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 && (
|
||||
<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) => (
|
||||
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
|
||||
@@ -84,13 +84,13 @@ export function AuthoritiesPage() {
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.required")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{create.isError && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.rejected")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function DeleteConfirmDialog({
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" className="text-red-600">
|
||||
<Button variant="ghost" size="sm" className="text-destructive">
|
||||
{triggerLabel ?? t("actions.delete")}
|
||||
</Button>
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function DeleteConfirmDialog({
|
||||
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
{message && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => (
|
||||
<option key={type} value={type}>
|
||||
@@ -140,7 +140,7 @@ export function FieldForm({
|
||||
value={vocabularyId}
|
||||
disabled={isEdit}
|
||||
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>
|
||||
{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"
|
||||
>
|
||||
<option value="">{t("fields.anyKind")}</option>
|
||||
{KINDS.map((kind) => (
|
||||
@@ -183,12 +183,12 @@ export function FieldForm({
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.required")}
|
||||
</p>
|
||||
)}
|
||||
{failed && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.rejected")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
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[]>();
|
||||
|
||||
@@ -53,7 +53,7 @@ export function FieldList({
|
||||
<ul className="overflow-auto">
|
||||
{entries.map(([group, defs]) => (
|
||||
<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}
|
||||
</div>
|
||||
<ul>
|
||||
@@ -61,7 +61,7 @@ export function FieldList({
|
||||
<li
|
||||
key={def.key}
|
||||
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
|
||||
@@ -71,13 +71,13 @@ export function FieldList({
|
||||
onClick={() => onSelect(def)}
|
||||
>
|
||||
<span className="font-medium">{labelText(def.labels, lang)}</span>
|
||||
<span className="text-xs text-neutral-400">{def.key}</span>
|
||||
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
|
||||
<span className="text-xs text-muted-foreground">{def.key}</span>
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{t(`fields.types.${def.data_type}`)}
|
||||
</span>
|
||||
{def.required && (
|
||||
<span
|
||||
className="text-xs text-red-600"
|
||||
className="text-xs text-destructive"
|
||||
title={t("fields.required")}
|
||||
aria-label={t("fields.required")}
|
||||
>
|
||||
|
||||
+28
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DeleteObjectDialog({ id }: { id: string }) {
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" className="text-red-600">
|
||||
<Button variant="ghost" size="sm" className="text-destructive">
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function DeleteObjectDialog({ id }: { id: string }) {
|
||||
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("actions.confirmDelete")}</AlertDialogDescription>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t("form.rejected")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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 <span className="text-neutral-400">…</span>;
|
||||
if (isLoading) return <span className="text-muted-foreground">…</span>;
|
||||
return (
|
||||
<span className="text-neutral-400">
|
||||
<span className="text-muted-foreground">
|
||||
{value} {t("objects.unknownRef")}
|
||||
</span>
|
||||
);
|
||||
@@ -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 <span className="text-neutral-400">…</span>;
|
||||
if (isLoading) return <span className="text-muted-foreground">…</span>;
|
||||
return (
|
||||
<span className="text-neutral-400">
|
||||
<span className="text-muted-foreground">
|
||||
{value} {t("objects.unknownRef")}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ObjectDetailDrawer({
|
||||
<div className="flex justify-end border-b p-2">
|
||||
<DrawerClose
|
||||
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" />
|
||||
</DrawerClose>
|
||||
|
||||
@@ -19,8 +19,8 @@ function Field({ label, value }: { label: string; value: ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="border-b py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div>
|
||||
<div className="text-sm text-neutral-900">{empty ? "—" : value}</div>
|
||||
<div className="label-caption">{label}</div>
|
||||
<div className="text-sm text-foreground">{empty ? "—" : value}</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.
|
||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||
@@ -105,7 +105,7 @@ export function ObjectDetail() {
|
||||
/>
|
||||
{groups.map((g) => (
|
||||
<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) => (
|
||||
<Field
|
||||
key={d.key}
|
||||
@@ -119,7 +119,7 @@ export function ObjectDetail() {
|
||||
key={key}
|
||||
label={key}
|
||||
value={
|
||||
<span className="text-neutral-400">
|
||||
<span className="text-muted-foreground">
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ObjectEditForm() {
|
||||
|
||||
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 = {
|
||||
object_number: object.object_number,
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ObjectForm({
|
||||
/>
|
||||
|
||||
{errors.core?.[key] && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.required")}
|
||||
</p>
|
||||
)}
|
||||
@@ -127,7 +127,7 @@ export function ObjectForm({
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4 overflow-auto p-4">
|
||||
{formError && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
@@ -147,7 +147,7 @@ export function ObjectForm({
|
||||
|
||||
<select
|
||||
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")}
|
||||
>
|
||||
<option value="draft">{t("form.draft")}</option>
|
||||
@@ -158,7 +158,7 @@ export function ObjectForm({
|
||||
|
||||
{definitions && definitions.length > 0 && (
|
||||
<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")}
|
||||
</legend>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ObjectForm({
|
||||
<FieldInput definition={def} form={form} />
|
||||
|
||||
{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")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ObjectsPage() {
|
||||
type="button"
|
||||
onClick={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" />
|
||||
</button>
|
||||
|
||||
@@ -138,10 +138,10 @@ export function ObjectsTable() {
|
||||
<button
|
||||
type="button"
|
||||
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])}
|
||||
<Icon className="size-3.5 text-neutral-400" aria-hidden="true" />
|
||||
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
@@ -170,7 +170,7 @@ export function ObjectsTable() {
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
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}`)}
|
||||
</button>
|
||||
@@ -184,7 +184,7 @@ export function ObjectsTable() {
|
||||
);
|
||||
|
||||
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>
|
||||
{headerCell("object_number")}
|
||||
{headerCell("object_name")}
|
||||
@@ -220,7 +220,7 @@ export function ObjectsTable() {
|
||||
body = (
|
||||
<tbody>
|
||||
<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")}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -230,7 +230,7 @@ export function ObjectsTable() {
|
||||
body = (
|
||||
<tbody>
|
||||
<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")}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -254,17 +254,17 @@ export function ObjectsTable() {
|
||||
if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`);
|
||||
}}
|
||||
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">
|
||||
<VisibilityBadge visibility={object.visibility} />
|
||||
</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-neutral-600">{formatUpdated(object.updated_at)}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -281,14 +281,14 @@ export function ObjectsTable() {
|
||||
{body}
|
||||
</table>
|
||||
</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">
|
||||
<span>{t("objects.pageSize")}</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(event) => 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) => (
|
||||
<option key={size} value={size}>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function OptionsCombobox({
|
||||
<ComboboxList>
|
||||
{(option: Option) => (
|
||||
<ComboboxItem key={option.id} value={option}>
|
||||
<ComboboxItemIndicator className="text-indigo-600">✓</ComboboxItemIndicator>
|
||||
<ComboboxItemIndicator className="text-primary">✓</ComboboxItemIndicator>
|
||||
{labelText(option.labels, lang)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||
|
||||
return (
|
||||
<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")}
|
||||
</div>
|
||||
|
||||
@@ -59,10 +59,10 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||
aria-current={i === currentIndex ? "step" : undefined}
|
||||
className={`flex-1 border px-2 py-1 text-center text-xs ${
|
||||
i === currentIndex
|
||||
? "bg-neutral-800 font-semibold text-white"
|
||||
? "bg-primary font-semibold text-primary-foreground"
|
||||
: i < currentIndex
|
||||
? "bg-neutral-100 text-neutral-600"
|
||||
: "text-neutral-400"
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(`visibility.${step}`)}
|
||||
@@ -117,7 +117,7 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||
</div>
|
||||
|
||||
{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")}{" "}
|
||||
<Link to={`/objects/${object.id}/edit`} className="underline">
|
||||
{t("publish.editLink")}
|
||||
@@ -125,12 +125,12 @@ export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||
</p>
|
||||
)}
|
||||
{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")}
|
||||
</p>
|
||||
)}
|
||||
{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")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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)',
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,18 +5,16 @@ import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Visibility = components["schemas"]["Visibility"];
|
||||
|
||||
const STYLES: Record<Visibility, string> = {
|
||||
draft: "bg-neutral-100 text-neutral-600",
|
||||
internal: "bg-amber-100 text-amber-800",
|
||||
public: "bg-green-100 text-green-800",
|
||||
const VARIANT: Record<Visibility, "secondary" | "warning" | "success"> = {
|
||||
draft: "secondary",
|
||||
internal: "warning",
|
||||
public: "success",
|
||||
};
|
||||
|
||||
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={STYLES[visibility]}>
|
||||
{t(`visibility.${visibility}`)}
|
||||
</Badge>
|
||||
<Badge variant={VARIANT[visibility]}>{t(`visibility.${visibility}`)}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Highlight({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<mark key={key++} className="bg-yellow-200">
|
||||
<mark key={key++} className="bg-highlight text-highlight-foreground">
|
||||
{rest.slice(start + PRE.length, end)}
|
||||
</mark>,
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ export function SearchPanel() {
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
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}`)}
|
||||
</button>
|
||||
@@ -81,7 +81,7 @@ export function SearchPanel() {
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<div className="space-y-2 p-3">
|
||||
@@ -92,7 +92,7 @@ export function SearchPanel() {
|
||||
)}
|
||||
|
||||
{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
|
||||
? t("search.unavailable")
|
||||
: t("search.loadError")}
|
||||
@@ -100,12 +100,12 @@ export function SearchPanel() {
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<>
|
||||
<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 })}
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
@@ -12,16 +12,16 @@ export function SearchResultRow({ hit }: { hit: SearchHitView }) {
|
||||
<NavLink
|
||||
to={`/search/${hit.id}`}
|
||||
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="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>
|
||||
<VisibilityBadge visibility={hit.visibility} />
|
||||
</div>
|
||||
{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} />
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ export function SelectSearchPrompt() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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")}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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()}
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<aside
|
||||
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",
|
||||
)}
|
||||
>
|
||||
@@ -82,8 +82,8 @@ export function Sidebar() {
|
||||
aria-label={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
|
||||
title={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded p-1 outline-none",
|
||||
"hover:bg-neutral-200 focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"flex items-center justify-center rounded-md p-1 outline-none",
|
||||
"hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ export function SelectVocabularyPrompt() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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")}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,20 +45,20 @@ export function VocabularyList() {
|
||||
</Button>
|
||||
</div>
|
||||
{create.isError && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.rejected")}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<li className="p-3 text-sm text-neutral-400">…</li>
|
||||
<li className="p-3 text-sm text-muted-foreground">…</li>
|
||||
)}
|
||||
{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 && (
|
||||
<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) => (
|
||||
<li key={v.id} className="flex items-center gap-1 border-b pr-2">
|
||||
@@ -85,7 +85,7 @@ export function VocabularyList() {
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
{renameVocabulary.isError && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.rejected")}
|
||||
</p>
|
||||
)}
|
||||
@@ -95,7 +95,7 @@ export function VocabularyList() {
|
||||
<NavLink
|
||||
to={`/vocabularies/${v.id}`}
|
||||
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}
|
||||
|
||||
@@ -49,18 +49,18 @@ export function VocabularyTerms() {
|
||||
|
||||
return (
|
||||
<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")}
|
||||
</h3>
|
||||
<ul className="mb-4">
|
||||
{isLoading && (
|
||||
<li className="text-sm text-neutral-400">…</li>
|
||||
<li className="text-sm text-muted-foreground">…</li>
|
||||
)}
|
||||
{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 && (
|
||||
<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) => (
|
||||
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
|
||||
@@ -78,12 +78,12 @@ export function VocabularyTerms() {
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.required")}
|
||||
</p>
|
||||
)}
|
||||
{addTerm.isError && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.rejected")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user