fix(library): make embedded editors reliable in host applications - #841
Conversation
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.
…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
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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.
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:
when the editor workspace enters fullscreen;
page remains usable, including pages with multiple editors;
popovers reliable and expose
revealAssessment(id)for host feedback lists;grid alignment, zoom rendering, and route previews visually and geometrically
stable;
offscreen export renderer from changing the host page's scroll size; and
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
scrolling and shortcuts remain host-safe, assessment feedback is easier to
open and navigate through
revealAssessment(id), host controls gain a labeledchrome 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.
with its new fullscreen workspace action.
These are the two changeset summaries verbatim, one for the
@tumaet/apollonembedder audience and one for the
@tumaet/webappplayground audience.Implementation notes
Fullscreen portals. Each editor instance owns a portal destination. It moves
with the fullscreen element on
fullscreenchange, so floating surfaces remaindescendants 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 whilethat 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 itsfeedback, 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
mainreaches 65–66 ms locally on that workload; this branch measures34–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:
Open http://localhost:5173/playground, then:
editor's color picker, and drag an element from the palette. Each surface must
remain visible and interactive. Press Escape until fullscreen exits.
browser page must zoom. Click the canvas and repeat; the diagram must zoom.
continue to scroll. Hold the displayed zoom modifier; the diagram must zoom.
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.
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.
SVG, PNG, and PDF. Label centers must match the editor, including headers,
stereotypes, wrapped text, diamonds, and ellipses.
The focused fullscreen regression can also be run directly:
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
pnpm changeset, how)fix) matches the user-visible kind of change and its release-note grouppnpm lint && pnpm format:check && pnpm build && pnpm testlocally — green (102 test files, 1853 passed, 1 skipped);pnpm --filter @tumaet/apollon buildis 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