Skip to content

A label class accepts a set of geometries - #593

Merged
JArmandoAnaya merged 17 commits into
mainfrom
feat/multi-geometry-classes
Aug 15, 2026
Merged

A label class accepts a set of geometries#593
JArmandoAnaya merged 17 commits into
mainfrom
feat/multi-geometry-classes

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #584.

A label class accepts a set of geometries

LabelClass.geometry becomes geometries, a non-empty deduplicated tuple kept in one sorted
order. A class labelled as a box on some frames and as an outline on others is one class;
splitting it into car and car_polygon made every consumer downstream re-unify them, and
the COCO exporter's own docstring already recorded the cost.

Identity stays the class name per schema version. Geometry becomes a per-annotation property
constrained by the class's set. No composite (name, geometry) identity.

Kernel

  • The write gate is a membership test in that class's set — still not the version's
    union, which is wider as soon as two classes accept different shapes. No new error class;
    DisallowedGeometry names the set.
  • schema_diff answers per geometry, the shape the select options rule already had: one
    added is additive, one removed is destructive. That falls straight out of the module's own
    governing question, and it is what makes widening an ordinary save while narrowing stays
    behind allow_destructive.
  • No migration. annotation_schema.classes is a JSON column, so a
    model_validator(mode="before") on LabelClass reading the retired singular key covers
    stored schemas and stored release manifests alike — and MCP, which takes the domain model
    directly, so an agent written against either spelling works. LabelClassBody deliberately
    does not read it: a REST client sending the old key is better told so than silently
    reinterpreted. FORMAT_VERSION stays 8.
  • Sorted tuple, not a frozenset. release.canonical_bytes hashes model_dump(mode="json")
    and a set's iteration order is not stable across processes, so a set-valued field would make
    a release hash irreproducible. Pinned by a test.
  • MANIFEST_VERSION 1 → 2, since Manifest.classes carries these verbatim. Documents
    already published keep their bytes and their hashes and still load, so every existing release
    stays verifiable — asserted.

Export — behaviour unchanged, report corrected

Every exporter already branches on the geometry an annotation holds, never on its class, so
formats/{coco,yolo,voc,lanes} needed no logic change.

One latent assumption is fixed, the one §5 of the dispatch anticipated:
ReleaseService._compatibility keyed its report by class with a single GeometryType each. A
class holding boxes and polygons is two answers under YOLO — the boxes written whole, the
polygons reduced — and one row could carry only one of them, misdescribing half its own output
whichever it picked. The report is now one row per (class, geometry); ClassCompatibility
keeps its scalar geometry field and a class simply contributes several rows. This moves
openapi.json, the generated client and visionset export's table; no UI renders these rows.

Wire and generated artifacts

geometries: string[] on input and output. Regenerated and committed: openapi.json,
frontend/ui-core/src/generated/{api,checks}.ts, tests/fixtures/wire_annotations.json,
docs/mcp-tools.md.

Annotator core

A tool can no longer be derived from a class alone, so toolFor gains the tool the host
holds and resolves: it keeps that tool when the class accepts it and falls to the class's
first drawable geometry when it does not. An active tool the selected class forbids is
unrepresentable, because one function decides and it never returns one.

InputHost gains activeTool. Without it activate-class compares two class defaults
rather than two resolved tools and misses a real move — a host drawing polygons under a
both-shapes class that switches to a boxes-only one does change tool, and a polygon in flight
has to be cancelled.

isTaggableClass and drawableGeometries stop being each other's negation, since a class may
accept a tag and a shape. classAction therefore sends toggle-tag only when the class
draws nothing: folding a drawable class into it would tag the asset where somebody pressing a
class digit meant to arm it.

Schema editor and the rescue flow

The single-select becomes a checkbox group under the same category headings, using the
native input this form already uses for an attribute's required flag — no new dependency and
no new primitive. The last ticked box does not come off and carries why (title +
aria-disabled, never the real attribute, so the keyboard still reaches the explanation).

A name the published version already declares is an offer, not an error. The alert says what
the class accepts today and what publishing would add; the primary reads Add polygon to “sign”. It carries the existing class's colour and attributes — a form opened to make a new
class must not quietly wipe what the old one declared — and goes out through the ordinary
create_version path, so it is an ordinary additive schema change. The refusal that remains is
a name typed twice in one sitting, which has nothing to offer because both entries are being
written now.

