From 898ef1a0e8e941d2eba722891c0f4720211898bb Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 13:37:02 -0400 Subject: [PATCH] feat(ui): add Modal chrome primitives, migrate file dialogs (#896) Introduces shared , , , , and primitives that wrap shadcn Dialog and encapsulate the canonical modal chrome: cyan rail at the left edge, mono uppercase kicker, italic serif title, footer hint, standardized button order (secondary outline, primary cyan / destructive). Migrates NewFolderDialog and DeleteFileConfirm onto the new primitives as the first proof points. The destructive variant flips the rail and kicker color to the destructive token without changing the chrome recipe. --- .../components/files/DeleteFileConfirm.tsx | 68 +++++----- .../src/components/files/NewFolderDialog.tsx | 47 +++---- frontend/src/components/ui/modal.tsx | 122 ++++++++++++++++++ 3 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/ui/modal.tsx diff --git a/frontend/src/components/files/DeleteFileConfirm.tsx b/frontend/src/components/files/DeleteFileConfirm.tsx index cbd827c6..111b7e05 100644 --- a/frontend/src/components/files/DeleteFileConfirm.tsx +++ b/frontend/src/components/files/DeleteFileConfirm.tsx @@ -1,13 +1,6 @@ import { useState, useEffect } from 'react'; -import { AlertTriangle, Loader2, Trash2 } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { Modal, ModalDestructiveHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -75,25 +68,30 @@ export function DeleteFileConfirm({ const deleteLabel = notEmpty ? 'Delete all' : 'Delete'; + const titleNode = entryName ? ( + <> + Delete {entryName}? + + ) : ( + 'Delete item?' + ); + return ( - - - - - - Delete {entryName ? `"${entryName}"` : 'item'}? - - - {notEmpty ? ( - - - This folder is not empty. Delete everything inside? - - ) : ( - 'This action cannot be undone.' - )} - - + + + + {notEmpty ? ( +
+ + This folder is not empty. Delete everything inside? +
+ ) : ( +

This action cannot be undone.

+ )} {isProtected && (
@@ -116,8 +114,10 @@ export function DeleteFileConfirm({
)} - - +
+ Cancel + } + primary={ - -
-
+ } + /> +
); } diff --git a/frontend/src/components/files/NewFolderDialog.tsx b/frontend/src/components/files/NewFolderDialog.tsx index 4202c708..cab33124 100644 --- a/frontend/src/components/files/NewFolderDialog.tsx +++ b/frontend/src/components/files/NewFolderDialog.tsx @@ -1,13 +1,6 @@ import { useState } from 'react'; -import { FolderPlus, Loader2 } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog'; +import { Loader2 } from 'lucide-react'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -73,19 +66,16 @@ export function NewFolderDialog({ if (e.key === 'Enter') void handleCreate(); }; - return ( - - - - - - New Folder - - - Enter a name for the new folder. - - + const parentLabel = currentDir || stackName; + return ( + + +
{validationError}

)}
- - +
+ Cancel + } + primary={ - -
-
+ } + /> + ); } diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx new file mode 100644 index 00000000..b6f41aee --- /dev/null +++ b/frontend/src/components/ui/modal.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; + +const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]'; + +type ModalSize = 'sm' | 'md' | 'lg' | 'xl'; + +const SIZE_CLASS: Record = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl w-[95vw]', +}; + +interface ModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + size?: ModalSize; + className?: string; +} + +export function Modal({ open, onOpenChange, children, size = 'md', className }: ModalProps) { + return ( + + + {children} + + + ); +} + +interface ModalHeaderBaseProps { + kicker: string; + title: React.ReactNode; + description?: string; +} + +function HeaderShell({ + kicker, + title, + description, + railClassName, + kickerClassName, +}: ModalHeaderBaseProps & { railClassName: string; kickerClassName: string }) { + return ( +
+ +
+ {kicker} +
+ + {title} + + + {description ?? (typeof title === 'string' ? title : kicker)} + +
+ ); +} + +export function ModalHeader(props: ModalHeaderBaseProps) { + return ( + + ); +} + +export function ModalDestructiveHeader(props: ModalHeaderBaseProps) { + return ( + + ); +} + +export function ModalBody({ className, ...props }: React.HTMLAttributes) { + return
; +} + +interface ModalFooterProps { + primary: React.ReactNode; + secondary?: React.ReactNode; + hint?: React.ReactNode; + hintAccent?: React.ReactNode; +} + +export function ModalFooter({ primary, secondary, hint, hintAccent }: ModalFooterProps) { + return ( +
+
+ {hint} + {hintAccent !== undefined && ( + + {hintAccent} + + )} +
+
+ {secondary} + {primary} +
+
+ ); +}