Files
sencho/frontend/src/components/ui/modal.tsx
T
Anso 8d9e6574cc feat(appearance): add Calm/Signature visual style, readability mode, and chart palette (#1407)
* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette

Turn the "too intense / italic headers hurt / the security graph fights my
eyes" feedback into a token-driven Visual style with Calm as the new default
and Signature one click back to the prior look.

- Heading family routes through a `.font-heading` utility driven by
  `--font-heading`/`--heading-style`: operational headings render upright in the
  interface face under Calm and italic Instrument Serif under Signature. Base
  rule sets family + style only, so each call site keeps its own weight/tracking
  and Signature stays a true no-op; the Calm lift is a `[data-headings="clean"]`
  descendant rule. Brand lockup, empty-state heroes, and onboarding stay serif.
- Severity charts resolve through `--sev-*` tokens with Muted, Heat, and
  Signature palettes; FindingsByType routes its series through the severity ramp
  plus a neutral so no brand-cyan sits next to rose. The risk trend flattens its
  gradient under Muted/Heat/reduced and keeps the gradient under Signature.
- Appearance settings gain Visual style cards, a Security visualization palette,
  a Readability master toggle, a Motion & effects group, and a "Reset to default"
  button (restores the Calm axes, disabled while readability is on). Contrast
  moves under Readability and Ambient glow under Motion & effects. A card is
  selected only while the stored sub-axes match its preset, so a custom
  combination de-selects both.
- The topbar Theme quick-switch swaps the interface/data font pickers for a
  Visual style switch and a Readability toggle (text size kept); its footer
  Settings link jumps straight to Appearance.
- Readability is a sticky master that forces the calm resolution and a contrast
  lift at apply time without mutating the stored sub-axes.
- New users default to Calm; any pre-existing persisted appearance state keeps
  the Signature look. The pre-paint script mirrors the store.
- SegmentedControl gains a `disabled` prop and a nullable value (no active
  segment for a custom combination, with a roving-tabindex keyboard anchor).
  Adds unit/component coverage for the store, migration, chart shape logic, the
  disabled control, the reset/de-selection, and the quick-switch.

* fix(appearance): migrate Blueprint serif headings and surface readability locks

- Migrate the two operational Blueprint headings (catalog tile name, drift-policy
  option title) from font-serif italic to the .font-heading utility; the first
  pass only covered font-display, so Calm still left these italic. font-serif and
  font-display both resolve to the same display face, so this is the same fix.
- Lock the Visual style cards under Readability (parity with the topbar switch and
  the on-screen guidance to turn Readability off to choose a style by hand).
- Lock the Border brightness slider under Readability and show its forced +0.03
  readout, since Readability overrides the stored value; dragging it previously
  appeared to do nothing.
- Correct the Appearance docs sentence for the topbar quick switch (it listed
  fonts; the quick switch now carries visual style, readability, and text size).
2026-06-22 00:19:57 -04:00

261 lines
7.1 KiB
TypeScript

import * as React from 'react';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'wide';
const SIZE_CLASS: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl w-[95vw]',
wide: 'max-w-5xl w-[95vw]',
};
interface ModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
size?: ModalSize;
className?: string;
/** Pass through to DialogContent. Set to false when the modal renders its own close affordance. */
showClose?: boolean;
/** Fill the viewport below md (for large overlays like logs / shell). Opt-in so
* small confirm dialogs keep their compact centered size on a phone. */
mobileFullScreen?: boolean;
}
export function Modal({ open, onOpenChange, children, size = 'md', className, showClose, mobileFullScreen }: ModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showClose={showClose}
className={cn(
'p-0 gap-0 overflow-hidden grid-cols-1',
SIZE_CLASS[size],
className,
mobileFullScreen && 'max-md:h-[100dvh] max-md:w-screen max-md:max-w-none max-md:rounded-none max-md:border-0',
)}
>
{children}
</DialogContent>
</Dialog>
);
}
interface ModalHeaderBaseProps {
kicker: string;
title: React.ReactNode;
description?: string;
}
type HeaderVariant = 'default' | 'destructive';
const HEADER_VARIANT: Record<HeaderVariant, { rail: string; kicker: string }> = {
default: { rail: 'bg-brand', kicker: 'text-stat-subtitle' },
destructive: { rail: 'bg-destructive', kicker: 'text-destructive' },
};
interface HeaderShellProps extends ModalHeaderBaseProps {
variant: HeaderVariant;
TitleComponent: React.ElementType;
DescriptionComponent: React.ElementType;
}
function HeaderShell({
kicker,
title,
description,
variant,
TitleComponent,
DescriptionComponent,
}: HeaderShellProps) {
const v = HEADER_VARIANT[variant];
return (
<div className="relative border-b border-card-border/60 px-6 pt-6 pb-4 pr-12">
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', v.rail)} />
<div className={cn(KICKER_CLASS, 'whitespace-nowrap', v.kicker)}>
{kicker}
</div>
<TitleComponent className="mt-1 font-heading text-[1.75rem] leading-tight text-stat-value">
{title}
</TitleComponent>
<DescriptionComponent className="sr-only">
{description ?? (typeof title === 'string' ? title : kicker)}
</DescriptionComponent>
</div>
);
}
export function ModalHeader(props: ModalHeaderBaseProps) {
return (
<HeaderShell
{...props}
variant="default"
TitleComponent={DialogTitle}
DescriptionComponent={DialogDescription}
/>
);
}
export function ModalDestructiveHeader(props: ModalHeaderBaseProps) {
return (
<HeaderShell
{...props}
variant="destructive"
TitleComponent={DialogTitle}
DescriptionComponent={DialogDescription}
/>
);
}
function ConfirmHeader(props: ModalHeaderBaseProps) {
return (
<HeaderShell
{...props}
variant="default"
TitleComponent={AlertDialogTitle}
DescriptionComponent={AlertDialogDescription}
/>
);
}
function ConfirmDestructiveHeader(props: ModalHeaderBaseProps) {
return (
<HeaderShell
{...props}
variant="destructive"
TitleComponent={AlertDialogTitle}
DescriptionComponent={AlertDialogDescription}
/>
);
}
export function ModalBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('px-6 py-5 space-y-4 max-h-[calc(85vh-12rem)] overflow-y-auto', className)} {...props} />;
}
interface ModalFooterProps {
primary: React.ReactNode;
secondary?: React.ReactNode;
hint?: React.ReactNode;
hintAccent?: React.ReactNode;
}
export function ModalFooter({ primary, secondary, hint, hintAccent }: ModalFooterProps) {
return (
<div className="flex items-center justify-between gap-4 border-t border-card-border/60 px-6 py-4">
<div className={cn(KICKER_CLASS, 'text-stat-subtitle')}>
{hint}
{hintAccent !== undefined && (
<span className="ml-1.5 rounded-sm border border-card-border bg-card px-1.5 py-0.5 text-stat-value">
{hintAccent}
</span>
)}
</div>
<div className="flex items-center gap-2">
{secondary}
{primary}
</div>
</div>
);
}
type ConfirmSize = 'sm' | 'md';
interface ConfirmModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
variant?: 'default' | 'destructive';
size?: ConfirmSize;
kicker: string;
title: React.ReactNode;
description?: string;
hint?: React.ReactNode;
confirmLabel: React.ReactNode;
cancelLabel?: React.ReactNode;
confirming?: boolean;
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
children?: React.ReactNode;
}
export function ConfirmModal({
open,
onOpenChange,
variant = 'default',
size = 'sm',
kicker,
title,
description,
hint,
confirmLabel,
cancelLabel = 'Cancel',
confirming = false,
onConfirm,
onCancel,
children,
}: ConfirmModalProps) {
const Header = variant === 'destructive' ? ConfirmDestructiveHeader : ConfirmHeader;
const cancelClass = buttonVariants({ variant: 'outline', size: 'sm' });
const actionClass = buttonVariants({
variant: variant === 'destructive' ? 'destructive' : 'default',
size: 'sm',
});
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className={cn('p-0 gap-0 overflow-hidden border-card-border/60', SIZE_CLASS[size])}>
<Header kicker={kicker} title={title} description={description} />
{children !== undefined && <ModalBody>{children}</ModalBody>}
<ModalFooter
hint={hint}
secondary={
<AlertDialogCancel
className={cancelClass}
disabled={confirming}
onClick={onCancel}
>
{cancelLabel}
</AlertDialogCancel>
}
primary={
<AlertDialogAction
className={actionClass}
disabled={confirming}
onClick={(e) => {
const result = onConfirm();
// Async confirms keep the dialog open so the caller can render
// `confirming` state and close via onOpenChange when work completes.
// Sync confirms let Radix auto-close.
if (result instanceof Promise) {
e.preventDefault();
void result;
}
}}
>
{confirmLabel}
</AlertDialogAction>
}
/>
</AlertDialogContent>
</AlertDialog>
);
}