Skip to content

feat: redesign TanStack Devtools workbench - #492

Merged
AlemTuzlak merged 9 commits into
mainfrom
codex/tanstack-devtools-workbench
Aug 10, 2026
Merged

feat: redesign TanStack Devtools workbench#492
AlemTuzlak merged 9 commits into
mainfrom
codex/tanstack-devtools-workbench

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Redesign the TanStack Devtools workbench with the current TanStack brand, color system, spacing, and type.
  • Add clear Plugins, Marketplace, SEO, and Settings destinations while keeping multi-plugin behavior and plugin lifecycle rules.
  • Improve the drawer controls, resize behavior, scrolling, shared secondary tabs, theme handling, and storage error handling.
  • Update the architecture and overview docs for the new workbench structure.

Test plan

  • Checked all touched files with Prettier and git diff --check.
  • Ran affected ESLint and type checks for 16 projects.
  • Passed 188 core non-workbench tests, 5 SEO and shared-tab tests, and all 42 exact workbench cases.
  • Passed the UI, accessibility, utils, React, and React basic example checks.
  • Built core, UI, accessibility, utils, React, and the React basic example.
  • Passed strict publint for all changed publishable packages.
  • Passed docs links, size limits, frozen install, sherif, and knip.
  • Checked the live React example in the browser in light and dark modes. Checked navigation, scrolling, drawer collapse and restore, resize behavior, and multiple plugin panes.

Summary by CodeRabbit

  • New Features
    • Introduced a compact, branded TanStack Workbench with light and dark themes.
    • Added resizable, split, stacked, draggable plugin panes with persistent layouts and support for up to 18 active plugins.
    • Added header navigation, expandable plugin navigation, Marketplace access, keyboard controls, and improved Picture-in-Picture behavior.
    • Improved accessibility across controls, navigation, overlays, and issue reporting.
  • Documentation
    • Updated Workbench, plugin workspace, architecture, branding, and React example documentation.
  • Tests
    • Expanded coverage for themes, accessibility, fonts, navigation, resizing, Marketplace, and Workbench behavior.

@AlemTuzlak
AlemTuzlak requested a review from a team as a code owner August 4, 2026 13:42
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a branded TanStack Devtools Workbench. It introduces semantic themes and fonts, replaces the tab layout with plugin workspaces, adds accessibility behavior, updates PiP handling, and expands tests and documentation.

Changes

TanStack Devtools Workbench redesign

Layer / File(s) Summary
Semantic theme and shared UI foundation
packages/devtools-ui/..., packages/devtools-a11y/...
Adds immutable light and dark semantic themes, internal exports, bundled fonts, semantic styling, accessibility metadata, themed severity states, and theme-aware overlays.
Workbench composition and plugin workspace
packages/devtools/...
Replaces the former tabs and draw context with Workbench navigation, collapsible and resizable panels, Marketplace routing, a persistent plugin workspace, split and stacked panes, drag-and-drop, keyboard movement, persistence, and PiP-aware styling.
Examples and validation
examples/react/basic/..., packages/devtools/tests/..., packages/devtools-ui/tests/..., packages/devtools-a11y/tests/..., e2e/...
Adds the accessibility fixture, font and contrast checks, Workbench integration coverage, semantic-color checks, plugin workspace browser tests, and controlled Select tests.
Documentation and release metadata
docs/..., .changeset/...
Documents Workbench structure, semantic-theme ownership, plugin workspace behavior, PiP behavior, and the minor release scope.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Devtools
  participant WorkbenchHeader
  participant PluginsStrip
  participant PluginWorkspace
  participant Plugin
  Devtools->>WorkbenchHeader: render destinations and actions
  WorkbenchHeader->>Devtools: select Plugins or Marketplace
  Devtools->>PluginsStrip: render closed-plugin entries
  PluginsStrip->>PluginWorkspace: activate or drag plugin
  PluginWorkspace->>Plugin: mount and preserve pane
Loading

Possibly related PRs

  • TanStack/devtools#491: Updates Devtools README branding surfaces related to this Workbench branding change.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary workbench redesign covered by the pull request.
Description check ✅ Passed The description explains the changes and test results in detail, but it omits the template's Checklist and Release Impact sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/tanstack-devtools-workbench

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.

@nx-cloud

nx-cloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit a75aa79

Command Status Duration Result
nx run-many --target=test:e2e --parallel=1 --pr... ✅ Succeeded 1m 13s View ↗
nx affected --targets=test:eslint,test:sherif,t... ✅ Succeeded 10s View ↗
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 3s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-10 12:01:10 UTC

@nx-cloud

nx-cloud Bot commented Aug 4, 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 feee9cd

Command Status Duration Result
nx affected --targets=test:eslint,test:sherif,t... ❌ Failed 36m 35s View ↗
nx run-many --target=test:e2e --parallel=1 --pr... ❌ Failed 13m 17s View ↗
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 39s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-04 14:20:45 UTC

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-devtools

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

@tanstack/devtools

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

@tanstack/devtools-a11y

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

@tanstack/devtools-bundler-core

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

@tanstack/devtools-client

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

@tanstack/devtools-rspack

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

@tanstack/devtools-ui

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

@tanstack/devtools-utils

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

@tanstack/devtools-vite

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

@tanstack/devtools-event-bus

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

@tanstack/devtools-event-client

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

@tanstack/preact-devtools

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

@tanstack/react-devtools

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

@tanstack/solid-devtools

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

@tanstack/svelte-devtools

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

@tanstack/vue-devtools

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

commit: 9e8ed23

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/devtools-ui/src/components/tree.tsx (1)

407-453: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the copy button’s unnecessary createTheme() dependency.

CopyButton calls createTheme() only to pass theme() to CopiedCopier, so JsonTree with copyable now requires ThemeContextProvider even though the value is not used. Remove createTheme() and the unused theme prop unless this provider becomes part of the public JsonTree contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/src/components/tree.tsx` around lines 407 - 453, Remove
the unnecessary `createTheme()` call and `theme` variable declaration at the
start of the button component, since the theme is only passed to `CopiedCopier`
and this dependency is not required for the component's public contract. Update
the `CopiedCopier` component invocation within the Match block to remove the
`theme={theme()}` prop so the component no longer depends on
ThemeContextProvider.
🟡 Minor comments (14)
docs/superpowers/specs/2026-07-31-tanstack-devtools-branding-design.md-250-250 (1)

250-250: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the pipe so the table row keeps its third cell.

The literal pipe inside light | dark starts a fourth cell. The "Rendered result and lifecycle" content is dropped when the table renders. markdownlint reports this as MD056.

📝 Proposed fix
-| Theme change | Existing `theme` value changes | Active plugin `render` and custom name callbacks receive the new `light | dark` value |
+| Theme change | Existing `theme` value changes | Active plugin `render` and custom name callbacks receive the new `light \| dark` value |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-31-tanstack-devtools-branding-design.md` at
line 250, Escape the literal pipe in the “Theme change” table row so “light |
dark” remains within the third cell and the existing “Rendered result and
lifecycle” content is preserved. Update only that row’s Markdown formatting.

Source: Linters/SAST tools

docs/superpowers/specs/2026-07-31-tanstack-devtools-branding-design.md-210-219 (1)

210-219: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the strip geometry sections to the shipped fixed-height strip.

The specification describes a 32px idle strip that expands to 48px on hover, :focus-within, and Marketplace-open, with a 400ms leave grace period. The tests added in this PR assert different behavior:

  • packages/devtools/tests/workbench.test.tsx line 203 asserts a 44px strip height, and lines 205-210 assert that hover and focus events change neither the height nor the timer count.
  • packages/devtools/tests/workbench.test.tsx line 871 asserts grid-template-rows: 36px 44px minmax(0, 1fr).
  • packages/devtools/tests/workbench.test.tsx lines 658-674 assert that the Marketplace control renders in the header destinations and not inside the strip, which contradicts line 246.

Update line 210 (grid row), line 214 (32/48px expansion), line 217 (32→48px growth and grace period), line 242 (forced 48px on Marketplace), line 246 (Marketplace inside the strip), and the acceptance criteria on lines 372 and 376 so the specification matches the implemented fixed-height strip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-31-tanstack-devtools-branding-design.md`
around lines 210 - 219, Update the strip geometry and interaction behavior in
the specification to match the implemented fixed-height strip rather than the
dynamic expansion behavior currently described. Replace the grid-template-rows
value on line 210 from the dynamic minmax expression to reflect the fixed 44px
strip height, update line 214 to remove the description of 32px idle and 48px
expansion states with hover and focus triggers, remove the 32→48px growth and
400ms grace period logic from line 217, correct line 242 to remove the forced
48px expansion on Marketplace-open, update line 246 to remove Marketplace as a
strip interior element, and update the acceptance criteria on lines 372 and 376
to reflect a static strip configuration that does not change height based on
interaction state.
packages/devtools/tests/workbench-secondary-tabs.test.tsx-155-160 (1)

155-160: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prove the bar background is opaque before measuring contrast.

contrastRatio drops the alpha channel on line 33. If getComputedStyle(bar).backgroundColor resolves to rgba(0, 0, 0, 0), the helper treats the background as opaque black and reports a high ratio. The 4.5 threshold then passes without measuring the real pairing. Line 151 already applies this check to the selected tab.

💚 Proposed fix
+      expect(getComputedStyle(bar).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
       expect(
         contrastRatio(
           getComputedStyle(two).color,
           getComputedStyle(bar).backgroundColor,
         ),
       ).toBeGreaterThanOrEqual(4.5)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/tests/workbench-secondary-tabs.test.tsx` around lines 155 -
160, The contrast ratio test for the bar element does not validate that the
background color is fully opaque before measuring contrast. Add an assertion
before the contrastRatio call (similar to the check on line 151 for the selected
tab) to verify that getComputedStyle(bar).backgroundColor has an alpha channel
of 1, ensuring the background is opaque before the contrast ratio calculation.
This prevents the helper from incorrectly treating transparent colors as opaque
when computing the ratio.
packages/devtools/tests/semantic-color-usage.test.ts-89-92 (1)

89-92: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the Marketplace section marker exists before slicing.

If // Plugin Marketplace Styles is renamed or removed, indexOf returns -1. slice(-1) then yields the last character of the file and the not.toMatch(/\bt\(/) assertion passes trivially. The guard stops protecting the Marketplace styles at the moment it is most likely to regress.

