From d96c4c4739db8b269c7db36aa69181defd8ecc4f Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:43:10 +0000 Subject: [PATCH 1/3] Keep detail-dialog buttons focusable while their action is in flight Promote, Save default and Discard and reset passed `busy` to native `disabled`. The browser blurs a disabled element, so activating one moved focus to ``; on the failure path the dialog stays open and focus never returns. The context's Escape and Tab handling both live on the `
`'s `onKeyDown` and need a focused descendant to bubble from, so a rejected edit left the `aria-modal` dialog with no Escape and no Tab trap. They now use the `busy` prop introduced in ca866516 for the launch button, which marks the button `aria-disabled` and drops its click while leaving it in the tab order. Two follow-ons from the same mechanism: `disabled:opacity-40` is unconditional again, since `busy` no longer feeds `disabled` and the ternary could only fire on a genuinely disabled button; and the `enabled:hover:*` interaction classes are withheld while busy, because `:enabled` no longer excludes a button that is dropping clicks. --- .../components/wall/TerminalContext.test.tsx | 26 +++++++++++++++++++ .../components/wall/TerminalContextView.tsx | 8 +++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index d86e449e0..506c2d082 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -107,6 +107,32 @@ it('keeps a failed promotion visible and retryable', async () => { props.onPromote = vi.fn(async () => { throw new Error('Placement failed'); }); render(); await click('Move this terminal into a new pane'); expect(container.querySelector('[role="alert"]')?.textContent).toBe('Placement failed'); expect(props.onClose).not.toHaveBeenCalled(); }); +it('keeps a submitting detail button focused so the dialog keeps Escape and its Tab trap', async () => { + let reject!: (reason: Error) => void; + props.onModify = vi.fn(() => new Promise((_resolve, fail) => { reject = fail; })); + props.initialDetail = 'modify'; render(); + const save = button('Save default'); + act(() => save.focus()); + await click('Save default'); + // In flight: inert via aria-disabled only, so the browser cannot blur it. + expect(save.disabled).toBe(false); + expect(save.getAttribute('aria-disabled')).toBe('true'); + expect(document.activeElement).toBe(save); + await click('Save default'); + expect(props.onModify).toHaveBeenCalledOnce(); + await act(async () => reject(new Error('Command rejected'))); + // The rejected edit keeps the dialog open, and focus never left the button. + expect(container.querySelector('[role="alert"]')?.textContent).toBe('Command rejected'); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(save.hasAttribute('aria-disabled')).toBe(false); + expect(document.activeElement).toBe(save); + // So the
's handlers still receive Tab and Escape from a focused descendant. + act(() => save.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))); + expect(container.querySelector('[role="dialog"]')!.contains(document.activeElement)).toBe(true); + act(() => (document.activeElement as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(props.onClose).not.toHaveBeenCalled(); +}); it('shows both directories in the mismatch warning', () => { props.mismatch = true; props.helperCwd = '~/other'; render(); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/other'); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/repo'); }); diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index f52c577d3..76cb039a2 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -34,8 +34,10 @@ export function ContextAction({ children, label, onClick, disabled = false, busy const windowFocused = useContext(WindowFocusedContext); // Native app launches can leave :hover stale until this window regains focus. const color = muted ? 'text-muted' : windowFocused ? SUBTLE_ACTION_COLOR_CLASS : SUBTLE_ACTION_REST_COLOR_CLASS; + // `busy` must never reach native `disabled`: the browser blurs a button the moment it is disabled, + // and this context's Escape and Tab handling both live on the
and need a focused descendant. return ; + className={`inline-flex h-6 shrink-0 items-center justify-center gap-1.5 rounded px-1.5 disabled:opacity-40 ${!busy && windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''} ${color}`}>{children}; } function ContextCopyAction({ children, label, onCopy }: { children: ReactNode; label: string; onCopy: () => Promise }) { @@ -178,7 +180,7 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{p.status === 'running' || p.status === 'waiting' ? : preserved || p.status === 'off' || p.status === 'unsupported' ? : }{status} {preserved || p.status === 'exited' ? setDetail('reset')}>Reset… : { setCommand(p.defaultCommand ?? p.command); setDetail('modify'); }}>Modify}
-
void submit(p.onPromote)}>Promote
+
void submit(p.onPromote)}>Promote
{p.mismatch &&
Helper directory differs from parent
Helper{p.helperCwd}Parent{p.cwd}
} {(p.warning || (!detail && error)) &&
{p.warning || error}
} @@ -186,7 +188,7 @@ export function TerminalContextView(p: TerminalContextViewProps) { {detail &&
setDetail(null)}>
e.stopPropagation()}>
{detail === 'title' ? 'Why this title?' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal?'} setDetail(null)} muted>
- {detail === 'title' ?
{p.titleSources.map((source, index) =>
{source.source}{source.value}{source.note}
)}
: detail === 'modify' ? <> setCommand(e.target.value)} maxLength={4096} placeholder="Leave empty to turn autorun off" className="w-full border-b border-input-border bg-input-bg px-2 py-1.5 outline-focus-ring" />

Global default. Applies to new and reset helpers. Leave empty to turn autorun off.

setDetail('reset')}>Reset helper… void submit(() => p.onModify(command))}>Save default
: <>

Discard this helper, including scrollback, unfinished input, and any running program? Unsaved edits will be lost.

A fresh helper starts in the parent's current directory using the global autorun default.

setDetail(null)}>Keep helper void submit(p.onReset)}>Discard and reset
} + {detail === 'title' ?
{p.titleSources.map((source, index) =>
{source.source}{source.value}{source.note}
)}
: detail === 'modify' ? <> setCommand(e.target.value)} maxLength={4096} placeholder="Leave empty to turn autorun off" className="w-full border-b border-input-border bg-input-bg px-2 py-1.5 outline-focus-ring" />

Global default. Applies to new and reset helpers. Leave empty to turn autorun off.

setDetail('reset')}>Reset helper… void submit(() => p.onModify(command))}>Save default
: <>

Discard this helper, including scrollback, unfinished input, and any running program? Unsaved edits will be lost.

A fresh helper starts in the parent's current directory using the global autorun default.

setDetail(null)}>Keep helper void submit(p.onReset)}>Discard and reset
} {error &&

{error}

}
} From 77f635e34a0210e516f32e9e885448d82da58518 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:57:36 +0000 Subject: [PATCH 2/3] Gate the subtle-action hover on aria-disabled instead of the call site The previous commit withheld SUBTLE_ACTION_INTERACTION_CLASS entirely while busy. That was wrong in both directions: - The constant's `focus-visible:outline` half carries no `enabled:` gate, so dropping the whole string also dropped the focus ring from every in-flight ContextAction -- including the button this PR exists to keep focused, and the `Opening...` launch button, which keeps its ring on main. - SUBTLE_ACTION_COLOR_CLASS carries `enabled:hover:text-link` and is applied unconditionally through `color`, so hovering an in-flight button still tinted it link-coloured. The hover withdrawal only ever covered the background. Both fall out of moving the gate into the constants: `:enabled` is the wrong test for "not accepting clicks" now that busy means `aria-disabled`, so the two hover rules become `enabled:not-aria-disabled:hover:*` and the call site goes back to keying only on window focus. Tailwind 4.3.3 compiles the variant to `:enabled:not([aria-disabled="true"]):hover`. OnOffSwitch is the only other consumer of either constant and never sets aria-disabled, so it is unaffected. A regression test pins both halves: every hover class on an in-flight button carries the aria-disabled gate, and the focus-ring classes survive the flight. Reverting either half turns it red. Also fixes the vacuous Tab assertion in the previous commit's test, which asserted the dialog contains document.activeElement while the line above had already pinned activeElement to a button inside it -- a no-op Tab handler passed. It now asserts focus moved off the button and stayed inside. It does not name the landing element: jsdom returns a comma-separated selector list grouped by selector rather than in document order, so the trap's wrap target under test differs from a real browser's. --- lib/src/components/design.tsx | 11 ++++++++--- lib/src/components/wall/TerminalContext.test.tsx | 15 +++++++++++++++ lib/src/components/wall/TerminalContextView.tsx | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 68cc3976e..e10bd6820 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -379,10 +379,15 @@ export const TextInput = forwardRef( */ export const UNDER_SWITCH_INDENT = 'ml-18'; -/** Quiet action tint and interaction treatment, shared by switches and context actions. */ +/** + * Quiet action tint and interaction treatment, shared by switches and context actions. + * Hover is gated on `not-aria-disabled` as well as `:enabled`, because a button that + * drops its clicks via `aria-disabled` (an in-flight `ContextAction`) is still `:enabled`. + * `focus-visible` is deliberately ungated: such a button keeps focus and must keep its ring. + */ export const SUBTLE_ACTION_REST_COLOR_CLASS = 'text-[color:color-mix(in_srgb,var(--color-link)_35%,var(--color-muted))]'; -export const SUBTLE_ACTION_COLOR_CLASS = `${SUBTLE_ACTION_REST_COLOR_CLASS} enabled:hover:text-link enabled:focus-visible:text-link`; -export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring'; +export const SUBTLE_ACTION_COLOR_CLASS = `${SUBTLE_ACTION_REST_COLOR_CLASS} enabled:not-aria-disabled:hover:text-link enabled:focus-visible:text-link`; +export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:not-aria-disabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring'; /** * The app's boolean control: compact track (off left, on right) and one state diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index 506c2d082..7d28010b2 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -128,11 +128,26 @@ it('keeps a submitting detail button focused so the dialog keeps Escape and its expect(document.activeElement).toBe(save); // So the
's handlers still receive Tab and Escape from a focused descendant. act(() => save.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))); + // Focus has to *move* and stay in: `contains` alone passes for a no-op Tab. Which element it + // lands on is not asserted — jsdom returns a selector list grouped by selector rather than in + // document order, so the trap's wrap target differs here from a real browser. + expect(document.activeElement).not.toBe(save); expect(container.querySelector('[role="dialog"]')!.contains(document.activeElement)).toBe(true); act(() => (document.activeElement as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); expect(container.querySelector('[role="dialog"]')).toBeNull(); expect(props.onClose).not.toHaveBeenCalled(); }); +it('keeps the focus ring on an in-flight button while withholding only its hover styling', async () => { + props.onModify = vi.fn(() => new Promise(() => {})); + props.initialDetail = 'modify'; render(); + const save = button('Save default'); + const rest = save.className; + await click('Save default'); + // Hover is gated on aria-disabled; focus-visible is not, so the focused button keeps its ring. + for (const hover of rest.split(' ').filter(c => c.includes('hover:'))) expect(hover).toContain('not-aria-disabled:'); + for (const ring of ['focus-visible:outline', 'focus-visible:outline-focus-ring', 'enabled:focus-visible:text-link']) expect(save.className.split(' ')).toContain(ring); + expect(save.className).toBe(rest); +}); it('shows both directories in the mismatch warning', () => { props.mismatch = true; props.helperCwd = '~/other'; render(); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/other'); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/repo'); }); diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 76cb039a2..f65a509cb 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -37,7 +37,7 @@ export function ContextAction({ children, label, onClick, disabled = false, busy // `busy` must never reach native `disabled`: the browser blurs a button the moment it is disabled, // and this context's Escape and Tab handling both live on the
and need a focused descendant. return ; + className={`inline-flex h-6 shrink-0 items-center justify-center gap-1.5 rounded px-1.5 disabled:opacity-40 ${windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''} ${color}`}>{children}; } function ContextCopyAction({ children, label, onCopy }: { children: ReactNode; label: string; onCopy: () => Promise }) { From c2e01f3e3731967e5784d6a1561854425833ef4d Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:07:26 +0000 Subject: [PATCH 3/3] State the in-flight hover rule in layout.md and pin the hover assertion The unfocused-hover rule in docs/specs/layout.md now also covers an in-flight action, which stays focusable and aria-disabled rather than disabled. The regression test's hover loop passed vacuously on an empty filter, so deleting the hover classes outright stayed green; it now asserts the filter is non-empty. --- docs/specs/layout.md | 2 +- lib/src/components/wall/TerminalContext.test.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7f26e63c3..c26852027 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -74,7 +74,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must tint the copyable Surface ref as an action and confirm each successful context copy in its button** with a checkmark and “Copied” for 1.4 seconds, preserving button width and keeping the context open. Failed copies show the action error without success feedback. -**Must suppress context action hover and focus highlights while the window is unfocused**, including after opening a native explorer or system browser. +**Must suppress context action hover and focus highlights while the window is unfocused**, including after opening a native explorer or system browser. **Must also withhold hover from an in-flight action, which stays focusable and `aria-disabled` rather than `disabled`** so the innermost disclosure keeps a focused descendant for Escape and Tab. **Must show “Opening…” with a spinner in the directory explorer button during launch and for at least 0.75 seconds**, preserving width and keyboard focus while blocking repeat clicks. Stop immediately on failure and show the action error. Respect reduced motion by keeping the spinner static. diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index 7d28010b2..22d810c13 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -144,7 +144,9 @@ it('keeps the focus ring on an in-flight button while withholding only its hover const rest = save.className; await click('Save default'); // Hover is gated on aria-disabled; focus-visible is not, so the focused button keeps its ring. - for (const hover of rest.split(' ').filter(c => c.includes('hover:'))) expect(hover).toContain('not-aria-disabled:'); + const hovers = rest.split(' ').filter(c => c.includes('hover:')); + expect(hovers.length).toBeGreaterThan(0); + for (const hover of hovers) expect(hover).toContain('not-aria-disabled:'); for (const ring of ['focus-visible:outline', 'focus-visible:outline-focus-ring', 'enabled:focus-visible:text-link']) expect(save.className.split(' ')).toContain(ring); expect(save.className).toBe(rest); });