Separate game file downloads and verification with progress timing

This commit is contained in:
2026-10-04 12:51:05 -04:00
parent 68323c0baa
commit f8c60fd101
17 changed files with 1150 additions and 132 deletions
+6
View File
@@ -7,10 +7,16 @@ export type GameLibraryState = 'importing' | 'ready' | 'failed';
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
export interface GameFileEntry { path: string; size: number; sha256: string }
export interface LibraryTransferProgress {
phase: 'downloading' | 'verifying'; startedAt: string;
elapsedSeconds: number; phaseElapsedSeconds: number;
bytesPerSecond: number | null; etaSeconds: number | null;
}
export interface HostLibraryCache {
hostId: string; libraryVersionId: string; status: LibraryCacheState;
bytesDownloaded: number; bytesTotal: number; manifestSha256: string | null;
error: string | null; updatedAt: string; operationId: string | null;
bytesVerified?: number; transfer?: LibraryTransferProgress;
}
export interface GameLibraryVersion {
id: string; libraryId: string; game: GameLibraryGame; version: string;
+3
View File
@@ -1,3 +1,5 @@
import type { HostLibraryCache } from './game-files.js';
export type HostMode = 'active' | 'draining' | 'disabled';
export type HostReadiness = 'preparing' | 'needs-runtime' | 'needs-game-files' | 'ready' | 'degraded' | 'draining' | 'disabled' | 'deleting';
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
@@ -86,6 +88,7 @@ export interface HostPreparationItem {
kind: 'runtime' | 'library'; label: string;
state: 'waiting' | 'preparing' | 'ready' | 'blocked'; detail: string;
operationId?: string; bytesDownloaded?: number; bytesTotal?: number;
cache?: HostLibraryCache;
}
export interface HostPreparation {
state: HostPreparationItem['state']; items: HostPreparationItem[];
+45 -9
View File
@@ -8,14 +8,16 @@ import { ensureGameFileSchema } from './schema.js';
import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js';
import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js';
import { GAME_FILE_GAMES } from '../../game-files.js';
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js';
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState, LibraryTransferProgress } from '../../game-files.js';
function database() { const handle = db(); ensureGameFileSchema(handle); return handle; }
export const gameFilesRoot = () => resolve(process.env.GAME_FILES_ROOT ?? join(dirname(config.dbPath), 'game-files'));
const stagingRoot = (id: string) => join(gameFilesRoot(), '.imports', id);
type VersionRow = { id: string; library_id: string; game: GameLibraryGame; version: string; description: string; dlc: string; state: GameLibraryVersion['state']; size_bytes: number; manifest: string; manifest_sha256: string | null; created_at: string; published_at: string | null; storage_location: string; source_host_id: string; source_volume: string; error: string | null; operation_id: string | null };
type CacheRow = { host_id: string; version_id: string; status: LibraryCacheState; bytes_downloaded: number; bytes_total: number; manifest_sha256: string | null; error: string | null; updated_at: string; operation_id: string | null };
const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id });
type CacheRow = { host_id: string; version_id: string; status: LibraryCacheState; bytes_downloaded: number; bytes_total: number; manifest_sha256: string | null; error: string | null; updated_at: string; operation_id: string | null; bytes_verified: number | null; transfer_progress: string | null };
const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id,
...(row.bytes_verified == null ? {} : { bytesVerified: row.bytes_verified }),
...(row.transfer_progress ? { transfer: JSON.parse(row.transfer_progress) as LibraryTransferProgress } : {}) });
function versionFromRow(row: VersionRow): GameLibraryVersion {
const files = JSON.parse(row.manifest) as GameFileEntry[];
return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version,
@@ -282,6 +284,28 @@ export function requestImportPublication(hostId: string, id: string) {
return importPublicationStatus(hostId, id);
}
function transferProgress(value: unknown, status: LibraryCacheState): LibraryTransferProgress | null {
if (value === null || value === undefined) return null;
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid transfer progress.');
const progress = value as Record<string, unknown>;
const nonnegative = (amount: unknown): amount is number => typeof amount === 'number' && Number.isFinite(amount) && amount >= 0 && amount <= Number.MAX_SAFE_INTEGER;
if (!['downloading', 'verifying'].includes(progress.phase as string)
|| typeof progress.startedAt !== 'string' || progress.startedAt.length > 80
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/.test(progress.startedAt)
|| !Number.isFinite(Date.parse(progress.startedAt))
|| !nonnegative(progress.elapsedSeconds) || !nonnegative(progress.phaseElapsedSeconds)
|| progress.phaseElapsedSeconds > progress.elapsedSeconds
|| (progress.bytesPerSecond != null && !nonnegative(progress.bytesPerSecond))
|| (progress.etaSeconds != null && !nonnegative(progress.etaSeconds))
|| (['downloading', 'verifying'].includes(status) && progress.phase !== status)
|| (['ready', 'outdated'].includes(status) && progress.phase !== 'verifying')) {
throw new Error('Invalid transfer progress.');
}
return { phase: progress.phase as LibraryTransferProgress['phase'], startedAt: progress.startedAt,
elapsedSeconds: progress.elapsedSeconds, phaseElapsedSeconds: progress.phaseElapsedSeconds,
bytesPerSecond: progress.bytesPerSecond as number | null ?? null, etaSeconds: progress.etaSeconds as number | null ?? null };
}
export function updateHostLibraryCaches(hostId: string, input: unknown, options: { snapshot?: boolean } = {}): void {
if (!Array.isArray(input) || input.length > 2000) throw new Error('Invalid host library report.');
const handle = database();
@@ -303,15 +327,27 @@ export function updateHostLibraryCaches(hostId: string, input: unknown, options:
const bytes = raw.bytesDownloaded ?? (['ready', 'outdated'].includes(status) ? version.sizeBytes : 0);
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > version.sizeBytes) throw new Error('Invalid library download progress.');
if (['ready', 'outdated'].includes(status) && bytes !== version.sizeBytes) { status = 'failed'; error = 'Host reported an incomplete library as verified.'; }
handle.prepare(`INSERT INTO host_library_caches(host_id,version_id,status,bytes_downloaded,bytes_total,manifest_sha256,error,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(host_id,version_id) DO UPDATE SET status=excluded.status,
bytes_downloaded=excluded.bytes_downloaded,bytes_total=excluded.bytes_total,manifest_sha256=excluded.manifest_sha256,error=excluded.error,updated_at=excluded.updated_at`)
.run(hostId, id, status, bytes, version.sizeBytes, checksum, error, now);
const prior = getHostLibraryCache(hostId, id);
const terminal = ['ready', 'outdated', 'failed'].includes(status);
// Older agents do not report timing. Keep a completed measurement on
// later inventory heartbeats, but never carry an ETA into a new phase.
let transfer = raw.transfer === undefined && terminal ? prior?.transfer ?? null : transferProgress(raw.transfer, status);
let verified = raw.bytesVerified ?? (['ready', 'outdated'].includes(status) ? version.sizeBytes : terminal ? prior?.bytesVerified ?? null : null);
if (verified !== null && (!Number.isSafeInteger(verified) || verified < 0 || verified > version.sizeBytes)) throw new Error('Invalid library verification progress.');
if (transfer && status === 'verifying' && bytes !== version.sizeBytes) throw new Error('Download must finish before verification starts.');
if (['ready', 'outdated'].includes(status) && verified !== version.sizeBytes) throw new Error('Host reported incomplete verification as ready.');
if (status === 'missing') { transfer = null; verified = null; }
if (terminal && transfer) transfer = { ...transfer, bytesPerSecond: null, etaSeconds: null };
handle.prepare(`INSERT INTO host_library_caches(host_id,version_id,status,bytes_downloaded,bytes_total,manifest_sha256,error,updated_at,bytes_verified,transfer_progress)
VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(host_id,version_id) DO UPDATE SET status=excluded.status,
bytes_downloaded=excluded.bytes_downloaded,bytes_total=excluded.bytes_total,manifest_sha256=excluded.manifest_sha256,error=excluded.error,updated_at=excluded.updated_at,
bytes_verified=excluded.bytes_verified,transfer_progress=excluded.transfer_progress`)
.run(hostId, id, status, bytes, version.sizeBytes, checksum, error, now, verified, transfer ? JSON.stringify(transfer) : null);
}
if (options.snapshot) {
const cached = handle.prepare('SELECT version_id FROM host_library_caches WHERE host_id=?').all(hostId) as { version_id: string }[];
for (const cache of cached) if (!reported.has(cache.version_id)) {
handle.prepare("UPDATE host_library_caches SET status='missing',bytes_downloaded=0,manifest_sha256=NULL,error=NULL,updated_at=? WHERE host_id=? AND version_id=?")
handle.prepare("UPDATE host_library_caches SET status='missing',bytes_downloaded=0,manifest_sha256=NULL,error=NULL,bytes_verified=NULL,transfer_progress=NULL,updated_at=? WHERE host_id=? AND version_id=?")
.run(now, hostId, cache.version_id);
}
}
@@ -336,7 +372,7 @@ export function queueLibraryDownload(hostId: string, versionId: string, options:
manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes,
...(options.automatic ? { automatic: true } : {})
});
updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: retainedBytes, manifestSha256: prior?.manifestSha256 }]);
updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: retainedBytes, manifestSha256: prior?.manifestSha256, bytesVerified: 0, transfer: null }]);
database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId);
return operation;
}
+3
View File
@@ -40,5 +40,8 @@ export function ensureGameFileSchema(handle: Database.Database): void {
CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
`);
const cacheColumns = handle.prepare('PRAGMA table_info(host_library_caches)').all() as { name: string }[];
if (!cacheColumns.some(column => column.name === 'bytes_verified')) handle.exec('ALTER TABLE host_library_caches ADD COLUMN bytes_verified INTEGER');
if (!cacheColumns.some(column => column.name === 'transfer_progress')) handle.exec('ALTER TABLE host_library_caches ADD COLUMN transfer_progress TEXT');
initialized.add(handle);
}
+4 -3
View File
@@ -56,7 +56,7 @@ function targets(host: Host): Target[] {
operation: latestOperation(host.id, 'library.download', 'libraryVersionId', version.id),
item: { kind: 'library', label: version.name, state: ready ? 'ready' : 'waiting',
detail: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.',
bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes } });
bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes, ...(cache ? { cache } : {}) } });
}
const blocker = hostBlocker(host);
for (const target of result) {
@@ -67,7 +67,8 @@ function targets(host: Host): Target[] {
if (operation && activeStates.includes(operation.state)) {
target.item.state = 'preparing';
target.item.detail = operation.state === 'unknown' ? 'Waiting for the agent to reconcile interrupted preparation.'
: target.image ? 'Downloading the runtime automatically.' : 'Downloading and verifying game files automatically.';
: target.image ? 'Downloading the runtime automatically.'
: target.item.cache?.status === 'verifying' ? 'Download complete. Verifying all game files.' : 'Downloading game files. Verification starts after all files arrive.';
} else if (operation && Date.parse(host.lastHeartbeat!) <= Date.parse(operation.updated_at)) {
target.item.state = 'preparing'; target.item.detail = 'Waiting for a fresh host inventory after preparation.';
} else if (retryAt(host.id, target) > Date.now()) {
@@ -110,7 +111,7 @@ function supersedeUndeliveredDownloads(hostId: string): void {
// automatic work can be replaced without consulting the agent journal.
db().prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?").run(supersededDownload, now, operation.id);
db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,?)").run(operation.id, now, supersededDownload);
db().prepare("UPDATE host_library_caches SET status='missing',error=NULL,updated_at=? WHERE host_id=? AND version_id=? AND operation_id=? AND status NOT IN ('ready','outdated')")
db().prepare("UPDATE host_library_caches SET status='missing',error=NULL,bytes_verified=NULL,transfer_progress=NULL,updated_at=? WHERE host_id=? AND version_id=? AND operation_id=? AND status NOT IN ('ready','outdated')")
.run(now, hostId, operation.versionId, operation.id);
}
}
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { HostLibraryCache } from '$lib/game-files.js';
import { formatGameBytes } from '$lib/game-files.js';
import { formatDuration } from '$ui/duration.js';
import { gameFileTransferProgress } from '$ui/game-file-transfer.js';
let { cache, connected }: { cache: HostLibraryCache; connected: boolean } = $props();
let now = $state(Date.now());
const progress = $derived(gameFileTransferProgress(cache, connected, now));
const downloading = $derived(progress.phase === 'downloading' && progress.active);
const verifying = $derived(progress.phase === 'verifying' && progress.active);
const downloaded = $derived(progress.complete || progress.phase === 'verifying');
onMount(() => {
const timer = setInterval(() => {
if (cache.transfer && connected && (cache.status === 'downloading' || cache.status === 'verifying')) now = Date.now();
}, 1000);
return () => clearInterval(timer);
});
</script>
<div class="transfer-progress">
<ol aria-label="Game file preparation steps">
<li class:active={downloading} class:complete={downloaded}>1. {downloaded ? 'Downloaded' : downloading ? 'Downloading' : 'Download'}</li>
<li class:active={verifying} class:complete={progress.complete}>2. {progress.complete ? 'Verified' : verifying ? 'Verifying' : 'Verify'}</li>
</ol>
{#if progress.phase && !progress.complete}
<progress max={progress.total || 1} value={progress.total ? progress.phaseBytes ?? undefined : undefined} aria-label={progress.phase === 'verifying' ? 'Host verification progress' : 'Host download progress'}></progress>
<span class="bytes">{#if progress.phaseBytes !== null}{formatGameBytes(progress.phaseBytes)} / {formatGameBytes(progress.total)} {progress.phase === 'verifying' ? 'checked' : 'downloaded'}{:else}Checking every file…{/if}</span>
{/if}
{#if progress.elapsedSeconds !== null}
<div class="timing" aria-live="off">
<span>Elapsed {formatDuration(progress.elapsedSeconds * 1000)}</span>
{#if progress.bytesPerSecond !== null}<span>{formatGameBytes(progress.bytesPerSecond)}/s</span>{/if}
{#if progress.etaSeconds !== null}<span>About {formatDuration(Math.ceil(progress.etaSeconds) * 1000)} {progress.phase === 'verifying' ? 'verification' : 'download'} remaining</span>
{:else if progress.live && progress.phaseBytes !== progress.total}<span>Estimating {progress.phase === 'verifying' ? 'verification' : 'download'} time remaining…</span>{/if}
</div>
{/if}
{#if progress.active && !connected}<span class="muted">Host offline · waiting to resume updates</span>
{:else if progress.stale}<span class="muted">Waiting for a fresh host report</span>
{:else if downloading}<span class="muted">All files download before verification starts.</span>{/if}
</div>
<style>
.transfer-progress { display:grid; gap:6px; min-width:200px; }
ol { display:flex; flex-wrap:wrap; gap:6px 16px; list-style:none; margin:0; padding:0; font-size:var(--text-xs); color:var(--text-secondary); }
li.active { color:var(--text-primary); font-weight:600; } li.complete { color:var(--accent-primary-hover); }
progress { display:block; width:100%; margin:0; accent-color:var(--accent-primary); }
.bytes,.timing,.muted { font-size:var(--text-xs); color:var(--text-secondary); font-variant-numeric:tabular-nums; line-height:1.5; }
.timing { display:flex; flex-wrap:wrap; gap:2px 12px; }
</style>
+5 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Host } from '$lib/hosts.js';
import { formatGameBytes } from '$lib/game-files.js';
import GameFileTransferProgress from '$ui/components/GameFileTransferProgress.svelte';
let { host }: { host: Host } = $props();
</script>
@@ -13,7 +14,9 @@
<li>
<div class="row"><strong>{item.label}</strong><span>{item.state === 'blocked' ? 'Needs attention' : item.state}</span></div>
<p class:blocked={item.state === 'blocked'}>{item.detail}</p>
{#if item.bytesTotal && item.bytesDownloaded !== undefined && item.state === 'preparing'}
{#if item.cache && item.cache.status !== 'missing'}
<div class="transfer"><GameFileTransferProgress cache={item.cache} connected={host.connected} /></div>
{:else if item.bytesTotal && item.bytesDownloaded !== undefined && item.state === 'preparing'}
<progress max={item.bytesTotal} value={item.bytesDownloaded} aria-label={`${item.label} download progress`}></progress>
<small>{formatGameBytes(item.bytesDownloaded)} / {formatGameBytes(item.bytesTotal)}</small>
{/if}
@@ -28,4 +31,5 @@
.preparation { display:grid; gap:12px; } p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .status { color:var(--text-primary); }
ul { list-style:none; padding:0; margin:0; display:grid; gap:16px; } li { min-width:0; } .row { display:flex; justify-content:space-between; gap:12px; font-size:var(--text-sm); } strong { overflow-wrap:anywhere; } span { text-transform:capitalize; color:var(--text-secondary); }
li p { margin-top:4px; } .blocked { color:var(--state-danger); } progress { width:100%; margin-top:8px; accent-color:var(--accent-primary); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); } a { color:var(--accent-primary-hover); }
.transfer { margin-top:8px; }
</style>
+33
View File
@@ -0,0 +1,33 @@
import type { HostLibraryCache } from '../game-files.js';
export const TRANSFER_REPORT_FRESH_MS = 30_000;
const nonnegative = (value: number | null | undefined): number | null =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
/** Keep byte progress factual; only the elapsed clock advances between host reports. */
export function gameFileTransferProgress(cache: HostLibraryCache, connected: boolean, now: number) {
const active = cache.status === 'downloading' || cache.status === 'verifying';
const complete = cache.status === 'ready' || cache.status === 'outdated';
const phase = active ? cache.status as 'downloading' | 'verifying' : cache.transfer?.phase ?? null;
const total = nonnegative(cache.bytesTotal) ?? 0;
const downloaded = Math.min(total, nonnegative(cache.bytesDownloaded) ?? 0);
const verified = complete ? total : nonnegative(cache.bytesVerified);
const phaseBytes = phase === 'verifying' ? verified === null ? null : Math.min(total, verified) : downloaded;
const transfer = cache.transfer;
const samePhase = transfer?.phase === phase;
const reportedAt = Date.parse(cache.updatedAt);
const reportAge = Number.isFinite(reportedAt) && Number.isFinite(now) ? Math.max(0, now - reportedAt) : null;
const stale = active && !!transfer && (reportAge === null || reportAge > TRANSFER_REPORT_FRESH_MS);
const live = active && connected && samePhase && !stale && reportAge !== null;
const elapsed = nonnegative(transfer?.elapsedSeconds);
// Stop extrapolating when reports stop. A disconnected or finished host uses its saved duration.
const elapsedSeconds = elapsed === null ? null : elapsed + (active && connected && samePhase && reportAge !== null
? Math.min(reportAge, TRANSFER_REPORT_FRESH_MS) / 1000 : 0);
const rate = nonnegative(transfer?.bytesPerSecond);
const estimate = nonnegative(transfer?.etaSeconds);
const hasProgress = phaseBytes !== null && phaseBytes > 0 && phaseBytes < total;
const bytesPerSecond = live && hasProgress && rate !== null && rate > 0 ? rate : null;
const etaSeconds = bytesPerSecond !== null && estimate !== null && estimate > 0 ? estimate : null;
return { active, complete, phase, total, downloaded, verified, phaseBytes, elapsedSeconds, bytesPerSecond, etaSeconds, stale, live };
}
+4 -3
View File
@@ -10,6 +10,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import GameFileTransferProgress from '$ui/components/GameFileTransferProgress.svelte';
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean }
@@ -292,7 +293,7 @@
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td>
<td>{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}</td>
<td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded} aria-label="Host download progress"></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · previous upload' : ''}{:else}—{/if}</td>
<td>{#if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={cacheHost?.connected ?? false} />{:else}—{/if}</td>
<td>{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} <a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}</td>
</tr>
{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
@@ -315,8 +316,8 @@
.path-list { padding-left:20px; font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; }
.current-file { font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; } progress { width:100%; accent-color:var(--accent-primary); }
h2,h3,p { margin:0; } h2 { font-size:var(--text-md); } h3 { font-size:var(--text-sm); }
p,td,th,details { font-size:var(--text-sm); line-height:1.6; } .muted,small { color:var(--text-secondary); }
small { display:block; } a { color:var(--accent-primary-hover); } summary { cursor:pointer; }
p,td,th,details { font-size:var(--text-sm); line-height:1.6; } .muted { color:var(--text-secondary); }
a { color:var(--accent-primary-hover); } summary { cursor:pointer; }
dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; } dd { margin:0; overflow-wrap:anywhere; }
.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; } th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
@media(max-width:640px) { .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } }