Desktop v2 design system (design.css tokens for both themes, ui/ds.tsx: PageHeader, Hero, Section, StatTile, Pipeline, StatusPill, EmptyState, Toolbar, Tabs, Dialog, tint and screenshot hooks), app shell: sidebar status dots, frosted top bar, app page Hero with screenshots and the two-store pipeline, pill tabs with blocker counts

This commit is contained in:
CRTkafa 2026-10-22 23:12:03 +08:00
parent 62366d95a7
commit bd316723f5
6 changed files with 559 additions and 172 deletions

View file

@ -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<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
export const WS_KEY: Record<WorkspaceScreen, Key> = { 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 (
<div role="listitem" key={a.key}>
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}>
<AppIcon name={a.name} src={d.iconFor(a)} />
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{tone && <span className={`ds-sdot ${tone}`} role="img" aria-label={tone === 'bad' ? t('pf.blocker') : tone === 'warn' ? t('pf.warning') : t('pf.note')} />}</span>
<span className="grow">{a.name}</span>
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>

201
apps/desktop/src/design.css Normal file
View file

@ -0,0 +1,201 @@
/* Design system v2 (components in src/ui/ds.tsx, guide in docs/drafts/desktop-v2/SYSTEM.md).
The store-like language of the portfolio home for every screen: tinted surfaces, big type, pill tabs, calm tables.
Loaded once after styles.css (main.tsx). Screens keep their own CSS in src/styles/<screen>.css. */
/* ---------- tokens ---------- */
:root {
/* surfaces and elevation */
--ds-page: var(--bg);
--ds-surface: var(--surface);
--ds-raised: #ffffff;
--ds-inset: var(--bg-sunken);
--ds-stroke: var(--line);
--ds-stroke-strong: var(--line-2);
--ds-e1: var(--shadow-1);
--ds-e2: var(--shadow-2);
--ds-e3: var(--shadow-3);
/* radii */
--ds-r-xs: 6px; --ds-r-sm: 10px; --ds-r: 14px; --ds-r-lg: 18px; --ds-r-xl: 24px; --ds-r-pill: 999px;
/* accent gradient (buttons, active pills, focus glow) */
--ds-grad: linear-gradient(135deg, #5b55e8, #8a5cf6 55%, #c264e8);
--ds-grad-soft: linear-gradient(135deg, color-mix(in srgb, #5b55e8 14%, transparent), color-mix(in srgb, #c264e8 10%, transparent));
/* per-app tint: set --tint-h (0-360) on an ancestor (tintStyle(hue) in ui/ds.tsx) */
--tint-h: 245;
--ds-tint-s: 78%; --ds-tint-l: 62%; --ds-tint-a: .16; --ds-tint-a2: .07;
/* typography scale */
--ds-t-display: 34px; --ds-t-h1: 28px; --ds-t-h2: 18px; --ds-t-h3: 15px; --ds-t-body: 13.5px; --ds-t-meta: 12px; --ds-t-micro: 11px;
--ds-t-stat: 32px;
--ds-w-display: 780; --ds-w-title: 700; --ds-w-strong: 650;
}
:root[data-theme="dark"] { --ds-raised: #151d2e; --ds-tint-l: 58%; --ds-tint-a: .26; --ds-tint-a2: .10; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ds-raised: #151d2e; --ds-tint-l: 58%; --ds-tint-a: .26; --ds-tint-a2: .10; } }
/* Anything below a tint: --tint is the app colour, .ds-tinted paints the soft gradient surface. */
[style*="--tint-h"] { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); }
.ds-tinted {
background:
radial-gradient(120% 80% at 100% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a)), transparent 60%),
linear-gradient(180deg, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 55%), var(--ds-surface);
border: 1px solid color-mix(in srgb, var(--tint, var(--accent-solid)) 22%, var(--ds-stroke));
}
/* ---------- shell: sidebar ---------- */
.side { background: linear-gradient(180deg, color-mix(in srgb, var(--bg-sunken) 92%, var(--accent-solid)) 0%, var(--bg-sunken) 260px); }
.side .nav { height: 34px; border-radius: 10px; }
.side .nav[aria-current="page"] { background: var(--ds-raised); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
.side .nav[aria-current="page"]::before { content: ""; position: absolute; left: 0; width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--ds-grad); }
.side .nav { position: relative; }
.side .nav-app { height: 36px; gap: 11px; }
.ds-side-ic { position: relative; display: inline-grid; flex: none; }
.side .nav-app .app-icon { width: 24px; height: 24px; border-radius: 7px; font-size: 9px; box-shadow: 0 2px 6px -2px rgba(0, 0, 0, .35), inset 0 0 0 1px rgba(255, 255, 255, .08); }
.ds-sdot { position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--bg-sunken); background: var(--neutral-solid); }
.ds-sdot.ok { background: var(--ok-solid); } .ds-sdot.warn { background: var(--warn-solid); } .ds-sdot.bad { background: var(--bad-solid); } .ds-sdot.info { background: var(--info-solid); }
.side-group-head { border-radius: 10px; }
.side-label { letter-spacing: .09em; }
/* ---------- shell: top bar ---------- */
.topbar { height: 56px; background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); position: relative; z-index: 5; }
.topbar .btn { border-radius: 10px; }
.crumbs .here { font-weight: 650; }
/* ---------- PageHeader ---------- */
.ds-ph { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.ds-ph-t { min-width: 0; display: grid; gap: 4px; }
.ds-eyebrow { font-size: var(--t-section); font-weight: 600; color: var(--accent); }
.ds-h1 { font-size: var(--ds-t-h1); line-height: 1.15; letter-spacing: -.025em; font-weight: var(--ds-w-display); display: flex; align-items: center; gap: 12px; }
.ds-ph-ic { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--ds-grad); color: #fff; box-shadow: 0 8px 20px -10px #5b55e8; }
.ds-ph-ic svg { width: 22px; height: 22px; }
.ds-sub { color: var(--muted); max-width: 80ch; }
/* ---------- Hero (app page) ---------- */
.ds-hero {
--tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l));
position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; border-radius: var(--ds-r-xl); overflow: hidden; min-height: 240px;
background:
radial-gradient(90% 120% at 100% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / calc(var(--ds-tint-a) * 1.6)), transparent 62%),
radial-gradient(60% 90% at 0% 100%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 70%), var(--ds-surface);
border: 1px solid color-mix(in srgb, var(--tint) 26%, var(--ds-stroke)); box-shadow: var(--ds-e2);
}
.ds-hero-main { display: grid; gap: 22px; padding: 26px 28px; align-content: space-between; min-width: 0; }
.ds-hero-id { display: flex; gap: 20px; align-items: center; min-width: 0; }
.ds-hero .ds-hero-icon { width: 88px; height: 88px; border-radius: 22px; font-size: 28px; box-shadow: 0 16px 36px -14px hsl(var(--tint-h) 70% 30% / .75), inset 0 0 0 1px rgba(255, 255, 255, .12); }
.ds-hero-t { display: grid; gap: 8px; min-width: 0; }
.ds-hero-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ds-hero-name { font-size: var(--ds-t-h1); font-weight: var(--ds-w-display); letter-spacing: -.025em; line-height: 1.1; }
.ds-hero-ids { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.ds-hero-ids .store-badge { height: 30px; padding-right: 4px; background: color-mix(in srgb, var(--ds-surface) 70%, transparent); }
.ds-hero-ids .store-badge .chip { height: 22px; }
.ds-hero-links { display: flex; gap: 6px; flex-wrap: wrap; }
.ds-hero-links .btn { height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--ds-surface) 60%, transparent); }
.ds-hero-pipe { display: grid; grid-template-columns: minmax(0, 560px) auto; gap: 28px; align-items: end; padding: 18px 20px; border-radius: var(--ds-r); background: color-mix(in srgb, var(--ds-surface) 55%, transparent); border: 1px solid color-mix(in srgb, var(--tint) 16%, var(--ds-stroke)); }
.ds-pipe-lg { gap: 9px; }
.ds-pipe-lg .pf-segs b { height: 10px; }
.ds-hero-foot { display: grid; gap: 10px; justify-items: end; }
.ds-hero-next { display: grid; justify-items: end; line-height: 1.1; }
.ds-hero-next-v { font-size: 26px; font-weight: var(--ds-w-display); letter-spacing: -.02em; }
.ds-hero-shots { position: relative; display: flex; gap: 12px; height: 100%; max-height: 300px; padding: 22px 22px 0; align-items: flex-start; overflow: hidden; width: min(44vw, 520px);
background: linear-gradient(160deg, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .30), transparent 80%);
-webkit-mask-image: linear-gradient(180deg, #000 78%, transparent), linear-gradient(90deg, transparent, #000 10%); -webkit-mask-composite: source-in; mask-image: linear-gradient(180deg, #000 78%, transparent); }
.ds-hero-shots img { height: 280px; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 16px; box-shadow: 0 18px 36px -14px rgba(0, 0, 0, .55); flex: none; transition: transform var(--speed) var(--ease); }
.ds-hero-shots img:nth-child(even) { margin-top: 26px; }
.ds-hero:hover .ds-hero-shots img { transform: translateY(-3px); }
.ds-hero-shots.empty { align-items: center; justify-content: center; padding: 0; width: 300px; -webkit-mask-image: none; mask-image: none;
background: radial-gradient(60% 70% at 50% 50%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .40), transparent 70%); }
.ds-hero-shots .pf-hero-icon { width: 120px; height: 120px; border-radius: 30px; font-size: 36px; box-shadow: 0 22px 48px -16px hsl(var(--tint-h) 70% 30% / .75), inset 0 0 0 1px rgba(255, 255, 255, .12); }
@media (max-width: 1180px) { .ds-hero { grid-template-columns: minmax(0, 1fr); } .ds-hero-shots { display: none; } }
@media (max-width: 900px) { .ds-hero-pipe { grid-template-columns: minmax(0, 1fr); } .ds-hero-foot { justify-items: start; } }
/* ---------- app page shell ---------- */
.ds-app { gap: 18px; }
.ds-app .app-controls { padding: 0 4px; }
.ds-app-sticky { position: sticky; top: -1px; z-index: 4; margin: 0 -28px; padding: 8px 28px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
/* ---------- Tabs v2 (pill) ---------- */
.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); overflow-x: auto; scrollbar-width: none; width: max-content; max-width: 100%; }
.ds-tabs::-webkit-scrollbar { display: none; }
.ds-tab { all: unset; flex: none; display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; transition: background var(--speed) var(--ease), color var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
.ds-tab svg { width: 16px; height: 16px; }
.ds-tab:hover { color: var(--ink); background: color-mix(in srgb, var(--ds-raised) 60%, transparent); }
.ds-tab[aria-selected="true"] { color: var(--ink); background: var(--ds-raised); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
.ds-tab[aria-selected="true"] svg { color: var(--tint, var(--accent-solid)); }
.ds-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.ds-tab-n { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px; display: inline-grid; place-items: center; font-size: 10.5px; font-weight: 700; background: var(--bad-solid); color: #fff; }
/* ---------- Section / Card v2 ---------- */
.ds-sec { background: var(--ds-surface); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); box-shadow: var(--ds-e1); min-width: 0; }
.ds-sec.plain { background: none; border: 0; box-shadow: none; }
.ds-sec-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px 0; }
.ds-sec.plain .ds-sec-h { padding: 0 2px; }
.ds-sec-h h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.015em; display: flex; align-items: center; gap: 10px; }
.ds-sec-ic { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); }
.ds-sec-ic svg { width: 16px; height: 16px; }
.ds-sec-sub { color: var(--muted); font-size: var(--ds-t-meta); margin-top: 3px; }
.ds-sec-b { padding: 16px 22px 20px; }
.ds-sec.plain .ds-sec-b { padding: 12px 0 0; }
.ds-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(var(--ds-min, 220px), 1fr)); }
/* ---------- StatTile ---------- */
.ds-stat { all: unset; box-sizing: border-box; display: grid; gap: 6px; align-content: start; padding: 18px 20px; border-radius: 16px; background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); position: relative; overflow: hidden; min-width: 0; }
.ds-stat::after { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--tint, transparent); opacity: .8; }
.ds-stat.clickable { cursor: pointer; transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
.ds-stat.clickable:hover { transform: translateY(-1px); box-shadow: var(--ds-e2); }
.ds-stat:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.ds-stat-l { display: flex; align-items: center; gap: 7px; font-size: var(--ds-t-meta); font-weight: 600; color: var(--muted); }
.ds-stat-l svg { width: 15px; height: 15px; }
.ds-stat-v { font-size: var(--ds-t-stat); font-weight: var(--ds-w-display); letter-spacing: -.03em; line-height: 1.05; color: var(--ink); }
.ds-stat-s { display: flex; gap: 8px; align-items: center; font-size: var(--ds-t-meta); color: var(--muted); min-width: 0; }
.ds-stat-s > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ds-stat.tone-bad { border-left: 3px solid var(--bad-solid); } .ds-stat.tone-warn { border-left: 3px solid var(--warn-solid); } .ds-stat.tone-ok { border-left: 3px solid var(--ok-solid); }
.ds-delta { font-weight: 700; font-variant-numeric: tabular-nums; padding: 1px 6px; border-radius: 6px; background: var(--neutral-bg); color: var(--neutral-ink); }
.ds-delta.up { background: var(--ok-bg); color: var(--ok-ink); } .ds-delta.down { background: var(--bad-bg); color: var(--bad-ink); }
/* ---------- StatusPill ---------- */
.ds-pill { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border-radius: var(--ds-r-pill); font-size: var(--ds-t-micro); font-weight: 650; white-space: nowrap; background: var(--neutral-bg); color: var(--neutral-ink); }
.ds-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--neutral-solid); }
.ds-pill svg { width: 13px; height: 13px; }
.ds-pill.ok { background: var(--ok-bg); color: var(--ok-ink); } .ds-pill.ok i { background: var(--ok-solid); }
.ds-pill.warn { background: var(--warn-bg); color: var(--warn-ink); } .ds-pill.warn i { background: var(--warn-solid); }
.ds-pill.bad { background: var(--bad-bg); color: var(--bad-ink); } .ds-pill.bad i { background: var(--bad-solid); }
.ds-pill.info { background: var(--info-bg); color: var(--info-ink); } .ds-pill.info i { background: var(--info-solid); }
.ds-pill.accent { background: var(--accent-soft); color: var(--accent); } .ds-pill.accent i { background: var(--accent-solid); }
.ds-pill.solid { color: #fff; } .ds-pill.solid i { background: #fff; }
.ds-pill.solid.ok { background: var(--ok-solid); } .ds-pill.solid.warn { background: var(--warn-solid); color: #1b1203; } .ds-pill.solid.bad { background: var(--bad-solid); } .ds-pill.solid.info { background: var(--info-solid); } .ds-pill.solid.accent { background: var(--ds-grad); }
/* ---------- EmptyState ---------- */
.ds-empty { display: grid; justify-items: center; gap: 12px; padding: 36px 24px; text-align: center; color: var(--muted); }
.ds-empty-ic { width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 14%, transparent); color: var(--tint, var(--accent-solid)); }
.ds-empty-ic svg { width: 26px; height: 26px; }
.ds-empty.tone-ok .ds-empty-ic { background: var(--ok-bg); color: var(--ok-solid); }
.ds-empty.tone-bad .ds-empty-ic { background: var(--bad-bg); color: var(--bad-solid); }
.ds-empty p { font-size: var(--t-em); color: var(--ink-2); max-width: 52ch; }
/* ---------- Toolbar ---------- */
.ds-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px; border-radius: var(--ds-r); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); }
.ds-toolbar .grow { flex: 1; }
.ds-toolbar .input, .ds-toolbar .btn { border-radius: 10px; }
/* ---------- Table v2 (add .v2 to .table, wrap in .ds-table-wrap) ---------- */
.ds-table-wrap { overflow: auto; max-height: var(--ds-table-max, none); border-radius: var(--ds-r-lg); border: 1px solid var(--ds-stroke); background: var(--ds-surface); box-shadow: var(--ds-e1); }
.table.v2 th { position: sticky; top: 0; z-index: 1; background: color-mix(in srgb, var(--ds-surface) 92%, var(--ds-inset)); backdrop-filter: blur(8px); text-transform: none; letter-spacing: 0; font-size: var(--ds-t-meta); font-weight: 600; padding-top: 12px; padding-bottom: 12px; }
.table.v2 th:first-child, .table.v2 th:last-child { border-radius: 0; }
.table.v2 td { padding-top: 14px; padding-bottom: 14px; border-bottom-color: color-mix(in srgb, var(--ds-stroke) 65%, transparent); }
.table.v2 tbody tr { transition: background-color var(--speed) var(--ease); }
.table.v2 tbody tr:hover td { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--hover)); }
.table.v2 tbody tr:focus-within td { background: var(--hover); }
.table.v2 .num, .table.v2 td.right { font-variant-numeric: tabular-nums; }
/* ---------- Dialog v2 ---------- */
.ds-scrim { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 24px; background: var(--scrim); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ds-fade var(--speed) var(--ease); }
.ds-dialog { width: min(560px, 100%); max-height: calc(100vh - 48px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-radius: var(--ds-r-xl); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); animation: ds-pop var(--speed) var(--ease); }
.ds-dialog.wide { width: min(880px, 100%); }
.ds-dialog:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.ds-dialog-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 22px 8px; }
.ds-dialog-h h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); display: flex; align-items: center; gap: 10px; }
.ds-dialog-b { padding: 8px 22px 18px; overflow: auto; }
.ds-dialog-f { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--ds-stroke); background: color-mix(in srgb, var(--ds-inset) 50%, transparent); border-radius: 0 0 var(--ds-r-xl) var(--ds-r-xl); }
@keyframes ds-fade { from { opacity: 0; } }
@keyframes ds-pop { from { opacity: 0; transform: translateY(6px) scale(.985); } }
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) { .ds-hero-shots img, .ds-stat.clickable, .ds-scrim, .ds-dialog { transform: none !important; transition: none; animation: none; } }
:root[data-motion="reduce"] .ds-hero-shots img, :root[data-motion="reduce"] .ds-stat.clickable, :root[data-motion="reduce"] .ds-scrim, :root[data-motion="reduce"] .ds-dialog { transform: none !important; transition: none; animation: none; }

