storebound/apps/desktop/src/ui/ds.tsx

320 lines
17 KiB
TypeScript

// 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<string, number>();
/** 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<number>(() => (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<string, Promise<string[]>>();
let shotChain: Promise<unknown> = Promise.resolve();
function loadShots(projectId: string, max: number): Promise<string[]> {
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<ToolResult & { data?: RenderImage }>('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<string[]>([]);
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 (
<div className={`pf-track${on ? '' : ' off'}`} role="img" aria-label={`${name}: ${state}`}>
<span className="pf-track-name"><StoreGlyph store={store} className="ic13" />{store === 'ios' ? 'App Store' : 'Play'}</span>
<span className="pf-segs" aria-hidden="true">
{STAGES.map((s, i) => {
const cls = i < lane.at ? 'done' : i === lane.at ? `at t-${lane.tone}` : '';
const fill = i === lane.at && s === 'rollout' && lane.share !== null && lane.share < 1 ? lane.share : null;
return <b key={s} className={cls}>{fill !== null && <i style={{ width: `${Math.max(8, fill * 100)}%` }} />}</b>;
})}
</span>
<span className="pf-track-v num" title={state}>{on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'}</span>
</div>
);
}
/** 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 (
<div className={`pf-pipe${size === 'lg' ? ' ds-pipe-lg' : ''}`}>
<div className="pf-stages" aria-hidden="true"><span />{STAGES.map(s => <span key={s}>{t(`stage.${s}`)}</span>)}<span /></div>
<PipelineTrack store="ios" all={all} on={ios} />
<PipelineTrack store="android" all={all} on={android} />
</div>
);
}
// ---- 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 (
<header className="ds-ph">
<div className="ds-ph-t">
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
<h1 className="ds-h1">{icon && <span className="ds-ph-ic"><Icon name={icon} weight="fill" /></span>}{title}</h1>
{subtitle && <p className="ds-sub">{subtitle}</p>}
</div>
{actions && <div className="actions">{actions}</div>}
</header>
);
}
/** 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 (
<section className="ds-hero" style={tintStyle(hue)} aria-label={name}>
<div className="ds-hero-main">
<div className="ds-hero-id">
<AppIcon name={name} src={icon} className="ds-hero-icon" />
<div className="ds-hero-t">
<div className="ds-hero-row"><h1 className="ds-hero-name">{name}</h1>{badges}</div>
{ids && <div className="ds-hero-ids">{ids}</div>}
{links && <div className="ds-hero-links">{links}</div>}
</div>
</div>
<div className="ds-hero-pipe">
<Pipeline all={all} ios={ios} android={android} size="lg" />
<div className="ds-hero-foot">
<div className="ds-hero-next"><span className="pf-next-l">{t('pf.next')}</span><span className="ds-hero-next-v num">{next ?? '–'}</span></div>
{cta && <button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => onCta?.(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>}
</div>
</div>
{children}
</div>
{/* no screenshots: no second (cropped) copy of the icon, which already leads the hero */}
{shots.length > 0 && <div className="ds-hero-shots" aria-hidden="true">
{shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} />)}
</div>}
</section>
);
}
/** 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 (
<section className={`ds-sec${plain ? ' plain' : ''} ${className}`} id={id} aria-labelledby={title !== undefined ? hid : undefined}>
{title !== undefined && (
<div className="ds-sec-h">
<div><h2 id={hid}>{icon && <span className="ds-sec-ic"><Icon name={icon} /></span>}{title}</h2>{sub && <p className="ds-sec-sub">{sub}</p>}</div>
{actions && <div className="actions">{actions}</div>}
</div>
)}
{pad ? <div className="ds-sec-b">{children}</div> : children}
</section>
);
}
/** 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 = (
<>
<span className="ds-stat-l">{icon && <Icon name={icon} />}{label}</span>
<span className="ds-stat-v num">{value}</span>
{(delta != null || sub) && <span className="ds-stat-s">
{delta != null && <span className={`ds-delta ${good === null ? '' : good ? 'up' : 'down'}`}>{delta > 0 ? '+' : ''}{delta}</span>}
{sub && <span>{sub}</span>}
</span>}
</>
);
const cls = `ds-stat${tone ? ` tone-${tone}` : ''}`;
return onClick ? <button type="button" className={`${cls} clickable`} onClick={onClick}>{body}</button> : <div className={cls}>{body}</div>;
}
/** 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 <span className={`ds-pill ${tone}${solid ? ' solid' : ''}`} title={title}>{icon ? <Icon name={icon} /> : <i />}{children}</span>;
}
/** 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 (
<div className={`ds-empty${tone ? ` tone-${tone}` : ''}`} role="status">
<span className="ds-empty-ic"><Icon name={icon} /></span>
<p>{children}</p>
{action}
</div>
);
}
/** Toolbar row: search, filters, then right-aligned controls after a `<span className="grow" />`. */
export function Toolbar({ label, children }: { label: string; children: ReactNode }) {
return <div className="ds-toolbar" role="toolbar" aria-label={label}>{children}</div>;
}
export interface TabItem<T extends string> { 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<T extends string>({ items, value, onChange, label, panelId = 'tabpanel' }: { items: TabItem<T>[]; 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 (
<div className="ds-tabs" role="tablist" aria-label={label} onKeyDown={onKey}>
{items.map((x, i) => (
<button key={x.id} ref={el => { refs.current[i] = el; }} role="tab" id={`tab-${x.id}`} aria-selected={value === x.id} aria-controls={panelId}
tabIndex={value === x.id ? 0 : -1} className="ds-tab" onClick={() => onChange(x.id)}>
{x.icon && <Icon name={x.icon} weight={value === x.id ? 'fill' : 'regular'} />}{x.label}
{x.count != null && x.count > 0 && <span className="ds-tab-n num">{x.count}</span>}
{x.dot && <span className="ed-dot" title={x.dotTitle} />}
</button>
))}
</div>
);
}
/** 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<HTMLDivElement>(null);
const hid = useId();
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
root.current?.focus();
return () => { prev?.focus?.(); };
}, []);
return (
<div className="ds-scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className={`ds-dialog${wide ? ' wide' : ''}`} role="dialog" aria-modal="true" aria-labelledby={hid} tabIndex={-1} ref={root}
onKeyDown={e => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } }}>
<div className="ds-dialog-h">
<h2 id={hid}>{icon && <span className="ds-sec-ic"><Icon name={icon} /></span>}{title}</h2>
<button className="btn btn-ghost btn-sm ds-x" onClick={onClose} aria-label={t('common.close')}><Icon name="x" /></button>
</div>
<div className="ds-dialog-b">{children}</div>
{actions && <div className="ds-dialog-f">{actions}</div>}
</div>
</div>
);
}