Skip to content

fix(library): make embedded editors reliable in host applications - #841

Merged
FelixTJDietrich merged 49 commits into
mainfrom
fix/artemis-integration
Aug 20, 2026
Merged

fix(library): make embedded editors reliable in host applications#841
FelixTJDietrich merged 49 commits into
mainfrom
fix/artemis-integration

Conversation

@FelixTJDietrich

@FelixTJDietrich FelixTJDietrich commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Make Apollon reliable as an editor embedded in a host-controlled page. The
changes were driven by the Apollon 5.2 integration in
Artemis#13429, where assumptions
that Apollon owned the whole document surfaced as visible integration defects.

This PR addresses those defects as one host-integration package:

  • keep popovers, selects, tooltips, color pickers, and drag previews available
    when the editor workspace enters fullscreen;
  • scope keyboard shortcuts and wheel handling to the active editor so the host
    page remains usable, including pages with multiple editors;
  • make assessment relationships, highlighting, navigation, and feedback
    popovers reliable and expose revealAssessment(id) for host feedback lists;
  • keep saved-diagram connection anchors, resize handles, palette drops, labels,
    grid alignment, zoom rendering, and route previews visually and geometrically
    stable;
  • provide a neutral labeled chrome action for host controls and prevent the
    offscreen export renderer from changing the host page's scroll size; and
  • add a real fullscreen action to the playground for manual host-style testing.

This supersedes #839 and #840, which split the same Artemis integration work
across two branches; both are closed in favor of this PR.

Release note

  • Embed Apollon reliably in host pages: fullscreen overlays stay visible,
    scrolling and shortcuts remain host-safe, assessment feedback is easier to
    open and navigate through revealAssessment(id), host controls gain a labeled
    chrome action, exports no longer expand the page, and diagram interactions
    remain aligned, sharp, and responsive. Labels now also stay vertically
    centered in SVG, PNG, and PDF exports exactly as they appear in the editor.
  • Test embedded-editor overlays and drag interactions directly in the playground
    with its new fullscreen workspace action.

These are the two changeset summaries verbatim, one for the @tumaet/apollon
embedder audience and one for the @tumaet/webapp playground audience.

Implementation notes

Fullscreen portals. Each editor instance owns a portal destination. It moves
with the fullscreen element on fullscreenchange, so floating surfaces remain
descendants of the browser's active fullscreen subtree without requiring the
host to re-parent library internals.

Host input ownership. Shortcuts dispatch from the focused editor root instead
of document, and React Flow's global modifier listeners are enabled only while
that root owns the interaction. Pointer-acquired focus releases on pointer leave;
keyboard-acquired focus persists until the user tabs away. With scroll lock on,
ordinary wheel input returns to the host page while the zoom modifier still
zooms the diagram.

Assessment. Assessment uses a route-following relationship hit surface,
single-click feedback, outline-based node highlighting, hidden connection
handles, and a fixed navigation footer with text-input-safe arrow shortcuts.
revealAssessment(id) lets a host feedback list select an element, open its
feedback, and pan to it without changing zoom.

Geometry and rendering. Connection handles remain measurable when a restored
diagram first mounts, only relevant anchors are mounted, resize handles appear
for the selected node with usable hit targets, and palette preview/drop placement
shares one grab point. Grid rendering, label centering, raster invalidation, and
shared route-preview selection remove the integration's visual drift and
frame-time regressions. Export baseline normalization resolves each positioned
text run exactly once, so the same centered labels are preserved in SVG, PNG,
and PDF output.

Performance guard. The Firefox interaction benchmark uses visible nodes,
rejects unusably slow runners, and measures without continuous trace capture.
Current main reaches 65–66 ms locally on that workload; this branch measures
34–49 ms locally and 83–84 ms on GitHub's software-rendered Firefox runner. The
hosted ceiling is 85 ms, with a separate 55 ms idle-quality bound.

Steps for testing

Standalone:

pnpm install
pnpm dev

