Skip to content

feat(annotator): class selection panel in the workspace side panel; top bar sheds the class field - #422

Merged
JArmandoAnaya merged 2 commits into
mainfrom
feat/class-panel
Aug 8, 2026
Merged

feat(annotator): class selection panel in the workspace side panel; top bar sheds the class field#422
JArmandoAnaya merged 2 commits into
mainfrom
feat/class-panel

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #420

The class selector leaves the top bar and becomes the upper region of the right-hand
panel, above the objects list.

Classes            12 classes  +      ← header: title · count · add-a-class
[ Filter classes…            ]        ← `c` focuses this; Enter takes the first match
● vehicle           bbox   [1]        ← swatch · name · geometry · hotkey badge
● lane              polygon [2]
…                                     ← 3 rows minimum, 8 maximum, then it scrolls
──────────────────────────────        ← a 1px rule, not a handle
Annotations         0 objects ◉      ← the existing view, taking the rest

Position and design, not behaviour: c focuses the class filter, Enter takes the first
match, hotkeys 1–9 stay global and fixed to the first nine classes in schema order
whatever the filter shows, selection stays sticky, geometry still switches the tool, and
the no-match row still seeds the add-class dialog with what was typed, with its session
semantics untouched.

The combobox diagnosis — the instance, not the primitive

Probed in chromium at f49428b before anything was removed, because the symptom
disappears with the evidence. The numbers:

box
the reservation slot from f49428b x=434 y=6 192×32, overflow: hidden
the combobox's open-state input x=370 y=6 **256**×32
the combobox's listbox x=370 y=**42** 256×148

The only clipping ancestor between the listbox and the page is
DIV.flex.w-48.shrink-0.justify-end overflow=hidden — the w-48 slot #416 wrapped the
field in. The mechanism is ordinary CSS and entirely the caller's: the listbox is
position: absolute at top-9, so it begins 36px down a slot that is 32px tall and is
clipped away in full. document.elementFromPoint at the listbox's own coordinates does
not find it — listVisibleToUser: false. The 256px input is clipped to 192px from the
left, which is why the placeholder read …asses….

So removal resolves it, and the Combobox primitive is sound and untouched: it has
exactly one consumer in tree, its own dedicated test file, and no other caller inherits
anything. It stays exported.

ClassField.tsx is deleted with the slot — its only consumer was the bar, and its
only coverage was through a page that no longer mounts it. classChoices goes with it;
the panel's list is schema order, and the recency ordering that function existed for is
deliberately not reproduced (see below).

The right zone, re-measured in chromium at 1440

On an annotated frame — the state that declares submit_for_review, which is the
heavier of the two reabsorbable controls and the reason the zone overflowed at all:

