Simplify single-server launch flow

This commit is contained in:
2026-10-07 13:56:42 -04:00
parent bd7d1ed86f
commit 2e1e5ea35f
9 changed files with 670 additions and 204 deletions
+210 -159
View File
@@ -1,198 +1,249 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js';
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
import NativeHostSelection from './NativeHostSelection.svelte';
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js';
import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }:
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let { open = $bindable(false), sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '' }:
{ open?: boolean; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string } = $props();
let game = $state<ZombieGame>('t5');
let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintId = $state('');
let collectionOpen = $state(false);
let maps = $derived(catalogs[game]);
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let preview = $state<LaunchPreview | null>(null);
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let gameSelect = $state<HTMLSelectElement>();
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false);
let busy = $state(false);
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
let view = $state<ServerLaunchState>({ preview: null, checking: false, error: '' });
const controller = createServerLaunchController(state => { view = state; });
let loaded = $state(false);
let error = $state('');
let blueprintName = $state('');
let description = $state('');
let savedName = $state('');
let saving = $state(false);
let wasOpen = false;
let session = 0;
let lobbyNotice = $state('');
let selectionNotice = $state('');
let loadingError = $state('');
let launchError = $state('');
let launching = $state(false);
let unconfirmedLaunch = $state(false);
let refreshVersion = $state(0);
let now = $state(Date.now());
let heading: HTMLHeadingElement;
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
let wasOpen = false;
let visit = 0;
let initialization: AbortController | undefined;
let submission: AbortController | undefined;
const maps = $derived(catalogs[game]);
const preview = $derived(view.preview);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
const checksBlocked = $derived(preview?.checks.filter(check => check.state === 'blocked') ?? []);
const missingKey = $derived(!!preview?.name && !preview.hasSavedKey);
const canLaunch = $derived(!launching && !!preview?.name && (unconfirmedLaunch ||
(hostReady && !hostStatus.loading && !view.checking && !expired && preview.ready && preview.hasSavedKey)));
const checking = $derived(!loaded && !loadingError || hostStatus.loading || view.checking);
const footerStatus = $derived(launching ? 'Queueing server…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
: loadingError ? 'Could not load launch options.' : checking ? 'Checking availability…'
: missingKey ? 'No game key available.' : canLaunch ? 'Ready to launch' : 'Launch unavailable');
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
if (open && !wasOpen) { wasOpen = true; void initialize(); }
else if (!open && wasOpen) {
wasOpen = false; visit++; initialization?.abort(); controller.clear();
}
});
$effect(() => {
// Selection changes invalidate the old preview before another launch can use it.
const input = open && loaded && hostReady && !hostStatus.loading ? {
game, mapSlug, libraryId: selection.libraryIds[game]!, image: selection.image,
bindAddress: selection.bindAddress || undefined, publicAddress: selection.publicAddress || undefined,
publicPort: selection.publicPort
} : null;
if (!launching && !unconfirmedLaunch) controller.select(input);
});
async function initialize() {
const current = ++session;
preview = null; error = ''; loaded = false; busy = false; lobbyNotice = ''; selectionNotice = '';
blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId;
game = initialGame;
mapSlug = initialMapSlug;
hostSelection = { libraryIds: {}, image: '' }; hostReady = false;
const current = ++visit;
initialization?.abort(); initialization = new AbortController();
const signal = initialization.signal;
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
game = initialGame; mapSlug = initialMapSlug;
selection = { libraryIds: {}, image: '' }; hostReady = false;
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
if (current !== session) return;
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
blueprints = blueprintBody.blueprints;
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
const response = await fetch('/api/maps', { signal });
if (!response.ok) throw new Error('Could not load maps. Try again.');
const body = await response.json();
if (current !== visit) return;
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (sourceDeploymentId) {
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json());
if (current !== session) return;
if (!source.recipe) throw new Error('The original launch recipe is unavailable.');
const sourceResponse = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`, { signal });
const source = await sourceResponse.json();
if (current !== visit) return;
if (!sourceResponse.ok || !source.recipe || !['t5', 't6'].includes(source.recipe.game)
|| !catalogs[source.recipe.game as ZombieGame].some(map => map.slug === source.recipe.mapSlug)) {
throw new Error('The original launch map is unavailable.');
}
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
}
if (!catalogs[game].some(map => map.slug === mapSlug)) mapSlug = catalogs[game][0]?.slug ?? '';
loaded = true;
} catch (err) { if (current === session) error = (err as Error).message; }
}
function selectionChanged() {
if (preview) selectionNotice = 'The launch settings or game files changed. Review the launch again with the latest files.';
preview = null; lobbyNotice = '';
}
async function review() {
if (busy) return;
if (!sourceDeploymentId && selectedBlueprint) {
open = false; collectionOpen = true; return;
await tick();
if (current === visit && open) gameSelect?.focus();
} catch (error) {
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load launch options.';
}
if (!hostReady) { error = 'Wait for a host to finish preparing the latest game files and runtime.'; return; }
const current = session;
const previousName = preview?.name;
busy = true; error = ''; savedName = ''; lobbyNotice = ''; selectionNotice = '';
try {
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
libraryId: hostSelection.libraryIds[game], image: hostSelection.image,
bindAddress: hostSelection.bindAddress || undefined, publicAddress: hostSelection.publicAddress || undefined, publicPort: hostSelection.publicPort }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) {
if (previousName && previousName !== body.name) lobbyNotice = 'The available lobby changed. Review the updated server before launching.';
preview = body; now = Date.now();
await tick(); heading?.focus();
}
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) busy = false; }
}
function refreshChecks() {
if (!open || launching || unconfirmedLaunch) return;
if (!loaded) { void initialize(); return; }
launchError = '';
controller.clear();
refreshVersion++;
}
function selectionChanged() { launchError = ''; }
onMount(() => {
const refreshOnReturn = () => { if (document.visibilityState !== 'hidden') refreshChecks(); };
window.addEventListener('focus', refreshOnReturn);
document.addEventListener('visibilitychange', refreshOnReturn);
const timer = setInterval(() => { now = Date.now(); }, 1000);
return () => {
window.removeEventListener('focus', refreshOnReturn);
document.removeEventListener('visibilitychange', refreshOnReturn);
clearInterval(timer);
};
});
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
async function launch() {
if (!preview || !preview.ready || !preview.hasSavedKey || !hostReady || expired || busy || saving) return;
busy = true; error = '';
if (!canLaunch || !preview) return;
const current = visit;
const reviewed = preview;
controller.suspend(); launching = true; launchError = '';
submission = new AbortController();
try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
expectedName: preview.name, previewId: preview.id }) });
const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
const response = await fetch('/api/servers', { method: 'POST', signal: submission.signal,
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
game: reviewed.recipe.game, gameMode: 'zombie', mapSlug: reviewed.recipe.mapSlug,
expectedName: reviewed.name, previewId: reviewed.id
}) });
const body = await response.json();
if (current !== visit) return;
if (!response.ok || !body.ok) {
unconfirmedLaunch = response.status >= 500;
launchError = body.error ?? 'Could not queue this launch.';
if (!unconfirmedLaunch) { controller.clear(); refreshVersion++; }
return;
}
open = false;
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function save() {
if (!preview || saving) return;
const current = session;
saving = true; error = '';
try {
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) saving = false; }
} catch (error) {
if (current === visit) {
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
unconfirmedLaunch = true;
launchError = 'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
}
} finally { if (current === visit) launching = false; }
}
</script>
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
<div class="composer">
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header>
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
{#if error}<div class="error" role="alert">{error}</div>{/if}
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
{#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview}
{#if lobbyNotice}<p role="status" class="muted">{lobbyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity and key availability.</p>{/if}
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
</section>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>An available key from this game’s pool is assigned when you launch.</p></div>
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p>
{:else}<p class="muted" role="status">No unused {preview.recipe.game.toUpperCase()} keys are available. Add a key in <a href={`/server-keys?game=${preview.recipe.game}`} target="_blank" rel="noopener">Keys (opens in a new tab)</a>, then Refresh preview to enable launch.</p>{/if}
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || !preview.hasSavedKey}>Launch server</Button></div>
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Capacity and key availability are checked again at launch. The host and port are assigned automatically.</p>
</form>
<details class="save"><summary>Make this a reusable collection</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your collection library.</p>{:else}
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Collection name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save collection</Button></form>
{/if}
</details>
{:else if sourceDeploymentId}
<p class="muted">Launch the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
<Dialog bind:open title="Create server" busy={launching}>
<div class="launch-content">
{#if loadingError}
<p class="issue" role="alert">{loadingError}</p>
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
{:else if !loaded}
<p class="muted" role="status">Loading maps…</p>
{:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and game key availability together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
</form>
<div class="fields">
<label>Game<select bind:this={gameSelect} bind:value={game} disabled={launching || unconfirmedLaunch}
onchange={event => { game = event.currentTarget.value as ZombieGame; mapSlug = catalogs[game][0]?.slug ?? ''; selectionChanged(); }}>
<option value="t5">Black Ops I</option><option value="t6">Black Ops II</option>
</select></label>
<label>Map<select bind:value={mapSlug} disabled={launching || unconfirmedLaunch} onchange={selectionChanged}>
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
</select></label>
</div>
<section class="summary" aria-label="Server summary" aria-busy={checking}>
<strong>{preview?.name ? serverTitle(preview.name) : maps.find(map => map.slug === mapSlug)?.friendly ?? 'Choose a map'}</strong>
<p class="muted">Uses the current production setup when the server starts.</p>
<div class="readiness" aria-label="Launch readiness">
{#if checking}<span role="status">Checking availability…</span>
{:else}
<span class:ready={hostStatus.filesReady}>{hostStatus.missingGames.length ? 'Game files missing' : hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>
{#if preview?.hasSavedKey}<span class="ready">Key available</span>{/if}
<span>Automatic host &amp; port</span>
{/if}
</div>
</section>
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener">View Provisioning ↗</a>{/if}</div>{/if}
{#if missingKey}<div class="issue" role="status"><p>No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.</p><a href={`/server-keys?game=${game}`} target="_blank" rel="noopener">Manage keys ↗</a></div>{/if}
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">{view.error}</p>{/if}
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div class="issue" role="status"><p>Add a host to run your server.</p><a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a></div>{/if}
{#each checksBlocked as check}<p class="issue" role="status">{check.detail}</p>{/each}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button class="text-button retry" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
{#if open}<NativeHostSelection games={[game]} bind:selection bind:ready={hostReady} bind:status={hostStatus}
compact network {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
{#if preview}<details class="setup-details">
<summary>Setup details</summary>
<dl>
<div><dt>Server ID</dt><dd><code>{preview.name ?? 'No lobby available'}</code></dd></div>
<div><dt>Configuration</dt><dd>{preview.recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd><code>{preview.recipe.image}</code></dd></div>
{#if hostStatus.uploadedAt}<div><dt>Game files uploaded</dt><dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd></div>{/if}
</dl>
<h3>{preview.recipe.files.length} managed files</h3>
<ul class="files">{#each preview.recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span></div><code>{file.path}</code></li>{/each}</ul>
</details>{/if}
{/if}
</div>
</dialog>
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
{#snippet actions()}
<div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry launch' : 'Launch server'}</Button></div>
</div>
{/snippet}
</Dialog>
<style>
.key-ready { color: var(--state-online); font-weight: 600; }
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: var(--radius-md); font-size: 13px; }.success { color: var(--accent-primary-hover); }
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } }
.launch-content { display: grid; gap: 18px; }
.fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 14px; }
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
select { width: 100%; }
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
.summary > strong { font-size: 17px; line-height: 1.4; }
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
.ready { color: var(--state-online); }
.issue { display: grid; gap: 8px; padding: 12px; border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent); border-radius: var(--radius-md); color: var(--state-warning); font-size: var(--text-sm); }
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
.text-button { border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
.retry { justify-self: start; }
.setup-details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
summary { cursor: pointer; font-size: var(--text-sm); }
dl { display: grid; gap: 14px; margin: 16px 0; }
dt { color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); line-height: 1.5; }
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
h3 { font-size: var(--text-sm); margin: 18px 0 8px; }
.files { list-style: none; padding: 0; margin: 0; }
.files li { display: grid; gap: 5px; padding: 10px 0; border-bottom: 1px solid var(--border-subtle); }
.files li > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.files strong { font-size: var(--text-sm); font-weight: 500; }
.files span, .files code { font-size: var(--text-xs); color: var(--text-secondary); }
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
.launch-actions > p.ready { color: var(--state-online); }
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
@media (max-width: 440px) { .fields { grid-template-columns: 1fr; } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
</style>