refactor(annotator): the workspace converges on Nova, and a dismissed menu stops swallowing the next press - #771
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-reactto@tabler/icons-react, which is what the primitives and thescreens 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.
GeometryIcondocuments that a polygon must read as aclosed path and a lane as an open one, and the old set inverted that by naming the
polygon's glyph
Spline; the polygon now takesIconPolygonand the polylineIconVectorSpline, so the distinction survives the migration rather than thespelling 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 andwould inherit one silently.
The compact controls
Six controls asked for
size="icon"and then overrode it withclassName="size-6".icon-xsissize-6 p-0, so each was spelling thevariant 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-3default applies only to icons carrying nosize of their own, which none of these do.
AssetImage'ssize-6stays. It is the glyph in an error state rather than acontrol's geometry, and there is no button under it.
CanvasReassigncouples thesize to arithmetic -
TRIGGER_PXpositions the button by its own width, andannotate.spec.tsmeasures that position in a real browser - so its comment nownames 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
Escapedid nothing. While theexit 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:
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
Escapealso failed, but that was focus location,not motion.
Escapereturns focus to the trigger by design and the canvascommands 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-100puts that window inside the gap between anEscapeand the clickafter 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:
SURFACEdresses everyDropdownMenuContentin the product, so anannotator-local motion exception would have left the same swallow in the screens
and given one primitive two behaviours.
data-[state=closed]:overflow-hiddengoeswith it, having clipped the surface while the exit shrank it.
DESIGN.mdcarries the decision rather than this diff carrying it alone: the Menurow 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/. Twotransition-colorsare the only motiondeclarations, and neither is animation timing.
Seven raw
<button>elements survive on purpose. Tag chips are toggles carryingaria-pressed, which no primitive in the set covers;object-selectis arow-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.mjsproves the absence independently.Found, not fixed
ClassRegion'sclass-createrow highlights withbg-muted, whereDESIGN.mdsays a list item highlights with
accent. It predates this change.Dialoganimates out, the same shape as the menu defect. No scenario herereproduced it, so it wants its own measurement.
@visionset/appdeclareslucide-reactand imports it nowhere; the workspace'sown imports are gone, while
@visionset/ui-corekeeps the package until thedemo'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.tsxstill explains its hand-drawn glyphs by sayingDESIGN.mdpinslucide-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
appcompose service is not a test runner - its image bakes manifests and tsconfigs but
neither
eslint.config.jsnorvitest.config.ts- and@visionset/annotatorresolves through a container-local
dist/, so a build and the suite that needs ithave to share one container. Separate
compose runinvocations do not.Node gates, on a full tree with a frozen install in
node:24-bookworm:Browser, in
mcr.microsoft.com/playwright:v1.62.1-noblewithCI=1:@visionset/annotator's own tests passing is the load-bearing one for scope: theheadless 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.