View file

@ -10,6 +10,7 @@ import { DataProvider } from './store';
import { ErrorBoundary } from './ui/ErrorBoundary';
import { ExportProvider } from './ui/Exports';
import './styles.css';
import './design.css';
const systemLang = (): string => (typeof navigator !== 'undefined' ? navigator.language : 'en');

View file

@ -1,17 +1,18 @@
// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no
// app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and
// "Start an app folder here" to make it a full app.
import { type KeyboardEvent, useRef, useState } from 'react';
import { useState } from 'react';
import { api, errorText } from '../api';
import { type Key, t } from '../i18n';
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withStudio, withUnpair } from '../lib/identity';
import { storeNow } from '../lib/model';
import { attention, storeNow } from '../lib/model';
import { Hero, nextStep, Tabs } from '../ui/ds';
import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav';
import { useData } from '../store';
import { Icon } from '../ui/icons';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { FolderMissing } from '../ui/FolderMissing';
import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
import { Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
import { AppInfoTab } from './tabs/AppInfo';
import { ArtTab } from './tabs/Art';
import { LiveArt } from './tabs/LiveArt';
@ -106,56 +107,40 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
const tabs = tabsFor(!!app.project);
const ed = useEdits();
useUnsavedGuard();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const studio = d.studios.find(s => s.id === app.studio) ?? null;
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x });
const onKey = (ev: KeyboardEvent) => {
const i = tabs.indexOf(tab);
const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? tabs.length - 1 : null;
if (j === null) return;
ev.preventDefault();
const n = (j + tabs.length) % tabs.length;
setTab(tabs[n]!);
tabRefs.current[n]?.focus();
};
const all = l?.release.all ?? [];
const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null;
const att = attention(app.key, app.name, e, l);
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
return (
<div className="page">
<div className="ph">
<div className="app-head">
<AppIcon name={name} src={d.iconFor(app)} />
<div>
<div className="row" style={{ gap: 10 }}>
<h1>{name}</h1>
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
{!app.project && <StoreOnlyTag />}
<button className="studio-pill" onClick={() => nav.go({ screen: 'accounts', section: app.studio ?? 'none' })} title={t('app.studioHint')}><StudioMark studio={studio} size="sm" />{studio?.name ?? t('studio.none')}</button>
</div>
<div className="ids">
<StoreBadge store="ios" id={ios?.bundleId} off={!ios && (!!e || !app.project)}>{iosNow && <><span className="num strong">{iosNow.version}</span><PhaseChip r={iosNow} /></>}</StoreBadge>
<StoreBadge store="android" id={android?.packageName} off={!android && (!!e || !app.project)}>{androidNow && <><span className="num strong">{androidNow.version}</span><PhaseChip r={androidNow} /></>}</StoreBadge>
</div>
</div>
</div>
<div className="actions">
{ios && <button className="btn btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
{android && <button className="btn btn-ghost" onClick={() => open(PLAY_CONSOLE)} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
</div>
</div>
<div className="page ds-app">
<Hero name={name} icon={d.iconFor(app)} projectId={app.project && !app.project.missing ? app.project.id : null}
badges={<>
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
{!app.project && <StoreOnlyTag />}
<button className="studio-pill" onClick={() => nav.go({ screen: 'accounts', section: app.studio ?? 'none' })} title={t('app.studioHint')}><StudioMark studio={studio} size="sm" />{studio?.name ?? t('studio.none')}</button>
</>}
ids={<>
<StoreBadge store="ios" id={ios?.bundleId} off={!ios && (!!e || !app.project)}>{iosNow && <><span className="num strong">{iosNow.version}</span><PhaseChip r={iosNow} /></>}</StoreBadge>
<StoreBadge store="android" id={android?.packageName} off={!android && (!!e || !app.project)}>{androidNow && <><span className="num strong">{androidNow.version}</span><PhaseChip r={androidNow} /></>}</StoreBadge>
</>}
links={(ios || android) && <>
{ios && <button className="btn btn-sm btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(PLAY_CONSOLE)} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
</>}
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} />
<AppControls app={app} />
{!app.project && <StoreOnlyBanner app={app} />}
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
{tabs.map((x, i) => (
<button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}
{EDIT_TABS[x] && ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) })} />}
</button>
))}
<div className="ds-app-sticky">
<Tabs label={t('app.sections', { name })} value={tab} onChange={setTab} items={tabs.map(x => {
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 };
})} />
</div>
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${tab}`} className="stack" style={{ gap: 16 }}>
<ErrorBoundary key={tab} where={`tab ${tab}`}>

View file

@ -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<string, Promise<string[]>>();
let shotChain: Promise<unknown> = Promise.resolve();
function loadShots(projectId: string): Promise<string[]> {
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<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(projectId, job);
return job;
}
function useShots(projectId: string | null): string[] {
const [shots, setShots] = useState<string[]>([]);
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<string, number>();
function useTint(src: string | null, 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;
}
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 (
<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>
);
}
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 (
<article className="pf-card" style={style} aria-label={name}>
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
@ -279,11 +161,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
</div>
</div>
{studio}
<div className="pf-pipe">
<div className="pf-stages" aria-hidden="true"><span />{STAGES.map(s => <span key={s}>{t(`stage.${s}`)}</span>)}<span /></div>
<Track store="ios" all={all} on={ios} />
<Track store="android" all={all} on={android} />
</div>
<Pipeline all={all} ios={ios} android={android} />
<div className="pf-foot">
<CardHealth c={health} android={android} />
<button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => go(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>

319
apps/desktop/src/ui/ds.tsx Normal file
View file

@ -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<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>
<div className={`ds-hero-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} />) : <AppIcon name={name} src={icon} className="pf-hero-icon" />}
</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>
);
}