From e308009a08822e612c0175cbb9f74f859dbb790e Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 20 May 2026 23:09:46 +0200 Subject: [PATCH] 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 --- design/README.md | 108 +++++++++- design/design_reference/SoftLAN Launcher.html | 7 +- design/design_reference/components.jsx | 102 ++++++++- design/design_reference/data.jsx | 44 +++- design/design_reference/styles.css | 195 ++++++++++++++++++ 5 files changed, 438 insertions(+), 18 deletions(-) diff --git a/design/README.md b/design/README.md index 2716c6a..184ba2f 100644 --- a/design/README.md +++ b/design/README.md @@ -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 `` 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: %`, 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:** ` %` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`. +- **Right:** `` — 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. `11.4 GB / 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. diff --git a/design/design_reference/SoftLAN Launcher.html b/design/design_reference/SoftLAN Launcher.html index ccab554..b98cf87 100644 --- a/design/design_reference/SoftLAN Launcher.html +++ b/design/design_reference/SoftLAN Launcher.html @@ -72,11 +72,16 @@ function App() { initialFilter="local" initialSort="az" initialOpenGame={GAMES.find(g => g.id === 'cod4mw')}/> + + g.id === 'avp')}/> + - + diff --git a/design/design_reference/components.jsx b/design/design_reference/components.jsx index 2c52534..64e6cb8 100644 --- a/design/design_reference/components.jsx +++ b/design/design_reference/components.jsx @@ -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 ; + } 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' ? @@ -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 ( +
+
+
+
+
+
+ + {fmtBytes(downloadedGb)} + / {fmtBytes(game.size)} + + · + {fmtSpeed(speed)} + · + {fmtEta(etaSec)} left +
+
{pct}%
+ +
+
+ ); + } + + return ( +
+
+
+ + + {fmtSpeedShort(speed)} +
+
+ ); +} + // ──────────────────────────────────────────────────────────────────── // Game card // ──────────────────────────────────────────────────────────────────── @@ -120,7 +208,7 @@ function GameCard({ game, accent, aspect, onOpen }) { · {game.tags[0]}
- +
); @@ -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 (
e.stopPropagation()}> @@ -336,13 +425,16 @@ function GameDetailModal({ game, accent, onClose }) {

{game.desc}

- + {game.state === 'installed' && ( )} {game.state === 'local' && ( )} + {showCancel && ( + + )} {game.state !== 'none' &&
}
@@ -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, diff --git a/design/design_reference/data.jsx b/design/design_reference/data.jsx index 9c18280..15032ee 100644 --- a/design/design_reference/data.jsx +++ b/design/design_reference/data.jsx @@ -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; diff --git a/design/design_reference/styles.css b/design/design_reference/styles.css index ed58719..6c98a32 100644 --- a/design/design_reference/styles.css +++ b/design/design_reference/styles.css @@ -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;