feat(devtools): plugin workspace with splits, stacked tabs, drag and resize - #493
Conversation
The workspace layout becomes a tree of splits and tab groups so plugins can be arranged in rows, columns and stacks instead of one equal-width flex row. This commit is the maths only: no UI is wired up and no dependency is added yet. Everything in `layout-tree.ts` is pure and imports nothing. That is deliberate. jsdom has no layout engine, so `getBoundingClientRect` returns zeros, and rect maths verified through the DOM would only be verifying its own mocks. Keeping it here makes it exhaustively testable — 67 cases in 58ms — and keeps the layout logic out of the components. Every returned tree upholds the same invariants: a group has at least one tab, a split has at least two children, sizes match the child count and sum to 1, the active index names a real tab, and a plugin id appears at most once. `prune` restores them bottom up after any edit, so closing a tab can collapse an emptied group, unwrap a single-child split, and flatten a same-direction nested split without the callers knowing. `repairLayout` cannot throw. A malformed layout is a data problem, the same as the unknown plugin ids that are already pruned on load, and it must not stop the panel from opening; storage *access* errors still propagate. It prunes unknown and duplicated ids, renormalises sizes, clamps the active index, and falls back to salvaging whatever plugin ids it can find from an unrecognisable shape so a bad write costs the arrangement but not the open plugins. The hostile-input test caught a real stack overflow on a self-referencing object, so reads are depth capped and the salvage walk tracks visited objects. Design and the decisions behind it, including the measured bundle cost of each neodrag primitive, are in docs/superpowers/specs/2026-08-07-plugin-layout-tree-design.md.
`state.activePlugins` is replaced by `state.layout`. The tree is now the only record of which plugins are open, and `activePlugins` is a memo that flattens it, so the two cannot disagree. Rendering is unchanged: the flattened order feeds the same flex row, so this commit moves the state without moving any pixels. Hydration migrates and repairs. State written before the tree reopens as a single group in the stored order, an existing tree wins over the superseded key, and everything goes through `repairLayout`, which prunes unknown plugin ids exactly as the old `activePlugins` filter did. The result is written back once so the migration does not repeat. Storage *access* errors still propagate. Two things the existing tests caught, both worth recording. `flattenTabs` builds a fresh array each call, so a bare memo made every unrelated store write look like a change and re-ran each plugin's `render` — the activation order test failed with a duplicated entry. The memo now compares contents. `plugin.destroy` cannot yet hang off the pane's own `onCleanup`, which is where the design puts it. The panes live inside the destination-switched subtree, so navigating to Marketplace unmounts them and would destroy every open plugin — "moves among Marketplace and core destinations without plugin destruction" failed immediately. Teardown stays on the close path until the panes live in a container that outlives the navigation, which is the next commit. `MAX_ACTIVE_PLUGINS` stays at 3 for now. Raising it to 9 only makes sense once the workspace can split and scroll, otherwise nine panes share one flex row.
The plugin panes move out of the destination-switched subtree into a workspace
that is mounted once and hidden rather than unmounted. Each pane is a direct
child of that workspace for its whole life and is placed with offsets computed
from the tree, so no drag, split or resize ever re-parents it. That is what stops
an iframe plugin reloading and a canvas plugin losing its context — the React
basic example registers a plugin whose whole body is an iframe.
Because the workspace outlives navigation, `plugin.destroy` finally moves to the
pane's own `onCleanup`: exactly once, however the pane was closed, and before the
node is detached so the plugin can still tidy up. Removing the call from the
close path at the same time was necessary, not tidying — with both in place every
close destroyed twice, which the lifecycle test caught.
`MAX_ACTIVE_PLUGINS` goes from 3 to 9. Panes can now split and stack, so the cap
limits how many are open rather than how many fit across.
Splitters, tab bars with per-tab close controls, drop-zone highlighting and full
keyboard operation all arrive with it. Each gutter is a real focusable
`role="separator"` driven by the same arrow/Home/End pattern as the whole-panel
resizer, and a tab can be picked up with Enter, moved with the arrows and dropped
with Enter, so nothing needs a pointer. A drop that has no room to split becomes
a stacked tab instead of being refused.
Three things worth recording.
`appendPane` exists because `splitAt` was wrong for opening from the strip: it
halves the last pane, so three plugins came out 1/2, 1/4, 1/4. Panes opened side
by side should match, and a test now pins the thirds.
The move hint's id was `${PLUGIN_CONTAINER_ID}-move-hint`, which matches the
`[id^="plugin-container-"]` selector the tests use and counted as a phantom pane.
PLUGIN_CONTAINER_ID is a public export and the shared prefix of every pane id;
nothing else may borrow it.
The tab bar is not a `role="tablist"`. Its arrow keys move a pane rather than
walking the tabs, so claiming the role would promise a keyboard contract this
does not implement. Selection is `aria-pressed`, the close control is a sibling
button rather than nested inside the tab, targets are 24px, and the state of a
move is narrated through a live region because `aria-grabbed` is deprecated.
…e cap to 18 Builds on the workspace with the interactions that make it usable, and fixes what turned up once it was driven by hand rather than by tests. Dragging. A press only becomes a drag after being **held** for 500ms. A movement threshold was tried first and was wrong: any distance small enough to feel responsive is also small enough that ordinary click jitter crosses it, so clicking a stacked tab resolved a drop target from the pointer sitting over the tab bar and split the pane straight back out. Holding is unambiguous — a click selects, a press picks up. Dropping on a tab bar now always means "put it in this group" rather than splitting its top edge, so the two gestures never compete for the same few pixels. The tab being carried follows the cursor and every surface shows the grabbing cursor while it does. The preview is portalled to the body because `MainPanel` sets a transform, which makes it a containing block, so a `position: fixed` child resolved against the panel and was clipped by the workspace's overflow. Plugins strip. Entries can be held and dragged into the workspace to place a pane where you want it instead of appending it, including onto an empty workspace, where it takes the whole area. The strip now lists only the plugins that are *not* open, so each plugin has exactly one control: its strip entry while closed, its pane tab once open. It folds itself away when everything is open and returns when a plugin closes. `MAX_ACTIVE_PLUGINS` goes 9 -> 18. The tests were already pinned to the constant rather than a literal, so this was a one-line change. Three fixes worth naming. The workspace measured itself once at mount. A hidden element measures zero, every rect derived from a zero box is zero, and hit-testing then silently found nothing — so a drag did nothing at all rather than looking broken. It re-measures when the panel opens or the destination returns, ignores zero measurements, and measures again at the start of every drag. The strip's click-suppression flag was sticky. A drag that ends away from the entry produces no `click` at all, so the flag survived and swallowed the *next* genuine click, which is why opening a plugin started taking several attempts. It resets on each press. The strip-to-workspace handoff moved from module-level state onto the context. This package ships several bundles, so two components can hold different copies of the same module and never see each other's writes. Two e2e cases are `test.fixme` rather than deleted: both drags that start from a strip entry work with real pointer input, verified by hand in two apps, but do not trigger under Playwright's synthetic mouse. Each carries a comment saying what is covered elsewhere and what is left unproven.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
|
View your CI Pipeline Execution ↗ for commit acba660
☁️ Nx Cloud last updated this comment at |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx affected --targets=test:eslint,test:sherif,t... |
❌ Failed | 3m 18s | View ↗ |
nx run-many --target=test:e2e --parallel=1 --pr... |
❌ Failed | 1m 2s | View ↗ |
nx run-many --targets=build --exclude=examples/... |
✅ Succeeded | 44s | View ↗ |
☁️ Nx Cloud last updated this comment at 2026-08-07 18:38:22 UTC
More templates
@tanstack/angular-devtools
@tanstack/devtools
@tanstack/devtools-a11y
@tanstack/devtools-bundler-core
@tanstack/devtools-client
@tanstack/devtools-rspack
@tanstack/devtools-ui
@tanstack/devtools-utils
@tanstack/devtools-vite
@tanstack/devtools-event-bus
@tanstack/devtools-event-client
@tanstack/preact-devtools
@tanstack/react-devtools
@tanstack/solid-devtools
@tanstack/svelte-devtools
@tanstack/vue-devtools
commit: |
…tters Three separate causes behind the red `Test` and `E2e` jobs. **knip.** `layout-tree.ts` exported five things nothing outside it uses — `isGroup`, `isSplit`, `findGroupById`, `nodeAtPath` and the `Size` type. They are module-private now. The two findings that remain locally (`check-font-assets.mjs`, a `svelte` config hint) reproduce on the untouched base branch and pass on CI. **Vue rendered every plugin twice.** The adapter's `render` and title callbacks appended to `pluginsToRender` / `titlesToRender` without dropping the previous entry for that mount element. `render` is called again whenever the theme or the panel's open state changes, so this was always wrong — it only became visible now that the core keeps one mount node per plugin for its lifetime instead of building a fresh one. Previously each call landed in a new node and the duplicate was never in the document. Both callbacks now replace by id. React was already correct: it keys by element id. **Gutters were rebuilt on every re-measure.** `splitterHandles` returns fresh objects, so a keyed `For` destroyed and recreated every splitter whenever the geometry changed. That threw keyboard focus away mid-resize and left stale element references behind — the cause of both the flaky keyboard-resize test and `boundingBox()` returning null in the drag test. Switched to `Index`, which keeps the elements and updates their values, and the handle is read through its accessor at gesture time so a re-measured gutter still moves the right sizes. The e2e specs also wait for the geometry to settle rather than the tab bars alone, and drive the keyboard through `locator.press` so focus and keypress are one step. react-vite: 30 passed, 2 skipped, no flakes over repeated runs. vue: passing. 323 unit tests green.
…n spec Closing a tab worked only sometimes. The close button sits inside the sortable row's element, so the drag layer saw its pointerdown, decided a sort might be starting, and swallowed the `click` that would have followed — which press landed and which did not came down to a pixel of pointer movement. The close control now stops the pointer events at itself and closes on pointerup, with the `click` handler kept for keyboard activation, which fires no pointer events at all. Closing twice is harmless: the second call finds no such tab and returns the tree unchanged. Verified by closing three panes in a row, each press landing. Also removes `docs/superpowers/specs/2026-08-07-plugin-layout-tree-design.md`. It is a planning artifact, not source, and does not belong in the history. The two earlier commits mention it by path; the reasoning that matters is in the code comments, `docs/plugin-workspace.md` and the changeset.
…styling the host page **Closing a tab.** `SortableRow` is only a data attribute — the drag engine listens globally and walks *up* from whatever the pointer hit looking for that key. Anything inside the row is therefore a drag surface no matter what its own handlers do, so the `stopPropagation` in the previous commit could not have worked. The close button is now a sibling of the sortable row, positioned over the tab's right end, so a press on it never reaches a sortable key. Verified in the browser: pressing the X, moving 6px and holding for 700ms produces no drag preview and still closes the pane, and three consecutive closes all land. **The host page's cursor is not ours to change.** The grabbing cursor was applied to `document.documentElement` with a descendant selector, which forced `cursor: grabbing !important` onto every element of the page under inspection for the length of a drag. It is scoped to the devtools panel now. Measured during a drag: the host `<html>`, `<body>` and `<h1>` all stay at `auto` while the panel shows `grabbing`, and the e2e test asserts both halves of that. Everything else the devtools inject was already contained: an audit of every rule in every injected stylesheet found zero selectors that could match an element the host page owns — each one is scoped to a goober hash, `.tsd-*`, `[data-tsd*]`, `[data-plugin*]` or `#tanstack_devtools`. SSR verified across all three server runtimes: react-start, react-nitro and react-cloudflare e2e all pass, including the server-to-client event bridge. Nothing added here touches a browser global at module scope, and `layout-tree.ts` has no DOM access at all.
The new release does not change `splitpane` — that file is byte-identical to `next.10`, which this branch already used. What is new is its documentation page. It does change `sortable`, `drop` and `resize`, and this uses `createSortable`, so the bump is worth taking for whatever landed there. Size goes 59.56 kB -> 61.23 kB, still inside the 65 kB limit. 323 unit tests and 30 react-vite e2e pass on it with no flakes. Still pinned exactly rather than floated on `@next`: v3 is unreleased and its published exports have already drifted from its docs more than once.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
a75aa79
into
codex/tanstack-devtools-workbench
* fix: update devtools trigger logo * docs: add TanStack Devtools branding design * feat: redesign TanStack Devtools workbench * fix: refine TanStack Devtools workbench * ci: apply automated fixes * fix(devtools): polish the Workbench UI and fix its interaction regressions Separate chrome from canvas: the header and the secondary strips paint the brand surface and close with a translucent ink hairline, while destination content and plugin panes paint the workspace surface. `border.decorative` is the cream brand colour, so any rule drawn on the chrome band was invisible. Align everything to one gutter. `WORKBENCH_GUTTER` (16px, 12px below 430px) is now the single inline gutter for the header, the strips and each destination's content, which previously started at 0, 8, 12 and 32px depending on the tab. Trade competing accents for the semantic theme: - The Marketplace partnership banner was a saturated block; it is brand paper with a charcoal rule. Tag filters lost their outlined container, section headings stopped being cards, and emoji labels are plain text. - SEO social cards were each outlined in their network's brand colour. The colour survives as a small dot; the card border is neutral. The tab also ran on the legacy grey ramp and is on the semantic theme now. - Featured and active plugin cards keep the neutral outline and let their badge carry the state; "Active" was wearing the info colour. The palm emblem is inline SVG instead of a raster filtered with `brightness(2.5)` to fake dark mode, so it stays sharp and takes its colour from the theme. Plugin destinations get a real empty state. Fold the subheader, not the panel. A pull tab on the strip's bottom edge animates the strip's height to zero and drops to the header's edge; the panel height, the header and the destination content are untouched, and the tab is only rendered where a strip exists. Folded, the strip is inert. Interaction fixes: - The resize handle had grown to 24px at `top: -10px`, covering the top 14px of the 36px header, so a press aimed at a header button started a resize. It is a thin bar on the panel edge again. - The Marketplace settings drawer was `position: fixed` and covered the host page instead of the Workbench. The marketplace is a shell with an inner scroll region and the drawer is absolute inside it. - Scroll gestures chained on to the host page. The outermost scroller in each destination contains them; deliberately not their descendants, since a plugin nests empty `overflow: auto` wrappers that must chain up to the pane. - Plugin mounts are their own positioning context. Plugins position chrome absolutely from a statically positioned root, so it resolved against the whole Workbench and painted over our header. - The floating trigger and drawer toggle replaced their brand fill with a translucent state colour on hover, making them vanish over the page. - The "New" ribbon overlapped the card icon; it is an inline pill. - HotkeyConfig never rendered its `title`, so each shortcut was headed by its own description, and its modifier chips were styled as success. Interactive controls animate over 0.3s, with one reduced-motion guard scoped to the core-owned `data-tsd-control` / `data-tsd-surface` markers. Also removes 41 dead style blocks, which took nearly all remaining legacy-grey colour usage with them, and a duplicated `plugin-marketplace` test id. * fix(devtools): stop the SEO head watcher looping and unblock CI The `Test` job died with a JavaScript heap OOM and the `E2e` job failed on all eight apps. Two separate causes. The head watcher looped. `createHeadChanges` observes attributes and character data across the whole `<head>` subtree, and goober rewrites a `<style>` tag there on every `css()` call — it even re-stamps the tag's `nonce` attribute each time. So an SEO analysis re-rendered, the re-render emitted CSS, the CSS mutated `<head>`, and the analysis ran again. The loop is synchronous, so no test timeout could break it and the worker ran to the 4 GB heap limit. Stylesheets carry no SEO metadata, so they are filtered out. jsdom multiplied goober's stylesheets. goober finds its single `<style id="_goober">` through `window._goober`, the global a browser creates for any element with an `id`. jsdom does not do that for `<style>`, so goober appended a new sheet on every `css()` call — about 2500 per Workbench mount, never removed. Test 1 took 0.9s and test 20 took 21s. The test setup now gives goober the global a browser would have: 2504 sheets per mount become 8, and the package's 236 tests run in 38s instead of running out of memory. That let `workbench.test.tsx` finish for the first time, which exposed nine assertions still describing the pre-polish design — the header's trailing gutter, the strip's 8px gutter, the 24px resize handle, a fixed 44px grid row, the strip unmounting when folded, and the SEO label foregrounds. Each now matches the shipped Workbench. The redesign also dropped the test hooks `@tanstack/devtools-e2e` locates the panel and header controls with, which is why every e2e app failed on `openViaTrigger()`. The header carries them again and the tab assertions read `data-tsd-selected` instead of the `active` class the old tabs used. * feat(devtools): plugin workspace with splits, stacked tabs, drag and resize (#493) * feat(devtools): add the plugin workspace layout tree The workspace layout becomes a tree of splits and tab groups so plugins can be arranged in rows, columns and stacks instead of one equal-width flex row. This commit is the maths only: no UI is wired up and no dependency is added yet. Everything in `layout-tree.ts` is pure and imports nothing. That is deliberate. jsdom has no layout engine, so `getBoundingClientRect` returns zeros, and rect maths verified through the DOM would only be verifying its own mocks. Keeping it here makes it exhaustively testable — 67 cases in 58ms — and keeps the layout logic out of the components. Every returned tree upholds the same invariants: a group has at least one tab, a split has at least two children, sizes match the child count and sum to 1, the active index names a real tab, and a plugin id appears at most once. `prune` restores them bottom up after any edit, so closing a tab can collapse an emptied group, unwrap a single-child split, and flatten a same-direction nested split without the callers knowing. `repairLayout` cannot throw. A malformed layout is a data problem, the same as the unknown plugin ids that are already pruned on load, and it must not stop the panel from opening; storage *access* errors still propagate. It prunes unknown and duplicated ids, renormalises sizes, clamps the active index, and falls back to salvaging whatever plugin ids it can find from an unrecognisable shape so a bad write costs the arrangement but not the open plugins. The hostile-input test caught a real stack overflow on a self-referencing object, so reads are depth capped and the salvage walk tracks visited objects. Design and the decisions behind it, including the measured bundle cost of each neodrag primitive, are in docs/superpowers/specs/2026-08-07-plugin-layout-tree-design.md. * feat(devtools): make the layout tree the stored truth for open plugins `state.activePlugins` is replaced by `state.layout`. The tree is now the only record of which plugins are open, and `activePlugins` is a memo that flattens it, so the two cannot disagree. Rendering is unchanged: the flattened order feeds the same flex row, so this commit moves the state without moving any pixels. Hydration migrates and repairs. State written before the tree reopens as a single group in the stored order, an existing tree wins over the superseded key, and everything goes through `repairLayout`, which prunes unknown plugin ids exactly as the old `activePlugins` filter did. The result is written back once so the migration does not repeat. Storage *access* errors still propagate. Two things the existing tests caught, both worth recording. `flattenTabs` builds a fresh array each call, so a bare memo made every unrelated store write look like a change and re-ran each plugin's `render` — the activation order test failed with a duplicated entry. The memo now compares contents. `plugin.destroy` cannot yet hang off the pane's own `onCleanup`, which is where the design puts it. The panes live inside the destination-switched subtree, so navigating to Marketplace unmounts them and would destroy every open plugin — "moves among Marketplace and core destinations without plugin destruction" failed immediately. Teardown stays on the close path until the panes live in a container that outlives the navigation, which is the next commit. `MAX_ACTIVE_PLUGINS` stays at 3 for now. Raising it to 9 only makes sense once the workspace can split and scroll, otherwise nine panes share one flex row. * feat(devtools): render the workspace from the layout tree, up to 9 panes The plugin panes move out of the destination-switched subtree into a workspace that is mounted once and hidden rather than unmounted. Each pane is a direct child of that workspace for its whole life and is placed with offsets computed from the tree, so no drag, split or resize ever re-parents it. That is what stops an iframe plugin reloading and a canvas plugin losing its context — the React basic example registers a plugin whose whole body is an iframe. Because the workspace outlives navigation, `plugin.destroy` finally moves to the pane's own `onCleanup`: exactly once, however the pane was closed, and before the node is detached so the plugin can still tidy up. Removing the call from the close path at the same time was necessary, not tidying — with both in place every close destroyed twice, which the lifecycle test caught. `MAX_ACTIVE_PLUGINS` goes from 3 to 9. Panes can now split and stack, so the cap limits how many are open rather than how many fit across. Splitters, tab bars with per-tab close controls, drop-zone highlighting and full keyboard operation all arrive with it. Each gutter is a real focusable `role="separator"` driven by the same arrow/Home/End pattern as the whole-panel resizer, and a tab can be picked up with Enter, moved with the arrows and dropped with Enter, so nothing needs a pointer. A drop that has no room to split becomes a stacked tab instead of being refused. Three things worth recording. `appendPane` exists because `splitAt` was wrong for opening from the strip: it halves the last pane, so three plugins came out 1/2, 1/4, 1/4. Panes opened side by side should match, and a test now pins the thirds. The move hint's id was `${PLUGIN_CONTAINER_ID}-move-hint`, which matches the `[id^="plugin-container-"]` selector the tests use and counted as a phantom pane. PLUGIN_CONTAINER_ID is a public export and the shared prefix of every pane id; nothing else may borrow it. The tab bar is not a `role="tablist"`. Its arrow keys move a pane rather than walking the tabs, so claiming the role would promise a keyboard contract this does not implement. Selection is `aria-pressed`, the close control is a sibling button rather than nested inside the tab, targets are 24px, and the state of a move is narrated through a live region because `aria-grabbed` is deprecated. * feat(devtools): drag panes from the strip, hold to drag, and raise the cap to 18 Builds on the workspace with the interactions that make it usable, and fixes what turned up once it was driven by hand rather than by tests. Dragging. A press only becomes a drag after being **held** for 500ms. A movement threshold was tried first and was wrong: any distance small enough to feel responsive is also small enough that ordinary click jitter crosses it, so clicking a stacked tab resolved a drop target from the pointer sitting over the tab bar and split the pane straight back out. Holding is unambiguous — a click selects, a press picks up. Dropping on a tab bar now always means "put it in this group" rather than splitting its top edge, so the two gestures never compete for the same few pixels. The tab being carried follows the cursor and every surface shows the grabbing cursor while it does. The preview is portalled to the body because `MainPanel` sets a transform, which makes it a containing block, so a `position: fixed` child resolved against the panel and was clipped by the workspace's overflow. Plugins strip. Entries can be held and dragged into the workspace to place a pane where you want it instead of appending it, including onto an empty workspace, where it takes the whole area. The strip now lists only the plugins that are *not* open, so each plugin has exactly one control: its strip entry while closed, its pane tab once open. It folds itself away when everything is open and returns when a plugin closes. `MAX_ACTIVE_PLUGINS` goes 9 -> 18. The tests were already pinned to the constant rather than a literal, so this was a one-line change. Three fixes worth naming. The workspace measured itself once at mount. A hidden element measures zero, every rect derived from a zero box is zero, and hit-testing then silently found nothing — so a drag did nothing at all rather than looking broken. It re-measures when the panel opens or the destination returns, ignores zero measurements, and measures again at the start of every drag. The strip's click-suppression flag was sticky. A drag that ends away from the entry produces no `click` at all, so the flag survived and swallowed the *next* genuine click, which is why opening a plugin started taking several attempts. It resets on each press. The strip-to-workspace handoff moved from module-level state onto the context. This package ships several bundles, so two components can hold different copies of the same module and never see each other's writes. Two e2e cases are `test.fixme` rather than deleted: both drags that start from a strip entry work with real pointer input, verified by hand in two apps, but do not trigger under Playwright's synthetic mouse. Each carries a comment saying what is covered elsewhere and what is left unproven. * fix(devtools): unbreak CI — knip, duplicated Vue plugins, unstable gutters Three separate causes behind the red `Test` and `E2e` jobs. **knip.** `layout-tree.ts` exported five things nothing outside it uses — `isGroup`, `isSplit`, `findGroupById`, `nodeAtPath` and the `Size` type. They are module-private now. The two findings that remain locally (`check-font-assets.mjs`, a `svelte` config hint) reproduce on the untouched base branch and pass on CI. **Vue rendered every plugin twice.** The adapter's `render` and title callbacks appended to `pluginsToRender` / `titlesToRender` without dropping the previous entry for that mount element. `render` is called again whenever the theme or the panel's open state changes, so this was always wrong — it only became visible now that the core keeps one mount node per plugin for its lifetime instead of building a fresh one. Previously each call landed in a new node and the duplicate was never in the document. Both callbacks now replace by id. React was already correct: it keys by element id. **Gutters were rebuilt on every re-measure.** `splitterHandles` returns fresh objects, so a keyed `For` destroyed and recreated every splitter whenever the geometry changed. That threw keyboard focus away mid-resize and left stale element references behind — the cause of both the flaky keyboard-resize test and `boundingBox()` returning null in the drag test. Switched to `Index`, which keeps the elements and updates their values, and the handle is read through its accessor at gesture time so a re-measured gutter still moves the right sizes. The e2e specs also wait for the geometry to settle rather than the tab bars alone, and drive the keyboard through `locator.press` so focus and keypress are one step. react-vite: 30 passed, 2 skipped, no flakes over repeated runs. vue: passing. 323 unit tests green. * fix(devtools): make a tab's close button reliable, and drop the design spec Closing a tab worked only sometimes. The close button sits inside the sortable row's element, so the drag layer saw its pointerdown, decided a sort might be starting, and swallowed the `click` that would have followed — which press landed and which did not came down to a pixel of pointer movement. The close control now stops the pointer events at itself and closes on pointerup, with the `click` handler kept for keyboard activation, which fires no pointer events at all. Closing twice is harmless: the second call finds no such tab and returns the tree unchanged. Verified by closing three panes in a row, each press landing. Also removes `docs/superpowers/specs/2026-08-07-plugin-layout-tree-design.md`. It is a planning artifact, not source, and does not belong in the history. The two earlier commits mention it by path; the reasoning that matters is in the code comments, `docs/plugin-workspace.md` and the changeset. * fix(devtools): take the close button out of the drag surface, stop restyling the host page **Closing a tab.** `SortableRow` is only a data attribute — the drag engine listens globally and walks *up* from whatever the pointer hit looking for that key. Anything inside the row is therefore a drag surface no matter what its own handlers do, so the `stopPropagation` in the previous commit could not have worked. The close button is now a sibling of the sortable row, positioned over the tab's right end, so a press on it never reaches a sortable key. Verified in the browser: pressing the X, moving 6px and holding for 700ms produces no drag preview and still closes the pane, and three consecutive closes all land. **The host page's cursor is not ours to change.** The grabbing cursor was applied to `document.documentElement` with a descendant selector, which forced `cursor: grabbing !important` onto every element of the page under inspection for the length of a drag. It is scoped to the devtools panel now. Measured during a drag: the host `<html>`, `<body>` and `<h1>` all stay at `auto` while the panel shows `grabbing`, and the e2e test asserts both halves of that. Everything else the devtools inject was already contained: an audit of every rule in every injected stylesheet found zero selectors that could match an element the host page owns — each one is scoped to a goober hash, `.tsd-*`, `[data-tsd*]`, `[data-plugin*]` or `#tanstack_devtools`. SSR verified across all three server runtimes: react-start, react-nitro and react-cloudflare e2e all pass, including the server-to-client event bridge. Nothing added here touches a browser global at module scope, and `layout-tree.ts` has no DOM access at all. * chore(devtools): pin @neodrag/solid to 3.0.0-next.11 The new release does not change `splitpane` — that file is byte-identical to `next.10`, which this branch already used. What is new is its documentation page. It does change `sortable`, `drop` and `resize`, and this uses `createSortable`, so the bump is worth taking for whatever landed there. Size goes 59.56 kB -> 61.23 kB, still inside the 65 kB limit. 323 unit tests and 30 react-vite e2e pass on it with no flakes. Still pinned exactly rather than floated on `@next`: v3 is unreleased and its published exports have already drifted from its docs more than once. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

Stacked on #492 — targets
codex/tanstack-devtools-workbench, so review that one first.What this adds
The Plugins destination becomes a workspace instead of a fixed row of equal-width panes. Panes can sit side by side, above and below each other, or stacked as tabs in one group. The arrangement is a tree that persists across reloads along with each pane's size and which tab is selected. The active-plugin cap goes from 3 to 18, because a stacked tab costs no space.
The strip now lists only the plugins that are not open, so each plugin has exactly one control: its strip entry while closed, its pane tab once open. It folds itself away when everything is open and comes back when a plugin closes.
User-facing docs:
docs/plugin-workspace.md. The reasoning behind each decision is in the code comments and the changeset.Two guarantees for plugin authors
A pane's mount node is never removed from the document while the plugin is open. Whatever the user does to the layout, an
<iframe>will not reload and a<canvas>will not lose its context. This needed more than avoiding re-parenting: Solid's<For>reorders by removing and re-inserting nodes, so iterating panes in layout order reloaded an iframe on every rearrangement even though the parent never changed. Panes are iterated sorted by id, so the DOM sequence only changes when a plugin opens or closes. Verified with a load counter on an injected iframe — a resize, a navigation away and back, and a move into another group all leave it at one load.destroyis called exactly once, when the plugin closes, before its node is detached. Moving, resizing, and switching destinations do not call it.Structure
packages/devtools/src/utils/layout-tree.tsholds every tree operation and is pure — no DOM, no Solid, no store. That is deliberate: jsdom has no layout engine, so rect maths verified through the DOM would only be verifying its own mocks. It carries 83 of the tests and runs in well under a second.state.activePluginsinlocalStorageis superseded bystate.layout;activePluginsis now derived from the tree so the two cannot disagree. Existing state migrates on first read. A layout that cannot be read is repaired rather than thrown: unknown ids dropped, empty groups closed up, and an unusable entry falls back to reopening whatever plugins it can still identify.Size
@tanstack/devtoolsgoes 45.41 kB → 59.56 kB brotlied. The limit moves 60 kB → 65 kB. Each neodrag primitive was measured before committing to any of them.createSortablealone is 6.04 kB and is kept for cross-list tab transfer and FLIP;createSplitPaneandcreateResizableare deliberately unused, on architecture rather than size — both write DOM styles that fight the absolute-rect model, andSplitPane'soverflow: hiddenwould break the per-pane scrolling a test asserts.@neodrag/solidis pinned to3.0.0-next.11, not floated on@next: v3 is unreleased and its published exports already differ from its documentation.Verification
@tanstack/devtools; 23/23 packages green; types and lint clean.react-vite, covering pointer drags, gutter drags, keyboard moves and resizes, drop-zone resolution, persistence across reload, and iframe survival.Two e2e cases are
test.fixmerather than deleted. Both drags that start from a strip entry work with real pointer input — verified by hand in two apps — but do not trigger under Playwright's synthetic mouse, and I could not pin down why in reasonable time. Each carries a comment naming what is covered elsewhere and what is left unproven. The strip's click path and every drag starting from a pane tab are covered.Notable fixes found by running it
appendPaneexists because splitting the last pane halves it, so three plugins opened 1/2, 1/4, 1/4 instead of equal thirds.splitterHandlesreturns fresh objects and a keyedForrecreates on new references. That threw keyboard focus away mid-resize — a real defect, not just a flaky test. They useIndexnow.renderand title callbacks appended without replacing the previous entry for a mount element. Always wrong, but only visible once the core stopped rebuilding the mount node each time.