Resolves #14660
Ships line D2 — the reveal/dismiss interaction contract — on top of the D1 rail (stacked on agent/14654-autohide-rail-affordance, PR #14906; rebase onto dev + retarget follows its merge). The timing brain is DockRevealStateMachine, a pure, timer-injectable class with the full states/transitions table in JSDoc and named design constants (DWELL_MS, DISMISS_GRACE_MS; the 200/160ms slide durations stay in CSS where animation belongs). DockRail upgrades click from the D1 interim (direct restore) to the contract default — click = focused transient reveal, re-click dismisses — with hover-reveal strictly behind the workspace opt-in (autoHideRevealOnHover, dwell-gated, never steals focus), threaded through the adapter from projection options. DockRevealOverlay is the presentation host: edge-anchored, sized from the last committed split extent (new pure DockLayoutAdapter.resolveRevealExtent()) else a configurable fraction, translating DOM reality into semantic intents the machine consumes. The persist path is the overlay pin: setItemPinned(true) through the same reducer contract as every dock affordance — dismissal in every form emits nothing.
Evidence: L2 (unit contract specs — 190/190 dashboard suite green, incl. 13 machine specs on injected fake timers) → L2 required (close-target ACs are spec-checkable: state table, focus-hold, executor-routed pin, never-persist). Residual: none — live hover/focus DOM verification rides the Demo-A showcase + the reserved tour e2e (#14591).
AC map
- State machine documented; grace constants named →
DockRevealStateMachine JSDoc carries the states table and the transitions table; DWELL_MS / DISMISS_GRACE_MS are named statics, workspace-overridable via rail configs (revealDwellMs, revealDismissGraceMs).
- Focused pane never auto-dismisses → focus-hold specs at BOTH altitudes: machine (
focus-hold: a focused reveal never auto-dismisses) and rail↔overlay binding (bindRevealOverlay … focus-hold holds).
- Pin escape = executor op, no parallel path →
onRevealPinRequested → commitOperation({operation: 'setItemPinned', pinned: true}) → callback / DockZoneModel.applyOperation(); the model clears autoHidden itself (landed guard). Specs cover commit, policy block, and stale-projection executor rejection.
- Reveal state never persists → dedicated spec: a full reveal/dismiss cycle leaves the document byte-identical and fires zero document-change notifications.
- Cross-family review → requested after the stack rebases onto
dev (per stacked-PR review semantics).
Deltas from ticket
- ADR precedence applied to the ticket's hover framing: the ticket headlines "hover-reveal", but its own cited authority (the docking design record's interaction table) mandates click-reveal as DEFAULT with hover as an a11y-gated workspace opt-in. Both paths shipped; the default is click. The Demo-A S3 hover choreography runs on the opt-in config (flagged to the screenplay owner via A2A at intake).
- D1's disabled-tab treatment migrates to the overlay pin control: composing D1+D2 revealed that a
pinnable: false item with a disabled tab would become unreachable content — item loss. Reveal is policy-free; only PIN is policy-gated. The D1 tab-disable code and specs are superseded in this stack.
- Pointer-away grace applies to unfocused reveals only — focused reveals dismiss on focus-leave/Escape/outside-click per the contract; this is the focus-hold rule, made explicit in the state table.
- The pane-side collapse control (tuck a visible pane to its rail) is out of scope here — it lives with the pin-controls line (D4), not the rail's interaction layer.
- Pane hosting is a documented slot contract (
<overlay id>__pane-slot): the consuming workspace mounts/clears the revealed pane, same seam as resolveComponentRef — keeps the overlay pane-blind at this altitude.
Test Evidence
npm run test-unit -- test/playwright/unit/dashboard/
190 passed (30.9s)
New: DockRevealStateMachine.spec.mjs (13 — dwell, grace, focus-hold, retarget, fail-close, destroy, all on injected fake timers), DockRevealOverlay.spec.mjs (5 — visibility incl. grace window, sizing, pin policy mirror, intent translation). Rewritten: DockRail.spec.mjs (10 — click-reveal, never-persist, pin escape, policy, hover opt-in, overlay binding, fail-close). Extended: DockLayoutAdapter.spec.mjs (resolveRevealExtent cases + hover opt-in threading).
Post-Merge Validation
Commits
- dc64c419c — state machine + overlay host + rail upgrade + adapter extent resolver + specs
Process note: authored during an operator-granted temporary Fable 5 model window (2026-07-10).
Authored by Grace (Claude Fable 5, Claude Code). Session ef6b9a4a-54ec-4afb-8438-f89a3ee46ad2.
Resolves #14660
Ships line D2 — the reveal/dismiss interaction contract — on top of the D1 rail (stacked on
agent/14654-autohide-rail-affordance, PR #14906; rebase ontodev+ retarget follows its merge). The timing brain isDockRevealStateMachine, a pure, timer-injectable class with the full states/transitions table in JSDoc and named design constants (DWELL_MS,DISMISS_GRACE_MS; the 200/160ms slide durations stay in CSS where animation belongs).DockRailupgrades click from the D1 interim (direct restore) to the contract default — click = focused transient reveal, re-click dismisses — with hover-reveal strictly behind the workspace opt-in (autoHideRevealOnHover, dwell-gated, never steals focus), threaded through the adapter from projection options.DockRevealOverlayis the presentation host: edge-anchored, sized from the last committed split extent (new pureDockLayoutAdapter.resolveRevealExtent()) else a configurable fraction, translating DOM reality into semantic intents the machine consumes. The persist path is the overlay pin:setItemPinned(true)through the same reducer contract as every dock affordance — dismissal in every form emits nothing.Evidence: L2 (unit contract specs — 190/190 dashboard suite green, incl. 13 machine specs on injected fake timers) → L2 required (close-target ACs are spec-checkable: state table, focus-hold, executor-routed pin, never-persist). Residual: none — live hover/focus DOM verification rides the Demo-A showcase + the reserved tour e2e (#14591).
AC map
DockRevealStateMachineJSDoc carries the states table and the transitions table;DWELL_MS/DISMISS_GRACE_MSare named statics, workspace-overridable via rail configs (revealDwellMs,revealDismissGraceMs).focus-hold: a focused reveal never auto-dismisses) and rail↔overlay binding (bindRevealOverlay … focus-hold holds).onRevealPinRequested→commitOperation({operation: 'setItemPinned', pinned: true})→ callback /DockZoneModel.applyOperation(); the model clearsautoHiddenitself (landed guard). Specs cover commit, policy block, and stale-projection executor rejection.dev(per stacked-PR review semantics).Deltas from ticket
pinnable: falseitem with a disabled tab would become unreachable content — item loss. Reveal is policy-free; only PIN is policy-gated. The D1 tab-disable code and specs are superseded in this stack.<overlay id>__pane-slot): the consuming workspace mounts/clears the revealed pane, same seam asresolveComponentRef— keeps the overlay pane-blind at this altitude.Test Evidence
New:
DockRevealStateMachine.spec.mjs(13 — dwell, grace, focus-hold, retarget, fail-close, destroy, all on injected fake timers),DockRevealOverlay.spec.mjs(5 — visibility incl. grace window, sizing, pin policy mirror, intent translation). Rewritten:DockRail.spec.mjs(10 — click-reveal, never-persist, pin escape, policy, hover opt-in, overlay binding, fail-close). Extended:DockLayoutAdapter.spec.mjs(resolveRevealExtentcases + hover opt-in threading).Post-Merge Validation
dev+ retarget after PR #14906 merges; full CI on the merge candidate before cross-family approval.autoHideRevealOnHoverfor the S3 hover beat).observe_motion(#14591, reserved lane).Commits
Process note: authored during an operator-granted temporary Fable 5 model window (2026-07-10).
Authored by Grace (Claude Fable 5, Claude Code). Session ef6b9a4a-54ec-4afb-8438-f89a3ee46ad2.