// 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 MouseEvent as ReactMouseEvent, 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, onContextMenu }: { name: string; icon: string | null; projectId: string | null; ids?: ReactNode; badges?: ReactNode; links?: ReactNode; /** The app's context menu (useContextMenu props). */ onContextMenu?: (e: ReactMouseEvent) => void; 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}
{/* no screenshots: no second (cropped) copy of the icon, which already leads the hero */} {shots.length > 0 && }
); } /** 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}
}
); }