💚 Proposed fix
-    const marketplaceSource = cssSource.slice(
-      cssSource.indexOf('// Plugin Marketplace Styles'),
-    )
+    const marketplaceStart = cssSource.indexOf('// Plugin Marketplace Styles')
+    expect(marketplaceStart).toBeGreaterThanOrEqual(0)
+    const marketplaceSource = cssSource.slice(marketplaceStart)
     expect(marketplaceSource).not.toMatch(/\bt\(/)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/tests/semantic-color-usage.test.ts` around lines 89 - 92,
Validate that the `// Plugin Marketplace Styles` marker exists before slicing
`cssSource` in the Marketplace assertion. Store its index, assert it is
non-negative, then slice from that validated index so the `not.toMatch(/\bt\(/)`
check cannot pass trivially when the marker is missing.
packages/devtools/src/styles/use-styles.ts-1785-1791 (1)

1785-1791: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the inverted banner hover on a solid white hover state.

semantic.color.state.hover is #1111110f in light mode over a #ffffff background and #ffffff14 in dark mode over a #1f1f1f background; this is workspace hover style, not a solid elevated hover surface for this inverted button. Use a light elevated hover token here so colors.blue[600] maintains contrast at the banner edge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 1785 - 1791, Update
the inverted banner hover rule near the existing transform and box-shadow to use
the light elevated hover token instead of semantic.color.state.hover, ensuring
the hover background remains solid white and preserves colors.blue[600]
contrast.
packages/devtools/src/tabs/settings-tab.tsx-89-106 (1)

89-106: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match the configured URL parameter exactly.

Line 103 describes urlFlag as a URL parameter name. DevTools checks it with window.location.search.includes(...), so debug also matches ?not-debug=1, and an empty value matches every URL. Parse URLSearchParams with has() and reject an empty parameter name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/tabs/settings-tab.tsx` around lines 89 - 106, Update
the DevTools URL-flag validation and matching flow to reject an empty urlFlag
and use URLSearchParams.has(urlFlag) instead of
window.location.search.includes(...). Preserve the configured parameter-name
behavior so “debug” matches only URLs containing the exact debug parameter, not
similarly named parameters.
packages/devtools/src/devtools.tsx-85-92 (1)

85-92: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Move focus out of the drawer when it closes.

createDisableTabbing() only sets tabIndex="-1" on the closed container. The Escape handler and header close button call toggleOpen(), which changes state without returning focus to the trigger. If focus remains inside the drawer after collapse, move it to the trigger before hiding the drawer and add coverage for the Escape and header close-button paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/devtools.tsx` around lines 85 - 92, In the toggleOpen
function, when the drawer is closing (newState is false), restore focus to the
trigger element before the drawer becomes hidden. This ensures that keyboard
focus returns to the trigger button when the drawer closes via any path,
including the Escape handler and header close button, so focus doesn't remain
trapped inside the drawer while it's hidden.
packages/devtools/src/components/tab-content.tsx-25-31 (1)

25-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the marketplace test selector unique.

When showMarketplace is true, Line 26 and packages/devtools/src/tabs/plugin-marketplace.tsx Line 327 create nested elements with the same data-testid. A getByTestId('plugin-marketplace') query then matches two elements and fails. Remove the wrapper test ID or give it a distinct value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/tab-content.tsx` around lines 25 - 31,
Update the wrapper around PluginMarketplace in the tab-content component to
remove its duplicate data-testid="plugin-marketplace" or replace it with a
distinct selector, preserving the existing plugin-marketplace test ID in the
PluginMarketplace implementation.
packages/devtools-ui/src/components/checkbox.tsx-26-28 (1)

26-28: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Synchronize data-tsd-selected with the effective checkbox state.

The native checked binding uses props.checked ?? isChecked(), but the marker reads only isChecked(). If a controlled parent changes checked without an input event, the marker remains stale.

  • packages/devtools-ui/src/components/checkbox.tsx#L26-L28: derive data-tsd-selected from props.checked ?? isChecked().
  • packages/devtools-ui/tests/index.test.ts#L124-L127: update a controlled checked prop after mount and assert that the marker changes with the native checkbox.
Proposed fix
-          data-tsd-selected={isChecked() ? 'true' : undefined}
+          data-tsd-selected={
+            (props.checked ?? isChecked()) ? 'true' : undefined
+          }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/src/components/checkbox.tsx` around lines 26 - 28, The
data-tsd-selected marker in packages/devtools-ui/src/components/checkbox.tsx
(lines 26-28) is derived only from isChecked(), but the native checked binding
uses props.checked ?? isChecked(). When a controlled parent changes the checked
prop without triggering an input event, the marker becomes stale. Update
data-tsd-selected to use the same fallback logic as the native checkbox binding
(props.checked ?? isChecked()) so the marker stays synchronized with the
effective checkbox state. Additionally, in
packages/devtools-ui/tests/index.test.ts (lines 124-127), add or update a test
case that sets a controlled checked prop after the component mounts and verifies
that the data-tsd-selected marker updates along with the native checkbox
element.
packages/devtools-ui/tests/tree.tsx-70-73 (1)

70-73: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore the original navigator.clipboard descriptor.

This test replaces navigator.clipboard directly, and vi.restoreAllMocks() only restores tracked spies, not manual Object.defineProperty assignments. Capture the original descriptor before overriding and restore it in afterEach or a finally block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/tests/tree.tsx` around lines 70 - 73, Update the test
setup around the manual navigator.clipboard override to capture its original
property descriptor before Object.defineProperty replaces it, then restore that
descriptor in afterEach or a finally block. Keep vi.restoreAllMocks() for
tracked mocks, but ensure each test restores the original navigator.clipboard
state.
packages/devtools-ui/src/styles/semantic-theme.ts-1-2 (1)

1-2: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the Inter font as a variable font or expand the import list for non-inlined assets.

packages/devtools-ui/src/styles/semantic-theme.ts:26-32 uses font-weight: 100 900, but packages/devtools-ui/src/assets/fonts/Inter-latin.woff2 is WOFF2 without an fvar axis, so it is a static instance. That can cause browser synthesis outside the declared weight range or different typography from the variable design source. The Vite version supports ?url&no-inline, so the asset query itself does not need change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/src/styles/semantic-theme.ts` around lines 1 - 2, Update
the Inter font asset used by semantic-theme.ts so it is a true variable font
supporting the declared 100–900 weight range, or expand the imports and
font-face declarations to cover each required static Inter weight. Keep the
existing ?url&no-inline query unchanged and ensure the font-weight declarations
match the imported assets.
packages/devtools-a11y/src/core/styles/styles.ts-257-265 (1)

257-265: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove cursor: pointer from the issue card.

A11yIssueCard now renders an <article> root and moves selection into a dedicated issueSelectButton. The card root is no longer interactive, but it still shows a pointer cursor across its whole area, including the tag row and the aside. That signals a click target that does not exist.

🛠 Proposed fix
     issueCard: css`
       padding: ${space[3]};
       margin-bottom: ${space[2]};
       border: 1px solid ${color.border.decorative};
       border-radius: ${radius.group};
-      cursor: pointer;
       background: ${color.surface.elevated};
       box-shadow: ${shadow.xs};
     `,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/src/core/styles/styles.ts` around lines 257 - 265,
Remove the cursor: pointer declaration from the issueCard style in the styles
configuration, leaving the dedicated issueSelectButton responsible for
interactive cursor behavior.
packages/devtools-a11y/src/core/utils/ui.utils.ts-92-98 (1)

92-98: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Rebuild the highlight stylesheet only when the theme changes.

injectStyles now removes the existing style element and builds a new one on every call. highlightElement calls injectStyles once per call, and IssueList.handleIssueClick calls highlightElement once per node of the selected issue. An issue with many nodes therefore removes and re-creates the same stylesheet many times in one click. During each gap the highlighted elements lose their outline, and the browser recomputes styles for the whole document.

Track the injected theme and skip the rebuild when it is unchanged.

🛠 Proposed fix
+let injectedTheme: TanStackDevtoolsTheme | null = null
+
 function injectStyles(theme: TanStackDevtoolsTheme): void {
-  document.getElementById(HIGHLIGHT_STYLE_ID)?.remove()
+  const existing = document.getElementById(HIGHLIGHT_STYLE_ID)
+  if (existing && injectedTheme === theme) return
+  existing?.remove()
+  injectedTheme = theme
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/src/core/utils/ui.utils.ts` around lines 92 - 98,
Update injectStyles to track the theme used for the currently injected highlight
stylesheet and return early when the incoming theme is unchanged. Only remove
the existing style element and rebuild the stylesheet when the theme differs,
preserving highlightElement behavior for repeated calls across multiple nodes.
packages/devtools-a11y/src/core/components/IssueCard.tsx-35-60 (1)

35-60: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The select button contains flow content, which is invalid inside <button>.

The HTML content model for <button> allows phrasing content only. Lines 57 and 59 place a <p> and a <div> inside the button. Browsers recover from this, but validators report it, and this is an accessibility plugin whose own markup should pass an audit. Nested block elements inside a button also produce inconsistent layout across browsers.

Replace the two elements with <span> and set display: block in issueSelectButton descendants.

🛠 Proposed fix
-          <p class={styles().issueMessage}>{props.issue.message}</p>
+          <span class={styles().issueMessage}>{props.issue.message}</span>

-          <div class={styles().selector}>{selector()}</div>
+          <span class={styles().selector}>{selector()}</span>

In packages/devtools-a11y/src/core/styles/styles.ts, add display: block; to issueMessage and selector so the spans keep the current stacked layout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/src/core/components/IssueCard.tsx` around lines 35 -
60, The button element contains block-level elements (p and div) which violates
HTML button content rules and causes accessibility/validation issues. In
IssueCard.tsx, replace the p element wrapping issueMessage and the div element
wrapping selector with span elements. Then in the styles file
(packages/devtools-a11y/src/core/styles/styles.ts), add display: block styling
to both the issueMessage and selector style definitions to preserve the current
stacked layout while using inline elements.
🧹 Nitpick comments (20)
packages/devtools/tests/index.test.ts (2)

116-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the elementFromPoint stub after the test.

Object.defineProperty is not a Vitest mock, so vi.restoreAllMocks() in afterEach leaves this stub installed. Later tests in this file keep receiving the detached target button from document.elementFromPoint. Use vi.spyOn so the existing afterEach restores it.

♻️ Proposed change
-    Object.defineProperty(document, 'elementFromPoint', {
-      configurable: true,
-      value: vi.fn().mockReturnValue(target),
-    })
+    vi.spyOn(document, 'elementFromPoint').mockReturnValue(target)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/tests/index.test.ts` around lines 116 - 119, Update the
document.elementFromPoint setup in the affected test to use vi.spyOn instead of
Object.defineProperty, preserving the target return value so the existing
vi.restoreAllMocks cleanup restores the original implementation.

113-113: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stub only navigator.clipboard instead of replacing navigator.

Object spread copies own enumerable properties. In jsdom, navigator exposes userAgent, platform, language, and clipboard through prototype accessors, so the spread produces an almost empty object. Code under test that reads any other navigator property then receives undefined. Hotkey and platform checks are common consumers of navigator.platform and navigator.userAgent.

♻️ Proposed change
-    vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
+    vi.spyOn(navigator, 'clipboard', 'get').mockReturnValue({
+      writeText,
+    } as unknown as Clipboard)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/tests/index.test.ts` at line 113, The current stub in the
vi.stubGlobal call uses object spread on navigator which copies only own
enumerable properties, causing prototype accessor properties like userAgent and
platform to be lost. Instead of spreading the entire navigator object, stub only
the clipboard property on the existing navigator global to preserve all other
navigator properties and their accessor behaviors.
packages/devtools/tests/workbench-secondary-tabs.test.tsx (1)

22-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract resolvedCssColor into a shared test helper.

packages/devtools/tests/seo-workbench.test.tsx lines 17-23 define the identical helper. Move it to one module under packages/devtools/tests/ and import it in both files. contrastRatio is a second candidate if further tests need it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/tests/workbench-secondary-tabs.test.tsx` around lines 22 -
28, Extract the resolvedCssColor function from workbench-secondary-tabs.test.tsx
into a new shared test helper module under packages/devtools/tests/. Remove the
local resolvedCssColor definition from both workbench-secondary-tabs.test.tsx
and seo-workbench.test.tsx, then import the shared function in both files. This
eliminates the duplicate helper definitions while preserving the existing
implementation.
packages/devtools/src/styles/use-styles.ts (6)

494-498: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not key layout on a data-testid value.

The grid row template depends on [data-testid='plugins-strip']. A test-hook rename then silently breaks the workbench layout, and the coupling is invisible from plugins-strip.tsx.

Add a dedicated presentation attribute to the strip and select on it. Do not reuse data-workbench-secondary-tabs, because SeoTab also renders WorkbenchSecondaryTabs inside the content row and :has() matches at any depth.

♻️ Proposed change
-      &:has([data-testid='plugins-strip']) {
+      &:has([data-tsd-plugins-strip]) {
         grid-template-rows:
           ${WORKBENCH_HEADER_HEIGHT}px ${PLUGINS_STRIP_HEIGHT}px
           minmax(0, 1fr);
       }

Then set the attribute on the strip container in packages/devtools/src/components/plugins-strip.tsx, for example by adding a pluginsStrip flag to WorkbenchSecondaryTabs that renders data-tsd-plugins-strip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 494 - 498, Replace
the [data-testid='plugins-strip'] layout selector in the styles with a dedicated
presentation attribute. Add a pluginsStrip flag to WorkbenchSecondaryTabs and
set the resulting data-tsd-plugins-strip attribute on the PluginsStrip
container, then select that attribute so nested SeoTab WorkbenchSecondaryTabs
instances cannot match.

1653-1660: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add reduced-motion handling to the remaining animated rules.

pluginMarketplace (Line 1232) and pluginMarketplaceGrid (Line 1441) suppress their animation under prefers-reduced-motion: reduce. pluginMarketplaceEmpty runs fadeIn with no such guard. pluginMarketplaceCardStatus (Lines 1628 and 1633) has the same gap. These elements do not carry the tsd-motion-safe class, so the global rule in ensureWorkbenchGeometryStyles does not reach them.

♻️ Proposed change
     pluginMarketplaceEmpty: css`
       padding: 3rem 2rem;
       text-align: center;
       background: ${semantic.color.surface.elevated};
       border: 2px dashed ${semantic.color.border.control};
       border-radius: 0.75rem;
       animation: ${fadeIn} 0.3s ease;
+      `@media` (prefers-reduced-motion: reduce) {
+        animation: none;
+      }
     `,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 1653 - 1660, Update
the animated style rules pluginMarketplaceEmpty and pluginMarketplaceCardStatus
to disable or suppress their fadeIn animations under prefers-reduced-motion:
reduce, matching the existing handling in pluginMarketplace and
pluginMarketplaceGrid. Keep the normal animation behavior unchanged when reduced
motion is not requested.

28-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two breakpoints hide the wordmark.

This global rule hides .tsd-workbench-wordmark below 360px. The workbenchWordmark class already hides the same element below 430px (Lines 619-621), so this rule never changes the outcome. Remove it, or keep one source of truth for the wordmark breakpoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 28 - 30, Remove the
redundant global `.tsd-workbench-wordmark` rule from the `@media (max-width:
360px)` block, keeping the existing `workbenchWordmark` breakpoint as the single
source of truth for hiding the wordmark.

713-716: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the resize handle focus indicator distinct from hover.

The handle is now focusable and drives keyboard resizing. :focus-visible::after renders the same 4px bar as :hover::after. A keyboard user cannot tell focus from a pointer hover. Use a stronger treatment for focus, for example the semantic.color.border.focus token and a larger bar.

♻️ Proposed change
       &:hover::after {
         background-color: ${semantic.color.border.control};
       }
+      &:focus-visible::after {
+        top: 8px;
+        height: 8px;
+        background-color: ${semantic.color.border.focus};
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 713 - 716, The
resize handle's `:focus-visible::after` pseudo-element currently applies the
same styling as `:hover::after`, using `semantic.color.border.control` for the
background, making keyboard focus indistinguishable from mouse hover. Separate
the `:focus-visible::after` rule from the `:hover::after` rule and update it to
use `semantic.color.border.focus` for the background color and increase the size
or height of the bar to create a visually distinct focus indicator for keyboard
users.

1594-1601: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Literal white foregrounds on semantic solid fills. Both rules paint text with a hard-coded white over a theme-dependent solidFill, so the contrast outcome is not guaranteed in light and dark themes. pluginMarketplaceFeatureBannerText at Line 1766 already shows the intended pattern with semantic.color.status.info.onFill.

  • packages/devtools/src/styles/use-styles.ts#L1594-L1601: replace color: white with semantic.color.status.success.onFill on pluginMarketplaceNewBanner.
  • packages/devtools/src/styles/use-styles.ts#L1750-L1753: replace color: white with semantic.color.status.info.onFill on pluginMarketplaceFeatureBannerTitle.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 1594 - 1601, The
pluginMarketplaceNewBanner rule at
packages/devtools/src/styles/use-styles.ts#L1594-L1601 and the
pluginMarketplaceFeatureBannerTitle rule at
packages/devtools/src/styles/use-styles.ts#L1750-L1753 both use hard-coded white
text color over theme-dependent semantic solid fills, which does not guarantee
sufficient contrast in light and dark themes. Replace the color: white
declaration in pluginMarketplaceNewBanner with
semantic.color.status.success.onFill and replace the color: white declaration in
pluginMarketplaceFeatureBannerTitle with semantic.color.status.info.onFill to
follow the established pattern already demonstrated in the
pluginMarketplaceFeatureBannerText rule.

125-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The ramp casts declare steps that do not exist.

SemanticRamp requires all ten steps. blue, green, and red are built from partial step lists and then cast with as SemanticRamp. For example red has no 300 or 900. A later colors.red[300] compiles, resolves to undefined, and emits color: undefined into the generated CSS. The current call sites happen to use only present steps, so nothing is broken today.

Populate every step in the source arrays so the cast matches reality.

♻️ Example for the `red` ramp
     red: Object.fromEntries(
-      [100, 400, 500, 600, 700].map((step) => [
+      [50, 100, 200, 300, 400, 500, 600, 700, 800, 900].map((step) => [
         step,
-        step === 100
+        step <= 100
           ? semantic.color.status.error.subtleFill
           : semantic.color.status.error.text,
       ]),
     ) as SemanticRamp,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 125 - 154, The blue,
green, red, and purple color ramp definitions are missing required steps to
satisfy the SemanticRamp type contract, causing later access to undefined
properties. Add all ten required steps (100, 200, 300, 400, 500, 600, 700, 800,
900) to each ramp's step array in the Object.fromEntries calls and the purple
object literal. For blue, green, and red, continue applying the existing
conditional logic to all steps so that step 100 uses subtleFill, higher steps
use text or border as defined, and all intermediate steps are represented.
packages/devtools/src/tabs/seo-tab/index.tsx (1)

30-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use one capitalization style for the two tab labels.

Line 30 uses sentence case ("Social previews"). Line 37 uses title case ("SERP Preview"). Pick one, for example "SERP preview".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/tabs/seo-tab/index.tsx` around lines 30 - 37, Use
consistent sentence-case capitalization for the tab labels: update the SERP
Preview label in the WorkbenchSecondaryTab with activeView() === 'serp-preview'
to “SERP preview,” matching “Social previews.”
packages/devtools/src/components/workbench-header.tsx (1)

53-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the duplicate accessible names around the logo.

Three elements now expose the same name. The header has aria-label="TanStack Devtools", the wrapper span has the same aria-label, and the img has alt="TanStack Devtools". aria-label on a plain span has no role to attach to, so support is inconsistent. The img name also repeats the visible wordmark on Line 60.

Mark the logo as decorative and keep the name on the header only.

♻️ Proposed change
-      <span aria-label="TanStack Devtools" class={styles().workbenchLogo}>
-        <img src={TanStackLogo} alt="TanStack Devtools" />
+      <span class={styles().workbenchLogo}>
+        <img src={TanStackLogo} alt="" />
       </span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/workbench-header.tsx` around lines 53 - 61,
Remove the aria-label="TanStack Devtools" from the span wrapper with class
workbenchLogo since it is not a semantic element and creates redundant
accessible names. Change the img alt attribute from "TanStack Devtools" to an
empty string to mark it as decorative, since the visible wordmark text is
already provided by the strong element on line 60 with the same label. Keep the
aria-label on the header element as the single accessible name source for the
entire component.
packages/devtools-a11y/src/core/utils/ui.utils.ts (1)

288-293: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two maps now define severity labels.

getSeverityStyle already returns a label for each severity, and severity-theme.ts owns those strings. This code uses a separate SEVERITY_LABELS map. The two maps can drift, and the tooltip text would then disagree with the panel badge text. Consider reading the label from getSeverityStyle here, or deriving SEVERITY_LABELS from the severity definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/src/core/utils/ui.utils.ts` around lines 288 - 293,
Update the tooltip label construction in the surrounding single- and
multiple-issue branches to reuse the label provided by getSeverityStyle, or
derive SEVERITY_LABELS from the shared severity definition in severity-theme.ts.
Remove the independent label source so tooltip text stays consistent with panel
badge text.
packages/devtools-ui/tests/contrast.test.ts (1)

69-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Select the syntax keys by name instead of by position.

Object.values(theme.color.syntax).slice(0, 6) depends on the key insertion order in semanticThemes. If someone reorders the syntax keys, this loop silently checks selectionFill and selectionText against a surface color and skips a real syntax color. Name the keys to make the intent explicit and order-independent.

♻️ Proposed change
-    for (const color of Object.values(theme.color.syntax).slice(0, 6))
-      expect(ratio(color, syntaxBackground)).toBeGreaterThanOrEqual(4.5)
+    for (const key of [
+      'keyword',
+      'string',
+      'number',
+      'comment',
+      'property',
+      'punctuation',
+    ] as const)
+      expect(
+        ratio(theme.color.syntax[key], syntaxBackground),
+      ).toBeGreaterThanOrEqual(4.5)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/tests/contrast.test.ts` around lines 69 - 70, Update the
contrast test loop around theme.color.syntax to select the intended syntax
colors by their explicit key names rather than relying on
Object.values(...).slice(0, 6). Keep the existing ratio check against
syntaxBackground and ensure selectionFill and selectionText are not included.
packages/devtools-ui/tests/fonts.test.ts (2)

46-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Byte-level license pinning can fail on line-ending normalization.

The test pins both a sha256 digest and an exact byte size for each license file. If a contributor checks out the repository with core.autocrlf=true, the files gain CRLF line endings and both assertions fail with an opaque message. The size assertion also adds nothing beyond the digest.

Consider dropping the size assertions and hashing the content with newlines normalized. The existing toContain assertions already cover copyright and license provenance.

♻️ Proposed change
-async function sha256(path: string): Promise<string> {
-  return createHash('sha256')
-    .update(await readFile(path))
-    .digest('hex')
-}
+async function sha256(path: string): Promise<string> {
+  const text = (await readFile(path, 'utf8')).replace(/\r\n/g, '\n')
+  return createHash('sha256').update(text, 'utf8').digest('hex')
+}

Recompute both expected digests after this change, and remove the two toMatchObject({ size: ... }) assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/tests/fonts.test.ts` around lines 46 - 57, Update the
license verification in fonts.test.ts by removing the size assertions for
bricolageLicensePath and interLicensePath, and normalize line endings before
hashing each file so checkout-specific CRLF changes do not affect the digest.
Recompute and replace both expected SHA-256 values using the normalized content
while preserving the existing provenance checks.

11-12: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both new test files resolve repository paths from process.cwd(). The working directory depends on how the test runner is invoked. If Vitest runs from the workspace root instead of a package root, readdir and readdirSync throw ENOENT. Resolve the paths from the module location with new URL(..., import.meta.url) and fileURLToPath.

  • packages/devtools-ui/tests/fonts.test.ts#L11-L12: replace join(process.cwd(), 'src', 'assets', 'fonts') with a path derived from import.meta.url.
  • packages/devtools-a11y/tests/theme.test.ts#L17-L18: derive packageRoot and coreRoot from import.meta.url so the source scan and the relative() paths in the violation messages stay stable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/tests/fonts.test.ts` around lines 11 - 12, Both tests
must resolve repository paths independently of the test runner’s working
directory. In packages/devtools-ui/tests/fonts.test.ts lines 11-12, update the
fontDirectory setup near expectedFontNames to derive the path from
import.meta.url using fileURLToPath and new URL; in
packages/devtools-a11y/tests/theme.test.ts lines 17-18, derive packageRoot and
coreRoot the same way so source scanning and relative() violation paths remain
stable.
packages/devtools-a11y/tests/theme.test.ts (1)

124-126: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This assertion does not discriminate the selected styling.

document.head.textContent holds every class goober has generated so far in the process. The colors #ffffff and #111111 appear in many unrelated rules, and the sheet accumulates across both describe.each iterations. The assertion therefore passes even if the selected-state styles are removed.

Assert on the resolved class name of the selected element instead, so the check binds to the styling under test.

♻️ Proposed change
-    expect(document.head.textContent).toContain(
-      resolveSemanticTheme(theme).color.state.selectionText,
-    )
+    const selectedClass = createStyles()().issueCardSelected
+    expect(host.querySelector('[data-tsd-surface]')?.className).toContain(
+      selectedClass,
+    )

createStyles requires a theme context, so read it inside the rendered tree, or export the selected class name from the style factory for assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/tests/theme.test.ts` around lines 124 - 126, Replace
the broad document.head.textContent assertion in the selected-styling test with
an assertion against the selected element’s resolved class name. Read the theme
context inside the rendered tree when calling createStyles, or expose the
selected class name from the style factory, and verify that class is applied to
the selected element rather than matching unrelated accumulated stylesheet
colors.
packages/devtools-a11y/src/core/styles/styles.ts (2)

227-237: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Resolve the severity style once per style function.

getSeverityStyle runs two or three times inside a single template. Each call allocates a new object through the spread in severity-theme.ts. Call it once and reuse the result.

♻️ Proposed change for `severityLabel`
-    severityLabel: (impact: SeverityThreshold) => css`
-      font-size: ${type.labelSm.size};
-      line-height: ${type.labelSm.lineHeight};
-      color: ${getSeverityStyle(impact, themeName).colors.text};
-      background: ${getSeverityStyle(impact, themeName).colors.subtleFill};
-      border: 1px solid ${getSeverityStyle(impact, themeName).colors.border};
-      padding: 2px ${space[1]};
-      border-radius: ${radius.control};
-    `,
+    severityLabel: (impact: SeverityThreshold) => {
+      const severity = getSeverityStyle(impact, themeName).colors
+      return css`
+        font-size: ${type.labelSm.size};
+        line-height: ${type.labelSm.lineHeight};
+        color: ${severity.text};
+        background: ${severity.subtleFill};
+        border: 1px solid ${severity.border};
+        padding: 2px ${space[1]};
+        border-radius: ${radius.control};
+      `
+    },

Apply the same pattern to summaryButtonActive, summaryCount, sectionTitle, and dot.

Also applies to: 249-256, 311-326

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/src/core/styles/styles.ts` around lines 227 - 237,
Update the style functions summaryButtonActive, summaryCount, severityLabel,
sectionTitle, and dot to call getSeverityStyle once per invocation, store the
returned style, and reuse it for all color properties within the template.

100-123: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused style keys from styles.ts.

primaryButton, primaryButtonDisabled, button, toggleOverlay, toggleOverlayOn, smallLinkButton, disableRule, doneButton, select, issueMain, and search have no styles().<key> consumers in packages/devtools-a11y. Remove the unused definitions while keeping keys that components still apply.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/src/core/styles/styles.ts` around lines 100 - 123, In
the styles definition, remove the unused keys primaryButton,
primaryButtonDisabled, button, toggleOverlay, toggleOverlayOn, smallLinkButton,
disableRule, doneButton, select, issueMain, and search. Preserve all style keys
that still have styles().<key> consumers in the devtools-a11y components.
packages/devtools-ui/src/styles/use-styles.ts (1)

25-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid buttons lose hover and pressed color feedback.

solidHover uses status.border and solidActive uses status.solidFill. In the light theme, border, text, and solidFill are the same value for every status role. For example, success uses #1d4226 for all three. In the dark theme, border and solidFill are also equal for success, warning, error, and info.

The result is that a solid button keeps the same background on hover and on press. Only the box-shadow changes. That is weak feedback for pointer and keyboard users.

Consider composing the hover and pressed layers from semantic.color.state.hover and semantic.color.state.pressed, which already encode translucent overlays.

♻️ Proposed change to restore state feedback
       solid: css`
         background: ${solidBg};
         color: ${solidText};
         border-color: ${solidBorder};
         &:hover {
-          background: ${solidHover};
-          border-color: ${solidHover};
+          background: linear-gradient(
+              ${semantic.color.state.hover},
+              ${semantic.color.state.hover}
+            ),
+            ${solidHover};
+          border-color: ${solidHover};
           box-shadow: ${semantic.shadow.xs};
         }
         &:active {
-          background: ${solidActive};
-          border-color: ${solidActive};
+          background: linear-gradient(
+              ${semantic.color.state.pressed},
+              ${semantic.color.state.pressed}
+            ),
+            ${solidActive};
+          border-color: ${solidActive};
           box-shadow: ${semantic.shadow.sm};
         }
       `,

Also applies to: 60-74

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-ui/src/styles/use-styles.ts` around lines 25 - 29, The
solidHover and solidActive variable assignments use status color properties that
are identical to solidFill in light theme and dark theme, preventing visual
feedback on hover and pressed states. Replace the solidHover assignment with a
reference to semantic.color.state.hover and the solidActive assignment with
semantic.color.state.pressed, which provide translucent overlay states. Apply
the same pattern fixes to the analogous hover and active state assignments in
the outlineHover, outlineActive, ghostHover, and ghostActive variable
definitions to ensure consistent state feedback across all button variants.
packages/devtools-a11y/tests/index.test.ts (1)

3-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a development-mode case for a11yDevtoolsPlugin.

The mock tuple order matches createReactPlugin, but the current test only covers the production no-op import path. Add a companion case that stubs NODE_ENV to development and asserts the active A11yDevtoolsPanel / a11yDevtoolsPlugin branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-a11y/tests/index.test.ts` around lines 3 - 9, Add a
development-mode test case in the existing test suite that stubs NODE_ENV to
development, imports or invokes the active a11yDevtoolsPlugin branch, and
asserts it uses the mocked A11yDevtoolsPanel/createReactPlugin path rather than
the production no-op path. Preserve the existing production coverage and restore
the environment or module state between cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a5ebdd3-1aaa-4775-914b-dac45d834f11

📥 Commits

Reviewing files that changed from the base of the PR and between 10f28d8 and feee9cd.

⛔ Files ignored due to path filters (4)
  • packages/devtools-ui/src/assets/fonts/BricolageGrotesque-Bold.ttf is excluded by !**/*.ttf
  • packages/devtools-ui/src/assets/fonts/Inter-latin.woff2 is excluded by !**/*.woff2
  • packages/devtools/src/components/tanstack-logo.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (87)
  • .changeset/tanstack-devtools-branding.md
  • docs/architecture.md
  • docs/overview.md
  • docs/superpowers/specs/2026-07-31-tanstack-devtools-branding-design.md
  • examples/react/basic/README.md
  • examples/react/basic/package.json
  • examples/react/basic/scripts/check-a11y-fixture.mjs
  • examples/react/basic/src/a11y-audit-fixture.tsx
  • examples/react/basic/src/example.css
  • examples/react/basic/src/index.tsx
  • examples/react/basic/src/setup.tsx
  • examples/react/basic/src/vite-env.d.ts
  • packages/devtools-a11y/src/core/components/IssueCard.tsx
  • packages/devtools-a11y/src/core/components/IssueList.tsx
  • packages/devtools-a11y/src/core/components/Settings.tsx
  • packages/devtools-a11y/src/core/components/Shell.tsx
  • packages/devtools-a11y/src/core/contexts/allyContext.tsx
  • packages/devtools-a11y/src/core/styles/severity-theme.ts
  • packages/devtools-a11y/src/core/styles/styles.ts
  • packages/devtools-a11y/src/core/utils/ui.utils.ts
  • packages/devtools-a11y/tests/index.test.ts
  • packages/devtools-a11y/tests/theme.test.ts
  • packages/devtools-a11y/vite.config.ts
  • packages/devtools-ui/package.json
  • packages/devtools-ui/scripts/check-font-assets.mjs
  • packages/devtools-ui/src/assets/fonts/OFL-Bricolage-Grotesque.txt
  • packages/devtools-ui/src/assets/fonts/OFL-Inter.txt
  • packages/devtools-ui/src/components/button.tsx
  • packages/devtools-ui/src/components/checkbox.tsx
  • packages/devtools-ui/src/components/header.tsx
  • packages/devtools-ui/src/components/icons.tsx
  • packages/devtools-ui/src/components/input.tsx
  • packages/devtools-ui/src/components/logo.tsx
  • packages/devtools-ui/src/components/main-panel.tsx
  • packages/devtools-ui/src/components/section.tsx
  • packages/devtools-ui/src/components/select.tsx
  • packages/devtools-ui/src/components/tag.tsx
  • packages/devtools-ui/src/components/theme.tsx
  • packages/devtools-ui/src/components/tree.tsx
  • packages/devtools-ui/src/internal.ts
  • packages/devtools-ui/src/styles/semantic-theme.ts
  • packages/devtools-ui/src/styles/use-styles.ts
  • packages/devtools-ui/tests/contrast.test.ts
  • packages/devtools-ui/tests/fonts.test.ts
  • packages/devtools-ui/tests/index.test.ts
  • packages/devtools-ui/tests/semantic-theme.test.ts
  • packages/devtools-ui/tests/tree.tsx
  • packages/devtools-ui/tsconfig.json
  • packages/devtools-ui/vite.config.ts
  • packages/devtools/src/components/content-panel.tsx
  • packages/devtools/src/components/main-panel.tsx
  • packages/devtools/src/components/plugins-strip.tsx
  • packages/devtools/src/components/source-inspector.tsx
  • packages/devtools/src/components/tab-content.tsx
  • packages/devtools/src/components/tabs.test.tsx
  • packages/devtools/src/components/tabs.tsx
  • packages/devtools/src/components/trigger.tsx
  • packages/devtools/src/components/workbench-header.tsx
  • packages/devtools/src/components/workbench-secondary-tabs.tsx
  • packages/devtools/src/context/devtools-context.test.ts
  • packages/devtools/src/context/devtools-context.tsx
  • packages/devtools/src/context/draw-context.tsx
  • packages/devtools/src/context/pip-context.tsx
  • packages/devtools/src/context/use-devtools-context.ts
  • packages/devtools/src/devtools.tsx
  • packages/devtools/src/styles/tokens.ts
  • packages/devtools/src/styles/use-styles.ts
  • packages/devtools/src/tabs/index.tsx
  • packages/devtools/src/tabs/marketplace/marketplace-header.tsx
  • packages/devtools/src/tabs/marketplace/plugin-card.tsx
  • packages/devtools/src/tabs/marketplace/plugin-section.tsx
  • packages/devtools/src/tabs/marketplace/settings-panel.tsx
  • packages/devtools/src/tabs/marketplace/tag-filters.tsx
  • packages/devtools/src/tabs/plugin-marketplace.tsx
  • packages/devtools/src/tabs/plugins-tab.tsx
  • packages/devtools/src/tabs/seo-tab/index.tsx
  • packages/devtools/src/tabs/seo-tab/serp-preview.tsx
  • packages/devtools/src/tabs/seo-tab/social-previews.tsx
  • packages/devtools/src/tabs/settings-tab.test.tsx
  • packages/devtools/src/tabs/settings-tab.tsx
  • packages/devtools/src/utils/constants.ts
  • packages/devtools/src/utils/storage.ts
  • packages/devtools/tests/index.test.ts
  • packages/devtools/tests/semantic-color-usage.test.ts
  • packages/devtools/tests/seo-workbench.test.tsx
  • packages/devtools/tests/workbench-secondary-tabs.test.tsx
  • packages/devtools/tests/workbench.test.tsx
💤 Files with no reviewable changes (5)
  • packages/devtools/src/components/tabs.test.tsx
  • packages/devtools/src/components/tabs.tsx
  • packages/devtools/src/tabs/index.tsx
  • packages/devtools/src/styles/tokens.ts
  • packages/devtools/src/context/draw-context.tsx

Comment on lines +38 to +45
let nextHeight: number | undefined
if (grows) nextHeight = clampedHeight() + step
if (shrinks) nextHeight = clampedHeight() - step
if (event.key === 'Home') nextHeight = PANEL_CLOSE_THRESHOLD
if (event.key === 'End') nextHeight = maxHeight()
if (nextHeight === undefined) return
event.preventDefault()
props.handleHeightChange?.(Math.min(maxHeight(), nextHeight))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clamp the keyboard resize to PANEL_CLOSE_THRESHOLD.

Line 45 clamps only the upper bound. When clampedHeight() is already at PANEL_CLOSE_THRESHOLD, ArrowDown (bottom panel) reports 20 px. updateHeight in packages/devtools/src/devtools.tsx then sets isOpen to false, so the panel collapses and the separator is unmounted. A keyboard user cannot restore the panel from that control.

This also contradicts aria-valuemin={PANEL_CLOSE_THRESHOLD} on Line 66 and the Home behavior on Line 41, which both declare 70 as the floor.

🐛 Proposed fix to clamp both bounds
     if (nextHeight === undefined) return
     event.preventDefault()
-    props.handleHeightChange?.(Math.min(maxHeight(), nextHeight))
+    props.handleHeightChange?.(
+      Math.min(maxHeight(), Math.max(PANEL_CLOSE_THRESHOLD, nextHeight)),
+    )
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let nextHeight: number | undefined
if (grows) nextHeight = clampedHeight() + step
if (shrinks) nextHeight = clampedHeight() - step
if (event.key === 'Home') nextHeight = PANEL_CLOSE_THRESHOLD
if (event.key === 'End') nextHeight = maxHeight()
if (nextHeight === undefined) return
event.preventDefault()
props.handleHeightChange?.(Math.min(maxHeight(), nextHeight))
let nextHeight: number | undefined
if (grows) nextHeight = clampedHeight() + step
if (shrinks) nextHeight = clampedHeight() - step
if (event.key === 'Home') nextHeight = PANEL_CLOSE_THRESHOLD
if (event.key === 'End') nextHeight = maxHeight()
if (nextHeight === undefined) return
event.preventDefault()
props.handleHeightChange?.(
Math.min(maxHeight(), Math.max(PANEL_CLOSE_THRESHOLD, nextHeight)),
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/content-panel.tsx` around lines 38 - 45,
Update the keyboard resize handler around nextHeight and
props.handleHeightChange so the reported height is clamped to both
PANEL_CLOSE_THRESHOLD and maxHeight(). Preserve the existing key-specific height
calculations and ensure ArrowDown cannot produce a value below the declared
minimum.

Comment on lines 85 to +90
<div
id={TANSTACK_DEVTOOLS}
data-testid="tsd-main-panel"
data-open={props.isOpen() ? 'true' : 'false'}
data-testid="tanstack-devtools-panel"
data-open={String(props.isOpen())}
data-collapsed={String(isAttachedCollapsed())}
data-tsd-surface

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use an instance-specific panel target.

Line 86 assigns every MainPanel the same tanstack_devtools ID. createDisableTabbing resolves that ID with document.getElementById, so multiple DevTools owners update tab stops only in the first panel. A later closed panel can remain keyboard reachable, and the document contains duplicate IDs. Pass the panel element to createDisableTabbing or generate a per-instance ID.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/main-panel.tsx` around lines 85 - 90, Update
MainPanel and createDisableTabbing so tab handling targets the current panel
element rather than the shared TANSTACK_DEVTOOLS ID. Pass each panel’s element
directly to createDisableTabbing (or generate and consistently use a
per-instance ID), ensuring multiple MainPanel instances have no duplicate IDs
and closed panels are correctly removed from keyboard navigation.

Comment thread packages/devtools/src/context/devtools-context.test.ts
Comment on lines +372 to +395
it('reactively replaces plugins through the existing onSetPlugins callback', async () => {
let replace!: (plugins: Array<TanStackDevtoolsPlugin>) => void
let ids: Array<string> = []
const dispose = createRoot((disposeRoot) => {
createComponent(DevtoolsProvider, {
plugins: [{ name: 'Old', render: () => {} }],
onSetPlugins: (setter) => {
replace = setter
},
get children() {
const context = useContext(DevtoolsContext)!
createEffect(() => {
ids = context.store.plugins?.map((entry) => entry.id!) ?? []
})
return null
},
})
return disposeRoot
})
await Promise.resolve()
expect(ids).toEqual(['old-0'])
replace([{ name: 'New', render: () => {} }])
expect(ids).toEqual(['new-0'])
dispose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Reconcile active plugin IDs during replacement.

Line 393 replaces store.plugins with new-0. DevtoolsProvider.updatePlugins only updates store.plugins, so the initial one-plugin state retains activePlugins: ['old-0']. PluginsTab then creates a pane for old-0 and finds no plugin to render. Reconcile active IDs with the replacement list and apply the normal default-selection behavior when no valid active ID remains. Extend this test to assert the active state and rendered replacement plugin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/context/devtools-context.test.ts` around lines 372 -
395, Update DevtoolsProvider.updatePlugins to reconcile activePlugins against
the replacement plugin IDs, removing stale IDs and applying the existing
default-selection behavior when none remain valid. Extend the reactive
replacement test around onSetPlugins to assert the active plugin state and
verify that the replacement plugin is rendered instead of the removed plugin.

Comment on lines +80 to +83
const updateHeight = (nextHeight: number) => {
setHeight(nextHeight)
setIsOpen(nextHeight >= PANEL_CLOSE_THRESHOLD)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve a usable height after a drag-close.

Line 81 stores the raw drag height, and Line 82 closes the drawer below PANEL_CLOSE_THRESHOLD. After a user drags to 50px, toggleOpen reopens the drawer without restoring its height. The drawer then opens at 50px. Preserve the last valid expanded height or clamp the stored height before closing. Apply the same maximum bound used by keyboard resizing.

Also applies to: 117-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/devtools.tsx` around lines 80 - 83, Update updateHeight
and the keyboard-resizing logic to clamp stored heights using the same maximum
bound, while preserving the last valid expanded height when a drag closes below
PANEL_CLOSE_THRESHOLD. Ensure toggleOpen reopens the drawer at a usable expanded
height rather than the raw drag height.

Comment on lines +18 to 33
<div
class={styles().pluginMarketplaceSettingsPanel}
data-tsd-surface
role="dialog"
aria-label="Marketplace settings"
>
<div class={styles().pluginMarketplaceSettingsPanelHeader}>
<h3 class={styles().pluginMarketplaceSettingsPanelTitle}>
Marketplace Settings
</h3>
<button
type="button"
aria-label="Close marketplace settings"
data-tsd-control
class={styles().pluginMarketplaceSettingsPanelClose}
onClick={props.onClose}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move focus into the settings dialog.

When the settings button opens this dialog, no code moves focus to the panel. SettingsPanel is inserted before MarketplaceHeader, so focus stays on the trigger and forward Tab moves past the close button and checkbox. Focus the close button or first control on open. Restore focus to the trigger on close.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/tabs/marketplace/settings-panel.tsx` around lines 18 -
33, Update SettingsPanel to move focus to its close button or first control when
the dialog opens, ensuring keyboard navigation begins inside the panel. On
close, restore focus to the settings trigger that opened it, using the existing
open/close lifecycle and refs or focusable element symbols rather than leaving
focus on the removed trigger.

Comment on lines +1 to +3
export const getStorageItem = (key: string) => {
return localStorage.getItem(key)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle unavailable local storage.

Line 2 can throw when storage is blocked or the document has an opaque origin. Devtools context initialization reads settings and state through this helper, so the exception prevents Devtools from mounting. Return null on storage failure and add a test for a throwing getItem.

Proposed fix
 export const getStorageItem = (key: string) => {
-  return localStorage.getItem(key)
+  try {
+    return localStorage.getItem(key)
+  } catch {
+    return null
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getStorageItem = (key: string) => {
return localStorage.getItem(key)
}
export const getStorageItem = (key: string) => {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/utils/storage.ts` around lines 1 - 3, Update
getStorageItem to catch failures from localStorage.getItem and return null when
storage is unavailable or access throws, while preserving normal retrieved
values. Add a test covering a throwing getItem call and asserting the helper
returns null.

…sions

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/devtools/src/tabs/marketplace/plugin-card.tsx (1)

118-141: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Mount the status live region before the status changes.

role="status" is placed on the Show fallback. That element enters the DOM at the same moment its text appears. Screen readers usually announce a live region only when the content of an already-present region changes, so install progress, success, and failure messages can go unannounced. Render an always-present live region and change only its content.

♻️ Proposed refactor
+      <div class={styles().pluginMarketplaceCardStatusLive} role="status">
+        <Show when={card.status !== 'idle'}>
+          <Show when={card.status === 'installing'}>Installing…</Show>
+          <Show when={card.status === 'success'}>Installed</Show>
+          <Show when={card.status === 'error'}>
+            {card.error || 'Failed to install'}
+          </Show>
+        </Show>
+      </div>
       <Show
         when={card.status === 'idle'}
         fallback={
-          <div class={styles().pluginMarketplaceCardStatus} role="status">
+          <div class={styles().pluginMarketplaceCardStatus} aria-hidden="true">

Add pluginMarketplaceCardStatusLive as a visually hidden style in packages/devtools/src/styles/use-styles.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/tabs/marketplace/plugin-card.tsx` around lines 118 -
141, Update the status rendering around the card status Show so an
always-present, visually hidden live region uses role="status", while only its
text/content changes as card.status transitions through installing, success, and
error; keep the visible status UI behavior unchanged. Add the
pluginMarketplaceCardStatusLive style in use-styles.ts and apply it to this
persistent live region.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/devtools/src/context/use-devtools-context.ts`:
- Around line 106-112: Move the collapsed signal out of module scope and make it
instance-owned for each createCollapsed() or DevTools mount, so isCollapsed,
toggleCollapsed, and setCollapsed operate only on that instance. Preserve the
existing collapsed API while ensuring later mounts do not reset earlier roots,
and add a test mounting two DevTools roots that verifies toggling one does not
affect the other.

In `@packages/devtools/src/styles/use-styles.ts`:
- Around line 544-567: Remove the stale first JSDoc block immediately before
workbenchCollapseToggle. Keep the second JSDoc block, which accurately documents
the toggle’s top positioning against the subheader or header when collapsed.
- Around line 390-395: Update the selector in the grid styling near the
plugins-strip rule to anchor on the shared secondary-tab element or ensure the
SEO strip renderer uses data-testid="plugins-strip". Verify the SEO secondary
strip receives the auto-sized row while its height animates, without changing
the existing header and content rows.

In `@packages/devtools/tests/workbench.test.tsx`:
- Around line 355-357: Update the folded-state assertion in the relevant
workbench test to expect the plugins strip element to remain mounted rather than
be null. Verify its folded accessibility state through the existing inert and
aria-hidden behavior while preserving the expectation that the panel stays open,
retains its height, and keeps the plugin running.

---

Nitpick comments:
In `@packages/devtools/src/tabs/marketplace/plugin-card.tsx`:
- Around line 118-141: Update the status rendering around the card status Show
so an always-present, visually hidden live region uses role="status", while only
its text/content changes as card.status transitions through installing, success,
and error; keep the visible status UI behavior unchanged. Add the
pluginMarketplaceCardStatusLive style in use-styles.ts and apply it to this
persistent live region.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9821b521-eaef-4dfb-886c-994e4348c051

📥 Commits

Reviewing files that changed from the base of the PR and between feee9cd and 6e56026.

📒 Files selected for processing (29)
  • .changeset/tanstack-devtools-branding.md
  • docs/architecture.md
  • docs/overview.md
  • packages/devtools-ui/src/styles/use-styles.ts
  • packages/devtools/src/components/main-panel.tsx
  • packages/devtools/src/components/tab-content.tsx
  • packages/devtools/src/components/tanstack-emblem.tsx
  • packages/devtools/src/components/trigger.tsx
  • packages/devtools/src/components/workbench-header.tsx
  • packages/devtools/src/components/workbench-secondary-tabs.tsx
  • packages/devtools/src/context/use-devtools-context.ts
  • packages/devtools/src/devtools.tsx
  • packages/devtools/src/styles/use-styles.ts
  • packages/devtools/src/tabs/hotkey-config.tsx
  • packages/devtools/src/tabs/marketplace/card-utils.test.ts
  • packages/devtools/src/tabs/marketplace/card-utils.ts
  • packages/devtools/src/tabs/marketplace/marketplace-header.tsx
  • packages/devtools/src/tabs/marketplace/plugin-card.tsx
  • packages/devtools/src/tabs/marketplace/plugin-utils.test.ts
  • packages/devtools/src/tabs/marketplace/plugin-utils.ts
  • packages/devtools/src/tabs/plugin-marketplace.tsx
  • packages/devtools/src/tabs/plugins-tab.tsx
  • packages/devtools/src/tabs/seo-tab/index.tsx
  • packages/devtools/src/tabs/seo-tab/social-previews.tsx
  • packages/devtools/src/utils/constants.ts
  • packages/devtools/tests/index.test.ts
  • packages/devtools/tests/semantic-color-usage.test.ts
  • packages/devtools/tests/workbench-secondary-tabs.test.tsx
  • packages/devtools/tests/workbench.test.tsx
🚧 Files skipped from review as they are similar to previous changes (14)
  • packages/devtools/src/tabs/seo-tab/index.tsx
  • .changeset/tanstack-devtools-branding.md
  • docs/overview.md
  • packages/devtools/src/tabs/seo-tab/social-previews.tsx
  • packages/devtools/tests/semantic-color-usage.test.ts
  • packages/devtools/src/components/tab-content.tsx
  • packages/devtools/tests/index.test.ts
  • packages/devtools/src/tabs/marketplace/marketplace-header.tsx
  • docs/architecture.md
  • packages/devtools/src/devtools.tsx
  • packages/devtools/src/tabs/plugins-tab.tsx
  • packages/devtools/src/components/workbench-header.tsx
  • packages/devtools/src/utils/constants.ts
  • packages/devtools/tests/workbench-secondary-tabs.test.tsx

Comment on lines +106 to +112
const [collapsed, setCollapsed] = createSignal(false)

export const createCollapsed = () => ({
isCollapsed: collapsed,
toggleCollapsed: () => setCollapsed((previous) => !previous),
setCollapsed,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Scope collapsed state to each DevTools mount.

collapsed is module-level, so every createCollapsed() caller reads and updates the same signal. If two DevTools roots mount in one document, toggling one root folds the secondary tabs in both roots. A later mount also resets the shared state to false.

Store this signal in instance-owned context, or pass it from DevTools. Add a two-root isolation test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/context/use-devtools-context.ts` around lines 106 -
112, Move the collapsed signal out of module scope and make it instance-owned
for each createCollapsed() or DevTools mount, so isCollapsed, toggleCollapsed,
and setCollapsed operate only on that instance. Preserve the existing collapsed
API while ensuring later mounts do not reset earlier roots, and add a test
mounting two DevTools roots that verifies toggling one does not affect the
other.

Comment thread packages/devtools/src/styles/use-styles.ts
Comment on lines +544 to +567
/**
* A pull tab protruding from the bottom edge of the lowest chrome band —
* below the secondary strip when one is on screen, below the header when
* not. It is positioned against the panel rather than nested inside the
* strip, because the strip scrolls horizontally and would clip it.
*
* Collapsed is the exception: the panel is then only as tall as the header
* and sits flush against the viewport edge, so a downward tab would be off
* screen. There it flips to the panel's outer edge instead.
*/
/**
* A pull tab protruding from the bottom edge of the subheader, dropping back
* to the header's bottom edge once the subheader is folded away — so it
* always hangs off whatever chrome band is lowest, always inside the panel.
*
* It is positioned against the panel rather than nested inside the strip,
* because the strip scrolls horizontally and would clip it. Both bands are
* border-box, so their hairlines already sit inside these heights.
*/
workbenchCollapseToggle: (isCollapsed: boolean) => css`
position: absolute;
top: ${isCollapsed
? WORKBENCH_HEADER_HEIGHT
: WORKBENCH_HEADER_HEIGHT + PLUGINS_STRIP_HEIGHT}px;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale duplicate JSDoc block on workbenchCollapseToggle.

Two JSDoc blocks document the same member. The first block (Lines 544-553) states that the toggle flips to the panel's outer edge when collapsed. The implementation does not do that: top moves from WORKBENCH_HEADER_HEIGHT + PLUGINS_STRIP_HEIGHT to WORKBENCH_HEADER_HEIGHT, which is the header's bottom edge. The second block (Lines 554-562) describes the actual behavior. Keep the second block only.

♻️ Proposed fix
-    /**
-     * A pull tab protruding from the bottom edge of the lowest chrome band —
-     * below the secondary strip when one is on screen, below the header when
-     * not. It is positioned against the panel rather than nested inside the
-     * strip, because the strip scrolls horizontally and would clip it.
-     *
-     * Collapsed is the exception: the panel is then only as tall as the header
-     * and sits flush against the viewport edge, so a downward tab would be off
-     * screen. There it flips to the panel's outer edge instead.
-     */
     /**
      * A pull tab protruding from the bottom edge of the subheader, dropping back
      * to the header's bottom edge once the subheader is folded away — so it
      * always hangs off whatever chrome band is lowest, always inside the panel.
      *
      * It is positioned against the panel rather than nested inside the strip,
      * because the strip scrolls horizontally and would clip it. Both bands are
      * border-box, so their hairlines already sit inside these heights.
      */

As per coding guidelines "JSDoc should read like documentation as it gets converted to markdown docs for the website".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* A pull tab protruding from the bottom edge of the lowest chrome band
* below the secondary strip when one is on screen, below the header when
* not. It is positioned against the panel rather than nested inside the
* strip, because the strip scrolls horizontally and would clip it.
*
* Collapsed is the exception: the panel is then only as tall as the header
* and sits flush against the viewport edge, so a downward tab would be off
* screen. There it flips to the panel's outer edge instead.
*/
/**
* A pull tab protruding from the bottom edge of the subheader, dropping back
* to the header's bottom edge once the subheader is folded away — so it
* always hangs off whatever chrome band is lowest, always inside the panel.
*
* It is positioned against the panel rather than nested inside the strip,
* because the strip scrolls horizontally and would clip it. Both bands are
* border-box, so their hairlines already sit inside these heights.
*/
workbenchCollapseToggle: (isCollapsed: boolean) => css`
position: absolute;
top: ${isCollapsed
? WORKBENCH_HEADER_HEIGHT
: WORKBENCH_HEADER_HEIGHT + PLUGINS_STRIP_HEIGHT}px;
/**
* A pull tab protruding from the bottom edge of the subheader, dropping back
* to the header's bottom edge once the subheader is folded away — so it
* always hangs off whatever chrome band is lowest, always inside the panel.
*
* It is positioned against the panel rather than nested inside the strip,
* because the strip scrolls horizontally and would clip it. Both bands are
* border-box, so their hairlines already sit inside these heights.
*/
workbenchCollapseToggle: (isCollapsed: boolean) => css`
position: absolute;
top: ${isCollapsed
? WORKBENCH_HEADER_HEIGHT
: WORKBENCH_HEADER_HEIGHT + PLUGINS_STRIP_HEIGHT}px;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/styles/use-styles.ts` around lines 544 - 567, Remove
the stale first JSDoc block immediately before workbenchCollapseToggle. Keep the
second JSDoc block, which accurately documents the toggle’s top positioning
against the subheader or header when collapsed.

Source: Coding guidelines

Comment thread packages/devtools/tests/workbench.test.tsx Outdated
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/devtools/src/hooks/use-head-changes.ts`:
- Around line 45-49: Update the mutation observer callback in useHeadChanges to
skip child-list records whose mutation target is a STYLE element before
processing addedNodes and removedNodes. Preserve the existing per-node
isStyleNode checks for elements inserted directly into HEAD, and add a
regression test covering style.textContent replacement without emitting a head
removal.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4cf6fd8e-68ce-41ff-9657-94a89fefe38e

📥 Commits

Reviewing files that changed from the base of the PR and between 6e56026 and be1673f.

📒 Files selected for processing (8)
  • .changeset/tanstack-devtools-branding.md
  • e2e/apps/react-vite/tests/tabs-and-plugin.spec.ts
  • e2e/helpers/src/selectors.ts
  • packages/devtools/src/components/workbench-header.tsx
  • packages/devtools/src/hooks/use-head-changes.ts
  • packages/devtools/tests/seo-workbench.test.tsx
  • packages/devtools/tests/test-setup.ts
  • packages/devtools/tests/workbench.test.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/devtools/src/components/workbench-header.tsx
  • .changeset/tanstack-devtools-branding.md
  • packages/devtools/tests/seo-workbench.test.tsx

Comment on lines +45 to +49
const isStyleNode = (node: Node): boolean => {
const element =
node.nodeType === 3 /* Node.TEXT_NODE */ ? node.parentNode : node
return (element as Element | null)?.nodeName === 'STYLE'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'packages/devtools/src/hooks/use-head-changes\.ts|packages/devtools/src/tabs/seo-tab/(serp-preview|social-previews)\.tsx' || true

echo
echo "use-head-changes outline:"
ast-grep outline packages/devtools/src/hooks/use-head-changes.ts --view expanded || true

echo
echo "Relevant use-head-changes lines:"
cat -n packages/devtools/src/hooks/use-head-changes.ts | sed -n '1,120p'

echo
echo "SEO preview relevant usages:"
for f in packages/devtools/src/tabs/seo-tab/serp-preview.tsx packages/devtools/src/tabs/seo-tab/social-previews.tsx; do
  echo "### $f"
  wc -l "$f"
  cat -n "$f" | sed -n '180,230p'
done

echo
echo "Search head changes consumers and goober/style text usage:"
rg -n "useHeadChanges|onChange\\({ kind: (added|removed)|textContent|style\\." packages/devtools/src -S

Repository: TanStack/devtools

Length of output: 8787


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant consumers:"
rg -n "createHeadChanges|setSerp\\(getSerpFromHead|reports\\(\\)" packages/devtools/src -S

echo
echo "Style/text mutations in packages/devtools/src:"
rg -n "textContent|style\\.nonce|css\\(|useHeadChanges|createHeadChanges|onChange\\(\\{ kind:" packages/devtools/src -S

echo
echo "Search for removedNodes behavior tests or docs if present:"
rg -n "removedNodes|childList|MutationObserver|goober|nonce" packages -S

Repository: TanStack/devtools

Length of output: 6289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Node behavior probe:"
node - <<'JS'
class MockElement {
  constructor(tagName) {
    this.nodeName = tagName.toUpperCase()
    this.parentNode = null
    this.childNodes = []
  }
  appendChild(child) {
    child.parentNode = this
    this.childNodes.push(child)
  }
}
class MockText {
  constructor(text) {
    this.nodeType = 3
    this.parentNode = null
    this.nodeValue = text
  }
}

const style = new MockElement('style')
style.appendChild(new MockText('body{}'))
const removed = style.childNodes[0]
style.childNodes.splice(0, removed.childNodes.length)
removed.parentNode = null

const isStyleNode = (node) => {
  const element = node.nodeType === 3 ? node.parentNode : node
  return element?.nodeName === 'STYLE'
}

console.log('removed parentNode:', removed.parentNode)
console.log('isStyleNode(removed):', isStyleNode(removed))
console.log('isStyleNode(style):', isStyleNode(style))
JS

Repository: TanStack/devtools

Length of output: 863


🌐 Web query:

MDN MutationRecord removedNodes removed element child text node before callback detached parentNode

💡 Result:

The MutationRecord property removedNodes is a NodeList containing all nodes removed from the target of a childList mutation [1][2]. This collection includes both element nodes and text nodes if they were removed from the direct children of the observed target node [3][4]. Key technical points regarding this behavior include: 1. Scope of removedNodes: This property contains only the direct children that were removed from the target node [1]. If a parent node is removed, the MutationRecord for that removal will contain the parent node in its removedNodes list, but it will not list the descendants of that parent [5]. Consequently, if you need to track the removal of a specific deep descendant, you must check whether the removed nodes or their ancestors match the target you are monitoring [5]. 2. Detached state: Once a node is removed, it becomes detached from the DOM tree [6]. Its parentNode property will be null [7]. Because MutationObserver callbacks are asynchronous and batched, the state of the DOM at the time the callback executes may differ from the state at the moment the removal actually occurred [8][5]. 3. NodeList characteristics: The NodeList returned by removedNodes is static, not live [6]. This means that the collection does not update automatically if the DOM changes further after the mutation record was generated [6]. 4. Distinguishing mutations: When performing operations like setting innerHTML or textContent, the browser may remove existing text nodes and replace them with new ones [9][10]. This registers as a childList mutation (with the old text node appearing in removedNodes) rather than a characterData mutation [9][10]. Many developers filter these records by checking if the removed node is an element node (nodeType === 1) to ignore implicit text node removals if they are only interested in structural changes [9][10]. 5. Accessing parent context: Because the removed node's parentNode is null, you cannot retrieve the original parent directly from the removed node instance [7]. You must rely on the MutationRecord.target property, which identifies the node whose children were mutated, to understand the context of the removal [7][8].

Citations:


Filter child-list records by their mutation target.

When a <style> element replaces its text node via textContent, the observer callback receives that old text node in removedNodes, but its parentNode is already detached. isStyleNode then returns false, so line 70 emits a head removal. Skip childList records whose m.target is a style element before iterating addedNodes and removedNodes. Keep the per-node checks for style elements inserted directly into <head>, and add a regression test for replacing style.textContent.

Proposed fix
         if (m.type === 'childList') {
+          if (isStyleNode(m.target)) continue
           m.addedNodes.forEach((node) => {
             if (!isStyleNode(node)) onChange({ kind: 'added', node }, m)
           })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/hooks/use-head-changes.ts` around lines 45 - 49, Update
the mutation observer callback in useHeadChanges to skip child-list records
whose mutation target is a STYLE element before processing addedNodes and
removedNodes. Preserve the existing per-node isStyleNode checks for elements
inserted directly into HEAD, and add a regression test covering
style.textContent replacement without emitting a head removal.

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

socket-security Bot commented Aug 10, 2026

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (8)
packages/devtools/src/utils/layout-tree.ts (1)

51-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the depth-ceiling comment.

The comment states "Nine panes cannot nest deeper than nine", but MAX_ACTIVE_PLUGINS is now 18 in packages/devtools/src/utils/constants.ts. The constant value of 32 still holds. Restate the reasoning against the current pane cap so the ceiling stays justified.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/utils/layout-tree.ts` around lines 51 - 56, Update the
comment above MAX_STORED_DEPTH to reference the current MAX_ACTIVE_PLUGINS cap
of 18 instead of nine, while preserving the explanation that values beyond the
ceiling may indicate corrupt or self-referencing data and must not be
recursively traversed. Keep MAX_STORED_DEPTH at 32.
e2e/apps/react-vite/tests/plugin-workspace.spec.ts (1)

259-298: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two test.fixme tests leave the strip-to-workspace pointer handoff unverified.

The reasons are documented clearly, which is good. The gap covers a headline feature of this PR: dragging a plugin out of the strip to choose its placement. Playwright's synthetic mouse usually needs the hold to be produced with explicit timed steps rather than mouse.down() followed immediately by mouse.move(); dispatching pointermove events during the hold window and adding a waitForTimeout(DRAG_HOLD_MS + margin) between down and the first move often unblocks this pattern.

Do you want me to open a tracking issue for the two skipped tests, or draft a revised pressAndHold helper in e2e/helpers/src/page-objects/devtools.ts?

Also applies to: 410-435

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/apps/react-vite/tests/plugin-workspace.spec.ts` around lines 259 - 298,
Replace the strip-to-workspace test.fixme cases around the dragging scenario
with working tests by updating the pressAndHold helper in DevtoolsPage to
perform an explicit timed hold, including pointermove events and a wait beyond
DRAG_HOLD_MS before the first drag movement. Preserve the existing drop
assertions and apply the same helper behavior to both strip-to-workspace tests.
packages/devtools/src/components/plugins-strip.tsx (1)

17-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share DRAG_HOLD_MS from one module.

plugin-workspace.tsx defines the same DRAG_HOLD_MS = 500 constant at line 70, and e2e/helpers/src/selectors.ts exports DRAG_HOLD_MS = 650. Three separate definitions of one timing contract will drift. Move the value into packages/devtools/src/utils/constants.ts, import it in both components, and derive the e2e margin from it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/plugins-strip.tsx` around lines 17 - 21,
Move the shared DRAG_HOLD_MS timing constant into
packages/devtools/src/utils/constants.ts, then import and reuse it in
plugins-strip.tsx and plugin-workspace.tsx instead of defining local copies.
Update e2e/helpers/src/selectors.ts to derive its exported timing value from the
shared constant while preserving the existing 150ms margin.
e2e/helpers/src/page-objects/devtools.ts (2)

90-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type direction and zone as unions instead of string.

dragTabToZone validates zone at runtime and throws on an unknown value. movePaneWithKeyboard does not validate direction at all: a wrong key name presses an unrecognised key, and the test then fails on a later assertion with no indication of the cause. Union types catch both mistakes at compile time and remove the need for the runtime check.

♻️ Proposed types
+type Zone = 'left' | 'right' | 'top' | 'bottom' | 'center'
+type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'
-  async movePaneWithKeyboard(pluginId: string, direction: string) {
+  async movePaneWithKeyboard(pluginId: string, direction: ArrowKey) {
-  async dragTabToZone(pluginId: string, targetPluginId: string, zone: string) {
+  async dragTabToZone(
+    pluginId: string,
+    targetPluginId: string,
+    zone: Zone,
+  ) {

Also applies to: 98-98

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/helpers/src/page-objects/devtools.ts` at line 90, Change the direction
parameter of movePaneWithKeyboard and the zone parameter of dragTabToZone from
string to unions of their supported literal values. Preserve the existing
behavior while allowing TypeScript to reject unsupported directions and zones at
compile time, and remove any now-unnecessary runtime validation if applicable.

79-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Import the storage key rather than repeating the literal.

storedLayout hardcodes 'tanstack_devtools_state'. The product defines the same value as TANSTACK_DEVTOOLS_STATE in packages/devtools/src/utils/storage.ts. If the key changes, this helper returns undefined instead of failing, and the tests that read the layout assert against undefined. Import the exported constant if the e2e helpers can depend on the package; otherwise add a comment naming the source of truth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/helpers/src/page-objects/devtools.ts` around lines 79 - 84, Update
storedLayout to use the exported TANSTACK_DEVTOOLS_STATE constant from the
devtools storage utility instead of the hardcoded localStorage key, preserving
the existing null and layout parsing behavior; only add a source-of-truth
comment if importing the package is not feasible.
packages/devtools/src/utils/get-default-active-plugins.test.ts (3)

167-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove this test: it duplicates the case at lines 72-103.

Both cases pass five defaultOpen: true plugins and assert that all five are returned. The title claims the test stops at the limit, but five is below the cap of 18, so no limiting occurs. The inline comment at lines 192-193 states this. The cap is covered at lines 204-212. Delete this case, or change its inputs so it actually crosses the cap.

Also applies to: 192-202

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/utils/get-default-active-plugins.test.ts` at line 167,
Remove the duplicate test case titled “should stop at MAX_ACTIVE_PLUGINS limit
when more plugins have defaultOpen: true” from get-default-active-plugins tests,
including its associated inline comment and assertions; the actual cap behavior
is already covered elsewhere.

72-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two cap-named tests no longer reach the cap. Raising MAX_ACTIVE_PLUGINS from 3 to 18 left both tests supplying five defaultOpen plugins. Neither crosses the limit, so both now assert the same thing as should activate plugins with defaultOpen: true, and their titles claim coverage that the new test at lines 204-212 actually provides.

  • packages/devtools/src/utils/get-default-active-plugins.test.ts#L72-L103: rename the case to describe activation below the cap, because plugins.slice(0, MAX_ACTIVE_PLUGINS) returns all five inputs and the toBeLessThanOrEqual assertion is trivially true.
  • packages/devtools/src/utils/get-default-active-plugins.test.ts#L167-L202: delete this case as a duplicate of the one above, or raise its plugin count past MAX_ACTIVE_PLUGINS so it exercises the limit.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/utils/get-default-active-plugins.test.ts` around lines
72 - 103, Update packages/devtools/src/utils/get-default-active-plugins.test.ts
at lines 72-103 by renaming the test to describe activation below the
MAX_ACTIVE_PLUGINS cap. At lines 167-202, either remove the duplicate cap-named
test or increase its defaultOpen plugin fixture count beyond MAX_ACTIVE_PLUGINS
so it verifies truncation; ensure the cap behavior is covered by an input that
exceeds the constant.

72-72: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename this test: it no longer exercises the limit.

The body supplies five plugins and MAX_ACTIVE_PLUGINS is 18. plugins.slice(0, MAX_ACTIVE_PLUGINS) therefore returns all five, so the assertion is satisfied for any implementation that returns every defaultOpen plugin. The toBeLessThanOrEqual check is also trivially true. The real cap coverage is the new test at lines 204-212. Rename this case to describe what it verifies.

📝 Proposed rename
-  it('should limit defaultOpen plugins to MAX_ACTIVE_PLUGINS', () => {
+  it('should activate every defaultOpen plugin when the count is under MAX_ACTIVE_PLUGINS', () => {

Also applies to: 97-102

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/utils/get-default-active-plugins.test.ts` at line 72,
Rename the test case around the defaultOpen plugin selection to describe its
actual behavior of returning the default-open plugins, rather than claiming it
verifies MAX_ACTIVE_PLUGINS enforcement; leave the separate cap-coverage test
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@e2e/apps/react-vite/tests/plugin-workspace.spec.ts`:
- Around line 121-131: Rename the test case title around dragTabToZone to state
that dropping on the bottom edge splits into a column, matching the existing
'bottom' zone and layout.dir === 'col' assertion; leave the test behavior
unchanged.

In `@e2e/helpers/src/page-objects/devtools.ts`:
- Around line 119-124: Update both drag helpers, including dragTabToZone and the
corresponding wait near the other mouse.down call, to wrap the drag-preview wait
in try/finally and call page.mouse.up() when the wait fails, ensuring the button
is released before the timeout propagates.

In `@e2e/helpers/src/selectors.ts`:
- Around line 3-8: Update the product-side drag threshold constants in
plugins-strip.tsx and plugin-workspace.tsx to 650, keep
selectors.ts:DRAG_HOLD_MS synchronized, and rewrite its comment to describe the
workspace drag-start threshold used by the wait timeout.

In `@packages/devtools/package.json`:
- Line 70: Update the `@neodrag/solid` dependency in the published devtools
package from the prerelease 3.0.0-next.11 to the current stable 2.3.1, unless
the package requires the next major line; if retaining the prerelease, document
the specific incompatibility that necessitates it.

In `@packages/devtools/src/components/plugin-workspace.tsx`:
- Around line 776-782: Update the aria-valuenow calculation on SplitterHandle to
normalize the handle position relative to the parent split's origin before
dividing by handle().extent, using the horizontal origin for row direction and
vertical origin for column direction. Preserve the existing rounding and 0–100
scale.
- Around line 296-304: Update the createEffect render flow around pluginRefs()
so registering or unregistering one pane does not rerender every mounted plugin.
Keep the ref lookup untracked or otherwise guard each plugin with its last
render inputs, while still triggering renders for activePlugins(), theme(), and
props.isOpen changes and rendering newly mounted panes once.

In `@packages/devtools/src/context/use-devtools-context.ts`:
- Around line 49-54: Route layout updates through replaceLayout rather than
directly spreading state.layout. In
packages/devtools/src/context/use-devtools-context.ts:49-54, update setLayout to
call the context’s replaceLayout implementation; in
packages/devtools/src/context/devtools-context.tsx:258-272, expose or consume
replaceLayout in the plugin context so createPlugins() uses the reconciled tree
write and split panes can collapse when emptied.

In `@packages/devtools/src/utils/layout-tree.test.ts`:
- Around line 220-224: Split the combined test around moveTab into separate
cases: keep the unknown-group assertion verifying the tree remains unchanged,
and add an unknown-tab case explicitly asserting that tab "zz" is inserted into
group "g0". Update the test descriptions to match these distinct behaviors.

---

Nitpick comments:
In `@e2e/apps/react-vite/tests/plugin-workspace.spec.ts`:
- Around line 259-298: Replace the strip-to-workspace test.fixme cases around
the dragging scenario with working tests by updating the pressAndHold helper in
DevtoolsPage to perform an explicit timed hold, including pointermove events and
a wait beyond DRAG_HOLD_MS before the first drag movement. Preserve the existing
drop assertions and apply the same helper behavior to both strip-to-workspace
tests.

In `@e2e/helpers/src/page-objects/devtools.ts`:
- Line 90: Change the direction parameter of movePaneWithKeyboard and the zone
parameter of dragTabToZone from string to unions of their supported literal
values. Preserve the existing behavior while allowing TypeScript to reject
unsupported directions and zones at compile time, and remove any now-unnecessary
runtime validation if applicable.
- Around line 79-84: Update storedLayout to use the exported
TANSTACK_DEVTOOLS_STATE constant from the devtools storage utility instead of
the hardcoded localStorage key, preserving the existing null and layout parsing
behavior; only add a source-of-truth comment if importing the package is not
feasible.

In `@packages/devtools/src/components/plugins-strip.tsx`:
- Around line 17-21: Move the shared DRAG_HOLD_MS timing constant into
packages/devtools/src/utils/constants.ts, then import and reuse it in
plugins-strip.tsx and plugin-workspace.tsx instead of defining local copies.
Update e2e/helpers/src/selectors.ts to derive its exported timing value from the
shared constant while preserving the existing 150ms margin.

In `@packages/devtools/src/utils/get-default-active-plugins.test.ts`:
- Line 167: Remove the duplicate test case titled “should stop at
MAX_ACTIVE_PLUGINS limit when more plugins have defaultOpen: true” from
get-default-active-plugins tests, including its associated inline comment and
assertions; the actual cap behavior is already covered elsewhere.
- Around line 72-103: Update
packages/devtools/src/utils/get-default-active-plugins.test.ts at lines 72-103
by renaming the test to describe activation below the MAX_ACTIVE_PLUGINS cap. At
lines 167-202, either remove the duplicate cap-named test or increase its
defaultOpen plugin fixture count beyond MAX_ACTIVE_PLUGINS so it verifies
truncation; ensure the cap behavior is covered by an input that exceeds the
constant.
- Line 72: Rename the test case around the defaultOpen plugin selection to
describe its actual behavior of returning the default-open plugins, rather than
claiming it verifies MAX_ACTIVE_PLUGINS enforcement; leave the separate
cap-coverage test unchanged.

In `@packages/devtools/src/utils/layout-tree.ts`:
- Around line 51-56: Update the comment above MAX_STORED_DEPTH to reference the
current MAX_ACTIVE_PLUGINS cap of 18 instead of nine, while preserving the
explanation that values beyond the ceiling may indicate corrupt or
self-referencing data and must not be recursively traversed. Keep
MAX_STORED_DEPTH at 32.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 192f5146-fb11-40ba-9c2a-34fb30440bde

📥 Commits

Reviewing files that changed from the base of the PR and between be1673f and 9e8ed23.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (27)
  • .changeset/plugin-workspace-layout-tree.md
  • docs/config.json
  • docs/plugin-workspace.md
  • e2e/apps/react-vite/tests/plugin-workspace.spec.ts
  • e2e/helpers/src/index.ts
  • e2e/helpers/src/page-objects/devtools.ts
  • e2e/helpers/src/selectors.ts
  • package.json
  • packages/devtools/package.json
  • packages/devtools/src/components/plugin-workspace.tsx
  • packages/devtools/src/components/plugins-strip.tsx
  • packages/devtools/src/components/tab-content.tsx
  • packages/devtools/src/components/workbench-secondary-tabs.tsx
  • packages/devtools/src/context/devtools-context.test.ts
  • packages/devtools/src/context/devtools-context.tsx
  • packages/devtools/src/context/devtools-store.ts
  • packages/devtools/src/context/use-devtools-context.ts
  • packages/devtools/src/styles/use-styles.ts
  • packages/devtools/src/tabs/index.tsx
  • packages/devtools/src/tabs/plugins-tab.tsx
  • packages/devtools/src/utils/constants.ts
  • packages/devtools/src/utils/get-default-active-plugins.test.ts
  • packages/devtools/src/utils/layout-tree.test.ts
  • packages/devtools/src/utils/layout-tree.ts
  • packages/devtools/tests/seo-workbench.test.tsx
  • packages/devtools/tests/workbench.test.tsx
  • packages/vue-devtools/src/vue-devtools.vue
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/devtools/src/components/workbench-secondary-tabs.tsx
  • packages/devtools/tests/seo-workbench.test.tsx
  • packages/devtools/src/tabs/index.tsx
  • packages/devtools/src/components/tab-content.tsx
  • packages/devtools/tests/workbench.test.tsx
  • packages/devtools/src/styles/use-styles.ts

Comment on lines +121 to +131
test('dropping a tab on a bottom edge splits into a row', async ({ page }) => {
const dt = new DevtoolsPage(page)
await openTwoPanes(dt)

await dt.dragTabToZone(PROBE, DEMO, 'bottom')
const layout = (await dt.storedLayout()) as { dir?: string; kind?: string }
// Either a column split was created, or the pane was too short to split and it
// stacked instead. Both are correct; a silent no-op is not.
const stacked = (await dt.groupTabBars().count()) === 1
expect(stacked || layout.dir === 'col').toBe(true)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The test title contradicts the assertion.

The title says "splits into a row", but the drop zone is bottom and the assertion checks layout.dir === 'col'. A bottom drop creates a column split. Rename the test to "dropping a tab on a bottom edge splits into a column".

💚 Proposed title fix
-test('dropping a tab on a bottom edge splits into a row', async ({ page }) => {
+test('dropping a tab on a bottom edge splits into a column', async ({
+  page,
+}) => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('dropping a tab on a bottom edge splits into a row', async ({ page }) => {
const dt = new DevtoolsPage(page)
await openTwoPanes(dt)
await dt.dragTabToZone(PROBE, DEMO, 'bottom')
const layout = (await dt.storedLayout()) as { dir?: string; kind?: string }
// Either a column split was created, or the pane was too short to split and it
// stacked instead. Both are correct; a silent no-op is not.
const stacked = (await dt.groupTabBars().count()) === 1
expect(stacked || layout.dir === 'col').toBe(true)
})
test('dropping a tab on a bottom edge splits into a column', async ({
page,
}) => {
const dt = new DevtoolsPage(page)
await openTwoPanes(dt)
await dt.dragTabToZone(PROBE, DEMO, 'bottom')
const layout = (await dt.storedLayout()) as { dir?: string; kind?: string }
// Either a column split was created, or the pane was too short to split and it
// stacked instead. Both are correct; a silent no-op is not.
const stacked = (await dt.groupTabBars().count()) === 1
expect(stacked || layout.dir === 'col').toBe(true)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/apps/react-vite/tests/plugin-workspace.spec.ts` around lines 121 - 131,
Rename the test case title around dragTabToZone to state that dropping on the
bottom edge splits into a column, matching the existing 'bottom' zone and
layout.dir === 'col' assertion; leave the test behavior unchanged.

Comment on lines +119 to +124
await this.page.mouse.down()
// Wait for the drag to have actually begun, not a fixed duration — see
// `pressAndHold`.
await this.page
.getByTestId(SELECTORS.dragPreview)
.waitFor({ state: 'visible', timeout: DRAG_HOLD_MS * 8 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Release the mouse button if the drag preview never appears.

Both helpers call mouse.down() and then wait for the drag preview. If the wait times out, the exception propagates while the button is still pressed. Playwright keeps mouse state for the whole page context, so every later test in the same context starts with a held button and fails for an unrelated reason. Wrap the wait in try/finally and release the button on failure.

🛡️ Proposed fix
   async pressAndHold(locator: Locator) {
     const box = await locator.boundingBox()
     if (box === null) throw new Error('cannot hold an element with no box')
     await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
     await this.page.mouse.down()
-    await this.page
-      .getByTestId(SELECTORS.dragPreview)
-      .waitFor({ state: 'visible', timeout: DRAG_HOLD_MS * 8 })
+    try {
+      await this.page
+        .getByTestId(SELECTORS.dragPreview)
+        .waitFor({ state: 'visible', timeout: DRAG_HOLD_MS * 8 })
+    } catch (error) {
+      await this.page.mouse.up()
+      throw error
+    }
   }

Apply the same pattern to the wait in dragTabToZone at lines 122-124.

Also applies to: 143-147

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/helpers/src/page-objects/devtools.ts` around lines 119 - 124, Update both
drag helpers, including dragTabToZone and the corresponding wait near the other
mouse.down call, to wrap the drag-preview wait in try/finally and call
page.mouse.up() when the wait fails, ensuring the button is released before the
timeout propagates.

Comment on lines +3 to +8
/**
* How long a tab or strip entry must be held before a drag begins. Mirrors
* `DRAG_HOLD_MS` in the workspace; a little longer here so a slow machine does not
* release the button early.
*/
export const DRAG_HOLD_MS = 650

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare the e2e mirror against the product-side DRAG_HOLD_MS definition.
rg -nP --type=ts --type=tsx -g '!e2e/**' '\bDRAG_HOLD_MS\b' -C 3 || \
rg -nP -g '*.ts' -g '*.tsx' -g '!e2e/**' '\bDRAG_HOLD_MS\b' -C 3

Repository: TanStack/devtools

Length of output: 187


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidates =="
fd -a 'selectors\.ts$|devtools\.ts$|packages|apps|src' . | sed 's#^\./##' | head -200

echo
echo "== DRAG_HOLD_MS occurrences, excluding e2e =="
rg -n -C 4 '\bDRAG_HOLD_MS\b' -g '!e2e/**' .

echo
echo "== relevant e2e occurrences =="
rg -n -C 4 '\bDRAG_HOLD_MS\b' e2e || true

echo
echo "== selector file =="
fd -a 'selectors\.ts$' e2e/helpers/src | while read -r f; do
  echo "--- $f"
  cat -n "$f"
done

echo
echo "== page-objects devtools outline =="
ast-grep outline e2e/helpers/src/page-objects/devtools.ts --view expanded || true

echo
echo "== devtools relevant slices =="
wc -l e2e/helpers/src/page-objects/devtools.ts
sed -n '1,320p' e2e/helpers/src/page-objects/devtools.ts | cat -n

Repository: TanStack/devtools

Length of output: 19822


Correct the mirrored drag-hold constant.

The e2e value is 650 while packages/devtools/src/components/plugins-strip.tsx and packages/devtools/src/components/plugin-workspace.tsx both use 500. Update the product-side threshold and bring e2e/helpers/src/selectors.ts:DRAG_HOLD_MS back in sync, then rewrite the comment to describe it as the workspace's drag start threshold used by the wait timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/helpers/src/selectors.ts` around lines 3 - 8, Update the product-side
drag threshold constants in plugins-strip.tsx and plugin-workspace.tsx to 650,
keep selectors.ts:DRAG_HOLD_MS synchronized, and rewrite its comment to describe
the workspace drag-start threshold used by the wait timeout.

"build": "tsup"
},
"dependencies": {
"@neodrag/solid": "3.0.0-next.11",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check published versions and dist-tags for `@neodrag/solid`.
curl -s https://registry.npmjs.org/@neodrag/solid | jq '{latest: .["dist-tags"], versions: (.versions | keys)}'

Repository: TanStack/devtools

Length of output: 800


Prefer the stable Neodrag Solid release for the published devtools package.

3.0.0-next.11 is still a prerelease and not behind any stable dist-tag; 2.3.1 is the current latest stable. Use the stable release when migration to the next line is not required, or document the exact prerelease-pinning reason if a stable version is incompatible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/package.json` at line 70, Update the `@neodrag/solid`
dependency in the published devtools package from the prerelease 3.0.0-next.11
to the current stable 2.3.1, unless the package requires the next major line; if
retaining the prerelease, document the specific incompatibility that
necessitates it.

Comment on lines +296 to +304
createEffect(() => {
for (const pluginId of activePlugins()) {
const plugin = pluginById(pluginId)
const ref = pluginRefs().get(pluginId)
if (plugin && ref) {
plugin.render(ref, { theme: theme(), devtoolsOpen: props.isOpen })
}
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

The render effect re-runs for every open plugin when any pane ref registers.

The effect reads pluginRefs(). setPluginRefs creates a new Map each time a pane mounts or unmounts, so the signal changes identity. Opening or closing one plugin therefore calls render again on every already-mounted plugin with the same mount node. A plugin that appends nodes in render instead of replacing them will duplicate its content.

Track the last render inputs per plugin, or read the ref map untracked and drive the effect only from activePlugins(), theme(), and props.isOpen.

🐛 Sketch of a guarded render
+  const rendered = new Map<string, string>()
   createEffect(() => {
+    const key = `${theme()}:${props.isOpen}`
     for (const pluginId of activePlugins()) {
       const plugin = pluginById(pluginId)
-      const ref = pluginRefs().get(pluginId)
-      if (plugin && ref) {
+      const ref = untrack(() => pluginRefs().get(pluginId))
+      if (plugin && ref && rendered.get(pluginId) !== key) {
+        rendered.set(pluginId, key)
         plugin.render(ref, { theme: theme(), devtoolsOpen: props.isOpen })
       }
     }
   })

A newly mounted pane still needs one render, so keep a separate signal or effect keyed only by the pane ids.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/plugin-workspace.tsx` around lines 296 -
304, Update the createEffect render flow around pluginRefs() so registering or
unregistering one pane does not rerender every mounted plugin. Keep the ref
lookup untracked or otherwise guard each plugin with its last render inputs,
while still triggering renders for activePlugins(), theme(), and props.isOpen
changes and rendering newly mounted panes once.

Comment on lines +776 to +782
aria-valuenow={Math.round(
((handle().dir === 'row'
? handle().rect.left
: handle().rect.top) /
Math.max(handle().extent, 1)) *
100,
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

aria-valuenow is wrong for a nested split.

handle().rect.left and handle().rect.top are absolute positions in workspace coordinates. handle().extent is only the parent split's own usable extent. For a split that does not start at the workspace origin, the ratio exceeds the declared aria-valuemax of 100. Subtract the parent split's origin before dividing, or expose that origin on SplitterHandle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/plugin-workspace.tsx` around lines 776 -
782, Update the aria-valuenow calculation on SplitterHandle to normalize the
handle position relative to the parent split's origin before dividing by
handle().extent, using the horizontal origin for row direction and vertical
origin for column direction. Preserve the existing rounding and 0–100 scale.

Comment on lines +49 to +54
const setLayout = (next: LayoutNode | null) => {
setStore((previous) => ({
...previous,
state: { ...previous.state, layout: next },
}))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate all callers of replaceLayout and all layout write paths.
set -euo pipefail

echo '--- replaceLayout references ---'
rg -nP -C4 '\breplaceLayout\b' -g '*.ts' -g '*.tsx' || echo 'no matches'

echo '--- setLayout definitions and call sites ---'
rg -nP -C4 '\bsetLayout\b' -g '*.ts' -g '*.tsx' || echo 'no matches'

echo '--- direct store writes touching layout ---'
rg -nP -C4 "setStore\(\s*'state'|state:\s*\{[^}]*layout" -g '*.ts' -g '*.tsx' || echo 'no matches'

Repository: TanStack/devtools

Length of output: 199


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- tracked relevant files ---'
git ls-files | rg 'packages/devtools/src/(context/(use-devtools-context|devtools-context)|components/plugin-workspace)\.(ts|tsx)$' || true

echo '--- outline use-devtools-context.ts ---'
ast-grep outline packages/devtools/src/context/use-devtools-context.ts --view expanded || true

echo '--- outline devtools-context.tsx (focused symbols) ---'
ast-grep outline packages/devtools/src/context/devtools-context.tsx --match createDevtoolsContext --view expanded || true

echo '--- use-devtools-context.ts lines 1-140 ---'
cat -n packages/devtools/src/context/use-devtools-context.ts | sed -n '1,140p'

echo '--- devtools-context.tsx lines 220-290 ---'
cat -n packages/devtools/src/context/devtools-context.tsx | sed -n '220,290p'

echo '--- setLayout all references ---'
rg -n -C3 '\bsetLayout\b' packages/devtools/src || true

echo '--- plugin-workspace relevant sections ---'
rg -n -C2 'setLayout|setTabs|moveTab|activateTab|closeTab|resize|commitDrop' packages/devtools/src/components/plugin-workspace.tsx packages/devtools/src/components || true

Repository: TanStack/devtools

Length of output: 36289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- devtools-context exports and imports ---'
rg -n -C2 'createDevtoolsContext|DevtoolsContext|setStore|replaceLayout' packages/devtools/src/context packages/devtools/src -g '*.ts' -g '*.tsx' || true

echo '--- plugin-workspace layout mutation call sites focused ---'
python3 - <<'PY'
from pathlib import Path
p = Path('packages/devtools/src/components/plugin-workspace.tsx')
text = p.read_text()
for needle in ['commitDrop', 'startSplitterDrag', 'startTabDrag', 'onReorder', 'onTransfer', 'onSelect', 'onClose']:
    idx = text.find(needle)
    print(f'--- {needle} at {idx} ---')
    if idx >= 0:
        start = max(text.rfind('\n\n', 0, idx - 1), 0)
        end = text.find('\n\n', idx + len(needle))
        print(text[start:end][:1200])
PY

echo '--- layout-tree closeTab implementation ---'
cat -n packages/devtools/src/utils/layout-tree.ts | sed -n '1,220p'

echo '--- package manifests for solid-js presence/version ---'
for f in package.json packages/devtools/package.json; do
  if [ -f "$f" ]; then
    echo "### $f"
    jq -r '.dependencies["solid-js"]? // .devDependencies["solid-js"]? // "solid-js not listed"' "$f"
  fi
done

Repository: TanStack/devtools

Length of output: 35365


Route plugin layout writes through replaceLayout.

setLayout still mutates state.layout by partial store spread, and PluginWorkspace routes tab reorder, transfer, select, close, drop, split, and resize writes through it. replaceLayout has the documented reconcile tree write, but createPlugins() does not consume it. Move replaceLayout into the plugin context and use it from setLayout so closing the last panes in a split can actually collapse.

📍 Affects 2 files
  • packages/devtools/src/context/use-devtools-context.ts#L49-L54 (this comment)
  • packages/devtools/src/context/devtools-context.tsx#L258-L272
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/context/use-devtools-context.ts` around lines 49 - 54,
Route layout updates through replaceLayout rather than directly spreading
state.layout. In packages/devtools/src/context/use-devtools-context.ts:49-54,
update setLayout to call the context’s replaceLayout implementation; in
packages/devtools/src/context/devtools-context.tsx:258-272, expose or consume
replaceLayout in the plugin context so createPlugins() uses the reconciled tree
write and split panes can collapse when emptied.

Comment on lines +220 to +224
it('is a no-op for an unknown group or tab', () => {
const tree = group('g0', ['a'])
expect(moveTab(tree, 'a', 'gX', 0)).toEqual(tree)
expect(moveTab(tree, 'zz', 'g0', 0)).toEqual(group('g0', ['zz', 'a'], 0))
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect moveTab to confirm whether an absent tab id is inserted or ignored.
fd -t f 'layout-tree.ts' packages | xargs -r ast-grep run --lang typescript \
  --pattern 'export const moveTab = $$$'
fd -t f 'layout-tree.ts' packages | xargs -r rg -n -A 30 'export const moveTab'

Repository: TanStack/devtools

Length of output: 4098


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg 'packages/devtools/src/utils/layout-tree\.(ts|test\.ts)$|createPlugins|activePlugins' || true

echo
echo "== layout-tree relevant sections =="
sed -n '1,90p' packages/devtools/src/utils/layout-tree.ts
sed -n '220,295p' packages/devtools/src/utils/layout-tree.ts

echo
echo "== test section =="
sed -n '200,235p' packages/devtools/src/utils/layout-tree.test.ts

echo
echo "== createPlugins references =="
rg -n "createPlugins|activePlugins|openPlugins|moveTab" packages/devtools/src -g '*.ts' -g '*.tsx'

Repository: TanStack/devtools

Length of output: 13878


Split the no-op cases for unknown move_tab targets.

moveTab(tree, 'zz', 'g0', 0) inserts zz into g0, but the test title says an unknown tab must be a no-op. Split this into two cases: unknown group returns unchanged; unknown tab inserts zz. This keeps the expected behavior explicit and avoids passing a stale tab id through setLayout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/utils/layout-tree.test.ts` around lines 220 - 224,
Split the combined test around moveTab into separate cases: keep the
unknown-group assertion verifying the tree remains unchanged, and add an
unknown-tab case explicitly asserting that tab "zz" is inserted into group "g0".
Update the test descriptions to match these distinct behaviors.

@AlemTuzlak
AlemTuzlak merged commit a46d1f5 into main Aug 10, 2026
11 checks passed
@AlemTuzlak
AlemTuzlak deleted the codex/tanstack-devtools-workbench branch August 10, 2026 12:12
@github-actions github-actions Bot mentioned this pull request Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants