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

@ -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>