Files
ODIAN_Launcher/src/components/ActionPanel.svelte
T
Kelorastus 3863809ae8 new console
2026-09-30 13:36:14 +02:00

869 lines
30 KiB
Svelte

<script lang="ts">
import Icon, { type IconName } from "./Icon.svelte";
import ProgressBar from "./ProgressBar.svelte";
import StackList from "./StackList.svelte";
import StateDot, { type DotTone } from "./StateDot.svelte";
import { launcher } from "../lib/launcher.svelte";
import { STAGE_LABEL, formatBytes, formatCount, formatDuration, formatSpeed } from "../lib/format";
const s = $derived(launcher.status);
const p = $derived(launcher.progress);
const action = $derived(launcher.action);
const busy = $derived(launcher.busy);
const checking = $derived(s?.phase === "checking");
const blocked = $derived(busy !== null || checking);
const site = $derived(launcher.siteName);
const fraction = $derived.by(() => {
if (!p) return null;
if (p.total_bytes > 0) return p.done_bytes / p.total_bytes;
if (p.total_items > 0) return p.done_items / p.total_items;
return null;
});
const transferred = $derived.by(() => {
if (!p) return "—";
if (p.total_bytes > 0) return `${formatBytes(p.done_bytes)} / ${formatBytes(p.total_bytes)}`;
if (p.total_items > 0) return `${formatCount(p.done_items)} / ${formatCount(p.total_items)}`;
return "—";
});
interface Head {
label: string;
tone: DotTone;
pulse: boolean;
meta: string | null;
}
const head: Head = $derived.by(() => {
const stage = p ? `Stage ${p.stage_index}/${p.stage_count}` : null;
const version = (v: string | null | undefined) => (v ? `v${v}` : null);
switch (action) {
case "loading":
return { label: "Connecting", tone: "idle", pulse: true, meta: null };
case "sign_in":
if (s?.installed && s.update_required) {
return { label: "Update required", tone: "warn", pulse: false, meta: `${s.installed} → ${s.available}` };
}
return s?.installed
? { label: "Signed out", tone: "idle", pulse: false, meta: version(s.installed) }
: { label: "Not installed", tone: "idle", pulse: false, meta: version(s?.available) };
case "signed_out_offline":
return { label: "Signed out", tone: "warn", pulse: false, meta: "offline" };
case "signing_in":
return { label: "Signing in", tone: "accent", pulse: true, meta: null };
case "install":
return { label: "Ready to install", tone: "accent", pulse: false, meta: version(s?.available) };
case "offline_unavailable":
return { label: "Not installed", tone: "warn", pulse: false, meta: "offline" };
case "installing":
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
case "restoring":
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "syncing":
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
case "pulling_tiles":
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
case "removing":
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null };
case "building":
return { label: "Building the checkout", tone: "accent", pulse: true, meta: null };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
return { label: s?.online ? "Ready" : "Ready offline", tone: "ok", pulse: false, meta: version(s?.installed) };
case "stack":
if (launcher.stopping) return { label: "Stopping", tone: "warn", pulse: true, meta: version(s?.installed) };
return s?.phase === "running"
? { label: "Running", tone: "ok", pulse: false, meta: version(s?.installed) }
: { label: "Starting ODIAN", tone: "accent", pulse: true, meta: version(s?.installed) };
case "error":
return { label: "Needs attention", tone: "danger", pulse: false, meta: null };
}
});
// What the DHP has baked, for the sets this PC holds, since it last looked. Zero means
// level with the site - which is worth saying nothing at all about.
// Chosen, and not all here. ODIAN runs without it, so this is said beside Launch rather
// than in place of it - but it IS said: the marketplace was the only place that knew.
const unfinished = $derived(s?.unfinished ?? null);
const unfinishedNames = $derived.by(() => {
const titles = unfinished?.titles ?? [];
if (titles.length === 0) return "";
const shown = titles.slice(0, 2).join(", ");
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown;
});
// Said out loud whenever this run is not the installed ODIAN. A dev client looks exactly
// like the real one on screen, and mistaking the two is how a bug gets chased in the
// wrong tree - or a demo gets given from an unbuilt branch.
const devOn = $derived(launcher.devOn);
const devRun = $derived(launcher.devReady);
const devRoot = $derived(launcher.settings?.dev_root ?? "");
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
const devBuild = $derived(launcher.settings?.dev_build ?? false);
const devUserField = $derived(launcher.settings?.dev_user ?? "");
const savingDev = $derived(busy === "settings");
// One stack either way: both clients talk to the same API and the same database, which is
// the only way a .exe/web divergence is visible at all.
const TARGETS = [
{ key: "client", name: "ODIAN", note: "Odian.exe" },
{ key: "web", name: "Web", note: ":5173" },
{ key: "both", name: "Both", note: "one stack" },
] as const;
// ---- releasing ------------------------------------------------------------
async function openRelease() {
launcher.releaseOpen = true;
await launcher.loadRelease();
}
// While one runs, this button is the way BACK to its output rather than a way to start a
// second: closing the console otherwise left a running release with nowhere to be seen.
const releasing = $derived(launcher.consoleRunning);
const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen);
async function browseCheckout() {
const path = await launcher.pickFolder();
if (path) await launcher.setDev({ dev_root: path });
}
// Named only when it is someone ELSE: running as yourself is not worth a warning.
const devUser = $derived.by(() => {
const asked = launcher.settings?.dev_user?.trim() ?? "";
return devRun && asked && asked !== s?.operator ? asked : "";
});
const tiles = $derived(s?.tiles_waiting ?? null);
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`);
const rp = $derived(launcher.removeProgress);
const removeFraction = $derived(rp === null || rp.total === 0 ? null : Math.min(rp.done / rp.total, 1));
const tp = $derived(launcher.tileProgress);
const tileMeta = $derived(tp === null ? null : `${formatCount(tp.tiles)} tiles`);
const tileFraction = $derived(tp === null || tp.page_tiles === 0 ? null : tp.page_done / tp.page_tiles);
const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error));
</script>
{#snippet primary(label: string, icon: IconName, onclick: () => void, disabled: boolean, pending = false)}
<div class="primary-wrap">
<button class="primary" {onclick} {disabled} aria-busy={pending}>
<span class="primary-icon">
{#if pending}
<span class="spinner" aria-hidden="true"></span>
{:else}
<Icon name={icon} size={18} strokeWidth={1.75} />
{/if}
</span>
<span class="primary-label">{label}</span>
</button>
<span class="corners" aria-hidden="true"></span>
</div>
{/snippet}
{#snippet driveLink(lead: string)}
<p class="secondary">
{lead}
<button class="btn-link" disabled={blocked} onclick={() => launcher.chooseDrive()}>Install from a drive…</button>
</p>
{/snippet}
<section class="action glass" aria-labelledby="action-title">
<header class="head">
<StateDot tone={head.tone} pulse={head.pulse} />
<h2 id="action-title" class="eyebrow title" aria-live="polite">{head.label}</h2>
{#if head.meta}<span class="meta mono">{head.meta}</span>{/if}
</header>
{#if action === "loading"}
{@render primary("Loading", "play", () => {}, true, true)}
{:else if action === "sign_in"}
{@render primary(
s?.update_required ? "Sign in to update" : s?.installed ? "Sign in" : "Sign in to install",
"key",
() => launcher.signIn(),
blocked,
busy === "sign_in",
)}
<p class="secondary">
{#if s?.update_required}
ODIAN <strong>{s.available}</strong> is required before launch while connected. Sign in with your {site}
account to download it.
{:else if s?.installed}
Sign in with your {site} account. This PC then remembers you, online and offline, until you sign out.
{:else}
Sign in with your {site} account to download ODIAN{#if s?.available}&nbsp;<strong>{s.available}</strong>{/if}.
{/if}
</p>
{#if !s?.installed}{@render driveLink("No access to the DHP?")}{/if}
{:else if action === "signing_in"}
{@render primary("Waiting for sign-in", "key", () => {}, true, true)}
<p class="secondary">
Complete the Keycloak sign-in in your browser, then come back here.
<button class="btn-link" onclick={() => launcher.cancelSignIn()}>Cancel sign-in</button>
</p>
{:else if action === "install"}
{@render primary("Install", "download", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
{@render driveLink("Installing needs your DHP, or an ODIAN drive.")}
{:else if action === "signed_out_offline"}
{@render primary("Connect to your DHP to sign in", "globe", () => {}, true)}
<p class="secondary">
This PC doesn't remember an operator anymore. Sign in once while connected to {site}, and ODIAN launches offline
again after that.
</p>
{:else if action === "update"}
{@render primary(`Update to ${s?.available}`, "update", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
Updating is required before launch while connected to {site}. Everything already verified is reused.
</p>
{:else if action === "launch"}
{@render primary("Launch", "play", () => launcher.launch(), blocked || !s?.can_launch, busy === "launch")}
<p class="secondary">
{#if checking}
Checking {site}…
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if}
</p>
{#if launcher.syncAttention > 0}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
<strong>{launcher.syncAttention}</strong>
{launcher.syncAttention === 1 ? "change needs" : "changes need"} your decision.
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Review</button>
</span>
</p>
{:else if s?.online || s?.sync}
<p class="secondary small">
Operator data syncs with {site} before every launch.
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{#if devOn}
<!-- Dev mode's own controls, where the launching happens. Everything that decides
what Launch does is here rather than behind Settings: the target, the checkout,
and whether it builds first. -->
<section class="dev" aria-label="Dev mode">
<header class="dev-head">
<Icon name="code" size={13} />
<span class="dev-title">DEV</span>
<!-- What Launch will actually start, in three words. The folder is not repeated
here: it is in the field two rows down. -->
<span class="dev-from mono">{devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"}</span>
</header>
<div class="dev-targets" role="radiogroup" aria-label="Launch starts">
{#each TARGETS as option (option.key)}
<label class="dev-target" class:on={devTarget === option.key}>
<input
type="radio"
name="dev-target"
value={option.key}
checked={devTarget === option.key}
disabled={blocked || savingDev}
onchange={() => launcher.setDev({ dev_target: option.key })}
/>
<span class="dev-target-name">{option.name}</span>
<span class="dev-target-note mono">{option.note}</span>
</label>
{/each}
</div>
<div class="dev-row">
<input
class="dev-path"
type="text"
spellcheck="false"
placeholder="Folder holding ODIAN_Engine"
value={devRoot}
disabled={blocked || savingDev}
onchange={(e) => launcher.setDev({ dev_root: e.currentTarget.value.trim() })}
/>
<button type="button" class="btn" disabled={blocked || savingDev} onclick={browseCheckout}>Browse…</button>
</div>
<div class="dev-row">
<label class="dev-label" for="dev-run-as">Run as</label>
<input
id="dev-run-as"
class="dev-path"
type="text"
spellcheck="false"
autocomplete="off"
placeholder={s?.operator ?? "the operator who signed in"}
value={devUserField}
disabled={blocked || savingDev}
onchange={(e) => launcher.setDev({ dev_user: e.currentTarget.value.trim() })}
/>
</div>
<div class="dev-row">
{#if releasing}
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
{:else}
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
Build &amp; deploy…
</button>
{/if}
{#if hasOutput && !releasing}
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
{:else if !s?.online}
<span class="dev-note">needs the DHP</span>
{/if}
</div>
<label class="dev-check">
<input
type="checkbox"
checked={devBuild}
disabled={blocked || savingDev}
onchange={(e) => launcher.setDev({ dev_build: e.currentTarget.checked })}
/>
<span>Build first</span>
</label>
{#if !devRun}
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
{:else if devUser}
<p class="dev-note attention">
Running as <strong>{devUser}</strong> — this PC will not sync with the DHP.
</p>
{/if}
</section>
{/if}
{#if unfinished}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
<strong>{unfinished.items}</strong>
{unfinished.items === 1 ? "chosen map is" : "chosen maps are"} only partly downloaded
{#if unfinishedNames}<span class="dim">({unfinishedNames})</span>{/if}.
<button class="btn-link" disabled={blocked} onclick={() => launcher.finishMarket()}>Finish</button>
</span>
</p>
{/if}
{#if tilesOffered}
<!-- Ground another operator looked at while this PC was away. Online it is already
on screen, drawn from {site}; nothing keeps it, so unplugging loses a week of
other people's map. -->
<p class="secondary attention">
<Icon name="download" size={14} />
<span>
<strong>{tilesCount}</strong> new map tiles on {site} ({formatBytes(tiles?.bytes ?? 0)}).
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Take them</button>
</span>
</p>
{/if}
{:else if action === "installing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">{p ? STAGE_LABEL[p.stage] : "Preparing"}</span>
{#if fraction !== null}<span class="pct mono tabular">{Math.floor(fraction * 100)}%</span>{/if}
</div>
<ProgressBar
value={fraction}
label={p ? `${STAGE_LABEL[p.stage]}, stage ${p.stage_index} of ${p.stage_count}` : "Preparing install"}
/>
{#if p}
<div class="segments" style:--count={p.stage_count} aria-hidden="true">
{#each { length: p.stage_count } as _, i (i)}
<span class="seg" class:done={i < p.stage_index - 1} class:current={i === p.stage_index - 1}></span>
{/each}
</div>
<dl class="metrics">
<div>
<dt>Transferred</dt>
<dd class="mono tabular" title={transferred}>{transferred}</dd>
</div>
<div>
<dt>Speed</dt>
<dd class="mono tabular">{launcher.speed > 0 ? formatSpeed(launcher.speed) : "—"}</dd>
</div>
<div>
<dt>Stage ETA</dt>
<dd class="mono tabular">{launcher.eta !== null ? formatDuration(launcher.eta) : "—"}</dd>
</div>
</dl>
{#if p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
{/if}
</div>
<button class="btn btn-danger wide" disabled={busy !== null} onclick={() => launcher.cancelInstall()}>
{busy === "cancel" ? "Cancelling…" : "Cancel"}
</button>
<p class="secondary small">Cancelling keeps every file already verified; the next install resumes from there.</p>
{:else if action === "restoring"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Loading the database snapshot</span>
</div>
<ProgressBar value={null} label="Restoring the database" />
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
</div>
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
{:else if action === "syncing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Exchanging operator data</span>
</div>
<ProgressBar value={null} label="Syncing with the DHP" />
</div>
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "building"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Compiling ODIAN from your checkout</span>
</div>
<ProgressBar value={null} label="Building" />
</div>
<p class="secondary">The build's own window has its output. A first build takes a while.</p>
{:else if action === "removing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Removing {rp?.title ?? "what you unticked"}</span>
{#if removeFraction !== null}<span class="pct mono tabular">{Math.floor(removeFraction * 100)}%</span>{/if}
</div>
<ProgressBar value={removeFraction} label="Removing" />
{#if rp}<p class="message mono">{formatCount(rp.done)} / {formatCount(rp.total)} files</p>{/if}
</div>
<p class="secondary">Deleting a map is hundreds of thousands of files. Nothing is downloaded meanwhile.</p>
{:else if action === "pulling_tiles"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Taking map tiles from {site}</span>
{#if tp}<span class="pct mono tabular">{formatBytes(tp.bytes)}</span>{/if}
</div>
<ProgressBar value={tileFraction} label="Taking map tiles" />
</div>
<p class="secondary">Tiles the other operators baked while this PC was away. It can be stopped and resumed.</p>
<button class="btn wide" onclick={() => launcher.cancelInstall()}>Stop</button>
{:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
{#if s?.map_from_site === false}
<!-- The map is this PC's own. Said plainly, because it looks like nothing is
wrong: tiles still draw, they are simply the ones this PC downloaded. -->
<div class="notice" role="status">
<Icon name="alert" size={14} />
<p class="selectable">
{site}'s map service is unreachable: drawing from this PC's own tiles.
{#if s?.map_problem}<span class="mono dim"> ({s.map_problem})</span>{/if}
</p>
</div>
{/if}
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
<Icon name="stop" size={12} />
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
</button>
{:else if action === "error"}
<div class="error-box" role="alert">
<Icon name="alert" size={16} />
<p class="selectable">{s?.error ?? "Something went wrong."}</p>
</div>
{@render primary("Retry", "refresh", () => launcher.retry(), blocked, busy !== null)}
<p class="secondary">
<button class="btn-link" onclick={() => launcher.setActivityOpen(true)}>View activity</button> for the full log.
</p>
{/if}
{#if showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
<p class="selectable">{launcher.notice}</p>
<button class="icon-btn dismiss" aria-label="Dismiss message" onclick={() => launcher.dismissNotice()}>
<Icon name="close" size={12} />
</button>
</div>
{/if}
</section>
<style>
/* Dev mode's controls. Deliberately compact and deliberately here: what Launch will do
is decided in the same place you press it, not two drawers away. */
.dev {
margin-top: 0.75rem;
padding: 0.7rem 0.75rem;
border: 1px solid color-mix(in srgb, #46d17f 32%, transparent);
border-radius: 8px;
background: color-mix(in srgb, #46d17f 7%, transparent);
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.dev-head {
display: flex;
align-items: center;
gap: 0.4rem;
color: #46d17f;
}
.dev-title {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
}
.dev-from {
margin-left: auto;
font-size: 0.72rem;
color: var(--text-dim, #9aa3ad);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 55%;
direction: rtl; /* keep the END of a long path, which is the part that differs */
text-align: right;
}
.dev-targets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem;
}
.dev-target {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
padding: 0.35rem 0.25rem;
border: 1px solid var(--line, #2a3139);
border-radius: 6px;
cursor: pointer;
line-height: 1.2;
}
.dev-target.on {
border-color: color-mix(in srgb, #46d17f 60%, transparent);
background: color-mix(in srgb, #46d17f 14%, transparent);
}
.dev-target:has(input:disabled) {
opacity: 0.55;
cursor: default;
}
.dev-target input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.dev-target-name {
font-size: 0.82rem;
font-weight: 600;
}
.dev-target-note {
font-size: 0.68rem;
color: var(--text-dim, #9aa3ad);
}
.dev-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.dev-path {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
font-family: inherit;
color: inherit;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--line, #2a3139);
border-radius: 6px;
}
.dev-label {
flex: none;
font-size: 0.78rem;
color: var(--text-dim, #9aa3ad);
}
.dev-check {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
white-space: nowrap;
}
.dev-note {
font-size: 0.75rem;
color: var(--text-dim, #9aa3ad);
margin: 0;
}
.dev-note code {
font-size: 0.72rem;
}
.dev-release {
font-weight: 600;
}
.action {
display: grid;
gap: 14px;
width: 100%;
padding: 18px 22px 22px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
min-height: 20px;
}
.title {
flex: 1;
color: var(--ink-2);
}
.meta {
color: var(--muted);
font-size: var(--text-xs);
}
/* Primary button */
.primary-wrap {
position: relative;
margin: 4px 0 2px;
}
.primary {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
height: 64px;
padding: 0 24px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-ink);
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 14px 40px -16px rgba(91, 140, 255, 0.7);
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur) var(--ease),
transform var(--dur-fast) var(--ease);
}
.primary:hover:not(:disabled) {
border-color: var(--accent-hover);
background: var(--accent-hover);
box-shadow: 0 18px 48px -14px rgba(91, 140, 255, 0.85);
}
.primary:active:not(:disabled) {
background: var(--accent-press);
transform: translateY(1px);
}
.primary:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
.primary:disabled {
border-color: var(--line-strong);
background: var(--surface-2);
color: var(--muted);
box-shadow: none;
font-size: var(--text-lg);
}
.primary-icon {
display: grid;
place-items: center;
width: 20px;
}
.primary-label {
flex: 1;
overflow: hidden;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
.corners {
--c: var(--accent-line);
position: absolute;
inset: -6px;
background:
linear-gradient(var(--c), var(--c)) 0 0 / 10px 1px,
linear-gradient(var(--c), var(--c)) 0 0 / 1px 10px,
linear-gradient(var(--c), var(--c)) 100% 0 / 10px 1px,
linear-gradient(var(--c), var(--c)) 100% 0 / 1px 10px,
linear-gradient(var(--c), var(--c)) 0 100% / 10px 1px,
linear-gradient(var(--c), var(--c)) 0 100% / 1px 10px,
linear-gradient(var(--c), var(--c)) 100% 100% / 10px 1px,
linear-gradient(var(--c), var(--c)) 100% 100% / 1px 10px;
background-repeat: no-repeat;
pointer-events: none;
transition:
inset var(--dur) var(--ease),
opacity var(--dur) var(--ease);
}
.primary-wrap:hover .corners {
inset: -8px;
}
.primary:disabled + .corners {
opacity: 0;
}
.spinner {
width: 16px;
height: 16px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Text */
.secondary {
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.secondary.small {
font-size: var(--text-xs);
}
.attention {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--ink-2);
}
.attention :global(.icon) {
margin-top: 3px;
color: var(--warn);
}
.secondary strong {
color: var(--ink-2);
font-weight: 500;
}
.path {
color: var(--ink-2);
font-size: var(--text-xs);
}
.wide {
width: 100%;
height: 44px;
}
/* Progress */
.progress {
display: grid;
gap: 10px;
}
.stage-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.stage-name {
color: var(--ink);
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 500;
}
.pct {
color: var(--ink-2);
font-size: var(--text-sm);
}
.segments {
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
gap: 3px;
}
.seg {
height: 2px;
background: var(--line-strong);
transition: background-color var(--dur) var(--ease);
}
.seg.done {
background: var(--accent);
}
.seg.current {
background: var(--accent-line);
}
.metrics {
display: grid;
grid-template-columns: 1.45fr 1fr 1fr;
gap: 1px;
margin: 2px 0 0;
border: 1px solid var(--line);
background: var(--line);
}
.metrics > div {
min-width: 0;
padding: 7px 10px;
background: var(--surface);
}
.metrics dt {
color: var(--muted);
font-size: var(--text-xs);
}
.metrics dd {
margin: 0;
overflow: hidden;
color: var(--ink);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.message {
overflow: hidden;
color: var(--muted);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
/* Errors */
.error-box,
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(248, 113, 113, 0.32);
border-radius: var(--radius);
background: var(--danger-soft);
color: var(--danger);
}
.error-box p,
.notice .dim {
color: var(--muted);
}
.notice p {
flex: 1;
color: var(--ink-2);
font-size: var(--text-sm);
line-height: 1.5;
}
.error-box :global(.icon),
.notice :global(.icon) {
margin-top: 2px;
}
.notice {
align-items: center;
padding: 6px 6px 6px 12px;
}
.dismiss {
width: 24px;
height: 24px;
}
</style>