docs(design): specify download progress treatment

Document and mock the redesigned downloading state for the launcher. The
reference now replaces the action button slot with a dedicated progress
primitive, covers both card and detail-modal layouts, and records the sizing,
number formatting, container-query fallback, and sample-data expectations that
implementation work should follow.

This commit keeps the design package separate from application code so the
next UI/backend changes can be reviewed against a stable reference.

Test Plan:
- git diff --cached --check

Refs: local design reference update
This commit is contained in:
2026-05-20 23:09:46 +02:00
parent 51216b7281
commit e308009a08
5 changed files with 438 additions and 18 deletions
+97 -5
View File
@@ -84,9 +84,12 @@ function StateChip({ state }) {
}
// ────────────────────────────────────────────────────────────────────
// Action button — Play / Install / Download
// Action button — Play / Install / Download / [Downloading progress]
// ────────────────────────────────────────────────────────────────────
function ActionButton({ state, accent, size = 'md', onClick, full = false }) {
function ActionButton({ state, accent, size = 'md', onClick, full = false, game }) {
if (state === 'downloading' && game) {
return <DownloadProgress game={game} accent={accent} size={size} full={full}/>;
}
const action = ACTION_FOR_STATE[state];
const cls = `act-btn act-${action.kind} ${size === 'lg' ? 'act-lg' : ''} ${full ? 'act-full' : ''}`;
const icon = action.kind === 'play' ? <Icon.play/>
@@ -100,6 +103,91 @@ function ActionButton({ state, accent, size = 'md', onClick, full = false }) {
);
}
// ────────────────────────────────────────────────────────────────────
// Live download progress — animates from initial progress upward,
// jitters speed slightly. Two layouts:
// md → card tile. Container-query: shows %+speed, falls back to % only.
// lg → detail overlay. Two-line stats + percentage on the right.
// ────────────────────────────────────────────────────────────────────
function useLiveDownload(game) {
const [p, setP] = useState(game.progress ?? 0.1);
const [speed, setSpeed] = useState(game.speed ?? 30);
useEffect(() => {
const id = setInterval(() => {
setP(prev => {
// step proportional to current speed so smaller games finish faster
const stepGb = (speed / 1024) * 0.6; // 600ms tick
const next = prev + stepGb / game.size;
return next >= 0.985 ? (game.progress ?? 0.1) : next;
});
setSpeed(prev => {
const target = (game.speed ?? 30);
const drift = (Math.random() - 0.5) * 6;
return Math.max(2, target * 0.85 + prev * 0.15 + drift);
});
}, 600);
return () => clearInterval(id);
}, [game.id, game.size, game.speed, game.progress]);
return { progress: p, speed };
}
function DownloadProgress({ game, accent, size = 'md', full = false }) {
const { progress, speed } = useLiveDownload(game);
const pct = Math.min(99, Math.round(progress * 100));
const downloadedGb = game.size * progress;
const remainingGb = Math.max(0, game.size - downloadedGb);
const etaSec = remainingGb * 1024 / Math.max(speed, 0.1);
const isLg = size === 'lg';
const onCancel = (e) => { e.stopPropagation(); /* mock */ };
if (isLg) {
return (
<div className={`dl dl-lg ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden="true"/>
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{fmtBytes(downloadedGb)}</strong>
<span className="dl-of"> / {fmtBytes(game.size)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{fmtSpeed(speed)}</span>
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{fmtEta(etaSec)} left</span>
</div>
<div className="dl-lg-pct">{pct}<span className="dl-pct-sym">%</span></div>
<button className="dl-cancel" onClick={onCancel} aria-label="Cancel download">
<Icon.close/>
</button>
</div>
</div>
);
}
return (
<div className={`dl dl-md ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}
title={`${pct}% · ${fmtSpeed(speed)} · ${fmtEta(etaSec)} left`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden="true"/>
{pct}<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">{fmtSpeedShort(speed)}</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Game card
// ────────────────────────────────────────────────────────────────────
@@ -120,7 +208,7 @@ function GameCard({ game, accent, aspect, onOpen }) {
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
</div>
<ActionButton state={game.state} accent={accent} full/>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
</article>
);
@@ -298,6 +386,7 @@ function KebabMenu({ items }) {
function GameDetailModal({ game, accent, onClose }) {
if (!game) return null;
const action = ACTION_FOR_STATE[game.state];
const showCancel = game.state === 'downloading';
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
@@ -336,13 +425,16 @@ function GameDetailModal({ game, accent, onClose }) {
</div>
<p className="modal-desc">{game.desc}</p>
<div className="modal-actions">
<ActionButton state={game.state} accent={accent} size="lg"/>
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
{game.state === 'installed' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
)}
{game.state === 'local' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Delete from disk</span></button>
)}
{showCancel && (
<button className="ghost-btn ghost-danger"><Icon.close/><span>Cancel</span></button>
)}
{game.state !== 'none' && <div className="modal-actions-spacer"/>}
<button className="ghost-btn">View files</button>
</div>
@@ -465,7 +557,7 @@ function SettingsDialog({ settings, onChange, onClose }) {
}
Object.assign(window, {
Icon, GameCover, StateChip, ActionButton, GameCard,
Icon, GameCover, StateChip, ActionButton, DownloadProgress, GameCard,
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
SettingsDialog,