Skip to content

feat(devtools): plugin workspace with splits, stacked tabs, drag and resize - #493

Merged
AlemTuzlak merged 8 commits into
codex/tanstack-devtools-workbenchfrom
feat/plugin-layout-tree
Aug 10, 2026
Merged

feat(devtools): plugin workspace with splits, stacked tabs, drag and resize#493
AlemTuzlak merged 8 commits into
codex/tanstack-devtools-workbenchfrom
feat/plugin-layout-tree

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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.

  • Drag a pane's tab onto the edge of another pane to split it, its middle to stack, or another tab bar to move it there.
  • Drag the gutter between two panes to resize: one grows by exactly what the other loses, and neither shrinks below a readable minimum.
  • Drag an entry out of the Plugins strip to place a pane where you drop it instead of appending it, including onto an empty workspace, where it fills the area.
  • Hold to drag. A press becomes a drag after 500ms; a click stays a click.
  • Keyboard parity for everything above, because the pointer gestures are suppressed while detached into picture-in-picture.

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.

destroy is 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.ts holds 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.activePlugins in localStorage is superseded by state.layout; activePlugins is 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/devtools goes 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. createSortable alone is 6.04 kB and is kept for cross-list tab transfer and FLIP; createSplitPane and createResizable are deliberately unused, on architecture rather than size — both write DOM styles that fight the absolute-rect model, and SplitPane's overflow: hidden would break the per-pane scrolling a test asserts.

@neodrag/solid is pinned to 3.0.0-next.11, not floated on @next: v3 is unreleased and its published exports already differ from its documentation.

Verification

  • 323 unit tests in @tanstack/devtools; 23/23 packages green; types and lint clean.
  • 30 e2e passing in react-vite, covering pointer drags, gutter drags, keyboard moves and resizes, drop-zone resolution, persistence across reload, and iframe survival.
  • Driven by hand in the React example: splits, stacks, reordering, resizing, keyboard moves, the empty state, and persistence.

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, 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

  • The workspace measured itself once at mount. A hidden element measures zero, so every derived rect was zero and hit-testing silently found nothing — a drag did nothing rather than looking broken.
  • The strip's click-suppression flag was sticky, so a drag ending away from an entry swallowed the next real click. That was the "takes several tries to open a plugin" symptom.
  • The strip-to-workspace handoff lived in module-level state. This package ships several bundles, so two components could hold different copies of the module and never see each other's writes; it is on the context now.
  • appendPane exists because splitting the last pane halves it, so three plugins opened 1/2, 1/4, 1/4 instead of equal thirds.
  • Gutters were rebuilt on every re-measure, because splitterHandles returns fresh objects and a keyed For recreates on new references. That threw keyboard focus away mid-resize — a real defect, not just a flaky test. They use Index now.
  • The Vue adapter rendered every plugin twice: its render and 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.
  • A tab's close button sat inside the sortable row, so the drag layer swallowed its click and closing worked only sometimes.

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.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ab3b2424-f09e-44f3-961c-2302dc24759f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Aug 7, 2026

Copy link
Copy Markdown

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 full report

@nx-cloud

nx-cloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit acba660

Command Status Duration Result
nx affected --targets=test:eslint,test:sherif,t... ✅ Succeeded 46s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-10 11:51:21 UTC

@nx-cloud

nx-cloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 174e2fd

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-devtools

npm i https://pkg.pr.new/@tanstack/angular-devtools@493

@tanstack/devtools

npm i https://pkg.pr.new/@tanstack/devtools@493

@tanstack/devtools-a11y

npm i https://pkg.pr.new/@tanstack/devtools-a11y@493

@tanstack/devtools-bundler-core

npm i https://pkg.pr.new/@tanstack/devtools-bundler-core@493

@tanstack/devtools-client

npm i https://pkg.pr.new/@tanstack/devtools-client@493

@tanstack/devtools-rspack

npm i https://pkg.pr.new/@tanstack/devtools-rspack@493

@tanstack/devtools-ui

npm i https://pkg.pr.new/@tanstack/devtools-ui@493

@tanstack/devtools-utils

npm i https://pkg.pr.new/@tanstack/devtools-utils@493

@tanstack/devtools-vite

npm i https://pkg.pr.new/@tanstack/devtools-vite@493

@tanstack/devtools-event-bus

npm i https://pkg.pr.new/@tanstack/devtools-event-bus@493

@tanstack/devtools-event-client

npm i https://pkg.pr.new/@tanstack/devtools-event-client@493

@tanstack/preact-devtools

npm i https://pkg.pr.new/@tanstack/preact-devtools@493

@tanstack/react-devtools

npm i https://pkg.pr.new/@tanstack/react-devtools@493

@tanstack/solid-devtools

npm i https://pkg.pr.new/@tanstack/solid-devtools@493

@tanstack/svelte-devtools

npm i https://pkg.pr.new/@tanstack/svelte-devtools@493

@tanstack/vue-devtools

npm i https://pkg.pr.new/@tanstack/vue-devtools@493

commit: acba660

…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.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​neodrag/​solid@​3.0.0-next.117910010089100

View full report

@AlemTuzlak
AlemTuzlak merged commit a75aa79 into codex/tanstack-devtools-workbench Aug 10, 2026
14 of 15 checks passed
@AlemTuzlak
AlemTuzlak deleted the feat/plugin-layout-tree branch August 10, 2026 11:57
AlemTuzlak added a commit that referenced this pull request Aug 10, 2026
* 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>
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