From 2657e4950064821429b11b8ff2e5b1ac2f45cac0 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 19:37:40 -0700 Subject: [PATCH 01/55] chore(ui-core): a script installs a shadcn primitive and keeps its pristine snapshot --- frontend/ui-core/package.json | 3 ++- frontend/ui-core/shadcn/README.md | 7 +++++++ scripts/shadcn_add.sh | 11 +++++++++++ scripts/shadcn_relativize.mjs | 16 ++++++++++++++++ tests/scripts/shadcn_relativize.test.mjs | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 frontend/ui-core/shadcn/README.md create mode 100644 scripts/shadcn_add.sh create mode 100644 scripts/shadcn_relativize.mjs create mode 100644 tests/scripts/shadcn_relativize.test.mjs diff --git a/frontend/ui-core/package.json b/frontend/ui-core/package.json index 63d0350f..11cfcc35 100644 --- a/frontend/ui-core/package.json +++ b/frontend/ui-core/package.json @@ -21,7 +21,8 @@ "build": "tsc -p tsconfig.build.json", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit", - "lint": "eslint src && pnpm run typecheck" + "lint": "eslint src && pnpm run typecheck", + "shadcn:add": "bash ../../scripts/shadcn_add.sh" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/ui-core/shadcn/README.md b/frontend/ui-core/shadcn/README.md new file mode 100644 index 00000000..4eccbdbd --- /dev/null +++ b/frontend/ui-core/shadcn/README.md @@ -0,0 +1,7 @@ +# shadcn canonical snapshots + +Each file here is exactly what `shadcn@4.19.0 add ` wrote for this +package's `components.json` (radix-nova, preset b2iH), before the import +relativisation `scripts/shadcn_relativize.mjs` applies. `tests/scripts/shadcn_canonical.test.mjs` +holds every `src/primitives/.tsx` to its snapshot plus added lines only. +Regenerate with `pnpm --filter @visionset/ui-core shadcn:add `; never edit by hand. diff --git a/scripts/shadcn_add.sh b/scripts/shadcn_add.sh new file mode 100644 index 00000000..29b18535 --- /dev/null +++ b/scripts/shadcn_add.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Runs from frontend/ui-core (pnpm --filter sets the cwd). Writes the CLI's +# pristine output to shadcn/.tsx, then relativises src/primitives/.tsx. +set -euo pipefail +[ "$#" -gt 0 ] || { echo "usage: pnpm shadcn:add ..." >&2; exit 2; } +npx --yes shadcn@4.19.0 add "$@" --overwrite --yes +mkdir -p shadcn +for name in "$@"; do + cp "src/primitives/$name.tsx" "shadcn/$name.tsx" + node ../../scripts/shadcn_relativize.mjs "src/primitives/$name.tsx" +done diff --git a/scripts/shadcn_relativize.mjs b/scripts/shadcn_relativize.mjs new file mode 100644 index 00000000..caa0f245 --- /dev/null +++ b/scripts/shadcn_relativize.mjs @@ -0,0 +1,16 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const RULES = [ + [/from "@\/lib\/cn"/g, 'from "../lib/cn"'], + [/from "@\/primitives\/([a-z-]+)"/g, 'from "./$1"'], +]; + +export function relativize(source) { + return RULES.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), source); +} + +if (process.argv[1] === new URL(import.meta.url).pathname || process.argv[1].endsWith("shadcn_relativize.mjs")) { + for (const file of process.argv.slice(2)) { + writeFileSync(file, relativize(readFileSync(file, "utf8"))); + } +} diff --git a/tests/scripts/shadcn_relativize.test.mjs b/tests/scripts/shadcn_relativize.test.mjs new file mode 100644 index 00000000..5a711074 --- /dev/null +++ b/tests/scripts/shadcn_relativize.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { relativize } from "../../scripts/shadcn_relativize.mjs"; + +test("the cn import and a sibling primitive import become relative; nothing else moves", () => { + const source = [ + 'import { cn } from "@/lib/cn"', + 'import { Button } from "@/primitives/button"', + 'import { Slot } from "radix-ui"', + ].join("\n"); + assert.equal( + relativize(source), + ['import { cn } from "../lib/cn"', 'import { Button } from "./button"', 'import { Slot } from "radix-ui"'].join("\n"), + ); +}); From 32cb0dae4551ede6cd7a052301d5d9435a429ecb Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 19:44:18 -0700 Subject: [PATCH 02/55] test(ui-core): every primitive is its shadcn snapshot plus added lines only --- tests/scripts/shadcn_canonical.test.mjs | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/scripts/shadcn_canonical.test.mjs diff --git a/tests/scripts/shadcn_canonical.test.mjs b/tests/scripts/shadcn_canonical.test.mjs new file mode 100644 index 00000000..92f68280 --- /dev/null +++ b/tests/scripts/shadcn_canonical.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { relativize } from "../../scripts/shadcn_relativize.mjs"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const PRIMITIVES = path.join(REPO, "frontend/ui-core/src/primitives"); +const SNAPSHOTS = path.join(REPO, "frontend/ui-core/shadcn"); + +const lines = (text) => text.split(/\r?\n/).map((l) => l.trimEnd()); + +// Every snapshot line must appear in the primitive, in order. Added lines are +// the only permitted difference — that is the whole of the "do not modify +// shadcn's code" rule, in a form a machine can check. +export function additiveOnly(snapshot, actual) { + const want = lines(snapshot).filter((l) => l !== ""); + const have = lines(actual); + let cursor = 0; + for (const line of want) { + const at = have.indexOf(line, cursor); + if (at === -1) return { ok: false, missing: line }; + cursor = at + 1; + } + return { ok: true }; +} + +test("additiveOnly accepts an added line and refuses a changed one", () => { + assert.equal(additiveOnly("a\nb\n", "a\nx\nb\n").ok, true); + assert.equal(additiveOnly("a\nb\n", "a\nB\n").ok, false); + assert.equal(additiveOnly("a\nb\n", "b\na\n").ok, false); +}); + +const primitives = readdirSync(PRIMITIVES).filter((f) => f.endsWith(".tsx") && !f.endsWith(".test.tsx")); + +for (const file of primitives) { + test(`${file} is shadcn's canonical file plus added lines only`, () => { + const snapshot = path.join(SNAPSHOTS, file); + assert.ok(existsSync(snapshot), `${file} has no snapshot in frontend/ui-core/shadcn/ — install it with pnpm --filter @visionset/ui-core shadcn:add`); + const result = additiveOnly(relativize(readFileSync(snapshot, "utf8")), readFileSync(path.join(PRIMITIVES, file), "utf8")); + assert.ok(result.ok, `${file} diverges from its snapshot at: ${result.missing}`); + }); +} From 719aa7b0b590f4340375f449027da9df73b33de0 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 19:52:03 -0700 Subject: [PATCH 03/55] test(ui-core): the canonical gate counts a deleted blank line as a deletion --- tests/scripts/shadcn_canonical.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/scripts/shadcn_canonical.test.mjs b/tests/scripts/shadcn_canonical.test.mjs index 92f68280..20dc6635 100644 --- a/tests/scripts/shadcn_canonical.test.mjs +++ b/tests/scripts/shadcn_canonical.test.mjs @@ -15,7 +15,7 @@ const lines = (text) => text.split(/\r?\n/).map((l) => l.trimEnd()); // the only permitted difference — that is the whole of the "do not modify // shadcn's code" rule, in a form a machine can check. export function additiveOnly(snapshot, actual) { - const want = lines(snapshot).filter((l) => l !== ""); + const want = lines(snapshot); const have = lines(actual); let cursor = 0; for (const line of want) { @@ -30,6 +30,8 @@ test("additiveOnly accepts an added line and refuses a changed one", () => { assert.equal(additiveOnly("a\nb\n", "a\nx\nb\n").ok, true); assert.equal(additiveOnly("a\nb\n", "a\nB\n").ok, false); assert.equal(additiveOnly("a\nb\n", "b\na\n").ok, false); + assert.equal(additiveOnly("a\n\nb\n", "a\nb\n").ok, false); + assert.equal(additiveOnly("a\r\nb \r\n", "a\nb\n").ok, true); }); const primitives = readdirSync(PRIMITIVES).filter((f) => f.endsWith(".tsx") && !f.endsWith(".test.tsx")); From f7dcfa6479e776592dc1f73f9c057ead80a6ddf1 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 20:25:26 -0700 Subject: [PATCH 04/55] refactor(ui-core): badge and alert are shadcn's canonical radix-nova files, and every chip is a Badge --- frontend/app/src/styleguide/Styleguide.tsx | 8 +- frontend/ui-core/package.json | 1 + frontend/ui-core/shadcn/alert.tsx | 76 ++ frontend/ui-core/shadcn/badge.tsx | 49 + .../ui-core/src/annotator/AddClassDialog.tsx | 40 +- .../ui-core/src/annotator/AnnotationPage.tsx | 36 +- .../ui-core/src/annotator/AnnotatorPanel.tsx | 49 +- frontend/ui-core/src/index.ts | 9 +- frontend/ui-core/src/patterns/AsyncStates.tsx | 13 +- .../ui-core/src/patterns/ProjectEyebrow.tsx | 2 +- frontend/ui-core/src/primitives/Badge.tsx | 88 -- frontend/ui-core/src/primitives/alert.tsx | 76 ++ frontend/ui-core/src/primitives/badge.tsx | 53 ++ .../src/primitives/primitives.test.tsx | 29 +- .../ui-core/src/screens/BatchesScreen.tsx | 6 +- .../src/screens/DatasetAssetDialog.tsx | 2 +- .../ui-core/src/screens/DatasetScreen.tsx | 38 +- .../ui-core/src/screens/GalleryScreen.tsx | 4 +- frontend/ui-core/src/screens/HomeScreen.tsx | 2 +- frontend/ui-core/src/screens/IngestScreen.tsx | 39 +- frontend/ui-core/src/screens/ModelsScreen.tsx | 4 +- .../ui-core/src/screens/PreLabelDialog.tsx | 13 +- .../src/screens/ProjectPreLabelDialog.tsx | 6 +- .../ui-core/src/screens/ProjectScreen.tsx | 6 +- frontend/ui-core/src/screens/SchemaEditor.tsx | 34 +- .../ui-core/src/screens/SchemaForeshadow.tsx | 4 +- .../ui-core/src/screens/batchState.test.ts | 4 +- frontend/ui-core/src/screens/batchState.ts | 11 +- pnpm-lock.yaml | 860 +++++++++++++++++- 29 files changed, 1274 insertions(+), 288 deletions(-) create mode 100644 frontend/ui-core/shadcn/alert.tsx create mode 100644 frontend/ui-core/shadcn/badge.tsx delete mode 100644 frontend/ui-core/src/primitives/Badge.tsx create mode 100644 frontend/ui-core/src/primitives/alert.tsx create mode 100644 frontend/ui-core/src/primitives/badge.tsx diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx index 81ad4045..3e5a0550 100644 --- a/frontend/app/src/styleguide/Styleguide.tsx +++ b/frontend/app/src/styleguide/Styleguide.tsx @@ -337,15 +337,15 @@ export function Styleguide(): JSX.Element {
- draft - in_annotation + draft + in_annotation completed stale outline failed
- quiet — a fact, not a state + quiet — a fact, not a state

