Skip to content

refactor(ui): one marking surface, invoked two ways - #162

Merged
pskeshu merged 1 commit into
gently-project:developmentfrom
pskeshu:feat/one-marking-surface
Sep 6, 2026
Merged

refactor(ui): one marking surface, invoked two ways#162
pskeshu merged 1 commit into
gently-project:developmentfrom
pskeshu:feat/one-marking-surface

Conversation

@pskeshu

@pskeshu pskeshu commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Net −339 lines. The question "why are there two tabs of embryos" surfaced this.

There were two complete marking implementations:

static/js/marking.js (452 lines) operate.js bottom-cam pane
where Embryos tab, "Marking" subtab Devices → Operate
own canvas marking-canvas op-mark-canvas
own hit-test yes yes
initiated by the agent (marking_image switched you there) the operator

So every improvement to marking landed on exactly one of them. The zoom, the display range, #105's hit-test radius and #126's roster aliasing were all on the Operate side; none of it existed on the agent's side. Whether an operator got the corrected behaviour depended on who had asked them to mark.

One surface

The agent's request now lands on the Operate pane. The pushed image is adapted into the payload shape a live camera frame already has — shape, downsample, stage_position, jpeg_b64 — so frameOf, stageOf, drawMarkers, onCanvasClick and the corrected hit-test apply with no special cases.

That's the point: the agent's request inherits zoom, geometry and the display range because it is the same surface, not because anything was reimplemented.

Initial markers arrive in pixels and convert to stage µm once, at the boundary — this pane keeps markers in stage coordinates so they stay attached to the sample under zoom and stage motion. The answer converts back.

pixel_size_um is a session parameter, not the rig default, threaded by pxBase(). A session declaring a different scale would otherwise place every marker at 0.65 µm/px.

The contract is preserved

It's a websocket request/response, not an HTTP call — the agent blocks on session["complete"] in routes/websocket.py and reads a role per marker. So marking_done goes back with the session id, pixel coordinates and a role each; marking_redetect still works; and the session panel carries a per-marker role toggle, because registered embryos get roles in the Acquisition roster but these aren't registered yet. A reference is tagged on the glyph too — it has to be distinguishable on the image, not only in the panel.

The session UI is present only while the agent is waiting.

Deleted

marking.js, the embryos-marking markup, the subtab bar (one subtab left is chrome for a choice that no longer exists), and 222 lines of CSS across 31 rules that became unreachable.

Verified live

The agent's frame lands on Devices → Operate → Bottom cam, image shown, session panel seated, two initial markers carrying the roles they arrived with. Cycling a role, re-detect and Done all answer over the websocket, and pixel coordinates round-trip exactly(100,80) and (220,140) in, the same out.

And the motivating claim: zoomed to 1.75×, clicking the frame centre put a marker back at exactly (200,150), with the display histogram available on the pushed image. Neither was possible on the old surface.

There were two complete implementations, and the question "why are there two
tabs of embryos" is what surfaced it.

`static/js/marking.js` — 452 lines with its own canvas, its own hit-test, its
own marker list — lived in the Embryos tab behind a "Marking" subtab, and
`websocket.js` switched the operator into it when the agent sent a
`marking_image` frame. `operate.js` had the other, on the bottom-camera pane,
for an operator marking unprompted.

So every improvement to marking landed on exactly one of them. The zoom, the
display range, gently-project#105's hit-test radius and gently-project#126's roster aliasing were all on
the Operate side and none of it existed on the agent's side. Whether an
operator got the corrected behaviour depended on who had asked them to mark.

The agent's request lands on the Operate pane. The pushed image is ADAPTED into
the payload shape a live camera frame already has — `shape`, `downsample`,
`stage_position`, `jpeg_b64` — so `frameOf`, `stageOf`, `drawMarkers`,
`onCanvasClick` and the corrected hit-test all apply with no special cases.
That is the point: the agent's request inherits zoom, geometry and the display
range because it is the same surface, not because anything was reimplemented.

Initial markers arrive in pixels and are converted to stage µm once, at the
boundary, because this pane keeps markers in stage coordinates so they stay
attached to the sample under zoom and stage motion. The answer converts back.

