Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ The source cwd is read from `getInheritableCwd(sourceId)`. **Never inherit a rem

### Kill confirmation

`x`/`k` (or the kill button, which first leaves passthrough) shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmModal`) with a random lowercase letter; typing it confirms the kill. **`x` and `k` are excluded from that alphabet** so a double-tap can't accept itself. `Escape`, the `Esc to cancel` button, and clicking another panel cancel; any other key runs a 400ms `shake-x` animation and then auto-dismisses.
`x`/`k` (or the kill button, which first leaves passthrough) shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmModal`) with a random lowercase letter (`cfg.killConfirm.char` pins it for visual snapshots); typing it confirms the kill. **`x` and `k` are excluded from that alphabet** so a double-tap can't accept itself. `Escape`, the `Esc to cancel` button, and clicking another panel cancel; any other key runs a 400ms `shake-x` animation and then auto-dismisses.

**Confirmation must be staged in a ref synchronously, not only in React state** — a second confirm keydown arriving before React flushes would otherwise pass the guard and kill twice (`lath.isDying` is the second line of defense).

Expand Down
2 changes: 2 additions & 0 deletions lib/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ if (visualSnapshot) {
// seconds after it appears, which a play function cannot outrun: whether it is
// still on screen at capture time depends on how loaded the runner is.
cfg.overlays.warningAutoDismissMs = 0;
// One kill-confirm letter rather than a random one per prompt.
cfg.killConfirm.char = 'q';
// Zero every CSS transition. Unlike the keyframe animations above, each of
// which has a static substitute, transitions are started by state that lands
// AFTER first paint — the primed-state decorator applies two rAFs in, which
Expand Down
6 changes: 6 additions & 0 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export const cfg = {
* Snapping straight to the final geometry removes that whole race. */
animate: true,
},
killConfirm: {
/** The letter a kill confirmation asks for; null draws one at random per
* prompt. Pinned under visual snapshots, where a random letter would
* change the capture on every run. */
char: null as string | null,
},
overlays: {
/** ms before the illegal-rename warning dismisses itself. 0 disables the
* timer entirely — what Chromatic uses, because a popover that removes
Expand Down
16 changes: 16 additions & 0 deletions lib/src/components/KillConfirm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cfg } from '../cfg';
import { randomKillChar } from './KillConfirm';

describe('randomKillChar', () => {
afterEach(() => { cfg.killConfirm.char = null; });

it('answers the pinned letter when one is set', () => {
cfg.killConfirm.char = 'q';
expect(Array.from({ length: 20 }, randomKillChar)).toEqual(Array(20).fill('q'));
});

it('draws a lowercase letter other than x or k when unpinned', () => {
for (let i = 0; i < 200; i++) expect(randomKillChar()).toMatch(/^[a-jl-wyz]$/);
});
});
3 changes: 2 additions & 1 deletion lib/src/components/KillConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef } from 'react';
import { resolvePaneElement } from './wall/resolve-pane-element';
import { ModalFrame, Shortcut, type ModalLayer } from './design';
import { cfg } from '../cfg';

export type KillExit = 'shake' | 'confirm';

Expand All @@ -16,7 +17,7 @@ export const KILL_CONFIRM_MS = 220;
// Excludes both kill shortcuts ('x' and 'k') so a double-tap can't accept itself.
const KILL_CONFIRM_CHARS = 'abcdefghijlmnopqrstuvwyz';
export function randomKillChar(): string {
return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)];
return cfg.killConfirm.char ?? KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)];
}

export function KillConfirmModal({
Expand Down
14 changes: 12 additions & 2 deletions lib/src/stories/RemoteControlSection.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
enrolledStatus,
OFFER_STATUS,
UNENROLLED_STATUS,
setupQrResult,
} from '../host/remote/test-burrow-link';
import { TEST_SETUP_PASSWORD } from '../remote/test-setup-password';

Expand All @@ -32,9 +31,18 @@ function RemoteControlStory() {
);
}

/** The clock every story here reads. A setup code encodes its expiry, so a real
* clock would draw a different QR on every run. */
const STORY_NOW = Date.UTC(2026, 0, 1);

const meta: Meta<typeof RemoteControlStory> = {
title: 'Modals/RemoteControlSection',
component: RemoteControlStory,
beforeEach: () => {
const realNow = Date.now;
Date.now = () => STORY_NOW;
return () => { Date.now = realNow; };
},
// Embedded in a docs page, each of these needs its own frame. The section
// reads a module-singleton store (`burrow-status-store.ts`: `state` is module
// scope, and the link is captured only when `listeners.size === 1`), so N
Expand Down Expand Up @@ -190,7 +198,9 @@ export const ConfirmingDisconnect: Story = {
*/
export const SetupPhoneQr: Story = {
parameters: {
primedBurrow: { status: enrolledStatus(), setupQr: setupQrResult() },
// No `setupQr`: the stub's default mints at request time, under the frozen
// clock, where one built here would read the real clock at import.
primedBurrow: { status: enrolledStatus() },
docs: { story: { height: '520px' } },
},
// The one setup-panel story that settles on the QR's accessible name rather
Expand Down
5 changes: 5 additions & 0 deletions lib/src/stories/Wall.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,5 +220,10 @@ export const TerminalContext: Story = {
const header = await requireElement('[data-pane-header-for="context-live"]', 'terminal header');
header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));
await waitForCondition(() => !!document.querySelector('[data-helper-terminal]'));
// Hold until autorun has finished and the helper has painted, so the capture
// is never the "Waiting for shell…" or "Running …" frame on the way there.
await waitForCondition(() =>
document.querySelector('[aria-label="Helper terminal status"]')?.textContent?.includes('autoran') ?? false);
await settleTerminals();
},
};
Loading