Skip to content

feat(studio): header and inspector tabs on the shared primitives - #3624

Merged
miguel-heygen merged 5 commits into
mainfrom
feat/studio-u9-header-tabs-sweep
Sep 19, 2026
Merged

miguel-heygen merged 5 commits into
mainfrom
feat/studio-u9-header-tabs-sweep

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Lands unit U9 (header and right-panel tabs on the shared primitives). Draft, stacked on the primitives PR (#3622); the token gate and ratchet (#3623) are already on main. Retarget to main when #3622 merges.

What

Lands U9, the vertical slice that validates the shared primitives against a real panel: Studio's header and the inspector's tab strip now use only Button, IconButton, Tooltip and Tabs.

  • The inspector's tab strip becomes real Tabs. (Main no longer has the header view-mode toggle: the storyboard was removed, so this replay drops it.)
  • Undo and Redo become IconButton; Inspector becomes a Button; Export becomes the primary Button at the medium size.
  • Capture stays an <a download> and wears Button's class recipe instead of a hand-written one.
  • PanelTabButton is deleted and replaced by RightPanelTabs, which owns the whole strip.
  • Colour literals in StudioHeader.tsx drop to zero. The ratchet baseline is unchanged at the replay: the ratchet test passes.

Why

Three of Studio's six tab strips render aria-pressed buttons that no arrow key reaches, and the inspector's was one of them (KTD7). Moving it onto the shared Tabs is an accessibility fix, not only a reskin: the strip gains a roving tabindex, ArrowLeft / ArrowRight, and Home / End, plus real tablist and tab roles.

Running one real panel through the primitives before the rest of the sweep starts (KTD14) is what proves the four APIs are usable. It found one gap, and it is fixed here rather than after five more panels have worked around it.

How

The gap the slice found. Capture is a download link: download on an <a> is what saves the frame, so a <button> cannot do that job. Rather than give Button a polymorphic escape hatch, its size map is exported beside the already-exported buttonBase and buttonVariants, and the link composes the same three strings. One source for the recipe, no new abstraction. The enabled: variant never matches a link, so the ghost variant's hover look is repeated unprefixed on the anchor, with a comment saying why.

The selected tab is derived, not stored. RightPanelTabs takes one descriptor per tab carrying the active flag the panel already computes and the callback it already had, and picks the selected value from those flags. No second owner for "which pane is open". When the panel shows something the strip has no tab for (block params), nothing reads selected, which is what the old buttons did.

Merge fix. Button.test.tsx's vacuity guard asserted that rounded-button and shadow-btn-primary resolve to nothing. The token branch added both as theme aliases, so on the merged tree the guard silently started passing against classes that now exist. It now names two classes inside real namespaces with no token behind them.

Test plan

  • Full Studio suite: 439 files, 4821 passed, 18 todo, 0 failed (vitest run, forks capped at 4).
  • New RightPanelTabs.test.tsx (6): ArrowRight moves Design to Layers and asks the panel to switch; Home and End jump; the on-screen pane is the selected tab; nothing is selected when the strip has no tab for the body; a second open pane in the legacy split inspector keeps the selected look; and the tabs are classified by isTypingTarget and the playback-shortcut selector exactly as the old buttons were (KTD13).
  • New StudioHeader.dom.test.tsx (6): Export carries the primary variant and the medium size, asserted against the exported recipe rather than a copied class string; Undo and Redo stay disabled on an empty history; Capture is still an <a download>; the toggle renders a real tablist; every header control keeps its hotkey classification (KTD13).
  • historyTooltipLabel unit tests: a disabled control gets no pointer events, so its tooltip wording is checked as a pure function rather than through the DOM.
  • Non-vacuity proven by flipping Export to secondary: the recipe test fails naming bg-accent. Reverted.
  • Typecheck, oxlint, oxfmt --check, workspace build, and fallow audit --base origin/main --fail-on-issues: all clean.
  • Load smoke run locally against a dev server on a free port: PASS: studio loaded with schema-valid API fixtures and no runtime errors.
  • Before and after capture plus computed-style table taken on the merge point and on this branch.

Computed-style table

The only row that moves is the header's Export, and it moves onto the type scale:

Control Height Radius Font size Background
Header Export, before 28px 6px 11px rgb(60, 230, 172)
Header Export, after 28px 6px 12px rgb(60, 230, 172)

Height and radius are unchanged, but they now come from h-ctl and rounded-md rather than a hand-written h-7. Every other row is identical between the two runs. The Renders Export still reads 11px; the unit that moves it onto the same medium Button brings the two back into line.

Not covered

  • The Renders panel body, the inspector and the timeline. Later units own those. The panel body's "Show Renders" button is still hand-styled.
  • The Renders Export. It stays at 11px until its own unit, so the two Exports differ by one type step in the meantime.
  • Re-clicking an already-selected tab. The old buttons ran their handler on every click, which in the legacy split inspector let a second click close that pane. A tab strip only fires on a change, so that affordance is gone. It was reachable only with the flat inspector disabled, and it was undiscoverable.
  • The header toggle's selected look changes. It was a light chip on grey; it is now the same dark chip the inspector strip uses, because both now come from one primitive. That unification is the point of the sweep, but it is a visible change.
  • Tooltips on disabled Undo and Redo. A disabled <button> receives no pointer events, so the tooltip does not open. That was true before this change too; the wording is covered by a unit test instead.
  • Hex outside StudioHeader.tsx. The standalone mark component still holds the brand gradient inline; it can read the same two tokens when its own unit touches it.

Replay onto current main (this head)

  • One own commit (header and inspector tabs) plus a comment trim for the comment gate, on top of feat(studio): shared Button, IconButton, Tooltip and Tabs primitives #3622 at 2e5e013.
  • Conflict hunks against both parents: StudioHeader.tsx (kept main's storyboard removal, dropped the view-mode toggle and its Tab imports; Export stays the primitive Button), hex-baseline.json and Button.test.tsx (took feat(studio): shared Button, IconButton, Tooltip and Tabs primitives #3622's side).
  • Tests: removed the view-mode tablist test and its ViewModeContext mock, since the toggle no longer exists.
  • Verified on a Linux runner: full Studio suite 469 files passed at the pre-fix head; components folder 156 files, 1704 tests passed after the fix; tsc 0; ratchet passes. Captures for the header and tabs follow when it is marked ready.

Before

Origin main on the left of each sheet, on the repo's own swiss-grid example.

Before and after: full window

Before and after: header cluster, Export hovered

Before and after: inspector tab strip

After

The right of each sheet, this branch. Keyboard: ArrowRight moves focus to the next tab with a teal ring; on main it does nothing.

Keyboard focus: before left, after right

Measured (same project, same 1600x900 viewport): Export height 28px both, radius 6px both, font 11px to 12px, background unchanged; tab strip radius 12px to 8px, height 32px both. Captured at 22ddd2f; the head differs only by a baseline JSON. The Bg Grid clip thumbnail differs in the full-window sheet because it loads asynchronously, not because of this PR.

Behaviour changes to know about

  • Arrow keys, Home and End now move between the inspector tabs (the old strip changed only on click). In the flat inspector an arrow key also selects the tab; in the legacy split inspector, where a tab click toggles a pane, an arrow key only moves focus (Enter or Space activates).
  • Capture, Inspector and Export text goes from 11px to 12px with the shared sizes.
  • Not covered: hover on the active Inspector button was not rendered, and the Capture click handler and capturing state have no unit test.

@miguel-heygen
miguel-heygen force-pushed the feat/studio-u4-primitives-button-tabs branch 8 times, most recently from eddcb73 to e556a20 Compare September 19, 2026 13:00
@miguel-heygen
miguel-heygen force-pushed the feat/studio-u4-primitives-button-tabs branch from e556a20 to 2e5e013 Compare September 19, 2026 16:15
@miguel-heygen
miguel-heygen force-pushed the feat/studio-u9-header-tabs-sweep branch from ef794b1 to d104075 Compare September 19, 2026 17:57
Base automatically changed from feat/studio-u4-primitives-button-tabs to main September 19, 2026 17:59
The header's view-mode toggle and the inspector's tab strip become real
Tabs, so both gain the roving tabindex, arrow keys and Home/End they never
had; undo, redo and Inspector become IconButton and Button; Export becomes
the primary Button at the medium size. Capture stays an <a download>,
wearing Button's recipe, because a button cannot save a file.

Colour literals in StudioHeader.tsx drop to zero: the brand gradient moves
into theme.css as two tokens and the Export's hand-written text colour
becomes text-bg-0. Ratchet total 475 to 470.
@miguel-heygen
miguel-heygen force-pushed the feat/studio-u9-header-tabs-sweep branch from d104075 to 22ddd2f Compare September 19, 2026 18:35
@miguel-heygen
miguel-heygen marked this pull request as ready for review September 19, 2026 19:16

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVING at 1f9a0262 — consumes only #3622 primitives (already on main); deletion clean; arrow-key-without-switch has mutation-red coverage

Base merge-base is 01aa446ed = hf#3622's merge commit on main. This PR does NOT depend on hf#4191 (which is separately stamped but not yet merged).

Dependency check — #4191 not required

Every primitive imported by this PR is from #3622 (Button, IconButton, Tooltip, Tabs family), all already on main:

  • StudioHeader.tsx: import { Button, buttonBase, buttonSizes, buttonVariants, cn, IconButton, Tooltip } from "./ui"
  • RightPanelTabs.tsx: import { Tab, Tabs, TabsList, Tooltip, cn } from "./ui"
  • StudioRightPanel.tsx: swap PanelTabButtonRightPanelTabs

Grep for imports of #4191's primitives (Menu, Popover, Input, NumberField, Select, Slider, Toggle) at HEAD: only SearchInput from ./ui/SearchInput (pre-existing, distinct file). Nothing from #4191. Safe to merge without waiting for #4191.

Scope

12 files, one deletion:

  • Modified (5): StudioHeader.tsx, StudioHeader.test.ts, StudioRightPanel.tsx, ui/Button.tsx, ui/index.ts
  • Added (3): RightPanelTabs.tsx, RightPanelTabs.test.tsx, StudioHeader.dom.test.tsx
  • Deleted (1): PanelTabButton.tsx
  • Style baseline (2): hex-baseline.json (total 423 → 418, strict decrease), theme.css (+2 brand-gradient tokens)
  • Deletion guard (1): scripts/check-no-main-deletions.mjs (+1 allowlist entry)

Button.tsx change is minimal: rename internal sizeStyles → exported buttonSizes (same object, +1 export; docstring names why: "the Capture download <a href> can wear the same recipe as a Button"). ui/index.ts gains one export to match. Zero behaviour change to existing consumers.

Deletion completeness — no dangling refs

PanelTabButton grep at HEAD across packages/**: zero hits. The RightPanelTabs replacement owns the tab strip that used to be a PanelTabButton array.

Deletion-guard entry in scripts/check-no-main-deletions.mjs:

[
  "packages/studio/src/components/PanelTabButton.tsx",
  "replaced by the shared Tabs primitive in RightPanelTabs; no remaining references",
],

Reason string matches reality. Per [[reference_deletion_pr_review_framework]] — 5 axes checked (refs / surfaces / allowlist / docs / visual); all clear.

Arrow-key-without-switching-split-inspector — mutation-red covered

The specific "arrow keys move focus between tabs without switching the split inspector" claim is pinned by RightPanelTabs.test.tsx:94:

it("only moves focus on an arrow key when selection would toggle a pane", async () => {
  const { host, selections } = mount(["design"], false);  // activateOnFocus = false
  act(() => tab(host, "design").focus());
  await arrow("ArrowRight");
  expect(document.activeElement).toBe(tab(host, "layers"));
  expect(selections).not.toHaveBeenCalled();  // ← this is the split-inspector guard
});

Mount takes an activateOnFocus flag (default true) representing the split-inspector mode. When false: arrow moves focus, selections callback is NOT invoked (no pane switch). The negative assertion .not.toHaveBeenCalled() is what makes this non-vacuous.

Complementary tests: :84 (default mode: arrow moves + activates), :104 (Home/End), :131 (second open pane stays open in the legacy split inspector), :141 (KTD13 hotkey classification — pairs the new tabs with the old buttons for typing-target parity, same pattern as hf#4191).

Hex ratchet strict decrease

hex-baseline.json: total 423 → 418 (−5). StudioHeader.tsx's row (was : 5) removed entirely — every color literal replaced by a token. theme.css adds the two new brand-gradient token definitions (theme.css is the source-of-truth file, not counted in the ratchet). Per [[feedback_ratchet_growth_is_prose_that_is_the_guard]] — decrease is the strict-improvement direction; the baseline update matches the actual code delta.

KTD14 vertical-slice property

The PR body's stated purpose beyond the reskin: "Running one real panel through the primitives before the rest of the sweep starts (KTD14) is what proves the four APIs are usable." The Button change (sizeStyles → exported buttonSizes) is exactly the shape-shift the extraction found — Capture download <a href> couldn't use Button directly and needed the recipe. Genuine API validation, not just a reskin.

CI

statusCheckRollup dedupe by latest run per context: 37 SUCCESS + 12 SKIPPED, zero FAILURE, zero IN_PROGRESS. All required lanes green.

Stamp mechanics

reviewDecision: REVIEW_REQUIRED, mergeStateStatus: BLOCKED. APPROVE binds to 1f9a026234211a1e4473b63f734e74bb96769224. Miguel miguel-heygen on trusted stamp list. Safe to stamp=merge; no #4191 dependency.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen merged commit 9bec64a into main Sep 19, 2026
49 checks passed
@miguel-heygen
miguel-heygen deleted the feat/studio-u9-header-tabs-sweep branch September 19, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants