364 lines
20 KiB
TypeScript
364 lines
20 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 MouseEvent as ReactMouseEvent, type ReactNode, useEffect, useId, useLayoutEffect, 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';
|
|
import { useMenuButton } from './ContextMenu';
|
|
import { fitTabs } from './tabs-fit';
|
|
|
|
// ---- 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, onContextMenu, compact }: {
|
|
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;
|
|
/** One slim row (icon, name, ids, next step): every tab but the overview, so the tab's content starts high. */
|
|
compact?: boolean;
|
|
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(compact ? null : projectId, 4);
|
|
const ctaBtn = cta && <button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => onCta?.(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>;
|
|
return (
|
|
<section className={`ds-hero${compact ? ' compact' : ''}`} style={tintStyle(hue)} aria-label={name} onContextMenu={onContextMenu}>
|
|
<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>
|
|
{compact && ctaBtn && <div className="ds-hero-cta">{ctaBtn}</div>}
|
|
</div>
|
|
{!compact && <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>
|
|
{ctaBtn}
|
|
</div>
|
|
</div>}
|
|
{children}
|
|
</div>
|
|
{/* no screenshots: no second (cropped) copy of the icon, which already leads the hero */}
|
|
{!compact && 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). One row always: tabs that
|
|
* do not fit go into a "More" menu at the end, the active tab always stays in the row. */
|
|
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 row = useRef<HTMLDivElement>(null);
|
|
const widths = useRef<number[]>([]);
|
|
const [shown, setShown] = useState<number[] | null>(null);
|
|
const more = useMenuButton();
|
|
const active = items.findIndex(x => x.id === value);
|
|
const ids = items.map(x => x.id).join('|');
|
|
useLayoutEffect(() => {
|
|
const el = row.current;
|
|
if (!el) return;
|
|
const fit = () => {
|
|
// widths are measured while a tab is in the row; a hidden one keeps its last width
|
|
refs.current.forEach((b, i) => { if (b && b.offsetParent !== null) widths.current[i] = b.getBoundingClientRect().width; });
|
|
if (widths.current.length < items.length || widths.current.some(w => w === undefined)) { setShown(null); return; }
|
|
const cs = getComputedStyle(el);
|
|
// the row's room: the parent's content box (the sticky bar has its own padding) minus the row's padding and border
|
|
const par = el.parentElement;
|
|
const ps = par ? getComputedStyle(par) : null;
|
|
const box = par && ps ? par.clientWidth - parseFloat(ps.paddingLeft) - parseFloat(ps.paddingRight) : el.clientWidth;
|
|
const avail = box - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth) - 2;
|
|
const next = fitTabs(widths.current, avail, active, parseFloat(cs.columnGap) || 4, 150);
|
|
setShown(prev => (prev && prev.join() === next.join() ? prev : next.length === items.length ? null : next));
|
|
};
|
|
fit();
|
|
const ro = new ResizeObserver(fit);
|
|
if (el.parentElement) ro.observe(el.parentElement);
|
|
return () => ro.disconnect();
|
|
}, [ids, active, items.length]);
|
|
const visible = (i: number) => !shown || shown.includes(i);
|
|
const order = items.map((_, i) => i).filter(visible);
|
|
const onKey = (ev: KeyboardEvent) => {
|
|
const i = order.indexOf(active);
|
|
const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? order.length - 1 : null;
|
|
if (j === null) return;
|
|
ev.preventDefault();
|
|
const n = order[(j + order.length) % order.length]!;
|
|
onChange(items[n]!.id);
|
|
refs.current[n]?.focus();
|
|
};
|
|
const hidden = items.filter((_, i) => !visible(i));
|
|
return (
|
|
<div className="ds-tabs" ref={row} 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)} hidden={!visible(i)}>
|
|
{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>
|
|
))}
|
|
{hidden.length > 0 && (
|
|
<button type="button" className="ds-tab ds-tab-more" tabIndex={-1} {...more(() => hidden.map(x => ({ label: x.count ? `${x.label} (${x.count})` : x.label, icon: x.icon, onSelect: () => onChange(x.id) })))}
|
|
aria-label={t('tabs.more', { count: hidden.length })}>
|
|
<Icon name="more" />{t('tabs.moreShort')}{hidden.some(x => x.count) && <span className="ds-tab-n num">{hidden.reduce((a, x) => a + (x.count ?? 0), 0)}</span>}<Icon name="chevronDown" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|