From c819acc62ed562954f01822d2d8b2ce1d66094ae Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Mon, 24 Aug 2026 01:48:38 -0700
Subject: [PATCH 1/5] refactor(annotator): the annotation workspace draws
Tabler
The primitives and the screens converged on `@tabler/icons-react` in earlier
phases, which left the annotator as the one place `lucide-react` still drew:
eleven modules, sixty-one call sites, thirty-one distinct glyphs. Every one of
them now imports the same set the rest of the frontend does.
The glyphs are chosen by what they mean here, not by which name matches. Most are
direct, and two deliberately are not. `GeometryIcon` documents that a polygon has
to read as a closed path and a lane as an open one, and the old set inverted that
by calling the polygon's glyph `Spline`; the polygon now takes `IconPolygon` and
the polyline `IconVectorSpline`, so the distinction survives the rename rather
than the spelling doing.
Sizes and accessible names are carried over untouched - `size-6` included, where
the compact geometry is a separate question from which set draws it. Two comments
claimed `DESIGN.md` pins three specific glyphs; it pins none, and they no longer
say so.
---
.../ui-core/src/annotator/AddClassDialog.tsx | 6 +-
.../ui-core/src/annotator/AnnotationPage.tsx | 74 +++++++++----------
.../ui-core/src/annotator/AnnotatorPanel.tsx | 14 ++--
frontend/ui-core/src/annotator/AssetImage.tsx | 4 +-
.../ui-core/src/annotator/CanvasReassign.tsx | 4 +-
.../ui-core/src/annotator/ClassRegion.tsx | 6 +-
.../ui-core/src/annotator/GeometryIcon.tsx | 24 +++---
.../ui-core/src/annotator/ReassignMenu.tsx | 4 +-
.../ui-core/src/annotator/SuggestPanel.tsx | 30 +++++---
.../ui-core/src/annotator/ToolPalette.tsx | 29 ++++----
frontend/ui-core/src/annotator/ZoomWidget.tsx | 10 +--
11 files changed, 106 insertions(+), 99 deletions(-)
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..a5ad3488 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";
@@ -285,7 +285,7 @@ export function AnnotatorPanel({
disabled={drawn.length === 0}
onClick={() => onHiddenChange(allHidden ? new Set() : new Set(drawn.map((o) => o.id)))}
>
- {allHidden ? : }
+ {allHidden ? : }
@@ -429,7 +429,7 @@ function TagRegion({
/>
{declared.name}
{on ? (
-
+
) : (
{hotkeyForClass(schema, declared.name) ?? "—"}
@@ -488,7 +488,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
@@ -593,7 +593,7 @@ function ObjectRow({
data-testid={`object-visibility-${index}`}
onClick={onToggleVisible}
>
- {hidden ? : }
+ {hidden ? : }
);
@@ -643,7 +643,7 @@ function RowReassign({
aria-label={`Reassign object ${index + 1}`}
data-testid={`object-reclass-${index}`}
>
-
+
-
+ 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..0c337d21 100644
--- a/frontend/ui-core/src/annotator/CanvasReassign.tsx
+++ b/frontend/ui-core/src/annotator/CanvasReassign.tsx
@@ -54,7 +54,7 @@ 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";
@@ -126,7 +126,7 @@ export function CanvasReassign({
aria-label={`Reassign the selected ${subject.label_class}`}
data-testid="canvas-reclass"
>
-
+ onAddClass?.("")}
>
-
+
@@ -268,7 +268,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 ? : }
)}
From 0a0b8099630759b905d51b49016c52b04197f91c Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Mon, 24 Aug 2026 01:48:49 -0700
Subject: [PATCH 2/5] test(annotator): the workspace's icon set is held to
Tabler only
Nothing would have noticed a reintroduction. The dependency stays declared while
the annotator's consumers still carry it, so a stray `lucide-react` import
resolves, typechecks, builds and renders - an editor auto-import or a rebase from
a branch that predates the migration is enough, and the glyph would look close
enough to pass review.
The scan reads the whole directory rather than the eleven files that were
migrated, because a list passes for the twelfth file nobody adds to it. Proving
that mattered: run against a list, the gate misses `EditorNotice.tsx`, which
carries no icon today and would inherit one silently. The length assertion is
there so the test cannot go quiet if the directory read ever stops matching.
---
.../ui-core/src/annotator/iconSet.test.ts | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 frontend/ui-core/src/annotator/iconSet.test.ts
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([]);
+ });
+});
From 8ba33f2d3caeb2c5fe6ec7088845113b9817f887 Mon Sep 17 00:00:00 2001
From: YaelAnaya
Date: Mon, 24 Aug 2026 01:58:17 -0700
Subject: [PATCH 3/5] refactor(annotator): the workspace's compact icon buttons
are Nova's icon-xs
Six controls asked for `size="icon"` and then overrode it with `className="size-6"`
- the object row's visibility, delete and reassign triggers, the panel's
show/hide-all, the class region's add, and the canvas picker. `icon-xs` is
`size-6 p-0`, so each of them was spelling the variant out by hand and the
override is what a reader had to notice to know the control's real size.
Nothing moves. `icon-xs` resolves to the geometry the override produced, and the
variant's `svg:size-3` default only applies to icons that carry no size of their
own, which none of these do - the explicit `size-3.5` and `size-4` glyphs are
untouched. `shadow-sm` stays on the canvas picker, where a control floating over
the image needs an edge the panel's buttons do not.
`AssetImage`'s `size-6` is left alone on purpose: it is the glyph in an
error state, not a control's geometry, and there is no button under it.
`CanvasReassign` couples this to arithmetic - `TRIGGER_PX` positions the button
by its own width, so the comment now names the variant that decides it rather
than the class that used to.
---
frontend/ui-core/src/annotator/AnnotatorPanel.tsx | 12 ++++--------
frontend/ui-core/src/annotator/CanvasReassign.tsx | 10 +++++++---
frontend/ui-core/src/annotator/ClassRegion.tsx | 3 +--
3 files changed, 12 insertions(+), 13 deletions(-)
diff --git a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
index a5ad3488..ebb8e8e4 100644
--- a/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
+++ b/frontend/ui-core/src/annotator/AnnotatorPanel.tsx
@@ -278,8 +278,7 @@ export function AnnotatorPanel({