`pixel_size_um` is a session parameter rather than the rig default, threaded
through the geometry by `pxBase()` — a session declaring a different scale
would otherwise place every marker at 0.65 µm/px.

It is a websocket request/response, not an HTTP call: the agent blocks on
`session["complete"]` in `routes/websocket.py` and reads a role per marker out
of the answer. So `marking_done` goes back with the session id, pixel
coordinates and a role each, `marking_redetect` still works, and the session
panel carries a per-marker role toggle — registered embryos get their roles in
the Acquisition roster, but these are not registered yet. A reference is also
tagged on the glyph, since it must be distinguishable on the image and not only
in the panel.

The session UI is present only while the agent is waiting.

marking.js, the `embryos-marking` markup, the subtab bar (one subtab left is
chrome for a choice that no longer exists), and 222 lines of CSS that became
unreachable — 31 rules, removed rather than left to rot.

The agent's frame lands on Devices → Operate → Bottom cam with the image shown,
the session panel seated, and two initial markers carrying the roles they
arrived with. Cycling a role, asking for a re-detect and pressing Done all
answer over the websocket, and pixel coordinates round-trip exactly: (100,80)
and (220,140) in, the same out.

And the claim that motivated it — zoomed to 1.75x, clicking the frame centre
put a marker back at exactly (200,150), the frame centre, with the display
histogram available on the pushed image too. Neither was possible on the old
surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pskeshu
pskeshu force-pushed the feat/one-marking-surface branch from 1fcee0e to 19de950 Compare September 6, 2026 02:42
@pskeshu
pskeshu merged commit c979d80 into gently-project:development Sep 6, 2026
2 checks passed
pskeshu added a commit that referenced this pull request Sep 6, 2026
The Light panel showed everything at once, and with no device layer that meant
four disabled power sliders reading an em dash — maximum clutter for zero
information.

Beam and power are now revealed under the Laser row when the config routes
something, and hidden when it does not.

## Scope, not dependency

The nesting groups the laser's own settings. It deliberately does NOT imply
`BeamEnabled` follows from the config, because it does not — emission is
conjunctive:

    emitting = armed AND routed AND power > 0

#106 is exactly what happens when someone assumes otherwise: a correctly
configured laser that emits nothing because the beam is off. So the indent
carries the grouping and the contradiction gets a line of its own rather than
being softened by it. Routed lines with the beam off now reads "this
configuration will not emit", and names the cause — every volume acquisition
leaves BeamEnabled at No.

The mirror case is handled too: an armed beam with nothing routed is safe,
surprising, and the state the rig is left in, so a note on the Laser row keeps
that fact visible. Hiding the detail must not hide the fact, or the disclosure
would have made the panel less honest than the flat version it replaced.

`routedLines()` is for display and answers "which lines do we KNOW are routed".
`wavelengthsOf()` is unchanged and still answers "which could be involved",
returning every line for an unknown config — right for `emitting()`, because an
unread config with unread power must come out unknown rather than safe.

The config select now always includes what is actually set. The preset list
comes from an endpoint that 503s with the device layer down, so the select read
an em dash while the detail below it showed routed lines and live power — two
halves of one panel contradicting each other.

## A regression fixed on the way, unrelated to the above

The device-layer strip's Start/Stop/Log buttons had been borrowing
`.marking-action-btn` from the old marking surface. Deleting that surface
(#162) took their styling with them, and they have been rendering unstyled
since.

My liveness check behind that deletion used a regex requiring a character
before "marking", so class names STARTING with it were never recognised as
live. `.marking-action-btn` was in use by markup that had nothing to do with
marking, and its rules went anyway. Second over-deletion this session with the
same root cause: bulk removal on an imperfect liveness test.

Re-homed onto `.devices-layer-btn`, which the buttons already carried, so the
name now says what it styles. The borrowed class names are gone from the
markup, and a test pins both.

Verified live across all four states: unknown reveals nothing (0 sliders, was
4), ALL OFF with an armed beam reveals nothing but shows the note, routed with
the beam off reveals the detail and states the contradiction, routed and armed
reveals it and reads EMITTING. Start is blue again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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