composeVersion replaces a same-named class in place: two classes with one name is what
create_version refuses outright, and appending would also renumber the digit hotkeys, which
are positions in the authored order.

Tool strip

Narrows to the held class's own geometries; with no class selected it is the schema's union, as
before. The page holds the preferred tool beside the drawing class at job scope, for the
query-key reason the drawing class is already there. Pressing a tool the held class already
accepts changes only the tool and never the class — a strip that re-armed the geometry's
first declaring class would silently retarget somebody's labels.

Two defects found while building this

  1. preventDefault() on a checkbox's click does not cancel React's onChange. The first
    draft refused the last ticked box that way; React synthesises a checkbox's onChange from
    the same native click, so the class went to an empty set while the tick stayed on screen — a
    control lying about what it had just done. The refusal now lives where the value is computed,
    which cannot come apart. The new test is what caught it.
  2. export_wire_fixtures.py asserted populated.geometry is GeometryType.BBOX and would
    have silently regenerated a fixture from a different class had the assertion not been there.

Tests

  • Kernel: the gate accepts every geometry in a set and refuses one outside it; a stored
    pre-A label class accepts a set of geometries, not one #584 schema row (rewritten through the store, since no service can produce that state)
    still loads; a v1 manifest still parses; canonical_bytes is deterministic; the diff calls
    widening additive and narrowing destructive.
  • Export: a mixed-geometry class per format, counts read off the artifacts — YOLO splits it into
    a whole half and a degraded half, COCO carries both.
  • Frontend: the checkbox group, the last-box refusal, the rescue offer and the merge it
    performs, composeVersion's four rules, the tool strip's narrowing, and both directions of
    the retarget guard.
  • Browser: cycle.spec.ts now authors a two-shape class against a real create_version.

Every new rule was verified by breaking it. Two mutation batteries, 17 cases, each asserting
its anchor before and after and reverting by the recorded diff with a clean-tree check between
cases. Two came back green and both were real gaps, now closed: no fixture had a class that was
both taggable and drawable, and none had a two-shape class to see the retarget guard. The
retarget site is a conditional, so both directions were mutated separately.

Test plan

Full bash scripts/check.sh, run in stages against the ~10-minute harness ceiling, plus the
docs group (opt-in, and this change edits docs/). Every stage's exit code:

Stage Command Exit
python 1/3 uv run pytest tests/kernel tests/cli tests/mcp -q 0
python 2/3 uv run pytest tests/server tests/formats tests/inference tests/jobs -q 0
python 3/3 uv run pytest tests/architecture tests/examples tests/packaging tests/scripts tests/test_versioning.py -q 0
static ruff check · ruff format --check · mypy src/visionset · lint-imports 0 (4 contracts kept)
generated bash scripts/check.sh generated 0
frontend bash scripts/check.sh frontend 0 — annotator 1039, ui-core 993
browser bash scripts/check.sh browser 0 — e2e 271 passed, cycle 1 passed
docs bash scripts/check.sh docs 0

The test directory list was read from ls tests/ at run time, not remembered.

Found, not fixed

  • The project's Schema tab has no duplicate-name detection at all. SchemaEditor.save()
    checks only dirty + blank name, so a collision there still surfaces as a raw INVALID_SCHEMA
    banner title after Save. Widening a class is already possible there by editing the class
    directly, so no rescue flow was added — but the two surfaces now answer the same mistake
    differently.
  • INVALID_SCHEMA carries no structured detail. The colliding class name is prose inside
    message, which docs/api.md says is not part of the contract. Nothing here depends on it —
    the rescue flow reads the schema the dialog already has — but a client wanting to build the
    same flow from the wire alone cannot.
  • INVALID_SCHEMA is not in REFUSAL_PROSE, so it renders with its raw code as the alert
    title. Pre-existing.