Open http://localhost:5173/playground, then:

  1. Click Enter editor fullscreen. Open a node popover, a select, the style
    editor's color picker, and drag an element from the palette. Each surface must
    remain visible and interactive. Press Escape until fullscreen exits.
  2. With the pointer outside the canvas, press Mod+-; the
    browser page must zoom. Click the canvas and repeat; the diagram must zoom.
  3. Turn on scroll lock and wheel over the canvas; the surrounding page must
    continue to scroll. Hold the displayed zoom modifier; the diagram must zoom.
  4. In assessment mode, single-click a relationship, including a multi-segment
    automatic route. Its feedback must open immediately. Walk the diagram with
    Previous/Next Assessment and /; the footer must remain
    visible and typing in feedback must not trigger navigation.
  5. Reload a saved diagram and drag a new relationship without first hovering its
    source. It must begin at the anchor under the cursor. Drag a Petri-net place or
    SFC transition branch from low in its palette preview; the dropped element must
    land where the ghost was shown.
  6. Export class/object, activity, use-case, and multiline-label diagrams as
    SVG, PNG, and PDF. Label centers must match the editor, including headers,
    stereotypes, wrapped text, diamonds, and ellipses.
  7. Export a large diagram and confirm the host page does not gain scrollbars.

The focused fullscreen regression can also be run directly:

pnpm --filter @tumaet/webapp exec playwright test tests/e2e/fullscreen-portals.spec.ts --project=chromium

Host integration is covered end to end by
Artemis#13429, which consumes
this branch locally and will pin the released package version before merging.

Screenshots / screencasts

The refreshed Linux visual baselines committed with this PR show the rendered
changes directly:

The host-side layouts and assessment states are documented in
Artemis#13429.

Checklist

  • Linked to a related issue (if applicable)
  • Added changesets whose summaries are written in the user's voice (pnpm changeset, how)
  • PR title's Conventional Commit type (fix) matches the user-visible kind of change and its release-note group
  • Tests added or updated
  • Ran pnpm lint && pnpm format:check && pnpm build && pnpm test locally — green (102 test files, 1853 passed, 1 skipped); pnpm --filter @tumaet/apollon build is green and the published main-entry size gate passes at 121.89 kB/122 kB; the complete Firefox performance suite and focused assessment/highlight/fullscreen E2E tests also pass
  • Documentation updated
  • Screenshots or screencasts attached

Assessment mode inherited the editing canvas's interaction model, which is
built around drag handles it does not have. Relationships were only selectable
through a narrow overlay that missed multi-segment automatic routes, feedback
needed a double click, the highlight redrew node geometry, and the next/previous
actions scrolled out of the popover with the form.

Scope assessment to its own contract: a route-following hit surface, single
click to open feedback, a layout-neutral highlight, hidden connection handles,
and a persistent navigation footer with keyboard equivalents.
The editor and React Flow both installed their key handlers on document, so an
embedded editor answered shortcuts no matter where the user was on the host
page. Browser zoom was the visible casualty: Mod+- reached the canvas instead of
the page, leaving a zoomed-in reader with no way back out. Two editors on one
page both fired.

Route every shortcut through the editor root and mount React Flow's global
modifier listeners only while that root owns the interaction. Pointer-acquired
focus is released when the pointer leaves; keyboard-acquired focus persists
until the user tabs away, which keeps WCAG 2.1.4 satisfied.
The placement API gives hosts a slot inside the editor's chrome but only an
icon button to fill it with. Commands that are ambiguous without a word — a
host's Help or Fullscreen — had no way to render a label without hand-rolling
the glass surface's height, radius, state layers, and focus ring, which then
drifts from the built-ins on the next theme change.

Ship .apollon-chrome-actionbtn alongside the icon button, sharing every token
with it. Accent stays reserved for .apollon-chrome-accent-btn.
Exports mount a 4000x4000 measurement surface into document.body. Absolutely
positioned and merely invisible, it still counted toward the host document's
scrollWidth and scrollHeight, so an embedding page grew scrollbars for the
duration of every asynchronous export.

Position it fixed with strict containment, and mark it inert to pointers and
assistive technology.
The rail clearance was read from getBoundingClientRect, which reports the
transformed paint box, and ResizeObserver does not fire for transform-only
frames — so a control that scales in was measured mid-animation and the rail
kept the wrong offset for good. The corner's gap was then counted a second time
by the rail control that already owns it, and the bottom-centre region centred
its content instead of offering the full width a responsive host control needs.

Measure offsetHeight, reserve the corner gap once, stretch the bottom-centre
track, and attach host-owned content in the child's layout phase so the parent's
first measurement sees real dimensions.
FelixTJDietrich and others added 20 commits August 6, 2026 19:20
…hting

