Skip to content

Commit a46d1f5

Browse files
feat: redesign TanStack Devtools workbench (#492)
* 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>
1 parent a69ea17 commit a46d1f5

115 files changed

Lines changed: 11689 additions & 2850 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@tanstack/devtools': minor
3+
---
4+
5+
feat: arrange plugin panes in splits and stacked tabs, drag and resize them, and raise the limit to eighteen
6+
7+
The Plugins destination is now a workspace instead of a fixed row. Panes can sit side by side, above and below each other, or stacked as tabs in one group, and the arrangement is a tree that persists across reloads along with each pane's size and which tab is selected. Up to eighteen plugins can be open, up from three, because a stacked tab costs no space.
8+
9+
Drag a pane's tab onto the edge of another pane to split it, onto its middle to stack, or onto 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 can shrink below a readable minimum. Where the panel is too short to split without leaving an unreadable cell, the same drop becomes a stacked tab rather than being refused. The tab being carried follows the cursor, and a highlight shows where it will land.
10+
11+
The Plugins 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. Entries can be dragged out of the strip to place a pane exactly where you want it instead of appending it, including onto an empty workspace, where it takes the whole area. The strip folds itself away once everything is open and returns when a plugin closes.
12+
13+
Every one of those actions has a keyboard equivalent, because the pointer gestures are suppressed while the panel is detached into a picture-in-picture window. `Enter` picks a pane up, the arrow keys choose where it goes, `Enter` drops it and `Escape` puts it back; gutters take arrow keys, `Shift`-arrow and `Home`/`End`, the same pattern the whole-panel resizer already used. Picking up and dropping is announced to screen readers.
14+
15+
For plugin authors, two guarantees are now explicit. A pane's mount node is never removed from the document while the plugin is open, whatever the user does to the layout, so an `<iframe>` will not reload and a `<canvas>` will not lose its context. And `destroy` is called exactly once, when the plugin closes, before the node is detached — not when a pane is moved, resized, or hidden by navigating to another destination.
16+
17+
`state.activePlugins` in `localStorage` is superseded by `state.layout`. Existing state is migrated on first read, reopening as a single group in the order it recorded, and an arrangement that cannot be read is repaired rather than throwing: unknown plugin ids are dropped, empty groups close up, and a wholly unusable entry falls back to reopening whatever plugins it can still identify.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@tanstack/devtools': minor
3+
'@tanstack/devtools-ui': minor
4+
'@tanstack/devtools-a11y': minor
5+
---
6+
7+
feat: apply TanStack branding and the compact Workbench layout across core, shared UI, and accessibility Devtools
8+
9+
The Workbench now separates chrome from canvas: the header and the secondary strips paint the brand surface and share one 16px gutter with the content below them. The palm emblem is inline SVG instead of a filtered raster, plugin destinations get a real empty state, and the Marketplace, SEO, and Settings destinations drop their competing accent colours in favour of the semantic theme.
10+
11+
The secondary strip gets a pull tab on its bottom edge that folds the strip away behind the header, leaving the panel height and the destination content untouched. It only appears on destinations that have a strip.
12+
13+
The SEO tab's `<head>` watcher no longer reports `<style>` tags. It observes attributes and character data across the whole `<head>` subtree, and a CSS-in-JS library rewrites a `<style>` tag there on every render — so an SEO analysis triggered a re-render, the re-render emitted CSS, and the CSS triggered another analysis. Stylesheets carry no SEO metadata, so they are filtered out.
14+
15+
Fixes along the way: the resize handle had grown to 24px and sat on top of the header, so a press aimed at a header button started a resize instead of clicking; the Marketplace settings drawer was `position: fixed` and covered the host page instead of the Workbench; the floating trigger lost its brand fill on hover and its transition was overridden away; the "New" ribbon on a plugin card overlapped the card icon; scroll gestures inside the panel chained on to the host page; and the hotkey editor showed each shortcut's description as its heading and never rendered its title.

docs/architecture.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ block-beta
1414
end
1515
block:core["Core Layer"]
1616
columns 3
17-
Shell["Devtools Shell"] UI["UI Components"] Client["Event Client"]
17+
Workbench["Devtools Workbench"] UI["UI components + private semantic theme"] Client["Event Client"]
1818
end
1919
block:transport["Transport Layer"]
2020
columns 3
@@ -37,10 +37,10 @@ graph TD
3737
end
3838
3939
subgraph Core["Core Layer"]
40-
shell["@tanstack/devtools<br/><i>Core shell (Solid.js)</i>"]
40+
shell["@tanstack/devtools<br/><i>Core Workbench (Solid.js)</i>"]
4141
client["@tanstack/devtools-client<br/><i>Core devtools events</i>"]
4242
eventClient["@tanstack/devtools-event-client<br/><i>Generic EventClient</i>"]
43-
ui["@tanstack/devtools-ui<br/><i>Shared UI components</i>"]
43+
ui["@tanstack/devtools-ui<br/><i>Shared UI + private semantic-theme owner</i>"]
4444
clientBus["@tanstack/devtools-event-bus/client<br/><i>ClientEventBus</i>"]
4545
end
4646
@@ -147,17 +147,32 @@ The devtools shell is a Solid.js application that renders the entire devtools UI
147147

148148
The shell renders:
149149
- A **trigger button** (the floating devtools toggle, customizable or replaceable)
150-
- A **resizable panel** (docked to the bottom of the viewport, resizable via drag)
151-
- **Tab navigation** for switching between plugins, settings, SEO inspector, and the plugin marketplace
150+
- A **resizable Workbench panel** (docked to the top or bottom of the viewport, resizable via pointer or keyboard)
151+
- A compact **36px TanStack Devtools header** with Plugins, Marketplace, SEO, and Settings destinations. The palm emblem is inline SVG, so it stays sharp and takes its colour from the theme.
152+
- A fixed-height **44px secondary strip** for plugin and SEO navigation. It scrolls horizontally when space is limited, and a pull tab on its bottom edge folds it away behind the header. Folding changes nothing else — the panel keeps its height and the destination content keeps running — so the tab is rendered only on the destinations that have a strip.
153+
- A separate **Marketplace** header tab that does not disturb mounted plugin panes
152154
- A **settings panel** for theme, hotkeys, position, and other preferences
153-
- **Plugin containers** -- DOM elements where each plugin's UI is mounted
155+
- Up to three simultaneous **plugin mount frames**, divided into equal widths by static separators
154156

155157
Settings and UI state (panel size, position, active tab, theme) are persisted in `localStorage` so they survive page reloads.
156158

159+
The core shell owns the Workbench header, navigation, mount-frame geometry, separators, and surrounding light/dark surfaces. Each external plugin owns everything inside its mount target; core styling deliberately does not reach into plugin descendants. Detaching the Workbench uses a fixed `100vh` Picture-in-Picture layout and restores the stored docked height when reattached.
160+
161+
#### Workbench surfaces and gutters
162+
163+
Two rules keep the shell reading as one surface:
164+
165+
- **Chrome versus canvas.** The header and every secondary strip paint the brand surface (cream in light mode, near-black in dark mode) and close with a translucent ink hairline. Destination content and plugin mount frames paint the workspace surface. A plugin pane can paint any colour it likes, so the separator between panes uses a mid-tone border that stays visible against both.
166+
- **One gutter.** `WORKBENCH_GUTTER` (16px, or `WORKBENCH_GUTTER_NARROW` at 12px below 430px) is the single inline gutter. The header, the strips, and each destination's content all start there, so the left edge is one column instead of three. `MainPanel withPadding` uses the same value.
167+
168+
Colour comes from the semantic theme only. Raw hex values in core-owned source are rejected by `tests/semantic-color-usage.test.ts` unless they carry a narrow, path-scoped `semantic-color-exempt` marker — currently only third-party network marks and the source-inspector highlight alpha. Status colours (success, warning, error, info) mark real state; identity accents do not compete with them, so a card that is both featured and active keeps the neutral outline and lets its badge say which it is.
169+
157170
### @tanstack/devtools-ui -- Component Library
158171

159172
A shared Solid.js component library used by the core shell and available for use in Solid.js plugins. Provides buttons, inputs, checkboxes, a JSON tree viewer, section layouts, and other UI primitives. The `@tanstack/devtools-utils` package also depends on it to provide framework-specific plugin helpers.
160173

174+
Core packages share a private semantic resolver through `@tanstack/devtools-ui/internal`. It supplies TanStack light/dark colors, typography, spacing, status roles, and focus treatment to core-owned UI and the accessibility plugin. This internal subpath is an implementation boundary, not a public theming API for application plugins.
175+
161176
### @tanstack/devtools-client -- Core Event Client
162177

163178
A specialized `EventClient` pre-configured with `pluginId: 'tanstack-devtools-core'` and a fixed event map for devtools-internal operations:

docs/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
{ "label": "Installation", "to": "installation" },
1515
{ "label": "Configuration", "to": "configuration" },
1616
{ "label": "Plugin Configuration", "to": "plugin-configuration" },
17+
{ "label": "Plugin Workspace", "to": "plugin-workspace" },
1718
{ "label": "Vite Plugin", "to": "vite-plugin" },
1819
{ "label": "Production", "to": "production" }
1920
],