Flags for Armando

  1. The tool strip's narrowing removes a route that existed. Pressing the polygon button used
    to arm the first polygon class from anywhere; with a class selected the strip now shows only
    that class's shapes, so switching to another class's geometry goes through the class list.
    This is what the dispatch specifies, and DESIGN.md now states it — worth a look in the
    browser before it ships, since it changes a gesture people have.
  2. MANIFEST_VERSION moved to 2. Nothing refuses a higher version today (the field is
    documentary, ge=1), so the bump buys an honest record rather than a behaviour. I did not
    add the refusal check — no caller asked for one. Say if you want it.
  3. MCP accepts both spellings, because it takes the domain model directly and that is where
    the back-compat read lives. Stated in the tool docs rather than hidden. REST accepts only the
    new one.
  4. The widening publishes the existing class's colour and attributes, not the form's. The
    alternative — letting the dialog's blank colour through — would overwrite what the class
    already declared. It does mean the colour picker on screen is not what gets published in that
    one case; the alert says so.
  5. describeGeometries copy ("One shape for now. Tick another and this class accepts
    both.") is mine, not yours. Reword freely.

cf. #584


The workspace surfaces, after a class became a set

Five commits on top of the above, from looking at the running app rather than at a test. Only
one of the four findings was the reported symptom.

One vocabulary for geometries, and it is not the wire's

The interface was showing users database identifiers, in two vocabularies. ToolPalette had
a private TOOL_LABELS saying Box; class rows, the reassignment menu, the add-a-class dialog's
checkboxes and prose and its primary button, the schema editor's badges and the project summary
all printed the raw GeometryType. So one thing was Box on the left of the canvas and bbox
on the right, and a tag class's row read classification_tag.

GEOMETRY_LABELS now lives beside GEOMETRY_CATEGORY, total over the union by satisfies.
Lowercase — box · polygon · polyline · tag — because the same word is read as a chip in a row
and inside a sentence; the strip capitalises at its own control, so there is one source and one
transform. The test states the rule as never starts with a capital rather than is
lowercase
, which is a correction the test itself forced: 3D box is an acronym and the stricter
rule would have demanded 3d box, wrong in every position.

It is also the largest width saving available in the class list — a tag class's row spent ~110px
of 248 on classification_tag and now spends 22 on tag, against the 32px widening the entire
panel buys.

The armed class row is the shape picker

Arming a class stopped answering which shape the next drag produces, and the only place that
answer lived was the tool strip at the far left of the canvas while the class was chosen on
the right — one decision split across the width of the picture, in a loop repeated hundreds of
times a job.

The armed row's geometry words become a segmented control: active shape lit, pressing another
switches the tool and never the class. That retarget rule already shipped in ToolPalette
and is tested in both directions, so the panel is a second caller of an existing rule.

Only the armed row, and the accessible answer and the density answer agree. ClassListRow is
documented as "a real <button> spanning the whole row" and HTML forbids interactive descendants
inside a button — so a row offering a choice becomes a group with an inner name button. That is
also what you want at fifty classes: an unarmed row has no live choice, and fifty pickers for one
decision is noise. Exactly one row is armed, so the extra tab stops are bounded at (shapes − 1).

toolForClass is extracted from toolFor and exported, because a list iterating schema.classes
holds the class and has no document; writing drawable.find(…) ?? drawable[0] in the panel would
be two spellings of one fallback, which is how a strip and a panel come to disagree about which
shape is lit. It is also why the lit segment resolves through it rather than comparing the raw
preference — the held tool may be one this class forbids.

The dialog, and a second defect on the same line

ClassFields splits Name | Geometry on md:, a viewport breakpoint rather than a container
one, so on any desktop the grid splits however narrow the box is: at the default max-w-lg each
column was ~224px against a geometry row needing ~269px. max-w-2xl is the smallest that clears
it.

Same string, second defect: the dialog carried no max-h and no scroll, and DialogContent is
centred with -translate-y-1/2 — content taller than the viewport overflowed off both edges and
took the footer with it, which a class with a few attributes reaches.

Panel width — measured, and mostly declined

The emptiness in the reported screenshot is vertical, not horizontal. 2xl:w-80 gives 32px
of headroom at 1536px and nothing below it, because ANNOTATOR_MIN_VIEWPORT_PX is 768, where
a collapsed rail already leaves a 384px stage — a width chosen on a large monitor must not be
charged to the smallest screen the editor opens on. EditorNotice's clearance arithmetic is
stated at 1280px and is untouched.

A fragility found in the suite itself

activate() in the cycle spec clicked the row's centre, which worked by about fourteen
pixels. Once an armed row carries a picker, a longer name or a third shape moves that centre onto
a shape segment — and the press would switch the tool while data-selected still read true, so
every assertion around it kept passing. Both markups now expose a -name handle.

Tests

Seven new panel tests and six vocabulary tests. Mutation-verified: 7 cases, 7 red, each
naming its intended test — every row sprouting a picker, a single-shape class sprouting one, the
picker moving the class, the lit segment reading the raw preference, a tag counting as a drawable
shape, the labels collapsing back to wire values, and the middot reverting to "or". Committed
before the first mutation and reverted by the recorded diff with a clean-tree check between cases.

Checkboxes are addressed by data-testid, which keeps the wire value, so a test says which
geometry without also asserting what it is called — that belongs to geometryCategory.test.ts.

Rebased onto main, and what that turned up

Three PRs landed in schema_service.py while this branch was open — #583, #589, #590 — plus
#591, which rewrote the add-a-class dialog this branch also touches. Rebased onto 7d99bae.
The conflicts were adjacency and resolved by keeping both sides; three things needed judgement.

The repin step is gone, and its tests go with it. #591 moved the pin into the kernel, so
runAddClass is save → publish and steps.repin no longer exists. This branch's
when the batch will not take the pin block was dropped rather than merged — it drives a
parameter that is not there. composeVersion's own tests stay.

SchemaEditor's draft comparison had a real defect, not a rename. #583's canonical
projection compared declared.geometry. Swapping in the array is not enough: a set has no order,
and the two sides spell it differently — the domain sorts, the draft holds tick-order. Untick a
shape a class already had and tick it back, and the editor offered to publish a version identical
to the one in force. Fixed with a sort, and verified by removing it, which reddens
does not call a reordered geometry set an unsaved change and nothing else.

That test asserts dirty rather than round-tripping a save, and the first version of it did
round-trip — passed, and passed just as happily with the fix removed. A save cannot see this,
because the draft is re-based onto the wire's own copy afterwards and both sides come out
canonical whatever the comparison does. Worth stating because it is the second time on this
branch that a test which looked right proved nothing.

The cycle spec posted the retired key. #591's own real-server walk was written before a class
held a set, so its publish answered 422. The browser suites were again the only ones that saw it:
the class list that line extends comes back off the wire already plural, so no Python or vitest
test reads that payload.

A limit this branch creates, stated rather than shipped quietly — #592

Removing one of a class's geometries is refused whenever any annotation exists under that
class, even when none of them carry the shape being removed and nothing would be orphaned.

SchemaDiff.destructive_classes is a set of names, and that set is what reaches
add_schema_version_unless_annotated, whose predicate asks whether the project holds any car
never whether it holds one drawn as the shape being dropped. While a class held a single geometry
those were the same question; a set makes the gate coarser than the question it stands in for.

Conservative in the safe direction — it refuses rather than orphaning — so no data is lost and no
invariant moves. Not fixed here because making it exact changes the port method and the
guarded-insert contract #589 landed for the TOCTOU race, which needs its own red-before-green pass
on the concurrency tests. Filed as #592, documented in docs/schemas.md, and pinned by
test_dropping_one_geometry_of_several_is_refused_by_the_class_not_the_shape — a tripwire to
invert when the gate learns about geometry, not to delete.

Verified end to end against the kernel

The loop that motivated the change, run for real on the rebased tree: a batch pinned to v1 where
car accepts bbox, publish car accepting bbox · polygon — additive, no flag — the pin
advances by itself (#591), and a polygon car is then accepted into that already-open batch.

Gate

Re-run in full on the rebased tree, in stages against the ~10-minute ceiling.

Stage Exit
pytest (kernel, architecture, formats, versioning) 0
pytest (server, cli, mcp) 0
pytest (jobs, inference, examples, packaging, scripts) 0
ruff format --check / ruff check / mypy src/visionset / lint-imports 0, 0, 0, 0 — 4 contracts kept
bash scripts/check.sh generated 0 — no drift
bash scripts/check.sh frontend 0
bash scripts/check.sh browser 0 — e2e 271, cycle 1
bash scripts/check.sh docs 0

Found, not fixed

  • ClassFields' grid should be a container query. Tailwind v4 supports @container, and a
    grid splitting on the viewport inside a fixed-width box is the root cause of the dialog being
    undersized. Not taken here because it would make the dialog stack rather than widen, which is
    the opposite of what was asked — but it is the better fix and the width is the workaround.
  • No keyboard route to a shape. b/p are unclaimed; digits arm classes and v is select.
    A keyboard-first annotator cannot switch shape at all. Recorded rather than invented under time
    pressure.

Flags for Armando

  1. At three or more shapes the class name truncates (~58px of 248). The full name is on hover
    and DESIGN.md states the ceiling. This is the accepted cost of words over glyphs.
  2. The tool strip and the panel now both change the tool, and the strip additionally moves the
    class when the held one cannot draw the shape pressed. Two controls, one state — worth
    confirming it feels right in the browser, which is what Tier B is for.
  3. GEOMETRY_LABELS names the four unimplemented geometries too (mask, keypoints,
    3D box, 3D polyline). They reach a user only through a refusal today. Reword freely.

`LabelClass.geometry` becomes `geometries`, a non-empty deduplicated tuple kept
in one sorted order. A class labelled as a box on some frames and as a polygon on
others is one class; splitting it in two made every consumer downstream re-unify
them, and COCO's own docstring already recorded the cost.

The write gate in `AnnotationService._validate` becomes a membership test in that
class's own set — still not the version's union, which is wider. `schema_diff`
answers the module's governing question per geometry, the shape the `select`
options rule already had: one added is additive, one removed is destructive. So
widening a class is an ordinary save and narrowing stays behind the flag.

No migration. `annotation_schema.classes` is a JSON column, so a
`model_validator(mode='before')` on `LabelClass` reading the retired singular key
covers stored schemas and stored release manifests alike — and MCP, which takes
the domain model directly. `LabelClassBody` deliberately does not read it: a REST
client sending it is better told so than silently reinterpreted.

`MANIFEST_VERSION` moves to 2, since `Manifest.classes` carries these verbatim.
Documents already published keep their bytes and their hashes and still load, so
every existing release stays verifiable.

The export report is now one row per (class, geometry). Keyed by class alone it
carried one verdict for a class YOLO answers twice — writing the boxes whole and
reducing the polygons — and would have misdescribed half its own output. No
exporter changed: every one of them already branches per annotation.

cf. #584
…ved from it

`LabelClass.geometries` mirrors the wire, and `toolFor` gains the tool the host
currently holds: it keeps that tool when the class accepts it and falls to the
class's first drawable geometry when it does not. **An active tool the selected
class forbids is unrepresentable**, because one function decides and it never
returns one.

`InputHost` gains `activeTool`. Without it `activate-class` compares two class
defaults rather than two resolved tools, and misses a real move: a host drawing
polygons under a both-shapes class that switches to a boxes-only one does change
tool, and a polygon in flight has to be cancelled.

`isTaggableClass` and `drawableGeometries` stop being each other's negation — a
class may accept a tag and a shape — so `classAction` sends `toggle-tag` only when
the class draws nothing. Folding a drawable class into it would tag the asset
where somebody pressing a class digit meant to arm it.

`allowedGeometriesFor` filters instead of wrapping a scalar, which its own
docstring had predicted was the only change a set would need.

cf. #584
…er than tags

The one rule the mutation battery found nothing watching: no class could be both
before #584, so `classAction`'s two tests had never been ordered against each
other. `PALETTE` gains a seventh row that is a tag and a box at once.

cf. #584
…exists is an offer

The schema editor's single-select becomes a checkbox group under the same
category headings, using the native input this form already uses for an
attribute's `required` flag — no new dependency, no new primitive, and what a
class accepts is readable without opening anything. The last ticked box does not
come off and carries why.

**A defect the new test caught, worth stating:** the first draft refused that last
box with `preventDefault()` on the input's click. React synthesises a checkbox's
`onChange` from the same native click, so cancelling the click does not cancel
the change — the class went to an empty set while the tick stayed on screen, a
control lying about what it had just done. The refusal now lives where the value
is computed, which cannot come apart.

**The rescue flow.** A name the published version already declares stops being a
red box and becomes an offer: the alert says what the class accepts today and
what publishing would add, and the primary reads `Add polygon to sign`. It
carries the **existing** class's colour and attributes, so a form opened to make
a new class cannot quietly wipe what the old one declared. The refusal that
remains is a name typed twice in one sitting, which has nothing to offer because
both entries are being written now.

`composeVersion` replaces a same-named class **in place** rather than appending:
two classes with one name is what `create_version` refuses outright, and
appending would also renumber the digit hotkeys, which are positions in the
authored order.

The tool strip narrows to the held class's own geometries, and the page holds the
preferred tool beside the drawing class at job scope, for the query-key reason
the drawing class is already there.

cf. #584
The interface was showing users database identifiers, in two vocabularies.
`ToolPalette` had a private `TOOL_LABELS` saying `Box`; every other surface —
class rows, the reassignment menu, the add-a-class dialog's checkboxes and prose
and its primary button, the schema editor's badges, the project summary — printed
the raw `GeometryType`. So one thing was `Box` on the left of the canvas and
`bbox` on the right, and a tag class's row read `classification_tag`.

`GEOMETRY_LABELS` lives beside `GEOMETRY_CATEGORY` in the module that already owns
geometry presentation, total over the union by `satisfies` so a ninth member fails
the build until somebody names it. The strip capitalises at its own control; every
other caller reads the word as-is.

**Lowercase, because the same word is used two ways** — as a chip in a dense row
(`box · polygon`) and inside a sentence ("Publishing adds polygon to it"). Only
the first letter is a sentence-position question, which the test states as *never
starts with a capital* rather than *is lowercase*: `3D box` is an acronym and the
stricter rule would have forced `3d box`, wrong in every position.

`formatGeometries` joins with ` · ` rather than ` or `. A middot is what a set
reads as at this density, and in a 248px row those four characters come out of the
class name.

It is also the largest width saving available in the class list — a tag class's
row spent about 110px of 248 on `classification_tag` and now spends 22 on `tag`,
against the 32px widening the whole panel would buy.

Tests address a checkbox by `data-testid`, which keeps the wire value, so a test
says *which* geometry without also asserting what it is called.

cf. #584
Arming a class stopped answering which shape the next drag produces, and until
now the only place that answer lived was the tool strip at the **far left** of the
canvas while the class was chosen on the right — one decision split across the
width of the picture, in a loop repeated hundreds of times a job.

The armed row's geometry words become a segmented control: the active shape lit,
pressing another switches the tool **without moving the class**. That retarget
rule already shipped in `ToolPalette` and is tested in both directions, so the
panel is a second caller of an existing rule rather than a new one.

**Only the armed row, and the accessible answer and the density answer agree.**
`ClassListRow` is documented as "a real `<button>` spanning the whole row", and
HTML forbids interactive descendants inside a button — so a row offering a choice
has to become a group with an inner name button. That is also what you want at the
fifty-class ontologies principle 7 is written for: an unarmed row has no live
choice, and fifty pickers for one decision is noise. Exactly one row is armed, so
the extra tab stops are bounded at (shapes − 1).

The row is gated on `drawableGeometries`, not `geometries`: a class may accept a
tag beside a box, and a tag has no canvas gesture — offering it would be a tool
the canvas cannot answer.

`toolForClass` is extracted from `toolFor` and exported, because a list iterating
`schema.classes` holds the class and has no document. Writing
`drawable.find(…) ?? drawable[0]` in the panel instead would be two spellings of
one fallback, which is how a strip and a panel come to disagree about which shape
is lit — the one thing they must not do. It is also why the lit segment resolves
through it rather than comparing the raw preference: the held tool may be one this
class forbids.

The panel takes `2xl:w-80`. The extra 32px is headroom for a class naming three
shapes, not a fix, and it is withheld below 1536px on purpose —
`ANNOTATOR_MIN_VIEWPORT_PX` is 768, where a collapsed rail already leaves a 384px
stage, and a width chosen on a large monitor must not be charged to the smallest
screen the editor opens on at all. `EditorNotice`'s clearance arithmetic is stated
at 1280px and stays true.

cf. #584
…n footer

`ClassFields` splits Name | Geometry on `md:`, a **viewport** breakpoint rather
than a container one, so on any desktop the grid splits however narrow the box is
— at the default `max-w-lg` each column was ~224px against a geometry row needing
~269px, and the checkboxes wrapped onto three lines. The box has to be wide enough
for a split it cannot prevent. `2xl` is the smallest that clears it.

Second defect, same string: the dialog carried no `max-h` and no scroll, and
`DialogContent` is centred with `-translate-y-1/2` — so content taller than the
viewport overflowed off both edges and took the footer with it, which a class with
a few attributes reaches.

cf. #584
`activate()` clicked the row's centre. That worked by about fourteen pixels: once
an armed row carries a shape picker, a longer class name or a third shape moves
that centre onto a shape segment — and the press would switch the **tool** while
`data-selected` still read true, so every assertion around it kept passing.

Both markups now put a `-name` handle on the name, so choosing a class has one
target whether the row is a plain button or a group.

cf. #584
…e docs

Found by checking this branch against `main` rather than by a conflict: a class
that can hold two shapes makes the orphan gate coarser than the question it
stands in for.

`car` accepts `bbox · polygon` and the project holds one **bbox** `car`. Taking
`polygon` away orphans nothing, and is refused anyway by the refusal no flag
overrides. `SchemaDiff.destructive_classes` is a set of *names*, and that set is
what reaches `add_schema_version_unless_annotated`, whose predicate asks whether
the project holds any `car` — never whether it holds one drawn as the shape being
removed. While a class held a single geometry those were the same question.

Conservative in the safe direction: it refuses rather than orphaning, so nothing
is lost and no invariant moves. Pinned rather than fixed because making it exact
changes the port method and the guarded-insert contract #589 landed for the TOCTOU
race — argued in #592, which the test names and which `docs/schemas.md` links.

The test is a tripwire for that work: invert it when the gate learns about
geometry, never quietly delete it.

cf. #584, #592
… know

Integration with what landed on main while this branch was open — three
places where code that arrived after the geometry sweep still spelled a class's
shape singular, and one that is a real defect rather than a rename.

`SchemaEditor`'s `canonical` projection — #583's fix for a draft that read as
dirty against the version it had just published — compared `declared.geometry`.
That field is gone, and swapping in the array is not enough: the two sides spell
one set differently. The domain sorts and dedupes, so the active version always
reads canonical, while a draft's copy is whatever order the boxes were ticked in.
Untick the shape a class already had and tick it back, and the editor offered to
publish a version identical to the one in force.

Verified by breaking it: with the sort removed, `does not call a reordered
geometry set an unsaved change` fails and the other nine in that file pass.

Asserted on `dirty` rather than through a save, and the reason is worth writing
down — going through a save **cannot see this**. The draft is re-based onto the
wire's own copy afterwards, so both sides come out canonical whatever the
comparison does; the first version of this test round-tripped a save, passed, and
passed just as happily with the fix removed.

The other two are renames in fixtures that arrived with #590 and #591:
`test_schema_refusals.py` and `test_batch_tools.py` posted the retired `geometry`
key, which `LabelClassBody` refuses with `extra_forbidden` — the wire is strict
on purpose while `LabelClass` still lifts the old spelling, so stored documents
keep loading and a client sending it is told rather than reinterpreted.

cf. #584
#591's own addition to the cycle, written before a class held a set. The wire
refuses the singular spelling with `extra_forbidden`, so the publish that walk
makes to prove a pin advances answered 422 and the whole cycle failed on it.

The browser suites were again the only ones that saw it: the class list this
line extends comes back off the wire already plural, so nothing in Python or in
vitest reads this payload.

cf. #584
@JArmandoAnaya JArmandoAnaya added this to the 0.1.0 milestone Aug 15, 2026
@JArmandoAnaya
JArmandoAnaya merged commit 6d737f4 into main Aug 15, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/multi-geometry-classes branch August 15, 2026 13:37
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.

A label class accepts a set of geometries, not one

1 participant