diff --git a/.agents/skills/frontend/nodejs-setup/SKILL.md b/.agents/skills/frontend/nodejs-setup/SKILL.md
index 8c757377..f5eb1dd7 100644
--- a/.agents/skills/frontend/nodejs-setup/SKILL.md
+++ b/.agents/skills/frontend/nodejs-setup/SKILL.md
@@ -64,8 +64,8 @@ pnpm add -w -D # root tooling only
- `@visionset/annotator` keeps `react` as an **optional peer dependency** — never promote it to
a hard dependency; the core must stay usable without React.
- `@visionset/ui-core` UI primitives are **Radix behaviour + shadcn Nova styling, iconed with
- Tabler**. Do not add another component library, and do not add a second icon set:
- `@tabler/icons-react` is the only one the frontend declares, and a gate refuses another.
+ lucide**. Do not add another component library, and do not add a second icon set:
+ `lucide-react` is the only one the frontend declares, and a gate refuses another.
- `@visionset/app` is `private: true` — never published to npm; its bundle ships inside the
Python wheel.
diff --git a/AGENTS.md b/AGENTS.md
index 1e391beb..19da2040 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -64,7 +64,7 @@ client of the same SDK, and the release artifact is a plain `pip` package.
| Component | Location | Stack |
| --- | --- | --- |
| Python distribution | `src/visionset/` | Python 3.12+, pydantic v2, FastAPI, Typer, MCP, SQLAlchemy, uv |
-| Frontend workspace | `frontend/` | Node 24, pnpm, TypeScript, React 19, Vite, vitest, Radix + Tabler |
+| Frontend workspace | `frontend/` | Node 24, pnpm, TypeScript, React 19, Vite, vitest, Radix + lucide |
| Dev infra | `docker/` | Docker Compose (dev only) |
See `README.md` for the monorepo map and `CONTRIBUTING.md` for the full check list.
diff --git a/DESIGN.md b/DESIGN.md
index 5b7f8e32..b646a3f6 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -27,6 +27,13 @@ that CLI version is the reference for every value in this document. Nothing here
invented — every token, every derived radius, every chart colour traces back to the
CLI's own output.
+**One property deliberately diverges: the icon set.** The preset decodes
+`icons: tabler`, and that line above says so because it is what the code decodes to;
+the product draws `lucide-react`, and `components.json` says `iconLibrary: lucide`.
+The schema accepts the value, so this is a decision rather than drift — but it is the
+one place where reading the preset and reading the configuration give different
+answers, and it is written here so nobody has to discover that by diffing them.
+
`@visionset/ui-core` owns the implementation, in exactly three files:
| File | Role |
@@ -409,11 +416,13 @@ First-class, and part of every rule above rather than a section to satisfy after
- **Ad-hoc geometry that fights Nova.** A control's height, padding, or radius is not a
per-screen decision; reaching past the geometry table above for a bespoke size is a
design decision to make in this document, not in a component diff.
-- **Mixing icon sets in new code.** Tabler is the set, and now the only one: every icon
- the primitives, the screens and the annotation workspace draw is
- `@tabler/icons-react`, no package declares a second icon library, and
- `tests/scripts/design_tokens.test.mjs` refuses one that reappears in a manifest or an
- import. A second set is a decision to make in this document, not a dependency to add.
+- **Mixing icon sets in new code.** `lucide-react` is the set, and the only one: every
+ icon the primitives, the screens and the annotation workspace draw comes from it, no
+ package declares a second icon library, and `tests/scripts/design_tokens.test.mjs`
+ refuses one that reappears in a manifest or an import. The rule is *one* set rather
+ than one particular set — what costs a reader is two of them on a screen, where the
+ same idea arrives at two weights and two grids. Changing which one is a decision to
+ make in this document, not a dependency to add.
- **Brand in a functional control.** Robomous coral is identity — the wordmark and its
styleguide swatch, nothing else. A functional control reaching for `brand` is a
semantic-colour violation regardless of how many other sites already use it correctly.
diff --git a/docs/content/architecture/frontend/ui-core.md b/docs/content/architecture/frontend/ui-core.md
index f74fc618..2f2019d7 100644
--- a/docs/content/architecture/frontend/ui-core.md
+++ b/docs/content/architecture/frontend/ui-core.md
@@ -79,12 +79,15 @@ Geist heading fonts, radius `medium`, menu `inverted`/`subtle`) - the CLI's own
generated output, transcribed verbatim, plus five VisionSet extension roles
(`stage`, `brand`, `success`, `warning`, `origin-*`) added through shadcn's own
extension convention. `components.json` (`style: "radix-nova"`,
-`iconLibrary: "tabler"`, `menuColor: "inverted"`, `menuAccent: "subtle"`) holds
+`iconLibrary: "lucide"`, `menuColor: "inverted"`, `menuAccent: "subtle"`) holds
the preset properties shadcn's own tools read - the fields its config schema
defines, and no others. The schema is strict, so the properties it has no field
for - the radius, the fonts, the chart palette, every colour - are values
carried by `styles.css` instead; see [`DESIGN.md`](../../../../DESIGN.md)'s
-Source of Truth for the three layers. `tokens.ts` is the TypeScript mirror for a caller that
+Source of Truth for the three layers. The icon set is the one place the
+configuration and the preset disagree on purpose: the preset decodes `tabler`, the
+product draws `lucide-react`, and `iconLibrary` records the product rather than the
+preset. `tokens.ts` is the TypeScript mirror for a caller that
cannot read CSS. Both themes - light and dark - are declared in full from the
preset, so `bg-primary` in a component here and `bg-primary` in a screen mean
the same colour by construction. There is no `tailwind.config.js` in this
@@ -106,7 +109,7 @@ visual-design rule. The current choices:
| Concern | Choice |
| --- | --- |
| UI primitives | Radix (+ shadcn-style composition with `cva` and `cn`) - the open-code shadcn maintenance model is the direction: a primitive is VisionSet-owned source in `frontend/ui-core/src/primitives/`, edited directly, not a package dependency upgraded blindly |
-| Icons | `@tabler/icons-react`, and nothing else: the primitives, the screens and the annotation workspace all draw from it, and no package declares a second icon library |
+| Icons | `lucide-react`, and nothing else: the primitives, the screens and the annotation workspace all draw from it, and no package declares a second icon library |
| Styling | Tailwind v4, CSS-first `@theme`, on the shadcn preset `b3bXyyPdWj` - no `tailwind.config.js`, ever |
| Toasts | sonner |
| Component tests | vitest + jsdom + @testing-library/react |
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-dark-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-dark-visual-linux.png
index bf1993e4..6ae82a8b 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-dark-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-dark-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-light-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-light-visual-linux.png
index a3fc21ff..333daeec 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-light-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/annotator-desktop-light-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-dark-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-dark-visual-linux.png
index 93eeed36..be5eac50 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-dark-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-dark-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-light-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-light-visual-linux.png
index 5720bfec..7da1e4d7 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-light-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/menu-open-light-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-dark-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-dark-visual-linux.png
index 96fc29e2..e80b8e32 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-dark-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-dark-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-light-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-light-visual-linux.png
index 780b8fa3..10683c6f 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-light-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/models-desktop-light-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-dark-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-dark-visual-linux.png
index c58e8660..aa9b1292 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-dark-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-dark-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-light-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-light-visual-linux.png
index 4e610f2d..f3ccd00c 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-light-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/projects-desktop-light-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/schema-desktop-dark-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/schema-desktop-dark-visual-linux.png
index 8b00f4d0..d7d4ca99 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/schema-desktop-dark-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/schema-desktop-dark-visual-linux.png differ
diff --git a/frontend/app/e2e/visual.spec.ts-snapshots/schema-narrow-light-visual-linux.png b/frontend/app/e2e/visual.spec.ts-snapshots/schema-narrow-light-visual-linux.png
index a442b967..18e8ff0f 100644
Binary files a/frontend/app/e2e/visual.spec.ts-snapshots/schema-narrow-light-visual-linux.png and b/frontend/app/e2e/visual.spec.ts-snapshots/schema-narrow-light-visual-linux.png differ
diff --git a/frontend/app/package.json b/frontend/app/package.json
index cdfa9a2b..273dd79f 100644
--- a/frontend/app/package.json
+++ b/frontend/app/package.json
@@ -17,9 +17,9 @@
"cycle": "playwright test -c playwright.cycle.config.ts"
},
"dependencies": {
- "@tabler/icons-react": "^3.46.0",
"@visionset/annotator": "workspace:*",
"@visionset/ui-core": "workspace:*",
+ "lucide-react": "^1.32.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^8.3.0"
diff --git a/frontend/app/src/shell/AppShell.tsx b/frontend/app/src/shell/AppShell.tsx
index e0734198..17712eb5 100644
--- a/frontend/app/src/shell/AppShell.tsx
+++ b/frontend/app/src/shell/AppShell.tsx
@@ -82,14 +82,7 @@
* right level for it.
*/
-import {
- IconCpu,
- IconFolders,
- IconHome,
- IconLayoutSidebarLeftCollapse,
- IconLayoutSidebarLeftExpand,
- IconLogout,
-} from "@tabler/icons-react";
+import { Cpu, Folders, House, LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import {
Button,
buttonVariants,
@@ -145,21 +138,21 @@ export function AppShell(): JSX.Element {
onClick={toggle}
>
{collapsed ? (
-
+
) : (
-
+
)}
-
+
-
+
-
+
@@ -243,7 +236,7 @@ function SignOut({ collapsed }: { readonly collapsed: boolean }): JSX.Element {
const label = access === "session" ? "Use a token" : "Sign out";
return (
-
+
{!collapsed && {label}}
);
diff --git a/frontend/app/src/shell/NotFound.tsx b/frontend/app/src/shell/NotFound.tsx
index 4c6a133a..a57d8462 100644
--- a/frontend/app/src/shell/NotFound.tsx
+++ b/frontend/app/src/shell/NotFound.tsx
@@ -9,7 +9,7 @@
*/
import { Button, EmptyState } from "@visionset/ui-core";
-import { IconCompass } from "@tabler/icons-react";
+import { Compass } from "lucide-react";
import type { JSX } from "react";
import { Link, useLocation } from "react-router";
@@ -18,7 +18,7 @@ export function NotFound(): JSX.Element {
return (
}
+ icon={}
title="No such page"
description={pathname}
action={
diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx
index 9a6a6c95..81ad4045 100644
--- a/frontend/app/src/styleguide/Styleguide.tsx
+++ b/frontend/app/src/styleguide/Styleguide.tsx
@@ -80,7 +80,7 @@ import {
formatGeometries,
toast,
} from "@visionset/ui-core";
-import { IconPlus, IconPointer, IconSquare, IconTrash } from "@tabler/icons-react";
+import { MousePointer2, Plus, Square, Trash2 } from "lucide-react";
import type { JSX, ReactNode } from "react";
/** The demo schema, borrowed so the swatches show the real palette rule. */
@@ -204,13 +204,13 @@ export function Styleguide(): JSX.Element {
}
+ 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 +489,7 @@ function TooNarrow({
data-testid="too-narrow-gallery"
onClick={() => onOpenGallery(destination.project_id, destination.id)}
>
-
+
Back to the batch
),
@@ -1926,7 +1912,7 @@ function Workspace({
onClick={() => attempt(onOpenGallery)}
disabled={onOpenGallery === undefined}
>
-
+
{/* The batch's pin, not the project's active version. Named here because
@@ -2038,7 +2024,7 @@ function Workspace({
data-testid="open-gallery"
onClick={() => setGalleryOpen(true)}
>
-
+
{/*
`tabular-nums` is the whole reason this is a separate span rather
@@ -2072,7 +2058,7 @@ function Workspace({
disabled={assetIndex >= assetCount - 1}
onClick={() => go(1)}
>
-
+
@@ -2117,7 +2103,7 @@ function Workspace({
disabled={!declares(asset, ASSET_ACTION.restore) || setProgress.isPending}
onClick={unskip}
>
-
+
Un-skip
) : (
@@ -2129,7 +2115,7 @@ function Workspace({
disabled={!declares(asset, ASSET_ACTION.skip) || setProgress.isPending}
onClick={() => settle("skipped")}
>
-
+
Skip
X
@@ -2222,7 +2208,7 @@ function Workspace({
});
}}
>
-
+
{jobState === "completed" ? "Finished" : "Finish job"}
@@ -2290,7 +2276,7 @@ function Workspace({
onClick={() => go(1)}
>
{flowLabel}
-
+
)}
@@ -2337,7 +2323,7 @@ function Workspace({
disabled={readOnly || !dirty || save.isPending}
onClick={() => attempt()}
>
-
+
Save and stay
@@ -2394,7 +2380,7 @@ function Workspace({
disabled={setProgress.isPending}
onClick={() => settle(reviewAction.progress)}
>
-
+
{reviewAction.label}
@@ -2427,7 +2413,7 @@ function Workspace({
aria-label="More actions"
data-testid="more-actions"
>
-
+
@@ -2444,7 +2430,7 @@ function Workspace({
disabled={readOnly || !dirty || save.isPending}
onSelect={() => attempt()}
>
-
+
Save and stay
)}
@@ -2456,7 +2442,7 @@ function Workspace({
disabled={setProgress.isPending}
onSelect={() => settle(reviewAction.progress)}
>
-
+
{reviewAction.label}
)}
@@ -2472,7 +2458,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 +2469,7 @@ function Workspace({
>
)}
setHelpOpen(true)}>
-
+
Keyboard shortcuts
@@ -2510,7 +2496,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 +2539,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.
@@ -2717,7 +2703,7 @@ function Workspace({
}
+ icon={}
title={asApiError(openingRefusal).code}
>
{refusalProse(openingRefusal)}
@@ -2736,7 +2722,7 @@ function Workspace({
}
+ icon={}
title={asApiError(save.error).code}
>
This frame could not be saved
@@ -2766,7 +2752,7 @@ function Workspace({
}
+ icon={}
title={asApiError(actionRefusal).code}
>
{refusalProse(actionRefusal)}
@@ -3201,7 +3187,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 ebb8e8e4..f562f194 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 { IconCheck, IconEye, IconEyeOff, IconSparkles, IconTag, IconTrash } from "@tabler/icons-react";
+import { Check, Eye, EyeOff, Sparkles, Tag, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, type JSX, type RefObject } from "react";
import { geometryLabel } from "../data/geometryCategory";
@@ -284,7 +284,7 @@ export function AnnotatorPanel({
disabled={drawn.length === 0}
onClick={() => onHiddenChange(allHidden ? new Set() : new Set(drawn.map((o) => o.id)))}
>
- {allHidden ? : }
+ {allHidden ? : }
@@ -428,7 +428,7 @@ function TagRegion({
/>
{declared.name}
{on ? (
-
+
) : (
{hotkeyForClass(schema, declared.name) ?? "—"}
@@ -487,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
@@ -591,7 +591,7 @@ function ObjectRow({
data-testid={`object-visibility-${index}`}
onClick={onToggleVisible}
>
- {hidden ? : }
+ {hidden ? : }
);
@@ -639,7 +639,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 26bc4a3a..9e7f8150 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 { IconTag } from "@tabler/icons-react";
+import { Tag } from "lucide-react";
import type { JSX } from "react";
import { Button } from "../primitives/Button";
@@ -130,7 +130,7 @@ export function CanvasReassign({
aria-label={`Reassign the selected ${subject.label_class}`}
data-testid="canvas-reclass"
>
-
+ onAddClass?.("")}
>
-
+
@@ -267,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 e1858876..4402549e 100644
--- a/frontend/ui-core/src/annotator/GeometryIcon.tsx
+++ b/frontend/ui-core/src/annotator/GeometryIcon.tsx
@@ -10,7 +10,7 @@
* size. The strip can afford them — it is five controls learned once.
*/
-import { IconPointer, IconPolygon, IconSquare, IconVectorSpline } from "@tabler/icons-react";
+import { MousePointer2, Spline, Square, Waypoints } from "lucide-react";
import type { JSX } from "react";
export interface GeometryIconProps {
@@ -27,10 +27,10 @@ export interface GeometryIconProps {
/** 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 it must read as an OPEN one — `IconPolygon` is already
+ if (tool === "bbox") return ;
+ if (tool === "polygon") return ;
+ // A lane is a path, and it must read as an OPEN one — `Spline` is already
// the closed shape above it, and using it here would say "closed" for a lane.
- if (tool === "polyline") return ;
- return ;
+ if (tool === "polyline") return ;
+ return ;
}
diff --git a/frontend/ui-core/src/annotator/ReassignMenu.tsx b/frontend/ui-core/src/annotator/ReassignMenu.tsx
index 29e77783..bca1d259 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 { IconCheck } from "@tabler/icons-react";
+import { Check } from "lucide-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 f47615a1..83e280ba 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -63,13 +63,7 @@ import {
type SuggestionState,
type Detail,
} from "@visionset/annotator";
-import {
- IconAlertTriangle,
- IconCheck,
- IconLoader2,
- IconSparkles,
- IconX,
-} from "@tabler/icons-react";
+import { Check, Loader2, Sparkles, TriangleAlert, X } from "lucide-react";
import type { JSX, ReactNode } from "react";
import { EditorNotice } from "./EditorNotice";
@@ -224,7 +218,7 @@ export function SuggestPanel({
*/
if (isParked(session)) {
return (
- }>
+ }>
{heldClass === null
? "Nothing selected to suggest for"
@@ -248,7 +242,7 @@ export function SuggestPanel({
data-testid="suggest-discard"
onClick={onDiscard}
>
-
+
Put the tool away
@@ -265,9 +259,9 @@ export function SuggestPanel({
tone={copy.tone}
icon={
copy.tone === "warn" ? (
-
+
) : (
-
+
)
}
>
@@ -294,7 +288,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. */}
@@ -315,7 +309,7 @@ export function SuggestPanel({
// lives in `usePendingIndicator` rather than here.
if (session.status === "asking") {
return (
- }>
+ }>
Looking at that…
@@ -334,7 +328,7 @@ export function SuggestPanel({
if (session.status === "none") {
return (
- }>
+ }>
Nothing to suggest there
@@ -375,7 +369,7 @@ export function SuggestPanel({
*/
if (isAcceptable(session)) {
return (
- }>
+ }>
A shape for “{session.labelClass}”
@@ -384,7 +378,7 @@ export function SuggestPanel({
);
diff --git a/frontend/ui-core/src/annotator/ZoomWidget.tsx b/frontend/ui-core/src/annotator/ZoomWidget.tsx
index 94524044..410236fc 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 { IconMaximize, IconMinimize, IconMinus, IconPlus, IconScan } from "@tabler/icons-react";
+import { Maximize2, Minimize2, Minus, Plus, Scan } from "lucide-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/data/TokenGate.tsx b/frontend/ui-core/src/data/TokenGate.tsx
index f0c488de..fa9727b7 100644
--- a/frontend/ui-core/src/data/TokenGate.tsx
+++ b/frontend/ui-core/src/data/TokenGate.tsx
@@ -45,7 +45,7 @@
* for ten minutes.
*/
-import { IconKey } from "@tabler/icons-react";
+import { Key } from "lucide-react";
import { useEffect, useState, type FormEvent, type JSX, type ReactNode } from "react";
import { createApiClient } from "../client";
@@ -123,7 +123,7 @@ export function TokenForm(): JSX.Element {
-
+
Connect to a workspace
diff --git a/frontend/ui-core/src/patterns/AsyncStates.tsx b/frontend/ui-core/src/patterns/AsyncStates.tsx
index cecbf2f6..6c85bfac 100644
--- a/frontend/ui-core/src/patterns/AsyncStates.tsx
+++ b/frontend/ui-core/src/patterns/AsyncStates.tsx
@@ -17,7 +17,7 @@
* incident id where a person can quote it. What a retry *does* is the caller's.
*/
-import { IconAlertTriangle, IconInbox } from "@tabler/icons-react";
+import { Inbox, TriangleAlert } from "lucide-react";
import type { JSX, ReactNode } from "react";
import { cn } from "../lib/cn";
@@ -78,7 +78,7 @@ export function EmptyState({
)}
>
- {icon ?? }
+ {icon ?? }
{title}
{description !== undefined && (
@@ -126,7 +126,7 @@ export function ErrorState({
variant="destructive"
title={
-
+
{message}
}
diff --git a/frontend/ui-core/src/patterns/BackLink.tsx b/frontend/ui-core/src/patterns/BackLink.tsx
index a19f2454..1eafdbeb 100644
--- a/frontend/ui-core/src/patterns/BackLink.tsx
+++ b/frontend/ui-core/src/patterns/BackLink.tsx
@@ -17,7 +17,7 @@
* renders no control rather than a dead one — the caller omits it.
*/
-import { IconArrowLeft } from "@tabler/icons-react";
+import { ArrowLeft } from "lucide-react";
import type { JSX } from "react";
import { Button } from "../primitives/Button";
@@ -42,7 +42,7 @@ export function BackLink({ label, onNavigate }: BackLinkProps): JSX.Element {
className="-ml-2 w-fit max-w-64 text-muted-foreground hover:text-foreground"
onClick={onNavigate}
>
-
+ {label}
);
diff --git a/frontend/ui-core/src/patterns/ClassFields.tsx b/frontend/ui-core/src/patterns/ClassFields.tsx
index a7e5c376..9d857649 100644
--- a/frontend/ui-core/src/patterns/ClassFields.tsx
+++ b/frontend/ui-core/src/patterns/ClassFields.tsx
@@ -19,7 +19,7 @@
* mounted.
*/
-import { IconPlus, IconTrash } from "@tabler/icons-react";
+import { Plus, Trash2 } from "lucide-react";
import type { JSX } from "react";
import { geometryLabel, groupGeometries } from "../data/geometryCategory";
@@ -287,7 +287,7 @@ function Attributes({
onChange([...attributes, { name: "", kind: "string", required: false, default: null }])
}
>
-
+
Add
@@ -404,7 +404,7 @@ function Attributes({
disabled={disabled}
onClick={() => onChange(attributes.filter((_, i) => i !== index))}
>
-
+
diff --git a/frontend/ui-core/src/patterns/ProjectNav.tsx b/frontend/ui-core/src/patterns/ProjectNav.tsx
index 517b8c17..a5853501 100644
--- a/frontend/ui-core/src/patterns/ProjectNav.tsx
+++ b/frontend/ui-core/src/patterns/ProjectNav.tsx
@@ -45,18 +45,7 @@
* and a hairline, no `sidebar-*` token anywhere in it.
*/
-import {
- IconChevronDown,
- IconDatabase,
- IconDots,
- IconLayoutGrid,
- IconPencil,
- IconSitemap,
- IconStack2,
- IconTrash,
- IconUpload,
- type Icon,
-} from "@tabler/icons-react";
+import { ChevronDown, Database, Grid3x3, Layers, MoreHorizontal, Network, Pencil, Trash2, type LucideIcon, Upload } from "lucide-react";
import { cva } from "class-variance-authority";
import type { JSX, MouseEvent, ReactNode } from "react";
@@ -87,7 +76,7 @@ export const DEFAULT_PROJECT_SECTION: ProjectSection = "overview";
interface SectionLabel {
readonly label: string;
- readonly icon: Icon;
+ readonly icon: LucideIcon;
}
/**
@@ -109,10 +98,10 @@ const navItemVariants = cva(
);
const SECTION_LABELS: Record = {
- overview: { label: "Overview", icon: IconLayoutGrid },
- schema: { label: "Schema", icon: IconSitemap },
- batches: { label: "Batches", icon: IconStack2 },
- dataset: { label: "Dataset", icon: IconDatabase },
+ overview: { label: "Overview", icon: Grid3x3 },
+ schema: { label: "Schema", icon: Network },
+ batches: { label: "Batches", icon: Layers },
+ dataset: { label: "Dataset", icon: Database },
};
/** One batch the annotator can be opened on: what the `Annotate ▾` menu lists. */
@@ -251,7 +240,7 @@ function Cta({ annotate, onIngest, contentOwnsTheAction = false, layout }: Proje
className={wide}
onClick={onIngest}
>
-
+
Ingest
);
@@ -277,7 +266,7 @@ function AnnotateAction({
if (targets.length === 1 && only !== undefined) {
return (
);
@@ -288,9 +277,9 @@ function AnnotateAction({
{/* Same testid and variant as the jumping form: one control with two
shapes, and the chevron is what tells them apart. */}
@@ -319,20 +308,20 @@ function Overflow({ onRename, onDelete }: ProjectNavProps): JSX.Element | null {
{onRename !== undefined && (
-
+
Rename
)}
{onRename !== undefined && onDelete !== undefined && }
{onDelete !== undefined && (
-
+
Delete
)}
diff --git a/frontend/ui-core/src/patterns/projectNav.test.tsx b/frontend/ui-core/src/patterns/projectNav.test.tsx
index 566e2ed5..0aa9b18e 100644
--- a/frontend/ui-core/src/patterns/projectNav.test.tsx
+++ b/frontend/ui-core/src/patterns/projectNav.test.tsx
@@ -163,10 +163,14 @@ describe("ProjectNav", () => {
expect(screen.getByTestId("project-menu")).toBeTruthy();
});
- it("is drawn with Tabler only", () => {
+ it("draws from the one icon set and no other", () => {
// `import.meta.url` is an `http://localhost/` URL under jsdom, so the path is
// resolved from the package root vitest runs in rather than from this module.
const source = readFileSync(resolve(process.cwd(), "src/patterns/ProjectNav.tsx"), "utf8");
- expect(source).not.toMatch(/lucide-react/);
+ // Both directions: the set it draws, and the one it must not reach back for.
+ // Asserting only the absence would pass for a file that had stopped drawing
+ // icons at all.
+ expect(source).toMatch(/lucide-react/);
+ expect(source).not.toMatch(/@tabler\/icons-react/);
});
});
diff --git a/frontend/ui-core/src/primitives/Dialog.tsx b/frontend/ui-core/src/primitives/Dialog.tsx
index 1846a89f..9109ca68 100644
--- a/frontend/ui-core/src/primitives/Dialog.tsx
+++ b/frontend/ui-core/src/primitives/Dialog.tsx
@@ -14,7 +14,7 @@
*/
import * as DialogPrimitive from "@radix-ui/react-dialog";
-import { IconX } from "@tabler/icons-react";
+import { X } from "lucide-react";
import {
createContext,
forwardRef,
@@ -95,7 +95,7 @@ export const DialogContent = forwardRef<
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:hover:bg-muted/50"
}
>
-
+
diff --git a/frontend/ui-core/src/primitives/Select.tsx b/frontend/ui-core/src/primitives/Select.tsx
index de28c64e..9f3aad0a 100644
--- a/frontend/ui-core/src/primitives/Select.tsx
+++ b/frontend/ui-core/src/primitives/Select.tsx
@@ -65,7 +65,7 @@
*/
import * as SelectPrimitive from "@radix-ui/react-select";
-import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
import {
forwardRef,
type ComponentPropsWithoutRef,
@@ -112,7 +112,7 @@ export const SelectTrigger = forwardRef<
>
{children}
-
+
);
@@ -156,7 +156,7 @@ export const SelectContent = forwardRef<
because it is the fix `models.spec.ts` asserts.
*/}
-
+
-
+
@@ -214,7 +214,7 @@ export const SelectItem = forwardRef<
-
+
diff --git a/frontend/ui-core/src/screens/AssetThumbnail.tsx b/frontend/ui-core/src/screens/AssetThumbnail.tsx
index 5922d8f7..c6f33a60 100644
--- a/frontend/ui-core/src/screens/AssetThumbnail.tsx
+++ b/frontend/ui-core/src/screens/AssetThumbnail.tsx
@@ -36,7 +36,7 @@
* gigabytes.
*/
-import { IconPhoto, IconPhotoOff } from "@tabler/icons-react";
+import { Image, ImageOff } from "lucide-react";
import { useEffect, useState, type JSX } from "react";
import { useApiClient } from "../data/ApiProvider";
@@ -71,9 +71,9 @@ export function ThumbnailPlaceholder({
className={`flex items-center justify-center bg-muted text-muted-foreground ${className ?? ""}`}
>
{broken ? (
-
+
) : (
-
+
)}
{alt}
diff --git a/frontend/ui-core/src/screens/BatchLifecycle.tsx b/frontend/ui-core/src/screens/BatchLifecycle.tsx
index ed6de80d..75acbf04 100644
--- a/frontend/ui-core/src/screens/BatchLifecycle.tsx
+++ b/frontend/ui-core/src/screens/BatchLifecycle.tsx
@@ -23,7 +23,7 @@
*/
import { useState, type JSX } from "react";
-import { IconPlayerPlay, IconSquareCheck } from "@tabler/icons-react";
+import { Play, SquareCheck } from "lucide-react";
import { asApiError } from "../data/errors";
import { refusalProse } from "../data/refusals";
@@ -152,7 +152,7 @@ export function CompleteBatchButton({
disabled={outstanding > 0 || finish.isPending}
onClick={() => finish.mutate()}
>
-
+
{finish.isPending ? "Completing…" : "Complete"}
@@ -356,7 +356,7 @@ export function StartAnnotatingButton({
disabled={start.isPending}
onClick={() => start.mutate()}
>
-
+
{start.isPending ? "Starting…" : "Start annotating"}
{refusal !== null && (
diff --git a/frontend/ui-core/src/screens/BatchesScreen.tsx b/frontend/ui-core/src/screens/BatchesScreen.tsx
index adbb0a64..8e1a67af 100644
--- a/frontend/ui-core/src/screens/BatchesScreen.tsx
+++ b/frontend/ui-core/src/screens/BatchesScreen.tsx
@@ -30,7 +30,7 @@
* UUIDs. A program has the SDK and the API.
*/
-import { IconPlayerPlay, IconStack2, IconUpload } from "@tabler/icons-react";
+import { Layers, Play, Upload } from "lucide-react";
import { useState, type JSX } from "react";
import { Async } from "../data/Async";
@@ -89,7 +89,7 @@ export function BatchesScreen({
<>
{onIngest !== undefined && (
)}
@@ -285,7 +285,7 @@ function Lifecycle({
// several filled buttons down the same column, under a page header whose
// "Annotate" is the actual forward action.
);
@@ -300,7 +300,7 @@ function Lifecycle({
disabled={start.isPending}
onClick={() => start.mutate()}
>
-
+
Start
{start.isError && (
diff --git a/frontend/ui-core/src/screens/CorrectionBatch.tsx b/frontend/ui-core/src/screens/CorrectionBatch.tsx
index 1605aee0..6fd8e5b7 100644
--- a/frontend/ui-core/src/screens/CorrectionBatch.tsx
+++ b/frontend/ui-core/src/screens/CorrectionBatch.tsx
@@ -31,7 +31,7 @@
* selection is a thing somebody chose.
*/
-import { IconGitBranch } from "@tabler/icons-react";
+import { GitBranch } from "lucide-react";
import { useState, type JSX } from "react";
import { BATCH_ACTION, declares } from "../data/capabilities";
@@ -119,7 +119,7 @@ export function CorrectionButton({
data-testid={`correct-${batch.name}`}
onClick={() => setShowing(true)}
>
-
+
Create correction batch
-
+
Correction of{" "}
{onOpenParent === undefined ? (
parentName
diff --git a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
index d315b98d..25b37ab4 100644
--- a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
+++ b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
@@ -22,13 +22,7 @@
* because coordinates without a frame cannot be placed.
*/
-import {
- IconChevronLeft,
- IconChevronRight,
- IconEye,
- IconEyeOff,
- IconTrash,
-} from "@tabler/icons-react";
+import { ChevronLeft, ChevronRight, Eye, EyeOff, Trash2 } from "lucide-react";
import { useState, type CSSProperties, type JSX, type KeyboardEvent } from "react";
import {
BboxShape,
@@ -164,7 +158,7 @@ export function DatasetAssetDialog({
disabled={!hasPrevious}
onClick={() => onIndex(index - 1)}
>
-
+
@@ -194,7 +188,7 @@ export function DatasetAssetDialog({
data-testid="preview-remove"
onClick={() => onRemove(asset)}
>
-
+
Remove from dataset
}
@@ -496,7 +488,7 @@ function TrunkTile({
onClick={onRemove}
className="text-muted-foreground hover:text-destructive"
>
-
+
@@ -595,7 +587,7 @@ function ReleaseCard({ release }: { readonly release: Release }): JSX.Element {
-
+
{release.tag}
v{release.schema_version}
{release.split !== null && release.split !== undefined && (
@@ -618,7 +610,7 @@ function ReleaseCard({ release }: { readonly release: Release }): JSX.Element {