Refresh script library with batch uploads and beta handoff

This commit is contained in:
2026-09-18 09:57:57 -04:00
parent b2ec01daf5
commit ec51e38312
9 changed files with 576 additions and 382 deletions
+142 -362
View File
@@ -1,387 +1,167 @@
<script lang="ts">
import { onMount } from 'svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
import { zombieMaps } from '$lib/map-aliases.js';
interface ScriptRow {
id: number;
name: string;
target_rel_path: string;
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
kind: 'script' | 'config' | 'plugin' | 'baseline';
latestVersion: {
id: number; version: number; sha256: string; size: number;
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
} | null;
versionCount: number;
servers: string[];
runningBetaId: number | null;
}
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
let scripts = $state<ScriptRow[]>([]);
let scripts = $state<LibraryScript[]>([]);
let loading = $state(true);
let flash = $state<string | null>(null);
let uploading = $state(false);
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
let successBanner = $state<{ name: string; scriptId: number; version: number } | null>(null);
let newName = $state('');
let newScopeType = $state<'none' | 'game' | 'map'>('none');
let newScopeGame = $state('');
let newScopeMap = $state('');
let newFile = $state<File | null>(null);
let newNotes = $state('');
const scopeValue = $derived.by(() => {
if (newScopeType === 'game') return newScopeGame;
if (newScopeType === 'map') return newScopeMap;
return '';
});
let serverCache = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
const matchingServers = $derived.by(() => {
if (newScopeType === 'none') return null;
if (!scopeValue) return null;
return serverCache
.filter((s) => (newScopeType === 'game' ? s.game : s.map) === scopeValue)
.map((s) => s.name);
});
async function load(): Promise<void> {
const [scriptsRes, scopeRes, serversRes] = await Promise.all([
fetch('/api/scripts?kind=script'),
fetch('/api/scope-options'),
fetch('/api/servers')
]);
const body = (await scriptsRes.json()) as { scripts: ScriptRow[] };
scripts = body.scripts;
scopeOptions = (await scopeRes.json()) as ScopeOptions;
const sBody = (await serversRes.json()) as { servers: Array<{ name: string; game: string | null; map: string | null }> };
serverCache = sBody.servers;
loading = false;
}
onMount(load);
function pickFile(e: Event): void {
const t = e.target as HTMLInputElement;
newFile = t.files?.[0] ?? null;
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
}
async function createScript(e: Event): Promise<void> {
e.preventDefault();
if (uploading || !newFile || !newName.trim()) return;
if (newScopeType !== 'none' && !scopeValue) {
flash = `Pick a ${newScopeType}`;
return;
}
uploading = true; flash = null;
const form = new FormData();
form.append('name', newName.trim());
form.append('scopeType', newScopeType);
if (scopeValue) form.append('scopeValue', scopeValue);
if (newNotes.trim()) form.append('notes', newNotes.trim());
form.append('content', newFile);
try {
const res = await fetch('/api/scripts', { method: 'POST', body: form });
const body = (await res.json()) as {
ok: boolean; error?: string;
script?: { id: number; name: string };
versionId?: number;
};
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
successBanner = body.script
? { name: body.script.name, scriptId: body.script.id, version: 1 }
: null;
newName = ''; newScopeType = 'none';
newScopeGame = ''; newScopeMap = '';
newNotes = ''; newFile = null;
await load();
} finally {
uploading = false;
}
}
function scopeLabel(s: ScriptRow): string {
if (!s.scope_type) return 'unscoped';
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
}
function detailHref(s: ScriptRow): string {
if (s.kind === 'config' || s.kind === 'baseline') return `/configs/${s.id}`;
if (s.kind === 'plugin') return `/plugins/${s.id}`;
return `/scripts/${s.id}`;
}
function stateVariantFor(s: ScriptRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
if (!s.latestVersion) return null;
if (s.current_version_id === null) return 'neutral';
if (s.latestVersion.id !== s.current_version_id) {
if (s.latestVersion.state === 'untested') return 'neutral';
if (s.latestVersion.state === 'tested') return 'online';
if (s.latestVersion.state === 'test-failed') return 'danger';
}
return null;
}
function stateLabel(s: ScriptRow): string {
if (!s.latestVersion) return '';
if (s.current_version_id === null) return 'untested';
if (s.latestVersion.id !== s.current_version_id) {
return `v${s.latestVersion.version} ${s.latestVersion.state.replace('-', ' ')}`;
}
return '';
}
let gameOptions = $derived([
{ value: '', label: '— Pick a game —', disabled: true },
...scopeOptions.games.map((g) => ({ value: g, label: g }))
]);
let mapOptions = $derived([
{ value: '', label: '— Pick a map —', disabled: true },
...(scopeOptions.mapsByGame[newScopeGame] ?? []).map((m) => ({ value: m, label: m }))
]);
let scopeTypeOptions = [
{ value: 'none', label: 'Unscoped' },
{ value: 'game', label: 'Game' },
{ value: 'map', label: 'Map' }
let error = $state('');
let search = $state('');
let status = $state<ScriptStatus | 'all'>('all');
let game = $state('');
let map = $state('');
let sort = $state('attention');
let selected = $state<number[]>([]);
let uploader: ScriptUploadDrawer;
let dragDepth = $state(0);
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
{ value: 'all', label: 'All scripts' }, { value: 'untested', label: 'Needs testing' },
{ value: 'testing', label: 'Beta active' }, { value: 'failed', label: 'Test failed' },
{ value: 'tested', label: 'Test passed' }, { value: 'promoted', label: 'Promoted' }
];
const priority = { failed: 0, untested: 1, testing: 2, tested: 3, promoted: 4, empty: 5 };
const maps = $derived([...new Set(scripts.filter(s => s.scope_type === 'map' && (!game || zombieMaps(game).some(m => m.canonical === s.scope_value))).map(s => s.scope_value!))].sort());
const visible = $derived(scripts.filter(s => {
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
&& (status === 'all' || scriptStatus(s) === status)
&& (!game || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
&& (!map || s.scope_type === 'map' && s.scope_value === map);
}).sort((a, b) => {
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
if (sort === 'attention') return priority[scriptStatus(a)] - priority[scriptStatus(b)] || a.name.localeCompare(b.name);
return a.name.localeCompare(b.name);
}));
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
const selectable = $derived(visible.filter(canTest));
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
async function load() {
error = '';
try {
const res = await fetch('/api/scripts?kind=script');
if (!res.ok) throw new Error('Could not load scripts. Try again.');
scripts = (await res.json()).scripts;
selected = selected.filter(id => scripts.some(s => s.id === id && canTest(s)));
} catch (e) { error = (e as Error).message; }
finally { loading = false; }
}
onMount(() => { void load(); });
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
function selectVisible() {
const ids = selectable.map(s => s.id);
selected = allSelected ? selected.filter(id => !ids.includes(id)) : [...new Set([...selected, ...ids])];
}
function betaHref(rows: LibraryScript[]) { return betaSelectionHref(rows.map(s => ({ scriptId: s.id, versionId: s.latestVersion!.id }))); }
function resetFilters() { search = ''; status = 'all'; game = ''; map = ''; }
function onDrag(event: DragEvent) { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); }
function onDrop(event: DragEvent) {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault(); dragDepth = 0;
void uploader.addFiles([...event.dataTransfer.files]);
}
</script>
<svelte:window ondragover={onDrag} ondrop={onDrop}
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
<div class="page">
<SectionHeader title="Scripts" subtitle="Upload a script, then open a beta to test it before promotion." />
<!-- Upload form -->
<Card padding="lg">
<h2 class="panel-title">Upload a script</h2>
{#if successBanner}
<div class="banner">
<strong>v{successBanner.version} uploaded — untested.</strong>
<span>
Open a beta on <a href="/scripts/{successBanner.scriptId}">{successBanner.name}</a>
to test it before it can be promoted.
</span>
</div>
{/if}
<form onsubmit={createScript} class="upload-form">
<Field label="Name">
<Input bind:value={newName} placeholder="hud tweaks" disabled={uploading} />
</Field>
<Field label="Notes" hint="Optional. What changed in this version.">
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
</Field>
<Field label="Scope" hint="Where this script deploys. Unscoped ships wherever a beta targets it.">
<div class="scope-row">
<Select bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
{#if newScopeType === 'game' || newScopeType === 'map'}
<Select bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
{/if}
{#if newScopeType === 'map'}
<Select bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
{/if}
</div>
{#if newScopeType !== 'none' && scopeValue}
{@const matches = matchingServers ?? []}
{#if matches.length === 0}
<div class="preview warn">No matching servers — the promoted script will land nowhere.</div>
{:else}
<div class="preview ok">
{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}
</div>
{/if}
{/if}
</Field>
<Field label="File">
<Input type="file" onchange={pickFile} disabled={uploading} />
</Field>
<div class="form-actions">
<Button
type="submit"
variant="primary"
disabled={uploading || !newFile || !newName}
loading={uploading}
>
Upload script
</Button>
{#if flash}<span class="flash">{flash}</span>{/if}
</div>
</form>
</Card>
<!-- Script list -->
<section class="section">
<div class="section-head">
<h2>All scripts</h2>
<Badge variant="neutral" size="sm">{scripts.length}</Badge>
<SectionHeader title="Scripts" eyebrow="Your script library" subtitle="Review versions, prepare a beta, and keep releases moving.">
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
</SectionHeader>
<div class="status-grid" aria-label="Filter by script status">
{#each tabs as tab}
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
</button>
{/each}
</div>
<section class="library" aria-label="Script library">
<div class="toolbar">
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
<label><span class="sr-only">Sort scripts</span><select bind:value={sort}><option value="attention">Needs attention first</option><option value="recent">Recently uploaded</option><option value="name">Name A–Z</option></select></label>
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if scripts.length === 0}
<EmptyState title="No scripts yet" description="Upload one above to get started." />
<div class="list-heading">
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
<span>{visible.length} of {scripts.length} scripts</span>
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
</div>
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
{#if loading}<p class="loading" role="status">Loading your library…</p>
{:else if !scripts.length && !error}
<EmptyState title="Your next release starts here" description="Drop script files anywhere, or upload your first script to prepare a beta." />
<div class="empty-action"><Button onclick={() => uploader.open()}>Upload scripts</Button></div>
{:else if !visible.length && !error}
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
{:else}
<div class="rows">
{#each scripts as s (s.id)}
<a class="row" href={detailHref(s)}>
<div class="row-top">
<span class="name">{s.name}</span>
<Badge
variant={s.scope_type === 'game' ? 'info' :
s.scope_type === 'map' ? 'warning' : 'neutral'}
size="sm"
>
{scopeLabel(s)}
</Badge>
{#if stateVariantFor(s)}
{@const v = stateVariantFor(s)!}
<Badge variant={v} size="sm">{stateLabel(s)}</Badge>
{/if}
{#if s.runningBetaId}
<Badge variant="warning" size="sm">β{s.runningBetaId} running</Badge>
{/if}
{#each visible as s (s.id)}
{@const state = scriptStatus(s)}
<article class="script-row" class:selected={selected.includes(s.id)}>
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
<div class="script-info">
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><Badge variant={state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral'} size="sm">{statusLabels[state]}</Badge></div>
<div class="path">{s.target_rel_path}</div>
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
</div>
<div class="row-meta">
<span class="path">{s.target_rel_path}</span>
<span>v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span>
{#if s.servers.length}
<span>{s.servers.length} matching server{s.servers.length === 1 ? '' : 's'}</span>
{/if}
<div class="versions">
<span>{s.currentVersion ? `Promoted v${s.currentVersion.version}` : 'Never promoted'}</span>
{#if s.latestVersion && s.latestVersion.id !== s.current_version_id}<strong>Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}</strong>{/if}
</div>
</a>
<div class="row-action">
{#if s.runningBetaId !== null}<Button size="sm" href="/betas/{s.runningBetaId}">View beta →</Button>
{:else if state === 'failed'}<Button size="sm" href="/scripts/{s.id}">Review version</Button>
{:else if canTest(s)}<Button size="sm" href={betaHref([s])}>Test version →</Button>
{:else}<Button size="sm" onclick={() => uploader.open(s.id)}>Upload version</Button>{/if}
</div>
</article>
{/each}
</div>
{/if}
<p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
</section>
{#if selectedScripts.length}
<div class="selection-bar" role="region" aria-label="Selected scripts">
<strong>{selectedScripts.length} selected</strong><span>Latest versions will be carried into beta setup.</span>
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
</div>
{/if}
</div>
{#if dragDepth > 0}<div class="drop-overlay"><strong>Drop your scripts here</strong><span>We’ll recognize existing scripts and check for duplicate versions.</span></div>{/if}
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
.panel-title {
margin: 0 0 var(--space-4);
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.banner {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.22);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-primary);
}
.banner a { color: var(--state-online); text-decoration: underline; }
.upload-form {
display: grid;
gap: var(--space-4);
grid-template-columns: 1fr 1fr;
}
.scope-row {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
.preview {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.preview.ok { background: var(--surface-sunken); color: var(--text-secondary); }
.preview.warn { background: rgba(255, 176, 32, 0.06); color: var(--state-warning); border: 1px solid rgba(255, 176, 32, 0.28); }
.form-actions {
flex-wrap: wrap;
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: var(--space-4);
}
.flash {
color: var(--state-danger);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.section-head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.section-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.rows { display: flex; flex-direction: column; gap: var(--space-2); }
.row {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.row:hover { background: var(--surface-raised); border-color: var(--border-strong); }
.row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-top {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.name {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.row-meta {
display: flex;
gap: var(--space-5);
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
flex-wrap: wrap;
}
.path { color: var(--text-secondary); }
@media (max-width: 720px) {
.upload-form { grid-template-columns: 1fr; }
}
.page { display: flex; flex-direction: column; gap: var(--space-5); padding-bottom: 90px; }
.status-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--space-2); }
.status-grid button { text-align: left; display: flex; flex-direction: column; gap: 12px; padding: 18px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); cursor: pointer; font: inherit; }
.status-grid button.active { border-color: var(--accent-primary); background: color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); }
.status-grid span { font-size: var(--text-xs); } .status-grid strong { color: var(--text-primary); font-size: var(--text-2xl); }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; } .toolbar label { min-width: 0; }
input[type='search'], select { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
input[type='checkbox'] { accent-color: var(--accent-primary); width: 16px; height: 16px; flex-shrink: 0; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.list-heading { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; padding: 20px 4px 12px; font-size: var(--text-xs); color: var(--text-secondary); }
.select-all { display: flex; align-items: center; gap: 10px; } .text-button { background: none; border: 0; color: var(--accent-primary); cursor: pointer; font: inherit; }
.rows { display: flex; flex-direction: column; gap: 8px; } .script-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(160px, auto) auto; gap: 20px; align-items: center; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.script-row.selected { border-color: var(--accent-primary); } .name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.name { font-weight: 600; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; } .name:hover { color: var(--accent-primary); }
.path { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 6px; overflow-wrap: anywhere; }
.meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 12px; }
.versions { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); } .versions strong { font-weight: 500; color: var(--text-primary); }
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 1100px) { .status-grid { grid-template-columns: repeat(3, 1fr); } .script-row { grid-template-columns: auto minmax(0, 1fr) auto; } .versions { grid-column: 2; } .row-action { grid-column: 3; grid-row: 1 / 3; } }
@media (max-width: 640px) { .status-grid { grid-template-columns: repeat(2, 1fr); } .status-grid button { padding: 12px; gap: 6px; } .status-grid strong { font-size: 24px; } .script-row { gap: 12px; padding: 16px; grid-template-columns: auto minmax(0, 1fr); } .row-action { grid-column: 2; grid-row: auto; } .toolbar label { flex: 1 1 140px; } .toolbar .search { flex-basis: 100%; } .selection-bar span { display: none; } }
</style>