diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a72750e..6538f00 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -23,6 +23,8 @@ import { type Command, Palette } from './ui/Palette'; import { ExportMenu, useExportItems, useRunExport } from './ui/Exports'; import { ErrorBoundary } from './ui/ErrorBoundary'; import { Toasts } from './ui/Toasts'; +import { worstTone } from './ui/ds'; +import { attention } from './lib/model'; const WS_ICON: Record = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' }; @@ -146,12 +148,13 @@ export function App({ onTheme }: { onTheme: () => void }) { const appItem = (a: UnifiedApp) => { const e = d.statusFor(a); const missing = !!a.project?.missing; + const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null)); const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); return (
-
-
- {iosNow && <>{iosNow.version}} - {androidNow && <>{androidNow.version}} -
- - -
- {ios && } - {android && } -
- +
+ + {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} + {!app.project && } + + } + ids={<> + {iosNow && <>{iosNow.version}} + {androidNow && <>{androidNow.version}} + } + links={(ios || android) && <> + {ios && } + {android && } + } + all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} + cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } {app.project?.storeboundPin && {t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}} {app.project?.missing ? : <> -
- {tabs.map((x, i) => ( - - ))} +
+ { + const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0; + return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; + })} />
diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index b6e1a9e..41cc6f3 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -4,18 +4,20 @@ // version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first. import { plainError } from '../../../../cli/lib/google-errors'; import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError'; -import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from 'react'; -import { api, type AppStatus, newJob, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; -import { type Attention, attention, healthSeries, type HealthSnapshot, hueOf, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; +import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; import { type AppTab, useNav } from '../nav'; import type { IconName } from '../ui/icons'; import { useData } from '../store'; import { exportName, portfolioCsv } from '../lib/exports'; import { useExports } from '../ui/Exports'; import { Icon, StoreGlyph } from '../ui/icons'; +import { nextStep, Pipeline, tintStyle, useShots, useTint } from '../ui/ds'; +export { nextStep } from '../ui/ds'; import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; /** Tag of one app's offline config check. */ @@ -111,126 +113,6 @@ function releasesOf(l: AppStatus | undefined): StoreRelease[] { return l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x); } -// Up to three phone screenshots of the app folder's rendered store art (`shots gallery`, then one `project.render` -// data: URL each), loaded once per window and one app at a time. No renders: an empty list (the card shows the icon). -const shotCache = new Map>(); -let shotChain: Promise = Promise.resolve(); -function loadShots(projectId: string): Promise { - const hit = shotCache.get(projectId); - if (hit) return hit; - const job = shotChain.then(async () => { - const r = await reads.renders(projectId).catch(() => null); - const res = r?.data; - if (!res) return []; - const v = res.variants.find(x => !x.id) ?? res.variants[0]; - const lang = v?.langs.find(l => l.lang === 'en' || l.lang.startsWith('en-')) ?? v?.langs[0]; - const rows = (lang?.rows ?? []).filter(row => !/feature|ipad|preview|tv|watch|mac|tablet/i.test(row.size) && row.tiles.some(x => x.kind === 'image')); - rows.sort((a, b) => (a.store === 'apple' ? 0 : 1) - (b.store === 'apple' ? 0 : 1)); - const files = (rows[0]?.tiles ?? []).filter(x => x.kind === 'image' && x.h >= x.w).slice(0, 3).map(x => x.src); - const out: string[] = []; - for (const file of files) { - const img = await api.call('project.render', projectId, { args: { file } }).catch(() => null); - if (img?.data?.dataUrl) out.push(img.data.dataUrl); - } - return out; - }).catch(() => [] as string[]); - shotChain = job; - shotCache.set(projectId, job); - return job; -} - -function useShots(projectId: string | null): string[] { - const [shots, setShots] = useState([]); - useEffect(() => { - if (!projectId) return; - let alive = true; - loadShots(projectId).then(s => { if (alive) setShots(s); }, () => {}); - return () => { alive = false; }; - }, [projectId]); - return shots; -} - -// The hue of an app icon (saturation-weighted mean colour), for the card's soft gradient; the name's hue without one. -const tintCache = new Map(); -function useTint(src: string | null, name: string): number { - const fallback = hueOf(name); - const [hue, setHue] = useState(() => (src ? tintCache.get(src) : undefined) ?? fallback); - useEffect(() => { - if (!src) { setHue(fallback); return; } - const cached = tintCache.get(src); - if (cached !== undefined) { setHue(cached); return; } - let alive = true; - const img = new Image(); - img.onload = () => { - try { - const c = document.createElement('canvas'); - c.width = c.height = 16; - const g = c.getContext('2d'); - if (!g) return; - g.drawImage(img, 0, 0, 16, 16); - const px = g.getImageData(0, 0, 16, 16).data; - let r = 0, gr = 0, b = 0, w = 0; - for (let i = 0; i < px.length; i += 4) { - const R = px[i]!, G = px[i + 1]!, B = px[i + 2]!, A = px[i + 3]! / 255; - const s = (Math.max(R, G, B) - Math.min(R, G, B)) / 255 * A; - r += R * s; gr += G * s; b += B * s; w += s; - } - if (w < 0.5) return; - r /= w; gr /= w; b /= w; - const mx = Math.max(r, gr, b), mn = Math.min(r, gr, b), d = mx - mn; - if (d < 1) return; - let h = mx === r ? ((gr - b) / d) % 6 : mx === gr ? (b - r) / d + 2 : (r - gr) / d + 4; - h = Math.round(h * 60 + 360) % 360; - tintCache.set(src, h); - if (alive) setHue(h); - } catch { /* keep the name's hue */ } - }; - img.src = src; - return () => { alive = false; }; - }, [src, fallback]); - return hue; -} - -interface Cta { text: string; tab: AppTab; icon: IconName; primary: boolean } - -/** The one next step of an app, from its state: blockers first, then the release that waits for a move. */ -export function nextStep(o: { project: boolean; att: Attention[]; all: StoreRelease[]; next: string | null }): Cta { - const bad = o.att.filter(a => a.tone === 'bad'); - if (bad.length) return { text: t('pf.ctaFix', { count: bad.length }), tab: bad[0]!.tab ?? 'checks', icon: 'alertCircle', primary: true }; - const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); - if (rolling) return { text: t('pf.ctaRollout', { share: share(rolling.fraction ?? 0) }), tab: 'releases', icon: 'rocket', primary: true }; - const approved = o.all.find(r => r.phase === 'approved'); - if (approved) return { text: t('pf.ctaRelease', { version: approved.version ?? '' }), tab: 'releases', icon: 'rocket', primary: true }; - const ready = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); - if (ready) return { text: t('pf.ctaSubmit', { version: ready.version ?? '' }), tab: 'releases', icon: 'send', primary: true }; - const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); - if (review) return { text: t('pf.ctaReview', { version: review.version ?? '' }), tab: 'releases', icon: 'clock', primary: false }; - if (o.att.length) return { text: t('pf.ctaWarnings', { count: o.att.length }), tab: o.att[0]!.tab ?? 'checks', icon: 'alert', primary: false }; - if (o.project && !o.next) return { text: t('pf.ctaUpload'), tab: 'releases', icon: 'upload', primary: false }; - return { text: t('pf.ctaOpen'), tab: 'releases', icon: 'arrowRight', primary: false }; -} - -function Track({ store, all, on }: { store: 'ios' | 'android'; all: StoreRelease[]; on: boolean }) { - const { now, next } = storeNow(all, store); - const r = next ?? now; - const lane = laneOf(store, on ? r : null); - const name = store === 'ios' ? 'App Store' : 'Google Play'; - const state = !on ? t('app.notOnStore') : !r ? t('portfolio.noVersion') : `${r.version ?? ''} · ${t(`phase.${r.phase}` as Key)}${lane.share !== null && lane.share < 1 ? ` ${share(lane.share)}` : ''}`; - return ( -
- {store === 'ios' ? 'App Store' : 'Play'} - - {on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'} -
- ); -} - function CardHealth({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) { const play = c?.h.play; if (!android || !c || !play || isError(play)) { @@ -260,7 +142,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; const cta = nextStep({ project: !!app.project, att, all, next }); const name = e?.app?.name && app.project ? e.app.name : app.name; const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab }); - const style = { '--tint-h': String(hue) } as CSSProperties; + const style = tintStyle(hue); return (
{studio} -
- - - -
+
diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx new file mode 100644 index 0000000..563e500 --- /dev/null +++ b/apps/desktop/src/ui/ds.tsx @@ -0,0 +1,319 @@ +// Design system v2 (styles in src/design.css, guide in docs/drafts/desktop-v2/SYSTEM.md): the store-like language of +// the portfolio home as reusable parts. PageHeader, Hero (app hero with screenshots, two-store pipeline, next step), +// Section, StatTile, Pipeline, StatusPill, EmptyState, Tabs, Toolbar, Dialog, plus the per-app tint (useTint / tintStyle) +// and the rendered-screenshot loader (useShots) the cards and the hero share. +import { type CSSProperties, type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'; +import { api, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api'; +import { type Key, t } from '../i18n'; +import { share } from '../lib/format'; +import { type Attention, hueOf, laneOf, STAGES, storeNow, type Tone } from '../lib/model'; +import type { AppTab } from '../nav'; +import { Icon, type IconName, StoreGlyph } from './icons'; +import { AppIcon } from './kit'; + +// ---- per-app tint ---- + +const tintCache = new Map(); +/** The hue of an app icon (saturation-weighted mean colour); the name's hue without an icon or while it loads. */ +export function useTint(src: string | null | undefined, name: string): number { + const fallback = hueOf(name); + const [hue, setHue] = useState(() => (src ? tintCache.get(src) : undefined) ?? fallback); + useEffect(() => { + if (!src) { setHue(fallback); return; } + const cached = tintCache.get(src); + if (cached !== undefined) { setHue(cached); return; } + let alive = true; + const img = new Image(); + img.onload = () => { + try { + const c = document.createElement('canvas'); + c.width = c.height = 16; + const g = c.getContext('2d'); + if (!g) return; + g.drawImage(img, 0, 0, 16, 16); + const px = g.getImageData(0, 0, 16, 16).data; + let r = 0, gr = 0, b = 0, w = 0; + for (let i = 0; i < px.length; i += 4) { + const R = px[i]!, G = px[i + 1]!, B = px[i + 2]!, A = px[i + 3]! / 255; + const s = (Math.max(R, G, B) - Math.min(R, G, B)) / 255 * A; + r += R * s; gr += G * s; b += B * s; w += s; + } + if (w < 0.5) return; + r /= w; gr /= w; b /= w; + const mx = Math.max(r, gr, b), mn = Math.min(r, gr, b), d = mx - mn; + if (d < 1) return; + let h = mx === r ? ((gr - b) / d) % 6 : mx === gr ? (b - r) / d + 2 : (r - gr) / d + 4; + h = Math.round(h * 60 + 360) % 360; + tintCache.set(src, h); + if (alive) setHue(h); + } catch { /* keep the name's hue */ } + }; + img.src = src; + return () => { alive = false; }; + }, [src, fallback]); + return hue; +} + +/** Style that tints a subtree: every `.ds-tinted` / `--tint` rule below it uses this hue. */ +export const tintStyle = (hue: number): CSSProperties => ({ '--tint-h': String(hue) } as CSSProperties); + +// ---- rendered store screenshots ---- + +// Up to three phone screenshots of the app folder's rendered store art (`shots gallery`, then one `project.render` +// data: URL each), loaded once per window and one app at a time. No renders: an empty list. +const shotCache = new Map>(); +let shotChain: Promise = Promise.resolve(); +function loadShots(projectId: string, max: number): Promise { + const k = `${projectId}#${max}`; + const hit = shotCache.get(k); + if (hit) return hit; + const job = shotChain.then(async () => { + const r = await reads.renders(projectId).catch(() => null); + const res = r?.data; + if (!res) return []; + const v = res.variants.find(x => !x.id) ?? res.variants[0]; + const lang = v?.langs.find(l => l.lang === 'en' || l.lang.startsWith('en-')) ?? v?.langs[0]; + const rows = (lang?.rows ?? []).filter(row => !/feature|ipad|preview|tv|watch|mac|tablet/i.test(row.size) && row.tiles.some(x => x.kind === 'image')); + rows.sort((a, b) => (a.store === 'apple' ? 0 : 1) - (b.store === 'apple' ? 0 : 1)); + const files = (rows[0]?.tiles ?? []).filter(x => x.kind === 'image' && x.h >= x.w).slice(0, max).map(x => x.src); + const out: string[] = []; + for (const file of files) { + const img = await api.call('project.render', projectId, { args: { file } }).catch(() => null); + if (img?.data?.dataUrl) out.push(img.data.dataUrl); + } + return out; + }).catch(() => [] as string[]); + shotChain = job; + shotCache.set(k, job); + return job; +} + +/** Rendered phone screenshots of an app folder (none for a store-only app or before the first render). */ +export function useShots(projectId: string | null | undefined, max = 3): string[] { + const [shots, setShots] = useState([]); + useEffect(() => { + if (!projectId) { setShots([]); return; } + let alive = true; + loadShots(projectId, max).then(s => { if (alive) setShots(s); }, () => {}); + return () => { alive = false; }; + }, [projectId, max]); + return shots; +} + +// ---- next step ---- + +export interface Cta { text: string; tab: AppTab; icon: IconName; primary: boolean } + +/** The one next step of an app, from its state: blockers first, then the release that waits for a move. */ +export function nextStep(o: { project: boolean; att: Attention[]; all: StoreRelease[]; next: string | null }): Cta { + const bad = o.att.filter(a => a.tone === 'bad'); + if (bad.length) return { text: t('pf.ctaFix', { count: bad.length }), tab: bad[0]!.tab ?? 'checks', icon: 'alertCircle', primary: true }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('pf.ctaRollout', { share: share(rolling.fraction ?? 0) }), tab: 'releases', icon: 'rocket', primary: true }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('pf.ctaRelease', { version: approved.version ?? '' }), tab: 'releases', icon: 'rocket', primary: true }; + const ready = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('pf.ctaSubmit', { version: ready.version ?? '' }), tab: 'releases', icon: 'send', primary: true }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: t('pf.ctaReview', { version: review.version ?? '' }), tab: 'releases', icon: 'clock', primary: false }; + if (o.att.length) return { text: t('pf.ctaWarnings', { count: o.att.length }), tab: o.att[0]!.tab ?? 'checks', icon: 'alert', primary: false }; + if (o.project && !o.next) return { text: t('pf.ctaUpload'), tab: 'releases', icon: 'upload', primary: false }; + return { text: t('pf.ctaOpen'), tab: 'releases', icon: 'arrowRight', primary: false }; +} + +/** Worst tone of an app's attention list (the sidebar dot); null when nothing needs a look. */ +export function worstTone(att: Attention[]): Tone | null { + if (att.some(a => a.tone === 'bad')) return 'bad'; + if (att.some(a => a.tone === 'warn')) return 'warn'; + return att.length ? 'info' : null; +} + +// ---- Pipeline (Build -> Testing -> Review -> Live -> Rollout, one lane per store) ---- + +export function PipelineTrack({ store, all, on }: { store: 'ios' | 'android'; all: StoreRelease[]; on: boolean }) { + const { now, next } = storeNow(all, store); + const r = next ?? now; + const lane = laneOf(store, on ? r : null); + const name = store === 'ios' ? 'App Store' : 'Google Play'; + const state = !on ? t('app.notOnStore') : !r ? t('portfolio.noVersion') : `${r.version ?? ''} · ${t(`phase.${r.phase}` as Key)}${lane.share !== null && lane.share < 1 ? ` ${share(lane.share)}` : ''}`; + return ( +
+ {store === 'ios' ? 'App Store' : 'Play'} + + {on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'} +
+ ); +} + +/** Both store lanes under one stage header. Needs a tint (`--tint-h`) on an ancestor. */ +export function Pipeline({ all, ios, android, size = 'md' }: { all: StoreRelease[]; ios: boolean; android: boolean; size?: 'md' | 'lg' }) { + return ( +
+ + + +
+ ); +} + +// ---- page parts ---- + +/** Page title row: optional eyebrow, a big title, one line under it, actions on the right. */ +export function PageHeader({ title, subtitle, eyebrow, actions, icon }: { title: ReactNode; subtitle?: ReactNode; eyebrow?: ReactNode; actions?: ReactNode; icon?: IconName }) { + return ( +
+
+ {eyebrow &&

{eyebrow}

} +

{icon && }{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ ); +} + +/** App hero: screenshots strip, big icon, name, ids, store links, the two-store pipeline and the next step. */ +export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children }: { + name: string; icon: string | null; projectId: string | null; ids?: ReactNode; badges?: ReactNode; links?: ReactNode; + all: StoreRelease[]; ios: boolean; android: boolean; cta?: Cta | null; onCta?: (tab: AppTab) => void; next?: string | null; children?: ReactNode; +}) { + const hue = useTint(icon, name); + const shots = useShots(projectId, 4); + return ( +
+
+
+ +
+

{name}

{badges}
+ {ids &&
{ids}
} + {links &&
{links}
} +
+
+
+ +
+
{t('pf.next')}{next ?? '–'}
+ {cta && } +
+
+ {children} +
+ +
+ ); +} + +/** Section v2: a titled block. `plain` drops the surface (content sits on the page). */ +export function Section({ title, icon, sub, actions, children, plain, pad = true, id, className = '' }: { title?: ReactNode; icon?: IconName; sub?: ReactNode; actions?: ReactNode; children: ReactNode; plain?: boolean; pad?: boolean; id?: string; className?: string }) { + const hid = useId(); + return ( +
+ {title !== undefined && ( +
+

{icon && }{title}

{sub &&

{sub}

}
+ {actions &&
{actions}
} +
+ )} + {pad ?
{children}
: children} +
+ ); +} + +/** Big number tile: value, label, optional delta (+ is good unless `invert`) and a sub line. */ +export function StatTile({ label, value, delta, invert, sub, icon, tone, onClick }: { label: string; value: ReactNode; delta?: number | null; invert?: boolean; sub?: ReactNode; icon?: IconName; tone?: Tone; onClick?: () => void }) { + const good = delta == null || delta === 0 ? null : (delta > 0) !== !!invert; + const body = ( + <> + {icon && }{label} + {value} + {(delta != null || sub) && + {delta != null && {delta > 0 ? '+' : ''}{delta}} + {sub && {sub}} + } + + ); + const cls = `ds-stat${tone ? ` tone-${tone}` : ''}`; + return onClick ? :
{body}
; +} + +/** Status pill: a tone dot and text; `solid` for the one state that matters on a row. */ +export function StatusPill({ tone = 'neutral', children, icon, solid, title }: { tone?: Tone; children: ReactNode; icon?: IconName; solid?: boolean; title?: string }) { + return {icon ? : }{children}; +} + +/** Empty state without illustration: icon, one line, an optional action. */ +export function EmptyState({ icon = 'sparkle', children, action, tone }: { icon?: IconName; children: ReactNode; action?: ReactNode; tone?: Tone }) { + return ( +
+ +

{children}

+ {action} +
+ ); +} + +/** Toolbar row: search, filters, then right-aligned controls after a ``. */ +export function Toolbar({ label, children }: { label: string; children: ReactNode }) { + return
{children}
; +} + +export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } + +/** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). */ +export function Tabs({ items, value, onChange, label, panelId = 'tabpanel' }: { items: TabItem[]; value: T; onChange: (v: T) => void; label: string; panelId?: string }) { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const onKey = (ev: KeyboardEvent) => { + const i = items.findIndex(x => x.id === value); + const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? items.length - 1 : null; + if (j === null) return; + ev.preventDefault(); + const n = (j + items.length) % items.length; + onChange(items[n]!.id); + refs.current[n]?.focus(); + }; + return ( +
+ {items.map((x, i) => ( + + ))} +
+ ); +} + +/** Dialog v2: modal panel with a title, body and footer actions; Escape and the scrim close it, focus moves in. */ +export function Dialog({ title, icon, children, actions, onClose, wide }: { title: ReactNode; icon?: IconName; children: ReactNode; actions?: ReactNode; onClose: () => void; wide?: boolean }) { + const root = useRef(null); + const hid = useId(); + useEffect(() => { + const prev = document.activeElement as HTMLElement | null; + root.current?.focus(); + return () => { prev?.focus?.(); }; + }, []); + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
{ if (e.key === 'Escape') { e.stopPropagation(); onClose(); } }}> +
+

{icon && }{title}

+ +
+
{children}
+ {actions &&
{actions}
} +
+
+ ); +}