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:
+106
-2
@@ -196,6 +196,7 @@ state label button style
|
||||
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
|
||||
local Install bg var(--accent), text white, inset top hl
|
||||
installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
|
||||
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
|
||||
```
|
||||
|
||||
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
|
||||
@@ -206,6 +207,107 @@ Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
|
||||
|
||||
---
|
||||
|
||||
## Download progress (state === 'downloading')
|
||||
|
||||
When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `<progress>` child. Two layouts share the same primitive:
|
||||
|
||||
### Shared visuals
|
||||
|
||||
- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below).
|
||||
- **Progress fill** (`.dl-fill`): absolutely positioned, `width: <pct>%`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 38–26%, transparent)`. Right edge gets a 1px accent rule + accent glow.
|
||||
- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting.
|
||||
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active.
|
||||
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
|
||||
|
||||
### Card layout (`.dl-md`, replaces the 32px action button)
|
||||
|
||||
A single row. Two values, separated by `justify-content: space-between`:
|
||||
|
||||
- **Left:** `<pulse> <pct>%` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`.
|
||||
- **Right:** `<speed>` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale).
|
||||
|
||||
Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`).
|
||||
|
||||
**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo:
|
||||
|
||||
```css
|
||||
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
|
||||
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
|
||||
```
|
||||
|
||||
At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly.
|
||||
|
||||
The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.
|
||||
|
||||
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
|
||||
|
||||
Fixed 56px height. CSS-grid with three columns and two rows:
|
||||
|
||||
```
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"primary pct cancel"
|
||||
"secondary pct cancel";
|
||||
```
|
||||
|
||||
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
|
||||
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, three groups separated by `·` (0.45 opacity):
|
||||
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
|
||||
2. `47.6 MB/s` (`var(--t-1)`)
|
||||
3. `8 min left` (`var(--t-2)`)
|
||||
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
|
||||
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
|
||||
|
||||
**Graceful degradation in narrow modals:**
|
||||
|
||||
```css
|
||||
@container (max-width: 380px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
|
||||
```
|
||||
|
||||
The ETA drops first if the modal narrows; bytes + speed stay (they're the actionable numbers). The pct/cancel column never collapses.
|
||||
|
||||
### Number formatting
|
||||
|
||||
All helpers live in `data.jsx`:
|
||||
|
||||
- `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`.
|
||||
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
|
||||
- `fmtBytes(gb)` — `<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`).
|
||||
- `fmtEta(seconds)` — `< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`.
|
||||
|
||||
Keep these formats; they're tuned so the secondary row never wraps at normal modal width.
|
||||
|
||||
### Data shape
|
||||
|
||||
The `Game` type gains a `downloading` state plus two transient fields:
|
||||
|
||||
```ts
|
||||
type Game = {
|
||||
// … existing fields …
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1, only when state === 'downloading'
|
||||
speed?: number; // current throughput in MB/s
|
||||
};
|
||||
```
|
||||
|
||||
In the real app, `progress` and `speed` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. Replace with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
|
||||
|
||||
Filter changes:
|
||||
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
|
||||
- Sort by `state` orders `installed < local < downloading < none`.
|
||||
|
||||
### State chip
|
||||
|
||||
Add a fourth entry to `STATE_META`:
|
||||
|
||||
```js
|
||||
downloading: { label: 'Downloading', dot: 'var(--accent)' }
|
||||
```
|
||||
|
||||
Dot uses the live accent so it visually ties to the progress fill.
|
||||
|
||||
---
|
||||
|
||||
## Filter controls — variant B (not used, kept for reference)
|
||||
|
||||
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
|
||||
@@ -255,7 +357,9 @@ type Game = {
|
||||
size: number; // GB
|
||||
version: string; // "YYYY.MM.DD"
|
||||
desc: string;
|
||||
state: 'installed' | 'local' | 'none';
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1 — present only when state === 'downloading'
|
||||
speed?: number; // MB/s — present only when state === 'downloading'
|
||||
players: string; // e.g. "2–32"
|
||||
tags: string[];
|
||||
cover: { c1: string; c2: string; accent: string; mood?: string };
|
||||
@@ -378,5 +482,5 @@ To preview the design in a browser:
|
||||
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
|
||||
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
|
||||
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
|
||||
- **Progress state** — when a game is actively downloading or installing, the action button should become a progress bar with a cancel affordance. Not designed; recommend: replace the button with a progress bar of the same dimensions, percentage text on the left, cancel "×" on the right.
|
||||
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
|
||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
||||
|
||||
@@ -72,11 +72,16 @@ function App() {
|
||||
initialFilter="local" initialSort="az"
|
||||
initialOpenGame={GAMES.find(g => g.id === 'cod4mw')}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="detail-modal-downloading" label="E · Detail overlay (downloading)" width={1340} height={840}>
|
||||
<Launcher variant="two" tweaks={t} setTweak={setTweak}
|
||||
initialFilter="local" initialSort="state"
|
||||
initialOpenGame={GAMES.find(g => g.id === 'avp')}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="settings" title="Settings dialog"
|
||||
subtitle="Same controls as the dev Tweaks panel, surfaced as an in-app preferences dialog. Open via top-bar menu → Settings.">
|
||||
<DCArtboard id="settings-open" label="E · Settings dialog (open)" width={1340} height={840}>
|
||||
<DCArtboard id="settings-open" label="F · Settings dialog (open)" width={1340} height={840}>
|
||||
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||
initialFilter="all" initialSort="recent"
|
||||
initialSettingsOpen={true}/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,7 @@ const GAMES = [
|
||||
{
|
||||
id: 'avp', title: 'Aliens vs. Predator', size: 35.0, version: '2019.10.01',
|
||||
desc: "Three campaigns, three nightmares. Be the alien stalking the dark, the predator hunting both, or the marine just trying to make it home with a working flashlight.",
|
||||
state: 'none', players: '2–16', tags: ['FPS', 'Horror', 'Multiplayer'],
|
||||
state: 'downloading', progress: 0.32, speed: 49.4, players: '2–16', tags: ['FPS', 'Horror', 'Multiplayer'],
|
||||
cover: { c1: '#064e3b', c2: '#020617', accent: '#34d399', mood: 'dark' },
|
||||
},
|
||||
{
|
||||
@@ -120,7 +120,7 @@ const GAMES = [
|
||||
{
|
||||
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15',
|
||||
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
|
||||
state: 'none', players: '2–16', tags: ['FPS', 'Arena', 'LAN'],
|
||||
state: 'downloading', progress: 0.71, speed: 12.8, players: '2–16', tags: ['FPS', 'Arena', 'LAN'],
|
||||
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
|
||||
},
|
||||
{
|
||||
@@ -152,25 +152,45 @@ const GAMES = [
|
||||
// Helpers
|
||||
const fmtSize = (gb) => gb < 1 ? `${Math.round(gb * 1024)} MB` : `${gb.toFixed(1)} GB`;
|
||||
const STATE_META = {
|
||||
installed: { label: 'Installed', dot: '#22c55e' },
|
||||
local: { label: 'Local', dot: '#f59e0b' },
|
||||
none: { label: '', dot: 'transparent' },
|
||||
installed: { label: 'Installed', dot: '#22c55e' },
|
||||
local: { label: 'Local', dot: '#f59e0b' },
|
||||
downloading: { label: 'Downloading', dot: 'var(--accent)' },
|
||||
none: { label: '', dot: 'transparent' },
|
||||
};
|
||||
const ACTION_FOR_STATE = {
|
||||
installed: { label: 'Play', kind: 'play' },
|
||||
local: { label: 'Install', kind: 'install' },
|
||||
none: { label: 'Download', kind: 'download' },
|
||||
installed: { label: 'Play', kind: 'play' },
|
||||
local: { label: 'Install', kind: 'install' },
|
||||
downloading: { label: 'Downloading', kind: 'downloading' },
|
||||
none: { label: 'Download', kind: 'download' },
|
||||
};
|
||||
|
||||
// Format helpers for download UI
|
||||
const fmtSpeed = (mbps) => mbps >= 100 ? `${Math.round(mbps)} MB/s` : `${mbps.toFixed(1)} MB/s`;
|
||||
const fmtSpeedShort = (mbps) => `${Math.round(mbps)} MB/s`;
|
||||
const fmtBytes = (gb) => {
|
||||
if (gb < 1) return `${Math.round(gb * 1024)} MB`;
|
||||
// No trailing zeros: 35 GB, 11.4 GB, 2.35 GB
|
||||
if (gb >= 10) return `${gb.toFixed(1).replace(/\.0$/,'')} GB`;
|
||||
return `${gb.toFixed(2).replace(/0$/,'').replace(/\.$/,'')} GB`;
|
||||
};
|
||||
const fmtEta = (sec) => {
|
||||
if (!isFinite(sec) || sec <= 0) return '—';
|
||||
if (sec < 60) return `${Math.round(sec)} s`;
|
||||
const m = Math.round(sec / 60);
|
||||
if (m < 60) return `${m} min`;
|
||||
return `${Math.floor(m/60)} h ${m%60} min`;
|
||||
};
|
||||
|
||||
const isLocalish = (s) => s === 'installed' || s === 'local' || s === 'downloading';
|
||||
const countByFilter = (games) => ({
|
||||
all: games.length,
|
||||
local: games.filter(g => g.state === 'installed' || g.state === 'local').length,
|
||||
local: games.filter(g => isLocalish(g.state)).length,
|
||||
installed: games.filter(g => g.state === 'installed').length,
|
||||
});
|
||||
|
||||
const filterGames = (games, key) => {
|
||||
if (key === 'all') return games;
|
||||
if (key === 'local') return games.filter(g => g.state === 'installed' || g.state === 'local');
|
||||
if (key === 'local') return games.filter(g => isLocalish(g.state));
|
||||
if (key === 'installed') return games.filter(g => g.state === 'installed');
|
||||
return games;
|
||||
};
|
||||
@@ -189,4 +209,8 @@ window.ACTION_FOR_STATE = ACTION_FOR_STATE;
|
||||
window.countByFilter = countByFilter;
|
||||
window.filterGames = filterGames;
|
||||
window.fmtSize = fmtSize;
|
||||
window.fmtSpeed = fmtSpeed;
|
||||
window.fmtSpeedShort = fmtSpeedShort;
|
||||
window.fmtBytes = fmtBytes;
|
||||
window.fmtEta = fmtEta;
|
||||
window.STORAGE = STORAGE;
|
||||
|
||||
@@ -630,6 +630,201 @@
|
||||
}
|
||||
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); }
|
||||
|
||||
/* ─── Download progress (in place of action button when state === 'downloading') ─── */
|
||||
.dl {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
container-type: inline-size;
|
||||
isolation: isolate;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04),
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.dl-full { width: 100%; }
|
||||
.dl-fill {
|
||||
position: absolute; inset: 0 auto 0 0;
|
||||
width: 0%;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 26%, transparent) 100%);
|
||||
border-right: 1px solid color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
box-shadow: 2px 0 8px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
transition: width 480ms cubic-bezier(.4,0,.2,1);
|
||||
z-index: 0;
|
||||
}
|
||||
/* shimmering scanline on top of the fill so it reads as 'live' */
|
||||
.dl-fill::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(115deg,
|
||||
transparent 0 14px,
|
||||
rgba(255,255,255,0.05) 14px 22px);
|
||||
background-size: 200% 100%;
|
||||
animation: dl-stripe 1.4s linear infinite;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@keyframes dl-stripe {
|
||||
from { background-position: 0% 0%; }
|
||||
to { background-position: -36px 0%; }
|
||||
}
|
||||
|
||||
/* live pulse dot */
|
||||
.dl-pulse {
|
||||
width: 7px; height: 7px; border-radius: 99px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
animation: dl-pulse 1.4s ease-out infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@keyframes dl-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
}
|
||||
|
||||
/* ── md (card tile) ── */
|
||||
.dl-md {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.dl-md-row {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; height: 100%;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
.dl-md .dl-pct {
|
||||
display: inline-flex; align-items: center;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.dl-md .dl-pulse { margin-right: 6px; }
|
||||
.dl-md .dl-pct-sym { opacity: 0.55; font-weight: 600; margin-left: 1px; }
|
||||
.dl-md .dl-speed {
|
||||
color: var(--t-2);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Container-query graceful degradation: when the tile is narrow,
|
||||
drop the speed and centre the percentage so nothing truncates. */
|
||||
@container (max-width: 132px) {
|
||||
.dl-md .dl-speed { display: none; }
|
||||
.dl-md-row { justify-content: center; gap: 6px; }
|
||||
}
|
||||
@container (max-width: 96px) {
|
||||
.dl-md .dl-pulse { display: none; }
|
||||
}
|
||||
|
||||
/* Density tuning — match the existing button heights */
|
||||
.density-compact .dl-md { height: 30px; padding: 0 9px; }
|
||||
.density-compact .dl-md-row { font-size: 11.5px; }
|
||||
.density-compact .dl-md .dl-speed { font-size: 10.5px; }
|
||||
.density-large .dl-md { height: 34px; padding: 0 12px; }
|
||||
.density-large .dl-md-row { font-size: 13px; }
|
||||
.density-large .dl-md .dl-speed { font-size: 11.5px; }
|
||||
|
||||
/* ── lg (detail overlay) ── */
|
||||
.dl-lg {
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 260px;
|
||||
}
|
||||
.dl-lg-grid {
|
||||
position: relative; z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"primary pct cancel"
|
||||
"secondary pct cancel";
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 14px 0 16px;
|
||||
column-gap: 14px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.dl-lg-primary {
|
||||
grid-area: primary;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--accent) 80%, white);
|
||||
min-width: 0;
|
||||
}
|
||||
.dl-lg-primary .dl-label { white-space: nowrap; }
|
||||
.dl-lg-secondary {
|
||||
grid-area: secondary;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--t-2);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dl-lg-secondary .dl-bytes {
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-bytes .dl-of { color: var(--t-2); font-weight: 500; }
|
||||
.dl-lg-secondary .dl-speed { color: var(--t-1); font-weight: 600; white-space: nowrap; }
|
||||
.dl-lg-secondary .dl-eta { white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dl-sep { opacity: 0.45; }
|
||||
|
||||
/* Gracefully drop the ETA when the modal is narrow */
|
||||
@container (max-width: 380px) {
|
||||
.dl-lg-secondary .dl-eta,
|
||||
.dl-lg-secondary .dl-sep-eta { display: none; }
|
||||
}
|
||||
|
||||
.dl-lg-pct {
|
||||
grid-area: pct;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--t-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.dl-lg-pct .dl-pct-sym {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0.55;
|
||||
margin-left: 1px;
|
||||
}
|
||||
.dl-cancel {
|
||||
grid-area: cancel;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bd-2);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--t-2);
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
}
|
||||
.dl-cancel:hover {
|
||||
background: rgba(239,68,68,0.12);
|
||||
border-color: rgba(239,68,68,0.40);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Ghost / secondary */
|
||||
.ghost-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
|
||||
Reference in New Issue
Block a user