diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7f26e63c..c2685202 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/design.tsx b/lib/src/components/design.tsx index 68cc3976..e10bd682 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 d86e449e..22d810c1 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -107,6 +107,49 @@ 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 }))); + // 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. + 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); +}); 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 f52c577d..f65a509c 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 ${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}

}
}