Skip to content

feat: redesign TanStack Devtools workbench - #492

Open
AlemTuzlak wants to merge 5 commits into
mainfrom
codex/tanstack-devtools-workbench
Open

feat: redesign TanStack Devtools workbench#492
AlemTuzlak wants to merge 5 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 TanStack Workbench with branded light and dark themes.
    • Added header navigation, expandable plugin navigation, simultaneous plugin panes, Marketplace access, and resizable panels.
    • Added keyboard resizing, panel collapse controls, and improved Picture-in-Picture behavior.
    • Expanded accessibility support across controls, navigation, overlays, and accessibility issue reporting.
  • Documentation
    • Updated architecture, overview, branding, and React example documentation.
  • Tests
    • Added coverage for accessibility, themes, contrast, 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 introduces a branded, compact TanStack Devtools Workbench. It adds a shared semantic theme system with light/dark tokens and bundled fonts, replaces the Tabs/draw-context layout with a WorkbenchHeader, PluginsStrip, and secondary-tab primitives, integrates accessible theming into the a11y plugin, and adds extensive tests, documentation, and a font/design specification.

Changes

Semantic theming, accessibility, and Workbench redesign

Layer / File(s) Summary
Semantic theme and shared UI foundation
packages/devtools-ui/src/styles/semantic-theme.ts, packages/devtools-ui/src/internal.ts, packages/devtools-ui/src/components/*, packages/devtools-ui/src/assets/fonts/*, packages/devtools-ui/scripts/check-font-assets.mjs, packages/devtools-ui/tests/*, .changeset/*, docs/architecture.md (Mermaid/package refs)
Adds immutable light/dark semantic theme tokens, forced-colors and font-face CSS, a private /internal export, bundled font licenses and validation, and applies semantic attributes/styling across button, checkbox, input, select, tag, header, tree, logo, and theme-provider components.
Accessibility theme integration
packages/devtools-a11y/src/core/**, packages/devtools-a11y/tests/*, packages/devtools-a11y/vite.config.ts, examples/react/basic/**
Adds theme-aware severity styling, accessible issue-card selection, themed overlays/tooltips, and a development-only accessibility fixture plus audit-check script in the React basic example.
Workbench composition and interaction
packages/devtools/src/components/{content-panel,main-panel,plugins-strip,workbench-header,workbench-secondary-tabs,tab-content,trigger,source-inspector}.tsx, packages/devtools/src/context/*, packages/devtools/src/devtools.tsx, packages/devtools/src/styles/use-styles.ts, packages/devtools/src/tabs/**, packages/devtools/src/utils/*
Replaces Tabs/DrawClientProvider with a resizable Workbench header, plugin strip, secondary tabs, collapsible main panel, marketplace-aware tab content, and equal-width plugin panes, with PiP-aware theme ownership and semantic styling.
Validation, documentation, and integration coverage
docs/overview.md, docs/superpowers/specs/2026-07-31-tanstack-devtools-branding-design.md, packages/devtools/tests/*
Adds the branding/Workbench design specification, updated feature docs, and comprehensive Workbench, SEO, secondary-tab, and semantic-color usage tests.

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

Sequence Diagram(s)

sequenceDiagram
  participant Devtools
  participant WorkbenchHeader
  participant PluginsStrip
  participant PluginsTab
  participant Plugin
  Devtools->>WorkbenchHeader: render destinations and actions
  WorkbenchHeader->>Devtools: select Plugins or Marketplace
  Devtools->>PluginsStrip: render plugin tabs
  PluginsStrip->>PluginsTab: activate plugin
  PluginsTab->>Plugin: render active plugin pane
Loading

Possibly related PRs

  • TanStack/devtools#491: Both PRs update TanStack Devtools branding, but #491 adds README banner images to adapter packages while this PR changes in-product Workbench/UI branding.

Suggested reviewers: crutchcorn

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.82% 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 summarizes the primary change: a redesign of the TanStack Devtools workbench.
Description check ✅ Passed The description clearly covers the changes and validation, 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 feee9cd

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 34s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-04 13:43:24 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: feee9cd

@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 on lines +358 to +364
it('generates stable unique IDs while preserving explicit duplicate IDs', () => {
const generated = getExistingStateFromStorage(undefined, [
{ name: 'Same Name', render: () => {} },
{ name: 'Same Name', render: () => {} },
{ name: () => {}, render: () => {} },
]).plugins!.map((entry) => entry.id)
expect(generated).toEqual(['same-name-0', 'same-name-1', '2'])

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

Add the missing expected plugin ID.

Lines 359-363 pass four plugin definitions, and getExistingStateFromStorage maps every definition into plugins. generated therefore has four entries. Line 364 expects three entries, so this test fails before it checks uniqueness. Add the fourth expected ID and assert that the generated ID set has four entries.

🤖 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 358 -
364, The test case passes four plugin definitions to getExistingStateFromStorage
in the array, but the expect().toEqual() assertion on line 364 only includes
three expected IDs. Add the fourth expected ID to the assertion array to match
the number of plugins provided, so the test validates all four generated IDs
including the unnamed plugin that should generate an auto-incremented numeric
ID.

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant