Skip to content

refactor(annotator): the workspace converges on Nova, and a dismissed menu stops swallowing the next press - #771

Merged
YaelAnaya merged 5 commits into
mainfrom
refactor/annotator-nova-convergence
Aug 24, 2026
Merged

refactor(annotator): the workspace converges on Nova, and a dismissed menu stops swallowing the next press#771
YaelAnaya merged 5 commits into
mainfrom
refactor/annotator-nova-convergence

Conversation

@YaelAnaya

Copy link
Copy Markdown
Collaborator

The annotation workspace was the last surface still drawing the old icon set and
the last one hand-spelling control geometry. Both are converged here. Measuring
the workspace's interaction tempo then turned up a defect that was not
annotator-specific at all: every menu in the product swallowed a press that
arrived while it was closing.

The icon set

Eleven modules, sixty-one call sites and thirty-one distinct glyphs move from
lucide-react to @tabler/icons-react, which is what the primitives and the
screens already drew. Sizes and accessible names are carried across untouched.

Glyphs are chosen by meaning rather than by matching names, and two of them
deliberately do not match. GeometryIcon documents that a polygon must read as a
closed path and a lane as an open one, and the old set inverted that by naming the
polygon's glyph Spline; the polygon now takes IconPolygon and the polyline
IconVectorSpline, so the distinction survives the migration rather than the
spelling doing.

A gate holds the result, because nothing else would have noticed a
reintroduction: the dependency stays declared while the workspace's consumers
carry it, so a stray import resolves, typechecks, builds and renders. The scan
reads the whole directory rather than the eleven files that moved - proving that
mattered, since a list misses EditorNotice.tsx, which carries no icon today and
would inherit one silently.

The compact controls

Six controls asked for size="icon" and then overrode it with
className="size-6". icon-xs is size-6 p-0, so each was spelling the
variant out by hand, and the override was what a reader had to notice to know the
control's real size. Nothing moves: the variant resolves to the geometry the
override produced, and its svg:size-3 default applies only to icons carrying no
size of their own, which none of these do.

AssetImage's size-6 stays. It is the glyph in an error state rather than a
control's geometry, and there is no button under it. CanvasReassign couples the
size to arithmetic - TRIGGER_PX positions the button by its own width, and
annotate.spec.ts measures that position in a real browser - so its comment now
names the variant that decides the number rather than the class that used to.

A dismissed menu, and the press after it

Pressing the overflow trigger straight after an Escape did nothing. While the
exit animation ran, Radix kept the content mounted and the dismissable layer with
it, so one pointer-down was read twice: the trigger toggled the menu open, and the
layer still listening treated the same press as an interaction outside itself and
dismissed. The two cancelled, and the menu never appeared.

Measured in chromium against the workspace, with a control to keep the experiment
honest:

CONTROL  reopen after the exit completes    5/5 pass
A        Escape then an immediate press      4/5 FAIL
B        two Escapes then a press            5/5 FAIL
C        a shortcut once focus is restored   5/5 pass

The control passing is what makes the reading conclusive: reopening works once the
animation finishes, so the window is the cause rather than the harness. Scenario C
is worth stating because it refutes a first draft of this investigation - a
shortcut pressed straight after Escape also failed, but that was focus location,
not motion. Escape returns focus to the trigger by design and the canvas
commands deliberately ignore keys aimed at a control, so the same press lands once
focus is asked for. Reported as motion it would have been a false finding.

duration-100 puts that window inside the gap between an Escape and the click
after it, which makes this a defect a fast hand meets routinely rather than an
edge case. After removing the exit animation both scenarios pass ten runs of ten,
and the browser suite's 256 scenarios pass with them.

The fix is the primitive's rather than the workspace's because the flaw was:
SURFACE dresses every DropdownMenuContent in the product, so an
annotator-local motion exception would have left the same swallow in the screens
and given one primitive two behaviours. data-[state=closed]:overflow-hidden goes
with it, having clipped the surface while the exit shrank it.

DESIGN.md carries the decision rather than this diff carrying it alone: the Menu
row reads enter-and-no-exit, and a new Motion section argues the general rule
from the principle the old behaviour broke - motion orients or confirms and never
makes anyone wait. An exit stays available to a surface whose trigger cannot be
pressed again immediately, which is why the tooltip keeps its own. Dialog keeps
its own too: the same mechanism is plausible there and no scenario reproduced it,
so it is named below rather than changed on a hunch.

What was audited and left alone

The workspace was already converged in the ways earlier phases converged it, and
saying so is part of the result. No overlay is hand-rolled - Radix owns every
menu, dialog and tooltip. No raw colour and no manual focus treatment appears
anywhere under annotator/. Two transition-colors are the only motion
declarations, and neither is animation timing.

Seven raw <button> elements survive on purpose. Tag chips are toggles carrying
aria-pressed, which no primitive in the set covers; object-select is a
row-select region whose row Nova's height and padding would break; the gallery
tiles are pictures, and their own docstring explains they are real buttons so that
Enter and Space come from the browser. Replacing any of them with a boxed control
would change the workflow rather than converge it.

Viewport and theme behaviour was looked at rather than assumed: the workspace at
1366x768, 1440x900, 1536x864 and 1920x1080, in both token sets, with the tool
strip and the panel inspected closely and a menu open. The top bar holds its 44px
row and its centre cluster at the narrowest of them, and no control clips. This
change introduces no colour of its own - Tabler glyphs inherit currentColor -
and tests/scripts/design_tokens.test.mjs proves the absence independently.

Found, not fixed

  • ClassRegion's class-create row highlights with bg-muted, where DESIGN.md
    says a list item highlights with accent. It predates this change.
  • Dialog animates out, the same shape as the menu defect. No scenario here
    reproduced it, so it wants its own measurement.
  • @visionset/app declares lucide-react and imports it nowhere; the workspace's
    own imports are gone, while @visionset/ui-core keeps the package until the
    demo's remaining usage goes. The frontend dependency bump (chore(deps): bump the frontend-minor-patch group across 1 directory with 5 updates #768) edits those
    manifest lines and the lockfile, so removing the declaration here would conflict
    for no gain.
  • src/demo/ToolStrip.tsx still explains its hand-drawn glyphs by saying
    DESIGN.md pins lucide-react. It has not since the preset landed.

Phase 7's cross-product debt is untouched: the Schema banner's narrow-width
overflow, the Gallery batch-detail header wrapping, and a full responsive sweep.

Test plan

Every command ran inside Docker/Linux; no repository validation ran natively on
the host. Two things are worth recording for whoever runs these next. The app
compose service is not a test runner - its image bakes manifests and tsconfigs but
neither eslint.config.js nor vitest.config.ts - and @visionset/annotator
resolves through a container-local dist/, so a build and the suite that needs it
have to share one container. Separate compose run invocations do not.

Node gates, on a full tree with a frozen install in node:24-bookworm:

install (pnpm install --frozen-lockfile)  PASS
build (pnpm -r build)                     PASS
@visionset/ui-core lint (incl. typecheck) PASS
@visionset/ui-core test                   PASS
@visionset/app build                      PASS
@visionset/app lint                       PASS
@visionset/annotator test                 PASS
pnpm test:scripts                         PASS
pnpm version:check                        PASS

Browser, in mcr.microsoft.com/playwright:v1.62.1-noble with CI=1:

the two reinteraction scenarios, --repeat-each=5   10 passed
the full suite                                     256 passed

@visionset/annotator's own tests passing is the load-bearing one for scope: the
headless engine is untouched, and so are the viewport, geometry, hit-testing and
selection paths. No route, wire shape, server behaviour or domain rule changes
here.

The new icon-set gate was verified by breaking it, and the reinteraction scenarios
were the failing measurement before they were coverage.

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.
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.
…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.
…s is not swallowed

Pressing the overflow trigger straight after an `Escape` did nothing. While the
exit animation ran, Radix kept the content mounted and the dismissable layer with
it, so one pointer-down was read twice - the trigger toggling the menu open, and
the layer still listening treating the same press as an interaction outside itself
and dismissing. The two cancelled and the menu never appeared.

`duration-100` puts that window inside the gap between an Escape and the click
after it, which makes it a defect a fast hand meets routinely. Measured in
chromium against the annotation workspace, the press failed four runs in five, and
the same press after a second Escape failed five in five; both pass with the exit
animation gone, and the browser suite's 256 scenarios pass with it.

The fix is the primitive's rather than the workspace's because the flaw was:
`SURFACE` dresses every `DropdownMenuContent` in the product, so an
annotator-local motion exception would have left the same swallow in the screens
and given one primitive two behaviours. `data-[state=closed]:overflow-hidden` goes
with it - it clipped the surface while the exit shrank it, and nothing paints in
that state now.

`DESIGN.md` carries the decision rather than this diff carrying it alone: the Menu
row says enter-and-no-exit, and a new *Motion* section argues the general rule from
the principle the old behaviour broke - motion never makes anyone wait. An exit
stays available to a surface whose trigger cannot be pressed again immediately,
which is why the tooltip keeps its own.
Two scenarios, both of which failed before the menu's exit animation went: the
trigger pressed straight after an `Escape`, and pressed after a second one.
Neither routes through `closeOverflow`, because that helper's wait for the menu to
leave is exactly what they must not do - the product's contract is that there is
nothing to wait for.

`_frame.ts`'s two overflow helpers stated the swallow as current fact and told the
reader the wait existed to avoid it. That was true and is not: the wait is the
assertion that `Escape` closed the menu, and the reason a scenario must not reach
for it when reopening immediately is now what the docstring says.
@YaelAnaya
YaelAnaya merged commit 52deab4 into main Aug 24, 2026
15 checks passed
@YaelAnaya
YaelAnaya deleted the refactor/annotator-nova-convergence branch August 24, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant