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
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { useRef } from 'react';
import { ProhibitIcon, WarningOctagonIcon, XIcon } from '@phosphor-icons/react';
import { ProhibitIcon, WarningOctagonIcon } from '@phosphor-icons/react';
import type { DisplayMatchVerdict, ExternalUriDecision } from '../lib/external-links';
import {
ModalOverlay,
ModalSurface,
ModalCloseButton,
ModalFrame,
ModalReviewBlock,
modalActionButton,
modalIconButton,
useModalFocusTrap,
} from './design';

export interface ExternalLinkDialogRequest {
export interface ExternalLinkModalRequest {
uri: string;
displayText: string;
verdict: DisplayMatchVerdict;
Expand Down Expand Up @@ -41,16 +40,15 @@ function schemePrefix(scheme: string, uri: string): string {
return uri.slice(scheme.length + 1).startsWith('//') ? `${scheme}://` : `${scheme}:`;
}

export function ExternalLinkDialog({
export function ExternalLinkModal({
request,
onCancel,
onConfirm,
}: {
request: ExternalLinkDialogRequest;
request: ExternalLinkModalRequest;
onCancel: () => void;
onConfirm: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const primaryButtonRef = useRef<HTMLButtonElement>(null);
const secondaryButtonRef = useRef<HTMLButtonElement>(null);

Expand All @@ -63,111 +61,99 @@ export function ExternalLinkDialog({
? pickOpenButtonNoun(openableDecision.scheme, openableDecision.uri)
: 'URL';

useModalFocusTrap(dialogRef, {
// Deceptive case: focus the copy action so a default Enter doesn't dismiss
// silently. Everywhere else: focus the safe affordance (Cancel/Close).
initialFocusRef: isDeceptive ? primaryButtonRef : secondaryButtonRef,
onEscape: onCancel,
});

const handleCopy = () => {
void navigator.clipboard.writeText(request.uri);
onCancel();
};

return (
<ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6">
<ModalSurface
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="external-link-dialog-title"
elevation="modal"
className="w-full max-w-[34rem]"
>
<div className="flex items-start gap-3">
<h2
id="external-link-dialog-title"
className="min-w-0 flex-1 text-sm leading-5 text-foreground"
>
{isDeceptive ? (
<DeceptiveTitle displayText={request.displayText} />
) : blockedDecision ? (
<BlockedTitle reason={blockedDecision.reason} />
) : (
<OpenTitle verdict={verdict} displayText={request.displayText} />
)}
</h2>
<button
type="button"
aria-label="Close"
className={modalIconButton()}
onClick={onCancel}
>
<XIcon size={13} weight="bold" />
</button>
</div>

{/* Bordered nested box: explicit exception to the bg-only chrome rule
in DESIGN.md. The URL is the literal artifact the user is being
asked to scrutinize, and a framed box reads better than a bare
bg-shift in this high-stakes context. */}
<div className="mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded border border-border bg-app-bg px-2.5 py-2 text-sm leading-relaxed text-foreground">
{displayUri}
</div>

<div className="mt-4 flex justify-end gap-2 text-xs">
<ModalFrame
titleId="external-link-modal-title"
layer="critical"
backdrop="strong"
elevation="modal"
overlayClassName="px-4 py-6"
className="w-full max-w-[34rem]"
// Deceptive case: focus the copy action so a default Enter doesn't dismiss
// silently. Everywhere else: focus the safe affordance (Cancel/Close).
initialFocusRef={isDeceptive ? primaryButtonRef : secondaryButtonRef}
onEscape={onCancel}
>
<div className="flex items-start gap-3">
<h2
id="external-link-modal-title"
className="min-w-0 flex-1 text-sm leading-5 text-foreground"
>
{isDeceptive ? (
<>
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
>
Close
</button>
<button
ref={primaryButtonRef}
type="button"
onClick={handleCopy}
className={modalActionButton({ tone: 'primary' })}
>
Copy deceptive URL to clipboard
</button>
</>
) : openableDecision ? (
<>
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
>
Cancel
</button>
<button
ref={primaryButtonRef}
type="button"
onClick={onConfirm}
className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`}
>
{'Open '}{buttonNoun}
</button>
</>
<DeceptiveTitle displayText={request.displayText} />
) : blockedDecision ? (
<BlockedTitle reason={blockedDecision.reason} />
) : (
<OpenTitle verdict={verdict} displayText={request.displayText} />
)}
</h2>
<ModalCloseButton onClick={onCancel} />
</div>

{/* Bordered nested box: explicit exception to the bg-only chrome rule
in DESIGN.md. The URL is the literal artifact the user is being
asked to scrutinize, and a framed box reads better than a bare
bg-shift in this high-stakes context. */}
<ModalReviewBlock className="mt-3" wrap="breakAll">
{displayUri}
</ModalReviewBlock>

<div className="mt-4 flex justify-end gap-2 text-xs">
{isDeceptive ? (
<>
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`}
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
>
Close
</button>
)}
</div>
</ModalSurface>
</ModalOverlay>
<button
ref={primaryButtonRef}
type="button"
onClick={handleCopy}
className={modalActionButton({ tone: 'primary' })}
>
Copy deceptive URL to clipboard
</button>
</>
) : openableDecision ? (
<>
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
>
Cancel
</button>
<button
ref={primaryButtonRef}
type="button"
onClick={onConfirm}
className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`}
>
{'Open '}{buttonNoun}
</button>
</>
) : (
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`}
>
Close
</button>
)}
</div>
</ModalFrame>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { ExternalLinkDialog } from './ExternalLinkDialog';
import { ExternalLinkModal } from './ExternalLinkModal';
import {
clearExternalLinkConfirmation,
getExternalLinkConfirmationSnapshot,
subscribeExternalLinkConfirmation,
} from '../lib/external-link-confirmation';
import { getPlatform } from '../lib/platform';

export function ExternalLinkDialogHost({
export function ExternalLinkModalHost({
onKeyboardActiveChange,
}: {
onKeyboardActiveChange: (active: boolean) => void;
Expand Down Expand Up @@ -37,7 +37,7 @@ export function ExternalLinkDialogHost({
if (!pending) return null;

return (
<ExternalLinkDialog
<ExternalLinkModal
request={{
uri: pending.uri,
displayText: pending.displayText,
Expand Down
51 changes: 39 additions & 12 deletions lib/src/components/KillConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef } from 'react';
import { resolvePaneElement } from '../lib/spatial-nav';
import { ModalOverlay, ModalSurface, Shortcut } from './design';
import { ModalFrame, Shortcut } from './design';

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

Expand All @@ -18,10 +19,32 @@ export function randomKillChar(): string {
return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)];
}

export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCancel?: () => void; exit?: KillExit }) {
export function KillConfirmModal({
char,
onCancel,
exit,
targetElement,
}: {
char: string;
onCancel?: () => void;
exit?: KillExit;
targetElement?: HTMLElement | null;
}) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
return (
<ModalSurface padding="spacious" align="center" className={exit === 'shake' ? 'motion-safe:animate-shake-x' : undefined}>
<h2 className="text-base font-bold mb-3 text-foreground">Confirm kill</h2>
<ModalFrame
titleId="kill-confirm-title"
targetElement={targetElement}
padding="spacious"
align="center"
className={exit === 'shake' ? 'motion-safe:animate-shake-x' : undefined}
overlayClassName={exit === 'confirm' ? 'kill-overlay-confirm' : undefined}
initialFocusRef={cancelButtonRef}
onEscape={onCancel}
>
<h2 id="kill-confirm-title" className="text-base font-bold mb-3 text-foreground">
Confirm kill
</h2>
<div className="bg-app-bg py-2 px-6 rounded border border-border inline-block mb-2">
<span
className={`text-xl font-bold${exit === 'confirm' ? ' kill-letter-flash' : ''}`}
Expand All @@ -33,12 +56,17 @@ export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCanc
<div className="text-sm text-muted leading-relaxed grid grid-cols-[auto_auto] gap-x-2 justify-center">
<Shortcut className="justify-self-end">{char}</Shortcut>
<span className="justify-self-start">to confirm</span>
<button type="button" onClick={onCancel} className="contents group cursor-pointer">
<button
ref={cancelButtonRef}
type="button"
onClick={onCancel}
className="contents group cursor-pointer"
>
<Shortcut className="justify-self-end group-hover:text-foreground transition-colors">Esc</Shortcut>
<span className="justify-self-start group-hover:text-foreground transition-colors">to cancel</span>
</button>
</div>
</ModalSurface>
</ModalFrame>
);
}

Expand All @@ -49,12 +77,11 @@ export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: {
}) {
const panelEl = resolvePaneElement(paneElements.get(confirmKill.id));
return (
<ModalOverlay
<KillConfirmModal
char={confirmKill.char}
onCancel={onCancel}
exit={confirmKill.exit}
targetElement={panelEl}
className={confirmKill.exit === 'confirm' ? 'kill-overlay-confirm' : undefined}
>
<KillConfirmCard char={confirmKill.char} onCancel={onCancel} exit={confirmKill.exit} />
</ModalOverlay>
/>
);
}

4 changes: 2 additions & 2 deletions lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from 'dockview-react';
import 'dockview-react/dist/styles/dockview.css';
import { Baseboard } from './Baseboard';
import { ExternalLinkDialogHost } from './ExternalLinkDialogHost';
import { ExternalLinkModalHost } from './ExternalLinkModalHost';
import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm';
import {
clearSessionAttention,
Expand Down Expand Up @@ -773,7 +773,7 @@ export function Wall({
version={paneElementsVersion}
/>

<ExternalLinkDialogHost onKeyboardActiveChange={setDialogKeyboardActive} />
<ExternalLinkModalHost onKeyboardActiveChange={setDialogKeyboardActive} />

</div>
</DialogKeyboardContext.Provider>
Expand Down
Loading
Loading