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
+31 -15
View File
@@ -3,10 +3,14 @@
import { serverTitle } from '$ui/server-labels.js';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
import { saveLibraryContext } from '$ui/library-context.js';
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
let servers = $state<ConfigServer[]>([]);
let selected = $state('');
let inspection = $state<ConfigInspection | null>(null);
let promoted = $state<ConfigInspection | null>(null);
let expanded = $state<string[]>([]);
let inspectedServer = '';
let loading = $state(true);
let error = $state('');
let query = $state('');
@@ -14,10 +18,15 @@
let raw = $state(false);
let reload = $state(0);
let ready = $state(false);
let desiredServer = '';
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
const previousValues = $derived(new Map((promoted?.settings ?? []).map(s => [s.key, s.value])));
const changes = $derived(promoted && inspection ? inspection.settings.filter(s => previousValues.get(s.key) !== s.value) : []);
const removed = $derived(promoted?.settings.filter(s => inspection && !inspection.settings.some(current => current.key === s.key)) ?? []);
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
function overriddenCount(source: ConfigSource) { return inspection?.settings.filter(s => (s.source.id !== source.id || s.source.kind !== source.kind) && s.history.some(h => h.source.id === source.id && h.source.kind === source.kind)).length ?? 0; }
async function loadServers() {
loading = true; error = '';
@@ -30,8 +39,9 @@
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
}
onMount(loadServers);
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; });
onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); });
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible.find(s => s.name === desiredServer)?.name ?? eligible[0]?.name ?? ''; });
$effect(() => { if (ready && selected && !fixedServer) saveLibraryContext({ server: selected }); });
$effect(() => {
const server = selected;
const text = draftText;
@@ -39,7 +49,8 @@
const refresh = reload;
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
const controller = new AbortController();
loading = true; error = ''; inspection = null;
loading = true; error = '';
if (inspectedServer !== server) { inspection = null; promoted = null; expanded = []; inspectedServer = server; }
const timer = setTimeout(async () => {
try {
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
@@ -48,7 +59,7 @@
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
if (!controller.signal.aborted) inspection = body.inspection;
if (!controller.signal.aborted) { inspection = body.inspection; promoted = body.promoted ?? null; }
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
finally { if (!controller.signal.aborted) loading = false; }
}, text === undefined ? 0 : 350);
@@ -71,26 +82,26 @@
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label>
{#if error}<div class="error" role="alert">{error}</div>
{:else if loading}<p class="empty" role="status">Composing configuration…</p>
{:else if !eligible.length}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
{:else if inspection}
<div class="sources" aria-label="Composition sources">
<ol class="sources" aria-label="Composition order, later layers take precedence">
{#each inspection.sources as source, index}
{#if index}<span class="arrow" aria-hidden="true">→</span>{/if}
{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
{:else}<span class="map-source">{source.name}</span>{/if}
<li><span class="layer-number">{index + 1}.</span>{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
{:else}<span class="map-source">{source.name}</span>{/if}<span>{overriddenCount(source)} settings superseded later</span></li>
{/each}
</div>
</ol>
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
{#if promoted}<p class="change-summary">Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.</p>{#if removed.length}<details><summary>Removed settings</summary><ul>{#each removed as setting}<li><code>{setting.key}</code> · previously {setting.value}</li>{/each}</ul></details>{/if}{/if}
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
{:else}
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
<div class="settings">
{#each settings as setting (setting.key)}
<details class="setting"><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span></summary>
<details class="setting" open={expanded.includes(setting.key)} ontoggle={event => { const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span>{#if promoted && previousValues.get(setting.key) !== setting.value}<span class="change-summary">{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}</span>{/if}</summary>
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
</details>
{:else}<p class="empty">No matching settings.</p>{/each}
@@ -110,10 +121,14 @@
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.sources { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; font-size: var(--text-xs); }
.sources { display: grid; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; font-size: var(--text-xs); list-style: none; }
.sources li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0; }
.layer-number { color: var(--text-primary); font-weight: 600; }
.preview-status { font-size: var(--text-xs); color: var(--text-secondary); min-height: 20px; }
.change-summary { display: block; font-size: var(--text-xs); color: var(--state-warning); margin-top: 8px; }
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
.sources a.draft { color: var(--state-warning); }
.map-source, .arrow { color: var(--text-secondary); }
.map-source { color: var(--text-secondary); }
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
.switch { display: flex; gap: 4px; }
@@ -142,4 +157,5 @@
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
@media (max-width: 600px) { .inspector { padding: 16px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
</style>