Ingest — 240 of 412

@@ -377,7 +377,7 @@ export function Styleguide(): JSX.Element { {batch.name} - + {batch.state} diff --git a/frontend/ui-core/package.json b/frontend/ui-core/package.json index 11cfcc35..a64849af 100644 --- a/frontend/ui-core/package.json +++ b/frontend/ui-core/package.json @@ -60,6 +60,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.32.0", "openapi-fetch": "^0.17.0", + "radix-ui": "^1.6.7", "shadcn": "^4.19.0", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", diff --git a/frontend/ui-core/shadcn/alert.tsx b/frontend/ui-core/shadcn/alert.tsx new file mode 100644 index 00000000..8a84d989 --- /dev/null +++ b/frontend/ui-core/shadcn/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/cn" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/frontend/ui-core/shadcn/badge.tsx b/frontend/ui-core/shadcn/badge.tsx new file mode 100644 index 00000000..fe855569 --- /dev/null +++ b/frontend/ui-core/shadcn/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/cn" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "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", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/ui-core/src/annotator/AddClassDialog.tsx b/frontend/ui-core/src/annotator/AddClassDialog.tsx index 934be458..fa0e68dd 100644 --- a/frontend/ui-core/src/annotator/AddClassDialog.tsx +++ b/frontend/ui-core/src/annotator/AddClassDialog.tsx @@ -91,7 +91,8 @@ import { useEffect, useRef, useState, type JSX } from "react"; import { asApiError } from "../data/errors"; import { refusalProse } from "../data/refusals"; import { classColor } from "../palette"; -import { Alert } from "../primitives/Badge"; +import { Alert, AlertDescription, AlertTitle } from "../primitives/alert"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { Dialog, @@ -552,7 +553,9 @@ export function AddClassDialog({ without seeing this would publish classes they never typed. */} {resumedNames.length > 0 && ( - + + Classes are already pending here + {namesInProse(resumedNames)} {resumedNames.length === 1 ? "was" : "were"} banked in an earlier sitting and never published — this draft is shared, so that may not have been you. Keep working to fold {resumedNames.length === 1 ? "it" : "them"} into this @@ -592,6 +595,7 @@ export function AddClassDialog({ {pending ? "Discarding…" : `Discard the pending ${resumedNames.length === 1 ? "class" : "classes"}`}
+ )} @@ -603,10 +607,11 @@ export function AddClassDialog({ {session.length > 0 && (
    {session.map((entry) => ( -
  • + ))}
)} @@ -688,7 +693,9 @@ export function AddClassDialog({ that before they act rather than from an error afterwards. */} {!canRepin && ( - + + This batch will stay on its current version + {/* The subject is the whole session, not the form field: by the time somebody presses, the field is often empty and the classes are banked — a notice saying “this class” would then name nothing at @@ -697,6 +704,7 @@ export function AddClassDialog({ {namesInProse(publishing.map((entry) => entry.name))} will not be available to draw with here. The version is still published to the project, and a correction batch approved from now on will pin to it. + )} @@ -710,27 +718,35 @@ export function AddClassDialog({ twice in this one sitting, where there is nothing to offer. */} {existing !== undefined && widening.length > 0 && ( - + + {`“${existing.name}” already exists`} + Version {active?.version} declares it as{" "} {formatGeometries(existing.geometries)}. Publishing adds{" "} {formatGeometries(widening)} to it, and leaves its colour and attributes alone. + )} {taken && ( - + + That name is taken + {/* Which of the two rules refused it, because the remedies differ — and each names what would clear it, which is what lets the primary below stay disabled without being a bare grey box. */} {inSession ? `You have already added a class called “${name}” to this version. Rename one of them, or take the banked one back out.` : `Version ${active?.version} already declares “${name}” as ${formatGeometries(existing?.geometries ?? [])}, and this form adds nothing to it. Tick a shape it does not have, or choose another name.`} + )} {failure !== null && ( - + + Could not add this class + {refusalProse(error)} {/* The one refusal whose remedy is somewhere else. `repin` has no flag for it on purpose: the pin did not move because somebody @@ -743,6 +759,7 @@ export function AddClassDialog({ project’s Schema tab to see what changed. )} + )} @@ -753,10 +770,13 @@ export function AddClassDialog({ resort — the question is about *this* form, so it belongs in it. */} {discarding && ( - + + Discard the classes you added? + {session.length} class{session.length === 1 ? "" : "es"} {session.length === 1 ? "is" : "are"}{" "} written and not published. Closing now discards {session.length === 1 ? "it" : "them"} from the shared draft — anyone else with this project open loses them too. + )}
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx index 1bb34d4f..36fdfbbf 100644 --- a/frontend/ui-core/src/annotator/AnnotationPage.tsx +++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx @@ -156,7 +156,7 @@ import type { OpenMember } from "../generated/api.js"; import { asApiError } from "../data/errors"; import { refusalProse } from "../data/refusals"; import { EmptyState, ErrorState, LoadingState } from "../patterns/AsyncStates"; -import { Badge } from "../primitives/Badge"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { DropdownMenu, @@ -2986,21 +2986,23 @@ function PinBadge({ return (
- + + + {open && (
+ unsaved ); diff --git a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx index f562f194..aa240ea6 100644 --- a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx +++ b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx @@ -87,6 +87,7 @@ import { useEffect, useRef, useState, type JSX, type RefObject } from "react"; import { geometryLabel } from "../data/geometryCategory"; import { classColor } from "../palette"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { Input } from "../primitives/Input"; import { @@ -406,35 +407,39 @@ function TagRegion({ {tagClasses.map((declared) => { const on = tagged.has(declared.name); return ( - + + ); })}
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index 695430c9..2e657bdc 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -55,13 +55,8 @@ export { } from "./primitives/Card.js"; export { FieldError, FieldHint, Input, Label, Textarea } from "./primitives/Input.js"; export { Combobox, type ComboboxFooter, type ComboboxProps } from "./primitives/Combobox.js"; -export { - Alert, - Badge, - badgeVariants, - type AlertProps, - type BadgeProps, -} from "./primitives/Badge.js"; +export { Badge, badgeVariants } from "./primitives/badge.js"; +export { Alert, AlertAction, AlertDescription, AlertTitle } from "./primitives/alert.js"; export { Dialog, DialogClose, diff --git a/frontend/ui-core/src/patterns/AsyncStates.tsx b/frontend/ui-core/src/patterns/AsyncStates.tsx index 6c85bfac..89533316 100644 --- a/frontend/ui-core/src/patterns/AsyncStates.tsx +++ b/frontend/ui-core/src/patterns/AsyncStates.tsx @@ -21,7 +21,7 @@ import { Inbox, TriangleAlert } from "lucide-react"; import type { JSX, ReactNode } from "react"; import { cn } from "../lib/cn"; -import { Alert } from "../primitives/Badge"; +import { Alert, AlertDescription, AlertTitle } from "../primitives/alert"; import { Button } from "../primitives/Button"; import { Skeleton } from "../primitives/Feedback"; @@ -122,16 +122,14 @@ export function ErrorState({ ]; return ( - + - } - className={className} - > + + {meta.length > 0 && (

{meta.join(" · ")} @@ -142,6 +140,7 @@ export function ErrorState({ {retryLabel} )} + ); } diff --git a/frontend/ui-core/src/patterns/ProjectEyebrow.tsx b/frontend/ui-core/src/patterns/ProjectEyebrow.tsx index 856949ec..4b581ae6 100644 --- a/frontend/ui-core/src/patterns/ProjectEyebrow.tsx +++ b/frontend/ui-core/src/patterns/ProjectEyebrow.tsx @@ -8,7 +8,7 @@ import type { JSX } from "react"; -import { Badge } from "../primitives/Badge"; +import { Badge } from "../primitives/badge"; export interface ProjectEyebrowProps { /** The project's name, or the empty string while it is in flight. */ diff --git a/frontend/ui-core/src/primitives/Badge.tsx b/frontend/ui-core/src/primitives/Badge.tsx deleted file mode 100644 index e86ccd29..00000000 --- a/frontend/ui-core/src/primitives/Badge.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Badges and the alert. - * - * The badge's variants are the ones the domain actually produces — a batch state, - * an asset's progress, a refusal — so a screen picks an intent and never a colour. - * - * Every variant is its colour as ink over that colour at 10% behind its own - * border. On a near-monochrome page that is enough separation to read as a - * state without any of them becoming a saturated block: `accent` is the near-black - * action, so it is the neutral chip, and `success` / `warning` / `destructive` are - * the three desaturated statuses. - * - * `quiet` is the one variant that is not a state: a square, colourless label for - * a fact read beside other facts — what a model does, what it writes — where a - * pill would read as a status and a colour would compete with the one the card - * carries. Square corners are the whole of its difference, and the reason it is - * a variant here rather than a class on a screen. - * - * `Alert` carries `role="alert"` on the destructive variant only. An informational - * panel announced as an alert interrupts a screen reader for something nobody - * needs to hear; an error must interrupt. - */ - -import { cva, type VariantProps } from "class-variance-authority"; -import type { HTMLAttributes, JSX, ReactNode } from "react"; - -import { cn } from "../lib/cn"; - -export const badgeVariants = cva( - "inline-flex h-5 w-fit shrink-0 items-center gap-1 rounded-4xl border px-2 py-0.5 text-xs font-medium " + - "whitespace-nowrap focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 " + - "[&>svg]:size-3!", - { - variants: { - variant: { - neutral: "border-border bg-muted text-muted-foreground", - accent: "border-primary bg-primary/10 text-primary", - success: "border-success bg-success/10 text-success", - warning: "border-warning bg-warning/10 text-warning", - destructive: "border-destructive bg-destructive/10 text-destructive", - outline: "border-border bg-card text-foreground", - quiet: "rounded-md border-transparent bg-muted text-foreground", - }, - }, - defaultVariants: { variant: "neutral" }, - }, -); - -export interface BadgeProps - extends HTMLAttributes, - VariantProps {} - -export function Badge({ className, variant, ...props }: BadgeProps): JSX.Element { - return ; -} - -// `title` is omitted from the DOM attributes and re-declared: the native one is a -// tooltip string, and an alert's heading is a node. Widening it in place is a type -// error, and shipping both under one name would be a trap. -export interface AlertProps extends Omit, "title"> { - readonly variant?: "info" | "destructive"; - readonly title?: ReactNode; -} - -export function Alert({ - className, - variant = "info", - title, - children, - ...props -}: AlertProps): JSX.Element { - return ( -

- {title !== undefined &&

{title}

} - {children !== undefined &&
{children}
} -
- ); -} diff --git a/frontend/ui-core/src/primitives/alert.tsx b/frontend/ui-core/src/primitives/alert.tsx new file mode 100644 index 00000000..8c88a3e1 --- /dev/null +++ b/frontend/ui-core/src/primitives/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../lib/cn" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/frontend/ui-core/src/primitives/badge.tsx b/frontend/ui-core/src/primitives/badge.tsx new file mode 100644 index 00000000..1ecdf6b4 --- /dev/null +++ b/frontend/ui-core/src/primitives/badge.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "../lib/cn" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "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 focus-visible:ring-success/20 dark:bg-success/20 dark:focus-visible:ring-success/40 [a]:hover:bg-success/20", + warning: + "bg-warning/10 text-warning focus-visible:ring-warning/20 dark:bg-warning/20 dark:focus-visible:ring-warning/40 [a]:hover:bg-warning/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx index 7af86a32..fa8893f6 100644 --- a/frontend/ui-core/src/primitives/primitives.test.tsx +++ b/frontend/ui-core/src/primitives/primitives.test.tsx @@ -17,7 +17,8 @@ import userEvent from "@testing-library/user-event"; import type { JSX } from "react"; import { describe, expect, it } from "vitest"; -import { Alert, Badge } from "./Badge"; +import { Alert, AlertDescription, AlertTitle } from "./alert"; +import { Badge } from "./badge"; import { Button } from "./Button"; import { Card, CardTitle } from "./Card"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./Dialog"; @@ -95,26 +96,22 @@ describe("Button", () => { }); describe("Alert and Badge", () => { - it("announces a destructive alert and stays quiet for an informational one", () => { - const { rerender } = render(); - expect(screen.getByRole("alert")).toHaveProperty("textContent", "PROJECT_NOT_FOUND"); - - rerender(); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - it("renders a node title, which the native attribute could not hold", () => { + it("announces an alert, and composes its title and description", () => { render( - !}> - the message + + Refused + because , ); - expect(screen.getByTestId("icon")).not.toBeNull(); + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain("Refused"); + expect(alert.textContent).toContain("because"); }); - it("gives a badge the accent only through the accent variant", () => { - render(annotated); - expect(screen.getByText("annotated").className).toContain("border-primary"); + it("marks a badge with its variant, so a style can be keyed on data rather than colour", () => { + render(done); + expect(screen.getByText("done").getAttribute("data-variant")).toBe("success"); + expect(screen.getByText("done").getAttribute("data-slot")).toBe("badge"); }); }); diff --git a/frontend/ui-core/src/screens/BatchesScreen.tsx b/frontend/ui-core/src/screens/BatchesScreen.tsx index 8e1a67af..31269b64 100644 --- a/frontend/ui-core/src/screens/BatchesScreen.tsx +++ b/frontend/ui-core/src/screens/BatchesScreen.tsx @@ -36,7 +36,7 @@ import { useState, type JSX } from "react"; import { Async } from "../data/Async"; import { BATCH_ACTION, declares } from "../data/capabilities"; import { refusalProse } from "../data/refusals"; -import { Badge } from "../primitives/Badge"; +import { Badge } from "../primitives/badge"; import { SectionHeader } from "../patterns/SectionHeader"; import { Button } from "../primitives/Button"; import { FieldError } from "../primitives/Input"; @@ -160,11 +160,11 @@ export function BatchesScreen({
{/* The label the gallery header already uses, rather than the raw kernel identifier. */} - + {batchStateLabel(batch.state)} {batch.pre_label_run !== null && isLiveJobState(batch.pre_label_run.state) && ( - + pre-labeling… )} diff --git a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx index 25b37ab4..fa887602 100644 --- a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx +++ b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx @@ -40,7 +40,7 @@ import type { WireAnnotation } from "../annotator/jobQueries"; import { refusalProse } from "../data/refusals"; import { formatWhen } from "../lib/format"; import { classColor } from "../palette"; -import { Badge } from "../primitives/Badge"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { DescriptionList, DescriptionRow } from "../patterns/DataDisplay"; import { diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index e1dda620..90f1acb7 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -48,7 +48,8 @@ import { useEffect, useState, type FormEvent, type JSX } from "react"; import { Async } from "../data/Async"; import { asApiError } from "../data/errors"; -import { Alert, Badge } from "../primitives/Badge"; +import { Alert, AlertDescription, AlertTitle } from "../primitives/alert"; +import { Badge } from "../primitives/badge"; import type { BadgeTone } from "./batchState"; import { SectionHeader } from "../patterns/SectionHeader"; import { Button } from "../primitives/Button"; @@ -191,11 +192,11 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp Assets - {stats.data !== undefined && {stats.data.asset_count}} + {stats.data !== undefined && {stats.data.asset_count}} Releases - {releases.data !== undefined && {releases.data.total}} + {releases.data !== undefined && {releases.data.total}}
@@ -589,9 +590,9 @@ function ReleaseCard({ release }: { readonly release: Release }): JSX.Element {
{source.video !== null && source.video !== undefined && ( @@ -1042,8 +1043,9 @@ function RunCard({
{job.error !== null && job.error !== undefined && ( - - {job.error} + + The run stopped + {job.error} )} @@ -1078,12 +1080,9 @@ function RunCard({ reason: same screen, two different things that went wrong. */} {resume.isError && ( - - {refusalProse(resume.error)} + + That resume was refused + {refusalProse(resume.error)} )}
@@ -1220,15 +1219,14 @@ function Partials({ if (partials.length === 0) return null; return ( - + - } - data-testid="partials" - > + +
    {partials.map((failure, index) => (
  • @@ -1244,6 +1242,7 @@ function Partials({
  • ))}
+
); } @@ -1285,7 +1284,7 @@ function Failures({ )} {unsupported.length > 0 && ( - {unsupported.length} unsupported + {unsupported.length} unsupported )}

@@ -1303,7 +1302,7 @@ function Failures({ {basename(failure.name)} - + {failureKindLabel(failure.kind)} diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx index 677cf80e..2e779627 100644 --- a/frontend/ui-core/src/screens/ModelsScreen.tsx +++ b/frontend/ui-core/src/screens/ModelsScreen.tsx @@ -161,7 +161,7 @@ import { import { jobFailureProse, refusalProse } from "../data/refusals"; import { cn } from "../lib/cn"; import { ErrorState, LoadingState } from "../patterns/AsyncStates"; -import { Badge } from "../primitives/Badge"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { Card, @@ -614,7 +614,7 @@ export function ConnectionCard({
    {abilities.map((label) => (
  • - + {label}
  • diff --git a/frontend/ui-core/src/screens/PreLabelDialog.tsx b/frontend/ui-core/src/screens/PreLabelDialog.tsx index 093f9c4c..b9f98574 100644 --- a/frontend/ui-core/src/screens/PreLabelDialog.tsx +++ b/frontend/ui-core/src/screens/PreLabelDialog.tsx @@ -92,7 +92,8 @@ import { BATCH_ACTION, declares } from "../data/capabilities"; import { producesProse } from "../data/geometryCategory"; import { useConnections, type Connection } from "../data/inferenceQueries"; import { refusalProse } from "../data/refusals"; -import { Alert, Badge } from "../primitives/Badge"; +import { Alert, AlertDescription } from "../primitives/alert"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { Dialog, @@ -146,11 +147,11 @@ const JOB_STATE_LABEL: Record = { }; const JOB_STATE_VARIANT: Record = { - queued: "neutral", - running: "accent", + queued: "secondary", + running: "default", succeeded: "success", failed: "destructive", - cancelled: "neutral", + cancelled: "secondary", }; /** The five faces of this dialog, over the watched run and nothing else. */ @@ -741,7 +742,7 @@ function PreLabelDialog({ {view !== null && (

    {JOB_STATE_LABEL[view.state] ?? view.state} @@ -840,7 +841,7 @@ function PreLabelDialog({ {blocked && mode !== "running" && ( - {blockedReason(view, preLabeled)} + {blockedReason(view, preLabeled)} )} diff --git a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx index bb390b1f..20184638 100644 --- a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx +++ b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx @@ -20,7 +20,7 @@ import { useMemo, useState, type JSX } from "react"; import { BATCH_ACTION, declares } from "../data/capabilities"; import { useConnections, type Connection } from "../data/inferenceQueries"; import { refusalProse } from "../data/refusals"; -import { Alert } from "../primitives/Badge"; +import { Alert, AlertDescription } from "../primitives/alert"; import { Button } from "../primitives/Button"; import { Dialog, @@ -235,15 +235,17 @@ function ProjectPreLabelDialog({ {refused.length > 0 && ( + {refused.length === 1 ? `${refused[0]!.name} cannot be pre-labeled as planned — uncheck it, or change the model or the shapes, to start.` : `${refused.map((one) => one.name).join(", ")} cannot be pre-labeled as planned — uncheck them, or change the model or the shapes, to start.`} + )} {launch.isError && ( - {refusalProse(launch.error)} + {refusalProse(launch.error)} )} diff --git a/frontend/ui-core/src/screens/ProjectScreen.tsx b/frontend/ui-core/src/screens/ProjectScreen.tsx index 42ec78f7..b44e4f61 100644 --- a/frontend/ui-core/src/screens/ProjectScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectScreen.tsx @@ -73,7 +73,7 @@ import { Async } from "../data/Async"; import { useApiClient } from "../data/ApiProvider"; import { asApiError } from "../data/errors"; import { refusalProse } from "../data/refusals"; -import { Badge } from "../primitives/Badge"; +import { Badge } from "../primitives/badge"; import { Button } from "../primitives/Button"; import { formatCount, formatWhen } from "../lib/format"; import { ErrorState, LoadingState } from "../patterns/AsyncStates"; @@ -951,7 +951,7 @@ function VersionRow({ v{entry.version} - {entry.version === active && active} + {entry.version === active && active} {/* Both are null for a version published before the fields existed, and nothing backfills either — an em dash is the honest rendering of a moment nobody recorded. */} @@ -1024,7 +1024,7 @@ function AnnotationRun({

  • {/* The kernel's own words — they are accurate — sentence-cased for a badge. `detail` below stays verbatim; see the docstring. */} - + {change.kind === "destructive" ? "Destructive" : change.kind === "additive" ? "Additive" : change.kind} {change.detail} @@ -1173,8 +1176,11 @@ function VersionDiff({ function PastVersion({ declared }: { readonly declared: SchemaVersion }): JSX.Element { if (declared.classes.length === 0) { return ( - + + No classes + Version {declared.version} declares nothing. A project can publish an empty contract. + ); } diff --git a/frontend/ui-core/src/screens/SchemaForeshadow.tsx b/frontend/ui-core/src/screens/SchemaForeshadow.tsx index 48cf621f..c4f8b06a 100644 --- a/frontend/ui-core/src/screens/SchemaForeshadow.tsx +++ b/frontend/ui-core/src/screens/SchemaForeshadow.tsx @@ -21,7 +21,7 @@ import type { JSX } from "react"; -import { Alert } from "../primitives/Badge"; +import { Alert, AlertDescription } from "../primitives/alert"; import { Button } from "../primitives/Button"; import { useProjectReadiness } from "./queries"; @@ -37,6 +37,7 @@ export function SchemaForeshadow({ if (readiness === null || readiness.hasSchema) return null; return ( + You can ingest now — you’ll need labels before annotating. {onOpenSchema !== undefined && ( + +
  • +
  • ))}
From 76d423012cf714248fba2a859ca2e3d866f36519 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 21:07:29 -0700 Subject: [PATCH 06/55] refactor(ui-core): the button is shadcn's canonical file, and the colour gate allows a mix of tokens --- frontend/app/src/shell/AppShell.tsx | 4 +- frontend/app/src/shell/NotFound.tsx | 2 +- frontend/app/src/styleguide/Styleguide.tsx | 16 +-- frontend/ui-core/shadcn/button.tsx | 67 ++++++++++ .../ui-core/src/annotator/AddClassDialog.tsx | 12 +- .../ui-core/src/annotator/AnnotationPage.tsx | 14 +-- .../ui-core/src/annotator/AnnotatorPanel.tsx | 2 +- .../ui-core/src/annotator/CanvasReassign.tsx | 4 +- .../ui-core/src/annotator/ClassRegion.tsx | 2 +- .../ui-core/src/annotator/SuggestPanel.tsx | 6 +- .../ui-core/src/annotator/ToolPalette.tsx | 4 +- frontend/ui-core/src/annotator/ZoomWidget.tsx | 2 +- .../ui-core/src/annotator/topBar.test.tsx | 2 +- frontend/ui-core/src/data/TokenGate.tsx | 4 +- frontend/ui-core/src/index.ts | 2 +- frontend/ui-core/src/patterns/AsyncStates.tsx | 4 +- frontend/ui-core/src/patterns/BackLink.tsx | 2 +- frontend/ui-core/src/patterns/ClassFields.tsx | 2 +- .../ui-core/src/patterns/ErrorBoundary.tsx | 6 +- frontend/ui-core/src/patterns/ProjectNav.tsx | 8 +- .../ui-core/src/patterns/projectNav.test.tsx | 6 +- frontend/ui-core/src/primitives/Button.tsx | 119 ------------------ frontend/ui-core/src/primitives/button.tsx | 69 ++++++++++ .../src/primitives/primitives.test.tsx | 33 ++--- .../ui-core/src/screens/BatchLifecycle.tsx | 10 +- .../ui-core/src/screens/BatchesScreen.tsx | 8 +- .../ui-core/src/screens/CorrectionBatch.tsx | 8 +- .../src/screens/DatasetAssetDialog.tsx | 10 +- .../ui-core/src/screens/DatasetScreen.tsx | 22 ++-- frontend/ui-core/src/screens/DeleteBatch.tsx | 4 +- .../ui-core/src/screens/GalleryScreen.tsx | 22 ++-- frontend/ui-core/src/screens/HomeScreen.tsx | 10 +- frontend/ui-core/src/screens/IngestScreen.tsx | 17 +-- frontend/ui-core/src/screens/ModelsScreen.tsx | 14 +-- .../ui-core/src/screens/OverviewPanel.tsx | 6 +- .../ui-core/src/screens/PreLabelDialog.tsx | 26 ++-- frontend/ui-core/src/screens/ProjectFrame.tsx | 8 +- .../src/screens/ProjectPreLabelDialog.tsx | 10 +- .../ui-core/src/screens/ProjectScreen.tsx | 4 +- .../ui-core/src/screens/ProjectsScreen.tsx | 12 +- .../ui-core/src/screens/PromoteButton.tsx | 4 +- frontend/ui-core/src/screens/SchemaEditor.tsx | 10 +- .../ui-core/src/screens/SchemaForeshadow.tsx | 2 +- tests/scripts/design_tokens.test.mjs | 17 ++- 44 files changed, 312 insertions(+), 304 deletions(-) create mode 100644 frontend/ui-core/shadcn/button.tsx delete mode 100644 frontend/ui-core/src/primitives/Button.tsx create mode 100644 frontend/ui-core/src/primitives/button.tsx diff --git a/frontend/app/src/shell/AppShell.tsx b/frontend/app/src/shell/AppShell.tsx index 17712eb5..02902617 100644 --- a/frontend/app/src/shell/AppShell.tsx +++ b/frontend/app/src/shell/AppShell.tsx @@ -273,7 +273,7 @@ function RailLink({ // one — so the geometry comes from `buttonVariants` directly. className={({ isActive }) => cn( - buttonVariants({ variant: "ghost", size: "md" }), + buttonVariants({ variant: "ghost", size: "default" }), "w-full justify-start gap-2 px-2 text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent", collapsed && "justify-center", isActive && @@ -303,7 +303,7 @@ function RailButton({ return ( } diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx index 3e5a0550..c1d2b92d 100644 --- a/frontend/app/src/styleguide/Styleguide.tsx +++ b/frontend/app/src/styleguide/Styleguide.tsx @@ -203,11 +203,11 @@ export function Styleguide(): JSX.Element {
- - + @@ -457,7 +457,7 @@ export function Styleguide(): JSX.Element { title="Overview" meta="11 images · ingested Aug 7, 2026" actions={ - @@ -483,7 +483,7 @@ export function Styleguide(): JSX.Element { releases. Blobs are never deleted. - + @@ -491,7 +491,7 @@ export function Styleguide(): JSX.Element { - + Rename @@ -502,7 +502,7 @@ export function Styleguide(): JSX.Element { } + action={} /> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/ui-core/src/annotator/AddClassDialog.tsx b/frontend/ui-core/src/annotator/AddClassDialog.tsx index 02c540f2..00c95c1d 100644 --- a/frontend/ui-core/src/annotator/AddClassDialog.tsx +++ b/frontend/ui-core/src/annotator/AddClassDialog.tsx @@ -93,7 +93,7 @@ import { refusalProse } from "../data/refusals"; import { classColor } from "../palette"; import { Alert, AlertDescription, AlertTitle } from "../primitives/alert"; import { Badge } from "../primitives/badge"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Dialog, DialogContent, @@ -562,7 +562,7 @@ export function AddClassDialog({ version, or clear the slate.
{/* @@ -842,7 +842,7 @@ export function AddClassDialog({ publish time. */}
)} diff --git a/frontend/ui-core/src/patterns/BackLink.tsx b/frontend/ui-core/src/patterns/BackLink.tsx index 1eafdbeb..748c0dce 100644 --- a/frontend/ui-core/src/patterns/BackLink.tsx +++ b/frontend/ui-core/src/patterns/BackLink.tsx @@ -20,7 +20,7 @@ import { ArrowLeft } from "lucide-react"; import type { JSX } from "react"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; export interface BackLinkProps { /** The parent, named: "Batches", a project's name. */ diff --git a/frontend/ui-core/src/patterns/ClassFields.tsx b/frontend/ui-core/src/patterns/ClassFields.tsx index 9d857649..17ee3333 100644 --- a/frontend/ui-core/src/patterns/ClassFields.tsx +++ b/frontend/ui-core/src/patterns/ClassFields.tsx @@ -24,7 +24,7 @@ import type { JSX } from "react"; import { geometryLabel, groupGeometries } from "../data/geometryCategory"; import { classColor, hexColor } from "../palette"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { FieldHint, Input, Label } from "../primitives/Input"; import { Select, diff --git a/frontend/ui-core/src/patterns/ErrorBoundary.tsx b/frontend/ui-core/src/patterns/ErrorBoundary.tsx index 44971e31..ea340b1a 100644 --- a/frontend/ui-core/src/patterns/ErrorBoundary.tsx +++ b/frontend/ui-core/src/patterns/ErrorBoundary.tsx @@ -41,7 +41,7 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import { refusalProse } from "../data/refusals.js"; -import { Button } from "../primitives/Button.js"; +import { Button } from "../primitives/button.js"; import { EmptyState } from "./AsyncStates.js"; export interface ErrorBoundaryProps { @@ -94,11 +94,11 @@ export class ErrorBoundary extends Component + @@ -276,7 +276,7 @@ function AnnotateAction({ {/* Same testid and variant as the jumping form: one control with two shapes, and the chevron is what tells them apart. */} - ); - expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty("type", "button"); - }); - it("keeps an explicit type", () => { - render(); - expect(screen.getByRole("button", { name: "Save" })).toHaveProperty("type", "submit"); + render(); + expect(screen.getByRole("button").getAttribute("type")).toBe("submit"); }); it("lets a caller override a conflicting utility rather than emitting both", () => { @@ -64,7 +59,7 @@ describe("Button", () => { it("renders the child element with asChild, so a link stays a link", () => { render( - , ); @@ -75,23 +70,9 @@ describe("Button", () => { expect(link.className).toContain("bg-primary"); }); - it("underlines a link button at rest, not only under the pointer", () => { - render(); - const classes = screen.getByRole("button").className.split(" "); - expect(classes).toContain("underline"); - expect(classes).not.toContain("hover:underline"); - }); - - it("collapses a link button to inline geometry, whatever size says", () => { - render( - , - ); - const classes = screen.getByRole("button").className.split(" "); - expect(classes).toContain("h-auto"); - expect(classes).toContain("p-0"); - expect(classes).not.toContain("h-7"); + it("styles a link button as an underline-on-hover text link", () => { + render(); + expect(screen.getByRole("button").getAttribute("data-variant")).toBe("link"); }); }); diff --git a/frontend/ui-core/src/screens/BatchLifecycle.tsx b/frontend/ui-core/src/screens/BatchLifecycle.tsx index 75acbf04..4f3005e0 100644 --- a/frontend/ui-core/src/screens/BatchLifecycle.tsx +++ b/frontend/ui-core/src/screens/BatchLifecycle.tsx @@ -27,7 +27,7 @@ import { Play, SquareCheck } from "lucide-react"; import { asApiError } from "../data/errors"; import { refusalProse } from "../data/refusals"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Dialog, DialogContent, @@ -146,7 +146,7 @@ export function CompleteBatchButton({ return (
- @@ -284,7 +284,7 @@ function Lifecycle({ // view — and a table holding a draft beside a queued batch used to render // several filled buttons down the same column, under a page header whose // "Annotate" is the actual forward action. - @@ -294,7 +294,7 @@ function Lifecycle({ return (
-
diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index 90f1acb7..08f4c9f8 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -52,7 +52,7 @@ import { Alert, AlertDescription, AlertTitle } from "../primitives/alert"; import { Badge } from "../primitives/badge"; import type { BadgeTone } from "./batchState"; import { SectionHeader } from "../patterns/SectionHeader"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "../primitives/Card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/Tabs"; import { @@ -151,7 +151,7 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp // `secondary`: the project's navigation holds the page's filled // action. One filled action per view.
- )} @@ -725,7 +725,7 @@ function BatchHeader({ {startsAnnotation && batch !== undefined && } {!startsAnnotation && onStartAnnotating !== undefined && openable && ( @@ -315,7 +315,7 @@ function Resume({ {act !== undefined && ( - diff --git a/frontend/ui-core/src/screens/IngestScreen.tsx b/frontend/ui-core/src/screens/IngestScreen.tsx index f24075a1..e67c77a1 100644 --- a/frontend/ui-core/src/screens/IngestScreen.tsx +++ b/frontend/ui-core/src/screens/IngestScreen.tsx @@ -141,7 +141,7 @@ import { parentLabel } from "../patterns/parentLabel"; import { Alert, AlertDescription, AlertTitle } from "../primitives/alert"; import { Badge } from "../primitives/badge"; import type { BadgeTone } from "./batchState"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Card, CardContent } from "../primitives/Card"; import { Progress } from "../primitives/Feedback"; import { FieldError, FieldHint, Input, Label } from "../primitives/Input"; @@ -531,7 +531,7 @@ export function IngestScreen({
@@ -1171,11 +1172,11 @@ function Outcome({ {/* Back to step 2, source kept: the same frames into a different batch is a real second run — registration is idempotent and content addressing makes re-reading free. */} - - diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx index 2e779627..cbbbbd31 100644 --- a/frontend/ui-core/src/screens/ModelsScreen.tsx +++ b/frontend/ui-core/src/screens/ModelsScreen.tsx @@ -162,7 +162,7 @@ import { jobFailureProse, refusalProse } from "../data/refusals"; import { cn } from "../lib/cn"; import { ErrorState, LoadingState } from "../patterns/AsyncStates"; import { Badge } from "../primitives/badge"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Card, CardAction, @@ -246,7 +246,7 @@ export function ModelsScreen(): JSX.Element { by every project in this workspace.

- @@ -262,7 +262,7 @@ export function ModelsScreen(): JSX.Element { // `secondary`, not `primary`: the header's "Add model" is on screen // and opens the same dialog. One filled action per view. action: ( - ), @@ -697,7 +697,7 @@ export function ConnectionCard({ control may exist is still `allowed_actions` and nothing else. */} @@ -393,7 +393,7 @@ function FirstRun({ action={
{onOpenSchema !== undefined && ( - diff --git a/frontend/ui-core/src/screens/PreLabelDialog.tsx b/frontend/ui-core/src/screens/PreLabelDialog.tsx index b9f98574..983f1e13 100644 --- a/frontend/ui-core/src/screens/PreLabelDialog.tsx +++ b/frontend/ui-core/src/screens/PreLabelDialog.tsx @@ -94,7 +94,7 @@ import { useConnections, type Connection } from "../data/inferenceQueries"; import { refusalProse } from "../data/refusals"; import { Alert, AlertDescription } from "../primitives/alert"; import { Badge } from "../primitives/badge"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Dialog, DialogContent, @@ -397,7 +397,7 @@ export function PromptClasses({ plan }: { readonly plan: PreLabelPlan | null }): */ function DeadStart(): JSX.Element { return ( - ); @@ -550,7 +550,7 @@ export function PreLabelButton({ batch, className, onSegment }: PreLabelButtonPr return ( <> )} {mode !== "running" && ( <> - @@ -875,7 +875,7 @@ function PreLabelDialog({ // button already on screen rather than replace it with another. <> )} @@ -896,7 +896,7 @@ function PreLabelDialog({ // Quiet, deliberately: the next real step is correcting what // this run already produced, not launching another one over it. @@ -917,7 +917,7 @@ function PreLabelDialog({ <>
{blocked && preLabeled > 0 && ( - )} @@ -944,7 +944,7 @@ function PreLabelDialog({ (offering ? ( <> )} diff --git a/frontend/ui-core/src/screens/ProjectFrame.tsx b/frontend/ui-core/src/screens/ProjectFrame.tsx index 6d1eb94f..aa79f6cc 100644 --- a/frontend/ui-core/src/screens/ProjectFrame.tsx +++ b/frontend/ui-core/src/screens/ProjectFrame.tsx @@ -25,7 +25,7 @@ import { ErrorState } from "../patterns/AsyncStates"; import { ProjectEyebrow } from "../patterns/ProjectEyebrow"; import type { AnnotateTarget, ProjectSection } from "../patterns/ProjectNav"; import { ProjectShell, type ProjectNavData } from "../patterns/ProjectShell"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Dialog, DialogContent, @@ -240,7 +240,7 @@ function DeleteDialog({ )} - )} diff --git a/frontend/ui-core/src/screens/ProjectScreen.tsx b/frontend/ui-core/src/screens/ProjectScreen.tsx index b44e4f61..6e351adb 100644 --- a/frontend/ui-core/src/screens/ProjectScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectScreen.tsx @@ -74,7 +74,7 @@ import { useApiClient } from "../data/ApiProvider"; import { asApiError } from "../data/errors"; import { refusalProse } from "../data/refusals"; import { Badge } from "../primitives/badge"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { formatCount, formatWhen } from "../lib/format"; import { ErrorState, LoadingState } from "../patterns/AsyncStates"; import { DEFAULT_PROJECT_SECTION, PROJECT_SECTIONS, type ProjectSection } from "../patterns/ProjectNav"; @@ -540,7 +540,7 @@ function Section({ }): JSX.Element | null { const headerIngest: ReactNode = ingestInHeader === undefined ? undefined : ( - diff --git a/frontend/ui-core/src/screens/ProjectsScreen.tsx b/frontend/ui-core/src/screens/ProjectsScreen.tsx index 96cfae82..fc11243a 100644 --- a/frontend/ui-core/src/screens/ProjectsScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectsScreen.tsx @@ -30,7 +30,7 @@ import { useState, type FormEvent, type JSX } from "react"; import { Async } from "../data/Async"; import { refusalProse } from "../data/refusals"; import { formatWhen } from "../lib/format"; -import { Button } from "../primitives/Button"; +import { Button } from "../primitives/button"; import { Dialog, DialogContent, @@ -85,7 +85,7 @@ export function ProjectsScreen({ onOpenProject }: ProjectsScreenProps): JSX.Elem A project owns a schema, its batches and one dataset.

- @@ -103,7 +103,7 @@ export function ProjectsScreen({ onOpenProject }: ProjectsScreenProps): JSX.Elem // One filled action per view — and the header's is the one that // survives when the list fills up. action: ( - ), @@ -301,12 +301,12 @@ export function CreateProjectDialog({ {refusal(create.error)} )} - @@ -1411,7 +1411,7 @@ function DestructiveDialog({ while they are in it. -
+ + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index c5982b93..d80629eb 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -102,12 +102,13 @@ export { Progress, Skeleton, Toaster, toast } from "./primitives/Feedback.js"; export { Table, TableBody, + TableCaption, TableCell, - TableEmpty, + TableFooter, TableHead, TableHeader, TableRow, -} from "./primitives/Table.js"; +} from "./primitives/table.js"; // The three states every async surface owes. export { diff --git a/frontend/ui-core/src/primitives/Table.tsx b/frontend/ui-core/src/primitives/Table.tsx deleted file mode 100644 index 1e0815ad..00000000 --- a/frontend/ui-core/src/primitives/Table.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * The table — the batch list, the release timeline. - * - * Real `` semantics rather than a grid of `
`s, because a screen reader - * announces "row 3 of 12, column State" only for the former, and both of the - * screens this exists for are dense lists somebody scans. - * - * `TableEmpty` is here rather than in `patterns/` because an empty *table* is not - * an empty *screen*: the header stays, so the columns still explain what is - * missing. `EmptyState` is the whole-surface version. - */ - -import { forwardRef, type HTMLAttributes, type JSX, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from "react"; - -import { cn } from "../lib/cn"; - -export const Table = forwardRef>(function Table( - { className, ...props }, - ref, -) { - return ( -
-
- - ); -}); - -export const TableHeader = forwardRef>( - function TableHeader({ className, ...props }, ref) { - return ; - }, -); - -export const TableBody = forwardRef>( - function TableBody({ className, ...props }, ref) { - return ; - }, -); - -export const TableRow = forwardRef>( - function TableRow({ className, ...props }, ref) { - return ( - - ); - }, -); - -export const TableHead = forwardRef>( - function TableHead({ className, ...props }, ref) { - return ( - - - - ); -} diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx index e228a78d..87fea809 100644 --- a/frontend/ui-core/src/primitives/primitives.test.tsx +++ b/frontend/ui-core/src/primitives/primitives.test.tsx @@ -37,7 +37,7 @@ import { SelectTrigger, SelectValue, } from "./Select"; -import { Table, TableBody, TableEmpty } from "./Table"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "./table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs"; describe("Button", () => { @@ -193,13 +193,17 @@ describe("Card and Table", () => { it("keeps the table's header while the body is empty", () => { render(
- ); - }, -); - -export const TableCell = forwardRef>( - function TableCell({ className, ...props }, ref) { - return ; - }, -); - -/** A row spanning every column, so the header keeps explaining what is absent. */ -export function TableEmpty({ - colSpan, - children, -}: { - readonly colSpan: number; - readonly children: ReactNode; -}): JSX.Element { - return ( -
- {children} -
- - No batches yet - + + + Name + State + + +
, ); - expect(screen.getByRole("table")).not.toBeNull(); - expect(screen.getByText("No batches yet")).not.toBeNull(); + expect(screen.getByRole("columnheader", { name: "Name" })).not.toBeNull(); + expect(screen.getByRole("columnheader", { name: "State" })).not.toBeNull(); }); }); diff --git a/frontend/ui-core/src/primitives/table.tsx b/frontend/ui-core/src/primitives/table.tsx new file mode 100644 index 00000000..593987c9 --- /dev/null +++ b/frontend/ui-core/src/primitives/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "../lib/cn" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/ui-core/src/screens/BatchesScreen.tsx b/frontend/ui-core/src/screens/BatchesScreen.tsx index a3113fde..8c09f34a 100644 --- a/frontend/ui-core/src/screens/BatchesScreen.tsx +++ b/frontend/ui-core/src/screens/BatchesScreen.tsx @@ -40,7 +40,7 @@ import { Badge } from "../primitives/badge"; import { SectionHeader } from "../patterns/SectionHeader"; import { Button } from "../primitives/button"; import { FieldError } from "../primitives/Input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table"; import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle"; import { BATCH_STATE_VARIANT, batchStateLabel } from "./batchState"; import { SchemaForeshadow } from "./SchemaForeshadow"; diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index 4d8e9076..708d2dde 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -77,7 +77,7 @@ import { SelectTrigger, SelectValue, } from "../primitives/Select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table"; import { EmptyState, ErrorState } from "../patterns/AsyncStates"; import { AssetThumbnail } from "./AssetThumbnail"; import { DatasetAssetDialog, trunkAssetLabel } from "./DatasetAssetDialog"; diff --git a/frontend/ui-core/src/screens/IngestScreen.tsx b/frontend/ui-core/src/screens/IngestScreen.tsx index 1f75e943..2ea2d85e 100644 --- a/frontend/ui-core/src/screens/IngestScreen.tsx +++ b/frontend/ui-core/src/screens/IngestScreen.tsx @@ -152,7 +152,7 @@ import { SelectTrigger, SelectValue, } from "../primitives/Select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table"; import { SchemaForeshadow } from "./SchemaForeshadow"; import { ClipRangeTimeline } from "./ClipRangeTimeline"; import { probeClip, type ClipProbe } from "./clipProbe"; diff --git a/frontend/ui-core/src/screens/ProjectScreen.tsx b/frontend/ui-core/src/screens/ProjectScreen.tsx index 6e351adb..05299875 100644 --- a/frontend/ui-core/src/screens/ProjectScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectScreen.tsx @@ -79,7 +79,7 @@ import { formatCount, formatWhen } from "../lib/format"; import { ErrorState, LoadingState } from "../patterns/AsyncStates"; import { DEFAULT_PROJECT_SECTION, PROJECT_SECTIONS, type ProjectSection } from "../patterns/ProjectNav"; import { SectionHeader } from "../patterns/SectionHeader"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table"; import { BatchesScreen } from "./BatchesScreen"; import { DatasetScreen } from "./DatasetScreen"; import { AssetThumbnail } from "./AssetThumbnail"; diff --git a/frontend/ui-core/src/screens/ProjectsScreen.tsx b/frontend/ui-core/src/screens/ProjectsScreen.tsx index fc11243a..3f78c3be 100644 --- a/frontend/ui-core/src/screens/ProjectsScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectsScreen.tsx @@ -39,7 +39,7 @@ import { DialogTitle, } from "../primitives/Dialog"; import { FieldError, FieldHint, Input, Label, Textarea } from "../primitives/Input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table"; import { AssetThumbnail, ThumbnailPlaceholder } from "./AssetThumbnail"; import { useCreateProject, useDeleteProject, useProjects, type Project } from "./queries"; From 9f5a3875d23730ede86c76de18ab836fd830356f Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 21:55:44 -0700 Subject: [PATCH 10/55] refactor(ui-core): tabs are shadcn's canonical file --- frontend/ui-core/shadcn/tabs.tsx | 88 ++++++++++ frontend/ui-core/src/index.ts | 4 +- frontend/ui-core/src/patterns/ProjectNav.tsx | 4 +- frontend/ui-core/src/primitives/Tabs.tsx | 154 ------------------ .../src/primitives/primitives.test.tsx | 2 +- frontend/ui-core/src/primitives/tabs.tsx | 88 ++++++++++ .../ui-core/src/screens/DatasetScreen.tsx | 4 +- 7 files changed, 182 insertions(+), 162 deletions(-) create mode 100644 frontend/ui-core/shadcn/tabs.tsx delete mode 100644 frontend/ui-core/src/primitives/Tabs.tsx create mode 100644 frontend/ui-core/src/primitives/tabs.tsx diff --git a/frontend/ui-core/shadcn/tabs.tsx b/frontend/ui-core/shadcn/tabs.tsx new file mode 100644 index 00000000..4d4a2001 --- /dev/null +++ b/frontend/ui-core/shadcn/tabs.tsx @@ -0,0 +1,88 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/cn" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index d80629eb..65e7c0bb 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -75,9 +75,7 @@ export { SheetTrigger, type SheetContentProps, } from "./primitives/Dialog.js"; -// `tabsListVariants` stays unexported: `TabsList`'s own `variant` prop is the -// public surface, and the `cva` behind it is `Tabs.tsx`'s implementation detail. -export { Tabs, TabsContent, TabsList, TabsTrigger } from "./primitives/Tabs.js"; +export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger } from "./primitives/tabs.js"; export { Select, SelectContent, diff --git a/frontend/ui-core/src/patterns/ProjectNav.tsx b/frontend/ui-core/src/patterns/ProjectNav.tsx index 836fe1a2..58161e97 100644 --- a/frontend/ui-core/src/patterns/ProjectNav.tsx +++ b/frontend/ui-core/src/patterns/ProjectNav.tsx @@ -57,7 +57,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../primitives/Menu"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/Tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs"; /** * The four sections of a project, in the order work happens in: what a project @@ -209,7 +209,7 @@ function Strip(props: ProjectNavProps): JSX.Element {
- + {sections.map((section) => { const { label, icon: Icon } = SECTION_LABELS[section]; return ( diff --git a/frontend/ui-core/src/primitives/Tabs.tsx b/frontend/ui-core/src/primitives/Tabs.tsx deleted file mode 100644 index 637e0bd5..00000000 --- a/frontend/ui-core/src/primitives/Tabs.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Tabs — Nova's segmented control by default, plus a `line` variant for when - * a tab bar should read as navigation rather than a switch. - * - * ## Two shapes, chosen at the call site - * - * `TabsList`'s `variant` prop defaults to `"default"`: a `bg-muted` pill with - * the active tab raised onto `bg-background` behind a hairline shadow, Nova's - * own segmented-control recipe. A caller that wants the underline shape - * instead — a row on a full-width hairline, content-sized tabs, the active one - * wearing a 2px rule that sits on the line — passes `variant="line"`. The - * hairline is the variant's own, so no consumer draws one around the list - * (`docs/content/ui/product-principles.md`, *Tabs*). `tabsListVariants` - * (a `cva`, unexported) holds both as data, the same shape `buttonVariants` - * uses for its own variants, rather than as a chain of ternaries a second - * shape would have to be threaded through by hand. - * - * The two previous shapes this file carried — a bordered `muted` segmented - * control, and a full-width `border-b` list with a `border-primary` underline - * — are both gone as literal recipes: Nova's `default` and `line` variants - * replace them, and `TabsContent`'s baked `mt-3` is gone too, folded into the - * `Tabs` root's own gap instead of living on the panel. - * - * ## The gap between the bar and the panel is the root's, and it knows the variant - * - * Nova's `gap-2` for the segmented control — a switch sitting directly on its - * panel. A `line` bar reads as navigation over a page's content, and content - * under navigation takes the layout unit, `gap-4`; the root reads which it holds - * through `:has()`, so the two values live in this one declaration and no consumer - * ever adds a margin of its own (`docs/content/ui/product-principles.md`, *Tabs*). - * - * ## `data-state`, not a boolean `data-active` - * - * Radix's `Tabs.Trigger` reports which tab is selected as `data-state="active" - * | "inactive"` and its orientation as `data-orientation="horizontal" | - * "vertical"` — there is no separate boolean `data-active` or `data-horizontal` - * attribute in the installed `@radix-ui/react-tabs`. Tailwind's bare `data-*` - * variant only ever matches attribute *presence* (`&[data-active]`), so the - * selectors here are the bracket form, `data-[state=active]:` and - * `group-data-[orientation=horizontal]/tabs:`, wired to the attribute Radix - * actually sets rather than to a same-looking one it does not. - * - * ## The focus ring - * - * `focus-visible:ring-[3px] focus-visible:ring-ring/50` plus a 1px - * `outline-ring` matches every other Nova control (`Button`, `Input`, - * `SelectTrigger`): one focus idiom regardless of which variant's background - * sits underneath it. - */ - -import * as TabsPrimitive from "@radix-ui/react-tabs"; -import { cva, type VariantProps } from "class-variance-authority"; -import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react"; - -import { cn } from "../lib/cn"; - -export const Tabs = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(function Tabs({ className, orientation = "horizontal", ...props }, ref) { - return ( - - ); -}); - -const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] " + - "text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 " + - "group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col " + - "data-[variant=line]:rounded-none", - { - variants: { - variant: { - default: "bg-muted", - line: "w-full justify-start gap-1 border-b bg-transparent", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -export const TabsList = forwardRef< - ElementRef, - ComponentPropsWithoutRef & VariantProps ->(function TabsList({ className, variant = "default", ...props }, ref) { - return ( - - ); -}); - -export const TabsTrigger = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(function TabsTrigger({ className, ...props }, ref) { - return ( - - ); -}); - -export const TabsContent = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(function TabsContent({ className, ...props }, ref) { - return ( - - ); -}); diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx index 87fea809..c1fabfde 100644 --- a/frontend/ui-core/src/primitives/primitives.test.tsx +++ b/frontend/ui-core/src/primitives/primitives.test.tsx @@ -38,7 +38,7 @@ import { SelectValue, } from "./Select"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "./table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; describe("Button", () => { it("keeps an explicit type", () => { diff --git a/frontend/ui-core/src/primitives/tabs.tsx b/frontend/ui-core/src/primitives/tabs.tsx new file mode 100644 index 00000000..7befd7c5 --- /dev/null +++ b/frontend/ui-core/src/primitives/tabs.tsx @@ -0,0 +1,88 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "../lib/cn" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index 708d2dde..82b5e570 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -54,7 +54,7 @@ import type { BadgeTone } from "./batchState"; import { SectionHeader } from "../patterns/SectionHeader"; import { Button } from "../primitives/button"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "../primitives/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/Tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs"; import { Dialog, DialogContent, @@ -186,7 +186,7 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp more than a scroll does. The padding pair keeps the focus ring off the scroller's clip. */}
- + Overview From 4a3225db9a9ec760f2cb674db0cdb1e10327553b Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Tue, 25 Aug 2026 22:14:58 -0700 Subject: [PATCH 11/55] fix(ui-core): a gate card and a release card keep a heading role --- frontend/ui-core/src/data/TokenGate.tsx | 2 +- frontend/ui-core/src/data/dataShell.test.tsx | 7 ++++++- frontend/ui-core/src/screens/DatasetScreen.tsx | 2 +- frontend/ui-core/src/screens/dataset.test.tsx | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/ui-core/src/data/TokenGate.tsx b/frontend/ui-core/src/data/TokenGate.tsx index 001fc514..d6efc327 100644 --- a/frontend/ui-core/src/data/TokenGate.tsx +++ b/frontend/ui-core/src/data/TokenGate.tsx @@ -122,7 +122,7 @@ export function TokenForm(): JSX.Element {
- + diff --git a/frontend/ui-core/src/data/dataShell.test.tsx b/frontend/ui-core/src/data/dataShell.test.tsx index dc940e5c..e25ab2e2 100644 --- a/frontend/ui-core/src/data/dataShell.test.tsx +++ b/frontend/ui-core/src/data/dataShell.test.tsx @@ -259,7 +259,12 @@ describe("the token form", () => { , ); - await userEvent.type(await screen.findByTestId("token-input"), "wrong"); + await screen.findByTestId("token-input"); + expect( + screen.getByRole("heading", { level: 2, name: /connect to a workspace/i }), + ).not.toBeNull(); + + await userEvent.type(screen.getByTestId("token-input"), "wrong"); await userEvent.click(screen.getByTestId("token-submit")); await waitFor(() => expect(screen.queryByTestId("token-error")).not.toBeNull()); diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index 82b5e570..40a7a697 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -587,7 +587,7 @@ function ReleaseCard({ release }: { readonly release: Release }): JSX.Element { return ( - +
diff --git a/frontend/ui-core/shadcn/field.tsx b/frontend/ui-core/shadcn/field.tsx new file mode 100644 index 00000000..af7e4441 --- /dev/null +++ b/frontend/ui-core/shadcn/field.tsx @@ -0,0 +1,236 @@ +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/cn" +import { Label } from "@/primitives/label" +import { Separator } from "@/primitives/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +