One ContextMenu in the design system (keyboard, type-ahead, submenus, check items, disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all. Menus on sidebar and portfolio apps, app hero, releases, store art, live art, reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
322 lines
17 KiB
TypeScript
322 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 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<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 }: {
|
|
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 (
|
|
<section className="ds-hero" 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>
|
|
</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>
|
|
);
|
|
}
|