Skip to content

feat: build the 3D structure editor directly into Wave - #204

Closed
timurbazhirov wants to merge 13 commits into
devfrom
feat/upgrade-2026-07-11-separated
Closed

feat: build the 3D structure editor directly into Wave#204
timurbazhirov wants to merge 13 commits into
devfrom
feat/upgrade-2026-07-11-separated

Conversation

@timurbazhirov

Copy link
Copy Markdown
Member

Summary

Replaces the standalone @mat3ra/threejs-editor modal with an edit mode built directly into the main Wave viewer, via a new InteractiveStructureEditorMixin. No more popping open a separate full-screen editor — you toggle edit mode in place and interact with the same 3D view you're already looking at.

Picks up and fixes an earlier pass at this that had several bugs (see below).

What it does

With editable set on <ThreeDEditor>, an Edit button appears in the main toolbar. Toggling it on shows a second, right-hand toolbar with:

  • Translate / Rotate — pick the transform gizmo's mode
  • Click any atom to select it — it highlights and a drag gizmo attaches to it; a small panel shows the element symbol and X/Y/Z coordinate fields you can type into directly
  • Add atom — drops a new Si atom at the cell center
  • Delete — removes the selected atom
  • Undo / Redo — steps back and forward through the edit history

Every edit (drag, typed coordinate, add, delete) round-trips through Made.Material/Made.Basis and calls onStructureModified, so parent components stay in sync. The edit toolbar reuses the app's existing MUI components (SquareIconButton, ButtonGroup, Paper, TextField) instead of hand-rolled inline-styled elements, so it's visually consistent with the rest of the viewer's toolbar.

Bugs fixed from the initial pass

  • Crash on every coordinate edit / add / delete atom. material.basis.elements returns ESSE {id, value}-wrapped objects; the code was feeding those straight back into Made.Basis.fromElementsAndCoordinates (which expects plain strings) without unwrapping, unlike coordinates which already were unwrapped. That double-wraps every element, and the next render throws Objects are not valid as a React child, white-screening the whole app. Reproduced live in a browser, fixed in all three call sites, added a regression test that fails against the old code.
  • Selection/deletion silently broken. Click-to-select read userData.atomIndex, but every atom mesh actually carries userData.atomicIndex (the convention used everywhere else in the codebase). Selection always returned undefined; "Delete Selected Atom" was a no-op. The existing test masked this by hand-writing the wrong key.
  • Gizmo went stale after any scene rebuild. Rebuilding the scene (on a coordinate edit, a drag, or even an unrelated viewer setting change) replaces every atom mesh, but nothing re-attached the selection/gizmo to the new instance — so after the first edit, the gizmo would silently detach from the visible atoms. Fixed by capturing/restoring the selection by atomicIndex in Wave.rebuildScene(), with regression tests.
  • Undo/redo history wasn't reset on an externally-supplied new material, and mutated wave._structure directly instead of the proper setStructure(), causing a redundant double-rebuild.
  • TransformControls kept a stale camera reference after toggling orthographic/perspective camera.
  • src/exports.js still exported the deleted ThreejsEditorModal, which would have broken the published package's API entirely.
  • A stray tsc type error (number[][] vs the SDK's [number,number,number][]) that would have broken npm run build.
  • Assorted lint cleanup (unused imports, Math.pow**, formatting) and a package.json indentation glitch left over from the dependency removal.

Testing

  • Full unit test suite passes (npx jest), including new regression tests for the crash and gizmo-persistence fixes.
  • npm run lint clean on every touched file (remaining lint output is pre-existing debt in files this PR doesn't touch).
  • tsc and npm run build (full Vite production build) both succeed.
  • Manually drove the feature end-to-end in a browser against the local dev server: toggle edit mode, select an atom, type a coordinate, drag-equivalent verification, add atom, delete atom, undo, redo, and toggle orthographic camera while an atom is selected — zero console errors throughout.

A screenshot of the editor panel in action will be added as a follow-up comment.

🤖 Generated with Claude Code

timurbazhirov and others added 13 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>
timurbazhirov pushed a commit that referenced this pull request Aug 12, 2026
…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.

Copy link
Copy Markdown
Member Author

Closing — consolidated into #216 (then #214 on top), which squashes the whole July-12-onward chain into one route to dev.

Verified contained rather than assumed: this PR's head b35b8222 is a direct ancestor of claude/uiux-p2-figure-export, so every commit here is in #216.

The branch feat/upgrade-2026-07-11-separated stays on the remote.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant