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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user