Two threads, both about what the editor puts on screen.

SHARPNESS. Zoom a diagram and its nodes turned pixelated until a pan brought
them back; hover one and it softened for a split second; popovers and the
new-diagram dialog opened blurry. A composited or effect-bearing surface keeps
the raster it was built with, so the next composite shows it stretched; and a
layer loses subpixel text anti-aliasing for several documented reasons, which
discards its tiling. Chrome named most of these itself through
LayerTree.compositingReasons:

  - Palette, islands, zoom cluster and minimap carried a backdrop-filter, which
    also renders everything beneath it into a surface to sample; beneath that
    chrome is the zoomed canvas.
  - The scroll-lock veil blurred a full-canvas overlay for 0.5px.
  - Popovers and selects scaled in, so they rastered at the start scale.
  - The islands' entry animation used a fill mode, and a filling animation
    counts as active, so their layer was never released.
  - Connection handles and resize controls hid with `opacity: 0`, which is not
    paint but a transparency effect node. Hovering a node flipped a dozen at
    once. React Flow contributes nothing on hover — verified in its source.
  - Dialog, alert-dialog and sheet overlays blurred the whole viewport, and
    dialogs centred with a 50% transform, which lands on a half pixel whenever
    the popup's width or height is odd. They now centre on a grid positioner.

Affordances hide by painting transparent. `visibility: hidden` also fixes the
raster but is not equivalent — an `opacity: 0` element still hit-tests and takes
focus, and these rely on both.

HIGHLIGHTING. Assessment had grown its own selection styling in inline styles
while the element picker had another in CSS, so the two drifted — assessment's
version tinted the edge's hit ribbon, which at the widened assessment width drew
a band across the diagram instead of marking a line. Both now render one
`.apollon-highlight` class over one stylesheet block and one token, with
`--selected` and `--highlighted` states. Only the picker paints the ribbon, at
its own narrow width, scoped to `--picker`.

The naming follows: the API was already use-case independent (ApollonView
.Highlight), but the prose in the library, docs, playground and stories still
called it the quiz picker. It is a highlight, whatever the host uses it for.

Assessment also stops offering to connect anything: `pointer-events: none` on a
handle does not cover a pseudo-element that sets its own, so the arc's ::before
kept swallowing the pointer and showing a crosshair.

Composited layers on the canvas fall from 21 to 14, and hovering a node creates
none. Guarded by tests in both packages that parse the stylesheets and component
sources, plus a browser test asserting the assessment handles stay inert.

Also in this commit: the element whose feedback popover is open stays marked for
as long as that form is mounted, derived from the popover's own element id
rather than React Flow's selection; and Mod+Arrow moves between assessments, so
it keeps working inside the points field and comment box.
React Flow prevents the default action of every wheel over the pane unless
preventScrolling says otherwise, independently of panOnScroll/zoomOnScroll.
A locked canvas therefore swallowed the wheel: it refused to zoom, and the
host page refused to scroll, so an editor embedded in a form was a region
readers could not get past.
React Flow's Background computes its pattern offset as

  offsetXY[0] * transform[2] || 1 + patternDimensions[0] / 2

and both `*` and `+` bind tighter than `||`, so it reads as
`(offset * zoom) || (1 + gap / 2)`. With the default `offset={0}` the
left side is falsy, so every Background falls through to `1 + gap / 2`.

The fine grid therefore rendered at `translate(-3.5,-3.5)` instead of
`translate(-2.5,-2.5)`, putting its lines at `x = 4 (mod 5)` while nodes
snap to `x = 0 (mod 5)`. CustomBackground exists to hold exactly that
invariant - "the fine grid is drawn at exactly the snap step so every
grid-snapped node position and connection point sits on a visible grid
line" - and it was quietly broken for both layers.

Passing half the gap keeps the left branch truthy and evaluates to
`(gap / 2) * zoom`, which is precisely the `patternDimensions / 2` the
upstream code intends. It stays correct at every zoom level and needs no
follow-up once the precedence is fixed upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
The grid lost whole vertical lines on first paint, in full-height bands,
and stayed that way until any zoom - after which it was correct forever.
Only Safari, and only on a 1x display.

