This commit is contained in:
JIMMY_JFEXP
2026-10-02 13:06:05 +02:00
18 changed files with 2648 additions and 143 deletions
+135 -77
View File
@@ -98,10 +98,17 @@
// 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);
// Dev mode's controls follow DEV MODE, not whatever the hub is currently offering. They
// used to live inside the "launch" branch, so a PC with nothing installed was told to name
// its checkout folder and given nowhere on screen to name it - the dead end that made a
// fresh dev PC look like it could only install.
const TRANSIENT = ["loading", "signing_in", "installing", "restoring", "syncing", "building", "removing", "pulling_tiles", "stack"];
const showDev = $derived(devOn && !TRANSIENT.includes(action));
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.
@@ -111,6 +118,17 @@
{ 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 });
@@ -197,6 +215,14 @@
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
<!-- A licensed PC with nothing installed should be running from its checkout against the
DHP. When it cannot, say which single thing is in the way - this screen used to give
no clue at all, which sent people looking at the install folder every time. -->
{#if s?.dhp_blocked}
<p class="secondary small">
<strong>Dev mode:</strong> a run straight against {site} needs no install, but {s.dhp_blocked}
</p>
{/if}
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
@@ -217,6 +243,14 @@
<p class="secondary">
{#if checking}
Checking {site}…
{:else if !s?.installed}
Nothing is installed here: the client comes from your checkout and runs on
<strong>{site}</strong>'s data. You sign in inside ODIAN, not here.
{:else if s?.update_required}
<!-- Only reachable on a dev run: any other PC would be on the "update" action. -->
ODIAN <strong>{s.installed}</strong> is installed, <strong>{s.available}</strong> is on {site}. A dev run starts
your checkout, so it does not need it.
<button class="btn-link" disabled={blocked} onclick={() => launcher.install()}>Update anyway</button>
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
@@ -252,78 +286,6 @@
<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 wrap">
<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 s?.online}
<!-- The stack is the install's, so its tiles and its data are still worth
refreshing from the DHP while there is a line to it. -->
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Sync tiles</button>
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncNow()}>Sync database</button>
{:else}
<span class="dev-offline mono">offline — tiles and data stay as they are</span>
{/if}
</div>
{#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">
@@ -458,6 +420,99 @@
</p>
{/if}
{#if showDev}
<!-- 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 showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
@@ -546,10 +601,6 @@
align-items: center;
gap: 0.4rem;
}
.dev-row.wrap {
flex-wrap: wrap;
row-gap: 0.3rem;
}
.dev-path {
flex: 1 1 auto;
min-width: 0;
@@ -561,6 +612,11 @@
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;
@@ -568,7 +624,6 @@
font-size: 0.8rem;
white-space: nowrap;
}
.dev-offline,
.dev-note {
font-size: 0.75rem;
color: var(--text-dim, #9aa3ad);
@@ -577,6 +632,9 @@
.dev-note code {
font-size: 0.72rem;
}
.dev-release {
font-weight: 600;
}
.action {
display: grid;
+21 -1
View File
@@ -15,7 +15,7 @@
const art = $derived(launcher.devOn ? keyartDev : keyart);
</script>
<div class="keyart" aria-hidden="true">
<div class="keyart" class:dev={launcher.devOn} aria-hidden="true">
<div class="frame">
<img class="art" src={art} alt="" draggable="false" />
</div>
@@ -71,9 +71,29 @@
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
}
/* Dev mode has no notes panel, so the picture gets the whole window and is centred in
it - and the left-edge fade goes with the panel it was hiding a seam against. */
.keyart.dev .frame {
left: 0;
-webkit-mask-image: none;
mask-image: none;
}
/* The dev picture is 3:2 in a window that is wider than that, so what gets cut is HEIGHT
- 11% of it at the default size. Taken off the middle it clips the top of her head and
the wordmark; taken mostly off the bottom it costs foreground crates the action panel
half-covers anyway. Hence 30% rather than centre. The other picture keeps its own
framing: it was placed against the notes panel, which dev mode does not draw. */
.keyart.dev .art {
object-position: 50% 30%;
}
@media (max-width: 1140px) {
.frame {
left: 384px;
}
.keyart.dev .frame {
left: 0;
}
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
let scroller: HTMLElement | undefined = $state();
const lines = $derived(launcher.consoleLines);
const running = $derived(launcher.consoleRunning);
const exit = $derived(launcher.consoleExit);
// Follow the output, but only while the reader is already at the bottom: scrolling back to
// read an error must not be yanked away by the next line that arrives.
let follow = $state(true);
function onScroll() {
const el = scroller;
if (!el) return;
follow = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}
$effect(() => {
void lines.length;
if (follow && scroller) scroller.scrollTop = scroller.scrollHeight;
});
// A step header is worth picking out of a wall of build output, as is a failure.
function kind(line: string): string {
if (/^\s*={3}/.test(line)) return "step";
if (/^FAILED:|^\s*Erreur|error[: ]/i.test(line)) return "bad";
if (/nothing to (release|deploy)|No pack cut|Release finished/i.test(line)) return "note";
return "";
}
</script>
<section class="console glass" aria-label="Release output">
<header class="head">
<h2 class="eyebrow">Release</h2>
<span class="state mono" class:live={running} class:bad={!running && exit !== null && exit !== 0}>
{#if running}
running
{:else if exit === null}
idle
{:else if exit === 0}
finished
{:else}
failed ({exit})
{/if}
</span>
<span class="grow"></span>
<span class="total mono">{lines.length}</span>
<button class="icon-btn" aria-label="Close release output" onclick={() => launcher.closeConsole()}>
<Icon name="close" size={14} />
</button>
</header>
<!-- Both are deliberate: `log` is the role for streamed output, and it has to be
focusable or a keyboard cannot scroll back through a failed build. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div bind:this={scroller} class="body mono selectable" onscroll={onScroll} tabindex="0" role="log">
{#if lines.length}
{#each lines as line, i (i)}
<div class="line {kind(line)}">{line || " "}</div>
{/each}
{:else}
<p class="empty">Waiting for the first line…</p>
{/if}
</div>
{#if !running && exit !== null}
<footer class="foot">
{exit === 0 ? "Closing this keeps the output until the next release." : "The full transcript is in state\\release\\release.log."}
</footer>
{/if}
</section>
<style>
.console {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
background: var(--glass-strong);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
}
.grow {
flex: 1;
}
.total,
.state {
color: var(--muted);
font-size: var(--text-xs);
}
/* The dot earns its place: from across the desk it is the only thing that says whether a
release is still going. */
.state.live::before,
.state.bad::before {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
background: var(--accent, #4ade80);
content: "";
vertical-align: middle;
}
.state.live {
color: var(--ink-2);
}
.state.bad {
color: var(--danger);
}
.state.bad::before {
background: var(--danger);
}
.body {
flex: 1;
padding: 8px 0;
overflow-y: auto;
min-height: 0;
font-size: var(--text-xs);
line-height: 1.55;
}
.line {
padding: 0 16px;
color: var(--ink-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.line.step {
margin-top: 6px;
color: var(--ink);
font-weight: 600;
}
.line.bad {
color: var(--danger);
}
.line.note {
color: var(--warn);
}
.empty {
padding: 8px 16px;
color: var(--muted);
font-size: var(--text-sm);
}
.foot {
padding: 8px 16px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: var(--text-xs);
}
</style>
+399
View File
@@ -0,0 +1,399 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { prefersReducedMotion } from "svelte/motion";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import { formatBytes } from "../lib/format";
// Its own panel rather than a block inside the action card: the card is anchored to the
// bottom of the window and cannot grow, so three rows plus a password field pushed the
// Release button off the bottom - visible, unreachable, and reported as "it doesn't work".
const plan = $derived(launcher.release);
const busy = $derived(launcher.busy === "release");
let password = $state("");
let withApi = $state(true);
// Off by default, deliberately: it serves /download/* and proxies /dhp, so it is the one
// step that interrupts the road everything else publishes over.
let withMcp = $state(false);
// The browser client at web.odian.fr. Built from _WEB in the Engine checkout, in Docker.
let withWeb = $state(false);
async function release() {
await launcher.releaseRun(password, withApi, withMcp, withWeb);
password = ""; // held for the one run, never kept
}
/** The short version of a row's state, for the column that has room for three words.
NOT called `state`: that shadows the `$state` rune in this file, and everything
declared with it silently stops being reactive. */
// The refresh is its own press, with its own password: it dumps a database operators are
// using, so it must not be reachable by the same click that ships a code change.
let refreshOpen = $state(false);
let pgPassword = $state("");
let tileRoot = $state("");
const canRefresh = $derived(
password.trim().length > 0 && (pgPassword.trim().length > 0 || tileRoot.trim().length > 0),
);
async function refresh() {
if (!canRefresh || busy) return;
await launcher.refreshRun(password, pgPassword, tileRoot);
pgPassword = "";
}
/** `20260921T125618Z` -> `21 Sep 2026`; anything else is shown as it came. */
function stamped(snapshot: string | null): string {
if (!snapshot || snapshot.length < 8) return snapshot ?? "unknown";
const y = Number(snapshot.slice(0, 4));
const m = Number(snapshot.slice(4, 6));
const d = Number(snapshot.slice(6, 8));
if (!y || !m || !d) return snapshot;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${d} ${MONTHS[m - 1] ?? "?"} ${y}`;
}
/** `20260423` -> `Apr 2026`. The vintage of the extract, not an age. */
function monthOf(stamp: string): string {
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const m = Number(stamp.slice(4, 6));
return `${MONTHS[m - 1] ?? "?"} ${stamp.slice(0, 4)}`;
}
function aged(days: number | null): string {
if (days === null) return "";
if (days <= 0) return "today";
if (days === 1) return "1 day old";
return `${days} days old`;
}
function rowState(row: { behind: boolean | null; dirty: boolean }): { text: string; tone: string } {
if (row.dirty) return { text: "uncommitted", tone: "warn" };
if (row.behind === true) return { text: "behind", tone: "go" };
if (row.behind === false) return { text: "up to date", tone: "dim" };
return { text: "unknown", tone: "dim" };
}
</script>
<section
class="release glass"
aria-label="Build and deploy"
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
>
<header class="head">
<Icon name="code" size={14} />
<h2 class="eyebrow">Build &amp; deploy</h2>
<span class="grow"></span>
<button class="icon-btn" aria-label="Close" onclick={() => (launcher.releaseOpen = false)}>
<Icon name="close" size={14} />
</button>
</header>
<div class="body">
{#if !plan}
<p class="note">Looking at what is live and what this PC holds…</p>
{:else}
<ul class="plan">
{#each plan.items as row (row.key)}
{@const st = rowState(row)}
<li>
<span class="name">{row.title}</span>
<span class="live mono">{row.live ?? "—"}</span>
<span class="state {st.tone}">{st.text}</span>
<span class="commit mono">{row.commit ?? ""}</span>
</li>
{/each}
</ul>
<!-- The payload a release does NOT rebuild. It is copied forward from the previous
version every time, so it ages quietly: this is the only place that says by how
much, which is what turns "the tiles are old" into a decision. -->
{#if plan.data}
{@const d = plan.data}
<div class="payload">
<span class="payload-head">Carried forward — a release does not rebuild these</span>
<div class="payload-row">
<span class="payload-name">Database seed</span>
<span class="payload-val mono">{stamped(d.db_snapshot)}</span>
<span class="payload-age" class:stale={(d.db_age_days ?? 0) > 30}>{aged(d.db_age_days)}</span>
<span class="payload-size mono">{formatBytes(d.db_bytes)}</span>
</div>
<div class="payload-row">
<span class="payload-name">Map tiles</span>
<span class="payload-val mono">{d.tile_sets} sets</span>
<!-- Deliberately NOT an age. Nothing records when tiles were baked, and the only
date available is the upstream extract's, which said "161 days old" about
work that is three weeks old. -->
<span class="payload-age" class:stale={(d.basemap_vintage_days ?? 0) > 365}>
{d.basemap_vintage ? `map data ${monthOf(d.basemap_vintage)}` : ""}
</span>
<span class="payload-size mono">{formatBytes(d.tile_bytes)}</span>
</div>
<span class="payload-foot mono">{d.tile_datasets.join(" · ")}</span>
<span class="payload-foot">
Tiles record no bake date, so only the source extract's vintage is known.
</span>
<div class="payload-act">
<button class="btn-link" onclick={() => (refreshOpen = !refreshOpen)}>
{refreshOpen ? "Never mind" : "Bring up to date…"}
</button>
</div>
{#if refreshOpen}
<div class="refresh">
<p class="refresh-why">
This rebuilds the seed from the live database and re-indexes the tiles. It
<strong>publishes nothing</strong> — it prepares the inputs, and the console says how to cut a
version from them.
</p>
<input
class="input"
type="password"
autocomplete="off"
placeholder="database password (Data PC) — leave empty to skip the seed"
bind:value={pgPassword}
disabled={busy}
/>
<input
class="input"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="tile folder laid out as style\dataset — leave empty to skip the tiles"
bind:value={tileRoot}
disabled={busy}
/>
<div class="row">
<span class="refresh-note">
The seed is minutes of load on a database people are using. Uses the node password above.
</span>
<button class="btn" disabled={!canRefresh || busy} onclick={refresh}>Refresh</button>
</div>
</div>
{/if}
{#if !d.is_live}
<span class="payload-foot">Read from this PC's {d.version}, which is not what the DHP serves.</span>
{/if}
</div>
{/if}
<div class="checks mono">
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
<span class:ok={plan.scripts}>scripts {plan.scripts ? "✓" : "✗"}</span>
</div>
{#if plan.blocked}
<p class="note warn">{plan.blocked}</p>
{:else}
<p class="note">
Each step decides for itself whether it has anything to do, so a press releases only
what has moved. It runs in its own window.
</p>
<label class="check">
<input type="checkbox" bind:checked={withApi} disabled={!plan.docker || busy} />
<span>
Also roll the Engine API{plan.docker ? "" : " — needs Docker"}
<em>— every operator is logged out</em>
</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={withWeb} disabled={!plan.docker || busy} />
<span>
Also deploy ODIAN WEB{plan.docker ? "" : " — needs Docker"}
<em>— web.odian.fr, from the Engine checkout</em>
</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={withMcp} disabled={!plan.docker || busy} />
<span>
Also deploy the MCP server{plan.docker ? "" : " — needs Docker"}
<em>— runs last; it serves the download feeds</em>
</span>
</label>
<div class="row">
<input
class="input"
type="password"
autocomplete="off"
placeholder="node sudo password"
bind:value={password}
disabled={busy}
/>
<button class="btn primary-ish" disabled={busy || !password.trim()} onclick={release}>
{busy ? "Starting…" : "Release"}
</button>
</div>
{/if}
{/if}
</div>
</section>
<style>
/* The data payload. Quiet by default - it is context, not a control - but the age goes
amber once it is old enough to matter, which is the whole point of showing it. */
.payload {
display: flex;
flex-direction: column;
gap: 2px;
margin: 10px 0 2px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
}
.payload-head {
color: var(--muted);
font-size: var(--text-xs);
margin-bottom: 4px;
}
.payload-row {
display: grid;
grid-template-columns: 7.5rem 7rem minmax(0, 1fr) auto;
gap: 8px;
align-items: baseline;
font-size: var(--text-xs);
}
.payload-name {
color: var(--ink-2);
}
.payload-val,
.payload-size {
color: var(--muted);
}
.payload-age {
color: var(--muted);
}
.payload-age.stale {
color: var(--warn);
}
.payload-act {
margin-top: 6px;
}
.refresh {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.refresh-why,
.refresh-note {
margin: 0;
color: var(--muted);
font-size: var(--text-xs);
line-height: 1.5;
}
.refresh .row {
display: flex;
align-items: center;
gap: 10px;
}
.refresh .row .refresh-note {
flex: 1;
}
.payload-foot {
margin-top: 4px;
color: var(--muted);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.release {
display: flex;
flex-direction: column;
max-height: min(460px, calc(100vh - 180px));
background: var(--glass-strong);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
color: #46d17f;
}
.grow {
flex: 1;
}
.body {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 12px 16px 14px;
overflow: auto;
}
.plan {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.plan li {
display: grid;
grid-template-columns: 6.5rem 4.5rem 6rem 1fr;
gap: 0.5rem;
align-items: baseline;
font-size: 0.8rem;
}
.name {
font-weight: 600;
}
.live,
.commit {
color: var(--muted);
font-size: 0.75rem;
}
.state.go {
color: #46d17f;
font-weight: 600;
}
.state.warn {
color: #e6b35a;
}
.state.dim {
color: var(--muted);
}
.checks {
display: flex;
gap: 0.8rem;
font-size: 0.74rem;
color: #e06c6c;
}
.checks span.ok {
color: #46d17f;
}
.note {
margin: 0;
font-size: 0.78rem;
line-height: 1.45;
color: var(--muted);
}
.note.warn {
color: #e6b35a;
}
.check {
display: flex;
align-items: flex-start;
gap: 0.45rem;
font-size: 0.8rem;
line-height: 1.35;
}
.check em {
color: #e6b35a;
font-style: normal;
}
.row {
display: flex;
gap: 0.4rem;
}
.row .input {
flex: 1 1 auto;
min-width: 0;
}
.primary-ish {
font-weight: 600;
}
</style>