docs/overview.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,15 @@ Your application loads a **Framework Adapter**, which mounts the **Core Shell**.
8383

8484
## Key Features
8585

86+
- **Compact TanStack Workbench**: The trigger reveals a branded light/dark panel with a 36px banner and Plugins, Marketplace, SEO, and Settings navigation. Navigation and the strips sit on the cream (or near-black) chrome band, content sits on the workspace surface, and every surface shares one 16px gutter. The whole panel keeps its existing hide-and-reveal lifecycle.
8687
- **Framework Agnostic**: Works with React, Vue, Solid, and Preact out of the box.
87-
- **Plugin System & Marketplace**: Build, share, and install devtools plugins with a simple API.
88+
- **Plugin System & Marketplace**: Build, share, discover, and install devtools plugins with a simple API. The fixed 44px secondary strip scrolls horizontally when space is limited. Marketplace is a separate header tab.
89+
- **Simultaneous Plugin Panes**: Open up to three plugins at once in equal-width panes while each plugin retains ownership of its own UI.
90+
- **Accessibility Auditing**: Add the accessibility plugin during development to surface issues alongside the other plugin panes; the basic React example includes an intentional audit fixture.
8891
- **Type-Safe Event System**: Communicate between plugins and the shell using fully typed events.
8992
- **Source Inspector**: Click any element in your app to jump straight to its source code (go-to-source).
9093
- **Console Piping**: Route devtools output to your browser console for a familiar debugging workflow.
91-
- **Picture-in-Picture Mode**: Pop the devtools panel out into its own window so it never covers your app.
94+
- **Picture-in-Picture Mode**: Pop the Workbench into a fixed-`100vh` window; reattaching restores its stored docked height.
9295
- **Customizable Hotkeys**: Rebind keyboard shortcuts to match your workflow.
9396