React Flow strokes each line down the middle of its tile, so a line lands
at `gap / 2 - offset`. With the previous `gap / 2` that is 0, and a 1px
line centred on a pixel boundary covers half of two device pixels instead
of one whole one. At 1x the fine grid's tile is five device pixels wide,
and WebKit rasterises that tile once before repeating it across the
canvas; a line already at half intensity drops out of the bitmap
altogether, taking every repeat with it. Zooming changes the tile's
dimensions, so WebKit rebuilds it - which is why a single zoom fixed the
canvas permanently. Blink rasterises patterns differently and never
showed it.

Both layers are nudged by the same half pixel. The shift has to be
identical for both: the offset scales with the zoom, so nudging only the
fine grid leaves the major grid `0.5 * zoom` away from it - invisible at
100% and a clearly doubled major line once zoomed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
…lainly

Labels sat slightly high everywhere - node titles, stereotypes, header names,
row labels - and visibly so in Safari.

Two faults. `dominant-baseline: middle` centres on the x-height, roughly half a
cap-height below the em box centre, so passing a true centre (`height / 2`, a row
centre, a computed group centre) rendered the text about 0.1em high. Every one of
the 18 sites intended real centring, and `CustomText` - the primitive they all
flow through - already defaulted to the correct `central`; each site was
overriding it. One title used `alignment-baseline`, which applies to inline
content inside a `<text>` rather than the element itself, so it was ignored and
that title fell back to the alphabetic baseline entirely.

The second is WebKit-only and is why this read as fine in Chrome. A tspan
carrying its own `x`/`y`/`dy` starts a new positioning run, and WebKit resolves
the baseline per run - dropping the parent's value and falling back to alphabetic,
lifting the line about 0.4em (5.8px on a 14px label). Blink inherits it. The
baseline is now repeated on every positioned tspan.

The stereotype pair was also genuinely off in every browser: `dy` of -8 then 18
leaves the two lines at -8 and +10 about the centre, a midpoint one pixel low.
They now sit symmetrically.

Measured after: max |offset| 0.00px in WebKit and 0.22px in Blink, the latter
being descender ink rather than misplacement.

Separately, the palette's note element is renamed "Description". It is appended
to every palette whatever the diagram type - UML draws a comment as exactly this
rectangle with a folded corner - so "Color Description" both misdescribed it and
was too long for the default box, which is why it rendered truncated. The
`colorDescription` type is left alone: it is serialised into saved diagrams.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
… target

Call sites pass `isVisible={isDiagramModifiable}`, which only answers "may this
diagram be edited". On its own that painted four corner handles and four edge
lines around every node on the canvas at once, permanently. Selection is the
conventional gate - React Flow documents a "NodeResizer when selected" example,
and every comparable editor behaves that way. The check goes in the sanctioned
wrapper rather than at the call sites, so all forty of them are untouched; a
resizer rendered outside a node has no selection to consult and is left alone.

Sizing is harmonised at the same time. The corner handle grows 8 -> 10px drawn,
which is where diagram editors actually sit, and both affordances now widen to a
grab area through two tokens instead of ad-hoc insets:

  --apollon-grab-target: 24px   point target, per WCAG 2.2 SC 2.5.8 (AA), which
                                measures the region accepting the pointer
  --apollon-grab-band:   14px   runs along an edge; matches --arc-short, the
                                connection arc at that same edge's midpoint

The edge lines were on a 7px band while the corners were on 24 - a generous
corner beside a hairline edge on one control. A full 24px band is deliberately
not used: a corner's target already reaches 12px along each edge it touches, so
24 would swallow it.

`app.css` also carries the assessment-focus scoping the next commit relies on;
it is inert CSS until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
Four places subscribed to the raw zoom, so a single wheel gesture re-rendered
most of the canvas on every frame - the pattern React Flow's performance guide
warns about, and the reason zooming got heavier the more a diagram held.

`DefaultNodeWrapper` sits on every node's render path and read `transform[2]` to
write `--arc-scale` onto each handle's inline style. Custom properties inherit,
so the value only has to exist on an ancestor: `ArcScalePublisher` now writes it
once for the whole canvas straight to the DOM from a store subscription, with no
React render per frame and no write when the value has not moved. The wrapper's
other use of zoom - the visible arc count - is selected through the reduction
instead of raw, so a node re-renders only when the count crosses 5 -> 3 -> 1.

`GenericEdge` had the same fault in a subtler form, in three handle components
mounted for every edge:

    getHandleScreenScale(useStore((state) => state.transform[2]))

