Skip to content

feat(studio): apply MDCMS design system to Studio surfaces#141

Merged
iipanda merged 2 commits into
mainfrom
feat/studio-redesign
May 9, 2026
Merged

feat(studio): apply MDCMS design system to Studio surfaces#141
iipanda merged 2 commits into
mainfrom
feat/studio-redesign

Conversation

@iipanda
Copy link
Copy Markdown
Collaborator

@iipanda iipanda commented May 8, 2026

Summary

  • Reskins every Studio admin surface — sidebar, dashboard, content overview, content list, schema browser, document editor — against the MDCMS design system (offwhite canvas, cobalt primary, lime accent, Space Grotesk display + Inter body + Geist Mono technical).
  • Adds an EmptyParagraphHint ProseMirror plugin so the design's "Type / to insert a component…" affordance only fires on the focused empty top-level paragraph and is rendered absolutely so the caret stays at column zero.
  • Adds keyboard navigation (//Enter/Tab/Esc) + mouse-hover sync to the slash component picker.
  • Document editor topbar gains a manual Save draft button that bypasses the 5 s auto-save debounce by calling the same persistence routine; the UNPUBLISHED CHANGES badge replaces the redundant Changed workflow pill and v{version} chip.
  • Updates docs/specs/SPEC-006-studio-runtime-and-ui.md to specify the new editor topbar contract (UNPUBLISHED CHANGES badge, Save draft, trailing action cluster), the canvas header (path chip + dashed frontmatter mono row), and the four-tab sidebar (Info / Properties / contextual Component / History) in normative terms.

Visual highlights

  • Sidebar — dark "ink" shell with the cube logomark, mono nav rows with read-only hint on Schema, admin section divider, account footer.
  • Dashboard — 36 px display heading, three mono-labelled stat cards, narrow Content types column with inline i18n badge + percent-published bar, wide Recently updated rail with truncated mono meta and hover affordance.
  • Content overview — per-type cards with letter mark, three-stat grid (Total/Published/Drafts), percent-published bar, browse → footer.
  • Content list (per type) — mono uppercase column headers, design-spec status pills (PUBLISHED, DRAFT, UNPUBLISHED CHANGES).
  • Schema page — split-pane master/detail with dashed registry strip (schemaHash, syncedAt, project, READ-ONLY IN STUDIO), kind chips, resolvedSchema (JSON) tab.
  • Document editor — sticky 56 px topbar over a flat 30×30 mono toolbar; centered max-880 canvas with path chip + dashed frontmatter mono row above the editor body; off-white writing surface against white control bars; redesigned slash picker with primary-tinted highlight + scroll-into-view.

Color tokens

  • Editor body uses bg-background (off-white in light, ink in dark) so the writing surface reads as canvas.
  • Topbar / toolbar / inspector sidebar use bg-card (white in light, card-grey in dark) as design controls.
  • Section dividers across the studio now use the visible border-border token consistently.

Test plan

  • bun run typecheck (studio only — workspace nx is out of sync from upstream core.system/domain.content modules unrelated to this change)
  • bun test --cwd packages/studio ./src — 588 pass / 1 unrelated pre-existing failure
  • Hard-refresh /admin and verify the dashboard layout, hover affordances, and stat-card spacing
  • /admin/content — overview cards render with i18n badge, percent bar, and browse → link
  • /admin/content/:type — table headers are mono uppercase; status pills render PUBLISHED / DRAFT / UNPUBLISHED CHANGES
  • /admin/schema — split-pane shows dashed registry strip + per-type kind chips + JSON tab
  • /admin/content/:type/:documentId — UNPUBLISHED CHANGES pill appears only when the doc has draft changes; Save draft ghost button persists immediately and disables when saved; toolbar pinned at top while body scrolls; canvas centered to ~880 px; path chip + dashed frontmatter row align with body width
  • In an empty top-level paragraph (focused), the slash hint appears and the caret stays at column zero; the hint disappears in lists, blockquotes, code blocks, and MDX wrappers
  • Press /↑/↓ walks the picker, Enter inserts, Tab inserts, Esc closes; mouse hover and keyboard cursor stay in sync
  • Inspector tabs read Info / Properties / History (Component appears only when an MDX node is selected); default tab is Properties

Summary by CodeRabbit

Release Notes

  • New Features

    • Added manual "Save draft" button to document editor
    • Component insertion hint ("Type / to insert a component…") now appears in empty paragraphs
    • Schema browser displays field kind and constraint indicators as inline badges
  • Style

    • Redesigned content listing status badges and admin dashboard layout
    • Updated document editor with canvas header showing path and metadata
    • Added "UNPUBLISHED CHANGES" indicator to document editor topbar
    • Improved schema browser UI with read-only marker and JSON viewer
    • Enhanced sidebar styling with project/environment information

Reskins the Studio admin shell, dashboard, content list/overview, schema
browser, and document editor against the MDCMS design system tokens
(offwhite canvas, cobalt primary, lime accent, Space Grotesk display +
Inter body + Geist Mono technical) introduced for the Studio runtime.

- Sidebar: dark "ink" shell with the cube logomark, mono nav rows, admin
  section divider, and account footer; existing routes preserved.
- Dashboard: 36px display heading, mono uppercase stat cards, narrower
  Content types column with i18n badge inline, Recently updated rail
  with one-line truncated mono meta, hover affordance with primary
  accent bar.
- Content overview: per-type cards with letter mark, total/published/
  drafts grid, percent-published bar, "browse →" footer.
- Content list (per type): mono uppercase column headers, design-aligned
  PUBLISHED / DRAFT / UNPUBLISHED CHANGES status pills.
- Schema page: split-pane master/detail with dashed registry strip
  (schemaHash, syncedAt, project, READ-ONLY IN STUDIO), kind chips, and
  resolvedSchema JSON tab.
- Document editor: design-spec topbar with UNPUBLISHED CHANGES pill,
  Save draft (manual save bypasses auto-save debounce), and Publish;
  flat 30x30 mono toolbar with primary-tinted active state; canvas
  header with path chip and dashed frontmatter mono row above the
  centered editor body; "Type / to insert a component…" hint scoped via
  a new EmptyParagraphHint plugin to the focused empty top-level
  paragraph and rendered absolutely so it does not push the caret;
  redesigned slash menu with keyboard navigation (↑/↓/Enter/Tab/Esc).
- Inspector: mono uppercase tab strip (Info / Properties / contextual
  Component / History) with primary bottom-border on the active tab.
- Tokens: ports the design's sidebar, divider, blue-100, vibrant-green,
  code-bg, and shadow-pop tokens into the runtime stylesheet, switches
  the editor body to bg-background and the controls (topbar/toolbar/
  inspector) to bg-card so the writing surface reads as the design's
  warm offwhite canvas while controls stay white.

SPEC-006 is updated to specify the new editor topbar contract (Save
draft, the UNPUBLISHED CHANGES badge, the trailing action cluster), the
canvas header (path chip + frontmatter mono row), and the four-tab
sidebar in normative terms.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements a comprehensive redesign of the Studio UI, focusing on improved editor affordances, refactored document workflows, and administrative interfaces. Changes include a new empty-paragraph hint extension for better component insertion, reorganized document editor pages with canvas headers and redesigned sidebars, a split-pane schema browser with field-level constraint chips, refactored dashboard with statistics cards, updated content list and overview pages with improved visual hierarchy, and an enhanced sidebar with session context integration. CSS theme tokens are extended for sidebar styling, and editor keyboard navigation is enhanced for the component picker.

Changes

Studio UI Redesign

Layer / File(s) Summary
Specification and Type Contracts
docs/specs/SPEC-006-studio-runtime-and-ui.md
Document editor topbar ordering, canvas header structure, sidebar tab layout, and save draft button contract defined. Props extended: TipTapEditorProps.canvasHeader, MdxComponentPickerProps.highlightedIndex, ContentDocumentPageViewProps.onSaveNow.
Editor Extensions
packages/studio/src/lib/editor-extensions.ts
New EmptyParagraphHint extension decorates focused empty top-level paragraphs with data-mdcms-empty-hint="true" to enable CSS-based insertion affordance display.
Editor Layout and Toolbar
packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
Toolbar button sizing updated to 30px square. Editor wrapper refactored for fixed toolbar with independently scrolling body. ProseMirror horizontal padding delegated to canvas wrapper. Canvas header rendered inside scrollable area.
Component Picker Keyboard Navigation
packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx, packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
Picker accepts highlightedIndex and onHighlightedIndexChange props. Editor manages highlight state with keyboard handlers for Arrow/Enter/Tab keys. Filtered catalog computed from slash query; highlight reset/clamped on picker open/list change. Removed kind badges.
Document Page Components
packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
New DocumentCanvasHeader component renders path chip and metadata row. New SidebarTabButton standardizes tab styling. SidebarInfoTab redesigned to show document metadata as monospace key/value pairs. Removed getDocumentWorkflowBadgeLabel helper.
Document Page Header and Actions
packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
Added conditional "UNPUBLISHED CHANGES" badge tied to hasUnpublishedChanges with data-mdcms-document-unpublished-changes attribute. New "Save draft" button calls onSaveNow callback. "Read-only" badge shown when document not writable.
Document Page Sidebar Tabs
packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
Sidebar refactored with SidebarTabButton helper. Tab rendering updated for Info, Properties, Component, and History tabs with correct ordering and active state. Sidebar content displayed based on active tab selection.
Document Page Editor Canvas
packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
Canvas header positioned inside scrollable area with DocumentCanvasHeader and alert banners. Save draft persistence guarded against viewing-version mode. Autosave effect similarly skips while viewing historical version.
Schema Browser
packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx, packages/studio/src/lib/remote-studio-app.tsx
New SchemaSplitPane with left type list and right detail panel. renderKindChip produces kind-specific inline chips. renderConstraintFlags displays required/optional, nullable, and descriptors as inline chips. JSON tab shows pretty-printed schema. Registry strip displays sync metadata and "READ-ONLY IN STUDIO" marker.
Dashboard Cards and Statistics
packages/studio/src/lib/runtime-ui/app/admin/page.tsx
New StatCard component for metrics with optional delta/tone. New ContentTypesCard with type links and published ratio progress. New RecentDraftsCard with recent document links and relative timestamps. formatRelativeTime updated to lowercase labels. Loading skeleton redesigned.
Content List Page
packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx
Status cells use uppercase labels ("PUBLISHED", "DRAFT", "UNPUBLISHED CHANGES") with new styling. Table rows navigate on click with actions cell stopping propagation. Header, cell padding, and typography updated.
Content Overview Page
packages/studio/src/lib/runtime-ui/pages/content-page.tsx
Cards redesigned with letter headers and progress bars for published percentage. CardStat component renders metric summaries. Aggregate statistics (total documents, localized types) computed and injected into header. Loading skeleton restructured.
Sidebar Navigation
packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx
Navigation refactored to typed NavItem model with optional hints. filterNav() gates main and admin items by permissions. SidebarNavLink helper centralizes active-route detection and tooltip rendering. Account display derives initials and name from session email.
Logo Styling
packages/studio/src/lib/runtime-ui/components/mdcms-logo.tsx
Logo label span removed text-foreground class.
CSS Theme and Editor Affordances
packages/studio/src/lib/runtime-ui/styles.css
New sidebar design-system variables (foreground-muted/faint, surface, border, accent) for light and dark modes. Editor paragraphs with data-mdcms-empty-hint="true" render ::before pseudo-element with "Type / to insert..." text. Tailwind theme extended with sidebar color tokens.
Configuration
apps/studio-example/lib/studio-example-studio-config.ts
resolveStudioExampleServerUrl() allows override via process.env.NEXT_PUBLIC_MDCMS_SERVER_URL in browser contexts; falls back to hostname-derived URL.
Tests
packages/studio/src/lib/runtime-ui/**/*.test.tsx
Assertions updated for new UI: document page sidebar tabs, unpublished changes badge, schema metadata tokens, MDX picker kind badge removal, content page locale badges, sidebar link data-active attribute.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • mdcms-ai/mdcms#99: Modifies Studio TipTap editor CSS and editor styling, directly related to editor presentation changes in this PR.
  • mdcms-ai/mdcms#124: Modifies packages/studio/src/lib/editor-extensions.ts and createEditorExtensions, directly related to the new EmptyParagraphHint extension added here.

Poem

🐰 A rabbit hops through the editor, so spry,
With hints of insertion beneath each shy eye.
The sidebar now whispers of drafts left unsaved,
Split panes display schemas, so beautifully paved.
From dashboard to content, the UI's been swayed—
A redesign so grand, by fine code we've made! 🎨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.32% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: applying the MDCMS design system to Studio surfaces across multiple admin pages and UI components.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/studio-redesign

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (2)
packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx (1)

584-594: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the unused context prop from SchemaPage.

SchemaPage is the only admin page that receives a context parameter, yet it immediately silences the unused variable warning with void context; and reads all its data from useStudioMountInfo() instead. All other admin pages (UsersPage, SettingsPage, TrashPage, etc.) follow the pattern of not taking context at all. Remove the parameter from the component signature at line 585 and the registration call in remote-studio-app.tsx line 89 to align with the established pattern.

🤖 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/studio/src/lib/runtime-ui/app/admin/schema-page.tsx` around lines
584 - 594, SchemaPage currently declares and silences an unused `context` prop;
remove the prop from the component signature and its type annotation, delete the
`void context;` line, and update any call sites that pass `context`
(specifically the registration call that mounts or registers SchemaPage) to stop
supplying that argument so SchemaPage matches other admin pages (e.g.,
UsersPage/SettingsPage patterns). Ensure the exported function is now `export
default function SchemaPage() { ... }` and adjust the registration invocation to
pass the component without a context parameter.
packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx (1)

32-67: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

useEffect must be moved above the early return — Rules of Hooks violation.

useRef is correctly placed at line 32 (before the early return), but useEffect at line 59 is placed after the conditional early return at line 34. Hooks must always be used at the top level of a React function, before any early returns. If components ever transitions from an empty array to a non-empty one (or vice versa) between renders — which is entirely possible if the MDX catalog loads asynchronously — React will throw "Rendered more/fewer hooks than during the previous render".

Move the useEffect (and keep useRef) above the early return, putting the guard condition inside the hook body instead:

🐛 Proposed fix
 export function MdxComponentPicker({ ... }) {
   const listRef = useRef<HTMLDivElement | null>(null);
+
+  // Scroll the highlighted row into view when the user arrows past the
+  // visible area of a tall component catalog.
+  useEffect(() => {
+    if (highlightedIndex === undefined) return;
+    const list = listRef.current;
+    if (!list) return;
+    const item = list.querySelector<HTMLElement>(
+      `[data-mdcms-mdx-picker-item-index="${highlightedIndex}"]`,
+    );
+    item?.scrollIntoView({ block: "nearest" });
+  }, [highlightedIndex]);
 
   if (components.length === 0) {
     return (
       <section
         data-mdcms-mdx-picker="catalog"
         data-mdcms-mdx-picker-state="empty"
         className="..."
       >
         No local MDX components registered.
       </section>
     );
   }
 
   const normalizedQuery = query.trim().toLowerCase();
   // ...filtered items...
 
-  // Scroll the highlighted row into view when the user arrows past the
-  // visible area of a tall component catalog.
-  useEffect(() => {
-    if (highlightedIndex === undefined) return;
-    const list = listRef.current;
-    if (!list) return;
-    const item = list.querySelector<HTMLElement>(
-      `[data-mdcms-mdx-picker-item-index="${highlightedIndex}"]`,
-    );
-    item?.scrollIntoView({ block: "nearest" });
-  }, [highlightedIndex]);
🤖 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/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx`
around lines 32 - 67, The useEffect that scrolls the highlighted row is declared
after the early return, causing a Rules of Hooks violation; move the useEffect
block (which references listRef and highlightedIndex) above the early return
that checks components.length === 0 so hooks are always called in the same
order, and inside the effect add the existing guards (if highlightedIndex ===
undefined return; if !list return) before querying
`[data-mdcms-mdx-picker-item-index="${highlightedIndex}"]` and calling
scrollIntoView; keep useRef(listRef) where it is and leave the early return for
rendering unchanged.
🧹 Nitpick comments (6)
packages/studio/src/lib/runtime-ui/pages/content-page.test.tsx (1)

136-141: ⚡ Quick win

Tighten subtitle assertion to validate computed summary values.

/content type/ is too permissive; it won’t catch regressions in totals/localized count formatting. Assert the full expected subtitle for this fixture.

Suggested fix
-  assert.match(markup, /content type/);
+  assert.match(markup, /2 content types · 10 documents · 1 localized/);
🤖 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/studio/src/lib/runtime-ui/pages/content-page.test.tsx` around lines
136 - 141, The assertion using assert.match(markup, /content type/) is too
loose; update the test in content-page.test.tsx to assert the full computed
subtitle string for the fixture instead of the generic regex—locate the
assertions that use assert.match(markup, /content type/) (and nearby i18n/locale
assertions) and replace the loose regex with an exact match of the expected
subtitle text (using the same markup variable and assert method), ensuring
totals and localized counts are validated precisely.
packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx (2)

502-527: ⚡ Quick win

Tabs lack ARIA tab semantics.

The two <button> triggers visually behave as a tablist but expose no role="tablist" / role="tab" / aria-selected / aria-controls, and the panel below has no role="tabpanel". Keyboard users and screen readers will hear two unrelated buttons and won't get arrow-key navigation. Adding the roles plus aria-selected={tab === "…"} is a small, contained improvement.

🤖 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/studio/src/lib/runtime-ui/app/admin/schema-page.tsx` around lines
502 - 527, The two buttons are acting as tabs but are missing ARIA roles and
attributes; wrap the button group div with role="tablist", give each button
role="tab" plus aria-selected={tab === "fields" || tab === "source"} and
aria-controls pointing to the corresponding panel id, and add matching id
attributes on the panel(s) with role="tabpanel" (e.g., id="fields-panel" and
id="source-panel"); keep using the existing tab state and setTab handler to
toggle aria-selected and the visible panel, and ensure focus/keyboard handling
uses the tab roles so arrow-key navigation works with the native button handlers
or by adding simple keydown handling if needed.

117-126: 💤 Low value

Replace hardcoded rgba() values with semantic tokens where they correspond.

The kind palette mixes design-system tokens (bg-vibrant-green, bg-code-bg) with hardcoded rgba(47,73,229,0.10) and text-[#516600] that bypass the token layer. However, note that not all hardcoded rgba values map exactly to defined tokens—only rgba(47,73,229,0.10) (line 118) and rgba(47,73,229,0.12) (line 169) match --primary at 10% and 12% opacity, which could become bg-primary/10 and bg-primary/12 in Tailwind v4. The text-[#516600] should be migrated to a semantic foreground token. Other rgba values may be intentional custom overrides; verify against the design system before replacing.

Minor: activeType state can become desynchronized when entries changes. While useMemo ensures the rendered entry stays correct, the state variable lags behind. Consider resetting or reconciling activeType when entries identity changes.

Minor: Tab buttons lack ARIA semantics. Lines 503–527 use <button> without role="tab", aria-selected, or tabpanel association. Consider adding these attributes for screen reader clarity.

Remove the unused context prop at line 594. The component receives context but ignores it, deriving all state from useStudioMountInfo(). The prop is passed by the caller at remote-studio-app.tsx; update both the component signature and the call site to remove 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/studio/src/lib/runtime-ui/app/admin/schema-page.tsx` around lines
117 - 126, KIND_CHIP_CLASSES contains hardcoded rgba and hex colors—replace
rgba(47,73,229,0.10) and rgba(47,73,229,0.12) with semantic Tailwind tokens
(e.g. bg-primary/10 and bg-primary/12) and swap text-[`#516600`] for the
corresponding semantic foreground token; leave intentional custom rgba values
alone after verifying with design. Also reconcile the activeType state with
changes to entries by resetting or syncing activeType whenever entries identity
updates (references: activeType, entries, and the useMemo'd entry). Add proper
ARIA semantics to the tab buttons (give each button role="tab", aria-selected,
and associate with a tabpanel) so screen readers can navigate the tabs. Finally
remove the unused context prop from the component signature and its caller (the
component derives state from useStudioMountInfo()), updating both the component
and remote-studio-app.tsx call site.
packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx (2)

279-290: ⚡ Quick win

bg-accent-subtle as a test oracle class is fragile — use a data-* attribute instead.

The comment acknowledges this is an "inert marker class" added only so that app-sidebar.test.tsx can assert active routing. This is problematic for two reasons:

  1. Potential visual regression: In Tailwind v4, the cascade order between bg-sidebar-accent (set in baseClasses) and bg-accent-subtle (appended after) is determined by the CSS output order, not the class attribute order. If Tailwind emits bg-accent-subtle after bg-sidebar-accent, the active link background is silently overridden to the subtle accent color instead of the intended sidebar accent.

  2. Coupling concerns: Styling classes carry semantic weight; using one purely as a test hook will confuse future readers and can break when the design token is renamed.

Prefer a semantically neutral data-active attribute:

♻️ Proposed fix
-  // Append `bg-accent-subtle` as an inert marker class on the active link.
-  // It's a no-op visually (the active background is bg-sidebar-accent above)
-  // but is asserted by app-sidebar.test.tsx to verify active routing under
-  // scenario base paths in the studio-review host.
-  const className = isActive ? `${baseClasses} bg-accent-subtle` : baseClasses;
+  const className = baseClasses;
   const link = (
-    <Link href={resolvedHref} className={className}>
+    <Link href={resolvedHref} className={className} data-active={isActive ? "true" : undefined}>

Then update app-sidebar.test.tsx to assert data-active="true" instead of the class.

🤖 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/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx` around
lines 279 - 290, Replace the inert test-only marker class usage: instead of
appending "bg-accent-subtle" to className when isActive, set a semantic data
attribute (e.g. data-active="true") on the element; update the code paths that
compute className (referencing baseClasses and className) to not rely on the
marker class and add the data-active attribute when isActive is true, and update
app-sidebar.test.tsx to assert data-active="true" (and remove/adjust the comment
about the inert marker class).

136-145: ⚡ Quick win

accountMeta ternary has dead logic — session condition never affects the output.

Both truthy branches of the ternary evaluate to mountInfo.project, making the session check in the first branch a no-op. The whole expression collapses to mountInfo.project ?? "MDCMS". If the intent was to include the user's identifier when a session is present (e.g., display the project + email domain), the value computation needs to be updated to actually use session.

♻️ Proposed simplification (if richer session metadata is not intended)
-  const accountMeta =
-    mountInfo.project && session
-      ? `${mountInfo.project}`
-      : mountInfo.project
-        ? mountInfo.project
-        : "MDCMS";
+  const accountMeta = mountInfo.project ?? "MDCMS";
🤖 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/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx` around
lines 136 - 145, The accountMeta ternary currently checks session but both
branches return mountInfo.project, so replace the expression to either (a)
simplify to use mountInfo.project ?? "MDCMS" if you don't intend to include
session info, or (b) if you do want session-specific metadata, construct a value
that actually uses session (for example `${mountInfo.project} •
${session.email}` or `${mountInfo.project} —
${deriveDisplayName(session.email)}`) — update the accountMeta assignment
accordingly (referencing the accountMeta and session variables and
mountInfo.project).
packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx (1)

2058-2080: ⚡ Quick win

Add tab semantics to the new sidebar tabstrip.

These controls now behave like tabs, but assistive tech only gets a row of generic buttons. Adding role="tablist", role="tab", aria-selected, and panel associations would make the active pane discoverable without changing the visual design.

Suggested fix
-function SidebarTabButton({
+function SidebarTabButton({
+  id,
+  controls,
   label,
   active,
   onClick,
 }: {
+  id: string;
+  controls: string;
   label: string;
   active: boolean;
   onClick: () => void;
 }) {
   return (
     <button
       type="button"
+      id={id}
+      role="tab"
+      aria-selected={active}
+      aria-controls={controls}
       onClick={onClick}
       className={cn(
         "flex-1 border-b-2 border-transparent py-2.5 text-center font-mono text-[11px] uppercase tracking-wider transition-colors",
         active
           ? "border-primary text-foreground"
           : "text-foreground-muted hover:text-foreground",
       )}
     >
       {label}
     </button>
   );
 }
-<div className="flex border-b border-border">
+<div role="tablist" aria-label="Document sidebar sections" className="flex border-b border-border">

Also applies to: 2519-2542

🤖 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/studio/src/lib/runtime-ui/pages/content-document-page.tsx` around
lines 2058 - 2080, Wrap the tab buttons container with role="tablist" and update
each SidebarTabButton to include role="tab", aria-selected={active},
aria-controls linking to the corresponding panel id, and set tabIndex={active ?
0 : -1} so only the active tab is in sequential focus; ensure the panel elements
use matching ids and include role="tabpanel" and aria-labelledby pointing back
to the tab id. Locate the SidebarTabButton component and the parent tabstrip
render (and the corresponding panel render around the pane content) to add the
attributes and id conventions (e.g., generate stable ids from the label or a
provided key) so assistive tech can discover the active pane without changing
visuals.
🤖 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/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx`:
- Around line 751-790: The Enter/Tab handlers are swallowing keystrokes even
when there is nothing to insert; update the handlers so they only consume the
key when there is an actionable item and the picker is actually visible.
Concretely, in the block gated by pickerSourceRef.current === "slash" add a
guard around the Enter and Tab handling that checks
filteredSlashComponentsRef.current.length > 0 and that the picker is visible
(e.g. verify slashPickerCoordsRef.current !== null or that slashTrigger/current
state exists) before reading slashHighlightIndexRef.current and calling
insertSelectedComponentRef.current; if the guard fails, do not return true so
the editor receives the key event. Also apply the same visibility+non-empty
guard to the Enter/Tab cases (ArrowUp/ArrowDown already check items.length).

In `@packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx`:
- Around line 2746-2758: The Save Draft button currently allows manual saves
while a historical version is open (state.viewingVersion), causing hidden
drafts; update the Button disabled logic and the shared save routine to prevent
any saves when state.viewingVersion is truthy: in the UI component (where the
Button uses state.saveState, state.publishState and onSaveNow) add a guard that
disables the button when state.viewingVersion is set, and then add the same
early-return guard inside the central save handler(s) (the onSaveNow entry-point
and the debounced autosave handler/shared save routine) so both manual and
autosave paths no-op when viewingVersion is active.

In `@packages/studio/src/lib/runtime-ui/pages/content-page.tsx`:
- Around line 44-45: Compute and clamp the published percentage to the 0–100
range immediately after it's derived so both the text and the progress bar use
the same safe value: replace the current publishedPercentage calculation (which
uses publishedCount and totalCount) with a clamped value (e.g., calculate
rawPercent = totalCount > 0 ? Math.round((publishedCount / totalCount) * 100) :
0, then publishedPercentage = Math.max(0, Math.min(100, rawPercent))). Update
any uses (the variable referenced where the text is rendered and the bar width
code around the block at lines ~107-112) to consume this single clamped
publishedPercentage. Ensure publishedCount/totalCount edge cases (published >
total or negative counts) are handled by the clamp.

---

Outside diff comments:
In `@packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx`:
- Around line 584-594: SchemaPage currently declares and silences an unused
`context` prop; remove the prop from the component signature and its type
annotation, delete the `void context;` line, and update any call sites that pass
`context` (specifically the registration call that mounts or registers
SchemaPage) to stop supplying that argument so SchemaPage matches other admin
pages (e.g., UsersPage/SettingsPage patterns). Ensure the exported function is
now `export default function SchemaPage() { ... }` and adjust the registration
invocation to pass the component without a context parameter.

In
`@packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx`:
- Around line 32-67: The useEffect that scrolls the highlighted row is declared
after the early return, causing a Rules of Hooks violation; move the useEffect
block (which references listRef and highlightedIndex) above the early return
that checks components.length === 0 so hooks are always called in the same
order, and inside the effect add the existing guards (if highlightedIndex ===
undefined return; if !list return) before querying
`[data-mdcms-mdx-picker-item-index="${highlightedIndex}"]` and calling
scrollIntoView; keep useRef(listRef) where it is and leave the early return for
rendering unchanged.

---

Nitpick comments:
In `@packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx`:
- Around line 502-527: The two buttons are acting as tabs but are missing ARIA
roles and attributes; wrap the button group div with role="tablist", give each
button role="tab" plus aria-selected={tab === "fields" || tab === "source"} and
aria-controls pointing to the corresponding panel id, and add matching id
attributes on the panel(s) with role="tabpanel" (e.g., id="fields-panel" and
id="source-panel"); keep using the existing tab state and setTab handler to
toggle aria-selected and the visible panel, and ensure focus/keyboard handling
uses the tab roles so arrow-key navigation works with the native button handlers
or by adding simple keydown handling if needed.
- Around line 117-126: KIND_CHIP_CLASSES contains hardcoded rgba and hex
colors—replace rgba(47,73,229,0.10) and rgba(47,73,229,0.12) with semantic
Tailwind tokens (e.g. bg-primary/10 and bg-primary/12) and swap text-[`#516600`]
for the corresponding semantic foreground token; leave intentional custom rgba
values alone after verifying with design. Also reconcile the activeType state
with changes to entries by resetting or syncing activeType whenever entries
identity updates (references: activeType, entries, and the useMemo'd entry). Add
proper ARIA semantics to the tab buttons (give each button role="tab",
aria-selected, and associate with a tabpanel) so screen readers can navigate the
tabs. Finally remove the unused context prop from the component signature and
its caller (the component derives state from useStudioMountInfo()), updating
both the component and remote-studio-app.tsx call site.

In `@packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx`:
- Around line 279-290: Replace the inert test-only marker class usage: instead
of appending "bg-accent-subtle" to className when isActive, set a semantic data
attribute (e.g. data-active="true") on the element; update the code paths that
compute className (referencing baseClasses and className) to not rely on the
marker class and add the data-active attribute when isActive is true, and update
app-sidebar.test.tsx to assert data-active="true" (and remove/adjust the comment
about the inert marker class).
- Around line 136-145: The accountMeta ternary currently checks session but both
branches return mountInfo.project, so replace the expression to either (a)
simplify to use mountInfo.project ?? "MDCMS" if you don't intend to include
session info, or (b) if you do want session-specific metadata, construct a value
that actually uses session (for example `${mountInfo.project} •
${session.email}` or `${mountInfo.project} —
${deriveDisplayName(session.email)}`) — update the accountMeta assignment
accordingly (referencing the accountMeta and session variables and
mountInfo.project).

In `@packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx`:
- Around line 2058-2080: Wrap the tab buttons container with role="tablist" and
update each SidebarTabButton to include role="tab", aria-selected={active},
aria-controls linking to the corresponding panel id, and set tabIndex={active ?
0 : -1} so only the active tab is in sequential focus; ensure the panel elements
use matching ids and include role="tabpanel" and aria-labelledby pointing back
to the tab id. Locate the SidebarTabButton component and the parent tabstrip
render (and the corresponding panel render around the pane content) to add the
attributes and id conventions (e.g., generate stable ids from the label or a
provided key) so assistive tech can discover the active pane without changing
visuals.

In `@packages/studio/src/lib/runtime-ui/pages/content-page.test.tsx`:
- Around line 136-141: The assertion using assert.match(markup, /content type/)
is too loose; update the test in content-page.test.tsx to assert the full
computed subtitle string for the fixture instead of the generic regex—locate the
assertions that use assert.match(markup, /content type/) (and nearby i18n/locale
assertions) and replace the loose regex with an exact match of the expected
subtitle text (using the same markup variable and assert method), ensuring
totals and localized counts are validated precisely.
🪄 Autofix (Beta)

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: 141ae5eb-fe56-4340-9d9b-37268887bfce

📥 Commits

Reviewing files that changed from the base of the PR and between 7788832 and 3757c32.

📒 Files selected for processing (18)
  • .changeset/studio-redesign-empty-paragraph-hint.md
  • apps/studio-example/lib/studio-example-studio-config.ts
  • docs/specs/SPEC-006-studio-runtime-and-ui.md
  • packages/studio/src/lib/editor-extensions.ts
  • packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/schema-page.test.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx
  • packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.test.tsx
  • packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx
  • packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
  • packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx
  • packages/studio/src/lib/runtime-ui/components/mdcms-logo.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-document-page.test.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-page.test.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-page.tsx
  • packages/studio/src/lib/runtime-ui/styles.css

Comment thread packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
Comment thread packages/studio/src/lib/runtime-ui/pages/content-page.tsx Outdated
- Editor slash picker: gate Enter/Tab on filteredSlashComponentsRef
  having items AND the picker actually being visible (slashTrigger +
  slashPickerCoords refs). When no item is actionable, return false so
  the editor receives the keystroke. ArrowUp/ArrowDown likewise no-op
  on an empty filtered list.
- Document editor: refuse to persist drafts while a historical version
  is being viewed. The Save draft button is disabled when
  state.viewingVersion is truthy, and both the autosave debounce and
  the central saveDraft routine early-return on the same condition so
  the manual button and the timer share one guard. "Restore this
  version" remains the only path to materialize a historical body as a
  new draft.
- Content overview cards: clamp the published percentage to [0, 100]
  immediately after deriving it so the "% published" label and the
  progress bar both consume the same safe value when upstream returns
  published > total or a negative count.
- SchemaPage: drop the unused `context` prop (`void context;` was the
  only use). The component already reads everything it needs from
  useStudioMountInfo(); update remote-studio-app.tsx to render
  <SchemaPage /> without an argument so it matches the
  Users/Settings/Promote pattern.
- MdxComponentPicker: move the scroll-into-view useEffect above the
  empty-catalog early return so hooks always fire in the same order
  (Rules of Hooks).
- AppSidebar: replace the inert `bg-accent-subtle` marker class on the
  active nav row with a semantic `data-active="true"` attribute and
  update app-sidebar.test.tsx to anchor on the attribute. The
  accountMeta ternary that returned mountInfo.project either way is
  collapsed to `mountInfo.project ?? "MDCMS"`.
- content-page test: replace the loose `/content type/` regex with the
  exact computed subtitle for the fixture.

Drop the changeset added in the previous commit — every code path
edited here lives under packages/studio/src/lib/runtime-ui/, which is
listed in the package's .changeset-gate.json `unpublishedSources` and
is served by the backend runtime, not exported through @mdcms/studio's
npm surface.

Skipped (nitpick / out of scope for this fix-up):
- ARIA tablist/tab/tabpanel roles on the Schema and editor sidebar
  tabs (a focused a11y pass; visuals unchanged either way).
- KIND_CHIP_CLASSES rgba/hex literals in schema-page (values are the
  exact design-system primary/olive tints).
@iipanda
Copy link
Copy Markdown
Collaborator Author

iipanda commented May 8, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx (1)

120-125: ⚡ Quick win

Replace bg-blue-100 with a semantic token to preserve dark-mode and theming correctness.

Every other color in this file uses semantic design-system tokens (bg-card, bg-accent-subtle, text-foreground, text-primary, border-border, etc.) that automatically adapt to theme changes and dark mode. bg-blue-100 is a hardcoded palette value that will render as a fixed light blue regardless of theme, which is inconsistent with the system and will break in a dark-mode context.

Using a single semantic token rather than two atomic tokens means the component adapts automatically — instead of needing both bg-blue-800 and dark:bg-blue-600, a single semantic alias handles both modes transparently.

Replace with either the existing bg-primary/10 tint (if the design token is defined that way) or a dedicated bg-accent / bg-primary-subtle semantic token consistent with the project's @theme definitions.

🎨 Proposed fix
-                  isHighlighted
-                    ? "bg-blue-100 text-foreground"
-                    : "text-foreground hover:bg-accent-subtle",
+                  isHighlighted
+                    ? "bg-primary/10 text-foreground"
+                    : "text-foreground hover:bg-accent-subtle",
🤖 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/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx`
around lines 120 - 125, The highlighted line uses a hardcoded color token
("bg-blue-100") inside the className built with cn for the MDX component picker;
replace that hardcoded palette class with the project's semantic design token
(e.g., "bg-primary/10" or a semantic alias like "bg-accent" or
"bg-primary-subtle") so the highlighted state adapts to dark mode and theming.
Update the conditional branch where isHighlighted is used in the className
expression in mdx-component-picker.tsx (the cn(...) call) to use the chosen
semantic token instead of "bg-blue-100" while preserving the existing
"text-foreground" text class.
packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx (1)

2001-2056: ⚡ Quick win

DocumentCanvasHeader — redundant cast and nullable-locale edge case

Two minor observations:

  1. Line 2012: (fm as Record<string, unknown>)[key]fm is already Record<string, unknown>, so the cast is a no-op and can be removed.
  2. Line 2033: fmEntries.push(["locale", state.locale]) always appends the locale, even when state.locale is the internal placeholder "__mdcms_default__" (used when a document has no real locale, see line 1340 for the inference path). This would display the raw placeholder string to the user in the canvas header.
✨ Proposed fix
-  const value = (fm as Record<string, unknown>)[key];
+  const value = fm[key];
-  fmEntries.push(["locale", state.locale]);
+  if (state.locale && state.locale !== "__mdcms_default__") {
+    fmEntries.push(["locale", state.locale]);
+  }
🤖 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/studio/src/lib/runtime-ui/pages/content-document-page.tsx` around
lines 2001 - 2056, Remove the unnecessary cast in pickValue by accessing fm
directly (replace (fm as Record<string, unknown>)[key] with fm[key]) and prevent
showing the internal placeholder locale by only pushing ["locale", state.locale]
into fmEntries when state.locale is defined and not equal to
"__mdcms_default__"; update DocumentCanvasHeader (pickValue, fmEntries
population) accordingly so the cast is removed and the placeholder locale is
skipped.
🤖 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/studio/src/lib/runtime-ui/pages/content-document-page.tsx`:
- Around line 2058-2081: The SidebarTabButton component and its surrounding
markup lack ARIA tab semantics; update SidebarTabButton to render the button
with role="tab", set aria-selected={active} and provide an id and aria-controls
that reference the matching panel id, ensure the container component
(ContentDocumentPageSidebar) that renders the tab buttons has role="tablist",
and update the corresponding panel render to use role="tabpanel" with
aria-labelledby pointing back to the tab id (and include proper id
generation/matching between SidebarTabButton and the panel). Ensure the active
tab has tabIndex=0 and inactive tabs have tabIndex=-1 so keyboard navigation
works.

---

Nitpick comments:
In
`@packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx`:
- Around line 120-125: The highlighted line uses a hardcoded color token
("bg-blue-100") inside the className built with cn for the MDX component picker;
replace that hardcoded palette class with the project's semantic design token
(e.g., "bg-primary/10" or a semantic alias like "bg-accent" or
"bg-primary-subtle") so the highlighted state adapts to dark mode and theming.
Update the conditional branch where isHighlighted is used in the className
expression in mdx-component-picker.tsx (the cn(...) call) to use the chosen
semantic token instead of "bg-blue-100" while preserving the existing
"text-foreground" text class.

In `@packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx`:
- Around line 2001-2056: Remove the unnecessary cast in pickValue by accessing
fm directly (replace (fm as Record<string, unknown>)[key] with fm[key]) and
prevent showing the internal placeholder locale by only pushing ["locale",
state.locale] into fmEntries when state.locale is defined and not equal to
"__mdcms_default__"; update DocumentCanvasHeader (pickValue, fmEntries
population) accordingly so the cast is removed and the placeholder locale is
skipped.
🪄 Autofix (Beta)

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: df9a309d-24fb-406d-ab9c-0367107db080

📥 Commits

Reviewing files that changed from the base of the PR and between 3757c32 and 57d6c86.

📒 Files selected for processing (9)
  • packages/studio/src/lib/remote-studio-app.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx
  • packages/studio/src/lib/runtime-ui/components/editor/mdx-component-picker.tsx
  • packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
  • packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.test.tsx
  • packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-page.test.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-page.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/studio/src/lib/runtime-ui/pages/content-page.tsx
  • packages/studio/src/lib/runtime-ui/components/layout/app-sidebar.tsx
  • packages/studio/src/lib/runtime-ui/pages/content-page.test.tsx
  • packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx

Comment on lines +2058 to +2081
function SidebarTabButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 border-b-2 border-transparent py-2.5 text-center font-mono text-[11px] uppercase tracking-wider transition-colors",
active
? "border-primary text-foreground"
: "text-foreground-muted hover:text-foreground",
)}
>
{label}
</button>
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

SidebarTabButton is missing ARIA tab semantics — screen readers cannot identify the tab pattern

<button type="button"> without role="tab" and aria-selected does not announce "tab, selected/not selected" to assistive technology. The container <div> wrapping the tab buttons also needs role="tablist", and the rendered panel needs role="tabpanel" with a matching aria-labelledby.

♿ Proposed fix
 function SidebarTabButton({
   label,
   active,
   onClick,
+  id,
+  panelId,
 }: {
   label: string;
   active: boolean;
   onClick: () => void;
+  id: string;
+  panelId: string;
 }) {
   return (
     <button
       type="button"
+      role="tab"
+      id={id}
+      aria-selected={active}
+      aria-controls={panelId}
+      tabIndex={active ? 0 : -1}
       onClick={onClick}
       className={cn(
         "flex-1 border-b-2 border-transparent py-2.5 text-center font-mono text-[11px] uppercase tracking-wider transition-colors",
         active
           ? "border-primary text-foreground"
           : "text-foreground-muted hover:text-foreground",
       )}
     >
       {label}
     </button>
   );
 }

And in ContentDocumentPageSidebar:

-      <div className="flex border-b border-border">
+      <div role="tablist" aria-label="Document sidebar" className="flex border-b border-border">
         <SidebarTabButton
           label="Info"
           active={activeTab === "info"}
           onClick={() => setActiveTab("info")}
+          id="sidebar-tab-info"
+          panelId="sidebar-panel"
         />
         ...
       </div>

-      <div className="flex-1 overflow-y-auto">
+      <div role="tabpanel" id="sidebar-panel" aria-labelledby={`sidebar-tab-${activeTab}`} className="flex-1 overflow-y-auto">

Also applies to: 2511-2568

🤖 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/studio/src/lib/runtime-ui/pages/content-document-page.tsx` around
lines 2058 - 2081, The SidebarTabButton component and its surrounding markup
lack ARIA tab semantics; update SidebarTabButton to render the button with
role="tab", set aria-selected={active} and provide an id and aria-controls that
reference the matching panel id, ensure the container component
(ContentDocumentPageSidebar) that renders the tab buttons has role="tablist",
and update the corresponding panel render to use role="tabpanel" with
aria-labelledby pointing back to the tab id (and include proper id
generation/matching between SidebarTabButton and the panel). Ensure the active
tab has tabIndex=0 and inactive tabs have tabIndex=-1 so keyboard navigation
works.

@iipanda iipanda merged commit 37ed44e into main May 9, 2026
5 of 6 checks passed
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