Skip to content

feat(ui): figure export, GIF sizing and review fixes (U-12) - #216

Merged
timurbazhirov merged 54 commits into
devfrom
claude/uiux-p2-figure-export
Aug 14, 2026
Merged

feat(ui): figure export, GIF sizing and review fixes (U-12)#216
timurbazhirov merged 54 commits into
devfrom
claude/uiux-p2-figure-export

Conversation

@timurbazhirov

@timurbazhirov timurbazhirov commented Aug 13, 2026

Copy link
Copy Markdown
Member

Merge this first, then #214. Together they are the consolidation of everything from July 12 onward — #204, #207, #208, #209, #210, #212, #213 and #215 are closed as superseded, each verified contained before closing.

This PR is the whole UI/UX proposal except U-13 (touch support), which lands with #214 on top. Design doc: docs/design/uiux-improvements-2026-08.md; §0.1 records what shipped and every deviation from the plan.

Try it: https://deploy-preview-216--mat3ra-mave.netlify.app/ — a Netlify config is included, so every PR now gets its own preview. That is the only way to exercise what the Jest suite structurally cannot reach: pointer capture, real event ordering, CSS layout at a given viewport, GPU rendering. It has already earned its keep — most of the fixes below came from reviewing a preview, not from the suite.

The publication case — figure export (U-12)

Every image the viewer could produce was toDataURL on the on-screen canvas: the dark theme at whatever size the container happened to have. A screenshot, not a figure.

Export ▸ Figure (PNG)… renders the scene once at an explicit resolution and background without disturbing the viewer:

  • Size stated in pixels and millimetres at 300 dpi, with single- and double-column presets at 85 mm and 180 mm. Height follows the canvas aspect for every preset, so choosing a width cannot silently squash the structure; only the custom preset lets both be set. Over-large requests scale down proportionally against the GL context's own reported limit, and say so.
  • White and transparent backgrounds invert the viewer's chrome. Cell edges are #CCCCCC and label text #EEEEEE — light-on-dark — so a white-background export without this loses its unit cell and its labels entirely. The rule is "light and achromatic is chrome": hued lines (boundary conditions) and atom colours keep theirs, since those carry meaning.
  • A scale bar at a round 1/2/5 number of Å, which declines to draw rather than guess when the scale is unusable. Exact under the orthographic camera; the dialog says so for perspective instead of presenting an approximation as a measurement.
  • The one-click Screenshot stays — capturing what's on screen shouldn't pay for a dialog.

Rendered by resizing the real renderer rather than allocating a second one (a second WebGLRenderer means a second GL context and a duplicate of every texture); the cost is restoring everything in a finally, which is what the tests pin.

Fixes from preview review

  • Atomic radius stopped at 10×. It multiplies each element's van der Waals radius, so 1 is already space-filling and the top 90% was unusable — which also left the useful band around the 0.2 default as a few pixels of slider travel. Now 0.1–1.

  • Parameters said too much: a two-line paragraph under every control, trimmed to one line each keeping the range and what the number multiplies. The per-cell count left the cost line because the status bar already shows it.

  • View toggles were janky. The keycap slot was only rendered on rows that had a hotkey, so each row sized itself and the switches landed at two x positions 32 px apart. The slot is now always present — empty and hidden at a fixed width — and all nine measure at one x.

  • GIF recording took the canvas's size, so every GIF came out the shape of whoever's window made it — letterboxed wherever it was embedded, and clipping the structure at the extremes of the rotation, since a turning structure sweeps through its own width. Now a fixed 512×512, overridable and clamped to the GL limit.

  • The embedded case (MD / WA), which was a gap in my own verification. I had checked a wide-but-short viewport and never a narrow one, which is exactly what a host panel gives. Measured in edit mode:

    viewport before
    520×900 pill 520 px wide, overlapping the icon strip, the inspector and the edit toolbar at once
    700×620 pill overlapping the inspector
    900×420 pill overlapping the inspector

    The container's insets now describe the space that is genuinely free, and the pill observes that space with a ResizeObserver — dropping its binding list below 460 px and moving to the left edge. What it sheds is the reminder, never the state or the exit: at 520 px it reads EDIT ✕, and the same bindings are in the shortcuts sheet. No collisions at any of the three sizes; desktop at 1280/1600 unchanged.

    A media query would have been the wrong instrument here — an embedded panel is narrow inside a wide window, the same mistake as the viewport-width isMobile that feat(ui): viewer chrome, edit-surface rework, figure export and touch support (the full UI/UX proposal) #214 removes.

Two pre-existing bugs, fixed here because each was load-bearing

  • setOrthographicCameraFrustum never called updateProjectionMatrix, so from construction until the first resize the orthographic camera projected the initial ±10 frustum from initCameras rather than the cell-fitted one. Invisible in a browser (ResizeObserver fires and handleResize repairs it) and fatal for a scale bar that reads those frustum fields. Caught by cross-checking the reported scale against the camera's own projection over a known 2 Å separation — the kind of assertion that catches a formula wrong by a constant factor when nothing else would.
  • The filename sanitiser kept .. runs; structure names are free text reaching a download filename.

Verification

  • Suite 457 passing, 32 suites, 0 failing (185 at the branch point). npm run lint and tsc --noEmit clean.
  • The visual-snapshot failures are fixed, not excused. They were red on the branch point too — reproduced exactly, 17/323. The van der Waals fix (7e3541f) made atom radii per-element, which legitimately changes every rendered image, and the baselines still encoded the pre-fix rendering. Verified legitimate three ways before regenerating: diffs show thin crescents on each sphere's rim and nothing else, differences are 0.00–0.50% of pixels, and reintroducing the uniform-radius bug makes the old baselines pass again. Regenerated rather than given a tolerance — any tolerance wide enough to absorb this would absorb the radius regression the baselines exist to catch.
  • Driven in Chromium against the production bundle at desktop, embedded (520/700/900) and touch sizes. Measured: shown resolution equals the PNG's real dimensions, white exports are opaque white with dark chrome, transparent carry alpha 0, the on-screen canvas is byte-identical after three consecutive exports, a GIF recorded from a 1400×620 window is 512×512 with round atoms, and no chrome collisions at any embedded size.

What this supersedes

Each closure was checked by ancestry rather than assumed: the heads of #204, #207, #208, #209, #210 and #212 are direct ancestors of this branch. #213 needed more care — its 18 regenerated baselines are byte-identical here, but three of its files were not redundant (an AGENTS.md LFS description that had gone stale, a workplan warning about already-finished work, and a missing *.save.png ignore rule that would let move-actual-expected.sh's rollback copies be committed as non-LFS blobs). Those were carried over verbatim in 819cc24 rather than dropped.

This branch still carries the interactive-editor stack, because that work is not on devinteractive_structure_editor.ts does not exist there. That accounts for most of the diff, along with dist/ being tracked; untracking dist/ remains the right follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5

timurbazhirov and others added 30 commits July 12, 2026 11:32
Replace the standalone @mat3ra/threejs-editor modal with an in-viewer
edit mode driven by a new InteractiveStructureEditorMixin: select an
atom by clicking it, drag it with a transform gizmo or type its
coordinates, add/delete atoms, and undo/redo - all without leaving
the main viewer.

Also fixes the correctness bugs from the initial pass: elements read
off material.basis.elements need unwrapping from their ESSE
{id,value} shape before being fed back into
Basis.fromElementsAndCoordinates, otherwise they double-wrap and crash
any UI that renders them; the edit gizmo now survives scene rebuilds
instead of going stale after the first edit; click-to-select was
reading the wrong userData key; and the edit toolbar now uses the
app's MUI components instead of raw inline-styled buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…drag

- Fix crystal/cartesian units corruption when adding or removing atoms
  and editing coordinates: convert to the material's actual basis units
  instead of relabeling raw values.
- Fix atomIndex/atomicIndex naming mismatch that broke atom selection
  and removal.
- Preserve the selected atom (and gizmo attachment) across scene
  rebuilds instead of losing selection on every structure edit.
- Keep TransformControls' camera reference in sync when toggling the
  orthographic camera.
- Add direct click-and-drag-to-move for atoms (camera-facing plane,
  offset-based), independent of the TransformControls gizmo.
- Refactor undo/redo to consistently use the bypassReloadViewer path
  and reset history on external material prop changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comprehensive spec for the editor introduced in 751a7e3: 12 user
stories, the interaction state machine, UI/data-flow mockups, the
host-app embedding contract, a 22-defect/17-rough-edge register, a
Jest-based test-pathway matrix, 12 open product decisions, and a
phased P0/P1/P2 implementation plan.

Grounded in a research pass over the deleted standalone editor's git
history, the current implementation, materials-designer's integration
contract, and prior art (Avogadro, VESTA, Blender, three.js editor,
Unity/Unreal/Figma), then adversarially fact-checked against the
source.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Six critical/confirmed defects from docs/design/interactive-editor-spec.md
Section 7/10 (P0):

- D1: camera view reset after every structure edit. WaveComponent's
  shouldViewerAdjust() compared the cell prop with exact JSON.stringify
  equality; a lossy scene-vertex round trip reintroduces the lattice to
  only ~1e-7 precision, spuriously tripping the comparison on every edit
  and snapping the camera back to its default framing. Now an
  epsilon-tolerant (1e-5 A) numeric comparison over the cell's scalar
  fields, with an exact units check retained.

- D5: extractBasisFromScene() swept up every Mesh-typed scene object as
  a phantom atom, including bond InstancedMeshes (which inherit type
  "Mesh" in the installed three.js version) and boundary-condition
  planes, and baked repetition clones into the material. Now scoped to
  the base atoms group only, explicitly excluding InstancedMesh.

- D6: extractLatticeFromScene() located the unit cell via the first
  LineSegments found by depth-first search and indexed a fixed vertex
  count, crashing under non-periodic boundary conditions (a
  differently-shaped cell object). Now locates the real unit-cell
  LineSegments unambiguously and handles both periodic and
  non-periodic geometry.

- D2: the edit-mode hotkey was dead - it referenced a settings key
  (toggleThreejsEditorModal) that didn't exist in hotKeysConfig, so the
  advertised "Edit [E]" tooltip did nothing (and "e" was already bound
  to element labels). Added a real toggleEditMode binding ("t"), fixed
  the tooltip to read the key dynamically, and fixed a related listener
  leak (removeEventListener was missing the capture flag used at
  registration).

- D4: the toolbar's Rotate mode button was a structural no-op (rotating
  a sphere about its own center never changes atom position) that still
  produced junk history entries. Removed the button, and added a
  zero-delta guard on the gizmo's commit path so a zero-movement
  interaction never fires a structure-modified event.

- D17: the window postMessage handler reflectively invoked any
  component method with attacker-controlled arguments, from any origin,
  with no allowlist. Removed - no confirmed external consumer depends
  on it.

Hotkey choice (t) and postMessage removal (vs. allowlisting) follow the
spec's own stated recommendations for open decisions D-10 and D-11.

Full suite: 81 tests (79 passed, 2 pre-existing skips), tsc --noEmit
clean, lint unchanged from baseline (pre-existing unrelated debt in
measurements/*.ts and viewSettingsUrl.ts). Browser-verified: camera
position now stays fixed across a real drag; Rotate button confirmed
removed from the toolbar; hotkey tooltip confirmed reading "edit [t]".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Formalizes the pointer-event/canvas-rect patterns already used by hand
throughout tests/__tests__/mixins/interactive_structure_editor.js into
tests/helpers/editor.js: stubCanvasRect, projectToScreen/
projectMeshToScreen, dispatchPointerEvent (direct-handler or real-
dispatch mode), simulateClick, simulateAtomDrag (encodes the two-move
drag-offset requirement), getWaveWithRecordedCallbacks, and
expectVisualMatch.

Also adds docs/design/MANUAL_SMOKE.md, an ~8-item checklist for the
handful of things the Jest+jsdom+headless-gl stack structurally cannot
prove (real event ordering, CSS/layout/DPR, GPU rendering, pointer-
capture/drag-leaves-canvas, focus/hotkey/IME, scrolled-container
drift), per the design spec's Section 8 test-pathway analysis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ign spec

Implements docs/design/interactive-editor-spec.md Section 10 P1: the
delta-based edit architecture plus the drag robustness, affordance,
host-API, and polish items that depend on it.

Architecture (src/mixins/interactive_structure_editor.ts):
- Replace scene re-derivation with basis mutation on the wave's own
  tracked structure. addAtom/removeSelectedAtom/commitMovedAtom_ now
  clone the current material, mutate its Basis in place (Basis.addAtom,
  index-based element/coordinate/label/constraint filtering, or a
  single coordinate swap), and write back via setBasis - never through
  ThreeDSceneDataToMaterial. Lattice, units, metadata, labels, and
  constraints on every untouched atom now survive edits exactly, and
  the new atom is auto-selected (US-7).
- Drag robustness: pointer capture on drag start/release (defensively
  guarded for environments without the API), pointercancel treated as
  a cancel, Esc reverts an in-progress direct or gizmo drag with no
  history entry (US-6), LMB-only filtering (D21), orbit-controls
  restored to their tracked pre-drag state instead of hardcoded true
  (D11).
- Affordances: hover and selection now render through a dedicated
  wireframe halo mesh instead of mutating the atom's own
  material.emissive, which spin-glow and measurement-hover already
  write to (D19); move/grabbing cursors follow hover/drag state.
- Selection persists across an edit-mode disable/re-enable cycle, but
  not across an explicit deselect (R12).

Host API and React-side fixes (src/components/ThreeDEditor.jsx):
- Selection changes no longer trigger a full WaveComponent reload:
  wrapped in the same bypassReloadViewer guard already used for
  structure edits, fixing both the mid-drag mesh-orphaning bug (D3) and
  per-click rebuild sluggishness (R17).
- Coordinate fields hold a local draft string while focused (clearable,
  minus-sign-capable, full precision) and commit once on blur/Enter -
  one history entry per edit instead of one per keystroke (D18).
- UNSAFE_componentWillReceiveProps only resets history/selection when
  the incoming material's actual content differs (via calculateHash())
  from what's already shown, so a host that echoes its own committed
  material back as a prop no longer wipes state on every edit (D16).
- New onEditModeChanged callback and canUndo()/canRedo() methods for
  host-driven UI (spec Section 6).
- Edit mode and measurement modes are now mutually exclusive - entering
  one force-exits the other (D20).
- Add Atom places the new atom at the true cell center (a+b+c)/2, not
  the component-wise (ax/2, by/2, cz/2) diagonal, and offsets it if
  that position is already occupied instead of stacking duplicates
  (D22).
- Download now exports the current edited material instead of the
  pre-edit snapshot (D8).
- Delete/Backspace and Ctrl(Cmd)+Z/Ctrl(Cmd)+Shift+Z are now wired via
  a keydown listener (keypress, used for the rest of this component's
  hotkeys, never fires for these keys) while edit mode is active.
- The edit toolbar no longer renders while the viewer's interactive
  cover is showing (R14).

Lifecycle (src/wave.js, src/components/WaveComponent.jsx):
- Replaced the leaked, never-removed window resize listener with a
  ResizeObserver scoped to the container and a real dispose() that
  disconnects it and releases the renderer; WaveComponent now calls
  dispose() on unmount and before constructing a replacement instance
  for a "Reset View" re-init (D15). tests/setupFiles.js gains a minimal
  ResizeObserver polyfill, since jsdom (unlike every real browser this
  targets) doesn't implement it.

36 new tests across the mixin, ThreeDEditor, WaveComponent, and wave
suites. Full run: 115 passed (2 pre-existing skips), tsc --noEmit
clean, lint unchanged from baseline aside from 2 new any-typed warnings
matching this file's existing convention, build succeeds. Browser-
verified end to end against the running dev server: hover halo/cursor,
drag-threshold/offset behavior, orbit-state tracking, Esc-revert,
Ctrl+Z/Ctrl+Shift+Z, and - the original user-reported bug - the camera
staying exactly fixed across a real drag and a real undo.

Not attempted: P2 (multi-select, snapping, element picker, etc.) is
explicitly gated on the spec's open product decisions (Section 9) and
was left for the user to resolve.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… status

Persists the planning artifacts behind docs/design/interactive-editor-spec.md:
the original plan for producing the spec (now marked historical) plus a new
status section tracking what's landed - spec approved, P0 (97707ba) and P1
(03b1376, c2524a8) implemented/tested/committed/pushed - and what's still
outstanding: P2, itemized against the spec's 12 open product decisions
(§9), none of which are inferable from the spec itself.

These files live under plan/, which is gitignored by default (scratch
planning area); force-added here since the user asked to keep this specific
plan's state trackable rather than let it stay session-local.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the design spec's decision D-4 (docs/design/interactive-editor-spec.md
§9): rubber-band multi-select, resolving the drag-on-empty-space conflict
with camera orbit by moving orbit rotation to the right mouse button while
edit mode is active (OrbitControls.mouseButtons remapped on enable, restored
on disable) - freeing left-drag on empty space for a marquee selection.

Mixin (src/mixins/interactive_structure_editor.ts):
- selectedMeshes_ array is now the canonical selection (selectedMesh_ stays
  as its last-entry alias for existing single-atom callers/tests).
  setSelectedAtomMesh/clearSelectedAtom are now thin wrappers over the new
  setSelectedAtomMeshes/clearSelection.
- Click selection gains modifiers: Shift+click adds, Ctrl/Cmd+click toggles;
  Shift/Ctrl+click on empty space is a no-op rather than clearing the
  selection.
- A left-drag starting on empty space now opens a rubber-band marquee (an
  absolutely-positioned overlay div sized live during the drag) instead of
  immediately deselecting; releasing selects every atom whose projected
  screen position falls inside the rectangle, combined with the existing
  selection via the same replace/add/toggle modifiers. A release before the
  drag threshold still behaves as a plain click.
- 2+ selected atoms get a pivot object (selectionPivot_) at their centroid;
  the gizmo attaches to it instead of an atom directly. Dragging the pivot
  (via the gizmo) or dragging any one of the selected atoms directly moves
  every selected atom by the same rigid delta, live during the drag.
- Multiple atoms moved together, or removed together, commit as a single
  basis-delta/one history entry (commitMovedAtoms_/removeSelectedAtoms_),
  matching the existing one-drag-one-commit convention.
- The selection highlight is now a reusable pool of halo meshes (one per
  selected atom) instead of a single mesh.
- onSelectionChanged's contract changes from (index: number | null) to
  (atomicIndices: number[]) - a breaking change from the P1-documented
  single-index signature, made safely since no external consumer existed
  yet (materials-designer is still pinned to the pre-P0 export).

React (src/components/ThreeDEditor.jsx):
- selectedAtomIndex state becomes selectedAtomIndices (array). The
  coordinate panel still shows X/Y/Z only for exactly one selected atom;
  2+ shows an "N atoms selected" caption instead (coordinates aren't
  meaningful for a group). Remove Selected / Delete already worked
  unchanged, since wave.removeSelectedAtom() itself now branches on
  selection size.

12 new tests (mixin: modifiers, marquee hit-testing and threshold gating,
orbit button remap, group drag via direct-drag and via the gizmo pivot,
Esc-revert for a group drag, group removal, multi-selection surviving an
edit-mode toggle). Full suite: 127 passed (2 pre-existing skips, up from
115), tsc --noEmit clean, lint unchanged from baseline, build succeeds.
Browser-verified end to end against the running dev server: Shift+click
building a 2-atom selection, the marquee overlay rendering and correctly
hit-testing both atoms, a direct drag on one selected atom moving both by
an identical delta in a single history entry, and the camera staying fixed
throughout.

Not implemented (see docs/design/interactive-editor-spec.md §11 roadmap):
group rotate (blocked on single-atom Rotate mode returning) and
double-click-to-select-bonded-fragment (needs bond connectivity data).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…amera focus, element rename)

Extends D-4's group translate with rotate about the shared centroid (same
gizmo, same one-commit-per-drag semantics, gated to a 2+ selection). Adds
clone selected atom(s), camera focus on selection (F key + button, preserves
viewing angle unlike the whole-cell fit reset), and the type-to-change half
of decision D-9 (editable, periodic-table-validated Element field).

Also fixes a latent defect found while wiring rotate: rebuildScene() in
wave.js only ever restored a single-atom selection, so any externally
triggered rebuild (e.g. the host's onStructureModified round-trip) silently
collapsed a group selection to one atom - breaking a second consecutive
group rotate/drag. Now preserves the full multi-selection regardless of who
triggers the rebuild; this also hardens D-4's group translate, which shared
the same gap.

vite.config.ts: extends the existing optimizeDeps.include workaround to the
new toolbar icons - a dev-server-only esbuild pre-bundling/chunk-splitting
issue with @emotion/styled, unrelated to the production build (which
already built clean throughout).

18 new/extended tests (mixin + component), all verified live against the
running dev server, including rotate math and event-order verified directly
against the real (non-mocked) three.js/WebGL runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…teractions

Mixin-level tests drive a bare Wave instance; component-level tests drive
ThreeDEditor's handler methods directly. Neither exercises a real pointer
gesture against the mounted wave's canvas together with the real
onStructureModified -> setState -> _applyMaterialToViewer round-trip - which
is exactly the gap the rebuildScene() multi-selection bug hid in. These
tests mount the real ThreeDEditor and drive its real, running wave instance
with the same helpers the mixin suite uses: click-select, click-and-drag
move, shift+click and marquee multi-select, group translate/rotate via the
gizmo (each proven to survive a second consecutive operation through the
real host round-trip, not a simulated one), undo, and clone-then-drag.

Along the way, found and fixed a real gap in the test harness itself:
WaveComponent renders its own inner ref'd div as the Wave's canvas
container, which never inherits the outer mount container's stubbed
clientWidth/clientHeight, so the renderer sized itself to 0x0 in jsdom and
every raycasting-based pointer test silently missed. Fixed by forcing the
renderer's drawing-buffer size after mount, before stubbing the canvas rect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fix)

Reviewed via agent-code-review-tb (Timur Bazhirov persona + real precedent
retrieval), 4 iteration rounds. Every finding independently re-verified
against the repo before trusting it - one (repetition-clone picking) broke
an existing test once fixed, confirming the value of that step even for a
well-sourced review. Full writeup in plan/interactive-editor-spec-plan.md.

Blockers fixed, each with a regression test (confirmed fails pre-fix):
- Esc did not cancel a gizmo-driven drag (translate's default interaction);
  cancelAtomDrag_() only knew how to revert a direct body-drag.
- tsconfig.json's declaration:false (confirmed added by this PR) was
  silently shipping a stale dist/exports.d.ts that still exported the
  deleted ThreejsEditorModal; restored, orphaned dist files removed.
- Repetition-clone atoms were selectable/draggable with a deliberately
  out-of-range atomicIndex; collectAllAtoms() (renamed
  collectSelectableAtoms()) is now scoped to the first ATOM_GROUP_NAME
  group, same fix (and invariant) utils.js's extractBasisFromScene already
  uses. getAtomGroups() (measurements' own clone-inclusive collector) is
  untouched.
- Undo/redo never told the host when a rebuild silently invalidated the
  selection (e.g. undo across an Add Atom); reselectAtomsByIndices is now
  the single source of truth for firing onSelectionChanged, only on actual
  change.
- onSelectionChanged was wired into the mixin but never forwarded to a host
  prop, despite spec Sec6.2 listing it as public API.

Should-fix applied: group direct-drag now keeps the pivot tracking the live
centroid instead of freezing at the pre-drag spot; cloneSelectedAtoms()
shares Add Atom's occupied-site guard (D22); the camera-focus hotkey now
reads settings.hotKeysConfig instead of a bare "f" literal; undo()/redo()
ref-API aliases added per spec Sec6.3; dead @codemirror/*, acorn, codemirror,
esprima, signals dependencies removed (zero usage, traced to the deleted
old editor's JS-scripting panel via orphaned CSS, also removed).

Investigated without a code change: the three fork-to-stock swap. Compared
the pinned fork commit against upstream three.js - its TransformControls.js
patches are either dead for this codebase (a class this architecture never
uses) or narrow edge cases; the one patch that touches live behavior
(rotate-mode drag-plane computation) looks like it relied on fragile stale
module state that stock three's unconditional reset avoids, and this
round's extensive rotate test coverage already exercises stock three's
version correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Completes the host-API gap the TB-persona review flagged: onEditCommit was
listed in spec §6.2 (P1 scope, alongside onSelectionChanged/
onEditModeChanged) but never implemented - only onUpdate's every-edit,
source-agnostic channel existed, which is exactly the flood that broke
materials-designer's redux history integration in the old modal editor.

Threads a `source` string through every commit path down to
onStructureModified(material, source): the mixin's commitMovedAtom_/
commitMovedAtoms_ now tag "drag" (direct body-drag, endAtomDrag_) vs
"gizmo" (TransformControls "mouseUp" listener, translate or rotate alike);
addAtom/removeSelectedAtom(s)/cloneSelectedAtoms tag "add"/"remove"/"clone".
ThreeDEditor.jsx forwards this to a new onEditCommit(material, {source})
prop via _applyMaterialToViewer, and adds "coordinate-input"/"element-input"
for its own typed-panel edits and "undo"/"redo" for handleUndo/handleRedo
(which bypass handleStructureModified entirely). Extends spec §6.2's
original 7-value source enum with "element-input" and "clone" for this
round's type-to-change and clone-atom features, which postdate the enum.

Also fixes tests/helpers/editor.js's simulateAtomDrag, whose
onStructureModified interceptor only forwarded the first (material)
argument - silently dropping source and would have masked this feature
working correctly for the direct-drag path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
commitMovedAtoms_/addAtom/cloneSelectedAtoms placed a touched atom's
coordinate by flipping the whole basis to Cartesian and back around
the mutation - Basis.toCartesian()/toCrystal() round-trip every
atom's coordinate via mapArrayInPlace, not just the touched one, so
spec §5.2's "untouched atoms preserved bit-for-bit because they are
never re-derived" claim didn't fully hold in the implementation.
Convert only the touched point(s) directly via
basis.cell.convertPointToCrystal/convertPointToCartesian instead, so
untouched atoms' coordinate entries are genuinely never read or
rewritten and each edit does O(touched) matrix work instead of O(n).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…TB-ARCH-2)

Extract marquee rubber-band selection into a new MarqueeSelectionMixin
and group-transform pivot/centroid math plus the group-only halves of
the TransformControls change/mouseUp listeners into a new
GroupTransformMixin, composed alongside InteractiveStructureEditorMixin
in wave.js. The direct-drag lifecycle (beginAtomDrag_/endAtomDrag_/
cancelAtomDrag_) stays in the base mixin - it's a genuinely unified
state machine covering all 4 drag shapes at once, and this round's
Esc-cancel regression tests are concentrated exactly there, so forcing
a single/group split there would trade real correctness coverage for
a readability preference.

Base file: 1460 -> 1211 lines. Pure refactor: all 181 pre-existing
tests pass unchanged, and behavioral equivalence was further verified
live against a real (non-jsdom) WebGL Wave instance - marquee-select,
group direct-drag, and gizmo group-rotate all independently confirmed
correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rather than (a) a materials[] prop on ThreeDEditor or (b) a new
merge-workflow component in this repo, the multi-material merge
workflow belongs in materials-designer, built on wave.js's existing
single-material ThreeDEditor plus this session's group-transform
primitives (select/drag/rotate a rigid group of atoms). Updates the
spec's decision table (§9), props table (§6.2), and roadmap (§11),
plus the plan doc's outstanding-decisions table, so nothing still
reads D-2 as open or as a wave.js roadmap item.

Notes two integration shapes for materials-designer, neither needing
wave.js changes today: pre-merging materials into one before handing
ThreeDEditor a single material as usual, or building a custom viewer
against lower-level primitives (Wave, createAtomsGroup,
getUnitCellObject) - which would need those exported from
src/exports.js, deferred until actually needed rather than grown
speculatively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Assesses the tip of the interactive-editor stack (1eefe20) against
origin/dev, with every claim verified by running it rather than read
off the existing plan notes.

Headline findings:

- PR #212 is unmergeable (mergeable_state: dirty) against a dev that
  has since migrated to the @mat3ra scope; two of the four conflicting
  files are generated build output we track for no reason.
- van der Waals radii have never been applied: settings.ts builds a
  positional array, atoms.ts indexes it by element symbol, so every
  atom renders at sphereRadius (1.5) regardless of element. Verified
  at runtime; snapshot baselines encode the bug as correct.
- CI verifies almost nothing - no lint, no tsc, no build - which is
  why 19 eslint errors sit on the tip unnoticed, and the Dockerfile
  uses npm install rather than npm ci so the lockfile is never
  exercised.
- Coverage config measures only src/**/*.js, reporting ~9% for a
  codebase whose real coverage is 82.3%.

Also records nine further defects (S-1..S-9), dependency and supply
chain state, the AGENTS.md convention mismatch, and a P0-P3 plan.
…types

Repo hygiene and the missing CI gate, from the 2026-08 status analysis
(docs/codebase-status-2026-08.md, batches A and B).

Hygiene:

- Drop the bare `plan` entry from .gitignore. It silently swallowed every
  new file under plan/, so the folder could only ever hold the files that
  predated the rule.
- Untrack dist/. Generated output has been committed on 74 of 174 commits,
  is how a stale dist/exports.d.ts shipped, and currently accounts for two
  of PR #212's four merge conflicts. The pre-commit hook no longer
  transpiles and re-adds it; a `prepack` script plus an explicit build step
  in the publish job produce it at release time instead.
- Un-LFS tests/fixtures/**. material.json is 769 bytes; keeping it behind
  Git LFS meant a clone without git-lfs failed all 13 suites at parse time
  with "Unexpected token 'v', \"version ht\"...". The .expected.png and
  .snap baselines stay in LFS, where they belong.

CI:

- Add a `verify` job running npm ci / lint / tsc --noEmit / build on a
  plain runner. Nothing here needs a GL context, so it gates every push in
  ~2 minutes. This is what the commented-out actions/js/validate step was
  supposed to do; without it, 19 eslint errors accumulated unnoticed.
- Dockerfile uses npm ci rather than npm install --legacy-peer-deps, so the
  committed lockfile is actually exercised and builds are reproducible.
- jest collectCoverageFrom now includes ts/tsx. The JS-only glob reported
  ~9% for a suite measuring 82.3%. Adds a coverageThreshold floor just
  under the measured baseline, to ratchet up rather than erode.

Lint (19 errors -> 0):

- class-methods-use-this is disabled for src/mixins/** via an overrides
  block with a written rationale, replacing what was already a dozen
  hand-written inline suppressions of the same rule. The tree is built on
  abstract base classes and template methods - getLabelTextFromLabeledObject
  is abstract with four overrides, getAdditionalObjectsFromSelectedObjects
  has two - so a base implementation that ignores `this` is the design, not
  a smell.
- viewSettingsUrl.ts: two for-of loops with `continue` rewritten as
  forEach with early return, per no-restricted-syntax/no-continue, plus one
  prettier fix.
Batch C of the 2026-08 status analysis.

Removed - zero import sites anywhere in src/ or tests/:
moment, underscore.string, sprintf-js, classnames, @mui/styles, @mui/lab.

Removed - replaced at their single call sites:

- underscore. One use, `_.omit(props, ...)` in SquareIconButton, now
  destructuring with a rest element. It carried GHSA-cf4h-3jhx-xvhq
  (arbitrary code execution, critical) for the sake of one function call.
  `isToggled` is added to the props interface: the omit list excluded it
  but the type never declared it, so the behavior is preserved rather than
  quietly changed. Nothing in the repo actually passes it.
- jquery. Three uses, all `parseFloat($(e.target).val())`, now
  `parseFloat(e.target.value)`.

Moved to devDependencies: typescript and pixelmatch. Consumers were
installing the TypeScript compiler and an image-diffing test tool as
runtime dependencies.

@types/three pinned to ^0.140.0 to match three@0.140.2, down from ^0.173.0.
The 33-minor drift meant tsc was validating against an API generation the
runtime does not have, and dropping to the real one surfaced two latent
errors it had been masking:

- atoms.ts used THREE.Object3D<THREE.Object3DEventMap>, a generic form that
  does not exist in 0.140 (Object3D is not generic there).
- listeners/mixins.ts wrote raycaster.params.Line.threshold in place, but
  params.Line is optional - an unchecked dereference. Now assigned as a
  whole object.

@types/underscore dropped along with underscore.

Verified: lint 0 errors, tsc clean, 181 tests passing.
settings.ts built vdwRadii with Array.prototype.map over
Object.keys(PERIODIC_TABLE), producing a positional array indexed 0..117,
while getAtomRadiusByElement (mixins/atoms.ts) looks radii up by element
symbol. `radiimap["Si"]` on an array is always undefined, so every lookup
fell through to `settings.sphereRadius` and every element rendered at
exactly 1.5 A. Verified against a live Wave instance before the fix:

    {"Si":1.5,"H":1.5,"U":1.5,"sphereRadius":1.5,"vdwLen":118}

Hydrogen and uranium were the same size. The 118-entry table was computed
correctly and discarded on every lookup. In an atomic viewer, sphere size
is the primary visual cue for element identity, so this is a correctness
defect, not a cosmetic one.

Fixed by building the map with Object.fromEntries, keyed by element symbol,
with a comment recording why the shape matters. Post-fix:

    {"H":1.2,"C":1.7,"Si":2.1,"Fe":1.5,"U":1.86}

No retune of atomRadiiScale needed: at the default 0.2 the largest sphere
pair in the test fixture spans 0.84 A against a 2.368 A nearest-neighbour
distance, so nothing overlaps.

Adds tests/__tests__/mixins/atoms.js - the first test module for the atoms
mixin - asserting the map is symbol-keyed, that radius ordering follows
H < C < Si, that the scale factor composes, and that an unknown element
still falls back to sphereRadius. The first two fail against the pre-fix
code and pass after (confirmed by stashing just the settings.ts change);
the last two hold in both, since they describe behavior that did not change.

move-actual-expected.sh was stale and silently did nothing: it looked for
baselines directly under __snapshots__/, but they live in
__snapshots__/expected/ per tests/utils.js::getExpectedImageFilePath. Its
`forward` pass copied to the wrong directory and its save pass matched no
files. Repointed at expected/, made glob-safe, and its help text now says
when a regenerated baseline is actually safe to commit.

ACTION REQUIRED - the 17 visual baselines are NOT in this commit.

Atoms legitimately render larger now, so 17 snapshot tests fail against
baselines that were generated with the bug and therefore encode it as
correct. Regenerating them needs a Git LFS write, which this environment
cannot perform (lfs.github.com returns a 403 policy denial), so they are
deliberately left out rather than half-committed. To finish:

    docker-compose build && docker-compose run test   # produces .actual.png
    ./move-actual-expected.sh forward
    # diff each image: atoms larger, cell wireframes/positions/colors identical
    git add tests/__tests__/__snapshots__/expected && git commit

The regeneration was performed and visually verified locally before being
dropped: only sphere radii changed, and the unit tests above prove the fix
independently of the images.
Batch D of the 2026-08 status analysis, excluding S-5 (see the workplan for
why hotkey scoping needs its own design pass).

S-1 WaveComponent.reloadViewer no longer wraps everything in
try/catch -> console.warn. The comment justified it with "tests have no
WebGL", but the suite renders through a real headless-gl context; in
production the catch only hid genuine render failures behind a stale
viewer.

S-2 _handleResizeTransition retains its 500ms timeout handle and
componentWillUnmount clears it. Unmounting inside the window previously ran
handleResize() against an already-disposed renderer. Repeat calls now
supersede the pending transition rather than stacking, since only the
latest container size matters.

S-3 createRotatingGifData restores orbitControls.autoRotate and
autoRotateSpeed in a finally block. They were trailing statements, so any
throw during frame capture or GIF encoding left the viewer spinning at a
modified speed for the rest of the session.

S-4 Removed two no-op canvas calls in image.js: getContext("2d", ...) on a
canvas that already holds a WebGL context returns null, and
`canvas.willReadFrequently = true` set an inert expando, since
willReadFrequently is a getContext attribute rather than a canvas property.

S-6 _applyInitialToggleSettings retries once on the next tick instead of
returning early forever when the Wave instance is not yet constructed.
Every toggle in a shared view link was being dropped silently. The retry
timeout is cleared on unmount.

S-7 The undo/redo history stack is capped at 50 entries. Each entry is a
full Material clone, so it grew unbounded for the length of a session -
worth caring about on structures with thousands of atoms.

S-9 Dead surface from the removed modal editor:

- handleSetMaterial deleted: no caller, no propType, absent from the ref
  API. The postMessage-bridge regression test used it as its canary, so it
  now watches handleStructureModified - live, and both mutates editor state
  and notifies the host, so the security assertion still means something.
- renderWaveOrThreejsEditorModal renamed to renderViewerWithToolbars; there
  has been no modal since the editor moved in-viewer.
- Two `.cm-editor` guards dropped along with the CodeMirror scripting panel
  they protected, whose dependencies and CSS were already removed.
- materialsToThreeDSceneData deleted from src/utils.js: it constructed an
  entire WebGL Wave just to serialize scene JSON, was never exported from
  exports.js, and had no remaining caller. It was also the only edge in the
  utils <-> wave import cycle, so both no-cycle suppressions are now gone.
- 470 of main.css's 496 lines were #threejs-editor-scoped rules for the
  deleted component - the status report undercounted this as 99, which was
  the number of string occurrences, not rules. The globally-scoped
  .selectBox and .three-renderer-selection classes are deliberately left in
  place: they are unused in this repo but a host app could be styling
  against them, so removing them is a separate, breaking-change decision.

Also: three eslint-disable directives went dead once the import cycle
broke, and `npm run lint` now passes --report-unused-disable-directives so
stale suppressions fail the build rather than accumulating.

Verified: lint 0 errors, tsc clean, 185 tests passing, coverage 82.9%
statements / 69.4% branches (above the new floor), production build clean.
…aims

AGENTS.md was the C++/DFT engine project's file verbatim - MPI rank-0
logging, GTest layouts, pseudopotential naming, snake_case variables and
file names, ESSE JSON schema rules. In a TypeScript/React repo that
actively misdirects, and the drift showed: file naming is three-way split
(interactive_structure_editor.ts / LinesManager.ts / viewSettingsUrl.ts)
and private markers are two-way split within one class hierarchy
(_structure vs selectedMeshes_).

Kept the OOP and design-pattern guidance, which transferred well and which
this codebase genuinely follows - the labels mixin is textbook polymorphic
dispatch. Replaced everything else with what the repo actually is:

- The two environment prerequisites that block a fresh clone (git-lfs, then
  a GL context via mesa/xvfb) stated up front, since without them the suite
  does not run at all.
- The four verification gates, matching what CI now enforces.
- The real architecture: WaveBase + mixwith mixins, construction order,
  where the React wrappers sit, and that editing is delta-based - with the
  scene-re-derivation pattern called out as removed, not merely unused.
- Naming rules translated to JS/TS, recording both the trailing-underscore
  convention and WaveBase's older leading-underscore usage, plus a
  going-forward rule for file names that does not require renaming
  load-bearing files in an open PR stack.
- The house testing standard written down: a fix ships with a regression
  test proven to fail first.
- Two new hard rules earned during this work: never commit generated output,
  and do not restructure files with unmerged changes.

Documents the counterexample to its own single-responsibility rule too -
the drag state machine that deliberately stays unsplit - so the principle
does not get applied mechanically.

README: mixin list corrected (cell is .ts, there is no mouse.js), the
clone URL and three.js fork reference updated, WaveComponent's actual
export status noted, the removed scripting console moved from "desirable
feature" to explicitly out of scope, the LFS note corrected now that JSON
fixtures are plain files, and the host-side test commands given with their
prerequisites. package.json repository/bugs URLs moved off Exabyte-io.

Two corrections to my own status report, both found by executing it:

- The dead #threejs-editor CSS was 470 of 496 lines, not 99. The original
  figure counted string occurrences rather than rules.
- Dropping the direct underscore dependency does not clear its critical
  advisory: underscore@1.8.3 also arrives transitively through
  @mat3ra/periodic-table, a production dependency. The prod-tree
  vulnerability count is unchanged at 13. npm audit's suggested remedy,
  @mat3ra/periodic-table@2026.2.6-0, does not install - its own prepare
  step fails on a missing resolveJsonModule and a missing
  @mat3ra/tsconfig file - so this is work in that repo, and it is recorded
  in the workplan's out-of-scope list rather than claimed as fixed.
The vdW fix's 17 snapshot baselines could not be committed: they are
LFS-tracked and this environment gets a 403 policy denial from
lfs.github.com. Documents why un-LFSing them was rejected (1.2 MB against a
1.43 MB pack, growing per refresh) and the exact commands to finish the job
from an environment with LFS write access.
…peers

Three changes on request, two of which correct earlier commits on this
branch.

@mat3ra scope, matching what dev already did in #205/#206:

- package renamed @mat3ra/wave.js; author updated
- @exabyte-io/cove.js -> @mat3ra/cove@2026.7.18-4 in peer and dev
  dependencies, and in prestart's npm-link-shared path
- all 8 source import sites, jest.config.js's transformIgnorePatterns, and
  the README badge, install command and cove-linking instructions

Every import path and export shape was verified against the new package
before migrating, since it is a ~17-month jump: DarkMaterialUITheme is
still a named export of dist/theme, ThemeProvider is still the default
export of dist/theme/provider, NestedDropdown/IconByName/Dialog are still
default exports with NestedDropdownAction and NestedDropdownProps named
alongside, and the downloader and alerts helpers are unchanged. tsc is
clean against it and the test suite is unchanged.

The migration also removes the one import that reached into a dependency's
untranspiled source: AlertProvider came from cove.js/src/theme/provider
because that version did not export it from dist/. @mat3ra/cove ships it
from dist/theme/provider next to the default ThemeProvider, so the two
collapse into a single dist/ import. That import was the only reason
wave.js forced a bundler exception on its consumers, and it is why
jest.config.js needed cove in transformIgnorePatterns at all.

dist/ is tracked again. Other packages consume it directly from the
repository, so it has to be in the tree; the pre-commit hook rebuilds and
stages it as before. `prepack` is kept so a published tarball always
carries a freshly-built dist/ regardless of how the publish action
installs. The build step added to the publish job is reverted as redundant.

While re-adding it: a clean build produces 97 files where 129 were tracked.
The 32-file difference is orphaned output - AtomColorManager, atomLabels,
baseLabels, elementLabels, labelUtils, labelsHolder, angleMeasurement,
baseMeasurement, distanceMeasurement, threeJsUtils, screenshot and others,
all compiled from source files deleted during the editor restructuring.
tsc does not remove outputs for deleted inputs and `git add dist` only ever
adds, so the committed dist/ had been accumulating them. This is the same
mechanism that shipped the stale dist/exports.d.ts caught in the PR #204
review. `transpile` now removes dist/ before building, so a tracked dist/
cannot drift from src/ again.

Restored dependencies wrongly pruned earlier on this branch: moment,
classnames, @mui/styles, @mui/lab and underscore. wave.js does not import
them, which is what the original analysis checked - but they are
@mat3ra/cove's peerDependencies, and this project installs with
--legacy-peer-deps (the publish action passes it explicitly), which does
not auto-install peers. dev declares all five for the same reason. Only
underscore.string, sprintf-js and jquery were genuinely unreferenced and
stay removed; the SquareIconButton and jQuery rewrites stand on their own.

Verified: lint 0 errors, tsc clean, 168 passing with the 17 known
stale-baseline snapshot failures unchanged, production build clean.
The editor spec settled what happens when you click. This proposes the other
half: knowing what will happen before you click, and seeing what happened
after. Fourteen findings verified against the source, thirteen proposals
numbered U-n to stay clear of the spec's D-n/R-n and the status doc's S-n,
and five SVG mockups following the convention already used by the spec's
assets.

The load-bearing gaps: nothing on screen identifies the structure (no
formula, atom count, lattice or units); nothing indicates the active mode,
so arming a measurement leaves no trace and edit mode's move of orbit-rotate
to the right mouse button is undiscoverable; ten of twenty-one bindings
appear in no tooltip or menu; and the edit panel is 84 px wide by ~600 px
tall, so its coordinate fields clip with no scroll in a short viewer.

Proposals only - no code changes. Two items (figure export, touch support)
are gated on the same wave.js-versus-host scope question that decision D-2
answered for multi-material editing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
Nothing in the viewer said what structure was on screen: no formula, no atom
count, no lattice, and no units except one caption inside the edit panel that
only appeared while exactly one atom was selected (finding F10). Element colours
encoded identity with no key (F11), and a measurement result existed only as a
3D sprite plus a silent clipboard write (F5).

StatusBar is a read-only view of state ThreeDEditor already holds, so it cannot
perturb the edit path:

- formula with subscripts, atom count, lattice type and constants, basis units
- composition chips coloured from settings.elementColors, doubling as a legend
  and - in edit mode only - a select-all-of-element control routed through the
  mixin's reselectAtomsByIndices, keeping one source of truth for
  onSelectionChanged
- a selection/measurement region marked aria-live, the package's first live
  region (part of F9)

Lattice constants are computed as vector norms from Lattice.unitCell rather than
read from a getter, so they cannot disagree with the cell that is drawn. A
component-wise reading would report 3 A for a (3,4,0) vector - the same class of
mistake defect D22 made when it took a cell centre component-wise - and there is
a regression test for exactly that.

Also extracts normalizeElement and reuses it in renderEditToolbar, replacing a
duplicated typeof-chain over the basis's two element-entry shapes.

The measurement slot is a prop the editor does not populate yet: surfacing a
running measurement needs a callback out of the measurements mixin, which lands
with U-2's mode pill where the "1 of 2 picked" state belongs.

Verified: tsc --noEmit clean, eslint clean, 19 new tests pass, and the existing
component suites stay green (63/64 - the single failure is atomRadiiScale's
visual baseline, an LFS pointer stub in a container without git-lfs, and fails
identically without this change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
A mode was signalled only by the Edit icon changing colour, and a measurement
mode not at all: it is armed from a dropdown that then closes, after which every
click means something different with nothing on screen to say so (finding F5).
Edit mode additionally remaps orbit-rotate to the right mouse button (spec 3f) -
a change to what the primary mouse gesture does, advertised nowhere (F4).

ModePill names the active mode, states the bindings that appear in no tooltip or
menu, and offers a one-click exit so leaving a mode no longer means hunting back
through the menu that armed it. Absence of a pill is itself information: clicks
do nothing but orbit.

Measurement plumbing turned out to need no new callback. The managers already
push getSettings() through updateState on every click, and that lands in
ThreeDEditor's measurementsSettings state, so the pill and the status bar read
what is already there. Two facts were missing from that payload and are now
included:

- selectedAtomsCount, so a part-specified measurement reads as "1 of 2 picked"
  instead of looking like nothing happened
- atomsPerMeasurement, declared on the managers (2 for distance, 3 for angle),
  so the UI never hardcodes an arity the managers already own via
  getPairsOfSelectedAtoms/getTripletsOfSelectedAtoms

Progress is the remainder, not the total: picks accumulate across measurements,
so five picks in distance mode is two finished pairs plus one atom waiting for a
partner. Regression-tested for both pair and triplet grouping.

This also populates the status bar's measurement slot, which U-1 deliberately
left empty - a measurement result now has a text readout rather than existing
only as a 3D sprite plus a silent clipboard write.

The pill deliberately does not advertise "? = keys" yet. The sheet lands in U-3;
promising a key that does nothing is exactly the drift that made defect D2.

Formatting and progress live in src/utils/measurementReadout.ts because both the
pill and the status bar consume them and neither owns them.

Verified: tsc --noEmit clean, eslint 0 errors, 22 new tests pass. Full suite goes
from 185 passing / 19 failing to 207 passing / 19 failing - the same 19 visual
snapshot tests, which compare against LFS pointer stubs in a container without
git-lfs and fail identically on the parent commit (measured by stashing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
Twelve configured keys plus nine hard-coded pointer and key bindings existed, and
ten of the twenty-one appeared in no tooltip and no menu (finding F3) - every
selection modifier, the right-button orbit remap, Delete, and undo itself. The
fastest paths in the editor were the least discoverable ones.

"?" now opens a sheet grouped View & camera / Select & edit / Measure. Every row
is generated from settings, so rebinding a key updates its label with it. Defect
D2 - a tooltip that promised a hotkey which did not exist - was this same drift
running the other way, which is why the sheet is generated rather than written.

To make that possible, the four editor keys that `keypress` never fires for moved
out of handleEditModeKeyDown and into settings.editorKeysConfig as declarations
(keys, usesModifier, requiresShift, label). The handler now matches through
matchesEditorKey against those declarations instead of inline comparisons, so the
handler and the sheet cannot disagree about what is bound.

matchesEditorKey is stricter than the code it replaces, deliberately: Ctrl+Delete
no longer removes an atom, because a modifier-free binding should not fire while
a modifier is held. Shift stays insignificant for bindings without a modifier, so
Shift+Delete is still Delete. Both are regression-tested.

Pointer gestures are listed too. They are in no config because there is nothing
to rebind, but omitting them is why "Shift-click adds to the selection" was
documented nowhere in the product.

The sheet respects `editable`: without it there is no edit mode, so its keys and
gestures are not advertised and the section is dropped rather than rendered as a
bare heading. The modifier renders as Cmd on macOS and Ctrl elsewhere, resolved
at call time.

Deliberately not carried over from the mockup: the "advertised nowhere" markers.
They were a device for arguing the case in the proposal; as code they would be a
claim about other UI with nothing to keep them true.

Escape is declared in editorKeysConfig so the sheet can list it, but stays
implemented in the mixin, which owns the cancel-drag-versus-deselect distinction.

Verified: tsc --noEmit clean, eslint 0 errors, 21 new tests pass. Full suite 228
passing / 19 failing against a 185/19 baseline - the same 19 visual snapshots,
which compare against LFS pointer stubs and fail identically without any of this.
No non-visual failure, so the keydown refactor preserved the existing undo/redo
and delete hotkey behaviour those suites already cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
getCheckmark drew every inactive View item as a grey checkmark (finding F2). A
grey checkmark reads as "checked but disabled", not "off": one shape was carrying
both answers, separated only by colour, which also put the state out of reach of
anyone who cannot compare two greens.

ToggleIndicator replaces it with a switch, which has exactly one reading, plus
the item's hotkey in a fixed slot beside it. The hotkey moves out of the label
text (F3) - spelled inline, some rows read "Bonds [B]" and others just "Axes",
with nothing keeping that consistent. All twelve call sites across the View and
Measurements menus are converted, and the keys come from settings.hotKeysConfig
rather than being written into the strings.

The switch is drawn rather than built from MUI's Switch on purpose: the menu row
is already the control, so a real form control would add a second focusable,
separately-clickable target inside it. It is marked aria-hidden and the state is
exposed as visually-hidden "on"/"off" text instead, which is also what makes the
state available without relying on colour at all.

Dropped the now-unused CheckIcon import - getCheckmark was its only consumer.

Verified: tsc --noEmit clean, eslint 0 errors, 8 new tests pass, including one
that mounts the real View menu and asserts the bonds key renders in the indicator
slot and no longer appears bracketed in the label. Full suite 236 passing / 19
failing against the 185/19 baseline, the same 19 visual snapshots that fail on
LFS pointer stubs without git-lfs. No non-visual failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
claude added 11 commits August 12, 2026 17:32
Extends §0.1 with the six P1 branches, their commits and test counts, and the
three further places the implementation diverged from what this document proposed:
U-6's units toggle changes the display rather than the material, U-8 ships no live
bond count because bonds are computed asynchronously and printing an uncomputed
number would be the same false claim this work exists to remove, and U-11 is
partial by design.

F1 moves from "still live" to fixed, with the measurement that shows it: at
1100x520 - the viewport that used to clip - the inspector occupies 12-196px and
the tool strip 12-380px against a status bar at 486px.

Also records that three layout bugs in newly-added code were caught only by
measuring the live DOM, none of them visible in jsdom, since that is the argument
for keeping a browser pass in the loop.

Status line now points at the one decision P2 waits on rather than implying the
whole document is unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
Four conflicts, the same set the codebase status analysis predicted for this stack
(section 2.1) - and half of it was generated output we track in git.

- package.json: three conflicts, all duplicate insertions. dev adds @mat3ra/cove
  to peerDependencies and devDependencies at the top of each block; this branch
  already carried both, alphabetically placed, at the identical version
  (2026.7.18-4). Kept ours, which loses nothing. The `prepack` script from the
  editor stack is kept.
- src/components/ThreeDEditor.jsx: one conflict, on a cove import. dev splits
  AlertProvider out to "@mat3ra/cove/src/theme/provider" - a deep import into the
  dependency's *untranspiled* source, which is exactly what forces jest.config.js
  to special-case cove in transformIgnorePatterns and would force the same
  exception on every downstream bundler (status doc 2.2). Kept ours, which imports
  both from dist/. Verified: tsc clean, suite passes, app renders.
- package-lock.json and dist/components/ThreeDEditor.js: generated. Resolved by
  regenerating rather than hand-merging - dist/ was rebuilt with `npm run
  transpile` so it matches src/ by construction, instead of by a merge that could
  silently pick the wrong half. This is the fourth time this stack has paid for
  tracking dist/; untracking it is still the right fix (status doc P0 item 1).

Verified after the merge: tsc --noEmit clean, eslint 0 errors, 321 passing / 19
failing - the same visual-snapshot baseline, unchanged.
CI's run-tests job has been red since the van der Waals fix (7e3541f) landed on
this stack: that commit made atom radii per-element instead of one size for all,
which legitimately changes every rendered image, and the baselines still encoded
the pre-fix rendering. The regeneration was the outstanding step recorded in
ab4b022 - it was done and verified in an earlier session but could not be
committed there, because the baselines are LFS-tracked and that environment got a
403 from the LFS endpoint. git-lfs works here, so this lands it.

Confirmed pre-existing rather than caused by the UI work: checking out the branch
point (4127347) in this environment fails the same 17 tests with the same diffs.
CI reported 17 failed / 323 passed on the branch tip; this environment reproduced
exactly 17 failed / 323 passed, so it is a faithful stand-in for the CI renderer.

Confirmed legitimate rather than a regression, three ways:
- The diff images show thin crescents on the upper rim of each atom sphere and
  nothing anywhere else - cell wireframes, atom positions and colours are
  pixel-identical. Same finding the earlier session recorded.
- Differences are 0.00%-0.50% of pixels, consistent with a small radius change.
- Reintroducing the uniform-radius bug makes the old baselines pass again, which
  is what pins them to the pre-fix rendering.

Chose regeneration over relaxing the comparison. `takeSnapshotAndAssertEqualityAsync`
asserts numDiffPixels === 0 while the README promises "comparison with a
tolerance", so a pixel-count budget is arguably missing - but any budget wide
enough to absorb this (>0.5%) would also absorb the radius regression these
baselines exist to catch. If cross-driver noise shows up later, that tolerance is
worth adding on its own evidence, not as a way to hide a stale reference.

Suite is now fully green: 25/25 suites, 340 passed, 2 skipped, 0 failed.
Every pull request gets a Deploy Preview URL. The Jest suite structurally cannot
reach real browser event ordering, pointer capture, CSS layout at a given viewport,
or GPU rendering - that is the explicit list in the editor spec section 8.1, and the
reason MANUAL_SMOKE.md exists. A preview URL is where that checklist can actually be
run, by a reviewer rather than only by whoever wrote the branch.

vite.config.js sets base = "/wave.js/" so the GitHub Pages deploy resolves assets
under that path. Netlify serves from the domain root, where that prefix would 404
every asset, so the base is overridden on the command line rather than in the config
- the Pages build is untouched. Verified by building with --base=/, serving build/
from a root static server, and driving the production bundle in Chromium: the status
bar, five quick toggles, mode pill, selection inspector, edit toolbar and keyboard
sheet all work, with no failed requests and no console errors.

NODE_VERSION pinned to 20 to match the CI verify job, so a green CI build implies a
green Netlify build.

Deliberately no SPA catch-all redirect: the demo is a single index.html with no
client-side routing, so rewriting unknown paths to it would turn genuine 404s into a
silently blank viewer.

One step cannot be done from the repository - linking the repo to a Netlify site -
so it is written down in README section 4.4 rather than left implicit.
Durable record of what P0/P1 produced, which decisions are settled, which
apparent defects were confirmed not to be defects, why CI was red and how it
was fixed, and the environment gotchas that cost real time - so none of it has
to be reconstructed from commit archaeology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
sharp - a transitive dev dependency of looks-same, which the visual regression
tests use - downloads a prebuilt libvips tarball from GitHub Releases during
install. When that endpoint answers 503 the install falls back to compiling
libvips from source, which neither the runner nor the test image has headers
for, so npm ci fails and the whole job goes red on a commit that changed
nothing relevant.

Both installs (the verify job and the Dockerfile) now retry with linear
backoff, four attempts total. Observed twice on this branch: a 503 on
libvips-8.14.5-linux-x64.tar.br at 20:30Z, and the source-compile fallback
failing at vips/vips8 at 19:56Z, while a parallel run on the same commit
installed cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185JNEgEJLfZZEx7jKHgwu5
Every image the viewer could produce was `toDataURL` on the on-screen canvas:
the dark theme, at whatever pixel size the container happened to have. That is
a screenshot, not a figure - a few hundred pixels tall, dark-on-dark, no scale.

Export > Figure (PNG) now renders the scene once at an explicit resolution and
background without disturbing the viewer:

- Sizes are stated in pixels *and* millimetres at 300 dpi, with single- and
  double-column presets at 85 mm and 180 mm because the question behind the
  dialog is "will this be sharp in print". Height follows the canvas aspect for
  every preset, so choosing a width cannot silently squash the structure; only
  the custom preset lets both be set. Over-large requests scale down
  proportionally against the GL context's own reported limit, and say so.
- White and transparent backgrounds invert the viewer's chrome. Cell edges are
  #CCCCCC and label text #EEEEEE - light-on-dark - so a white-background export
  without this loses its unit cell and its labels entirely. The rule is "light
  and achromatic is chrome": hued lines (boundary conditions) and atoms keep
  their colours, since those carry meaning.
- The scale bar is a round 1/2/5 number of Angstrom drawn in image pixels, and
  it declines to draw at all rather than guess when the scale is unusable. It is
  exact under the orthographic camera; the dialog says so for perspective, where
  a projection has no single scale, instead of presenting an approximation as a
  measurement.
- Chrome is excluded for free: this reads the WebGL canvas, and the toolbars and
  panels are DOM siblings of it.

State is restored in a finally block - size, pixel ratio, clear colour and
alpha, scene background, fog, and every material colour touched - so a failure
mid-capture cannot leave the viewer white, huge and mis-coloured.

Two bugs found while verifying, both fixed here:

- `setOrthographicCameraFrustum` never called `updateProjectionMatrix`, so
  between construction and the first resize the orthographic camera projected
  the initial +-10 frustum from initCameras rather than the cell-fitted one.
  Invisible in a browser (ResizeObserver fires and handleResize repairs it) and
  load-bearing here, because the scale bar reads those frustum fields. Caught by
  cross-checking the reported scale against the camera's own projection over a
  known 2 A separation - an assertion every other test here would have passed
  with the formula off by a factor of two.
- The filename sanitiser kept ".." runs, so a structure named "../../Si" became
  "..-..-Si". Names are free text reaching a download filename.

`handleResize` is split into `setViewportSize(width, height, updateStyle)`; the
export passes updateStyle: false so the drawing buffer changes without writing
figure-resolution pixels into the canvas's CSS.

Verified in Chromium against the production bundle: shown resolution equals the
PNG's actual dimensions, white exports are opaque white with dark chrome,
transparent exports carry alpha 0, and the on-screen canvas is byte-identical
before and after three consecutive exports. The scale bar's type size came from
looking at the result - height/36 renders at about 5 pt at 300 dpi, below most
journals' minimum.

70 new tests (410 passing, was 340).
`npm run lint` - what CI runs - passes --report-unused-disable-directives, which
`npx eslint src tests` does not. My local check used the latter, so a
`class-methods-use-this` disable that the rule never fired for read as clean
locally and failed the verify job.

Rather than delete just the comment: `isChromeLineColor` takes a colour and
returns a boolean about it, using no instance state, so it belongs at module
scope. Moving it there removes the reason the directive existed.
Records figure export against the plan: what it deviates on, the two
pre-existing bugs it surfaced, and the answer it reached to the scope question
this document had held P2 on - the render needs the scene graph, camera frustum
and renderer clear state, none of which a host can reach.

Also states why U-13 is a stacked PR rather than another commit here: it is the
only item in the set that changes how existing input is handled - touch-action on
the canvas, and OrbitControls' touch mapping in edit mode - rather than adding a
new surface. That makes it the one slice worth being separately reviewable and
separately revertable, so this PR stops growing.
…ded layout

From review of the deploy preview. All three were reproduced by measuring the
live DOM before changing anything.

Atomic radius stopped at 10x. It multiplies each element's van der Waals radius,
so 1 is already space-filling and everything above it is atoms swallowing the
cell - a range whose top 90% is unusable also left the useful band around the 0.2
default as a few pixels of slider travel. Now 0.1-1, step 0.05.

Parameters said too much. Every control carried a two-line paragraph. Trimmed to
one line each, keeping the part that was the point - the range, and what the
number multiplies: "0.1-1 - 1 = full van der Waals size", "0-2 - x the two atoms'
van der Waals sum", "1 x 1 x 1 -> 192 atoms". The per-cell count came out of the
cost line because the status bar already shows it.

View toggles were janky. The keycap slot was only rendered on rows that had a
hotkey, so each row sized itself and the switches landed at two different x
positions 32 px apart. The slot is now always present - empty and hidden, fixed
width - and all nine switches measure at one x.

The embedded case was the real find, and it was a gap in my own verification: I
had checked a wide-but-short viewport and never a narrow one, which is exactly
what embedding in a host panel gives. Measured, in edit mode:

  520x900  pill 520 px wide, overlapping the icon strip, the inspector AND the
           edit toolbar simultaneously
  700x620  pill overlapping the inspector
  900x420  pill overlapping the inspector

Two causes. The pill's container spanned left: 0 / right: 0, so it could grow
under the chrome pinned to both edges; and it took the inspector's corner into
account nowhere. Now the container's insets describe the space that is genuinely
free - clearing the icon strip always, and the inspector too while editing - and
the pill observes that space with a ResizeObserver, dropping its binding list
below 460 px and moving to the left edge.

A media query would have been the wrong instrument: an embedded panel can be
narrow inside a wide window, which is the same mistake as the viewport-width
isMobile that U-13 removes. utils/useObservedWidth.ts is a real container query.
It uses a callback ref, because the pill renders nothing until a mode is armed and
an object ref with a mount-time effect attaches the observer on a render where the
node does not exist yet, then never re-attaches. A zero measurement reads as "not
measured", so a no-layout environment does not look like the narrowest container.

What gets dropped is the reminder, never the state or the exit: at 520 px the pill
is "EDIT x", and the same bindings are in the shortcuts sheet the View menu opens.

Re-measured after: no collisions at any of the three embedded sizes, all nine
switches at one x, and desktop at 1280 and 1600 unchanged - pill still centred
with its full text.
… size

Reported from review: "Gif recording now dependent on the size of the window,
must be restricted by the square canvas like we had before."

createRotatingGifData read gifWidth/gifHeight straight off the canvas, so every
GIF came out the shape of whoever's window recorded it - a 1400x620 window gave a
1400x620 GIF, letterboxed wherever it was embedded. A rotating structure also
wants a square specifically: it sweeps through its own width as it turns, so a
frame narrower than the structure's largest dimension clips at the extremes.

Frames are now captured at 512x512, overridable via options.size and clamped to
what the GL context can render. 512 is smaller in area than the window-sized
frames it replaces on a typical desktop, so encoding gets cheaper too.

The size handling is shared with figure export rather than duplicated:
getFigureImage's save/restore is extracted to beginFixedRenderSize, which returns
a restore callback so a synchronous caller and an await loop over frames can each
wrap it in their own try/finally. Restoring inside finally means a failure during
capture or encoding cannot leave the viewer stuck at 512x512.

Verified in Chromium from a 1400x620 window: the downloaded GIF's logical screen
size is 512x512, atoms render round rather than stretched, the structure stays
fully in frame through the rotation, and the viewer's own canvas is back to
1400x620 afterwards.
@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for mat3ra-mave ready!

Name Link
🔨 Latest commit fafa8d2
🔍 Latest deploy log https://app.netlify.com/projects/mat3ra-mave/deploys/6a7e68cf27b60f0008b7aef0
😎 Deploy Preview https://deploy-preview-216--mat3ra-mave.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Consolidating the July-12 chain, #213 turned out not to be fully redundant. Its
baselines are byte-identical to the ones regenerated here, but three files were
not, and all three of its versions are the correct ones:

- AGENTS.md described the old failure mode - "fails at parse time on a fixture
  that is still a pointer stub". Fixtures are plain JSON now; what is LFS-tracked
  is the visual baselines, and without git-lfs they stay 130-byte stubs so the 17
  visual tests fail while every other suite passes. That green-looking partial run
  is the signal to check LFS first, which is exactly how it presented this session.
- plan/codebase-fixes-workplan.md still flagged the baselines as outstanding.
  They were regenerated in 5882636, so the warning was claiming unfinished work
  that is done.
- tests/__tests__/__snapshots__/.gitignore was missing the *.save.png rule.
  move-actual-expected.sh writes its rollback copies into expected/ under that
  name, and the documented finishing step is `git add .../expected` - which would
  commit all 18 of them as plain non-LFS blobs.

None of the three are touched by this branch after the common ancestor, so these
are #213's versions verbatim rather than a merge.
claude added 3 commits August 13, 2026 07:42
…ed parameters

Five findings from a maintainer read of the two open branches.

1. The GIF fix put the square drawing buffer back in a `finally`, which meant
   it stayed square through gifshot's encode - seconds for 60 frames, all of
   them with the on-screen canvas stretching a 512x512 buffer across a wide
   viewer. The frames are already captured by then, so the restore belongs
   right after the capture loop; the `finally` keeps a null-guarded copy for
   the throwing path.

2. `useObservedWidth` declared its record outside the `[]`-dep callback, so
   one module-level object was shared by every mounted observer.

3. `atomRadiiScale` and the repetitions were clamped on the way out of the
   menu but not on the way in. A saved URL or a host's initialViewSettings
   could carry 3.0, which rendered at 3.0 while the slider pinned at 1.0 and
   then snapped there on first touch. `clampParameterSettings` puts the
   clamp next to the ranges that define it, and the editor runs its initial
   settings through it.

4. `getMaxFigureDimension()` issues three `gl.getParameter` calls, and the
   render path called it unconditionally - a pipeline stall per frame to
   compute a bound only the export dialog reads. Gated on the dialog.

5. ModePill imported a width constant from SelectionInspector, so the
   overlay depended on a sibling for its geometry. The shared numbers now
   live in `chromeLayout.ts`, which both read from.
…e API

#202 (SOF-7926) landed on dev while this was open, removing the `Material.Basis`
and `Material.Lattice` accessors in favour of `getBasis()` / `getLattice()` and
`setLattice()`, and bumping code/esse/made to 2026.8.13-0.

That commit adapted the four call sites that existed on dev. This branch carries
the interactive-editor stack, which dev has never seen, so it had eleven more —
in `ThreeDEditor`, `interactive_structure_editor` and `StatusBar` — plus ten in
tests. All migrated. `Made.Basis` / `Made.Lattice` are static references on the
namespace and are deliberately untouched.

`StatusBar`'s `MaterialLike` gains `getLattice?: () => LatticeLike` in place of
the `Lattice` property. Its fixtures are deliberately plain objects rather than
real Materials, so they now expose the accessor as a function; a test was added
for a material carrying no accessor at all, which must return "" rather than
throw.

Two conflicts resolved in this branch's favour, both against changes #202 made
to code the editor stack had already deleted:

- `materialsToThreeDSceneData` in `src/utils.js`. Removed in `7beb8ae` because it
  built an entire WebGL `Wave` just to serialize scene JSON, was never exported
  from `exports.js`, had no caller, and was the only edge in the utils <-> wave
  import cycle. #202 only adapted its accessors; the deletion stands.
- `onThreejsEditorModalHide` in `ThreeDEditor`. Dead since the editor moved
  in-viewer.

Verified: lint 0 errors, `tsc --noEmit` clean, 431 tests passing across 30
suites, production build clean.
@timurbazhirov
timurbazhirov merged commit 33940cb into dev Aug 14, 2026
8 checks passed
timurbazhirov pushed a commit that referenced this pull request Aug 14, 2026
#216 landed the same work minus U-13, so its cherry-picked commits arrive back
through dev as duplicates. Content-identical changes merged cleanly; the three
conflicts are the files where the two lines legitimately differ:

- `ModePill.tsx` — comment rewrapping only. This branch's wording names the
  inspector alongside the toolbar, which is what `chromeLayout.ts` made true.
  The coarse-pointer bindings are U-13's and are untouched by the merge.
- The design doc and the context record — #216 carries the U-12-only telling
  ("U-13 lands separately"); this branch carries the complete one. The complete
  one wins, since this is the merge that makes it true.

Suite counts in both documents refreshed to the landed figures: 461 passing
across 32 suites, up from the 440/30 recorded before the review pass added its
tests.

Verified: lint 0 errors, `tsc --noEmit` clean, 461 tests passing, build clean.
timurbazhirov added a commit that referenced this pull request Aug 14, 2026
feat(ui): touch and small-screen support (U-13), plus the review pass

Completes the UI/UX proposal on top of #216: the canvas claims its own
gestures, edit mode reserves the first finger, controls reach 44 px on
coarse pointers, and nothing names an input the device lacks.
timurbazhirov pushed a commit that referenced this pull request Aug 14, 2026
…r bump

Rebased onto `dev` now that #216 and #214 have landed. The earlier version of
this branch targeted #216's branch and pinned versions that have since been
overtaken by their own advisories — `tar-fs@^3.0.9`, `form-data@^4.0.5` and
`vite@^6.4.2` are all inside the current ranges. Everything below is re-derived
against `dev`'s actual installed tree.

Baseline on `dev`: 61 advisories (5 critical, 25 high). Of those, 29 packages
had a fix reachable without a semver-major bump; the rest need jest 27 -> 30,
`gl`, `looks-same` (which also replaces the `sharp` line) or `@mat3ra/*` majors,
which is separate work with real breakage risk.

Result: **61 -> 36** advisories. Critical 5 -> 4, high 25 -> 10, moderate
13 -> 5. Twenty-five packages resolved, none newly flagged.

## How each version was chosen

Not by hand. For every advisory npm reported as fixable, the lowest release
was taken that is (a) outside the advisory range and (b) still inside the major
that every installed copy already sits in — so no dependent is forced across a
major it did not declare. Where a package is installed at two majors at once,
each gets its own entry: `form-data` 3.0.5 *and* 4.0.6, `js-yaml` 3.15.1 *and*
4.3.1, `tar-fs` 2.1.5 *and* 3.1.3, `ws` 7.5.13 *and* 8.21.3, plus
`brace-expansion`, `minimatch` and `picomatch`.

That scoping is the whole point. A blanket `js-yaml: ^4` moves `eslint`,
`@eslint/eslintrc` and `@istanbuljs/load-nyc-config` — all of which ask for
`^3.13.1` — onto the major that removed `safeLoad`. A blanket `tar-fs: ^3`
moves `prebuild-install`, which is what fetches sharp's prebuilt binary and is
the exact step whose flakiness `19527bd` retries around.

Three entries are deliberately absent:

- **esbuild** needs none. `vite@6.4.3` declares `esbuild ^0.25.0` itself, so
  bumping vite carries it — which matters, because forcing `^0.25.0` onto
  vite 6.0.7's declared `^0.24.2` would cross a 0.x boundary npm treats as
  breaking.
- **ip-address** is reached the same way: `socks@2.8.7` declares
  `ip-address ^10.0.1`, and the override only lifts it past the advisory floor.
- **vite** itself is the one direct dependency here, so it moves in
  `devDependencies` rather than through an override.

`yaml` needed its declared specs as selectors (`yaml@^1.10.0`, `yaml@^1.10.2`)
rather than a bare `yaml@^1`, which npm did not match. It is scoped so vite's
own `yaml ^2.4.2` is untouched.

## What is left, and why

- `dompurify` (and the `@toast-ui/editor` / `@toast-ui/react-editor` pair it
  drags) — the advisory covers every 2.x, and `@toast-ui/editor` declares
  `^2.3.3`, so this is a major bump inside `@mat3ra/cove`'s tree, not ours.
- `@jest/core` / `jest-cli` — jest 27 to 28+.
- The `gl`, `looks-same`, `vite-plugin-node-polyfills` and `@mat3ra/*` chains,
  all semver-major.

## Verification

The lockfile was updated **incrementally** rather than regenerated. A clean
regeneration on this linux-x64 container silently dropped every non-linux
optional binary — 25 `@esbuild/*`, 24 `@rollup/rollup-*` and `fsevents` — which
would have broken macOS and Windows installs. All 26 esbuild and 25 rollup
platform entries are present and bumped in step.

Sixty collateral lockfile changes, all accounted for: the platform binaries
above, two rollup targets that upstream *renamed* (`loongarch64` -> `loong64`,
`powerpc64le` -> `ppc64`), `jsbn` dropped by ip-address 10, and
`regenerator-runtime` no longer needed by `@babel/runtime` 7.29.

Run against the real installed tree from a clean `npm ci`, not just the
lockfile:

- `npm run lint` 0 errors, `tsc --noEmit` clean
- `npm run build` clean; bundle 5,479 kB -> 5,532 kB (+1%, newer esbuild and
  rollup codegen), and the built bundle parses and still carries its feature
  markers
- **461 passing / 32 suites / 0 failing** — the same count as the branch point.
  The visual-regression suites are inside that number, so a renderer-affecting
  change would have surfaced as a pixel diff.
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.

2 participants