The selector returned the raw zoom while the reduction ran outside it, so the
comparison never saw that `1 / min(zoom, 1)` is exactly 1 for every zoom >= 1.
Moving the reduction inside means zooming in stops re-rendering edge handles
altogether.

`ActivitySwimlane` subscribed to zoom but used it only inside a drag handler,
nothing in its render output; it now reads it imperatively. `ZoomControls`
rounded outside its selector, so the readout re-rendered on every frame rather
than on whole percents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
`popoverElementId` is set by every popover in every mode, and the focus marking
was applied from it unconditionally. Opening any popover while modelling
therefore stamped the assessment-focus class on the element - in the submission
editor and on the exercise form alike, where there is no feedback form to mark -
and it painted the amber "being assessed" ring around it. Blue means selected for
editing; amber means this element is marked. The gate is the same
`mode === ApollonMode.Assessment` check the editor class already uses.

The CSS half landed with the resize-handle commit: the node and edge rules were
the only ones of their family not scoped to `.apollon-editor--assessment`, which
is what let the marking escape modelling at all. Both are scoped now, so even a
stray class cannot paint outside assessment, and the affordance test asserts the
scoped selector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
… yet

Assessment navigation listed only elements with something to show, plus the one
the reader was on. Combined with `canNavigate: total > 1`, a fresh submission
collapsed the list to a single element and the footer disappeared - navigation
went missing exactly when it is most useful, at the start of an assessment.

What is worth stepping through depends on who is stepping, the same split
`PopoverManager` already makes when deciding whether to open a popover at all: a
tutor gets an empty form everywhere because that is how feedback is written,
while for a reader an element nobody graded has nothing to say. Giving feedback
now walks every element; reading feedback keeps the assessed ones plus the
current position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
The palette renders its preview at 0.8 scale and the ghost then applied
`transform: scale((ratio * zoom) / 0.8)` to it, on an element carrying
`opacity: 0.8`. Opacity forces a compositing layer, so the browser rasterised the
SVG at the small preview size and the compositor stretched that bitmap - the
ghost came off the palette pixelated, and worse the further the canvas was zoomed
in.

The ghost now renders the config's own SVG at the drop size with the zoom as its
scale, so the viewBox stays in element units while the rendered box is the
on-screen size: vector sharp at any zoom, and laid out for the size the element
actually becomes rather than having a smaller rendering stretched over it. With
no transform left, the grab point is kept as a fraction of the shape, which is
what makes it land correctly when the ghost is a different size from the preview.

Verified live: transform none, box 160x100, cursor at fraction 0.500/0.500 of the
ghost after grabbing at its centre, drop still places.

Both test fixtures gained the `svg` they had been casting away with
`as unknown as DropElementConfig` - a required field they could omit only while
the ghost ignored it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
Every node mounted all 36 connection anchors regardless of how many were drawn.
At 45 nodes that is 2583 elements in the viewport, and a zoom gesture repaints
all of them at a new scale on every frame - the cost is paint, not script: a
profile of that gesture shows only 13.8% of samples in JS, with native style and
layout work above every React frame in the list.

Three sets are kept: the handles that are drawn, the handles an edge is anchored
to, and - while a connection is being dragged - all of them, so a drag can still
land on an anchor that is not currently shown. The rest exist only so a saved
edge can resolve its anchor, which the connected set already covers; unmounting
one an edge uses would strand it, because React Flow derives an edge's endpoint
from its handle's measured geometry.

The connected set is selected as a sorted key so the subscription fires when the
set changes rather than on every edge mutation.

Measured on 45 nodes: 53 -> 29 elements per node, 36 -> 12 handles, viewport DOM
2583 -> 1659, worst frame 94.9 -> 67.7ms, blocking 42 -> 27ms - with more edges
present in the after run than the before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
An arc is transparent until its node is hovered or selected, so on an idle canvas
every one of them is laid out and painted on each frame of a pan or zoom without
ever being seen. `display: none` removes them from both, and CSS flips it back
synchronously with the pointer.

Mounting them from JS on hover was tried first and is wrong: the arcs sit on the
node's edge, which is exactly where the pointer arrives, so a press beat React's
mount and started a node drag instead of a connection - connecting two nodes
stopped working entirely. CSS has no such gap.

