Desktop portfolio v2: greeting with what is moving, store-style app cards (rendered screenshots or the icon on its tint), two-store release pipeline, crash sparkline, one next-step button; What changed feed and Fix first queue; Cards / Table switch keeps the old table
This commit is contained in:
parent
5e043fe17c
commit
27db41c3f0
4 changed files with 519 additions and 35 deletions
|
|
@ -1865,4 +1865,51 @@ export const en = {
|
|||
'gs.syncHint': 'Dry run first, then you confirm; nothing is submitted or published.',
|
||||
'gs.synced': 'Game services in step',
|
||||
'gs.setup': 'Setup plan',
|
||||
'pf.morning': "Good morning.",
|
||||
'pf.afternoon': "Good afternoon.",
|
||||
'pf.evening': "Good evening.",
|
||||
'pf.moving': "{count} releases are moving",
|
||||
'pf.moving_one': "1 release is moving",
|
||||
'pf.movingNone': "Nothing is moving right now",
|
||||
'pf.notMovingUnknown': "Read the stores to see what is moving",
|
||||
'pf.readAgo': "stores read {when}",
|
||||
'pf.layout': "Layout",
|
||||
'pf.cards': "Cards",
|
||||
'pf.table': "Table",
|
||||
'pf.next': "next",
|
||||
'pf.crash': "crash",
|
||||
'pf.ctaFix': "Fix {count} blockers",
|
||||
'pf.ctaFix_one': "Fix 1 blocker",
|
||||
'pf.ctaRollout': "Roll out (now {share})",
|
||||
'pf.ctaRelease': "Release {version}",
|
||||
'pf.ctaSubmit': "Submit {version}",
|
||||
'pf.ctaReview': "Follow review of {version}",
|
||||
'pf.ctaWarnings': "Look at {count} warnings",
|
||||
'pf.ctaWarnings_one': "Look at 1 warning",
|
||||
'pf.ctaUpload': "Upload a build",
|
||||
'pf.ctaOpen': "Open releases",
|
||||
'pf.feed': "What changed",
|
||||
'pf.feedSub': "Releases, health and folders, newest first",
|
||||
'pf.feedEmpty': "Nothing changed yet",
|
||||
'pf.feedEmptyText': "Read the stores to see releases and health here.",
|
||||
'pf.evLive': "{version} is live on {store}",
|
||||
'pf.evRollout': "{version} is rolling out to {share} on {store}",
|
||||
'pf.evReview': "{version} went into review on {store}",
|
||||
'pf.evApproved': "{version} was approved on {store}",
|
||||
'pf.evRejected': "{version} was rejected on {store}",
|
||||
'pf.evHalted': "{version} rollout halted on {store}",
|
||||
'pf.evTesting': "{version} is in testing on {store}",
|
||||
'pf.evDraft': "{version} draft on {store}",
|
||||
'pf.evCrash': "crash rate {rate} on Google Play",
|
||||
'pf.evAdded': "folder added to the workspace",
|
||||
'pf.more': "Show {count} more",
|
||||
'pf.less': "Show less",
|
||||
'pf.fixFirst': "Fix first",
|
||||
'pf.fixFirstSub': "{count} items, blockers first",
|
||||
'pf.fixFirstSub_one': "1 item",
|
||||
'pf.blocker': "Blocker",
|
||||
'pf.warning': "Warning",
|
||||
'pf.note': "Note",
|
||||
'pf.fixIn': "Fix in {tab}",
|
||||
'pf.openTab': "Open {tab}",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1867,4 +1867,51 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'gs.syncHint': 'Önce deneme çalıştırması, sonra sen onaylarsın; hiçbir şey gönderilmez ya da yayınlanmaz.',
|
||||
'gs.synced': 'Oyun hizmetleri eşit',
|
||||
'gs.setup': 'Kurulum planı',
|
||||
'pf.morning': "Günaydın.",
|
||||
'pf.afternoon': "İyi günler.",
|
||||
'pf.evening': "İyi akşamlar.",
|
||||
'pf.moving': "{count} sürüm ilerliyor",
|
||||
'pf.moving_one': "1 sürüm ilerliyor",
|
||||
'pf.movingNone': "Şu an ilerleyen sürüm yok",
|
||||
'pf.notMovingUnknown': "Neyin ilerlediğini görmek için mağazaları oku",
|
||||
'pf.readAgo': "mağazalar {when} okundu",
|
||||
'pf.layout': "Görünüm",
|
||||
'pf.cards': "Kartlar",
|
||||
'pf.table': "Tablo",
|
||||
'pf.next': "sıradaki",
|
||||
'pf.crash': "çökme",
|
||||
'pf.ctaFix': "{count} engeli düzelt",
|
||||
'pf.ctaFix_one': "1 engeli düzelt",
|
||||
'pf.ctaRollout': "Dağıtımı sürdür (şu an {share})",
|
||||
'pf.ctaRelease': "{version} sürümünü yayınla",
|
||||
'pf.ctaSubmit': "{version} sürümünü gönder",
|
||||
'pf.ctaReview': "{version} incelemesini izle",
|
||||
'pf.ctaWarnings': "{count} uyarıya bak",
|
||||
'pf.ctaWarnings_one': "1 uyarıya bak",
|
||||
'pf.ctaUpload': "Derleme yükle",
|
||||
'pf.ctaOpen': "Sürümleri aç",
|
||||
'pf.feed': "Neler değişti",
|
||||
'pf.feedSub': "Sürümler, sağlık ve klasörler, en yenisi üstte",
|
||||
'pf.feedEmpty': "Henüz değişiklik yok",
|
||||
'pf.feedEmptyText': "Sürümleri ve sağlığı burada görmek için mağazaları oku.",
|
||||
'pf.evLive': "{version}, {store} üzerinde yayında",
|
||||
'pf.evRollout': "{version}, {store} üzerinde {share} kullanıcıya dağıtılıyor",
|
||||
'pf.evReview': "{version}, {store} incelemesine girdi",
|
||||
'pf.evApproved': "{version}, {store} tarafından onaylandı",
|
||||
'pf.evRejected': "{version}, {store} tarafından reddedildi",
|
||||
'pf.evHalted': "{version} dağıtımı {store} üzerinde durduruldu",
|
||||
'pf.evTesting': "{version}, {store} üzerinde testte",
|
||||
'pf.evDraft': "{version}, {store} üzerinde taslak",
|
||||
'pf.evCrash': "Google Play çökme oranı {rate}",
|
||||
'pf.evAdded': "klasör çalışma alanına eklendi",
|
||||
'pf.more': "{count} tane daha göster",
|
||||
'pf.less': "Daha az göster",
|
||||
'pf.fixFirst': "Önce bunları düzelt",
|
||||
'pf.fixFirstSub': "{count} madde, önce engeller",
|
||||
'pf.fixFirstSub_one': "1 madde",
|
||||
'pf.blocker': "Engel",
|
||||
'pf.warning': "Uyarı",
|
||||
'pf.note': "Not",
|
||||
'pf.fixIn': "{tab} sekmesinde düzelt",
|
||||
'pf.openTab': "{tab} sekmesini aç",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
// Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort,
|
||||
// grouping), one row per app (live version and state per store with its place on the release track, next version,
|
||||
// crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state.
|
||||
// Portfolio: every app of every studio on both stores. A greeting that says what is moving, KPIs, a toolbar (search,
|
||||
// studio chips with counts, sort, grouping, cards / table), one store-listing card per app (rendered screenshots, the
|
||||
// release track per store, crash rate, one next step) or the dense table (live version and state per store, next
|
||||
// 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 { useEffect, useMemo, useState } from 'react';
|
||||
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
|
||||
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 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, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
|
||||
import { useNav } from '../nav';
|
||||
import { type Attention, attention, healthSeries, type HealthSnapshot, hueOf, 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';
|
||||
|
|
@ -95,35 +97,304 @@ function HealthCell({ c, android }: { c: { h: HealthSnapshot; at: string } | nul
|
|||
);
|
||||
}
|
||||
|
||||
function AttentionList({ items }: { items: Attention[] }) {
|
||||
const nav = useNav();
|
||||
if (!items.length) return <Empty compact illo="shield" title={t('portfolio.allClear')}>{t('portfolio.allClearText')}</Empty>;
|
||||
return (
|
||||
<div className="list">
|
||||
{items.slice(0, 8).map((a, i) => (
|
||||
<button key={i} className="li" onClick={() => nav.go({ screen: 'app', appId: a.projectId, tab: a.tab ?? 'checks' })}>
|
||||
<span className={`li-icon tone-bg-${a.tone}`}><Icon name={a.tone === 'bad' ? 'alertCircle' : a.tone === 'warn' ? 'alert' : 'info'} /></span>
|
||||
<span className="li-main"><span className="li-title">{a.text}</span><span className="li-sub">{a.app}{a.tab ? ` · ${t(`tab.${a.tab}`)}` : ''}</span></span>
|
||||
<Icon name="chevronRight" className="ic14 muted" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Newest store activity of an app (release record date), else when its folder was added. */
|
||||
function updatedAt(a: UnifiedApp, l: AppStatus | undefined): string | null {
|
||||
const dates = (l?.release.all ?? []).map(r => r.date).filter((x): x is string => !!x);
|
||||
return dates.sort().at(-1) ?? a.project?.added ?? null;
|
||||
}
|
||||
|
||||
// ---- App cards ----
|
||||
|
||||
/** Every release record of an app (live read first). */
|
||||
function releasesOf(l: AppStatus | undefined): StoreRelease[] {
|
||||
if (!l) return [];
|
||||
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)) {
|
||||
return <span className="pf-health muted small">{!android || (c && !play) ? t('portfolio.healthNoPlay') : c && play && isError(play) ? t('portfolio.unreadable') : t('portfolio.healthNotRead')}</span>;
|
||||
}
|
||||
const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
return (
|
||||
<span className="pf-health" title={t('portfolio.healthTitle', { when: ago(c.at) })}>
|
||||
<span className="pf-spark"><Sparkline compact values={s.values.slice(-14)} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: t('health.crash'), value: pct(s.current) })} /></span>
|
||||
<span className="pf-health-v"><span className="small muted">{t('pf.crash')}</span><span className={`num strong ${s.tone === 'bad' ? 'tbad' : s.tone === 'warn' ? 'twarn' : ''}`}>{pct(s.current)}</span></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardRow { app: UnifiedApp; e: AppStatus | undefined; l: AppStatus | undefined; att: Attention[] }
|
||||
|
||||
function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; health: { h: HealthSnapshot; at: string } | null | undefined; studio?: ReactNode }) {
|
||||
const nav = useNav();
|
||||
const { app, e, l, att } = r;
|
||||
const projectId = app.project && !app.project.missing ? app.project.id : null;
|
||||
const shots = useShots(projectId);
|
||||
const hue = useTint(icon, app.name);
|
||||
const all = releasesOf(l);
|
||||
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 ?? '';
|
||||
const next = l?.release.next?.version ?? 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;
|
||||
return (
|
||||
<article className="pf-card" style={style} aria-label={name}>
|
||||
<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>
|
||||
<div className="pf-body">
|
||||
<div className="pf-head">
|
||||
<AppIcon name={app.name} src={icon} className="pf-icon" />
|
||||
<div className="pf-title">
|
||||
<button className="pf-name" onClick={() => go('releases')}>{name}</button>
|
||||
<span className="pf-id mono-id">{sub}{!app.project && <span className="so-inline" title={t('apps.storeOnlyHint')}><Icon name="cloud" /><span className="sr">{t('apps.storeOnly')}</span></span>}</span>
|
||||
</div>
|
||||
<div className="pf-next">
|
||||
<span className="pf-next-l">{t('pf.next')}</span>
|
||||
<span className="pf-next-v num">{next ?? '–'}</span>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- What changed ----
|
||||
|
||||
interface Ev { key: string; app: UnifiedApp; text: string; at: string; icon: IconName; tone: Tone }
|
||||
|
||||
function events(rows: CardRow[], health: Record<string, { h: HealthSnapshot; at: string } | null>): Ev[] {
|
||||
const out: Ev[] = [];
|
||||
for (const r of rows) {
|
||||
for (const rel of releasesOf(r.l)) {
|
||||
if (!rel.date) continue;
|
||||
const store = rel.store === 'ios' ? 'App Store' : 'Google Play', version = rel.version ?? '';
|
||||
const ev = (key: Key, icon: IconName, tone: Tone, vars: Record<string, string> = {}) =>
|
||||
out.push({ key: `${r.app.key}|${rel.store}|${version}|${rel.phase}`, app: r.app, text: t(key, { version, store, ...vars }), at: rel.date!, icon, tone });
|
||||
switch (rel.phase) {
|
||||
case 'live': ev('pf.evLive', 'check', 'ok'); break;
|
||||
case 'rollout': ev('pf.evRollout', 'rocket', 'accent', { share: share(rel.fraction ?? 1) }); break;
|
||||
case 'review': case 'sent': ev('pf.evReview', 'clock', 'warn'); break;
|
||||
case 'approved': ev('pf.evApproved', 'check', 'ok'); break;
|
||||
case 'rejected': ev('pf.evRejected', 'alertCircle', 'bad'); break;
|
||||
case 'halted': ev('pf.evHalted', 'pause', 'bad'); break;
|
||||
case 'testing': ev('pf.evTesting', 'flask', 'info'); break;
|
||||
case 'draft': ev('pf.evDraft', 'edit', 'neutral'); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
const c = health[r.app.target];
|
||||
const play = c?.h.play;
|
||||
if (c && play && !isError(play)) {
|
||||
const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
if (s.current !== null) out.push({ key: `${r.app.key}|health`, app: r.app, text: t('pf.evCrash', { rate: pct(s.current) }), at: c.at, icon: 'activity', tone: s.tone });
|
||||
}
|
||||
if (r.app.project?.added) out.push({ key: `${r.app.key}|added`, app: r.app, text: t('pf.evAdded'), at: r.app.project.added, icon: 'folderPlus', tone: 'neutral' });
|
||||
}
|
||||
return out.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||
}
|
||||
|
||||
function Activity({ items, iconFor }: { items: Ev[]; iconFor: (a: UnifiedApp) => string | null }) {
|
||||
const nav = useNav();
|
||||
const [more, setMore] = useState(false);
|
||||
if (!items.length) return <Empty compact illo="unknown" title={t('pf.feedEmpty')}>{t('pf.feedEmptyText')}</Empty>;
|
||||
const shown = more ? items : items.slice(0, 8);
|
||||
return (
|
||||
<>
|
||||
<ol className="pf-feed">
|
||||
{shown.map(ev => (
|
||||
<li key={ev.key}>
|
||||
<button className="pf-ev" onClick={() => nav.go({ screen: 'app', appId: ev.app.key, tab: ev.icon === 'activity' ? 'health' : 'releases' })}>
|
||||
<span className={`pf-ev-ic tone-bg-${ev.tone === 'accent' ? 'info' : ev.tone}`}><Icon name={ev.icon} /></span>
|
||||
<AppIcon name={ev.app.name} src={iconFor(ev.app)} className="pf-ev-app" />
|
||||
<span className="pf-ev-t"><b>{ev.app.name}</b> {ev.text}</span>
|
||||
<time className="pf-ev-at small muted" dateTime={ev.at}>{ago(ev.at)}</time>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{items.length > 8 && <button className="btn btn-ghost btn-sm pf-more" aria-expanded={more} onClick={() => setMore(x => !x)}>{more ? t('pf.less') : t('pf.more', { count: items.length - 8 })}</button>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FixFirst({ items, appOf, iconFor }: { items: Attention[]; appOf: (key: string) => UnifiedApp | undefined; iconFor: (a: UnifiedApp) => string | null }) {
|
||||
const nav = useNav();
|
||||
const [more, setMore] = useState(false);
|
||||
if (!items.length) return <Empty compact illo="shield" title={t('portfolio.allClear')}>{t('portfolio.allClearText')}</Empty>;
|
||||
const shown = more ? items : items.slice(0, 5);
|
||||
return (
|
||||
<div className="pf-fixes">
|
||||
{shown.map((a, i) => {
|
||||
const app = appOf(a.projectId), tab = a.tab ?? 'checks';
|
||||
return (
|
||||
<div key={i} className={`pf-fix tone-${a.tone}`}>
|
||||
<div className="pf-fix-head">
|
||||
{app ? <AppIcon name={app.name} src={iconFor(app)} className="pf-fix-icon" /> : <Icon name="alert" />}
|
||||
<span className="pf-fix-app">{a.app}</span>
|
||||
<Chip tone={a.tone}>{a.tone === 'bad' ? t('pf.blocker') : a.tone === 'warn' ? t('pf.warning') : t('pf.note')}</Chip>
|
||||
</div>
|
||||
<p className="pf-fix-t">{a.text}</p>
|
||||
{a.hint && <p className="pf-fix-h small muted">{a.hint}</p>}
|
||||
<div className="pf-fix-a">
|
||||
<button className={`btn btn-sm ${a.tone === 'bad' ? 'btn-primary' : ''}`} onClick={() => nav.go({ screen: 'app', appId: a.projectId, tab })}>
|
||||
<Icon name={tab === 'checks' ? 'wand' : 'arrowRight'} />{tab === 'checks' ? t('pf.fixIn', { tab: t('tab.checks') }) : t('pf.openTab', { tab: t(`tab.${tab}`) })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.length > 5 && <button className="btn btn-ghost btn-sm pf-more" aria-expanded={more} onClick={() => setMore(x => !x)}>{more ? t('pf.less') : t('pf.more', { count: items.length - 5 })}</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function greeting(now = new Date()): string {
|
||||
const h = now.getHours();
|
||||
return h < 5 ? t('pf.evening') : h < 12 ? t('pf.morning') : h < 18 ? t('pf.afternoon') : t('pf.evening');
|
||||
}
|
||||
|
||||
const VIEW_KEY = 'storebound.portfolio.v1';
|
||||
interface View { studio: string; sort: SortBy; group: boolean }
|
||||
interface View { studio: string; sort: SortBy; group: boolean; layout: 'cards' | 'table' }
|
||||
function loadView(): View {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(VIEW_KEY) ?? '{}') as Partial<View>;
|
||||
return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false };
|
||||
} catch { return { studio: 'all', sort: 'attention', group: true }; }
|
||||
return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false, layout: v.layout === 'table' ? 'table' : 'cards' };
|
||||
} catch { return { studio: 'all', sort: 'attention', group: true, layout: 'cards' }; }
|
||||
}
|
||||
|
||||
export function Portfolio() {
|
||||
|
|
@ -226,10 +497,22 @@ export function Portfolio() {
|
|||
};
|
||||
|
||||
const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead');
|
||||
const moving = rolling.length + review.length;
|
||||
const headline = !known ? t('pf.notMovingUnknown') : moving ? t('pf.moving', { count: moving }) : t('pf.movingNone');
|
||||
const sortedAtt = [...att].sort((a, b) => (a.tone === 'bad' ? 0 : a.tone === 'warn' ? 1 : 2) - (b.tone === 'bad' ? 0 : b.tone === 'warn' ? 1 : 2));
|
||||
const feed = events(rows, health);
|
||||
const appOf = (key: string) => visible.find(a => a.key === key);
|
||||
const cards = (list: typeof all) => (
|
||||
<div className="pf-grid">{list.map(r => <AppCard key={r.app.key} r={r} icon={d.iconFor(r.app)} health={health[r.app.target]} />)}</div>
|
||||
);
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.portfolio')}</h1><p>{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}</p></div>
|
||||
<div className="page pf2">
|
||||
<div className="ph pf-hero">
|
||||
<div>
|
||||
<p className="pf-greet">{greeting()}</p>
|
||||
<h1 className="pf-headline">{headline}</h1>
|
||||
<p>{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length} title={t('action.importAppsHint')}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy} title={t('action.readStoresHint')}>
|
||||
|
|
@ -263,9 +546,22 @@ export function Portfolio() {
|
|||
<span className="grow" />
|
||||
<Seg value={view.sort} onChange={sort => setView({ sort })} label={t('portfolio.sort')} options={[['attention', t('portfolio.sortAttention'), 'alert'], ['recent', t('portfolio.sortRecent'), 'clock'], ['name', t('portfolio.sortName')]]} />
|
||||
{d.studios.length > 0 && <label className="check"><input type="checkbox" checked={view.group} onChange={e => setView({ group: e.target.checked })} />{t('portfolio.group')}</label>}
|
||||
<Seg value={view.layout} onChange={layout => setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} />
|
||||
</div>
|
||||
|
||||
<Card title={t('portfolio.apps')} icon="portfolio" sub={known ? t('portfolio.appsSub', { when: ago(d.live?.at) }) : t('portfolio.appsSubOffline')} pad={false}
|
||||
{view.layout === 'cards' && (rows.length ? grouped.map(g => {
|
||||
const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null;
|
||||
return (
|
||||
<section key={g.studio ?? (g.studio === null ? 'none' : 'all')} className="pf-group" aria-label={s === undefined ? t('portfolio.apps') : s?.name ?? t('studio.none')}>
|
||||
{s !== undefined && (
|
||||
<h2 className="pf-group-h"><StudioMark studio={s} size="sm" /><span>{s?.name ?? t('studio.none')}</span><span className="muted small">{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}</span></h2>
|
||||
)}
|
||||
{cards(g.rows)}
|
||||
</section>
|
||||
);
|
||||
}) : <Card pad={false}><Empty compact illo="unknown" title={t('portfolio.noMatch')}>{t('portfolio.noMatchText')}</Empty></Card>)}
|
||||
|
||||
{view.layout === 'table' && <Card title={t('portfolio.apps')} icon="portfolio" sub={known ? t('portfolio.appsSub', { when: ago(d.live?.at) }) : t('portfolio.appsSubOffline')} pad={false}
|
||||
actions={<div className="legend-row"><span><span className="pips"><b className="on" /></span>{t('legend.done')}</span><span>{t('legend.stages')}</span></div>}>
|
||||
{rows.length ? (
|
||||
<div className="table-wrap">
|
||||
|
|
@ -289,11 +585,13 @@ export function Portfolio() {
|
|||
</table>
|
||||
</div>
|
||||
) : <Empty compact illo="unknown" title={t('portfolio.noMatch')}>{t('portfolio.noMatchText')}</Empty>}
|
||||
</Card>
|
||||
</Card>}
|
||||
|
||||
<div className="grid g-main-side">
|
||||
<Card title={t('portfolio.attention')} icon="alert" sub={t('portfolio.attentionSub')} pad={false}><AttentionList items={[...att].sort((a, b) => (a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1))} /></Card>
|
||||
<Card title={t('portfolio.workspace')} icon="settings">
|
||||
<div className="pf-cols">
|
||||
<Card title={t('pf.feed')} icon="activity" sub={t('pf.feedSub')} pad={false}><Activity items={feed} iconFor={d.iconFor} /></Card>
|
||||
<div className="pf-side">
|
||||
<Card title={t('pf.fixFirst')} icon="alert" sub={sortedAtt.length ? t('pf.fixFirstSub', { count: sortedAtt.length }) : t('portfolio.attentionSub')} pad={false}><FixFirst items={sortedAtt} appOf={appOf} iconFor={d.iconFor} /></Card>
|
||||
<Card title={t('portfolio.workspace')} icon="settings">
|
||||
<dl className="kv">
|
||||
<dt>{t('nav.studios')}</dt><dd>{d.studios.length ? t('portfolio.studiosN', { count: d.studios.length }) : <button className="link" onClick={() => nav.go({ screen: 'accounts' })}>{t('studio.add')}</button>}</dd>
|
||||
<dt>{t('nav.accounts')}</dt><dd>{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : <button className="link" onClick={() => nav.go({ screen: 'accounts' })}>{t('action.addAccount')}</button>}</dd>
|
||||
|
|
@ -302,7 +600,8 @@ export function Portfolio() {
|
|||
<dt>{t('portfolio.storesRead')}</dt><dd>{d.live ? ago(d.live.at) : t('time.never')}</dd>
|
||||
<dt>CLI</dt><dd>{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1019,3 +1019,94 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
|
|||
.ai-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 8px; }
|
||||
.ai-panel .row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.ai-panel .ai-diff { max-height: 320px; }
|
||||
|
||||
/* Portfolio home (screens/Portfolio.tsx, .pf2): greeting, KPI row, store-listing app cards with the release track per
|
||||
store, then "What changed" and "Fix first". Cards are tinted by the app icon's hue (--tint-h, set per card). */
|
||||
:root { --pf-tint-s: 78%; --pf-tint-l: 62%; --pf-tint-a: .16; --pf-tint-a2: .07; --pf-seg: #e6e9f0; --pf-shot-l: 93%; --pf-shot-s: 60%; }
|
||||
:root[data-theme="dark"] { --pf-tint-l: 58%; --pf-tint-a: .26; --pf-tint-a2: .10; --pf-seg: #232e42; --pf-shot-l: 15%; --pf-shot-s: 45%; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --pf-tint-l: 58%; --pf-tint-a: .26; --pf-tint-a2: .10; --pf-seg: #232e42; --pf-shot-l: 15%; --pf-shot-s: 45%; } }
|
||||
.pf2 { gap: 24px; }
|
||||
.pf2 .pf-hero h1.pf-headline { font-size: 30px; line-height: 1.15; letter-spacing: -.025em; font-weight: 780; margin: 2px 0 6px; }
|
||||
.pf2 .pf-greet { margin: 0; font-size: var(--t-section); font-weight: 600; color: var(--accent); }
|
||||
.pf2 .kpis .kpi { padding: 18px 20px; border-radius: 16px; }
|
||||
.pf2 .kpis .kpi .k-value { font-size: 30px; }
|
||||
.pf-group { display: grid; gap: 12px; }
|
||||
.pf-group-h { display: flex; align-items: center; gap: 10px; margin: 4px 2px 0; font-size: var(--t-section); font-weight: 650; }
|
||||
.pf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 20px; }
|
||||
.pf-card {
|
||||
--tint: hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l));
|
||||
--shot-bg: hsl(var(--tint-h) var(--pf-shot-s) var(--pf-shot-l));
|
||||
position: relative; display: grid; grid-template-rows: auto 1fr; border-radius: 18px; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(120% 70% at 100% 0%, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / var(--pf-tint-a)), transparent 60%),
|
||||
linear-gradient(180deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / var(--pf-tint-a2)), transparent 50%), var(--surface);
|
||||
border: 1px solid color-mix(in srgb, var(--tint) 22%, var(--line));
|
||||
box-shadow: var(--shadow-2);
|
||||
transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease), border-color var(--speed) var(--ease);
|
||||
}
|
||||
.pf-card:hover, .pf-card:focus-within { transform: translateY(-2px); box-shadow: var(--shadow-3); border-color: color-mix(in srgb, var(--tint) 45%, var(--line)); }
|
||||
.pf-shots { display: flex; gap: 10px; height: 200px; padding: 14px 14px 0; overflow: hidden; background: linear-gradient(160deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .35), var(--shot-bg) 70%); -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); }
|
||||
.pf-shots img { height: 100%; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 12px 12px 0 0; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .45); flex: none; transition: transform var(--speed) var(--ease); }
|
||||
.pf-card:hover .pf-shots img { transform: translateY(-3px); }
|
||||
.pf-shots.empty { align-items: center; justify-content: center; padding: 0; -webkit-mask-image: none; mask-image: none;
|
||||
background: radial-gradient(60% 90% at 50% 60%, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .45), transparent 70%), linear-gradient(160deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .22), var(--shot-bg)); }
|
||||
.pf-shots .pf-hero-icon { width: 96px; height: 96px; border-radius: 24px; font-size: 30px; box-shadow: 0 18px 40px -14px hsl(var(--tint-h) 70% 30% / .7), inset 0 0 0 1px rgba(255, 255, 255, .12); transition: transform var(--speed) var(--ease); }
|
||||
.pf-card:hover .pf-hero-icon { transform: scale(1.04) rotate(-2deg); }
|
||||
.pf-body { display: grid; gap: 16px; padding: 18px 20px; align-content: start; }
|
||||
.pf-head { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.pf-head .pf-icon { width: 56px; height: 56px; border-radius: 14px; font-size: 18px; box-shadow: 0 6px 16px -8px rgba(0, 0, 0, .35); }
|
||||
.pf-title { display: grid; gap: 2px; min-width: 0; flex: 1; }
|
||||
.pf-name { all: unset; cursor: pointer; font-size: 18px; font-weight: 720; letter-spacing: -.01em; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px; }
|
||||
.pf-name:hover { color: var(--accent); }
|
||||
.pf-name:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.pf-id { font-size: var(--t-meta); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; gap: 6px; align-items: center; }
|
||||
.pf-next { display: grid; justify-items: end; flex: none; }
|
||||
.pf-next-l { font-size: var(--t-micro); color: var(--muted); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
|
||||
.pf-next-v { font-size: 22px; font-weight: 760; letter-spacing: -.02em; color: var(--ink); }
|
||||
.pf-pipe { display: grid; gap: 7px; }
|
||||
.pf-stages, .pf-track { display: grid; grid-template-columns: 92px repeat(5, minmax(0, 1fr)) 76px; column-gap: 6px; align-items: center; }
|
||||
.pf-stages span { font-size: var(--t-micro); color: var(--muted); text-align: center; }
|
||||
.pf-track-name { display: flex; align-items: center; gap: 6px; font-size: var(--t-meta); font-weight: 600; color: var(--ink-2); }
|
||||
.pf-segs { display: contents; }
|
||||
.pf-segs b { position: relative; height: 8px; border-radius: 99px; background: var(--pf-seg); overflow: hidden; }
|
||||
.pf-segs b.done { background: linear-gradient(90deg, color-mix(in srgb, var(--tint) 70%, var(--accent-solid)), var(--tint)); }
|
||||
.pf-segs b.at { background: linear-gradient(90deg, var(--tint), color-mix(in srgb, var(--tint) 60%, #fff)); box-shadow: 0 0 0 3px hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .22); overflow: visible; }
|
||||
.pf-segs b.at.t-warn { background: var(--warn-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn-solid) 25%, transparent); }
|
||||
.pf-segs b.at.t-bad { background: var(--bad-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad-solid) 25%, transparent); }
|
||||
.pf-segs b.at:has(i) { background: var(--pf-seg); }
|
||||
.pf-segs b i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; background: var(--tint); }
|
||||
.pf-track-v { font-size: var(--t-meta); color: var(--ink-2); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pf-track.off .pf-track-name, .pf-track.off .pf-track-v { color: var(--faint); }
|
||||
.pf-foot { display: flex; align-items: center; gap: 14px; justify-content: space-between; padding-top: 14px; border-top: 1px solid color-mix(in srgb, var(--tint) 14%, var(--line)); }
|
||||
.pf-health { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.pf-spark { display: block; width: 120px; height: 34px; }
|
||||
.pf-health-v { display: grid; line-height: 1.2; }
|
||||
.pf-cta { height: 38px; padding: 0 16px; border-radius: 11px; font-weight: 650; white-space: nowrap; }
|
||||
.pf-cols { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 20px; align-items: start; }
|
||||
.pf-side { display: grid; gap: 20px; }
|
||||
.pf-feed { list-style: none; margin: 0; padding: 6px; display: grid; }
|
||||
.pf-ev { all: unset; box-sizing: border-box; width: 100%; display: grid; grid-template-columns: 32px 28px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 10px 12px; border-radius: 12px; cursor: pointer; transition: background var(--speed) var(--ease); }
|
||||
.pf-ev:hover { background: var(--hover); }
|
||||
.pf-ev:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.pf-ev-ic { width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; }
|
||||
.pf-ev-ic svg { width: 16px; height: 16px; }
|
||||
.pf-ev .pf-ev-app { width: 28px; height: 28px; border-radius: 8px; font-size: 10px; }
|
||||
.pf-ev-t { font-size: var(--t-body); color: var(--ink-2); line-height: 1.4; }
|
||||
.pf-ev-t b { color: var(--ink); font-weight: 650; }
|
||||
.pf-ev-at { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.pf-more { margin: 0 12px 12px; justify-self: start; }
|
||||
.pf-fixes { display: grid; gap: 10px; padding: 12px; }
|
||||
.pf-fix { display: grid; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line); border-left: 3px solid var(--neutral-solid); background: var(--surface-2); transition: background var(--speed) var(--ease); }
|
||||
.pf-fix.tone-bad { border-left-color: var(--bad-solid); }
|
||||
.pf-fix.tone-warn { border-left-color: var(--warn-solid); }
|
||||
.pf-fix.tone-info { border-left-color: var(--info-solid); }
|
||||
.pf-fix:hover { background: var(--hover); }
|
||||
.pf-fix-head { display: flex; align-items: center; gap: 10px; }
|
||||
.pf-fix-head .pf-fix-icon { width: 28px; height: 28px; border-radius: 8px; font-size: 10px; }
|
||||
.pf-fix-app { font-weight: 650; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pf-fix-t { margin: 0; font-size: var(--t-body); color: var(--ink-2); line-height: 1.45; }
|
||||
.pf-fix-h { margin: 0; }
|
||||
.pf-fix-a { display: flex; gap: 8px; }
|
||||
@media (max-width: 1100px) { .pf-cols { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (prefers-reduced-motion: reduce) { .pf-card, .pf-card:hover, .pf-shots img, .pf-hero-icon { transform: none !important; transition: none; } }
|
||||
:root[data-motion="reduce"] .pf-card, :root[data-motion="reduce"] .pf-card:hover, :root[data-motion="reduce"] .pf-shots img, :root[data-motion="reduce"] .pf-hero-icon { transform: none !important; transition: none; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue