img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx
index 8245461c..e228a78d 100644
--- a/frontend/ui-core/src/primitives/primitives.test.tsx
+++ b/frontend/ui-core/src/primitives/primitives.test.tsx
@@ -20,7 +20,7 @@ import { describe, expect, it } from "vitest";
import { Alert, AlertDescription, AlertTitle } from "./alert";
import { Badge } from "./badge";
import { Button } from "./button";
-import { Card, CardTitle } from "./Card";
+import { Card, CardTitle } from "./card";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./Dialog";
import { Progress } from "./Feedback";
import { FieldError, Input, Label } from "./Input";
@@ -181,13 +181,13 @@ describe("Select", () => {
});
describe("Card and Table", () => {
- it("gives a card title a heading role, so a screen is navigable", () => {
+ it("marks a card title with its slot, for the styling that reads it", () => {
render(
Classes
,
);
- expect(screen.getByRole("heading", { name: "Classes" })).not.toBeNull();
+ expect(screen.getByText("Classes").getAttribute("data-slot")).toBe("card-title");
});
it("keeps the table's header while the body is empty", () => {
diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx
index 08f4c9f8..4d8e9076 100644
--- a/frontend/ui-core/src/screens/DatasetScreen.tsx
+++ b/frontend/ui-core/src/screens/DatasetScreen.tsx
@@ -53,7 +53,7 @@ import { Badge } from "../primitives/badge";
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 { Card, CardAction, CardContent, CardHeader, CardTitle } from "../primitives/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/Tabs";
import {
Dialog,
diff --git a/frontend/ui-core/src/screens/IngestScreen.tsx b/frontend/ui-core/src/screens/IngestScreen.tsx
index e67c77a1..1f75e943 100644
--- a/frontend/ui-core/src/screens/IngestScreen.tsx
+++ b/frontend/ui-core/src/screens/IngestScreen.tsx
@@ -142,7 +142,7 @@ import { Alert, AlertDescription, AlertTitle } from "../primitives/alert";
import { Badge } from "../primitives/badge";
import type { BadgeTone } from "./batchState";
import { Button } from "../primitives/button";
-import { Card, CardContent } from "../primitives/Card";
+import { Card, CardContent } from "../primitives/card";
import { Progress } from "../primitives/Feedback";
import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
import {
diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx
index cbbbbd31..e6c84d51 100644
--- a/frontend/ui-core/src/screens/ModelsScreen.tsx
+++ b/frontend/ui-core/src/screens/ModelsScreen.tsx
@@ -171,7 +171,7 @@ import {
CardFooter,
CardHeader,
CardTitle,
-} from "../primitives/Card";
+} from "../primitives/card";
import { Progress } from "../primitives/Feedback";
import {
Dialog,
diff --git a/frontend/ui-core/src/screens/SchemaEditor.tsx b/frontend/ui-core/src/screens/SchemaEditor.tsx
index 57c5097d..bdb023e7 100644
--- a/frontend/ui-core/src/screens/SchemaEditor.tsx
+++ b/frontend/ui-core/src/screens/SchemaEditor.tsx
@@ -105,7 +105,7 @@ import { classBlockers, describeClassCount, refusalProse } from "../data/refusal
import { Alert, AlertDescription, AlertTitle } from "../primitives/alert";
import { Badge } from "../primitives/badge";
import { Button } from "../primitives/button";
-import { Card, CardAction, CardContent, CardHeader, CardTitle } from "../primitives/Card";
+import { Card, CardAction, CardContent, CardHeader, CardTitle } from "../primitives/card";
import {
Dialog,
DialogContent,
diff --git a/frontend/ui-core/src/screens/models.test.tsx b/frontend/ui-core/src/screens/models.test.tsx
index 2dbe2f00..7dcaa7f5 100644
--- a/frontend/ui-core/src/screens/models.test.tsx
+++ b/frontend/ui-core/src/screens/models.test.tsx
@@ -406,7 +406,7 @@ it("renders every field a full connection carries, top to bottom", async () => {
card(
undefined} onDelete={() => undefined} />),
);
const shown = await screen.findByTestId("connection-sam2-full");
- expect(within(shown).getByRole("heading", { level: 3 }).textContent).toBe("sam2-full");
+ expect(within(shown).getByText("sam2-full").getAttribute("data-slot")).toBe("card-title");
expect(within(shown).getByTestId("model-reference").textContent).toBe(
`${SAM_BASE_PLUS} @ ${SAM_BASE_PLUS_COMMIT}`,
);
From 32aefa34c4b6896cb7859b51938491d496e0c824 Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Tue, 25 Aug 2026 21:50:28 -0700
Subject: [PATCH 09/55] refactor(ui-core): the table is shadcn's canonical file
---
frontend/ui-core/shadcn/table.tsx | 114 ++++++++++++++++++
frontend/ui-core/src/index.ts | 5 +-
frontend/ui-core/src/primitives/Table.tsx | 85 -------------
.../src/primitives/primitives.test.tsx | 16 ++-
frontend/ui-core/src/primitives/table.tsx | 114 ++++++++++++++++++
.../ui-core/src/screens/BatchesScreen.tsx | 2 +-
.../ui-core/src/screens/DatasetScreen.tsx | 2 +-
frontend/ui-core/src/screens/IngestScreen.tsx | 2 +-
.../ui-core/src/screens/ProjectScreen.tsx | 2 +-
.../ui-core/src/screens/ProjectsScreen.tsx | 2 +-
10 files changed, 246 insertions(+), 98 deletions(-)
create mode 100644 frontend/ui-core/shadcn/table.tsx
delete mode 100644 frontend/ui-core/src/primitives/Table.tsx
create mode 100644 frontend/ui-core/src/primitives/table.tsx
diff --git a/frontend/ui-core/shadcn/table.tsx b/frontend/ui-core/shadcn/table.tsx
new file mode 100644
index 00000000..70280a10
--- /dev/null
+++ b/frontend/ui-core/shadcn/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/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 (
-
- );
- },
-);
-
-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}
-
-
- );
-}
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(
-
- 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 {
-
+
Connect to a workspace
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 (
-
+
{release.tag}
v{release.schema_version}
diff --git a/frontend/ui-core/src/screens/dataset.test.tsx b/frontend/ui-core/src/screens/dataset.test.tsx
index 2916fde4..bfb431a4 100644
--- a/frontend/ui-core/src/screens/dataset.test.tsx
+++ b/frontend/ui-core/src/screens/dataset.test.tsx
@@ -303,7 +303,8 @@ describe("verification", () => {
});
render(mount( ));
- await screen.findByTestId("release-v1");
+ const releaseCard = await screen.findByTestId("release-v1");
+ expect(within(releaseCard).getByRole("heading", { level: 3, name: /v1/i })).not.toBeNull();
expect(sent.some((r) => r.url.endsWith("/verify"))).toBe(false);
await userEvent.click(screen.getByTestId("verify-v1"));
From 23805e39501c7ad0e1a656f25b5b45029f1c87cc Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Tue, 25 Aug 2026 22:30:14 -0700
Subject: [PATCH 12/55] refactor(ui-core): input, textarea, label and field are
shadcn's canonical files
---
frontend/app/src/styleguide/Styleguide.tsx | 10 +-
frontend/ui-core/shadcn/field.tsx | 236 ++++++++++++++++++
frontend/ui-core/shadcn/input.tsx | 19 ++
frontend/ui-core/shadcn/label.tsx | 22 ++
frontend/ui-core/shadcn/separator.tsx | 28 +++
frontend/ui-core/shadcn/textarea.tsx | 18 ++
.../ui-core/src/annotator/AddClassDialog.tsx | 3 +-
.../ui-core/src/annotator/AnnotatorPanel.tsx | 2 +-
.../ui-core/src/annotator/ClassRegion.tsx | 2 +-
frontend/ui-core/src/data/TokenGate.tsx | 8 +-
frontend/ui-core/src/index.ts | 16 +-
frontend/ui-core/src/patterns/ClassFields.tsx | 8 +-
frontend/ui-core/src/primitives/Combobox.tsx | 2 +-
frontend/ui-core/src/primitives/Input.tsx | 84 -------
frontend/ui-core/src/primitives/field.tsx | 236 ++++++++++++++++++
frontend/ui-core/src/primitives/input.tsx | 19 ++
frontend/ui-core/src/primitives/label.tsx | 22 ++
.../src/primitives/primitives.test.tsx | 4 +-
frontend/ui-core/src/primitives/separator.tsx | 28 +++
frontend/ui-core/src/primitives/textarea.tsx | 18 ++
.../ui-core/src/screens/BatchLifecycle.tsx | 12 +-
.../ui-core/src/screens/BatchesScreen.tsx | 2 +-
.../ui-core/src/screens/CorrectionBatch.tsx | 8 +-
.../ui-core/src/screens/DatasetScreen.tsx | 10 +-
frontend/ui-core/src/screens/DeleteBatch.tsx | 2 +-
.../ui-core/src/screens/GalleryScreen.tsx | 3 +-
frontend/ui-core/src/screens/IngestScreen.tsx | 24 +-
frontend/ui-core/src/screens/ModelsScreen.tsx | 34 +--
.../ui-core/src/screens/PreLabelDialog.tsx | 20 +-
frontend/ui-core/src/screens/ProjectFrame.tsx | 4 +-
.../src/screens/ProjectPreLabelDialog.tsx | 3 +-
.../ui-core/src/screens/ProjectsScreen.tsx | 7 +-
.../ui-core/src/screens/PromoteButton.tsx | 2 +-
frontend/ui-core/src/screens/SchemaEditor.tsx | 3 +-
34 files changed, 761 insertions(+), 158 deletions(-)
create mode 100644 frontend/ui-core/shadcn/field.tsx
create mode 100644 frontend/ui-core/shadcn/input.tsx
create mode 100644 frontend/ui-core/shadcn/label.tsx
create mode 100644 frontend/ui-core/shadcn/separator.tsx
create mode 100644 frontend/ui-core/shadcn/textarea.tsx
delete mode 100644 frontend/ui-core/src/primitives/Input.tsx
create mode 100644 frontend/ui-core/src/primitives/field.tsx
create mode 100644 frontend/ui-core/src/primitives/input.tsx
create mode 100644 frontend/ui-core/src/primitives/label.tsx
create mode 100644 frontend/ui-core/src/primitives/separator.tsx
create mode 100644 frontend/ui-core/src/primitives/textarea.tsx
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index c1d2b92d..3f26bcc1 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -45,7 +45,7 @@ import {
DropdownMenuTrigger,
EmptyState,
ErrorState,
- FieldHint,
+ FieldDescription,
Input,
Label,
LIGHT_THEME,
@@ -274,7 +274,7 @@ export function Styleguide(): JSX.Element {
Project name
- Unique per workspace, case-insensitively.
+ Unique per workspace, case-insensitively.
Geometry
@@ -288,7 +288,7 @@ export function Styleguide(): JSX.Element {
classification_tag
- A hint, under a field that needs one.
+ A hint, under a field that needs one.
{/*
The two-line option. Here because it is a primitive variant
@@ -324,9 +324,9 @@ export function Styleguide(): JSX.Element {
-
+
The id at the label role, what it costs beneath it. Nothing truncates.
-
+
Description
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 (
+ [data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/frontend/ui-core/shadcn/input.tsx b/frontend/ui-core/shadcn/input.tsx
new file mode 100644
index 00000000..f73a5796
--- /dev/null
+++ b/frontend/ui-core/shadcn/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "@/lib/cn"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/frontend/ui-core/shadcn/label.tsx b/frontend/ui-core/shadcn/label.tsx
new file mode 100644
index 00000000..9f9ea366
--- /dev/null
+++ b/frontend/ui-core/shadcn/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/cn"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/frontend/ui-core/shadcn/separator.tsx b/frontend/ui-core/shadcn/separator.tsx
new file mode 100644
index 00000000..5fc60fc0
--- /dev/null
+++ b/frontend/ui-core/shadcn/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/cn"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/frontend/ui-core/shadcn/textarea.tsx b/frontend/ui-core/shadcn/textarea.tsx
new file mode 100644
index 00000000..b056e573
--- /dev/null
+++ b/frontend/ui-core/shadcn/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/cn"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/ui-core/src/annotator/AddClassDialog.tsx b/frontend/ui-core/src/annotator/AddClassDialog.tsx
index 00c95c1d..affe1de5 100644
--- a/frontend/ui-core/src/annotator/AddClassDialog.tsx
+++ b/frontend/ui-core/src/annotator/AddClassDialog.tsx
@@ -101,7 +101,8 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
import { formatGeometries } from "../data/geometryCategory";
import { ClassFields } from "../patterns/ClassFields";
import type {
diff --git a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
index 8b6cabea..e08f8670 100644
--- a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
+++ b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
@@ -89,7 +89,7 @@ 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 { Input } from "../primitives/input";
import {
DropdownMenu,
DropdownMenuTrigger,
diff --git a/frontend/ui-core/src/annotator/ClassRegion.tsx b/frontend/ui-core/src/annotator/ClassRegion.tsx
index 45cf1e2c..21bc2b06 100644
--- a/frontend/ui-core/src/annotator/ClassRegion.tsx
+++ b/frontend/ui-core/src/annotator/ClassRegion.tsx
@@ -64,7 +64,7 @@ import { useState, type JSX, type RefObject } from "react";
import { formatGeometries, geometryLabel } from "../data/geometryCategory";
import { classColor } from "../palette";
import { Button } from "../primitives/button";
-import { Input } from "../primitives/Input";
+import { Input } from "../primitives/input";
import { CLASS_ROW_PX, ClassListRow } from "../patterns/DataDisplay";
/** The fewest rows' worth of height the region ever takes. */
diff --git a/frontend/ui-core/src/data/TokenGate.tsx b/frontend/ui-core/src/data/TokenGate.tsx
index d6efc327..3447fe9d 100644
--- a/frontend/ui-core/src/data/TokenGate.tsx
+++ b/frontend/ui-core/src/data/TokenGate.tsx
@@ -51,7 +51,9 @@ import { useEffect, useState, type FormEvent, type JSX, type ReactNode } from "r
import { createApiClient } from "../client";
import { Button } from "../primitives/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../primitives/card";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import { useApiSession } from "./ApiProvider";
import { refusalProse } from "./refusals";
import { asApiError, NETWORK_ERROR, unwrap } from "./errors";
@@ -146,10 +148,10 @@ export function TokenForm(): JSX.Element {
onChange={(event) => setValue(event.target.value)}
placeholder="paste the secret printed by visionset token create"
/>
-
+
Mint one with visionset token create --name ui.
It is shown exactly once. Kept for this browser tab only.
-
+
{failure !== null && {failure} }
))}
-
+
{describeGeometries(declared.geometries)}
-
+
diff --git a/frontend/ui-core/src/primitives/Combobox.tsx b/frontend/ui-core/src/primitives/Combobox.tsx
index f8711147..78673fa7 100644
--- a/frontend/ui-core/src/primitives/Combobox.tsx
+++ b/frontend/ui-core/src/primitives/Combobox.tsx
@@ -43,7 +43,7 @@
import { useEffect, useId, useMemo, useRef, useState, type JSX, type ReactNode } from "react";
import { cn } from "../lib/cn";
-import { Input } from "./Input";
+import { Input } from "./input";
/** An extra row after the items, or `null` for none in this state. */
export interface ComboboxFooter {
diff --git a/frontend/ui-core/src/primitives/Input.tsx b/frontend/ui-core/src/primitives/Input.tsx
deleted file mode 100644
index 2945ca2b..00000000
--- a/frontend/ui-core/src/primitives/Input.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * Text input, textarea and label.
- *
- * `Label` is Radix's, not a bare ``, because Radix's handles the case an
- * ordinary one gets wrong: clicking a label whose control is a Radix `Select` or
- * `Checkbox` — a composite that is not a native form element — still focuses it.
- *
- * The focus ring is the base layer's `:focus-visible`, not a per-component
- * `focus:ring-*`. One rule, in one place, that a new screen cannot forget.
- */
-
-import * as LabelPrimitive from "@radix-ui/react-label";
-import {
- forwardRef,
- type ComponentPropsWithoutRef,
- type ElementRef,
- type HTMLAttributes,
- type InputHTMLAttributes,
- type JSX,
- type TextareaHTMLAttributes,
-} from "react";
-
-import { cn } from "../lib/cn";
-
-export const Input = forwardRef>(
- function Input({ className, ...props }, ref) {
- return (
-
- );
- },
-);
-
-export const Textarea = forwardRef>(
- function Textarea({ className, ...props }, ref) {
- return (
-
- );
- },
-);
-
-export const Label = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function Label({ className, ...props }, ref) {
- return (
-
- );
-});
-
-/** Helper text under a field, and its error twin. `DESIGN.md`'s meta scale. */
-export function FieldHint({
- className,
- ...props
-}: HTMLAttributes): JSX.Element {
- return
;
-}
-
-export function FieldError({
- className,
- ...props
-}: HTMLAttributes): JSX.Element {
- return
;
-}
diff --git a/frontend/ui-core/src/primitives/field.tsx b/frontend/ui-core/src/primitives/field.tsx
new file mode 100644
index 00000000..47a9ee7c
--- /dev/null
+++ b/frontend/ui-core/src/primitives/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 "./label"
+import { Separator } from "./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 (
+ [data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/frontend/ui-core/src/primitives/input.tsx b/frontend/ui-core/src/primitives/input.tsx
new file mode 100644
index 00000000..531fc6ab
--- /dev/null
+++ b/frontend/ui-core/src/primitives/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "../lib/cn"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/frontend/ui-core/src/primitives/label.tsx b/frontend/ui-core/src/primitives/label.tsx
new file mode 100644
index 00000000..16076f0f
--- /dev/null
+++ b/frontend/ui-core/src/primitives/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "../lib/cn"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx
index c1fabfde..40c244be 100644
--- a/frontend/ui-core/src/primitives/primitives.test.tsx
+++ b/frontend/ui-core/src/primitives/primitives.test.tsx
@@ -23,7 +23,9 @@ import { Button } from "./button";
import { Card, CardTitle } from "./card";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./Dialog";
import { Progress } from "./Feedback";
-import { FieldError, Input, Label } from "./Input";
+import { FieldError } from "./field";
+import { Input } from "./input";
+import { Label } from "./label";
import {
DropdownMenu,
DropdownMenuContent,
diff --git a/frontend/ui-core/src/primitives/separator.tsx b/frontend/ui-core/src/primitives/separator.tsx
new file mode 100644
index 00000000..e5f1419a
--- /dev/null
+++ b/frontend/ui-core/src/primitives/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "../lib/cn"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/frontend/ui-core/src/primitives/textarea.tsx b/frontend/ui-core/src/primitives/textarea.tsx
new file mode 100644
index 00000000..6f935a30
--- /dev/null
+++ b/frontend/ui-core/src/primitives/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "../lib/cn"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/ui-core/src/screens/BatchLifecycle.tsx b/frontend/ui-core/src/screens/BatchLifecycle.tsx
index 4f3005e0..fe1d7abb 100644
--- a/frontend/ui-core/src/screens/BatchLifecycle.tsx
+++ b/frontend/ui-core/src/screens/BatchLifecycle.tsx
@@ -35,7 +35,9 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import { Progress } from "../primitives/Feedback";
import {
Select,
@@ -245,10 +247,10 @@ export function ApproveDialog({
Jobs of N assets
-
+
The cut is exact — disjoint, and every asset in one job. An explicit list of
segments is the SDK’s and the API’s, not a form’s.
-
+
{kind === "by_size" && (
@@ -262,9 +264,9 @@ export function ApproveDialog({
value={size}
onChange={(event) => setSize(event.target.value)}
/>
-
+
{batch?.asset_count ?? 0} assets → {jobs} job{jobs === 1 ? "" : "s"}
-
+
)}
diff --git a/frontend/ui-core/src/screens/BatchesScreen.tsx b/frontend/ui-core/src/screens/BatchesScreen.tsx
index 8c09f34a..14438f13 100644
--- a/frontend/ui-core/src/screens/BatchesScreen.tsx
+++ b/frontend/ui-core/src/screens/BatchesScreen.tsx
@@ -39,7 +39,7 @@ import { refusalProse } from "../data/refusals";
import { Badge } from "../primitives/badge";
import { SectionHeader } from "../patterns/SectionHeader";
import { Button } from "../primitives/button";
-import { FieldError } from "../primitives/Input";
+import { FieldError } from "../primitives/field";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table";
import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle";
import { BATCH_STATE_VARIANT, batchStateLabel } from "./batchState";
diff --git a/frontend/ui-core/src/screens/CorrectionBatch.tsx b/frontend/ui-core/src/screens/CorrectionBatch.tsx
index 2aad833c..bdee94fd 100644
--- a/frontend/ui-core/src/screens/CorrectionBatch.tsx
+++ b/frontend/ui-core/src/screens/CorrectionBatch.tsx
@@ -44,7 +44,9 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import { useCreateCorrection, type Batch } from "./queries";
/** What a correction covers. `all` sends nothing and lets the server default. */
@@ -235,10 +237,10 @@ function CorrectionDialog({
The {chosen.length} frame{chosen.length === 1 ? "" : "s"} selected
)}
-
+
The correction starts as a draft, so its frames can still change. Approving it pins
the project’s current label schema — not the one {batch.name} was judged against.
-
+
{create.isError && (
diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx
index 40a7a697..7a087123 100644
--- a/frontend/ui-core/src/screens/DatasetScreen.tsx
+++ b/frontend/ui-core/src/screens/DatasetScreen.tsx
@@ -62,7 +62,9 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import {
classBlockers,
describeClassCount,
@@ -800,7 +802,7 @@ function PublishDialog({
/>
{/* The opposite of a project name, which is unique case-insensitively.
Two rules, each beside its own index. */}
- Unique per dataset, and case-sensitive.
+ Unique per dataset, and case-sensitive.
@@ -1061,9 +1063,9 @@ function ExportDialog({
{/* Declared by the format, never by the release: a bbox-only format
loses a polygon whether or not today's dataset holds one. */}
{chosen?.lossy === true && (
-
+
This format cannot express everything the schema allows.
-
+
)}
diff --git a/frontend/ui-core/src/screens/DeleteBatch.tsx b/frontend/ui-core/src/screens/DeleteBatch.tsx
index 27fc2a34..156965c9 100644
--- a/frontend/ui-core/src/screens/DeleteBatch.tsx
+++ b/frontend/ui-core/src/screens/DeleteBatch.tsx
@@ -46,7 +46,7 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError } from "../primitives/Input";
+import { FieldError } from "../primitives/field";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../primitives/Menu";
import { useDeleteBatch, type Batch } from "./queries";
diff --git a/frontend/ui-core/src/screens/GalleryScreen.tsx b/frontend/ui-core/src/screens/GalleryScreen.tsx
index c8109040..1ff70c97 100644
--- a/frontend/ui-core/src/screens/GalleryScreen.tsx
+++ b/frontend/ui-core/src/screens/GalleryScreen.tsx
@@ -50,7 +50,8 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, Input } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { FieldError } from "../primitives/field";
import { AssetThumbnail } from "./AssetThumbnail";
import { BackLink } from "../patterns/BackLink";
import {
diff --git a/frontend/ui-core/src/screens/IngestScreen.tsx b/frontend/ui-core/src/screens/IngestScreen.tsx
index 2ea2d85e..c06a7975 100644
--- a/frontend/ui-core/src/screens/IngestScreen.tsx
+++ b/frontend/ui-core/src/screens/IngestScreen.tsx
@@ -144,7 +144,9 @@ import type { BadgeTone } from "./batchState";
import { Button } from "../primitives/button";
import { Card, CardContent } from "../primitives/card";
import { Progress } from "../primitives/Feedback";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import {
Select,
SelectContent,
@@ -622,7 +624,7 @@ export function IngestScreen({
))}
- Only a draft batch can take new assets.
+ Only a draft batch can take new assets.
{batchChoice === NEW_BATCH && (
@@ -634,7 +636,7 @@ export function IngestScreen({
placeholder={source.name}
onChange={(event) => setBatchName(event.target.value)}
/>
- Defaults to the source name.
+ Defaults to the source name.
)}
@@ -910,10 +912,10 @@ function SelectionPanel({
placeholder={suggestedName}
onChange={(event) => onSourceName(event.target.value)}
/>
-
+
Names the source — and the new batch inherits it. Without one the server calls
both by the upload's content digest.
-
+
)}
@@ -947,10 +949,10 @@ function SelectionPanel({
{selectionSummary(ranges, clip.durationSeconds)}
-
+
Part of what the source is — the same clip registered at another
rate or other ranges becomes a second source.
-
+
}
/>
@@ -962,10 +964,10 @@ function SelectionPanel({
)}
-
+
Part of what the source is — the same clip registered at another rate
or other ranges becomes a second source.
-
+
)}
@@ -1067,10 +1069,10 @@ function RunCard({
{resume.isPending ? "Resuming…" : "Resume"}
-
+
A resume is a redo, not a skip — nothing records which files already
succeeded, and content addressing makes re-reading them free.
-
+
{/*
The refusal of the *resume*, which is a different fact from the
run's own error above it (audit F9). `resume.isError` was read
diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx
index e6c84d51..cd761013 100644
--- a/frontend/ui-core/src/screens/ModelsScreen.tsx
+++ b/frontend/ui-core/src/screens/ModelsScreen.tsx
@@ -186,7 +186,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../primitives/Menu";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import {
Select,
SelectContent,
@@ -669,7 +671,7 @@ export function ConnectionCard({
)}
{probe.isPending && (
- Asking the endpoint…
+ Asking the endpoint…
)}
{probe.isError && (
{refusalProse(probe.error)}
@@ -917,10 +919,10 @@ function FixedModel({ connection }: { readonly connection: Connection }): JSX.El
{connection.model_id} @ {connection.model_revision}
-
+
Set up with these weights, so the model cannot change here. Add a new model to run a
different one.
-
+
);
}
@@ -1256,7 +1258,7 @@ function ConnectionForm({
onChange={(event) => setName(event.target.value)}
autoFocus
/>
- Unique in this workspace, ignoring case.
+ Unique in this workspace, ignoring case.
{/*
The offered list is the *local* form's, and only its. An offer is
@@ -1290,7 +1292,7 @@ function ConnectionForm({
be announced twice.
*/}
- Reading which models this installation can run…
+ Reading which models this installation can run…
) : catalog.isError ? (
@@ -1302,11 +1304,11 @@ function ConnectionForm({
/>
) : groups.length === 0 ? (
-
+
No installed driver offers a model by name. Install a provider
distribution to be offered one, or name a model below and pin its
revision yourself.
-
+
) : (
<>
@@ -1345,11 +1347,11 @@ function ConnectionForm({
-
+
{custom
? "Any model this build has an adapter for. The list above is a starting point, not a limit."
: "Pinned to the revision the driver that offers it declares."}
-
+
>
)}
{/* What choosing this model commits to, read where the choice is made. */}
@@ -1400,7 +1402,7 @@ function ConnectionForm({
value={revision}
onChange={(event) => setRevision(event.target.value)}
/>
- Pinned. A moving pointer is not a provenance.
+ Pinned. A moving pointer is not a provenance.
>
)}
@@ -1447,11 +1449,11 @@ function ConnectionForm({
))}
-
+
{precisionsFor(device).length === 1
? "Half precision applies on CUDA only — on a CPU it has no effect."
: "fp16 halves the memory and runs faster on CUDA."}
-
+
>
) : (
@@ -1465,7 +1467,7 @@ function ConnectionForm({
value={revision}
onChange={(event) => setRevision(event.target.value)}
/>
- Pinned. A moving pointer is not a provenance.
+ Pinned. A moving pointer is not a provenance.
)}
@@ -1485,11 +1487,11 @@ function ConnectionForm({
value={credentialEnv}
onChange={(event) => setCredentialEnv(event.target.value)}
/>
-
+
The name of an environment variable, not the secret itself. VisionSet reads
it where the server runs, sends it as a bearer token, and never stores the
value. Leave empty if the endpoint wants none.
-
+
>
)}
diff --git a/frontend/ui-core/src/screens/PreLabelDialog.tsx b/frontend/ui-core/src/screens/PreLabelDialog.tsx
index 983f1e13..cb73c7dd 100644
--- a/frontend/ui-core/src/screens/PreLabelDialog.tsx
+++ b/frontend/ui-core/src/screens/PreLabelDialog.tsx
@@ -102,7 +102,9 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import {
Select,
SelectContent,
@@ -494,11 +496,11 @@ export function PreLabelSettings({
disabled={disabled}
onChange={(event) => onConfidenceChange(event.target.value)}
/>
-
+
How well a region matches the words it was asked for — a different scale from a
point-prompt model’s mask confidence, which is why the number needs a name of
its own rather than a bare percentage.
-
+
{selected !== null && (
@@ -524,10 +526,10 @@ export function PreLabelSettings({
Tick at least one shape — a run that writes no shape has nothing to do.
) : (
-
+
This model answers in every shape here, one region each, and writes every ticked
one. Untick a shape to leave it out of the run.
-
+
)}
)}
@@ -824,10 +826,10 @@ function PreLabelDialog({
{preLabeled === 1 ? "" : "s"}
-
+
Frames anyone has edited, confirmed or skipped in this batch are never
touched. This cannot be undone.
-
+
)}
@@ -924,11 +926,11 @@ function PreLabelDialog({
>
Continue
-
+
{replacing
? `Also rewrites the model labels on the ${preLabeled} pre-labeled frame${preLabeled === 1 ? "" : "s"}.`
: "Only untouched assets are eligible — this can’t create a duplicate label."}
-
+
{blocked && preLabeled > 0 && (
diff --git a/frontend/ui-core/src/screens/ProjectFrame.tsx b/frontend/ui-core/src/screens/ProjectFrame.tsx
index aa79f6cc..3e323999 100644
--- a/frontend/ui-core/src/screens/ProjectFrame.tsx
+++ b/frontend/ui-core/src/screens/ProjectFrame.tsx
@@ -33,7 +33,9 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
+import { FieldError } from "../primitives/field";
import {
useActiveSchema,
useBatches,
diff --git a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx
index 8c74e422..e8b19fb1 100644
--- a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx
+++ b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx
@@ -29,7 +29,8 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, Label } from "../primitives/Input";
+import { Label } from "../primitives/label";
+import { FieldError } from "../primitives/field";
import {
DEFAULT_CONFIDENCE,
NO_SHAPES,
diff --git a/frontend/ui-core/src/screens/ProjectsScreen.tsx b/frontend/ui-core/src/screens/ProjectsScreen.tsx
index 3f78c3be..78e90595 100644
--- a/frontend/ui-core/src/screens/ProjectsScreen.tsx
+++ b/frontend/ui-core/src/screens/ProjectsScreen.tsx
@@ -38,7 +38,10 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { FieldError, FieldHint, Input, Label, Textarea } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Textarea } from "../primitives/textarea";
+import { Label } from "../primitives/label";
+import { FieldDescription, FieldError } from "../primitives/field";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table";
import { AssetThumbnail, ThumbnailPlaceholder } from "./AssetThumbnail";
import { useCreateProject, useDeleteProject, useProjects, type Project } from "./queries";
@@ -295,7 +298,7 @@ export function CreateProjectDialog({
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
- Optional.
+ Optional.
{create.isError && (
{refusal(create.error)}
diff --git a/frontend/ui-core/src/screens/PromoteButton.tsx b/frontend/ui-core/src/screens/PromoteButton.tsx
index e9147110..fb317531 100644
--- a/frontend/ui-core/src/screens/PromoteButton.tsx
+++ b/frontend/ui-core/src/screens/PromoteButton.tsx
@@ -49,7 +49,7 @@ import type { JSX } from "react";
import { BATCH_ACTION, declares } from "../data/capabilities";
import { refusalProse } from "../data/refusals";
import { Button } from "../primitives/button";
-import { FieldError } from "../primitives/Input";
+import { FieldError } from "../primitives/field";
import { usePromoteBatch, type Batch } from "./queries";
export interface PromoteButtonProps {
diff --git a/frontend/ui-core/src/screens/SchemaEditor.tsx b/frontend/ui-core/src/screens/SchemaEditor.tsx
index bdb023e7..c97018a9 100644
--- a/frontend/ui-core/src/screens/SchemaEditor.tsx
+++ b/frontend/ui-core/src/screens/SchemaEditor.tsx
@@ -113,7 +113,8 @@ import {
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
-import { Input, Label } from "../primitives/Input";
+import { Input } from "../primitives/input";
+import { Label } from "../primitives/label";
import { ClassFields, swatchOf } from "../patterns/ClassFields";
import { ClassListRow } from "../patterns/DataDisplay";
import { formatCount, formatWhen } from "../lib/format";
From d850ce73127b74472561e22778f5157c80c964ac Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Tue, 25 Aug 2026 22:57:55 -0700
Subject: [PATCH 13/55] refactor(ui-core): the dialog is shadcn's canonical
file
---
frontend/ui-core/shadcn/dialog.tsx | 166 +++++++++++++++
.../ui-core/src/annotator/AddClassDialog.tsx | 2 +-
.../ui-core/src/annotator/FrameGallery.tsx | 2 +-
.../ui-core/src/annotator/ShortcutSheet.tsx | 2 +-
.../src/annotator/frameGallery.test.tsx | 2 +-
frontend/ui-core/src/harness.test.tsx | 2 +-
frontend/ui-core/src/index.ts | 19 +-
frontend/ui-core/src/primitives/Dialog.tsx | 188 -----------------
frontend/ui-core/src/primitives/dialog.tsx | 190 ++++++++++++++++++
.../src/primitives/primitives.test.tsx | 2 +-
.../ui-core/src/screens/BatchLifecycle.tsx | 2 +-
.../ui-core/src/screens/CorrectionBatch.tsx | 2 +-
.../src/screens/DatasetAssetDialog.tsx | 2 +-
.../ui-core/src/screens/DatasetScreen.tsx | 2 +-
frontend/ui-core/src/screens/DeleteBatch.tsx | 2 +-
.../ui-core/src/screens/GalleryScreen.tsx | 2 +-
frontend/ui-core/src/screens/ModelsScreen.tsx | 2 +-
.../ui-core/src/screens/PreLabelDialog.tsx | 2 +-
frontend/ui-core/src/screens/ProjectFrame.tsx | 2 +-
.../src/screens/ProjectPreLabelDialog.tsx | 2 +-
.../ui-core/src/screens/ProjectsScreen.tsx | 2 +-
frontend/ui-core/src/screens/SchemaEditor.tsx | 2 +-
22 files changed, 375 insertions(+), 224 deletions(-)
create mode 100644 frontend/ui-core/shadcn/dialog.tsx
delete mode 100644 frontend/ui-core/src/primitives/Dialog.tsx
create mode 100644 frontend/ui-core/src/primitives/dialog.tsx
diff --git a/frontend/ui-core/shadcn/dialog.tsx b/frontend/ui-core/shadcn/dialog.tsx
new file mode 100644
index 00000000..f1b8ae66
--- /dev/null
+++ b/frontend/ui-core/shadcn/dialog.tsx
@@ -0,0 +1,166 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/cn"
+import { Button } from "@/primitives/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/frontend/ui-core/src/annotator/AddClassDialog.tsx b/frontend/ui-core/src/annotator/AddClassDialog.tsx
index affe1de5..7f6ad6f9 100644
--- a/frontend/ui-core/src/annotator/AddClassDialog.tsx
+++ b/frontend/ui-core/src/annotator/AddClassDialog.tsx
@@ -100,7 +100,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { formatGeometries } from "../data/geometryCategory";
diff --git a/frontend/ui-core/src/annotator/FrameGallery.tsx b/frontend/ui-core/src/annotator/FrameGallery.tsx
index 5059bb1a..96d461f7 100644
--- a/frontend/ui-core/src/annotator/FrameGallery.tsx
+++ b/frontend/ui-core/src/annotator/FrameGallery.tsx
@@ -50,7 +50,7 @@ import { useEffect, useRef, useState, type JSX, type KeyboardEvent } from "react
import { AssetThumbnail } from "../screens/AssetThumbnail";
-import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../primitives/Dialog";
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../primitives/dialog";
import { ThumbnailGrid } from "../patterns/DataDisplay";
import {
inSegment,
diff --git a/frontend/ui-core/src/annotator/ShortcutSheet.tsx b/frontend/ui-core/src/annotator/ShortcutSheet.tsx
index 8b82b813..c9eacb0a 100644
--- a/frontend/ui-core/src/annotator/ShortcutSheet.tsx
+++ b/frontend/ui-core/src/annotator/ShortcutSheet.tsx
@@ -67,7 +67,7 @@ import {
} from "@visionset/annotator";
import type { JSX } from "react";
-import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../primitives/Dialog";
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../primitives/dialog";
/**
* What each action kind means, in words.
diff --git a/frontend/ui-core/src/annotator/frameGallery.test.tsx b/frontend/ui-core/src/annotator/frameGallery.test.tsx
index b8fe31d2..9c5af4c0 100644
--- a/frontend/ui-core/src/annotator/frameGallery.test.tsx
+++ b/frontend/ui-core/src/annotator/frameGallery.test.tsx
@@ -289,7 +289,7 @@ describe("what the gallery shows", () => {
? "frame"
: (button.getAttribute("data-testid")?.startsWith("frame-segment-") ?? false)
? "filter"
- : (button.getAttribute("aria-label") ?? "other"),
+ : (button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "other"),
);
expect(new Set(kinds)).toEqual(new Set(["frame", "filter", "Close"]));
});
diff --git a/frontend/ui-core/src/harness.test.tsx b/frontend/ui-core/src/harness.test.tsx
index 2820d9fa..0f411e0a 100644
--- a/frontend/ui-core/src/harness.test.tsx
+++ b/frontend/ui-core/src/harness.test.tsx
@@ -17,7 +17,7 @@
import { render } from "@testing-library/react";
import { expect, it, vi } from "vitest";
-import { Dialog, DialogContent, DialogTitle } from "./primitives/Dialog";
+import { Dialog, DialogContent, DialogTitle } from "./primitives/dialog";
const unmountFocusReturned = vi.fn();
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index 92375bd9..d7116569 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -71,24 +71,7 @@ export {
export { Combobox, type ComboboxFooter, type ComboboxProps } from "./primitives/Combobox.js";
export { Badge, badgeVariants } from "./primitives/badge.js";
export { Alert, AlertAction, AlertDescription, AlertTitle } from "./primitives/alert.js";
-export {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogOverlay,
- DialogPortal,
- DialogTitle,
- DialogTrigger,
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetTitle,
- SheetTrigger,
- type SheetContentProps,
-} from "./primitives/Dialog.js";
+export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "./primitives/dialog.js";
export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger } from "./primitives/tabs.js";
export {
Select,
diff --git a/frontend/ui-core/src/primitives/Dialog.tsx b/frontend/ui-core/src/primitives/Dialog.tsx
deleted file mode 100644
index 9109ca68..00000000
--- a/frontend/ui-core/src/primitives/Dialog.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * Dialog and sheet — one Radix primitive, two presentations.
- *
- * A sheet is a dialog that slides in from an edge. Building it on
- * `@radix-ui/react-dialog` rather than on its own primitive is what gives both the
- * focus trap, the `Escape` handler, the scroll lock, the `aria-modal` wiring and
- * the return of focus to the trigger — none of which a hand-rolled panel gets, and
- * all of which a destructive-confirmation dialog genuinely needs.
- *
- * `DialogTitle` is **required** by Radix: without one it warns, and a modal with
- * no accessible name is unusable with a screen reader. It is exported rather than
- * folded into `DialogContent` so a caller can place it, but there is no variant
- * that omits it.
- */
-
-import * as DialogPrimitive from "@radix-ui/react-dialog";
-import { X } from "lucide-react";
-import {
- createContext,
- forwardRef,
- useCallback,
- useContext,
- useId,
- useLayoutEffect,
- useState,
- type ComponentPropsWithoutRef,
- type ElementRef,
- type HTMLAttributes,
- type JSX,
-} from "react";
-
-import { cn } from "../lib/cn";
-
-export const Dialog = DialogPrimitive.Root;
-
-/*
- * Radix mints one description id per dialog root: every `Description` renders it
- * and `Content` points `aria-describedby` at it, so a dialog with two
- * descriptions has duplicate ids and a screen reader hears only the first. Each
- * description here carries its own id and registers it with the enclosing
- * content, which describes itself by all of them.
- */
-type RegisterDescription = (id: string) => () => void;
-const DescriptionRegistry = createContext(null);
-
-function useDescribedBy(): { readonly describedBy: string | undefined; readonly register: RegisterDescription } {
- const [ids, setIds] = useState([]);
- const register = useCallback((id) => {
- setIds((previous) => [...previous, id]);
- return () => setIds((previous) => previous.filter((known) => known !== id));
- }, []);
- return { describedBy: ids.length > 0 ? ids.join(" ") : undefined, register };
-}
-export const DialogTrigger = DialogPrimitive.Trigger;
-export const DialogClose = DialogPrimitive.Close;
-export const DialogPortal = DialogPrimitive.Portal;
-
-const OVERLAY =
- "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs " +
- "data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0";
-
-export const DialogOverlay = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function DialogOverlay({ className, ...props }, ref) {
- return ;
-});
-
-export const DialogContent = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function DialogContent({ className, children, ...props }, ref) {
- const { describedBy, register } = useDescribedBy();
- return (
-
-
-
- {children}
-
-
-
-
-
- );
-});
-
-export const DialogTitle = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function DialogTitle({ className, ...props }, ref) {
- return (
-
- );
-});
-
-export const DialogDescription = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function DialogDescription({ className, ...props }, ref) {
- const id = useId();
- const register = useContext(DescriptionRegistry);
- useLayoutEffect(() => register?.(id), [register, id]);
- return (
-
- );
-});
-
-export function DialogFooter({
- className,
- ...props
-}: HTMLAttributes): JSX.Element {
- return (
-
- );
-}
-
-/** The sheet: the same dialog, anchored to an edge. `DESIGN.md`'s 12px radius. */
-export const Sheet = DialogPrimitive.Root;
-export const SheetTrigger = DialogPrimitive.Trigger;
-export const SheetClose = DialogPrimitive.Close;
-
-export interface SheetContentProps
- extends ComponentPropsWithoutRef {
- readonly side?: "left" | "right";
-}
-
-export const SheetContent = forwardRef<
- ElementRef,
- SheetContentProps
->(function SheetContent({ className, side = "right", children, ...props }, ref) {
- const { describedBy, register } = useDescribedBy();
- return (
-
-
-
- {children}
-
-
- );
-});
-
-export const SheetTitle = DialogTitle;
-export const SheetDescription = DialogDescription;
diff --git a/frontend/ui-core/src/primitives/dialog.tsx b/frontend/ui-core/src/primitives/dialog.tsx
new file mode 100644
index 00000000..30fba2a8
--- /dev/null
+++ b/frontend/ui-core/src/primitives/dialog.tsx
@@ -0,0 +1,190 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "../lib/cn"
+import { Button } from "./button"
+import { XIcon } from "lucide-react"
+
+// Radix mints one description id per dialog root and points `aria-describedby`
+// at it, so a dialog with two `DialogDescription`s has duplicate ids and a
+// screen reader hears only the first. Each description here registers its own
+// id with the enclosing content, which describes itself by all of them.
+type RegisterDescription = (id: string) => () => void
+const DescriptionRegistry = React.createContext(null)
+
+function useDescribedBy(): { readonly describedBy: string | undefined; readonly register: RegisterDescription } {
+ const [ids, setIds] = React.useState([])
+ const register = React.useCallback((id) => {
+ setIds((previous) => [...previous, id])
+ return () => setIds((previous) => previous.filter((known) => known !== id))
+ }, [])
+ return { describedBy: ids.length > 0 ? ids.join(" ") : undefined, register }
+}
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ const { describedBy, register } = useDescribedBy()
+ return (
+
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const id = React.useId()
+ const register = React.useContext(DescriptionRegistry)
+ React.useLayoutEffect(() => register?.(id), [register, id])
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx
index 40c244be..e3998566 100644
--- a/frontend/ui-core/src/primitives/primitives.test.tsx
+++ b/frontend/ui-core/src/primitives/primitives.test.tsx
@@ -21,7 +21,7 @@ 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";
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
import { Progress } from "./Feedback";
import { FieldError } from "./field";
import { Input } from "./input";
diff --git a/frontend/ui-core/src/screens/BatchLifecycle.tsx b/frontend/ui-core/src/screens/BatchLifecycle.tsx
index fe1d7abb..2a91d3e2 100644
--- a/frontend/ui-core/src/screens/BatchLifecycle.tsx
+++ b/frontend/ui-core/src/screens/BatchLifecycle.tsx
@@ -34,7 +34,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { FieldDescription, FieldError } from "../primitives/field";
diff --git a/frontend/ui-core/src/screens/CorrectionBatch.tsx b/frontend/ui-core/src/screens/CorrectionBatch.tsx
index bdee94fd..893731ef 100644
--- a/frontend/ui-core/src/screens/CorrectionBatch.tsx
+++ b/frontend/ui-core/src/screens/CorrectionBatch.tsx
@@ -43,7 +43,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { FieldDescription, FieldError } from "../primitives/field";
diff --git a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
index 011eeea6..ed7b0993 100644
--- a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
+++ b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
@@ -49,7 +49,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { useActiveSchema, useDatasetAssetAnnotations, type DatasetAsset } from "./queries";
export interface DatasetAssetDialogProps {
diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx
index 7a087123..1a54af50 100644
--- a/frontend/ui-core/src/screens/DatasetScreen.tsx
+++ b/frontend/ui-core/src/screens/DatasetScreen.tsx
@@ -61,7 +61,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { FieldDescription, FieldError } from "../primitives/field";
diff --git a/frontend/ui-core/src/screens/DeleteBatch.tsx b/frontend/ui-core/src/screens/DeleteBatch.tsx
index 156965c9..e3916daa 100644
--- a/frontend/ui-core/src/screens/DeleteBatch.tsx
+++ b/frontend/ui-core/src/screens/DeleteBatch.tsx
@@ -45,7 +45,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { FieldError } from "../primitives/field";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../primitives/Menu";
import { useDeleteBatch, type Batch } from "./queries";
diff --git a/frontend/ui-core/src/screens/GalleryScreen.tsx b/frontend/ui-core/src/screens/GalleryScreen.tsx
index 1ff70c97..0e418a71 100644
--- a/frontend/ui-core/src/screens/GalleryScreen.tsx
+++ b/frontend/ui-core/src/screens/GalleryScreen.tsx
@@ -49,7 +49,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { FieldError } from "../primitives/field";
import { AssetThumbnail } from "./AssetThumbnail";
diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx
index cd761013..eb1ce74e 100644
--- a/frontend/ui-core/src/screens/ModelsScreen.tsx
+++ b/frontend/ui-core/src/screens/ModelsScreen.tsx
@@ -179,7 +179,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import {
DropdownMenu,
DropdownMenuContent,
diff --git a/frontend/ui-core/src/screens/PreLabelDialog.tsx b/frontend/ui-core/src/screens/PreLabelDialog.tsx
index cb73c7dd..be0744cb 100644
--- a/frontend/ui-core/src/screens/PreLabelDialog.tsx
+++ b/frontend/ui-core/src/screens/PreLabelDialog.tsx
@@ -101,7 +101,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { FieldDescription, FieldError } from "../primitives/field";
diff --git a/frontend/ui-core/src/screens/ProjectFrame.tsx b/frontend/ui-core/src/screens/ProjectFrame.tsx
index 3e323999..09a09991 100644
--- a/frontend/ui-core/src/screens/ProjectFrame.tsx
+++ b/frontend/ui-core/src/screens/ProjectFrame.tsx
@@ -32,7 +32,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { FieldError } from "../primitives/field";
diff --git a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx
index e8b19fb1..6b8e7036 100644
--- a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx
+++ b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx
@@ -28,7 +28,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Label } from "../primitives/label";
import { FieldError } from "../primitives/field";
import {
diff --git a/frontend/ui-core/src/screens/ProjectsScreen.tsx b/frontend/ui-core/src/screens/ProjectsScreen.tsx
index 78e90595..7aaff9c3 100644
--- a/frontend/ui-core/src/screens/ProjectsScreen.tsx
+++ b/frontend/ui-core/src/screens/ProjectsScreen.tsx
@@ -37,7 +37,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Textarea } from "../primitives/textarea";
import { Label } from "../primitives/label";
diff --git a/frontend/ui-core/src/screens/SchemaEditor.tsx b/frontend/ui-core/src/screens/SchemaEditor.tsx
index c97018a9..b518a751 100644
--- a/frontend/ui-core/src/screens/SchemaEditor.tsx
+++ b/frontend/ui-core/src/screens/SchemaEditor.tsx
@@ -112,7 +112,7 @@ import {
DialogDescription,
DialogFooter,
DialogTitle,
-} from "../primitives/Dialog";
+} from "../primitives/dialog";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { ClassFields, swatchOf } from "../patterns/ClassFields";
From 7e2de0ef3b2602fab55da8400d30e81f066bde2d Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Tue, 25 Aug 2026 23:16:33 -0700
Subject: [PATCH 14/55] fix(ui-core): a dialog with several descriptions names
them at the call site
---
frontend/ui-core/src/primitives/dialog.tsx | 24 ----------
.../src/primitives/primitives.test.tsx | 45 +++++++++++--------
frontend/ui-core/src/screens/SchemaEditor.tsx | 34 ++++++++++----
3 files changed, 52 insertions(+), 51 deletions(-)
diff --git a/frontend/ui-core/src/primitives/dialog.tsx b/frontend/ui-core/src/primitives/dialog.tsx
index 30fba2a8..cf7e8c6f 100644
--- a/frontend/ui-core/src/primitives/dialog.tsx
+++ b/frontend/ui-core/src/primitives/dialog.tsx
@@ -5,22 +5,6 @@ import { cn } from "../lib/cn"
import { Button } from "./button"
import { XIcon } from "lucide-react"
-// Radix mints one description id per dialog root and points `aria-describedby`
-// at it, so a dialog with two `DialogDescription`s has duplicate ids and a
-// screen reader hears only the first. Each description here registers its own
-// id with the enclosing content, which describes itself by all of them.
-type RegisterDescription = (id: string) => () => void
-const DescriptionRegistry = React.createContext(null)
-
-function useDescribedBy(): { readonly describedBy: string | undefined; readonly register: RegisterDescription } {
- const [ids, setIds] = React.useState([])
- const register = React.useCallback((id) => {
- setIds((previous) => [...previous, id])
- return () => setIds((previous) => previous.filter((known) => known !== id))
- }, [])
- return { describedBy: ids.length > 0 ? ids.join(" ") : undefined, register }
-}
-
function Dialog({
...props
}: React.ComponentProps) {
@@ -69,20 +53,17 @@ function DialogContent({
}: React.ComponentProps & {
showCloseButton?: boolean
}) {
- const { describedBy, register } = useDescribedBy()
return (
-
{children}
{showCloseButton && (
@@ -97,7 +78,6 @@ function DialogContent({
)}
-
)
@@ -160,13 +140,9 @@ function DialogDescription({
className,
...props
}: React.ComponentProps) {
- const id = React.useId()
- const register = React.useContext(DescriptionRegistry)
- React.useLayoutEffect(() => register?.(id), [register, id])
return (
{
});
describe("Dialog", () => {
- function Described({ second }: { readonly second: boolean }): JSX.Element {
- return (
-
-
- Narrowing
- one class narrows
- {second ? nothing becomes invalid : null}
-
-
- );
- }
-
function describedBy(dialog: HTMLElement): readonly (string | null)[] {
return (dialog.getAttribute("aria-describedby") ?? "")
.split(" ")
@@ -305,15 +293,36 @@ describe("Dialog", () => {
.map((id) => document.getElementById(id)?.textContent ?? null);
}
- it("points aria-describedby at every description, each under its own id", () => {
- const { rerender } = render( );
+ it("points aria-describedby at every description, each under its own id, when the caller names them", () => {
+ render(
+
+
+ Narrowing
+ one class narrows
+ nothing becomes invalid
+
+ ,
+ );
const dialog = screen.getByRole("dialog");
+ expect(dialog.getAttribute("aria-describedby")).toBe("d1 d2");
expect(describedBy(dialog)).toEqual(["one class narrows", "nothing becomes invalid"]);
- const ids = Array.from(dialog.querySelectorAll("p")).map((p) => p.id);
- expect(new Set(ids).size).toBe(ids.length);
+ expect(document.getElementById("d1")?.textContent).toBe("one class narrows");
+ expect(document.getElementById("d2")?.textContent).toBe("nothing becomes invalid");
+ });
- rerender( );
- expect(describedBy(dialog)).toEqual(["one class narrows"]);
+ it("wires a single, unnamed description through Radix's own id by default", () => {
+ render(
+
+
+ Narrowing
+ one class narrows
+
+ ,
+ );
+ const dialog = screen.getByRole("dialog");
+ const describedById = dialog.getAttribute("aria-describedby");
+ expect(describedById).toBeTruthy();
+ expect(document.getElementById(describedById ?? "")?.textContent).toBe("one class narrows");
});
});
diff --git a/frontend/ui-core/src/screens/SchemaEditor.tsx b/frontend/ui-core/src/screens/SchemaEditor.tsx
index b518a751..f6b37453 100644
--- a/frontend/ui-core/src/screens/SchemaEditor.tsx
+++ b/frontend/ui-core/src/screens/SchemaEditor.tsx
@@ -97,7 +97,7 @@
*/
import { Plus, Trash2 } from "lucide-react";
-import { useMemo, useRef, useState, type JSX, type KeyboardEvent } from "react";
+import { useId, useMemo, useRef, useState, type JSX, type KeyboardEvent } from "react";
import { formatGeometries } from "../data/geometryCategory";
import { asApiError } from "../data/errors";
@@ -1335,15 +1335,24 @@ function OrphanBlockersDialog({
readonly framesListed: boolean;
readonly onClose: () => void;
}): JSX.Element {
+ // Radix mints one description id per dialog root, so a dialog with several
+ // `DialogDescription`s needs each one named here — `label_class` can hold a
+ // space, which a `space`-joined `aria-describedby` cannot, so `uid` prefixes
+ // an index rather than the class name itself.
+ const uid = useId();
+ const blockerIds = blockers?.map((_blocker, index) => `${uid}-blocker-${index}`) ?? [];
+ const noteId = `${uid}-note`;
return (
!next && onClose()}>
-
+
Annotations already use these classes
- {blockers?.map((blocker) => (
- {describeClassCount(blocker)}
+ {blockers?.map((blocker, index) => (
+
+ {describeClassCount(blocker)}
+
))}
-
+
There is no override for this one. Keep the class, or clear those labels first
{framesListed ? " — the frames carrying them are under “Frames in the way”, below the editor" : ""}.
@@ -1392,21 +1401,28 @@ function DestructiveDialog({
readonly onConfirm: () => void;
}): JSX.Element {
const destructiveClasses = preview?.diff.destructive_classes ?? [];
+ const uid = useId();
+ const summaryId = `${uid}-summary`;
+ const noRegressionId = `${uid}-no-regression`;
+ const openBatchId = `${uid}-open-batch`;
return (
!next && onCancel()}>
-
+
This narrows the schema
-
+
{formatCount(destructiveClasses.length)} {" "}
{destructiveClasses.length === 1 ? "class narrows" : "classes narrow"}:{" "}
{describeDestructiveClasses(destructiveClasses)}.
-
+
No existing annotation becomes invalid — that is why this can be published at all.
Publishing adds a new version; earlier versions keep what they declared.
-
+
A batch still open on the current version keeps writing what that version declares,
including what this one drops. Those labels are kept — but a release cannot be published
while they are in it.
From 89f4d636ec1cd40de45b7b0d7d02048c9d7f9d9e Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Tue, 25 Aug 2026 23:55:28 -0700
Subject: [PATCH 15/55] refactor(ui-core): dropdown menu and tooltip are
shadcn's canonical files, inverted by the preset
---
frontend/app/src/styleguide/Styleguide.tsx | 2 +-
frontend/ui-core/shadcn/dropdown-menu.tsx | 267 ++++++++++++++++++
frontend/ui-core/shadcn/tooltip.tsx | 57 ++++
.../ui-core/src/annotator/AnnotationPage.tsx | 19 +-
.../ui-core/src/annotator/AnnotatorPanel.tsx | 9 +-
.../ui-core/src/annotator/CanvasReassign.tsx | 2 +-
.../ui-core/src/annotator/ReassignMenu.tsx | 2 +-
.../ui-core/src/annotator/ToolPalette.tsx | 2 +-
frontend/ui-core/src/annotator/ZoomWidget.tsx | 2 +-
.../src/annotator/addClassProvenance.test.tsx | 2 +-
.../src/annotator/drawingClass.test.tsx | 2 +-
.../src/annotator/editorNotice.test.tsx | 2 +-
.../src/annotator/frameGallery.test.tsx | 2 +-
frontend/ui-core/src/annotator/panel.test.tsx | 2 +-
.../ui-core/src/annotator/pinBadge.test.tsx | 2 +-
.../src/annotator/suggestFlow.test.tsx | 15 +-
.../src/annotator/toolPalette.test.tsx | 24 +-
.../ui-core/src/annotator/topBar.test.tsx | 2 +-
frontend/ui-core/src/index.ts | 17 +-
frontend/ui-core/src/patterns/ProjectNav.tsx | 4 +-
frontend/ui-core/src/primitives/Menu.tsx | 130 ---------
.../ui-core/src/primitives/dropdown-menu.tsx | 267 ++++++++++++++++++
.../src/primitives/primitives.test.tsx | 14 +-
frontend/ui-core/src/primitives/tooltip.tsx | 57 ++++
frontend/ui-core/src/screens/DeleteBatch.tsx | 4 +-
frontend/ui-core/src/screens/ModelsScreen.tsx | 2 +-
26 files changed, 737 insertions(+), 173 deletions(-)
create mode 100644 frontend/ui-core/shadcn/dropdown-menu.tsx
create mode 100644 frontend/ui-core/shadcn/tooltip.tsx
delete mode 100644 frontend/ui-core/src/primitives/Menu.tsx
create mode 100644 frontend/ui-core/src/primitives/dropdown-menu.tsx
create mode 100644 frontend/ui-core/src/primitives/tooltip.tsx
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index 3f26bcc1..f6d49fdc 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -497,7 +497,7 @@ export function Styleguide(): JSX.Element {
Rename
Duplicate schema
- Delete
+ Delete
diff --git a/frontend/ui-core/shadcn/dropdown-menu.tsx b/frontend/ui-core/shadcn/dropdown-menu.tsx
new file mode 100644
index 00000000..98f8a975
--- /dev/null
+++ b/frontend/ui-core/shadcn/dropdown-menu.tsx
@@ -0,0 +1,267 @@
+import * as React from "react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/cn"
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/frontend/ui-core/shadcn/tooltip.tsx b/frontend/ui-core/shadcn/tooltip.tsx
new file mode 100644
index 00000000..aef6c65c
--- /dev/null
+++ b/frontend/ui-core/shadcn/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/cn"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index 9affb44e..ad237cc2 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -164,10 +164,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "../primitives/Menu";
+} from "../primitives/dropdown-menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../primitives/tooltip";
import { AnnotatorPanel } from "./AnnotatorPanel";
import { CanvasReassign } from "./CanvasReassign";
import { EditorNotice, EditorNotices } from "./EditorNotice";
@@ -2410,7 +2408,18 @@ function Workspace({
-
+ {/*
+ Canonical `DropdownMenuContent` animates its exit; DESIGN.md's
+ *Motion* is why this one may not. While that animation plays, Radix
+ keeps the dismissable layer mounted, so a press on the trigger
+ straight after `Escape` is read as both an open and an
+ outside-dismiss and the menu never appears — `annotate.spec.ts`
+ holds the two presses that catch it coming back.
+ `animate-none!` beats the canonical `animate-out` regardless of
+ which rule Tailwind emits first, since `tailwind-merge` does not
+ know the two share a group and leaves both classes standing.
+ */}
+
{/* `Save and stay`, reabsorbed — `xl:hidden` is the exact inverse of
the button's `hidden xl:inline-flex`, so the control exists once
at every width. Gated on `frameVerbs` for the same reason the
diff --git a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
index e08f8670..cc1ff12d 100644
--- a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
+++ b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
@@ -89,14 +89,9 @@ import { geometryLabel } from "../data/geometryCategory";
import { classColor } from "../palette";
import { Badge } from "../primitives/badge";
import { Button } from "../primitives/button";
+import { DropdownMenu, DropdownMenuTrigger } from "../primitives/dropdown-menu";
import { Input } from "../primitives/input";
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "../primitives/Menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../primitives/tooltip";
import { ClassRegion } from "./ClassRegion";
import { ReassignMenu } from "./ReassignMenu";
import { cn } from "../lib/cn";
diff --git a/frontend/ui-core/src/annotator/CanvasReassign.tsx b/frontend/ui-core/src/annotator/CanvasReassign.tsx
index 174943b6..d39bf493 100644
--- a/frontend/ui-core/src/annotator/CanvasReassign.tsx
+++ b/frontend/ui-core/src/annotator/CanvasReassign.tsx
@@ -58,7 +58,7 @@ import { Tag } from "lucide-react";
import type { JSX } from "react";
import { Button } from "../primitives/button";
-import { DropdownMenu, DropdownMenuTrigger } from "../primitives/Menu";
+import { DropdownMenu, DropdownMenuTrigger } from "../primitives/dropdown-menu";
import { ReassignMenu } from "./ReassignMenu";
/**
diff --git a/frontend/ui-core/src/annotator/ReassignMenu.tsx b/frontend/ui-core/src/annotator/ReassignMenu.tsx
index bca1d259..f49c1cb3 100644
--- a/frontend/ui-core/src/annotator/ReassignMenu.tsx
+++ b/frontend/ui-core/src/annotator/ReassignMenu.tsx
@@ -52,7 +52,7 @@ import type { JSX, KeyboardEvent } from "react";
import { formatGeometries } from "../data/geometryCategory";
import { classColor } from "../palette";
-import { DropdownMenuContent, DropdownMenuItem } from "../primitives/Menu";
+import { DropdownMenuContent, DropdownMenuItem } from "../primitives/dropdown-menu";
export interface ReassignMenuProps {
/** The object whose class is being changed. Its geometry decides what fits. */
diff --git a/frontend/ui-core/src/annotator/ToolPalette.tsx b/frontend/ui-core/src/annotator/ToolPalette.tsx
index c9ec8228..65057af1 100644
--- a/frontend/ui-core/src/annotator/ToolPalette.tsx
+++ b/frontend/ui-core/src/annotator/ToolPalette.tsx
@@ -100,7 +100,7 @@ import { GeometryIcon } from "./GeometryIcon";
import type { JSX, MouseEvent, ReactNode } from "react";
import { Button } from "../primitives/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "../primitives/Menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../primitives/tooltip";
/**
* Why a declared geometry has no tool yet, keyed by the geometry.
diff --git a/frontend/ui-core/src/annotator/ZoomWidget.tsx b/frontend/ui-core/src/annotator/ZoomWidget.tsx
index eb94ff94..02bff94d 100644
--- a/frontend/ui-core/src/annotator/ZoomWidget.tsx
+++ b/frontend/ui-core/src/annotator/ZoomWidget.tsx
@@ -29,7 +29,7 @@ import { Maximize2, Minimize2, Minus, Plus, Scan } from "lucide-react";
import { useEffect, useState, type JSX } from "react";
import { Button } from "../primitives/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "../primitives/Menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../primitives/tooltip";
export interface ZoomWidgetProps {
/** The live scale, or `null` before the stage has measured itself. */
diff --git a/frontend/ui-core/src/annotator/addClassProvenance.test.tsx b/frontend/ui-core/src/annotator/addClassProvenance.test.tsx
index 61115f1d..3ae41e01 100644
--- a/frontend/ui-core/src/annotator/addClassProvenance.test.tsx
+++ b/frontend/ui-core/src/annotator/addClassProvenance.test.tsx
@@ -29,7 +29,7 @@ import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
import { Toaster } from "../primitives/Feedback";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { writeToken } from "../data/session";
import { AnnotationPage } from "./AnnotationPage";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
diff --git a/frontend/ui-core/src/annotator/drawingClass.test.tsx b/frontend/ui-core/src/annotator/drawingClass.test.tsx
index b1c24fab..f200a03a 100644
--- a/frontend/ui-core/src/annotator/drawingClass.test.tsx
+++ b/frontend/ui-core/src/annotator/drawingClass.test.tsx
@@ -23,7 +23,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { writeToken } from "../data/session";
import { AnnotationPage } from "./AnnotationPage";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
diff --git a/frontend/ui-core/src/annotator/editorNotice.test.tsx b/frontend/ui-core/src/annotator/editorNotice.test.tsx
index 16ff27d5..9a552dc0 100644
--- a/frontend/ui-core/src/annotator/editorNotice.test.tsx
+++ b/frontend/ui-core/src/annotator/editorNotice.test.tsx
@@ -25,7 +25,7 @@ import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
import { writeToken } from "../data/session";
import { AnnotationPage } from "./AnnotationPage";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
const API = "http://visionset.test";
diff --git a/frontend/ui-core/src/annotator/frameGallery.test.tsx b/frontend/ui-core/src/annotator/frameGallery.test.tsx
index 9c5af4c0..85e42cdc 100644
--- a/frontend/ui-core/src/annotator/frameGallery.test.tsx
+++ b/frontend/ui-core/src/annotator/frameGallery.test.tsx
@@ -29,7 +29,7 @@ import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
import { writeToken } from "../data/session";
import { AnnotationPage } from "./AnnotationPage";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
const API = "http://visionset.test";
diff --git a/frontend/ui-core/src/annotator/panel.test.tsx b/frontend/ui-core/src/annotator/panel.test.tsx
index e601d37d..1e79b6ef 100644
--- a/frontend/ui-core/src/annotator/panel.test.tsx
+++ b/frontend/ui-core/src/annotator/panel.test.tsx
@@ -18,7 +18,7 @@ import { describe, expect, it, vi } from "vitest";
import type { JSX } from "react";
import { AnnotatorPanel } from "./AnnotatorPanel";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
const SCHEMA = {
project_id: "11111111-1111-4111-8111-111111111111",
diff --git a/frontend/ui-core/src/annotator/pinBadge.test.tsx b/frontend/ui-core/src/annotator/pinBadge.test.tsx
index 977d8bb1..765c8dd0 100644
--- a/frontend/ui-core/src/annotator/pinBadge.test.tsx
+++ b/frontend/ui-core/src/annotator/pinBadge.test.tsx
@@ -21,7 +21,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { writeToken } from "../data/session";
import { AnnotationPage } from "./AnnotationPage";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
diff --git a/frontend/ui-core/src/annotator/suggestFlow.test.tsx b/frontend/ui-core/src/annotator/suggestFlow.test.tsx
index d4c4706e..3fd925c1 100644
--- a/frontend/ui-core/src/annotator/suggestFlow.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestFlow.test.tsx
@@ -27,7 +27,7 @@ import { ApiProvider } from "../data/ApiProvider";
import { clearPrefs, writePref } from "../data/prefs";
import { writeToken } from "../data/session";
import { AnnotationPage } from "./AnnotationPage";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
const API = "http://visionset.test";
@@ -244,6 +244,19 @@ beforeEach(() => {
addEventListener: () => {},
removeEventListener: () => {},
}));
+ // Canonical `TooltipProvider` defaults `delayDuration` to 0, so the tool
+ // palette's tooltips now open on the same hover `userEvent.click` produces —
+ // Nova's `TooltipContent` renders a Radix `Arrow`, and the popper measures it
+ // through `@radix-ui/react-use-size`, which reaches for `ResizeObserver`
+ // unconditionally on mount. jsdom has none.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ },
+ );
vi.stubGlobal("fetch", async (request: Request) => {
const path = new URL(request.url).pathname;
if (request.method !== "GET") {
diff --git a/frontend/ui-core/src/annotator/toolPalette.test.tsx b/frontend/ui-core/src/annotator/toolPalette.test.tsx
index 0cc74968..954d74b6 100644
--- a/frontend/ui-core/src/annotator/toolPalette.test.tsx
+++ b/frontend/ui-core/src/annotator/toolPalette.test.tsx
@@ -9,12 +9,32 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JSX } from "react";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { ToolPalette, toolChoices } from "./ToolPalette";
+// Canonical `TooltipProvider` defaults `delayDuration` to 0, so every button
+// press below — each one is a Tooltip trigger — opens its tooltip on the same
+// hover `userEvent.click` produces. Nova's `TooltipContent` renders a Radix
+// `Arrow`, and the popper measures it through `@radix-ui/react-use-size`,
+// which reaches for `ResizeObserver` unconditionally on mount. jsdom has none.
+beforeEach(() => {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ },
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
/**
* Four classes covering all four cases `drawableGeometry` distinguishes: two bbox
* classes (which must collapse to one tool), a polygon, a tag, and a geometry the
diff --git a/frontend/ui-core/src/annotator/topBar.test.tsx b/frontend/ui-core/src/annotator/topBar.test.tsx
index 9f76ed86..28c35cff 100644
--- a/frontend/ui-core/src/annotator/topBar.test.tsx
+++ b/frontend/ui-core/src/annotator/topBar.test.tsx
@@ -23,7 +23,7 @@ import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
import { writeToken } from "../data/session";
import { AnnotationPage, REVIEW_ACTIONS } from "./AnnotationPage";
-import { TooltipProvider } from "../primitives/Menu";
+import { TooltipProvider } from "../primitives/tooltip";
import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js";
const API = "http://visionset.test";
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index d7116569..588edff4 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -84,15 +84,22 @@ export {
} from "./primitives/Select.js";
export {
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "./primitives/Menu.js";
+} from "./primitives/dropdown-menu.js";
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/tooltip.js";
export { Progress, Skeleton, Toaster, toast } from "./primitives/Feedback.js";
export {
Table,
diff --git a/frontend/ui-core/src/patterns/ProjectNav.tsx b/frontend/ui-core/src/patterns/ProjectNav.tsx
index 58161e97..746cfb8c 100644
--- a/frontend/ui-core/src/patterns/ProjectNav.tsx
+++ b/frontend/ui-core/src/patterns/ProjectNav.tsx
@@ -56,7 +56,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "../primitives/Menu";
+} from "../primitives/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs";
/**
@@ -320,7 +320,7 @@ function Overflow({ onRename, onDelete }: ProjectNavProps): JSX.Element | null {
)}
{onRename !== undefined && onDelete !== undefined && }
{onDelete !== undefined && (
-
+
Delete
diff --git a/frontend/ui-core/src/primitives/Menu.tsx b/frontend/ui-core/src/primitives/Menu.tsx
deleted file mode 100644
index 81b63752..00000000
--- a/frontend/ui-core/src/primitives/Menu.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * The dropdown menu and the tooltip — the two overlays a row of icon buttons
- * needs.
- *
- * `TooltipProvider` is exported and must wrap the app **once**: Radix keeps the
- * open/close delay on the provider, so a tooltip mounted without one either warns
- * or opens on a timing of its own. `DESIGN.md` gives the tool strip tooltips that
- * carry the shortcut ("Select (V)"), which only reads as one behaviour if they
- * share a delay.
- */
-
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
-import * as TooltipPrimitive from "@radix-ui/react-tooltip";
-import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
-
-import { cn } from "../lib/cn";
-
-export const DropdownMenu = DropdownMenuPrimitive.Root;
-export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-
-/**
- * The enter animation, and no exit animation — the absence is the load-bearing
- * part.
- *
- * An animated exit keeps Radix's content mounted for its duration after `open`
- * goes false, and the dismissable layer stays mounted with it. A press on the
- * trigger inside that window is then read twice: the trigger toggles the menu
- * open, and the layer still listening reads the same pointer-down as an
- * interaction *outside* itself and dismisses. The two cancel and the menu never
- * appears. A hundred milliseconds is well inside the gap between an `Escape` and
- * the click after it, so a fast hand hit this routinely rather than rarely.
- *
- * `DESIGN.md`'s "fast and quiet" is the principle it broke — motion orients or
- * confirms and never makes anyone wait — so the menu leaves on the frame it is
- * dismissed. Restoring a fade here reintroduces the swallow;
- * `annotate.spec.ts` holds the two presses that prove it.
- */
-const SURFACE =
- "dark z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 " +
- "origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto " +
- "rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 " +
- "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 " +
- "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 " +
- "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95";
-
-export const DropdownMenuContent = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function DropdownMenuContent({ className, sideOffset = 4, ...props }, ref) {
- return (
-
-
-
- );
-});
-
-export const DropdownMenuItem = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef & { readonly destructive?: boolean }
->(function DropdownMenuItem({ className, destructive = false, ...props }, ref) {
- return (
-
- );
-});
-
-export const DropdownMenuSeparator = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function DropdownMenuSeparator({ className, ...props }, ref) {
- return (
-
- );
-});
-
-export const TooltipProvider = TooltipPrimitive.Provider;
-export const Tooltip = TooltipPrimitive.Root;
-export const TooltipTrigger = TooltipPrimitive.Trigger;
-
-export const TooltipContent = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function TooltipContent({ className, sideOffset = 0, children, ...props }, ref) {
- return (
-
-
- {children}
-
-
-
- );
-});
diff --git a/frontend/ui-core/src/primitives/dropdown-menu.tsx b/frontend/ui-core/src/primitives/dropdown-menu.tsx
new file mode 100644
index 00000000..7f5f6fdf
--- /dev/null
+++ b/frontend/ui-core/src/primitives/dropdown-menu.tsx
@@ -0,0 +1,267 @@
+import * as React from "react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "../lib/cn"
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx
index 4e62a2d0..f87349ad 100644
--- a/frontend/ui-core/src/primitives/primitives.test.tsx
+++ b/frontend/ui-core/src/primitives/primitives.test.tsx
@@ -22,16 +22,16 @@ import { Badge } from "./badge";
import { Button } from "./button";
import { Card, CardTitle } from "./card";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
-import { Progress } from "./Feedback";
-import { FieldError } from "./field";
-import { Input } from "./input";
-import { Label } from "./label";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
-} from "./Menu";
+} from "./dropdown-menu";
+import { Progress } from "./Feedback";
+import { FieldError } from "./field";
+import { Input } from "./input";
+import { Label } from "./label";
import {
Select,
SelectContent,
@@ -341,7 +341,9 @@ describe("DropdownMenu", () => {
);
await user.click(screen.getByRole("button", { name: "Actions" }));
const classes = (await screen.findByRole("menu")).className.split(" ");
+ // Canonical also carries `w-(--radix-dropdown-menu-trigger-width)`, so the
+ // surface tracks the trigger's width as a floor rather than an equality —
+ // `min-w-32` is the assertable part of that contract.
expect(classes).toContain("min-w-32");
- expect(classes).not.toContain("w-(--radix-dropdown-menu-trigger-width)");
});
});
diff --git a/frontend/ui-core/src/primitives/tooltip.tsx b/frontend/ui-core/src/primitives/tooltip.tsx
new file mode 100644
index 00000000..badbca95
--- /dev/null
+++ b/frontend/ui-core/src/primitives/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "../lib/cn"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/frontend/ui-core/src/screens/DeleteBatch.tsx b/frontend/ui-core/src/screens/DeleteBatch.tsx
index e3916daa..738aa320 100644
--- a/frontend/ui-core/src/screens/DeleteBatch.tsx
+++ b/frontend/ui-core/src/screens/DeleteBatch.tsx
@@ -47,7 +47,7 @@ import {
DialogTitle,
} from "../primitives/dialog";
import { FieldError } from "../primitives/field";
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../primitives/Menu";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../primitives/dropdown-menu";
import { useDeleteBatch, type Batch } from "./queries";
export interface BatchOverflowMenuProps {
@@ -102,7 +102,7 @@ export function BatchOverflowMenu({
a menu item that cannot be hovered into is a reason nobody reads.
*/}
setConfirming(true)}
diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx
index eb1ce74e..2da74ee1 100644
--- a/frontend/ui-core/src/screens/ModelsScreen.tsx
+++ b/frontend/ui-core/src/screens/ModelsScreen.tsx
@@ -185,7 +185,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
-} from "../primitives/Menu";
+} from "../primitives/dropdown-menu";
import { Input } from "../primitives/input";
import { Label } from "../primitives/label";
import { FieldDescription, FieldError } from "../primitives/field";
From dc379bbb2b182caa8ba03a3feb061585cc713db3 Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Wed, 26 Aug 2026 00:17:55 -0700
Subject: [PATCH 16/55] fix(ui-core): every menu surface leaves on the frame it
is dismissed
---
frontend/app/src/styleguide/Styleguide.tsx | 3 ++-
frontend/ui-core/src/annotator/AnnotationPage.tsx | 14 ++------------
frontend/ui-core/src/annotator/ReassignMenu.tsx | 4 +++-
frontend/ui-core/src/index.ts | 1 +
frontend/ui-core/src/lib/motion.ts | 4 ++++
frontend/ui-core/src/patterns/ProjectNav.tsx | 5 +++--
frontend/ui-core/src/screens/DeleteBatch.tsx | 4 +++-
frontend/ui-core/src/screens/ModelsScreen.tsx | 3 ++-
8 files changed, 20 insertions(+), 18 deletions(-)
create mode 100644 frontend/ui-core/src/lib/motion.ts
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index f6d49fdc..932e3408 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -78,6 +78,7 @@ import {
TooltipTrigger,
classColor,
formatGeometries,
+ menuNoExit,
toast,
} from "@visionset/ui-core";
import { MousePointer2, Plus, Square, Trash2 } from "lucide-react";
@@ -493,7 +494,7 @@ export function Styleguide(): JSX.Element {
Actions
-
+
Rename
Duplicate schema
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index ad237cc2..f2ae282c 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -155,6 +155,7 @@ import {
import type { OpenMember } from "../generated/api.js";
import { asApiError } from "../data/errors";
import { refusalProse } from "../data/refusals";
+import { menuNoExit } from "../lib/motion";
import { EmptyState, ErrorState, LoadingState } from "../patterns/AsyncStates";
import { Badge } from "../primitives/badge";
import { Button } from "../primitives/button";
@@ -2408,18 +2409,7 @@ function Workspace({
- {/*
- Canonical `DropdownMenuContent` animates its exit; DESIGN.md's
- *Motion* is why this one may not. While that animation plays, Radix
- keeps the dismissable layer mounted, so a press on the trigger
- straight after `Escape` is read as both an open and an
- outside-dismiss and the menu never appears — `annotate.spec.ts`
- holds the two presses that catch it coming back.
- `animate-none!` beats the canonical `animate-out` regardless of
- which rule Tailwind emits first, since `tailwind-merge` does not
- know the two share a group and leaves both classes standing.
- */}
-
+
{/* `Save and stay`, reabsorbed — `xl:hidden` is the exact inverse of
the button's `hidden xl:inline-flex`, so the control exists once
at every width. Gated on `frameVerbs` for the same reason the
diff --git a/frontend/ui-core/src/annotator/ReassignMenu.tsx b/frontend/ui-core/src/annotator/ReassignMenu.tsx
index f49c1cb3..881ba67b 100644
--- a/frontend/ui-core/src/annotator/ReassignMenu.tsx
+++ b/frontend/ui-core/src/annotator/ReassignMenu.tsx
@@ -51,6 +51,8 @@ import { Check } from "lucide-react";
import type { JSX, KeyboardEvent } from "react";
import { formatGeometries } from "../data/geometryCategory";
+import { cn } from "../lib/cn";
+import { menuNoExit } from "../lib/motion";
import { classColor } from "../palette";
import { DropdownMenuContent, DropdownMenuItem } from "../primitives/dropdown-menu";
@@ -119,7 +121,7 @@ export function ReassignMenu({
}
return (
-
+
{schema.classes.map((declared) => {
const ok = fits(declared);
const current = declared.name === annotation.label_class;
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index 588edff4..d50a2ac7 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -41,6 +41,7 @@ export { CLASS_FILL_OPACITY, classColor } from "./palette.js";
export type { LabelClass } from "./palette.js";
export { cn } from "./lib/cn.js";
+export { menuNoExit } from "./lib/motion.js";
// Primitives — Radix behaviour under shadcn Nova styling, iconed with Tabler.
export { Button, buttonVariants } from "./primitives/button.js";
diff --git a/frontend/ui-core/src/lib/motion.ts b/frontend/ui-core/src/lib/motion.ts
new file mode 100644
index 00000000..9fe90c6f
--- /dev/null
+++ b/frontend/ui-core/src/lib/motion.ts
@@ -0,0 +1,4 @@
+// A menu surface leaves on the frame it is dismissed: while Radix runs an exit
+// animation the dismissable layer stays mounted, and a press that should open
+// the next menu is swallowed as the dismissal of this one (DESIGN.md, Motion).
+export const menuNoExit = "data-closed:animate-none!";
diff --git a/frontend/ui-core/src/patterns/ProjectNav.tsx b/frontend/ui-core/src/patterns/ProjectNav.tsx
index 746cfb8c..30579952 100644
--- a/frontend/ui-core/src/patterns/ProjectNav.tsx
+++ b/frontend/ui-core/src/patterns/ProjectNav.tsx
@@ -49,6 +49,7 @@ import { ChevronDown, Database, Grid3x3, Layers, MoreHorizontal, Network, Pencil
import { cva } from "class-variance-authority";
import type { JSX, MouseEvent, ReactNode } from "react";
+import { menuNoExit } from "../lib/motion";
import { Button } from "../primitives/button";
import {
DropdownMenu,
@@ -282,7 +283,7 @@ function AnnotateAction({
-
+
{targets.map((batch) => (
-
+
{onRename !== undefined && (
diff --git a/frontend/ui-core/src/screens/DeleteBatch.tsx b/frontend/ui-core/src/screens/DeleteBatch.tsx
index 738aa320..f4b645f7 100644
--- a/frontend/ui-core/src/screens/DeleteBatch.tsx
+++ b/frontend/ui-core/src/screens/DeleteBatch.tsx
@@ -38,6 +38,8 @@ import { useState, type JSX } from "react";
import { BATCH_ACTION, declares, withheldBecause } from "../data/capabilities";
import { refusalProse } from "../data/refusals";
+import { cn } from "../lib/cn";
+import { menuNoExit } from "../lib/motion";
import { Button } from "../primitives/button";
import {
Dialog,
@@ -92,7 +94,7 @@ export function BatchOverflowMenu({
-
+
{/*
Disabled-with-reason rather than hidden: there *is* an operation
behind this and a state that would enable it, which is the whole
diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx
index 2da74ee1..5c5c7577 100644
--- a/frontend/ui-core/src/screens/ModelsScreen.tsx
+++ b/frontend/ui-core/src/screens/ModelsScreen.tsx
@@ -160,6 +160,7 @@ import {
} from "../data/inferenceQueries";
import { jobFailureProse, refusalProse } from "../data/refusals";
import { cn } from "../lib/cn";
+import { menuNoExit } from "../lib/motion";
import { ErrorState, LoadingState } from "../patterns/AsyncStates";
import { Badge } from "../primitives/badge";
import { Button } from "../primitives/button";
@@ -551,7 +552,7 @@ export function ConnectionCard({
-
+
{/*
Two checks over the same files, and each label says what its
own check *proves* rather than what it is called.
From 5f8411f67720a1568dc1c1089ba4e09c1ef2591a Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Wed, 26 Aug 2026 00:41:16 -0700
Subject: [PATCH 17/55] refactor(ui-core): the select is shadcn's canonical
file, and a two-line option is composed at the call site
---
frontend/app/src/styleguide/Styleguide.tsx | 47 ++--
frontend/ui-core/shadcn/select.tsx | 190 ++++++++++++++
.../ui-core/src/annotator/SuggestPanel.tsx | 15 +-
frontend/ui-core/src/index.ts | 10 +-
frontend/ui-core/src/patterns/ClassFields.tsx | 2 +-
frontend/ui-core/src/primitives/Select.tsx | 235 ------------------
.../src/primitives/primitives.test.tsx | 31 ++-
frontend/ui-core/src/primitives/select.tsx | 190 ++++++++++++++
.../ui-core/src/screens/BatchLifecycle.tsx | 2 +-
.../ui-core/src/screens/DatasetScreen.tsx | 2 +-
frontend/ui-core/src/screens/IngestScreen.tsx | 2 +-
frontend/ui-core/src/screens/ModelsScreen.tsx | 33 ++-
.../ui-core/src/screens/PreLabelDialog.tsx | 15 +-
frontend/ui-core/src/screens/SchemaEditor.tsx | 2 +-
14 files changed, 468 insertions(+), 308 deletions(-)
create mode 100644 frontend/ui-core/shadcn/select.tsx
delete mode 100644 frontend/ui-core/src/primitives/Select.tsx
create mode 100644 frontend/ui-core/src/primitives/select.tsx
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index 932e3408..5eb993cd 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -292,36 +292,41 @@ export function Styleguide(): JSX.Element {
A hint, under a field that needs one.
{/*
- The two-line option. Here because it is a primitive variant
- rather than one screen's styling: an option that is an identifier
- plus the facts about it stacks them, and the trigger shows the same
- two lines the list does because Radix renders the selected item's
- own text into it.
+ The two-line option, composed at the call site: an option that is
+ an identifier plus the facts about it stacks them, and the trigger
+ shows the same two lines the list does because Radix renders the
+ selected item's own children into it.
*/}
Model
-
+
-
- facebook/sam2.1-hiera-tiny
+
+
+ facebook/sam2.1-hiera-tiny
+
+ 311.9 MB · tiny — fastest, comfortable on a CPU
+
+
-
- facebook/sam2.1-hiera-base-plus
+
+
+ facebook/sam2.1-hiera-base-plus
+
+ 647.1 MB · base-plus — the balanced default
+
+
-
- facebook/sam2.1-hiera-large
+
+
+ facebook/sam2.1-hiera-large
+
+ 1.8 GB · large — the most accurate, wants a GPU
+
+
diff --git a/frontend/ui-core/shadcn/select.tsx b/frontend/ui-core/shadcn/select.tsx
new file mode 100644
index 00000000..40638e77
--- /dev/null
+++ b/frontend/ui-core/shadcn/select.tsx
@@ -0,0 +1,190 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/cn"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+function Select({
+ ...props
+}: React.ComponentProps
) {
+ return
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx
index 76b77516..5465a24a 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -74,7 +74,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import type { Connection, SuggestBlocker } from "../data/inferenceQueries";
export interface SuggestPanelProps {
@@ -462,7 +462,11 @@ function Through({
}
return (
-
+
@@ -470,8 +474,11 @@ function Through({
// The two-line option: the name people gave it, then the model it
// actually names. Two connections onto the same weights at different
// precisions are otherwise told apart by nothing on screen.
-
- {one.name}
+
+
+ {one.name}
+ {one.model_id}
+
))}
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index d50a2ac7..5786ad66 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -74,15 +74,7 @@ export { Badge, badgeVariants } from "./primitives/badge.js";
export { Alert, AlertAction, AlertDescription, AlertTitle } from "./primitives/alert.js";
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "./primitives/dialog.js";
export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger } from "./primitives/tabs.js";
-export {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectTrigger,
- SelectValue,
-} from "./primitives/Select.js";
+export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from "./primitives/select.js";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
diff --git a/frontend/ui-core/src/patterns/ClassFields.tsx b/frontend/ui-core/src/patterns/ClassFields.tsx
index a7e60e4c..0fe2f8ad 100644
--- a/frontend/ui-core/src/patterns/ClassFields.tsx
+++ b/frontend/ui-core/src/patterns/ClassFields.tsx
@@ -34,7 +34,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import type { AttributeBody, GeometryType, LabelClassBody } from "../screens/queries";
/**
diff --git a/frontend/ui-core/src/primitives/Select.tsx b/frontend/ui-core/src/primitives/Select.tsx
deleted file mode 100644
index 9f3aad0a..00000000
--- a/frontend/ui-core/src/primitives/Select.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * Select — the schema class picker, the partition strategy, the export format.
- *
- * Radix's rather than a native ``, and the reason is `DESIGN.md`'s own
- * side panel: a class picker shows a colour swatch beside each name, and a native
- * option element cannot hold one. Everything else that matters — typeahead, the
- * keyboard, `aria-activedescendant`, closing on `Escape` — comes with it.
- *
- * One rule rides on this component's *callers* rather than on the component:
- * every class control in VisionSet is a picker over the schema, never free text.
- *
- * ## An option can be two lines
- *
- * Some options are an identifier plus the facts about it — a model id, then the
- * one line saying what it is for. On one line that is a sentence long enough to
- * wrap inside a control measured for one line, which reads as squashed text
- * rather than as a choice.
- *
- * So {@link SelectItem} takes an optional `meta`: the children stay the
- * identifier, at the label role, and `meta` goes underneath at the meta role.
- * Because Radix renders the *selected* item's `ItemText` into the trigger, the
- * closed control and the open list are the same two lines by construction — there
- * is no second place to keep in step, which is the whole reason this lives on the
- * primitive rather than at a call site.
- *
- * The trigger grows to fit rather than clipping: `min-h-8` and vertical padding
- * instead of Nova's fixed `data-[size=default]:h-8`. A single-line option still
- * lands on Nova's own control height, and a two-line one grows the control
- * instead of overflowing it. Nothing truncates and nothing ellipsises — an
- * identifier cut off in the middle is not an identifier, which is also why the
- * trigger omits Nova's own `*:data-[slot=select-value]:line-clamp-1`.
- *
- * The padding is `py-1`, which is `Input`'s — and it is `py-1` *because* the
- * height is a minimum rather than a fixed one. Under a fixed `h-8` the padding
- * cannot change the height, so Nova's own `py-2` is free; under `min-h-8` it
- * adds, and one line of `text-sm` (20px) plus `py-2` (16px) plus the border
- * (2px) is a 38px control standing beside a 32px `Input` in the same field row.
- * With `py-1` the minimum governs the one-line case at exactly Nova's 32px and
- * the second line still grows the box. `styleguide.spec.ts` measures both, and
- * measures the trigger against the `Input` next to it.
- *
- * ## The list scrolls; it never runs off the screen
- *
- * Two-line options make a list tall quickly, and a list taller than the room under
- * its trigger used to be **clipped** rather than scrolled: the options past the
- * edge stayed in the DOM and stayed reachable by keyboard, while a pointer had no
- * way to get to them and nothing on screen said they were there. A list that looks
- * complete and is not is worse than either a scrollbar or a shorter list. Measured
- * before the fix, on a 600px-tall window, the list's bottom edge sat at 836px.
- *
- * So the content is bounded by `--radix-select-content-available-height` — Radix's
- * own measurement of the gap between the trigger and the viewport edge, which
- * tracks a window resize and a trigger near the bottom of the screen without a
- * constant here guessing at either — and the viewport scrolls inside it.
- *
- * The scroll buttons are the affordance, not decoration. macOS hides overlay
- * scrollbars until a scroll is already under way, so on a stock Mac a truncated
- * list and a whole one look the same until somebody gambles on a gesture; Radix
- * mounts the buttons only while there is somewhere to scroll to, which is exactly
- * when that ambiguity exists.
- *
- * It is a property of layout under a real viewport, so `models.spec.ts` asserts
- * it in chromium. jsdom reports every height as zero and would agree with any
- * implementation, including the broken one this replaced.
- */
-
-import * as SelectPrimitive from "@radix-ui/react-select";
-import { Check, ChevronDown, ChevronUp } from "lucide-react";
-import {
- forwardRef,
- type ComponentPropsWithoutRef,
- type ElementRef,
- type ReactNode,
-} from "react";
-
-import { cn } from "../lib/cn";
-
-export const Select = SelectPrimitive.Root;
-export const SelectGroup = SelectPrimitive.Group;
-export const SelectValue = SelectPrimitive.Value;
-
-export const SelectTrigger = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function SelectTrigger({ className, children, ...props }, ref) {
- return (
- , which centres its text, and a value that
- // wrapped would centre with it. No `whitespace-nowrap`: that is Nova's
- // one-line contract, and this trigger's is two lines.
- "flex min-h-8 w-full items-center justify-between gap-1.5 rounded-lg border border-input " +
- "bg-transparent py-1 pr-2 pl-2.5 text-left text-sm transition-colors outline-none " +
- "select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 " +
- "disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive " +
- "aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground " +
- "dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 " +
- "dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 " +
- "[&_svg:not([class*='size-'])]:size-4",
- className,
- )}
- {...props}
- >
- {children}
-
-
-
-
- );
-});
-
-export const SelectContent = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function SelectContent({ className, children, position = "popper", ...props }, ref) {
- return (
-
-
- {/*
- `overflow-hidden` above is what rounds the corners, and on its own it
- silently clipped anything past the bottom edge. The bound and the
- scrolling viewport are what turn that into a scroll; the module note
- above carries the argument and the measurement. Nova's own recipe
- scrolls the content element itself — this keeps the split deliberately,
- because it is the fix `models.spec.ts` asserts.
- */}
-
-
-
-
- {children}
-
-
-
-
-
-
- );
-});
-
-export const SelectItem = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef & {
- /**
- * A second line under the option, for the facts about it.
- *
- * Inside `ItemText`, so the trigger shows the same two lines the list does.
- * Absent leaves the one-line option exactly as it was.
- */
- readonly meta?: ReactNode;
- }
->(function SelectItem({ className, children, meta, ...props }, ref) {
- return (
-
-
- {meta === undefined ? (
- children
- ) : (
- // `break-words` and no `truncate`: a long identifier wraps onto a third
- // line rather than losing its end, because half a model id is not one.
-
- {children}
- {meta}
-
- )}
-
-
-
-
-
-
-
- );
-});
-
-export const SelectLabel = forwardRef<
- ElementRef,
- ComponentPropsWithoutRef
->(function SelectLabel({ 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 f87349ad..9f5e9825 100644
--- a/frontend/ui-core/src/primitives/primitives.test.tsx
+++ b/frontend/ui-core/src/primitives/primitives.test.tsx
@@ -38,7 +38,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "./Select";
+} from "./select";
import { Table, TableBody, TableHead, TableHeader, TableRow } from "./table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
@@ -116,24 +116,28 @@ describe("fields", () => {
});
/**
- * The two-line option — a primitive variant rather than one screen's styling,
- * which is why it is asserted here.
+ * The two-line option — composed at the call site rather than a primitive prop,
+ * which is why what is asserted here is the trigger's own behaviour and not a
+ * layout this file would otherwise have to keep in step with a call site.
*
* The claim worth a test is the one that is easy to lose: the trigger shows the
* *same* two lines the list does, because Radix renders the selected item's own
- * `ItemText` into it. A second copy of the layout at the call site would look
+ * children into it. A second copy of the layout at the call site would look
* identical the day it was written and drift the day either half moved.
*/
describe("Select", () => {
function pickOne(): JSX.Element {
return (
-
+
-
- org/model-tiny
+
+
+ org/model-tiny
+ 311.9 MB · tiny
+
org/model-large
@@ -164,21 +168,14 @@ describe("Select", () => {
expect(trigger.className).not.toContain("truncate");
});
- it("leaves an option with no meta exactly as it was", async () => {
- render(pickOne());
- await userEvent.click(screen.getByTestId("model"));
- const plain = screen.getByRole("option", { name: "org/model-large" });
- expect(plain.querySelector(".text-muted-foreground")).toBeNull();
- });
-
it("floors the open list at the closed control's width", async () => {
render(pickOne());
await userEvent.click(screen.getByTestId("model"));
const viewport = document.querySelector("[data-radix-select-viewport]");
expect(viewport).not.toBeNull();
- const classes = (viewport as HTMLElement).className.split(" ");
- expect(classes).toContain("w-full");
- expect(classes).toContain("min-w-(--radix-select-trigger-width)");
+ const className = (viewport as HTMLElement).className;
+ expect(className).toContain("w-full");
+ expect(className).toContain("min-w-(--radix-select-trigger-width)");
});
});
diff --git a/frontend/ui-core/src/primitives/select.tsx b/frontend/ui-core/src/primitives/select.tsx
new file mode 100644
index 00000000..115b420d
--- /dev/null
+++ b/frontend/ui-core/src/primitives/select.tsx
@@ -0,0 +1,190 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "../lib/cn"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/frontend/ui-core/src/screens/BatchLifecycle.tsx b/frontend/ui-core/src/screens/BatchLifecycle.tsx
index 2a91d3e2..41646243 100644
--- a/frontend/ui-core/src/screens/BatchLifecycle.tsx
+++ b/frontend/ui-core/src/screens/BatchLifecycle.tsx
@@ -45,7 +45,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import { annotatedShare, outstandingWork } from "./batchState";
import {
useApproveBatch,
diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx
index 1a54af50..a389119f 100644
--- a/frontend/ui-core/src/screens/DatasetScreen.tsx
+++ b/frontend/ui-core/src/screens/DatasetScreen.tsx
@@ -78,7 +78,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table";
import { EmptyState, ErrorState } from "../patterns/AsyncStates";
import { AssetThumbnail } from "./AssetThumbnail";
diff --git a/frontend/ui-core/src/screens/IngestScreen.tsx b/frontend/ui-core/src/screens/IngestScreen.tsx
index c06a7975..2bf29755 100644
--- a/frontend/ui-core/src/screens/IngestScreen.tsx
+++ b/frontend/ui-core/src/screens/IngestScreen.tsx
@@ -153,7 +153,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table";
import { SchemaForeshadow } from "./SchemaForeshadow";
import { ClipRangeTimeline } from "./ClipRangeTimeline";
diff --git a/frontend/ui-core/src/screens/ModelsScreen.tsx b/frontend/ui-core/src/screens/ModelsScreen.tsx
index 5c5c7577..908d5526 100644
--- a/frontend/ui-core/src/screens/ModelsScreen.tsx
+++ b/frontend/ui-core/src/screens/ModelsScreen.tsx
@@ -198,7 +198,7 @@ import {
SelectLabel,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import {
CUSTOM_MODEL,
DEVICES,
@@ -1313,7 +1313,11 @@ function ConnectionForm({
) : (
<>
-
+
@@ -1328,22 +1332,25 @@ function ConnectionForm({
harder to read than a stacked one.
*/}
{group.entries.map((entry) => (
-
- {entry.model_id}
+
+
+ {entry.model_id}
+
+ {entry.hint}
+
+
))}
))}
-
- Custom model…
+
+
+ Custom model…
+
+ Any model id, at a revision you pin yourself
+
+
diff --git a/frontend/ui-core/src/screens/PreLabelDialog.tsx b/frontend/ui-core/src/screens/PreLabelDialog.tsx
index be0744cb..1d54b9d2 100644
--- a/frontend/ui-core/src/screens/PreLabelDialog.tsx
+++ b/frontend/ui-core/src/screens/PreLabelDialog.tsx
@@ -111,7 +111,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import type { BadgeTone, Segment } from "./batchState";
import type { KnownMembers } from "../generated/api";
import {
@@ -469,13 +469,20 @@ export function PreLabelSettings({
) : (
-
+
{candidates.map((one) => (
-
- {one.name}
+
+
+ {one.name}
+ {connectionMeta(one)}
+
))}
diff --git a/frontend/ui-core/src/screens/SchemaEditor.tsx b/frontend/ui-core/src/screens/SchemaEditor.tsx
index f6b37453..bc9b28ed 100644
--- a/frontend/ui-core/src/screens/SchemaEditor.tsx
+++ b/frontend/ui-core/src/screens/SchemaEditor.tsx
@@ -125,7 +125,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "../primitives/Select";
+} from "../primitives/select";
import type {
ClassCount,
DraftLabelClassBody,
From f7f5339274d836f3c501250008bb2842b8cc148b Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Wed, 26 Aug 2026 01:06:01 -0700
Subject: [PATCH 18/55] fix(ui-core): a two-line select option is neither
clamped nor squashed in its trigger
---
frontend/app/src/styleguide/Styleguide.tsx | 3 ++-
frontend/ui-core/src/annotator/SuggestPanel.tsx | 4 +++-
frontend/ui-core/src/index.ts | 1 +
frontend/ui-core/src/lib/select.ts | 4 ++++
frontend/ui-core/src/primitives/primitives.test.tsx | 11 +++++++++--
frontend/ui-core/src/screens/ModelsScreen.tsx | 3 ++-
frontend/ui-core/src/screens/PreLabelDialog.tsx | 3 ++-
7 files changed, 23 insertions(+), 6 deletions(-)
create mode 100644 frontend/ui-core/src/lib/select.ts
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index 5eb993cd..4c2107a2 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -80,6 +80,7 @@ import {
formatGeometries,
menuNoExit,
toast,
+ twoLineTrigger,
} from "@visionset/ui-core";
import { MousePointer2, Plus, Square, Trash2 } from "lucide-react";
import type { JSX, ReactNode } from "react";
@@ -300,7 +301,7 @@ export function Styleguide(): JSX.Element {
Model
-
+
diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx
index 5465a24a..72dbe1bc 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -67,6 +67,8 @@ import { Check, Loader2, Sparkles, TriangleAlert, X } from "lucide-react";
import type { JSX, ReactNode } from "react";
import { EditorNotice } from "./EditorNotice";
+import { cn } from "../lib/cn";
+import { twoLineTrigger } from "../lib/select";
import { Button } from "../primitives/button";
import {
Select,
@@ -463,7 +465,7 @@ function Through({
return (
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index 5786ad66..84e0b421 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -42,6 +42,7 @@ export type { LabelClass } from "./palette.js";
export { cn } from "./lib/cn.js";
export { menuNoExit } from "./lib/motion.js";
+export { twoLineTrigger } from "./lib/select.js";
// Primitives — Radix behaviour under shadcn Nova styling, iconed with Tabler.
export { Button, buttonVariants } from "./primitives/button.js";
diff --git a/frontend/ui-core/src/lib/select.ts b/frontend/ui-core/src/lib/select.ts
new file mode 100644
index 00000000..cc90b6ba
--- /dev/null
+++ b/frontend/ui-core/src/lib/select.ts
@@ -0,0 +1,4 @@
+// A two-line option needs the closed trigger to grow and to stop clamping its
+// value; both overrides name canonical's own variants so the merge replaces them.
+export const twoLineTrigger =
+ "data-[size=default]:h-auto min-h-8 *:data-[slot=select-value]:line-clamp-none";
diff --git a/frontend/ui-core/src/primitives/primitives.test.tsx b/frontend/ui-core/src/primitives/primitives.test.tsx
index 9f5e9825..275dc0ac 100644
--- a/frontend/ui-core/src/primitives/primitives.test.tsx
+++ b/frontend/ui-core/src/primitives/primitives.test.tsx
@@ -17,6 +17,7 @@ import userEvent from "@testing-library/user-event";
import type { JSX } from "react";
import { describe, expect, it } from "vitest";
+import { twoLineTrigger } from "../lib/select";
import { Alert, AlertDescription, AlertTitle } from "./alert";
import { Badge } from "./badge";
import { Button } from "./button";
@@ -129,7 +130,7 @@ describe("Select", () => {
function pickOne(): JSX.Element {
return (
-