before (#419) after
left demand 290px 290px
cluster 632px 423px
right demand 460px 460px
right offered 366px 462px
more-actions 16px (squashed) 36px

job-progress=85 · save-and-stay=164 · submit-for-review=151 · more-actions=36, every one
at full width. So the breakpoints are reverted: Save and stay back to xl,
the review move back to lg, both visible buttons at every supported width.

It clears by one pixel, and that is stated rather than smoothed over. The margin is
thin, but the failure mode is not: n / m annotated carries truncate, so what gives way
under pressure is the tail of a readout and never a button. Pinned by a new browser
scenario asserting both controls are visible and the overflow trigger is at its declared
36px.

The panel

Two stacked regions, no tabs and no draggable splitter. ClassRegion is shrink-0 and
sizes itself in rows; the objects region is min-h-0 flex-1 and takes the rest. Two
scrollers, one per region.

The height rule is stated in rows, so CLASS_ROW_PX is an explicit h-9 rather than
whatever the content came to
— a row whose height depended on a hotkey badge being drawn
would make the region eight-and-a-bit rows tall. classListHeight is exported and pure,
which is what stops the arithmetic becoming a second spelling of what the component does.

Computed from the schema's class count, never the filtered one. A height that tracked
the filter would resize this region — and reflow the objects region under it — on every
keystroke, which is the controls-moving-under-the-cursor problem #416 was spent on.

Hotkeys are hotkeyForClass and nothing else: schema position, capped at nine, so the
badge on a row and the digit the input layer claims cannot disagree, and filtering does not
remap them. Past the ninth a row carries no chip — a key badge on a row the key does not
reach is the lie ReassignMenu refuses to tell one column over.

Read-only frames render the list. Which classes exist stays true on an accepted frame,
so the rows are there, disabled and carrying title with the page's own banner sentence
(withheld ?? settledBecause ?? …) — principle 9 rather than a grey box. This is a change
from the field it replaces, which was not rendered at all while read-only.

ClassListRow was extended, not duplicatedcount became optional (omit it for the
compact single-line variant), plus hotkey, refusal and testId. The schema editor and
the annotator showing "the selected class" two different ways is exactly the drift worth
spending a prop to avoid.

Mutation verification

Implementation committed first; each anchor asserted present-and-unique before applying and
restored after by its exact diff.

Mutation Named test that turned red
the 8-row clamp removed (Math.min(…, MAX_CLASS_ROWS) dropped) the height rule > stops growing at the cap and scrolls the surplus instead
the 3-row floor removed the height rule > gives a one-class schema three rows' worth of height, not one
height fed shown.length instead of schema.classes.length the height rule > does not move when the filter narrows the list

Found and fixed on the way

  • Skip is 104px and Un-skip 96px, so a skipped frame pulled the whole centred
    cluster 4px sideways — pre-existing, and newly the only remaining width variable once
    the class field's reservation left. Both now carry min-w-27. 108px, not 104: a
    floor equal to the wider label is not a floor, because Skip actually measures 104.09
    and clamping Un-skip to 104 left 0.39px of drift — caught by a browser assertion, and
    invisible to a person.
  • The demo's aside shrank the panel into the debug panels below it. The panel carries
    min-h-0 so its lower region can scroll inside a container of definite height, which is
    what it gets in the app. The showcase's aside is a scrolling column, where min-h-0
    also licenses shrinking it below its content — every scenario in e2e/panel.spec.ts
    failed with <aside> intercepts pointer events. Fixed in the demo, in its own commit.

Tests

  • classRegion.test.tsx (new, 10): the height rule at 1 / 5 / 8 / 12 classes, the
    filter not moving it, the exported arithmetic, hotkeys past the ninth, and the refusal
    rendering and refusing Enter.
  • topBar.test.tsx: the class-field describe is rewritten in the panel's terms —
    arming a row, the digits shown in schema order, the digits not remapped by a filter,
    geometry switching the tool, c focusing the panel filter, Enter typeahead, the create
    row, and the read-only refusal. Plus a new assertion that the cluster holds no instrument
    sub-group.
  • Chromium (e2e/annotate.spec.ts, 4 new): the regions stack and the objects region
    reaches the panel's bottom; the two scrollers move independently and the header and
    filter do not scroll away; a digit arms the schema-order class while the filter hides it;
    both reabsorbable controls visible at 1440; the cluster is the same width and position
    whichever resolution verb the frame offers. The Annotation top bar: consolidate image navigation into a centered cluster #416 centring scenario is unchanged and
    still fails on a broken track — re-verified by mutation in that PR, and its 0.5px
    tolerance is untouched here.
  • Sticky-selection, geometry-switches-tool and the drawing-class-lifetime regressions all
    kept, relocated to the panel's testids.

DESIGN.md §"The annotation workspace": the top-bar enumeration, the class-field entry
(now the classes region), and the side-panel paragraph (now the two-region spec with the
3/8-row rule and the no-splitter statement). No other section touched.

Test plan

bash scripts/check.sh in stages, against the harness's ~10-minute ceiling. Exit codes
verbatim:

STAGE python exit=0                 # 440s tests, ruff lint+format, mypy, import contracts (3 kept, 0 broken)
STAGE frontend+generated exit=0     # build 9s, tests 31s, lint 18s, openapi/client/mcp/version drift clean
STAGE browser exit=0                # annotator + app e2e: 222 passed (2.3m); browser cycle, real server: 1 passed (27.7s)

@visionset/annotator 863 passed (unchanged), @visionset/ui-core 734 passed (was 720).
No Python touched; openapi.json and the generated client are byte-identical.

Found, not fixed

  • The 1px of headroom in the right zone at 1440 is real. It degrades gracefully — the
    readout truncates, no button clips — but a future control on that zone has no room, and
    the next one to be added will need this measured again rather than assumed.

…op bar sheds the class field

The class selector leaves the top bar and becomes the upper region of the right-hand
panel, above the objects list. Two stacked regions, no tabs and no draggable splitter:
classes on top with a content-driven height — three rows' worth minimum, one row per
class, eight maximum, then fixed with internal scroll — and objects below taking all
remaining height with its own scroller.

Position and design change, not behaviour: `c` focuses the class filter, Enter takes the
first match, hotkeys 1-9 stay global and fixed to the first nine classes in schema order
whatever the filter shows, selection stays sticky, geometry still switches the tool, and
the create row still seeds the add-class dialog with what was typed.

It also fixes the defect the field had regressed into. The top-bar instance was clipped
by the `w-48 overflow-hidden` reservation #416 wrapped it in: the listbox is
`position: absolute` at `top-9` inside a 32px-tall slot, so it rendered entirely outside
the clip and never appeared, and the 256px open-state input was cut to 192px. The
`Combobox` primitive is sound and untouched.

With the field's 192px out of the cluster, the width-based reabsorption #419 introduced
is reverted: Save and stay is back to `xl` and the review move to `lg`, so both are
visible buttons at every supported width. Measured in chromium at 1440 — cluster 423px,
each side offered 462px against 460px of demand.

Closes #420
The panel carries `min-h-0` so its lower region can scroll inside a container of
definite height, which is what it gets in the app. The demo's aside is a scrolling
column, where `min-h-0` also licenses shrinking the panel below its content — the
overflow then landed on the debug panels underneath and every scenario in
e2e/panel.spec.ts failed with `<aside> intercepts pointer events`.
@JArmandoAnaya
JArmandoAnaya merged commit 08e1483 into main Aug 8, 2026
13 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/class-panel branch August 8, 2026 03:21
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…op bar sheds the class field (#422)

* feat(annotator): class selection panel in the workspace side panel; top bar sheds the class field

The class selector leaves the top bar and becomes the upper region of the right-hand
panel, above the objects list. Two stacked regions, no tabs and no draggable splitter:
classes on top with a content-driven height — three rows' worth minimum, one row per
class, eight maximum, then fixed with internal scroll — and objects below taking all
remaining height with its own scroller.

Position and design change, not behaviour: `c` focuses the class filter, Enter takes the
first match, hotkeys 1-9 stay global and fixed to the first nine classes in schema order
whatever the filter shows, selection stays sticky, geometry still switches the tool, and
the create row still seeds the add-class dialog with what was typed.

It also fixes the defect the field had regressed into. The top-bar instance was clipped
by the `w-48 overflow-hidden` reservation #416 wrapped it in: the listbox is
`position: absolute` at `top-9` inside a 32px-tall slot, so it rendered entirely outside
the clip and never appeared, and the 256px open-state input was cut to 192px. The
`Combobox` primitive is sound and untouched.

With the field's 192px out of the cluster, the width-based reabsorption #419 introduced
is reverted: Save and stay is back to `xl` and the review move to `lg`, so both are
visible buttons at every supported width. Measured in chromium at 1440 — cluster 423px,
each side offered 462px against 460px of demand.

Closes #420

* fix(demo): the showcase aside must not shrink the two-region panel

The panel carries `min-h-0` so its lower region can scroll inside a container of
definite height, which is what it gets in the app. The demo's aside is a scrolling
column, where `min-h-0` also licenses shrinking the panel below its content — the
overflow then landed on the debug panels underneath and every scenario in
e2e/panel.spec.ts failed with `<aside> intercepts pointer events`.
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.

Class selection moves from the top bar into a split right panel

1 participant