Anchored handles are exempt, because React Flow measures a handle's geometry to
place the edge attached to it and `display: none` has none. Guidance mode drives
handles directly, so it is exempt too.

Measured over a 70-step zoom on 45 nodes, against the 36-anchor baseline:

  worst frame   94.9 -> 69.4 ms
  blocking        42 -> 25 ms
  long tasks       3 -> 1
  p99           73.5 -> 59.7 ms

with connections still made through the UI in the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
Comments that restated the code, narrated the history of a fix, or baked in
benchmark numbers that stop being true the moment anything changes. The WebKit
baseline explanation was repeated in five files; it now lives once in
`CustomText`, with the rest pointing there.

On the tests: `grabTargetSizing` locked the exact `calc()` spelling of the
insets while never touching `pointer-events`, which is what actually arms a
grab target — so every target could have gone inert with the suite green. It
now asserts the arming rule and the band/arc relationship, and leaves the
arithmetic free. `handleMounting` replaces a test that restated the mounting
predicate with one that renders the real wrapper against a seeded store; both
directions of the gate were mutation-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
`display: none` on idle handles corrupted every connection drag on a diagram
that was opened rather than drawn — the common case in Artemis, where a student
opens a submission and a tutor opens an assessment.

React Flow measures a node's handles once, when the node first gets dimensions,
and reuses that cache for the life of the node. Nothing here forces a
re-measure and hovering does not trigger one, so handles hidden at load were
cached at zero size: `getHandleBounds` stores `(0 - nodeBounds.left) / zoom`,
putting every handle hundreds of flow units outside its node. Measured on a
restored two-node diagram, the connection origin came back at (0, 0) against an
expected (385, 255) — 462 units off — and `getClosestHandle` rejected every arc
past `connectionRadius`, so drops fell through to the shape-aware fallback.
That fallback is why this degraded quietly instead of breaking outright.

The rule earned nothing to offset it. Idle arcs are already transparent, so on
a real GPU, zooming a 30-node diagram measures the same with the rule, without
it, and with it replaced by `visibility: hidden`: style 10ms, layout 2ms,
script ~290ms over 120 frames, and screenshots byte-identical. It is deleted
rather than repaired.

The per-node mount gate does pay for itself and stays — 360 handles against
1080 is ~35% of script and task time — but it now reads the connection map via
`useNodeConnections` instead of scanning every edge inside a selector that
Zustand re-runs on each store write, including every frame of a pan. The
`connectionInProgress` branch is gone: mounting handles mid-drag cannot make
them droppable, since that too needs a re-measure, so it only forced two
canvas-wide re-renders per gesture into the 1080-handle shape.

Also fixes the resize band, which claimed to match the arc thickness but did
not: React Flow's `autoScale` applies to its handle variant only, so the band
was the one affordance still shrinking with zoom. It now carries `--arc-scale`
at the point of use, measured at 14px on screen against the arc's 14px at both
zoom 1 and zoom 0.4, with the corner holding 24px for WCAG 2.2 SC 2.5.8.

The e2e test covers what jsdom cannot: it loads a saved diagram, parks the
pointer off-canvas and asserts no handle measures zero. Reinstating the rule
fails it with 102 collapsed handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
`arcScaleForZoom` and `getHandleScreenScale` were the same function written
twice with different clamp spellings, and React Flow writes it a third time as
its own `scaleSelector`. Keeps the descriptive name, in `geometry/scalar`
alongside the other scalar helpers, so the arcs, the edge grips and the resize
band all read the same definition.

`ArcScalePublisher` also drops its hand-rolled store subscription. It now
reduces to the scale inside a `useStore` selector — the idiom the file next
door already uses, and the reason it re-renders only when the scale changes
rather than on every frame — leaving a `useLayoutEffect` to write the custom
property. The old `store.subscribe` had no selector, so it ran on every store
mutation and needed its own dedup to avoid writing on node drags and
selections; and as a plain effect it let the first frame after mount paint at
the fallback scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
The ghost took the grab point as a fraction of the palette preview; the drop
recomputed it from the same pixel offset divided by the shape's own height.
Those agree until a preview is taller than its shape, which is exactly the case
for `sfcTransitionBranch`, `petriNetPlace` and `petriNetTransition`, whose
previews reserve a label band. Grabbing one of those low enough dropped the
node up to a full band away from where the ghost had been sitting.

