Improve operational UX, recovery flows, and action safety

Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
2026-09-19 21:34:34 -04:00
parent 940f81e6ca
commit 8ac420fe61
83 changed files with 2574 additions and 1190 deletions
+33 -9
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -9,9 +10,14 @@
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';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
let limit = $state(25);
$effect(() => { search; status; game; map; sort; limit = 25; });
let mounted = $state(false);
afterNavigate(() => { mounted = true; });
let lastFilters = '';
$effect(() => { const key = JSON.stringify([search, status, game, map, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
$effect(() => { if (mounted) saveLibraryContext({ q: search, status, game, map, sort, limit, selected: selected.join(',') }, { status: 'all', sort: 'attention', limit: 25 }); });
let scripts = $state<LibraryScript[]>([]);
let loading = $state(true);
let error = $state('');
@@ -34,7 +40,7 @@
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)))
&& (!game || !s.scope_type || (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);
@@ -42,6 +48,8 @@
return a.name.localeCompare(b.name);
}));
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
const hiddenSelected = $derived(selectedScripts.filter(s => !visible.slice(0, limit).some(row => row.id === s.id)).length);
let reviewSelection = $state(false);
const selectable = $derived(visible.slice(0,limit).filter(canTest));
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
async function load() {
@@ -54,7 +62,14 @@
} catch (e) { error = (e as Error).message; }
finally { loading = false; }
}
onMount(() => { void load(); });
onMount(() => {
const params = new URLSearchParams(window.location.search);
search = params.get('q') ?? ''; game = params.get('game') ?? ''; map = params.get('map') ?? '';
status = tabs.some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all';
sort = ['attention', 'recent', 'name'].includes(params.get('sort') ?? '') ? params.get('sort')! : 'attention';
limit = libraryLimit(params); selected = (params.get('selected') ?? '').split(',').map(Number).filter(id => Number.isSafeInteger(id) && id > 0).slice(0, 500);
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);
@@ -70,6 +85,8 @@
}
</script>
<svelte:head><title>Scripts · Electric Cherry</title></svelte:head>
<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} />
@@ -78,15 +95,15 @@
<SectionHeader title="Scripts" 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">
{#if loading || scripts.length || error}<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>
</div>{/if}
<section class="library" aria-label="Script library">
<div class="toolbar">
{#if loading || scripts.length || error}<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>
@@ -96,7 +113,8 @@
<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>
</div>{/if}
{#if game}<p class="hint">Game results include unscoped scripts that can use explicitly selected beta servers.</p>{/if}
{#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}
@@ -134,9 +152,11 @@
</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>
<strong title={hiddenSelected ? 'Some selected scripts are outside the current filters or loaded rows.' : undefined}>{selectedScripts.length} selected{hiddenSelected ? ` · ${hiddenSelected} hidden` : ''}</strong><span>Latest versions will be carried into beta setup.</span>
<Button size="sm" variant="ghost" ariaLabel="Review selected scripts" onclick={() => reviewSelection = !reviewSelection}>Review</Button>
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
<Button size="sm" variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
{#if reviewSelection}<ul class="selection-review">{#each selectedScripts as script}<li>{script.name} · v{script.latestVersion?.version}<Button size="sm" variant="ghost" onclick={() => toggle(script.id)} ariaLabel={`Remove ${script.name} from selection`}>Remove</Button></li>{/each}</ul>{/if}
</div>
{/if}
</div>
@@ -164,6 +184,9 @@
.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); }
.selection-review { flex-basis: 100%; margin: 0; padding: 0; list-style: none; max-height: 240px; overflow: auto; }
.selection-review li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 6px 0; }
@media (max-width: 640px) { .selection-bar { padding: 12px; gap: 8px; bottom: 12px; } .selection-bar > strong { flex-basis: 100%; 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; }
@@ -172,4 +195,5 @@
.status-grid { display:flex; gap:8px; overflow-x:auto; }
.status-grid button { flex-direction:row; align-items:center; flex-shrink:0; padding:8px 12px; gap:8px; }
.status-grid strong { font-size:13px; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
</style>