feat(studio): dock dividers and tabs work from the keyboard and side columns fit narrow windows - #4186
Conversation
terencecho
left a comment
There was a problem hiding this comment.
APPROVING at 35575487 — keyboard behaviors pinned by test, narrow-window clip is genuinely intentional (numbers derivable from source), standalone-mergeable
6 commits on main. Scope: 7 files, +564/−11. dockAccessibility.ts (+98, new), dockAccessibility.test.ts (+173, new), dockLayout.ts (+59/−6), dockLayout.test.ts (+138, new), Dock.tsx (+19/−2), Dock.test.tsx (+72/−3), dock.css (+5).
Keyboard behaviors — every claim has a test
Focusable sash separators. decorateSashes at dockAccessibility.ts:24 sets tabIndex=0, role="separator", aria-orientation="vertical"|"horizontal", aria-label="Resize columns"|"Resize rows". dockAccessibility.test.ts:44 pins all four attributes and asserts 2 vertical + 1 horizontal in the default layout.
Arrow-resize (16 / 64 with Shift). onSashKeyDown:52 matches the split axis, guards modifiers, preventDefault, calls nudgeSash with SASH_STEP=16 or SASH_STEP_SHIFT=64. Test at :66 presses ArrowRight (compositions +16), Shift+ArrowRight (+80 total = 16+64), ArrowLeft (+64 total = 80−16). Clamping test at :74 walks 20 shift-lefts / 40 shift-rights and asserts compositions.width == 200 (its min) and preview.width == 360 (preview floor). Off-axis / off-target keys ignored (:82).
Tab-wrap + activate. onTabKeyDown:71 maps ArrowRight/Left/Home/End to targets, calls api.getPanel(panelId)?.api.setActive() then target.focus(). Wrap logic: index === last ? 0 : index + 1 and index === 0 ? last : index - 1. Test at :135 presses ArrowRight from tab 0 (asserts active becomes assets, focus moves to tab 1), then ArrowLeft twice (wrap from tab 0 to tab 3 = catalog), then ArrowRight from tab 3 (wrap back to compositions). Home/End at :150.
Alt/Ctrl/Meta preserved. hasShortcutModifier returns true for those; both handlers early-return. Test at :110 sends the arrows with each modifier and asserts no width change on the sash + no activation change on the tab + defaultPrevented === false.
Rows too. Horizontal split's sash uses vertical arrows; test at :98 presses ArrowUp on the horizontal sash and asserts timeline.height + 16.
Narrow-window "clips by design" — verified as an explicit choice, not a leak
sideMinimumWidth at dockLayout.ts:17:
const fair = Math.floor((dockWidth - MIN_PREVIEW_W) / 2);
return Math.min(MIN_SIDE_W, Math.max(MIN_SIDE_W_FLOOR, fair));Where MIN_PREVIEW_W=360, MIN_SIDE_W=200, MIN_SIDE_W_FLOOR=120. Below 120 + 360 + 120 = 600 the sides can't shrink further without invading the preview's floor; the dock overflows. Test at dockLayout.test.ts:29 pins the exact table: [1680,200], [1200,200], [860,200], [760,200], [700,170], [600,120], [560,120], [400,120]. The 700-row 170 is the transition point where fair = (700-360)/2 = 170.
Compare to old code that dropped: Math.max(MIN_SIDE_W, ...) clamped side minimums at 200, so a 560px viewport pushed the right column entirely off-screen (120+360+280 = 760 > 560). The screenshots + before/after in the PR body corroborate. Genuine intentional design decision, not a masked regression — the alternative (shrinking preview below 360) would make the render canvas unusable.
applySideMinimums:36 is idempotent: rewrites every group's minimum every call, so a group that stops holding the preview or gains the timeline can never keep a stale value (test at :117 and :125 verify this specifically). Called on: onReady, onDidAddPanel, onDidMovePanel, and ResizeObserver on window resize (Dock.tsx:143,148,152). The holdsPreview special-case also correctly handles preview being tabbed INTO a side group (:106).
Standalone-mergeable — verified
PR body: "Nothing is wired into Studio's shell yet, so this changes no visible Studio screen on its own; the dock only shows up once the migration PR lands." Grep confirms: installDockAccessibility and applySideMinimums are consumed only by Dock.tsx (already-existing component), which is not yet mounted in Studio's shell. The dock TESTS mount <Dock> directly. The migration PR adds the shell wiring. So this PR is a capability-add: no user-visible change, but the tests exercise the full behavior. Safe standalone merge.
a11y correctness
Sash separators get role, aria-orientation, aria-label per WAI-ARIA. Tabs keep dockview's role="tab" + aria-selected + tabIndex singleton (roving tabindex) — test at :127 pins [0,-1,-1,-1]. No focus traps: .focus() is only called on the destination target, no wrapper. Keyboard event listener at Dock.tsx on the .hf-dock root scope, not global.
Author-flagged limits (all fair):
- Divider focus style is
background-color: var(--dv-active-sash-color); outline: noneatdock.css:25— no explicit contrast check. Non-blocking but a real WCAG SC 1.4.11 gap for low-vision users; a:focus-visible outlinefallback would be a small follow-up. - Capture-phase ordering vs dockview's own arrow handler isn't tested (happy-dom doesn't run dockview's own listeners). Uses
addEventListener(..., true)forcapture: true, which is the correct primitive; runtime behaviour verified through the before/after screenshots. - The "reset user-widened column below 760px" tradeoff is called out in the body and by design.
Wiring soundness
Dock.tsx:onReady disposes the previous observer/subscription set (disposeRef.current() on line 133) BEFORE constructing new ones, so a project switch (which triggers a re-onReady) cannot leak listeners. disposeAccessibility() and resizeObserver.disconnect() are in the new disposeRef closure. Test Dock.test.tsx:212 proves the ResizeObserver actually watches the dock root (hf-dock class filter).
applySideMinimums(api, window.innerWidth) uses window width, not the dock's own box — Miguel documented this as an intentional trade because the dock's own box lags a resize. Works while the dock is windowfitting; if the migration ever places the dock in a smaller container the number would need to change. Known assumption, non-blocking.
CI
Build, Typecheck, Studio and player captures, Test: runtime contract, regression, Semantic PR title green. Test + Render on windows-latest pending. Stamping on code merit per [[feedback_ci_is_reference_not_gate]]; loop handles CI-settle.
Stamp mechanics
reviewDecision: REVIEW_REQUIRED → APPROVE closes the gate. hf-oss require_last_push_approval=true — binds to 35575487. Miguel miguel-heygen on trusted stamp list.
— Review by tai (pr-review)
…ide columns yield on narrow windows Every dockview sash is a focusable separator (role, aria-orientation, aria-label); arrow keys along the split axis resize the neighbouring groups by 16px (Shift 64), clamped by the panels' minimum sizes, by replaying a pointer drag so dockview's own clamping applies. Tab arrows now activate the tab they land on and wrap; Home/End jump to the first and last tab. Narrow-window rule: the preview keeps its 360x200 floor first; side groups' minimum width is clamp((viewport - 360) / 2, 120, 200) and they shrink to their defaults when it drops below 200. Below 600px the dock overflows and clips instead of squeezing the preview. Measured at 560px: before, the inspector column sat fully off-screen at x 560-760; after, columns are 120/360/120 and reachable.
…ves and is covered through the shell A side panel tabbed into the preview group no longer lowers the preview minimum width, moving a panel re-applies the side minimums, a project switch disposes the previous observer, and the dock shell tests now cover the sash and resize wiring.
A group that held the preview kept a stale side-column minimum after a drag, so the preview could shrink below its floor. Every group with a preview tab is now constrained to the preview minimum explicitly.
…eir meaning on tabs and sashes The side-fit now rewrites the minimum of every group each time, so a group that stopped holding the preview or gained the timeline never keeps a stale value. Alt, Ctrl and Meta arrows are left to the browser. The shell test now proves the resize observer watches the dock.
The sash decorator is internal, the bare dockview setup and the persisted-group lookup used by several dock tests live in one place each.
3557548 to
9683eb3
Compare
terencecho
left a comment
There was a problem hiding this comment.
RE-APPROVING at 9683eb3e — audit-fix delta is exactly as claimed (one export made private, test harness shared); product code byte-identical
Prior APPROVE at 35575487 invalidated by the rebase push (hf-oss require_last_push_approval=true). Head is 9683eb3e53958d97633d7a1a349d8fcbbc05aec9.
Author-scoped delta since 35575487
Merge-bases moved 8d072211 → 01aa446e — the new base is hf#3622's merge commit (which landed on main 5 min ago at 17:59Z). So the rebase-only churn is exactly the hf#3622 landing plus v0.8.51 release-cut noise.
Author commit list at HEAD: same 6 commits as PREV, rebased, plus one new commit:
9683eb3e5 refactor(studio): share the dock test harness and drop an unused export
Product code — unchanged, verified by blob-SHA parity
Files touched by both PREV and HEAD (author-scoped), compared at each head:
- IDENTICAL:
Dock.tsx,dock.css,dockLayout.ts - CHANGED:
dockAccessibility.ts,Dock.test.tsx,dockAccessibility.test.ts,dockLayout.test.ts
The one non-test file that changed:
dockAccessibility.ts — a single character-level edit:
-export function decorateSashes(root: HTMLElement) {
+function decorateSashes(root: HTMLElement) {Body unchanged. Grep at HEAD confirms decorateSashes is now internal-only: two hits, both inside dockAccessibility.ts (definition at :16, self-call at :79 inside installDockAccessibility). Nothing outside the module reaches for it. The audit finding was "this export has no non-test consumer" and the fix is exactly that. Doc comment even shifts to "The sash decorator is internal" in the commit message.
New file — genuine shared-harness extraction
dockTestHarness.ts (new, 11 lines):
export function mountBareDockview(): { host: HTMLElement; api: DockviewApi } {
const host = document.createElement("div");
document.body.append(host);
const api = createDockview(host, {
createComponent: () => ({ element: document.createElement("div"), init() {}, dispose() {} }),
});
return { host, api };
}Three tests were previously inlining this same setup; they now import it. Net commit is +30/−31 across 5 files: 3 test refactors + 1 line in dockAccessibility.ts + 11-line new harness file. Clean scope.
Tests now reach decorateSashes through the public installDockAccessibility entry point instead of directly, which was the audit's suggestion. Encapsulation strictly improves; test coverage is unchanged (same assertions, same layout, same key-events — the harness only replaces the mount plumbing).
Rebase-noise
.fallowrc.jsonc, bun.lock, packages/studio/package.json — new-base-only. package.json shows 0.8.50 → 0.8.51 and the dockview-react dep that hf#3622 already added (now the base declares it too).
CI
Head at 9683eb3e: 30 SUCCESS + 12 SKIPPED + 6 IN_PROGRESS, no FAILURE. IN_PROGRESS lanes are Windows render + CodeQL + CI Test/Smoke — fresh-push queue. Stamping on code merit per [[feedback_ci_is_reference_not_gate]]; the gate mechanic keeps CI honest (mergeStateStatus stays BLOCKED until each required lane lands).
Stamp mechanics
mergeStateStatus: BLOCKED, reviewDecision: REVIEW_REQUIRED. APPROVE binds to 9683eb3e53958d97633d7a1a349d8fcbbc05aec9. Miguel miguel-heygen on trusted stamp list.
— Review by tai (pr-review)
terencecho
left a comment
There was a problem hiding this comment.
RE-APPROVING at 32139fda — one-file comment shrink; product code byte-identical to 9683eb3e
Delta from prior approval 9683eb3e is exactly one commit and one file:
32139fda4 refactor(studio): shorten the side-minimum comment to one line
packages/studio/src/components/dock/dockLayout.ts | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
The change is a docstring shrink on applySideMinimums — 5-line paragraph → single sentence. Function body unchanged, no other file touched. The condensed comment ("Idempotent; rewrites every group's minimum, since dockview keeps a constraint once set.") preserves the two load-bearing invariants (idempotence + rewrite-every-group) that gate correctness. The details dropped (when-to-call sites, preview-vs-side branching, narrow-window shrink rationale) are all derivable from grep + reading the function body — no behavior claim now depends on the doc text alone.
Prior-approval reasoning at 9683eb3e (audit fix: decorateSashes made internal + dockTestHarness.ts shared harness + product code IDENTICAL to 35575487) carries forward unchanged.
Stamp mechanics
reviewDecision: REVIEW_REQUIRED (push invalidated 9683eb3e APPROVE). New APPROVE binds to 32139fda4abe4938ae64655fae2318d6e0aff823. Miguel miguel-heygen on trusted stamp list. Zero product-code delta since my last review.
— Review by tai (pr-review)
Follow-up to the dock engine. Nothing is wired into Studio's shell yet, so this changes no visible Studio screen on its own; the dock only shows up once the migration PR lands. It gives the dock two things the old fixed layout had.
What changes for a user of the dock
How it works
dockAccessibility.ts: dividers getrole="separator", orientation and a label; a nudge replays a pointer drag so the dock's own minimum-size clamping applies. dockview already sets the tablist and tab roles, so only the arrow behaviour is added.dockLayout.ts:applySideMinimumsrewrites every group's minimum width each time (the preview floor for any group holding the preview, the side minimum for the rest), because dockview keeps a constraint once set. It re-runs on window resize, on panel add and on panel move.Dock.tsx: wires both, and disposes the previous observer when the project (and so the dock) is replaced.Before
The fixed layout at a 560 px window: the right column is pushed entirely off-screen.
After
At 640 px, all three columns fit: the sides shrink to 120 px and the preview keeps 360 px.
At 560 px, below the boundary, the preview keeps its floor and the right column clips at the window edge, but stays reachable (it was fully off-screen before):
Focus on a divider, and the active tab after an arrow key:
Verification