diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 9e21009..c400f03 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 79f399c..afc278c 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1867,4 +1867,51 @@ export const tr: Record = { '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ç", }; diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 91dc8d1..b6e1a9e 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -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 {t('portfolio.allClearText')}; - return ( -
- {items.slice(0, 8).map((a, i) => ( - - ))} -
- ); -} - /** 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>(); +let shotChain: Promise = Promise.resolve(); +function loadShots(projectId: string): Promise { + 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('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([]); + 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(); +function useTint(src: string | null, name: string): number { + const fallback = hueOf(name); + const [hue, setHue] = useState(() => (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 ( +
+ {store === 'ios' ? 'App Store' : 'Play'} + + {on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'} +
+ ); +} + +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 {!android || (c && !play) ? t('portfolio.healthNoPlay') : c && play && isError(play) ? t('portfolio.unreadable') : t('portfolio.healthNotRead')}; + } + const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); + return ( + + + {t('pf.crash')}{pct(s.current)} + + ); +} + +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 ( +
+ +
+
+ +
+ + {sub}{!app.project && {t('apps.storeOnly')}} +
+
+ {t('pf.next')} + {next ?? '–'} +
+
+ {studio} +
+ + + +
+
+ + +
+
+
+ ); +} + +// ---- What changed ---- + +interface Ev { key: string; app: UnifiedApp; text: string; at: string; icon: IconName; tone: Tone } + +function events(rows: CardRow[], health: Record): 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 = {}) => + 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 {t('pf.feedEmptyText')}; + const shown = more ? items : items.slice(0, 8); + return ( + <> +
    + {shown.map(ev => ( +
  1. + +
  2. + ))} +
+ {items.length > 8 && } + + ); +} + +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 {t('portfolio.allClearText')}; + const shown = more ? items : items.slice(0, 5); + return ( +
+ {shown.map((a, i) => { + const app = appOf(a.projectId), tab = a.tab ?? 'checks'; + return ( +
+
+ {app ? : } + {a.app} + {a.tone === 'bad' ? t('pf.blocker') : a.tone === 'warn' ? t('pf.warning') : t('pf.note')} +
+

{a.text}

+ {a.hint &&

{a.hint}

} +
+ +
+
+ ); + })} + {items.length > 5 && } +
+ ); +} + +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; - 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) => ( +
{list.map(r => )}
+ ); return ( -
-
-

{t('nav.portfolio')}

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}

+
+
+
+

{greeting()}

+

{headline}

+

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}

+
- { + const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null; + return ( +
+ {s !== undefined && ( +

{s?.name ?? t('studio.none')}{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}

+ )} + {cards(g.rows)} +
+ ); + }) : {t('portfolio.noMatchText')})} + + {view.layout === 'table' && {t('legend.done')}{t('legend.stages')}
}> {rows.length ? (
@@ -289,11 +585,13 @@ export function Portfolio() {
) : {t('portfolio.noMatchText')}} - + } -
- (a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1))} /> - +
+ +
+ +
{t('nav.studios')}
{d.studios.length ? t('portfolio.studiosN', { count: d.studios.length }) : }
{t('nav.accounts')}
{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : }
@@ -302,7 +600,8 @@ export function Portfolio() {
{t('portfolio.storesRead')}
{d.live ? ago(d.live.at) : t('time.never')}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}
-
+ +
); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index e04f89b..e777cf9 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; }