feat(studio): header and inspector tabs on the shared primitives - #3624
Conversation
eddcb73 to
e556a20
Compare
e556a20 to
2e5e013
Compare
ef794b1 to
d104075
Compare
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.
d104075 to
22ddd2f
Compare
terencecho
left a comment
There was a problem hiding this comment.
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: swapPanelTabButton→RightPanelTabs
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)
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,TooltipandTabs.Tabs. (Main no longer has the header view-mode toggle: the storyboard was removed, so this replay drops it.)IconButton; Inspector becomes aButton; Export becomes the primaryButtonat the medium size.<a download>and wearsButton's class recipe instead of a hand-written one.PanelTabButtonis deleted and replaced byRightPanelTabs, which owns the whole strip.StudioHeader.tsxdrop to zero. The ratchet baseline is unchanged at the replay: the ratchet test passes.Why
Three of Studio's six tab strips render
aria-pressedbuttons that no arrow key reaches, and the inspector's was one of them (KTD7). Moving it onto the sharedTabsis an accessibility fix, not only a reskin: the strip gains a roving tabindex,ArrowLeft/ArrowRight, andHome/End, plus realtablistandtabroles.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:
downloadon an<a>is what saves the frame, so a<button>cannot do that job. Rather than giveButtona polymorphic escape hatch, its size map is exported beside the already-exportedbuttonBaseandbuttonVariants, and the link composes the same three strings. One source for the recipe, no new abstraction. Theenabled: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.
RightPanelTabstakes one descriptor per tab carrying theactiveflag 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 thatrounded-buttonandshadow-btn-primaryresolve 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
vitest run, forks capped at 4).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 byisTypingTargetand the playback-shortcut selector exactly as the old buttons were (KTD13).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 realtablist; every header control keeps its hotkey classification (KTD13).historyTooltipLabelunit tests: a disabled control gets no pointer events, so its tooltip wording is checked as a pure function rather than through the DOM.secondary: the recipe test fails namingbg-accent. Reverted.oxlint,oxfmt --check, workspace build, andfallow audit --base origin/main --fail-on-issues: all clean.PASS: studio loaded with schema-valid API fixtures and no runtime errors.Computed-style table
The only row that moves is the header's Export, and it moves onto the type scale:
Height and radius are unchanged, but they now come from
h-ctlandrounded-mdrather than a hand-writtenh-7. Every other row is identical between the two runs. The Renders Export still reads 11px; the unit that moves it onto the same mediumButtonbrings the two back into line.Not covered
<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.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)
StudioHeader.tsx(kept main's storyboard removal, dropped the view-mode toggle and its Tab imports; Export stays the primitive Button),hex-baseline.jsonandButton.test.tsx(took feat(studio): shared Button, IconButton, Tooltip and Tabs primitives #3622's side).ViewModeContextmock, since the toggle no longer exists.Before
Origin main on the left of each sheet, on the repo's own
swiss-gridexample.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.
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