diff --git a/DESIGN.md b/DESIGN.md
index 46dce52c..936e5322 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -176,7 +176,7 @@ primitive targets):
| Button — hover | default `hover:bg-primary/80`; secondary `color-mix(in oklch, var(--secondary), var(--foreground) 5%)`; destructive soft |
| Input | `h-8 rounded-lg border-input px-2.5 text-base md:text-sm`; dark theme `bg-input/30` |
| Badge | `h-5 px-2 text-xs rounded-4xl`; icons `size-3`. The `quiet` variant alone is `rounded-md` — a square, colourless label for a fact read beside other facts, never a state |
-| Menu — surface | `dark` subtree + `bg-popover p-1 rounded-lg ring-1 ring-foreground/10 shadow-md`, `min-w-32`, `duration-100` enter/exit |
+| Menu — surface | `dark` subtree + `bg-popover p-1 rounded-lg ring-1 ring-foreground/10 shadow-md`, `min-w-32`, `duration-100` enter and **no exit** — see *Motion* |
| Menu — item | `px-1.5 py-1 text-sm rounded-md focus:bg-accent focus:text-accent-foreground`; destructive item soft |
| Card | `rounded-xl ring-1 ring-foreground/10 text-sm`; `--card-spacing` = `--spacing(4)` (16px; 12px at the `sm` size); footer `bg-muted/50` |
| Dialog — overlay | `bg-black/10 supports-backdrop-filter:backdrop-blur-xs duration-100` |
@@ -185,7 +185,7 @@ primitive targets):
| Elevation | `ring-1 ring-foreground/10` + a resting shadow — never a coloured border |
`frontend/ui-core/src/primitives/` carries the table itself, not a family resemblance to
-it: the heights, the `ring-3` focus treatment, the `duration-100` enter/exit, the
+it: the heights, the `ring-3` focus treatment, the `duration-100` enter, the
`--card-spacing` variable and Nova's interaction idioms (uniform disabled opacity, soft
destructive, `/80`-opacity hover, the inverted menu subtree) are the primitives' own
declarations. The table is the contract and the primitives are where it is spelled, so a
@@ -248,6 +248,33 @@ which is the only place the composition can be checked: Tailwind renders `ring-3
`box-shadow` layer, so "the ring is there" is a question about a computed shadow list
rather than about a class string.
+## Motion
+
+Motion orients or confirms, and never stands between somebody and their next action —
+*Fast and quiet*, applied. An **enter** animation is free to play: nothing is waiting on
+it, because the surface it introduces did not exist a frame ago. An **exit** animation is
+not, and the difference is not a matter of taste.
+
+**A floating surface leaves on the frame it is dismissed.** While an exit animation runs,
+Radix keeps the content mounted, and the dismissable layer stays mounted with it. A press
+on the trigger inside that window is read twice — the trigger toggles the surface open,
+and the layer still listening reads the same pointer-down as an interaction outside itself
+and dismisses — so the two cancel and the surface never appears. At `duration-100` that
+window covers the gap between an `Escape` and the click after it, which makes it a defect
+a fast hand meets routinely rather than an edge case. The annotation workspace is where
+that tempo is normal, and it is where the behaviour was measured; the fix belongs to the
+primitive because every menu in the product shared the flaw.
+
+So `DropdownMenuContent` animates in and not out, and
+`frontend/app/e2e/annotate.spec.ts` holds the two presses that would catch a fade being
+restored. A surface that genuinely needs an exit — one whose trigger cannot be pressed
+again straight away, as a modal's cannot — may keep one; the tooltip keeps its own,
+because a hover has no toggle to swallow.
+
+`prefers-reduced-motion` sits above all of this: the base layer in `styles.css` collapses
+every animation and transition to a single frame under that query, so none of the above is
+something a component opts into.
+
## Sidebar / Menu
- **`menuColor: inverted`.** Every menu and popover subtree — dropdown content, select
diff --git a/frontend/app/e2e/_frame.ts b/frontend/app/e2e/_frame.ts
index 7ed90e7b..1fc7c4fc 100644
--- a/frontend/app/e2e/_frame.ts
+++ b/frontend/app/e2e/_frame.ts
@@ -294,10 +294,9 @@ export async function expectProgress(page: Page, progress: string): Promise {
await page.getByTestId("more-actions").click();
@@ -305,19 +304,18 @@ export async function openOverflow(page: Page): Promise {
}
/**
- * Escape out of the overflow menu, and wait until it has actually left.
+ * Escape out of the overflow menu, and assert it has actually left.
*
- * Nova's menu surface animates out (`data-closed:animate-out` over `duration-100`),
- * which means Radix keeps the content — and its dismissable layer — mounted for a
- * beat after `open` goes false. A press on the trigger inside that beat hits both
- * handlers at once: the trigger toggles the menu open, and the still-listening
- * layer reads the same pointer-down as an interaction *outside* itself and
- * dismisses. The two cancel, and the menu never appears — which is exactly what
- * reopening the overflow straight after an `Escape` used to run into.
+ * The wait is the assertion that `Escape` closed the menu at all, and it is on the
+ * menu leaving the DOM rather than on a clock — per
+ * `tests/scripts/e2e_discipline.test.mjs`.
*
- * So the close is a state to wait on, not a keystroke to fire and forget. The wait
- * is on the menu leaving the DOM, which is also the assertion that `Escape` closed
- * it at all — no clock anywhere, per `tests/scripts/e2e_discipline.test.mjs`.
+ * It is not a workaround for a surface that leaves slowly. The menu has no exit
+ * animation, deliberately, so there is no window in which it is still mounted and
+ * swallowing the next press — `DESIGN.md`'s *Motion* argues why, and
+ * `annotate.spec.ts` presses the trigger straight after an `Escape` to keep it
+ * true. A scenario that wants to reopen the menu immediately should do exactly
+ * that rather than reach for this.
*/
export async function closeOverflow(page: Page): Promise {
await page.keyboard.press("Escape");
diff --git a/frontend/app/e2e/annotate.spec.ts b/frontend/app/e2e/annotate.spec.ts
index a89317b8..59ff78dd 100644
--- a/frontend/app/e2e/annotate.spec.ts
+++ b/frontend/app/e2e/annotate.spec.ts
@@ -3517,3 +3517,38 @@ test("a press on the suggest panel never reaches the picture underneath", async
await expect(page.getByTestId("suggest-adjustments")).toBeVisible();
expect(asks(sent)).toBe(before);
});
+
+/**
+ * ## Reopening a menu that was just dismissed
+ *
+ * `DESIGN.md`'s *Motion*: a floating surface leaves on the frame it is dismissed,
+ * because an animated exit keeps Radix's dismissable layer mounted and the next
+ * press on the trigger is then read as both an open and an outside-dismiss, which
+ * cancel. These two scenarios are the presses that catch a fade being restored —
+ * both failed against `duration-100` on the way out, the first in four runs of
+ * five and the second in five.
+ *
+ * Neither uses `closeOverflow`. Its wait is what a suite does to stay honest about
+ * a surface that is still leaving; the product's contract is that there is nothing
+ * to wait for.
+ */
+test("the overflow reopens on a press straight after Escape", async ({ page }) => {
+ const sent: Request[] = [];
+ await openJob(page, sent);
+
+ await openOverflow(page);
+ await page.keyboard.press("Escape");
+ await page.getByTestId("more-actions").click();
+ await expect(page.getByRole("menu")).toBeVisible();
+});
+
+test("the overflow reopens on a press straight after a second Escape", async ({ page }) => {
+ const sent: Request[] = [];
+ await openJob(page, sent);
+
+ await openOverflow(page);
+ await page.keyboard.press("Escape");
+ await page.keyboard.press("Escape");
+ await page.getByTestId("more-actions").click();
+ await expect(page.getByRole("menu")).toBeVisible();
+});
diff --git a/frontend/ui-core/src/annotator/AddClassDialog.tsx b/frontend/ui-core/src/annotator/AddClassDialog.tsx
index 934be458..e67e356e 100644
--- a/frontend/ui-core/src/annotator/AddClassDialog.tsx
+++ b/frontend/ui-core/src/annotator/AddClassDialog.tsx
@@ -85,7 +85,7 @@
* where every other refusal here renders.
*/
-import { Plus, X } from "lucide-react";
+import { IconPlus, IconX } from "@tabler/icons-react";
import { useEffect, useRef, useState, type JSX } from "react";
import { asApiError } from "../data/errors";
@@ -634,7 +634,7 @@ export function AddClassDialog({
onBank?.(banked);
}}
>
-
+
))}
@@ -830,7 +830,7 @@ export function AddClassDialog({
disabled={pending || !readyForm}
onClick={addAnother}
>
-
+
Create and add another
⌘↵
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index 6289716a..7741847e 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -132,19 +132,20 @@ import {
} from "@visionset/annotator";
import { AnnotatorStore as Store } from "@visionset/annotator";
import {
- ArrowLeft,
- Check,
- CheckCheck,
- ChevronLeft,
- ChevronRight,
- CircleHelp,
- Grid3x3,
- MonitorSmartphone,
- MoreHorizontal,
- SkipForward,
- TriangleAlert,
- Undo2,
-} from "lucide-react";
+ IconAlertTriangle,
+ IconArrowBackUp,
+ IconArrowLeft,
+ IconCheck,
+ IconChecks,
+ IconChevronLeft,
+ IconChevronRight,
+ IconDevices,
+ IconDots,
+ IconEye,
+ IconHelpCircle,
+ IconLayoutGrid,
+ IconPlayerSkipForward,
+} from "@tabler/icons-react";
import {
useCallback,
useEffect,
@@ -182,7 +183,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "../primitives/Menu";
-import { Eye } from "lucide-react";
import { AnnotatorPanel } from "./AnnotatorPanel";
import { CanvasReassign } from "./CanvasReassign";
import { EditorNotice, EditorNotices } from "./EditorNotice";
@@ -491,7 +491,7 @@ function TooNarrow({
return (
}
+ icon={}
title="This screen is too narrow to annotate on"
description={`Annotating is precision work on a large surface: the editor needs at least ${ANNOTATOR_MIN_VIEWPORT_PX}px of width for the canvas, the tools and the object list to coexist. Rotate to landscape, widen the window, or open this job on a larger screen.`}
{...(onOpenGallery === undefined || destination === undefined
@@ -503,7 +503,7 @@ function TooNarrow({
data-testid="too-narrow-gallery"
onClick={() => onOpenGallery(destination.project_id, destination.id)}
>
-
+
Back to the batch
),
@@ -1926,7 +1926,7 @@ function Workspace({
onClick={() => attempt(onOpenGallery)}
disabled={onOpenGallery === undefined}
>
-
+
{/* The batch's pin, not the project's active version. Named here because
@@ -2038,7 +2038,7 @@ function Workspace({
data-testid="open-gallery"
onClick={() => setGalleryOpen(true)}
>
-
+
{/*
`tabular-nums` is the whole reason this is a separate span rather
@@ -2072,7 +2072,7 @@ function Workspace({
disabled={assetIndex >= assetCount - 1}
onClick={() => go(1)}
>
-
+
@@ -2117,7 +2117,7 @@ function Workspace({
disabled={!declares(asset, ASSET_ACTION.restore) || setProgress.isPending}
onClick={unskip}
>
-
+
Un-skip
) : (
@@ -2129,7 +2129,7 @@ function Workspace({
disabled={!declares(asset, ASSET_ACTION.skip) || setProgress.isPending}
onClick={() => settle("skipped")}
>
-
+
Skip
X
@@ -2222,7 +2222,7 @@ function Workspace({
});
}}
>
-
+
{jobState === "completed" ? "Finished" : "Finish job"}
@@ -2290,7 +2290,7 @@ function Workspace({
onClick={() => go(1)}
>
{flowLabel}
-
+
)}
@@ -2337,7 +2337,7 @@ function Workspace({
disabled={readOnly || !dirty || save.isPending}
onClick={() => attempt()}
>
-
+
Save and stay
@@ -2394,7 +2394,7 @@ function Workspace({
disabled={setProgress.isPending}
onClick={() => settle(reviewAction.progress)}
>
-
+
{reviewAction.label}
@@ -2427,7 +2427,7 @@ function Workspace({
aria-label="More actions"
data-testid="more-actions"
>
-
+
@@ -2444,7 +2444,7 @@ function Workspace({
disabled={readOnly || !dirty || save.isPending}
onSelect={() => attempt()}
>
-
+
Save and stay
)}
@@ -2456,7 +2456,7 @@ function Workspace({
disabled={setProgress.isPending}
onSelect={() => settle(reviewAction.progress)}
>
-
+
{reviewAction.label}
)}
@@ -2472,7 +2472,7 @@ function Workspace({
data-testid="return-to-annotator"
onSelect={() => settle("annotated")}
>
-
+
Return to annotator
{/* Inside the conditional, not above the row below it: the two
@@ -2483,7 +2483,7 @@ function Workspace({
>
)}
setHelpOpen(true)}>
-
+
Keyboard shortcuts
@@ -2510,7 +2510,7 @@ function Workspace({
className="flex shrink-0 items-center gap-2 border-b border-border bg-muted px-3 py-1.5 text-xs text-muted-foreground"
data-testid="readonly-banner"
>
-
+ Viewing only.
{workflowBecause ?? settledBecause}
{/*
@@ -2553,7 +2553,7 @@ function Workspace({
className="flex shrink-0 items-center gap-2 border-b border-destructive/30 bg-destructive/5 px-3 py-1.5 text-xs text-destructive"
data-testid="skipped-notice"
>
-
+
This asset is skipped, so it will not reach the dataset and its annotations will not
count towards the job. Un-skip it to put it back in play.
@@ -2766,7 +2766,7 @@ function Workspace({
}
+ icon={}
title={asApiError(actionRefusal).code}
>
{refusalProse(actionRefusal)}
@@ -3201,7 +3201,7 @@ function SaveState({
// so the exception is retired rather than inherited. The tick still
// carries the meaning on its own — state is never colour alone.
-
+
Saved
);
diff --git a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
index 98b95a9a..ebb8e8e4 100644
--- a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
+++ b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
@@ -82,7 +82,7 @@ import {
type LabelClass,
type Tool,
} from "@visionset/annotator";
-import { Check, Eye, EyeOff, Sparkles, Tag, Trash2 } from "lucide-react";
+import { IconCheck, IconEye, IconEyeOff, IconSparkles, IconTag, IconTrash } from "@tabler/icons-react";
import { useEffect, useRef, useState, type JSX, type RefObject } from "react";
import { geometryLabel } from "../data/geometryCategory";
@@ -278,14 +278,13 @@ export function AnnotatorPanel({
@@ -429,7 +428,7 @@ function TagRegion({
/>
{declared.name}
{on ? (
-
+
) : (
{hotkeyForClass(schema, declared.name) ?? "—"}
@@ -488,7 +487,7 @@ function ModelMark({
aria-label={`Model-produced by ${annotation.model_ref ?? "an unnamed model"}`}
className="flex shrink-0 items-center text-xs text-muted-foreground"
>
-
+
{/* The full reference, which is far too long for the row and is exactly
@@ -587,24 +586,22 @@ function ObjectRow({
)}
);
@@ -638,12 +635,11 @@ function RowReassign({
-
+ The asset’s content could not be loaded.
);
diff --git a/frontend/ui-core/src/annotator/CanvasReassign.tsx b/frontend/ui-core/src/annotator/CanvasReassign.tsx
index 3f5ec28a..26bc4a3a 100644
--- a/frontend/ui-core/src/annotator/CanvasReassign.tsx
+++ b/frontend/ui-core/src/annotator/CanvasReassign.tsx
@@ -54,14 +54,18 @@ import {
useAnnotatorSnapshot,
} from "@visionset/annotator";
import type { AnnotatorStore, Geometry, Viewport } from "@visionset/annotator";
-import { Tag } from "lucide-react";
+import { IconTag } from "@tabler/icons-react";
import type { JSX } from "react";
import { Button } from "../primitives/Button";
import { DropdownMenu, DropdownMenuTrigger } from "../primitives/Menu";
import { ReassignMenu } from "./ReassignMenu";
-/** The trigger's own size, in screen pixels — `size="icon"` at `size-6`. */
+/**
+ * The trigger's own size, in screen pixels — Nova's `icon-xs`, which is 24px
+ * square. The clamps below position the button by this number, so a different
+ * button size here is a silent mispositioning rather than a visible mistake.
+ */
const TRIGGER_PX = 24;
/**
@@ -121,12 +125,12 @@ export function CanvasReassign({
onAddClass?.("")}
>
-
+
@@ -268,7 +267,7 @@ export function ClassRegion({
className="flex h-9 w-full shrink-0 items-center gap-2 border-t border-border px-3 text-left text-sm text-muted-foreground hover:bg-muted"
onClick={() => onAddClass?.(creatable)}
>
-
+ Create class “{creatable}”
)}
diff --git a/frontend/ui-core/src/annotator/GeometryIcon.tsx b/frontend/ui-core/src/annotator/GeometryIcon.tsx
index f3d095f2..e1858876 100644
--- a/frontend/ui-core/src/annotator/GeometryIcon.tsx
+++ b/frontend/ui-core/src/annotator/GeometryIcon.tsx
@@ -1,16 +1,16 @@
/**
* The glyph for a drawable geometry, and there is exactly one of each.
*
- * The tool strip is the only caller. It stays a module of its own because
- * `DESIGN.md` pins these three glyphs as a contract rather than as one
- * component's private detail.
+ * The tool strip is the only caller. It stays a module of its own because there
+ * is exactly one glyph per geometry and that mapping is worth keeping in one
+ * place rather than re-decided at each call site.
*
* Worth knowing before reaching for it elsewhere: a class list went back to words
- * because a square, a spline and a waypoint node are not self-describing at chip
+ * because a square, a closed path and an open path are not self-describing at chip
* size. The strip can afford them — it is five controls learned once.
*/
-import { MousePointer2, Spline, Square, Waypoints } from "lucide-react";
+import { IconPointer, IconPolygon, IconSquare, IconVectorSpline } from "@tabler/icons-react";
import type { JSX } from "react";
export interface GeometryIconProps {
@@ -25,12 +25,12 @@ export interface GeometryIconProps {
readonly className?: string;
}
-/** `DESIGN.md` pins the three icons. */
+/** One glyph per geometry: a closed rectangle, a closed path, an open path, or the pointer. */
export function GeometryIcon({ tool, className = "size-4" }: GeometryIconProps): JSX.Element {
- if (tool === "bbox") return ;
- if (tool === "polygon") return ;
- // A lane is a path, and `Waypoints` is the one icon in the set that reads as an
- // open one — `Spline` is already the polygon's and would say "closed".
- if (tool === "polyline") return ;
- return ;
+ if (tool === "bbox") return ;
+ if (tool === "polygon") return ;
+ // A lane is a path, and it must read as an OPEN one — `IconPolygon` is already
+ // the closed shape above it, and using it here would say "closed" for a lane.
+ if (tool === "polyline") return ;
+ return ;
}
diff --git a/frontend/ui-core/src/annotator/ReassignMenu.tsx b/frontend/ui-core/src/annotator/ReassignMenu.tsx
index bca1d259..29e77783 100644
--- a/frontend/ui-core/src/annotator/ReassignMenu.tsx
+++ b/frontend/ui-core/src/annotator/ReassignMenu.tsx
@@ -47,7 +47,7 @@
import { digitFromCode, hotkeyForClass } from "@visionset/annotator";
import type { Annotation, AnnotationSchema, LabelClass } from "@visionset/annotator";
-import { Check } from "lucide-react";
+import { IconCheck } from "@tabler/icons-react";
import type { JSX, KeyboardEvent } from "react";
import { formatGeometries } from "../data/geometryCategory";
@@ -143,7 +143,7 @@ export function ReassignMenu({
style={{ background: classColor(declared, declared.name) }}
/>
{declared.name}
- {current && }
+ {current && }
{ok ? (
hotkey !== null && (
diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx
index 83e280ba..f47615a1 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -63,7 +63,13 @@ import {
type SuggestionState,
type Detail,
} from "@visionset/annotator";
-import { Check, Loader2, Sparkles, TriangleAlert, X } from "lucide-react";
+import {
+ IconAlertTriangle,
+ IconCheck,
+ IconLoader2,
+ IconSparkles,
+ IconX,
+} from "@tabler/icons-react";
import type { JSX, ReactNode } from "react";
import { EditorNotice } from "./EditorNotice";
@@ -218,7 +224,7 @@ export function SuggestPanel({
*/
if (isParked(session)) {
return (
- }>
+ }>
{heldClass === null
? "Nothing selected to suggest for"
@@ -242,7 +248,7 @@ export function SuggestPanel({
data-testid="suggest-discard"
onClick={onDiscard}
>
-
+
Put the tool away
@@ -259,9 +265,9 @@ export function SuggestPanel({
tone={copy.tone}
icon={
copy.tone === "warn" ? (
-
+
) : (
-
+
)
}
>
@@ -288,7 +294,7 @@ export function SuggestPanel({
if (session.status === "refused") {
return (
- }>
+ }>
That suggestion could not be made
{/* The server's sentence, verbatim. It is the one that carries the
install command when the cause is a missing extra. */}
@@ -309,7 +315,7 @@ export function SuggestPanel({
// lives in `usePendingIndicator` rather than here.
if (session.status === "asking") {
return (
- }>
+ }>
Looking at that…
@@ -328,7 +334,7 @@ export function SuggestPanel({
if (session.status === "none") {
return (
- }>
+ }>
Nothing to suggest there
@@ -369,7 +375,7 @@ export function SuggestPanel({
*/
if (isAcceptable(session)) {
return (
- }>
+ }>
A shape for “{session.labelClass}”
@@ -378,7 +384,7 @@ export function SuggestPanel({
);
@@ -578,7 +578,8 @@ function PaletteButton({
/**
* The strip's own name for the shared glyph.
*
- * The map lives in `GeometryIcon`, because `DESIGN.md` pins the three glyphs.
+ * The map lives in `GeometryIcon`, so one glyph per geometry is decided once
+ * rather than at each call site.
*/
function ToolIcon({ tool }: { readonly tool: ToolChoice["tool"] }): JSX.Element {
return ;
diff --git a/frontend/ui-core/src/annotator/ZoomWidget.tsx b/frontend/ui-core/src/annotator/ZoomWidget.tsx
index 410236fc..94524044 100644
--- a/frontend/ui-core/src/annotator/ZoomWidget.tsx
+++ b/frontend/ui-core/src/annotator/ZoomWidget.tsx
@@ -25,7 +25,7 @@
* a person could change to get it.
*/
-import { Maximize2, Minimize2, Minus, Plus, Scan } from "lucide-react";
+import { IconMaximize, IconMinimize, IconMinus, IconPlus, IconScan } from "@tabler/icons-react";
import { useEffect, useState, type JSX } from "react";
import { Button } from "../primitives/Button";
@@ -70,7 +70,7 @@ export function ZoomWidget({
reason={floorReason}
onClick={onZoomOut}
>
-
+
{/* The stage's own scale, capped by it — so the ceiling reads exactly
@@ -89,7 +89,7 @@ export function ZoomWidget({
reason={ceilingReason}
onClick={onZoomIn}
>
-
+
@@ -97,7 +97,7 @@ export function ZoomWidget({
{/* The same implementation `mod+0` reaches, which is why that chord stays
intercepted rather than forwarded to the host. */}
-
+
{fullscreen !== null && (
@@ -108,7 +108,7 @@ export function ZoomWidget({
reason=""
onClick={fullscreen.toggle}
>
- {fullscreen.active ? : }
+ {fullscreen.active ? : }
)}
diff --git a/frontend/ui-core/src/annotator/iconSet.test.ts b/frontend/ui-core/src/annotator/iconSet.test.ts
new file mode 100644
index 00000000..f6c4ca26
--- /dev/null
+++ b/frontend/ui-core/src/annotator/iconSet.test.ts
@@ -0,0 +1,48 @@
+/**
+ * The annotation workspace draws Tabler, and nothing else.
+ *
+ * `DESIGN.md`'s icon rule names this directory as the last place `lucide-react`
+ * survived: the primitives and the screens were converged first, and the
+ * annotator's own glyphs came last. Once migrated, the interesting failure is not
+ * a wrong glyph but a *reintroduction* — a new file importing the old set because
+ * a neighbouring branch still did, or because an editor auto-imported it while
+ * both packages sat in `node_modules`. Nothing else in the repository would
+ * notice: the dependency stays declared until the annotator's consumers drop it,
+ * so a stray import resolves, builds, and renders.
+ *
+ * The scan is over the whole directory rather than a list of the eleven files
+ * that were migrated. A list would pass for a twelfth file nobody added to it,
+ * which is the failure mode a gate exists to prevent, and the emptiness
+ * assertion below is what stops the scan from proving nothing if the glob ever
+ * stops matching.
+ */
+
+import { readdirSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+/**
+ * `import.meta.url` is an `http://localhost/` URL under jsdom, so the path
+ * resolves from the package root vitest runs in — the reason `ProjectNav`'s own
+ * Tabler assertion spells it this way too.
+ */
+const ANNOTATOR = resolve(process.cwd(), "src/annotator");
+
+/** Every production module here — the tests themselves are not the contract. */
+function productionSources(): readonly string[] {
+ return readdirSync(ANNOTATOR)
+ .filter((name) => /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name))
+ .sort();
+}
+
+describe("the annotation workspace's icon set", () => {
+ it("draws Tabler only, in every production module", () => {
+ const sources = productionSources();
+ expect(sources.length).toBeGreaterThan(0);
+
+ const offenders = sources.filter((name) =>
+ readFileSync(resolve(ANNOTATOR, name), "utf8").includes("lucide-react"),
+ );
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/frontend/ui-core/src/primitives/Menu.tsx b/frontend/ui-core/src/primitives/Menu.tsx
index 25cbdddf..81b63752 100644
--- a/frontend/ui-core/src/primitives/Menu.tsx
+++ b/frontend/ui-core/src/primitives/Menu.tsx
@@ -18,14 +18,30 @@ 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-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 " +
- "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95";
+ "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95";
export const DropdownMenuContent = forwardRef<
ElementRef,