mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
8d9e6574cc
* 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).
261 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|