diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkModal.tsx similarity index 50% rename from lib/src/components/ExternalLinkDialog.tsx rename to lib/src/components/ExternalLinkModal.tsx index c90cf3e6..0af20ef6 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkModal.tsx @@ -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; @@ -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(null); const primaryButtonRef = useRef(null); const secondaryButtonRef = useRef(null); @@ -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 ( - - -
- - -
- - {/* 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. */} -
- {displayUri} -
- -
+ +
+ + +
+ + {/* 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. */} + + {displayUri} + + +
+ {isDeceptive ? ( + <> - )} -
- - + + + ) : openableDecision ? ( + <> + + + + ) : ( + + )} +
+ ); } diff --git a/lib/src/components/ExternalLinkDialogHost.tsx b/lib/src/components/ExternalLinkModalHost.tsx similarity index 91% rename from lib/src/components/ExternalLinkDialogHost.tsx rename to lib/src/components/ExternalLinkModalHost.tsx index f7266906..f770aca5 100644 --- a/lib/src/components/ExternalLinkDialogHost.tsx +++ b/lib/src/components/ExternalLinkModalHost.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useSyncExternalStore } from 'react'; -import { ExternalLinkDialog } from './ExternalLinkDialog'; +import { ExternalLinkModal } from './ExternalLinkModal'; import { clearExternalLinkConfirmation, getExternalLinkConfirmationSnapshot, @@ -7,7 +7,7 @@ import { } from '../lib/external-link-confirmation'; import { getPlatform } from '../lib/platform'; -export function ExternalLinkDialogHost({ +export function ExternalLinkModalHost({ onKeyboardActiveChange, }: { onKeyboardActiveChange: (active: boolean) => void; @@ -37,7 +37,7 @@ export function ExternalLinkDialogHost({ if (!pending) return null; return ( - void; exit?: KillExit }) { +export function KillConfirmModal({ + char, + onCancel, + exit, + targetElement, +}: { + char: string; + onCancel?: () => void; + exit?: KillExit; + targetElement?: HTMLElement | null; +}) { + const cancelButtonRef = useRef(null); return ( - -

Confirm kill

+ +

+ Confirm kill +

{char} to confirm -
-
+ ); } @@ -49,12 +77,11 @@ export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: { }) { const panelEl = resolvePaneElement(paneElements.get(confirmKill.id)); return ( - - - + /> ); } - diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index c105f15f..b6b19ee7 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -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, @@ -773,7 +773,7 @@ export function Wall({ version={paneElementsVersion} /> - + diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 9cd64c13..e5576908 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -1,7 +1,8 @@ import { clsx } from 'clsx'; import { tv, type VariantProps } from 'tailwind-variants'; -import { forwardRef, useEffect, useLayoutEffect, useState } from 'react'; -import type { CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react'; +import { XIcon } from '@phosphor-icons/react'; +import { forwardRef, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react'; // App-wide type scale, color strategy, and chrome conventions: see // docs/specs/theme.md and AGENTS.md. @@ -62,6 +63,14 @@ export interface ModalRect { height: number; } +export const MODAL_LAYERS = { + app: 50, + pane: 100, + critical: 9999, +} as const; + +export type ModalLayer = keyof typeof MODAL_LAYERS; + export const modalOverlay = tv({ base: 'flex items-center justify-center', variants: { @@ -83,6 +92,7 @@ export const modalSurface = tv({ base: 'rounded-lg border border-border bg-surface-raised font-mono text-foreground shadow-lg', variants: { padding: { + none: 'p-0', compact: 'p-3', default: 'p-4', spacious: 'px-6 py-4', @@ -92,11 +102,11 @@ export const modalSurface = tv({ center: 'text-center', }, elevation: { - dialog: 'shadow-lg', + raised: 'shadow-lg', modal: 'shadow-2xl', }, }, - defaultVariants: { padding: 'default', align: 'start', elevation: 'dialog' }, + defaultVariants: { padding: 'default', align: 'start', elevation: 'raised' }, }); export type ModalSurfaceVariants = VariantProps; @@ -114,10 +124,75 @@ export const modalActionButton = tv({ export type ModalActionButtonVariants = VariantProps; +export const modalReviewBlock = tv({ + base: 'block rounded border border-border bg-app-bg font-mono text-foreground whitespace-pre-wrap', + variants: { + density: { + compact: 'p-2 text-xs', + default: 'px-2.5 py-2 text-sm leading-relaxed', + }, + overflow: { + short: 'max-h-32 overflow-auto', + medium: 'max-h-40 overflow-auto', + }, + wrap: { + breakAll: 'break-all', + breakWords: 'break-words', + }, + }, + defaultVariants: { + density: 'default', + overflow: 'medium', + wrap: 'breakWords', + }, +}); + +export type ModalReviewBlockVariants = VariantProps; +export type ModalReviewBlockProps = HTMLAttributes & ModalReviewBlockVariants; + +export function ModalReviewBlock({ + density, + overflow, + wrap, + className, + ...props +}: ModalReviewBlockProps) { + return ( +
+ ); +} + export const modalIconButton = tv({ base: 'shrink-0 rounded p-0.5 text-muted transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring', }); +export type ModalCloseButtonProps = ButtonHTMLAttributes; + +export const ModalCloseButton = forwardRef( + function ModalCloseButton({ + children, + className, + type = 'button', + ...props + }, ref) { + const ariaLabel = props['aria-label'] ?? 'Close'; + return ( + + ); + }, +); + export function useMeasuredElementRect(element: HTMLElement | null): ModalRect | null { const [rect, setRect] = useState(null); @@ -153,16 +228,19 @@ export function useMeasuredElementRect(element: HTMLElement | null): ModalRect | export function ModalOverlay({ children, targetElement, - zIndex = 100, + layer = 'pane', + zIndex, backdrop = 'standard', className, style, ...props }: HTMLAttributes & ModalOverlayVariants & { targetElement?: HTMLElement | null; + layer?: ModalLayer; zIndex?: number; }) { const rect = useMeasuredElementRect(targetElement ?? null); + const resolvedZIndex = zIndex ?? MODAL_LAYERS[layer]; const overlayStyle: CSSProperties = rect ? { position: 'fixed', @@ -170,10 +248,10 @@ export function ModalOverlay({ left: rect.left, width: rect.width, height: rect.height, - zIndex, + zIndex: resolvedZIndex, ...style, } - : { zIndex, ...style }; + : { zIndex: resolvedZIndex, ...style }; return (
(functi ); }); +export type ModalFrameProps = HTMLAttributes & ModalSurfaceVariants & { + titleId: string; + targetElement?: HTMLElement | null; + layer?: ModalLayer; + backdrop?: ModalOverlayVariants['backdrop']; + overlayClassName?: string; + initialFocusRef?: RefObject; + onEscape?: () => void; +}; + +export function ModalFrame({ + children, + titleId, + targetElement, + layer, + backdrop, + overlayClassName, + initialFocusRef, + onEscape, + padding, + align, + elevation, + className, + ...props +}: ModalFrameProps) { + const surfaceRef = useRef(null); + useModalFocusTrap(surfaceRef, { initialFocusRef, onEscape }); + + return ( + + + {children} + + + ); +} + const MODAL_FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', @@ -216,8 +346,8 @@ const MODAL_FOCUSABLE_SELECTOR = [ '[tabindex]:not([tabindex="-1"])', ].join(','); -export function useModalFocusTrap( - dialogRef: RefObject, +function useModalFocusTrap( + modalRef: RefObject, { initialFocusRef, onEscape, @@ -232,8 +362,8 @@ export function useModalFocusTrap { const handleKeyDown = (event: KeyboardEvent) => { - const dialog = dialogRef.current; - if (!dialog) return; + const modal = modalRef.current; + if (!modal) return; if (event.key === 'Escape') { if (onEscape) { @@ -246,7 +376,7 @@ export function useModalFocusTrap(MODAL_FOCUSABLE_SELECTOR)); + const focusables = Array.from(modal.querySelectorAll(MODAL_FOCUSABLE_SELECTOR)); if (focusables.length === 0) return; const currentIndex = focusables.findIndex((item) => item === document.activeElement); @@ -260,7 +390,7 @@ export function useModalFocusTrap window.removeEventListener('keydown', handleKeyDown, true); - }, [dialogRef, onEscape]); + }, [modalRef, onEscape]); } // Chrome buttons: icon-only and labeled triggers used in the standalone app diff --git a/lib/src/stories/ExternalLinkDialog.stories.tsx b/lib/src/stories/ExternalLinkModal.stories.tsx similarity index 81% rename from lib/src/stories/ExternalLinkDialog.stories.tsx rename to lib/src/stories/ExternalLinkModal.stories.tsx index 676557a5..6cc62395 100644 --- a/lib/src/stories/ExternalLinkDialog.stories.tsx +++ b/lib/src/stories/ExternalLinkModal.stories.tsx @@ -1,15 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { ExternalLinkDialog } from '../components/ExternalLinkDialog'; +import { ExternalLinkModal } from '../components/ExternalLinkModal'; import { classifyDisplayMatch, inspectExternalUri } from '../lib/external-links'; -function DialogStory({ uri, displayText }: { uri: string; displayText: string }) { +function ExternalLinkModalStory({ uri, displayText }: { uri: string; displayText: string }) { return (
dev@dormouse:~/repo$ pnpm test
See the linked report for details.
- = { - title: 'Components/ExternalLinkDialog', - component: DialogStory, +const meta: Meta = { + title: 'Modals/ExternalLinkModal', + component: ExternalLinkModalStory, argTypes: { uri: { control: 'text' }, displayText: { control: 'text' }, @@ -33,7 +33,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; // Match: terminal auto-detected the URL (no separate link text). export const Https: Story = { @@ -102,7 +102,7 @@ export const MailtoPlain: Story = { // Long URL stress test (match). export const VeryLongUrl: Story = { args: { - uri: 'https://ci.example.com/builds/dormouse/kitty-keyboard/jobs/terminal-osc-8-hyperlink-confirmation/artifacts/reports/playwright/index.html?runId=2026-05-18T23%3A41%3A02.441Z&attempt=7&sha=d96cc07f9f66ff72b7f89433cf571e9a13d4c081680&path=packages%2Flib%2Fsrc%2Fcomponents%2FExternalLinkDialog.tsx&label=the-terminal-output-rendered-this-link-with-a-short-friendly-label-but-the-real-url-is-intentionally-extremely-long-to-verify-wrapping-scrolling-and-full-target-review-before-opening&token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwiZGVtb19vbmx5Ijp0cnVlLCJwdXJwb3NlIjoic3Rvcnlib29rLWxvbmdfdXJsLXZpc3VhbC1jYXNlIn0', + uri: 'https://ci.example.com/builds/dormouse/kitty-keyboard/jobs/terminal-osc-8-hyperlink-confirmation/artifacts/reports/playwright/index.html?runId=2026-05-18T23%3A41%3A02.441Z&attempt=7&sha=d96cc07f9f66ff72b7f89433cf571e9a13d4c081680&path=packages%2Flib%2Fsrc%2Fcomponents%2FExternalLinkModal.tsx&label=the-terminal-output-rendered-this-link-with-a-short-friendly-label-but-the-real-url-is-intentionally-extremely-long-to-verify-wrapping-scrolling-and-full-target-review-before-opening&token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwiZGVtb19vbmx5Ijp0cnVlLCJwdXJwb3NlIjoic3Rvcnlib29rLWxvbmdfdXJsLXZpc3VhbC1jYXNlIn0', displayText: '', }, }; diff --git a/lib/src/stories/KillModal.stories.tsx b/lib/src/stories/KillModal.stories.tsx index 40d7881f..148056c9 100644 --- a/lib/src/stories/KillModal.stories.tsx +++ b/lib/src/stories/KillModal.stories.tsx @@ -1,24 +1,23 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { KillConfirmCard, type KillExit } from '../components/KillConfirm'; +import { useState } from 'react'; +import { KillConfirmModal, type KillExit } from '../components/KillConfirm'; function KillModal({ char = 'G', onCancel, exit }: { char?: string; onCancel?: () => void; exit?: KillExit }) { + const [frameEl, setFrameEl] = useState(null); return ( -
+
{/* Simulated terminal content behind the overlay */}
user@dormouse:~$ npm run build
Building project...
- {/* Kill confirmation overlay — positioned over the pane */} -
- -
+
); } const meta: Meta = { - title: 'Components/KillModal', + title: 'Modals/KillModal', component: KillModal, argTypes: { char: { control: 'text' }, diff --git a/lib/src/stories/UpdateDebugDialog.stories.tsx b/lib/src/stories/UpdateDebugModal.stories.tsx similarity index 71% rename from lib/src/stories/UpdateDebugDialog.stories.tsx rename to lib/src/stories/UpdateDebugModal.stories.tsx index 77da4cc3..e57db15b 100644 --- a/lib/src/stories/UpdateDebugDialog.stories.tsx +++ b/lib/src/stories/UpdateDebugModal.stories.tsx @@ -1,21 +1,20 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; -import { UpdateDebugDialog } from '../../../standalone/src/UpdateDebugDialog'; +import { UpdateDebugModal } from '../../../standalone/src/UpdateDebugModal'; interface StoryArgs { failure: { version: string; error?: string }; body: string | null; } -function UpdateDebugDialogStory({ failure, body }: StoryArgs) { - // Bumping `key` on close re-mounts the dialog so the story stays interactive +function UpdateDebugModalStory({ failure, body }: StoryArgs) { + // Bumping `key` on close re-mounts the modal so the story stays interactive // after the user dismisses it (otherwise the canvas goes blank). const [tick, setTick] = useState(0); return (
- setTick((t) => t + 1)} failure={failure} body={body} @@ -41,13 +40,13 @@ const BODY = [ '', ].join('\n'); -const meta: Meta = { - title: 'Components/UpdateDebugDialog', - component: UpdateDebugDialogStory, +const meta: Meta = { + title: 'Modals/UpdateDebugModal', + component: UpdateDebugModalStory, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/standalone/src/UpdateDebugDialog.tsx b/standalone/src/UpdateDebugDialog.tsx deleted file mode 100644 index 6b794f18..00000000 --- a/standalone/src/UpdateDebugDialog.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { XIcon } from '@phosphor-icons/react'; -import { openIssueSearch } from './updater'; - -interface UpdateDebugDialogProps { - open: boolean; - onClose: () => void; - failure: { version: string; error?: string }; - body: string | null; -} - -export function UpdateDebugDialog({ open, onClose, failure, body }: UpdateDebugDialogProps) { - const dialogRef = useRef(null); - const [copied, setCopied] = useState(false); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) dialog.showModal(); - if (!open && dialog.open) dialog.close(); - }, [open]); - - useEffect(() => { - if (!copied) return; - const id = setTimeout(() => setCopied(false), 2_000); - return () => clearTimeout(id); - }, [copied]); - - useEffect(() => { - if (!open) setCopied(false); - }, [open]); - - const handleCopy = async () => { - if (!body) return; - try { - await navigator.clipboard.writeText(body); - setCopied(true); - } catch (e) { - console.error('[updater] Failed to copy report:', e); - } - }; - - const errorPreview = failure.error ?? ''; - - return ( - -
- Update failed - -
- -
-
-

- We couldn't install v{failure.version}. The error was: -

-
-            {errorPreview || '(no error captured)'}
-          
-
- -
-

1. Search existing reports

-

- Someone may have already hit this — a quick search saves a duplicate report. -

- -
- -
-

2. File a new bug

-

- If you can't find an existing bug,{' '} - - {copied && — copied!} - {' '}and paste it into a new issue. -

-