Desktop: custom context menus everywhere, never the browser's menu

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.
This commit is contained in:
CRTkafa 2026-10-23 08:59:14 +08:00
parent 944b24fe59
commit dece83c3e8
23 changed files with 839 additions and 28 deletions

View file

@ -3,6 +3,8 @@
// issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI);
// the app keeps a label, the studio and the profile name only. Per account: a health
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
import { useContextMenu } from '../ui/ContextMenu';
import { copyToast } from '../ui/app-menu';
import { type FormEvent, useEffect, useState } from 'react';
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
import { type Key, t } from '../i18n';
@ -54,8 +56,22 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
};
const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); await d.reload(); setEdit(false); }); };
const check = h?.check;
const cm = useContextMenu();
return (
<div className={`acct${ignored ? ' is-ignored' : ''}`}>
<div className={`acct${ignored ? ' is-ignored' : ''}`} {...cm(() => [
{ label: t('acct.test'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => run(`ck-${a.id}`, () => d.checkAccount(a.id)) },
{ label: t('acct.import'), icon: 'download', disabled: busy || d.importBusy ? t('cm.busy') : false, onSelect: () => run(`im-${a.id}`, () => d.importApps(a.id)) },
{ label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
{ label: t('cm.copyName'), icon: 'text', onSelect: () => copyToast(d, a.label) },
check?.principal && { label: t('cm.copyPrincipal'), icon: 'key', shortcut: check.principal, onSelect: () => copyToast(d, check.principal!) },
a.profile && { label: t('cm.copyProfile'), icon: 'folder', shortcut: a.profile, onSelect: () => copyToast(d, a.profile!) },
] },
'sep',
{ label: ignored ? t('acct.unignore') : t('acct.ignore'), icon: ignored ? 'eye' : 'eyeOff', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], !ignored)); } },
{ label: t('accounts.remove'), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => run(`rm-${a.id}`, async () => { await api.removeAccount(a.id); await d.reload(); }) },
])}>
<div className="acct-row">
<span className={`acct-glyph ${a.kind}`}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} /></span>
<div className="acct-main">

View file

@ -1,5 +1,7 @@
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
import { type MenuInput, useContextMenu } from '../ui/ContextMenu';
import { copyToast } from '../ui/app-menu';
import { failureText } from '../lib/results';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
@ -19,9 +21,10 @@ type Profile = 'app' | 'game' | 'subscription-app';
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */
function Tinted({ src, name, children }: { src: string | null | undefined; name: string; children: ReactNode }) {
function Tinted({ src, name, children, menu }: { src: string | null | undefined; name: string; children: ReactNode; menu: () => MenuInput }) {
const hue = useTint(src, name);
return <div className="li fold-card" style={tintStyle(hue)}>{children}</div>;
const cm = useContextMenu();
return <div className="li fold-card" style={tintStyle(hue)} {...cm(menu)}>{children}</div>;
}
function LocateButton({ project }: { project: Project }) {
@ -100,7 +103,21 @@ export function AppFolders() {
const e = d.statusOf(p);
const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e);
return (
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name}>
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name} menu={() => [
!p.missing && { label: t('folders.open'), icon: 'arrowRight', onSelect: () => nav.go({ screen: 'app', appId: p.id, tab: 'releases' }) },
!p.missing && { label: t('folders.reveal'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(x => setError(errorText(x))); } },
'sep',
!p.missing && { label: t('folders.check'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => check(p) },
!p.missing && { label: t('folders.gallery'), icon: 'image', disabled: busy ? t('cm.busy') : false, onSelect: () => gallery(p) },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
{ label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) },
e?.app?.platforms.ios?.bundleId && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: e.app.platforms.ios.bundleId, onSelect: () => copyToast(d, e.app!.platforms.ios!.bundleId) },
e?.app?.platforms.android?.packageName && { label: t('cm.copyPackage'), icon: 'tag', shortcut: e.app.platforms.android.packageName, onSelect: () => copyToast(d, e.app!.platforms.android!.packageName) },
] },
'sep',
{ label: t('folders.remove', { name: p.name }), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => remove(p) },
]}>
<AppIcon name={p.name} src={d.icons[p.id]} className="fold-icon" />
<div className="li-main">
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><StatusPill tone={st.tone}>{st.text}</StatusPill></span>

View file

@ -1,6 +1,9 @@
// 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 { useContextMenu } from '../ui/ContextMenu';
import { appMenu } from '../ui/app-menu';
import { useShowIgnored } from '../lib/show-ignored';
import { useState } from 'react';
import { api, errorText } from '../api';
import { type Key, t } from '../i18n';
@ -122,10 +125,13 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
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(() => {}); };
const cm = useContextMenu();
const [showIgnored] = useShowIgnored();
return (
<div className="page ds-app">
<Hero name={name} icon={d.iconFor(app)} projectId={app.project && !app.project.missing ? app.project.id : null}
{...cm(() => appMenu(app, d, nav, { showIgnored }))}
badges={<>
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
{!app.project && <StoreOnlyTag />}

View file

@ -1,5 +1,7 @@
// Ignored: apps and whole store accounts left out of the portfolio, the sidebar and store reads. Restore brings them
// back (nothing on the stores changes either way).
import { useContextMenu } from '../ui/ContextMenu';
import { appMenu } from '../ui/app-menu';
import { t } from '../i18n';
import { withIgnored, withSideIgnored } from '../lib/identity';
import { useNav } from '../nav';
@ -16,6 +18,7 @@ export function Ignored() {
const accounts = d.accounts.filter(a => d.overrides.ignored.includes(`account:${a.id}`));
const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] }));
const empty = !apps.length && !accounts.length;
const cm = useContextMenu();
return (
<div className="page scr-ignored">
<PageHeader icon="eyeOff" eyebrow={t('v2cfg.workspace')} title={t('nav.ignored')} subtitle={t('ignored.lead')} actions={!empty ? <><button className="btn" onClick={restoreAll}><Icon name="eye" />{t('ignored.restoreAll')}</button></> : undefined} />
@ -27,7 +30,7 @@ export function Ignored() {
{apps.map(a => {
const s = d.studios.find(x => x.id === a.studio) ?? null;
return (
<div className="li" key={a.key}>
<div className="li" key={a.key} {...cm(() => appMenu(a, d, nav, { showIgnored: true }))}>
<AppIcon name={a.name} src={d.iconFor(a)} />
<div className="li-main">
<span className="li-title row" style={{ gap: 8 }}>{a.name}{!a.project && <StoreOnlyTag />}</span>
@ -49,7 +52,7 @@ export function Ignored() {
{accounts.length ? (
<div className="list">
{accounts.map(a => (
<div className="li" key={a.id}>
<div className="li" key={a.id} {...cm(() => [{ label: t('ignored.restore'), icon: 'eye', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], false)); } }, { label: t('cm.openAccounts'), icon: 'key', onSelect: () => nav.go({ screen: 'accounts' }) }])}>
<span className="li-icon tone-bg-neutral"><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} /></span>
<div className="li-main"><span className="li-title">{a.label}</span><span className="li-sub">{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')} · {t('ignored.accountApps', { count: d.storeApps[a.id]?.value.apps.length ?? 0 })}</span></div>
<button className="btn btn-sm" onClick={() => d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], false))}><Icon name="eye" />{t('ignored.restore')}</button>

View file

@ -12,6 +12,8 @@ import { useShowIgnored } from '../lib/show-ignored';
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity';
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 { useContextMenu } from '../ui/ContextMenu';
import { appMenu } from '../ui/app-menu';
import type { IconName } from '../ui/icons';
import { useData } from '../store';
import { exportName, portfolioCsv } from '../lib/exports';
@ -144,8 +146,11 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
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 = tintStyle(hue);
const d = useData();
const menu = useContextMenu();
const [showIgnored] = useShowIgnored();
return (
<article className="pf-card" style={style} aria-label={name}>
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <AppIcon name={app.name} src={icon} className="pf-hero-icon" />}
</div>
@ -285,6 +290,7 @@ export function Portfolio() {
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]);
const [showIgnored, setShowIgnored] = useShowIgnored();
const menu = useContextMenu();
const [round, setRound] = useState(0);
const [rechecking, setRechecking] = useState(false);
const health = useCachedHealth(visible.map(a => a.target), round);
@ -349,7 +355,7 @@ export function Portfolio() {
const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android);
const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? '';
return (
<tr key={app.key} className="clickable" onClick={() => open(app)}>
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<td>
<div className="cell-app">
<AppIcon name={app.name} src={d.iconFor(app)} />
@ -435,7 +441,7 @@ export function Portfolio() {
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
{showIgnored && ignoredApps.map(a => (
<span key={a.key} className="row muted" style={{ gap: 4 }}>
<button className="link" onClick={() => open(a)}>{a.name}</button>
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }))}>{a.name}</button>
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
</span>
))}

View file

@ -2,6 +2,8 @@
// size (`shots gallery --json`, offline), store check issues outlined. Images load one by one as they scroll into view
// (`project.render`, a data: URL each: the window loads no files itself). Re-render (dry run, dialog, real run), write
// the review page, open it or the folder.
import { type MenuInput, useContextMenu } from '../../ui/ContextMenu';
import { copyToast } from '../../ui/app-menu';
import '../../styles/art.css';
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, errorText, type Project, type RenderImage, type RendersResult, type RenderTile, type ToolResult } from '../../api';
@ -41,7 +43,8 @@ function loadRender(projectId: string, file: string, stamp: string): Promise<str
});
}
function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: RenderTile; stamp: string; onOpen: () => void }) {
function Thumb({ projectId, tile, stamp, onOpen, menu }: { projectId: string; tile: RenderTile; stamp: string; onOpen: () => void; menu: () => MenuInput }) {
const cm = useContextMenu();
const ref = useRef<HTMLButtonElement>(null);
const file = tile.kind === 'video' ? tile.poster : tile.src;
const [src, setSrc] = useState<string | null | undefined>(file ? cache.get(`${projectId}|${file}|${stamp}`) : null);
@ -61,7 +64,7 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re
const lvl = tile.issues.some(i => i.level === 'error') ? 'err' : tile.issues.length ? 'warn' : '';
const shape = tile.h > tile.w ? 'portrait' : 'landscape';
return (
<figure className={`art-tile ${lvl} ${shape}`} style={{ ['--ar' as string]: `${tile.w} / ${tile.h}` }}>
<figure {...cm(menu)} className={`art-tile ${lvl} ${shape}`} style={{ ['--ar' as string]: `${tile.w} / ${tile.h}` }}>
<button ref={ref} className="art-pic" onClick={onOpen} aria-label={t('art.open', { name: tile.name })}>
{src ? <img src={src} alt="" draggable={false} /> : src === null && file ? <span className="art-ph"><Icon name="image" />{t('art.tooBig')}</span> : <span className="art-ph">{tile.kind === 'video' ? <Icon name="play" /> : <span className="sk sk-block" />}</span>}
{tile.kind === 'video' && <span className="art-play"><Icon name="play" /></span>}
@ -170,7 +173,17 @@ export function ArtTab({ project }: { project: Project }) {
<h3><span>{row.label}</span><code>{lg}/{row.size}</code><Chip tone="outline" dot={false}>{row.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>{row.issues.length > 0 && <StatusPill tone={row.issues.some(i => i.level === 'error') ? 'bad' : 'warn'}>{t('v2lst.issues', { count: row.issues.length })}</StatusPill>}<span className="muted small">{t('art.count', { count: row.tiles.length })}</span></h3>
{row.issues.length > 0 && <ul className="note-list art-issues">{row.issues.map((i, k) => <li key={k} className={i.level}>{i.msg}</li>)}</ul>}
<div className="art-tiles" style={{ ['--tw' as string]: row.size === 'feature' || /landscape/.test(row.size) ? '300px' : row.tiles[0] && row.tiles[0].w > row.tiles[0].h ? '260px' : '170px' }}>
{row.tiles.map((tile, i) => <Thumb key={tile.src} projectId={project.id} tile={tile} stamp={stamp} onOpen={() => setOpen({ tiles: row.tiles, i })} />)}
{row.tiles.map((tile, i) => <Thumb key={tile.src} projectId={project.id} tile={tile} stamp={stamp} onOpen={() => setOpen({ tiles: row.tiles, i })} menu={() => {
const path = `${dir}/${tile.src}`;
return [
{ label: t('cm.preview'), icon: 'eye', shortcut: 'Enter', onSelect: () => setOpen({ tiles: row.tiles, i }) },
{ label: t('cm.openFile'), icon: 'external', onSelect: () => reveal(path, true) },
{ label: t('cm.reveal'), icon: 'folder', onSelect: () => reveal(path) },
{ label: t('cm.copyPath'), icon: 'copy', onSelect: () => copyToast(d, path) },
'sep',
tile.kind === 'image' && { label: t('art.replace'), icon: 'upload', onSelect: () => { void replace(tile); } },
];
}} />)}
</div>
</section>
))}

View file

@ -1,5 +1,7 @@
// Catalog: products and subscriptions defined once in the config against both stores (catalog diff): a parity
// matrix (present, mismatched, missing, not sold there, not read), and price gaps between the stores per region.
import { useContextMenu } from '../../ui/ContextMenu';
import { copyToast, projectConsoles } from '../../ui/app-menu';
import '../../styles/catalog.css';
import type { Project } from '../../api';
import { type Key, t } from '../../i18n';
@ -71,6 +73,9 @@ export function CatalogTab({ project }: { project: Project }) {
const d = useData();
const e = d.statusOf(project);
const diff = useRead<CatalogDiff>(project.id, 'catalog.diff', { network: true, title: t('tab.catalog') });
const cm = useContextMenu();
const nav = useNav();
const plat = e?.app?.platforms;
const s = e?.summary ?? null;
const name = s?.name ?? project.name;
useExports('catalog', [
@ -101,7 +106,12 @@ export function CatalogTab({ project }: { project: Project }) {
{rows.map(r => {
const worst = [r.ios.state, r.android.state].includes('error') ? 'bad' : [r.ios.state, r.android.state].includes('warn') || r.kind === 'extra' ? 'warn' : 'ok';
return (
<li key={r.id} className={`cat-card t-${worst}`} data-item={r.id}>
<li key={r.id} className={`cat-card t-${worst}`} data-item={r.id} {...cm(() => [
{ label: t('cm.copyProductId'), icon: 'copy', shortcut: r.id, onSelect: () => copyToast(d, r.id) },
{ label: t('cm.showPrices'), icon: 'coins', onSelect: () => nav.go({ screen: 'app', appId: project.id, tab: 'prices' }) },
'sep',
...projectConsoles(d, plat?.ios?.ascAppId, !!plat?.ios, !!plat?.android),
])}>
<div className="cat-card-h">
<span className="cat-ic" aria-hidden="true"><Icon name={r.kind === 'subscription' ? 'refresh' : 'tag'} /></span>
<div className="cat-names">
@ -127,7 +137,10 @@ export function CatalogTab({ project }: { project: Project }) {
<thead><tr><th>{t('col.item')}</th><th>{t('col.region')}</th><th className="right">App Store</th><th className="right">Google Play</th><th className="right">{t('col.diff')}</th></tr></thead>
<tbody>
{gaps.slice(0, 12).map((g, i) => (
<tr key={i}>
<tr key={i} {...cm(() => [
{ label: t('cm.copyProductId'), icon: 'copy', shortcut: g.id, onSelect: () => copyToast(d, g.id) },
{ label: t('cm.copyRow'), icon: 'clipboard', onSelect: () => copyToast(d, [g.id, g.region, `${g.apple} ${g.currency}`, `${g.play} ${g.currency}`, `${g.diffPct} %`].join('\t')) },
])}>
<td className="mono-id">{g.id}</td><td>{g.region}</td>
<td className="right num">{num(g.apple, 2)} {g.currency}</td><td className="right num">{num(g.play, 2)} {g.currency}</td>
<td className="right"><Chip tone={g.diffPct > 25 ? 'bad' : 'warn'}>{`${num(g.diffPct, 1)} %`}</Chip></td>

View file

@ -1,5 +1,7 @@
// Checks: the offline rejection-risk scan of the project files (`scan`, every time), the offline config check and
// the read-only store readiness report (`validate`, on demand) with its ordered blockers and fix hints.
import { type MenuInput, type MenuItem, useContextMenu } from '../../ui/ContextMenu';
import { copyToast, openUrl } from '../../ui/app-menu';
import type { Project } from '../../api';
import { t } from '../../i18n';
import { ago } from '../../lib/format';
@ -26,6 +28,8 @@ const LEVEL: Record<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'a
/** Fix methods the window runs from a finding (cli/lib/fix-actions.ts FIX_METHODS): local config writes and store
* text pushes, each a dry run, the native dialog showing it, then the real run. Anything else gets no button. */
export const FIX_METHODS = new Set(['scan.write', 'play.listing', 'apple.listing', 'apple.review_details']);
/** The context-menu twin of FixButton (same write: dry run, then confirm). */
const fixItem = (w: ReturnType<typeof useWrite>, action: FixAction, what: string): MenuItem => ({ label: t('checks.fix'), icon: 'wand', onSelect: () => { void w.run({ method: action.method, args: action.args, title: t('checks.fixing', { what }), done: t('checks.fixed') }); } });
export const fixable = (a: FixAction | undefined): a is FixAction => !!a && FIX_METHODS.has(a.method) && !!a.args && typeof a.args === 'object';
/** The Fix button of one finding: its action as a confirm-gated write. */
@ -40,9 +44,10 @@ const SEV: Record<ScanSeverity, ['bad' | 'warn' | 'info', 'alertCircle' | 'alert
const SEV_ORDER: ScanSeverity[] = ['blocker', 'high', 'medium', 'low'];
/** One check as a card: tone icon tile, title, detail lines, the fix, and its actions on the right. */
function CheckCard({ tone, icon, title, children, actions, tags }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode }) {
function CheckCard({ tone, icon, title, children, actions, tags, menu }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode; menu?: () => MenuInput }) {
const cm = useContextMenu();
return (
<div className={`ck-card tone-${tone}`}>
<div className={`ck-card tone-${tone}`} {...(menu ? cm(menu) : {})}>
<span className={`ck-ic tone-bg-${tone}`}><Icon name={icon} /></span>
<div className="ck-main">
<div className="ck-title">{title}</div>
@ -61,6 +66,7 @@ function CheckCard({ tone, icon, title, children, actions, tags }: { tone: 'bad'
function ScanCard({ project }: { project: Project }) {
const s = useRead<ScanResult>(project.id, 'scan', { network: false, title: t('checks.scan') });
const w = useWrite(project.id);
const d = useData();
const findings = Array.isArray(s.data?.findings) ? s.data!.findings : [];
// Fix with AI: is claude installed (else Copy for AI); one run at a time
const [claude, setClaude] = useState<boolean | null>(null);
@ -86,6 +92,18 @@ function ScanCard({ project }: { project: Project }) {
const ev = Array.isArray(f.evidence) ? f.evidence : [];
return (
<CheckCard key={k} tone={tone} icon={icon} title={<><span className="ck-gl">{f.guideline}</span>{f.title}</>}
menu={() => [
!f.accepted && fixable(f.action) && fixItem(w, f.action, f.title),
!f.accepted && f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) },
ev[0] && { label: t('cm.revealEvidence'), icon: 'folder', shortcut: `${ev[0].file.split('/').pop()}:${ev[0].line}`, onSelect: () => { api.reveal(project.id, ev[0]!.file).catch(() => {}); } },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
{ label: t('cm.copyTitle'), icon: 'text', onSelect: () => copyToast(d, `${f.guideline} ${f.title}`) },
f.fix && { label: t('cm.copyFix'), icon: 'wand', onSelect: () => copyToast(d, f.fix) },
f.skill && { label: t('cm.copySkill'), icon: 'sparkle', shortcut: f.skill, onSelect: () => copyToast(d, f.skill) },
ev.length > 0 && { label: t('cm.copyEvidence'), icon: 'clipboard', onSelect: () => copyToast(d, ev.map(e => `${e.file}:${e.line}${e.text ? ` ${e.text}` : ''}`).join('\n')) },
] },
]}
tags={<><StatusPill tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</StatusPill><span className="ck-store"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} className="ic13" />{f.store === 'apple' ? 'App Store' : 'Google Play'}</span></>}
actions={<>{!f.accepted && <OpenLink url={f.url} />}{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}</>}>
{ev.slice(0, 2).map((e, j) => <span className="ck-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
@ -182,6 +200,14 @@ export function ChecksTab({ project }: { project: Project }) {
const [tone, icon] = LEVEL[f.level];
return (
<CheckCard key={`${f.level}${i}`} tone={tone} icon={icon} title={f.msg}
menu={() => [
f.level !== 'ok' && fixable(f.action) && fixItem(w, f.action, f.msg),
f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) },
'sep',
{ label: t('cm.copyTitle'), icon: 'copy', onSelect: () => copyToast(d, f.msg) },
f.fix && { label: t('cm.copyFix'), icon: 'wand', onSelect: () => copyToast(d, f.fix!) },
{ label: t('cm.copyCode'), icon: 'tag', shortcut: f.code, onSelect: () => copyToast(d, f.code) },
]}
tags={<span className="ck-store"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} className="ic13" />{f.store === 'apple' ? 'App Store' : 'Google Play'}</span>}
actions={<><OpenLink url={f.url} />{f.level !== 'ok' && fixable(f.action) && <FixButton action={f.action} what={f.msg} w={w} />}</>}>
{f.fix && <span className="ck-fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}

View file

@ -12,6 +12,7 @@ import { Icon } from '../../ui/icons';
import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
import { Section, StatusPill } from '../../ui/ds';
import { type MenuInput, useContextMenu } from '../../ui/ContextMenu';
// thumbnails by target + URL, three at a time
const cache = new Map<string, string | null>();
@ -79,6 +80,11 @@ export function LiveArt({ target, project }: { target: string; project?: Project
<WriteButton run={w} primary icon="download" w={{ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')}</WriteButton>
) : null;
const actions = <>{pull}<ReadControl r={r} /></>;
const cm = useContextMenu();
const setMenu = (): MenuInput => [
project && { label: t('art.pull'), icon: 'download', onSelect: () => { void w.run({ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }); } },
{ label: t('cm.readAgain'), icon: 'refresh', disabled: r.loading ? t('cm.reading') : false, onSelect: () => { r.refresh(); } },
];
if (!data) {
return (
<Section className="scr-art art-live" title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
@ -115,7 +121,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project
{!total ? <Empty compact illo="text" title={t('art.liveNone')}>{t('art.liveNoneText')}</Empty> : (
<div className="art-rows">
{sets.map(s => (
<section className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
<section {...cm(setMenu)} className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
<h3><span>{s.label}</span>{s.lang && <code>{s.lang}/{s.size}</code>}<Chip tone="outline" dot={false}>{s.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: s.tiles.length })}</span></h3>
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '170px' }}>
{s.tiles.map(tile => <LiveThumb key={`${tile.index}-${tile.thumb}`} target={target} tile={tile} name={String(tile.index + 1).padStart(2, '0')} />)}

View file

@ -1,6 +1,8 @@
// Prices: what every product costs in every territory on both stores (`prices export --json`, read-only), the price
// sheet as a CSV (save it anywhere, or write store/prices.csv for the terminal), and a price sheet compared with the
// stores (`prices import --dry --json`) and applied as an owner action: dry run, native dialog, then the real run.
import { useContextMenu } from '../../ui/ContextMenu';
import { copyToast } from '../../ui/app-menu';
import '../../styles/prices.css';
import { useEffect, useMemo, useState } from 'react';
import { api, errorText, type PricesCurrent, type PricesDiff, type Project } from '../../api';
@ -91,6 +93,7 @@ export function PricesTab({ project }: { project: Project }) {
const s = e?.summary ?? null;
const w = useWrite(project.id);
const runExport = useRunExport();
const cm = useContextMenu();
const [withAuto, setWithAuto] = useState(true);
const cur = useRead<PricesCurrent>(project.id, 'prices.current', { network: true, args: withAuto ? { with_automatic: true } : {}, title: t('prices.readTitle') });
const [sheet, setSheet] = useState<{ csv?: string } | null>(null);
@ -170,7 +173,11 @@ export function PricesTab({ project }: { project: Project }) {
<thead><tr><th>{t('col.item')}</th>{matrix!.regions.map(r => <th key={r}><Region code={r} /></th>)}</tr></thead>
<tbody>
{matrix!.rows.map(row => (
<tr key={row.product} className={`clickable${row.product === sel ? ' sel' : ''}`} onClick={() => setProduct(row.product)}>
<tr key={row.product} className={`clickable${row.product === sel ? ' sel' : ''}`} onClick={() => setProduct(row.product)} {...cm(() => [
{ label: t('cm.showPerRegion'), icon: 'globe', onSelect: () => setProduct(row.product) },
'sep',
{ label: t('cm.copyProductId'), icon: 'copy', shortcut: row.product, onSelect: () => copyToast(d, row.product) },
])}>
<td><span className="mono-id strong">{row.product}</span></td>
{matrix!.regions.map(r => <td key={r}><PriceStack r={row.cells[r]} apple={apple} play={play} /></td>)}
</tr>
@ -190,7 +197,11 @@ export function PricesTab({ project }: { project: Project }) {
<thead><tr><th>{t('col.region')}</th>{apple && <th className="right">App Store</th>}{apple && <th>{t('prices.colKind')}</th>}{play && <th className="right">Google Play</th>}<th className="right">{t('col.priceGap')}</th></tr></thead>
<tbody>
{regions.map(r => (
<tr key={r.region} className={r.gapPct !== null && r.gapPct >= GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined}>
<tr key={r.region} className={r.gapPct !== null && r.gapPct >= GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined} {...cm(() => [
r.apple && { label: t('cm.copyPriceOf', { store: 'App Store' }), icon: 'copy', shortcut: money(r.apple.price, r.apple.currency), onSelect: () => copyToast(d, money(r.apple!.price, r.apple!.currency)) },
r.play && { label: t('cm.copyPriceOf', { store: 'Google Play' }), icon: 'copy', shortcut: money(r.play.price, r.play.currency), onSelect: () => copyToast(d, money(r.play!.price, r.play!.currency)) },
{ label: t('cm.copyRegion'), icon: 'globe', shortcut: r.region, onSelect: () => copyToast(d, r.region) },
])}>
<td className="strong"><Region code={r.region} /></td>
{apple && <td className="right num">{r.apple ? money(r.apple.price, r.apple.currency) : <span className="muted">–</span>}{r.scheduled && <div className="small muted">{t('prices.scheduled', { price: money(r.scheduled.price, r.scheduled.currency), date: r.scheduled.startDate ?? '' })}</div>}</td>}
{apple && <td>{r.apple ? <Chip tone={r.apple.kind === 'auto' ? 'neutral' : 'accent'}>{r.apple.kind === 'auto' ? t('prices.auto') : t('prices.manual')}</Chip> : null}</td>}

View file

@ -17,6 +17,8 @@ import { RolloutBar } from '../../ui/charts';
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
import { useContextMenu } from '../../ui/ContextMenu';
import { releaseMenu } from '../../ui/app-menu';
import '../../styles/releases.css';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -49,8 +51,10 @@ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st:
const ph = r ? phaseLabel(r) : null;
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
const d = useData();
const menu = useContextMenu();
return (
<article className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
<article {...menu(() => releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
<header className="rel-store-h">
<span className="rel-mark"><StoreGlyph store={lane.store} /></span>
<div className="rel-who">
@ -168,6 +172,7 @@ function Train({ title, train, tf, tone, app, st }: { title: string; train: Rele
export function ReleasesTab({ app }: { app: UnifiedApp }) {
const d = useData();
const menu = useContextMenu();
const l = d.liveFor(app);
const project = app.project;
const hue = useTint(d.iconFor(app), app.name);
@ -221,7 +226,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
<tbody>
{history.map(h => (
<tr key={h.version}>
<tr key={h.version} {...menu(() => [
...(h.ios ? [{ label: 'App Store', icon: 'phone' as const, submenu: releaseMenu(d, app, 'ios', h.ios.version, h.ios.build) }] : []),
...(h.android ? [{ label: 'Google Play', icon: 'phone' as const, submenu: releaseMenu(d, app, 'android', h.android.version, h.android.build) }] : []),
])}>
<td className="strong num rel-hv">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
<td className="num muted">{h.ios?.build ?? '–'}</td>

View file

@ -1,6 +1,8 @@
// Reviews: the inbox of both stores with the star distribution and filters, and per review its local reply draft
// (.storebound/replies) with the send checks. Approving a draft is a local, confirmed write; sending stays in the
// terminal (`storebound reviews send`): replies are public.
import { type MenuInput, useContextMenu } from '../../ui/ContextMenu';
import { consoleItem, copyToast } from '../../ui/app-menu';
import { useEffect, useMemo, useState } from 'react';
import type { DraftsResult, ReplyDraft } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
@ -91,6 +93,17 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
const [f, setF] = useState<ReviewFilter>({ store: 'all', stars: [], unanswered: false, query: '' });
const [sel, setSel] = useState<string | null>(null);
const w = useWrite(project?.id ?? null);
const cm = useContextMenu();
const reviewMenu = (r: Review, k: string, dr: ReplyDraft | undefined): MenuInput => [
{ label: t('cm.openDraft'), icon: 'edit', onSelect: () => setSel(k) },
!dr && !r.reply && project && { label: t('reviews.makeDrafts'), icon: 'text', onSelect: () => { void w.run({ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: () => drafts.refresh() }); } },
'sep',
{ label: t('cm.copyReview'), icon: 'copy', onSelect: () => copyToast(d, [r.title, r.body].filter(Boolean).join('\n\n')) },
r.reply && { label: t('cm.copyReply'), icon: 'copy', onSelect: () => copyToast(d, r.reply!.text) },
dr?.reply && { label: t('cm.copyDraft'), icon: 'copy', onSelect: () => copyToast(d, dr.reply) },
'sep',
consoleItem(d, app, r.store === 'apple' ? 'ios' : 'android'),
];
const all = list.data ?? [];
const stats = useMemo(() => starStats(f.store === 'all' ? all : all.filter(r => r.store === f.store)), [all, f.store]);
@ -154,7 +167,7 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
const dr = byFile.get(draftName(r));
const k = `${r.store}:${r.id}`;
return (
<button key={k} type="button" role="option" aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)}>
<button key={k} type="button" role="option" aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)} {...cm(() => reviewMenu(r, k, dr))}>
<span className="rv2-c-top">
<Stars n={r.rating} />
<span className="rv2-c-date">{date(r.date)}</span>