9497
## Next Steps

docs/plugin-workspace.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
title: Plugin workspace
3+
id: plugin-workspace
4+
---
5+
6+
The Plugins destination arranges open plugins in a workspace you can rearrange:
7+
panes side by side, stacked on top of each other, or grouped as tabs. Up to
8+
eighteen plugins can be open at once.
9+
10+
## Opening a plugin
11+
12+
The Plugins strip lists the plugins that are **not** open. Once a plugin has a
13+
pane, its entry leaves the strip — the pane's own tab is where you select and
14+
close it, so there is only ever one control for it.
15+
16+
- **Click** an entry to open it beside the others, sharing the space equally.
17+
- **Drag** an entry down into the workspace to choose where it lands, using the
18+
same zones as below. Dropping onto an empty workspace gives it the whole area.
19+
20+
The strip folds itself away when everything is open, and comes back on its own the
21+
moment a plugin closes and returns to it.
22+
23+
## Arranging panes
24+
25+
Each pane has a tab above it. **Click** a tab to bring that pane to the front of
26+
its group; **drag** it to rearrange:
27+
28+
| Drop it on | Result |
29+
| --- | --- |
30+
| the left or right quarter of a pane | the pane splits into a column, and the tab takes the new side |
31+
| the top or bottom quarter of a pane | the pane splits into a row |
32+
| the middle of a pane | the tab joins that pane as a stacked tab |
33+
| another pane's tab bar | the tab moves into that group, at the position you drop it |
34+
35+
A highlight shows where the pane will land before you let go, and the tab you are
36+
carrying follows the cursor so it is clear which pane is moving.
37+
38+
Dropping on a tab bar always means "put it in this group" rather than splitting the
39+
top edge, so the two gestures never compete for the same few pixels.
40+
41+
### When a pane is too small to split
42+
43+
The devtools panel is short, so splitting a small pane would leave both halves
44+
unreadable. When there is not enough room, the same drop becomes a **stacked tab**
45+
instead of being refused. Stacking costs no space, so every plugin stays usable in
46+
a short panel. The highlight tells you which is about to happen.
47+
48+
At the default panel height a vertical split has no room at all, so drops on a top
49+
or bottom edge stack instead. Drag the panel taller first if you want rows.
50+
51+
## Resizing
52+
53+
Drag the gutter between two panes. One pane grows by exactly what the other loses,
54+
and neither can shrink past a readable minimum.
55+
56+
## Keyboard
57+
58+
Everything above works without a pointer. This matters in a detached
59+
picture-in-picture window, where the pointer gestures are deliberately turned off
60+
(the same way the panel's own resize handle is) but the keyboard still works.
61+
62+
**Moving a pane** — focus a tab, then:
63+
64+
| Key | Action |
65+
| --- | --- |
66+
| `Enter` or `Space` | pick the pane up, or drop it if already held |
67+
| `Arrow` keys | choose where it goes — a neighbouring pane, or a side of its own pane to split |
68+
| `Escape` | put it back |
69+
70+
**Resizing** — focus a gutter, then:
71+
72+
| Key | Action |
73+
| --- | --- |
74+
| `Arrow` keys | move the gutter |
75+
| `Shift` + `Arrow` | move it in larger steps |
76+
| `Home` / `End` | push it as far as it will go |
77+
78+
Picking a pane up and putting it down is announced to screen readers.
79+
80+
## What persists
81+
82+
The arrangement is saved to `localStorage` and restored on reload, including which
83+
tab is selected in each group and the size of every pane.
84+
85+
If a plugin is no longer registered, it is dropped from the saved arrangement and
86+
the panes around it close up. If the saved arrangement cannot be read at all, the
87+
devtools recover by reopening whichever plugins it can identify as a single group,
88+
rather than refusing to open.
89+
90+
State saved by an older version, which recorded only *which* plugins were open,
91+
reopens as one group in that order. This happens once and is then saved in the new
92+
form.
93+
94+
## Notes for plugin authors
95+
96+
Panes are never re-parented. A plugin's mount node stays a child of the same
97+
element for as long as the plugin is open, whatever you do to the layout, so an
98+
`<iframe>` will not reload and a `<canvas>` will not lose its context when the user
99+
drags panes around.
100+
101+
`destroy` is called when the plugin closes, once, and before its mount node is
102+
removed — so the node is still there if you need to clean up inside it. Moving,
103+
splitting, resizing and switching destinations do not call it. Navigating to
104+
Marketplace, SEO or Settings leaves your pane mounted and hidden.
105+
106+
`render` is called again when the theme or the panel's open state changes, as
107+
documented in [plugin lifecycle](./plugin-lifecycle.md).

0 commit comments

Comments
 (0)