Rather than correcting the second derivation, the drop now takes the fraction
the ghost already computed, so there is one definition and the two cannot
drift. That removes the drop/preview ratio and the preview scale from the
placement hook, which had no other reader.

The tap-placement test was asserting a jsdom artefact: with no layout the
preview measured zero, so the old code divided a raw client coordinate by the
preview scale and the expectation encoded the result. It now gives the preview
a size and asserts the grabbed fraction survives onto the larger drop size —
inflating the drop height by a label band fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
Covers the release notes for the connection-targeting and palette-drop fixes;
the scale-helper consolidation and the comment pass are internal and carry
none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
Nine Playwright tests fail on this branch and pass on `main`. Three separate
causes, none of them the tests being wrong about what the product should do.

Edges anchored to a handle the node does not draw stopped rendering at all.
A node mounts only the handles it draws plus the ones its edges point at, so a
"between" slot appears only once its edge exists — after React Flow has already
measured that node. Measurement happens once and is never repeated, so the slot
had no geometry, the edge could not resolve an endpoint, and it vanished:
`handleBounds` held twelve entries and the DOM thirteen. `useUpdateNodeInternals`
re-measures when the mounted set changes, which is the supported way to do this.
Same root cause as the loaded-diagram fix earlier on this branch — measure once,
never again — reached from the other side.

Delete on a focused waypoint deleted the whole edge. The shortcut listener
moved from the document onto the editor root, but React delegates its own
listeners to the container it was mounted into, which is an ANCESTOR of that
root. The root's listener therefore ran BEFORE React's, so the `defaultPrevented`
guard could not see the `preventDefault()` the waypoint handler had yet to make.
It listens on the document again and filters by containment, which keeps the
scoping the move was for. Escape leaving multi-selection behind was the same
ordering fault.

The drag ghost lost `data-draggable-preview` when it stopped cloning the palette
entry. It is the same affordance in flight, so it carries the same marker.

The resize specs asserted controls on an unselected node. Controls now appear on
selection, deliberately — the tests select first, and assert what they were
always about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
A comment audit across the branch, which turned up three things the code was
getting wrong rather than merely describing badly.

`app.css` documents at length why canvas affordances must never animate opacity
inside the zoomed viewport: Chrome promotes the layer and shows a stale, scaled
raster. Every node's handle style then set `transition: opacity 120ms ease`, and
`@keyframes fadeIn` sat at the bottom of the file with no consumer left. Both
gone. The rule's own test only scans the stylesheet, so an inline style was
never going to be caught by it.

Two comment blocks were committed alongside the drafts they replaced, so the
file carried both. Two more were plainly wrong: `--arc-scale` is published by
`ArcScalePublisher`, not by `DefaultNodeWrapper`, which is now the one component
that deliberately does not set it; and `.edge-overlay` does not stay invisible
through `opacity: 0` — an inline `opacity: 0.4` outranks that rule, and the
overlay is invisible because it carries no stroke at all.

The rest is bloat: comments restating the assertion below them, the WebKit
baseline rule restated at the two sites that already point at `CustomText` for
it, the SVG paint-order rule stated three times in one file, benchmark-shaped
numbers nobody will re-measure ("99% of renders"), and stale claims left by
earlier commits on this branch — a JSDoc for a prop that no longer exists, three
comments insisting the drag ghost portals to `document.body` when it portals to
the editor root under fullscreen, and arithmetic for a test expectation that had
already changed.

Two tests were rewritten rather than reworded, because both were verified to
pass through the regression they exist to catch. `grabTargetSizing` asserted the
spelling of two `calc()` insets: inverting them, which collapses a 24px pointer
target to nothing, kept it green. Its reachability half duplicated a real-browser
test. What it actually guards — a relationship between two source constants — is
all that remains, and the corner's grab area is now hit-tested for real in
`resize-edge-reachable`, where inverting that inset fails. `handle-measurement`
asserted no handle measured zero, which also holds when there are no handles at
all; it now pins the fixture's edge anchors and the rendered edge count, and was
mutation-tested against both ways this has broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
The e2e repairs that a user would notice: edges reappearing, and Delete and
Escape reaching the handler that was meant to answer them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich I found two blocking issues on this exact head: read-only assessment selection no longer paints node highlights, and the captured Codacy run is ACTION_REQUIRED for 19 issues introduced in the changed stylesheet. The inline comments identify the affected code and concrete fixes.

Comment thread library/lib/styles/app.css
Comment thread library/lib/components/wrapper/AssessmentSelectableWrapper.tsx
@github-project-automation github-project-automation Bot moved this from Backlog to In progress in Apollon Development Aug 20, 2026

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The two prior blockers are resolved: assessment nodes now receive the highlight classes, and Codacy succeeds on this head. Two medium issues remain in revealAssessment: its selection state becomes inconsistent, and it can open editing UI outside assessment mode.

Comment thread library/lib/apollon-editor.tsx Outdated
Comment thread library/lib/apollon-editor.tsx Outdated

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The two prior revealAssessment blockers are resolved, and Codacy succeeds on this head. One medium issue remains: host-driven reveal and assessment footer navigation send transient edge selection through diagramStore.setEdges, persisting it to Yjs and undo history. The captured non-required jobs are still running, with no failed checks reported.

Comment thread library/lib/apollon-editor.tsx Outdated

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The prior Yjs persistence issue is fixed, but two medium blockers remain on this head. Bulk selection now goes through per-item handlers that cause quadratic work and transient subscription notifications, while read-only clicks on ungraded elements leave stale focus state without mounting a popover. The captured snapshot has no failed checks; several non-required jobs remain in progress.

Comment thread library/lib/apollon-editor.tsx Outdated
Comment thread library/lib/hooks/useElementInteractions.ts

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The two prior blockers are fixed: selection updates are now atomic and local-only, and read-only pointer clicks no longer target unavailable feedback. One medium issue remains in the public revealAssessment path. The captured snapshot has no failed checks; non-required jobs remain in progress.

Comment thread library/lib/apollon-editor.tsx Outdated

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The latest callback-stability change looks sound, but the existing medium blocker remains on this exact head. revealAssessment still assigns every target in assessment mode without applying the read-only feedback guard, while PopoverManager rejects ungraded targets, leaving stale popover and focus state. The snapshot reports no failed checks; the unfinished jobs are non-required.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The prior read-only reveal blocker is fixed on this head. Two medium issues remain: the exported translation type introduces a breaking requirement, and label-bearing palette elements no longer preserve their grabbed point. The captured snapshot has no failed checks; the unfinished jobs are non-required.

Comment thread library/lib/i18n/labels.ts Outdated
Comment thread library/lib/components/DraggableGhost.tsx Outdated

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The two prior blockers are fixed, and the captured CI checks succeed on this exact head. One medium issue remains: assessment reveal/navigation computes pan targets from parent-relative node coordinates, so nested elements are centered at the wrong canvas location.

Comment thread library/lib/apollon-editor.tsx Outdated

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The nested-node coordinate fix is sound, and the captured checks succeed on this head. One medium issue remains: reveal and footer navigation still pan routed relationships to the midpoint between their endpoint nodes rather than to the relationship’s rendered assessment anchor.

Comment thread library/lib/apollon-editor.tsx Outdated

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The routed-assessment fix is sound: reveal and footer navigation now derive edge centers from the same displayed route geometry and midpoint calculation used by rendering, with endpoint fallback. All prior threads are resolved and the captured snapshot has no failed checks; several non-required jobs remain in progress.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The new export pass correctly resolves parent and tspan baselines exactly once after positions and relative font sizes become absolute. Focused unit coverage and refreshed visual exports exercise repeated and child-only baselines through the production download path; the captured snapshot has no failed checks, with only non-required jobs still running.

@Claudia-Anthropica Claudia-Anthropica left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FelixTJDietrich The export-size follow-up preserves the approved normalization: it still resolves parent and tspan baselines exactly once, removes them afterward, and the refreshed use-case baseline reflects the intended positioning change. All retained prior threads remain resolved, and the complete captured CI snapshot reports no failed checks; the unfinished jobs are non-required.

@FelixTJDietrich
FelixTJDietrich merged commit 8e88cb2 into main Aug 20, 2026
35 of 37 checks passed
@FelixTJDietrich
FelixTJDietrich deleted the fix/artemis-integration branch August 20, 2026 16:19
@github-project-automation github-project-automation Bot moved this from In progress to Done in Apollon Development Aug 20, 2026
@github-actions github-actions Bot mentioned this pull request Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants