ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import ActionPanel from "./components/ActionPanel.svelte";
|
||||
import ActivityPanel from "./components/ActivityPanel.svelte";
|
||||
import Hero from "./components/Hero.svelte";
|
||||
import KeyArt from "./components/KeyArt.svelte";
|
||||
import NewsPanel from "./components/NewsPanel.svelte";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||
import StatusBar from "./components/StatusBar.svelte";
|
||||
import TitleBar from "./components/TitleBar.svelte";
|
||||
import { launcher } from "./lib/launcher.svelte";
|
||||
|
||||
let settingsOpen = $state(false);
|
||||
|
||||
onMount(() => launcher.start());
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<KeyArt />
|
||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} />
|
||||
|
||||
<main class="hub">
|
||||
<div class="news-slot"><NewsPanel /></div>
|
||||
<div class="hero-slot"><Hero /></div>
|
||||
<div class="action-slot"><ActionPanel /></div>
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
|
||||
{#if launcher.activityOpen}
|
||||
<div class="activity-slot"><ActivityPanel /></div>
|
||||
{/if}
|
||||
|
||||
<SettingsDrawer bind:open={settingsOpen} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: var(--titlebar-h) minmax(0, 1fr) var(--statusbar-h);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr) minmax(360px, 404px);
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: 24px 40px;
|
||||
min-height: 0;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.news-slot {
|
||||
display: flex;
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hero-slot {
|
||||
grid-row: 1;
|
||||
grid-column: 2 / span 2;
|
||||
align-self: start;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.action-slot {
|
||||
grid-row: 2;
|
||||
grid-column: 3;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.activity-slot {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
bottom: calc(var(--statusbar-h) + 10px);
|
||||
left: 32px;
|
||||
width: min(640px, calc(100% - 64px));
|
||||
}
|
||||
|
||||
@media (max-width: 1140px) {
|
||||
.hub {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(340px, 380px);
|
||||
gap: 20px 24px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.activity-slot {
|
||||
left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.hero-slot {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<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 "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 };
|
||||
}
|
||||
});
|
||||
|
||||
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.installFromDrive()}>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} <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>
|
||||
{: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 === "stack"}
|
||||
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
||||
<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>
|
||||
.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);
|
||||
}
|
||||
.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 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>
|
||||
@@ -0,0 +1,129 @@
|
||||
<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 { formatClock } from "../lib/format";
|
||||
|
||||
function close() {
|
||||
launcher.setActivityOpen(false);
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector("dialog[open]")) close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<section
|
||||
id="activity-panel"
|
||||
class="activity glass"
|
||||
aria-label="Activity"
|
||||
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
|
||||
>
|
||||
<header class="head">
|
||||
<h2 class="eyebrow">Activity</h2>
|
||||
<span class="total mono">{launcher.logs.length}</span>
|
||||
<span class="grow"></span>
|
||||
<button class="btn clear" disabled={launcher.logs.length === 0} onclick={() => launcher.clearLogs()}>Clear</button>
|
||||
<button class="icon-btn" aria-label="Close activity" onclick={close}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if launcher.logs.length}
|
||||
<ol class="log selectable" aria-label="Recent backend messages, newest first">
|
||||
{#each launcher.logs as entry, i (i)}
|
||||
<li class="entry {entry.level}">
|
||||
<time class="mono tabular" datetime={new Date(entry.at * 1000).toISOString()}>{formatClock(entry.at)}</time>
|
||||
<span class="level mono">{entry.level}</span>
|
||||
<span class="message">{entry.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="empty">No activity yet. Backend messages show up here as they happen.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(340px, 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);
|
||||
}
|
||||
|
||||
.total {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clear {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 48px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 5px 16px;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.entry + .entry {
|
||||
border-top: 1px solid rgba(35, 35, 38, 0.6);
|
||||
}
|
||||
|
||||
time {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.level {
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--ink-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.warn .level {
|
||||
color: var(--warn);
|
||||
}
|
||||
.error .level,
|
||||
.error .message {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
|
||||
const status = $derived(launcher.status);
|
||||
</script>
|
||||
|
||||
<div class="hero">
|
||||
<p class="eyebrow tagline">Tactical map console</p>
|
||||
<h1 class="wordmark">ODIAN</h1>
|
||||
{#if status}
|
||||
<p class="meta mono">
|
||||
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<span>{launcher.siteName}</span>
|
||||
{#if !status.online}
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<span>Offline mode</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.tagline::before {
|
||||
content: "";
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0 calc(var(--wordmark-tracking) * -1) 0 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(52px, 5.4vw, 72px);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: var(--wordmark-tracking);
|
||||
text-shadow: 0 0 48px rgba(91, 140, 255, 0.28);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--line-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts" module>
|
||||
export type IconName =
|
||||
| "minimize"
|
||||
| "maximize"
|
||||
| "restore"
|
||||
| "close"
|
||||
| "settings"
|
||||
| "refresh"
|
||||
| "play"
|
||||
| "download"
|
||||
| "update"
|
||||
| "user"
|
||||
| "stop"
|
||||
| "folder"
|
||||
| "drive"
|
||||
| "activity"
|
||||
| "alert"
|
||||
| "check"
|
||||
| "signout"
|
||||
| "key"
|
||||
| "globe";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
let { name, size = 16, strokeWidth = 1.5 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Original 16px line icons; decorative, so hidden from assistive tech. -->
|
||||
<svg
|
||||
class="icon"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="square"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{#if name === "minimize"}
|
||||
<path d="M3.5 8.5h9" />
|
||||
{:else if name === "maximize"}
|
||||
<rect x="3.5" y="3.5" width="9" height="9" />
|
||||
{:else if name === "restore"}
|
||||
<path d="M6 5.5V3.5h6.5V10h-2" />
|
||||
<rect x="3.5" y="6" width="7" height="6.5" />
|
||||
{:else if name === "close"}
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
{:else if name === "settings"}
|
||||
<path d="M2.5 4.5h6M12.5 4.5h1M2.5 11.5h1M7.5 11.5h6" />
|
||||
<rect x="8.5" y="3" width="3" height="3" />
|
||||
<rect x="4.5" y="10" width="3" height="3" />
|
||||
{:else if name === "refresh"}
|
||||
<path d="M13 8a5 5 0 1 1-1.5-3.55" />
|
||||
<path d="M12 2v3h-3" />
|
||||
{:else if name === "play"}
|
||||
<path d="M5 3.25l7.5 4.75L5 12.75z" fill="currentColor" stroke="none" />
|
||||
{:else if name === "download"}
|
||||
<path d="M8 2.5v8M4.75 7.25L8 10.5l3.25-3.25M3 13.5h10" />
|
||||
{:else if name === "update"}
|
||||
<path d="M8 13.5v-8M4.75 8.75L8 5.5l3.25 3.25M3 2.5h10" />
|
||||
{:else if name === "user"}
|
||||
<circle cx="8" cy="5.5" r="2.5" />
|
||||
<path d="M3 13.5c.6-2.3 2.6-3.75 5-3.75s4.4 1.45 5 3.75" />
|
||||
{:else if name === "stop"}
|
||||
<rect x="4" y="4" width="8" height="8" fill="currentColor" stroke="none" />
|
||||
{:else if name === "folder"}
|
||||
<path d="M2.5 4h4l1.5 1.5h5.5v7h-11z" />
|
||||
{:else if name === "drive"}
|
||||
<rect x="2.5" y="5" width="11" height="6.5" />
|
||||
<path d="M5 8.25h.5M10 8.25h1.5" />
|
||||
{:else if name === "activity"}
|
||||
<path d="M1.5 8.5h3l2-5 3 9 2-4h3" />
|
||||
{:else if name === "alert"}
|
||||
<path d="M8 2.5l6 11H2z" />
|
||||
<path d="M8 6.75v3M8 11.5v.25" />
|
||||
{:else if name === "check"}
|
||||
<path d="M3.5 8.5l3 3 6-7" />
|
||||
{:else if name === "signout"}
|
||||
<path d="M9.5 3h-6v10h6M7 8h6.5M11 5.5L13.5 8 11 10.5" />
|
||||
{:else if name === "key"}
|
||||
<circle cx="5.25" cy="10.75" r="2.5" />
|
||||
<path d="M7 9l5.5-5.5M10.5 5.5L12 7" />
|
||||
{:else if name === "globe"}
|
||||
<circle cx="8" cy="8" r="5.5" />
|
||||
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
|
||||
<path d="M2.5 8h11" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script lang="ts">
|
||||
// Original key art, generated here: an orthographic wireframe globe with a
|
||||
// bearing ring, a slow orbit, a few track markers and faint topographic
|
||||
// contours. Static except for one orbiting point (off with reduced motion).
|
||||
import { prefersReducedMotion } from "svelte/motion";
|
||||
|
||||
const W = 1280;
|
||||
const H = 760;
|
||||
const CX = 930;
|
||||
const CY = 392;
|
||||
const R = 288;
|
||||
const TILT = rad(24); // view latitude
|
||||
const SPIN = rad(-20); // view longitude
|
||||
|
||||
function rad(deg: number) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function project(latDeg: number, lonDeg: number): Pt {
|
||||
const phi = rad(latDeg);
|
||||
const lambda = rad(lonDeg) - SPIN;
|
||||
const cosPhi = Math.cos(phi);
|
||||
const x = R * cosPhi * Math.sin(lambda);
|
||||
const y = R * (Math.cos(TILT) * Math.sin(phi) - Math.sin(TILT) * cosPhi * Math.cos(lambda));
|
||||
const cosC = Math.sin(TILT) * Math.sin(phi) + Math.cos(TILT) * cosPhi * Math.cos(lambda);
|
||||
return { x: CX + x, y: CY - y, visible: cosC >= 0 };
|
||||
}
|
||||
|
||||
const fmt = (n: number) => n.toFixed(1);
|
||||
|
||||
/** Splits a projected polyline into its near-side and far-side runs. */
|
||||
function trace(points: Pt[]) {
|
||||
let front = "";
|
||||
let back = "";
|
||||
let prev: boolean | null = null;
|
||||
for (const p of points) {
|
||||
const seg = `${prev === p.visible ? "L" : "M"}${fmt(p.x)} ${fmt(p.y)}`;
|
||||
if (p.visible) front += seg;
|
||||
else back += seg;
|
||||
prev = p.visible;
|
||||
}
|
||||
return { front, back };
|
||||
}
|
||||
|
||||
function range(from: number, to: number, step: number) {
|
||||
const out: number[] = [];
|
||||
for (let v = from; v <= to + 1e-9; v += step) out.push(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
...range(-180, 165, 15).map((lon) => trace(range(-90, 90, 2).map((lat) => project(lat, lon)))),
|
||||
...range(-75, 75, 15).map((lat) => trace(range(-180, 180, 2).map((lon) => project(lat, lon)))),
|
||||
];
|
||||
const gridFront = lines.map((l) => l.front).join("");
|
||||
const gridBack = lines.map((l) => l.back).join("");
|
||||
const equator = trace(range(-180, 180, 2).map((lon) => project(0, lon))).front;
|
||||
|
||||
// Bearing ring around the globe.
|
||||
const RING = R + 26;
|
||||
const ticks = range(0, 355, 5)
|
||||
.map((deg) => {
|
||||
const a = rad(deg - 90);
|
||||
const len = deg % 30 === 0 ? 9 : deg % 10 === 0 ? 5 : 2.5;
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
return `M${fmt(CX + c * RING)} ${fmt(CY + s * RING)}L${fmt(CX + c * (RING + len))} ${fmt(CY + s * (RING + len))}`;
|
||||
})
|
||||
.join("");
|
||||
// Labels in the upper-left quadrant would sit under the hero wordmark.
|
||||
const ringLabels = range(0, 330, 30)
|
||||
.filter((deg) => deg < 285 || deg > 345)
|
||||
.map((deg) => {
|
||||
const a = rad(deg - 90);
|
||||
return { x: CX + Math.cos(a) * (RING + 24), y: CY + Math.sin(a) * (RING + 24), text: String(deg).padStart(3, "0") };
|
||||
});
|
||||
|
||||
// Track markers joined by great-circle legs.
|
||||
type Vec = [number, number, number];
|
||||
const toVec = (lat: number, lon: number): Vec => [
|
||||
Math.cos(rad(lat)) * Math.cos(rad(lon)),
|
||||
Math.cos(rad(lat)) * Math.sin(rad(lon)),
|
||||
Math.sin(rad(lat)),
|
||||
];
|
||||
function leg(a: [number, number], b: [number, number]) {
|
||||
const va = toVec(...a);
|
||||
const vb = toVec(...b);
|
||||
const omega = Math.acos(Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
|
||||
const pts: Pt[] = [];
|
||||
for (let i = 0; i <= 48; i++) {
|
||||
const t = i / 48;
|
||||
const ka = Math.sin((1 - t) * omega) / Math.sin(omega);
|
||||
const kb = Math.sin(t * omega) / Math.sin(omega);
|
||||
const v = [0, 1, 2].map((k) => ka * va[k] + kb * vb[k]);
|
||||
const lat = (Math.asin(v[2]) * 180) / Math.PI;
|
||||
const lon = (Math.atan2(v[1], v[0]) * 180) / Math.PI;
|
||||
pts.push(project(lat, lon));
|
||||
}
|
||||
return trace(pts).front;
|
||||
}
|
||||
|
||||
const waypoints: { lat: number; lon: number; label: string }[] = [
|
||||
{ lat: 14, lon: -36, label: "WPT K7" },
|
||||
{ lat: 33, lon: -12, label: "TRK 1187" },
|
||||
{ lat: 47, lon: 5, label: "TRK 0412" },
|
||||
];
|
||||
const markers = waypoints.map((w) => ({ ...w, ...project(w.lat, w.lon) })).filter((m) => m.visible);
|
||||
const route =
|
||||
leg([waypoints[0].lat, waypoints[0].lon], [waypoints[1].lat, waypoints[1].lon]) +
|
||||
leg([waypoints[1].lat, waypoints[1].lon], [waypoints[2].lat, waypoints[2].lon]);
|
||||
|
||||
// Orbit: an inclined ellipse; the far half is hidden behind the globe.
|
||||
const ORBIT_TILT = -14;
|
||||
const RX = R * 1.42;
|
||||
const RY = R * 0.34;
|
||||
const orbitBack = `M${fmt(CX - RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX + RX)} ${CY}`;
|
||||
const orbitFront = `M${fmt(CX + RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX - RX)} ${CY}`;
|
||||
const orbitPath = `${orbitBack}${orbitFront.replace(/^M[^A]+/, "")}`;
|
||||
const staticSat = { x: CX + RX * Math.cos(rad(62)), y: CY + RY * Math.sin(rad(62)) };
|
||||
|
||||
// Topographic contours: nested wobbly rings.
|
||||
function contours(cx: number, cy: number, count: number, seed: number, sx: number, sy: number) {
|
||||
return range(0, count - 1, 1).map((i) => {
|
||||
const pts = range(0, 120, 1).map((k) => {
|
||||
const th = (k / 120) * Math.PI * 2;
|
||||
const r =
|
||||
28 +
|
||||
i * 26 +
|
||||
(10 + i * 1.5) * Math.sin(3 * th + seed + i * 0.35) +
|
||||
(6 + i) * Math.sin(5 * th - seed * 1.7 + i * 0.2) +
|
||||
4 * Math.sin(2 * th + i * 0.5);
|
||||
return `${fmt(cx + r * Math.cos(th) * sx)} ${fmt(cy + r * Math.sin(th) * sy)}`;
|
||||
});
|
||||
return `M${pts.join("L")}Z`;
|
||||
});
|
||||
}
|
||||
const topo = [...contours(520, 780, 12, 1.3, 1.35, 0.72), ...contours(1270, -10, 8, 4.1, 1.1, 0.9)];
|
||||
</script>
|
||||
|
||||
<div class="keyart" aria-hidden="true">
|
||||
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="ka-glow" cx={CX} cy={CY} r={R * 1.9} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#5b8cff" stop-opacity="0.2" />
|
||||
<stop offset="0.42" stop-color="#5b8cff" stop-opacity="0.06" />
|
||||
<stop offset="1" stop-color="#5b8cff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="ka-sphere" cx={CX - R * 0.38} cy={CY - R * 0.42} r={R * 1.55} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#172241" />
|
||||
<stop offset="0.55" stop-color="#0d1019" />
|
||||
<stop offset="1" stop-color="#09090b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="ka-fade" cx="0.72" cy="0.5" r="0.62">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="1" />
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<pattern id="ka-dots" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<rect width="1" height="1" fill="#4a4a52" />
|
||||
</pattern>
|
||||
<mask id="ka-dots-mask">
|
||||
<rect width={W} height={H} fill="url(#ka-fade)" />
|
||||
</mask>
|
||||
<mask id="ka-behind" maskUnits="userSpaceOnUse" x="-600" y="-600" width={W + 1200} height={H + 1200}>
|
||||
<rect x="-600" y="-600" width={W + 1200} height={H + 1200} fill="#fff" />
|
||||
<circle cx={CX} cy={CY} r={R} fill="#000" />
|
||||
</mask>
|
||||
<path id="ka-orbit" d={orbitPath} />
|
||||
</defs>
|
||||
|
||||
<rect width={W} height={H} fill="#09090b" />
|
||||
<rect width={W} height={H} fill="url(#ka-dots)" mask="url(#ka-dots-mask)" />
|
||||
|
||||
<g fill="none" stroke="#9a999e" stroke-opacity="0.075">
|
||||
{#each topo as d, i (i)}
|
||||
<path {d} />
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<circle cx={CX} cy={CY} r={R * 1.9} fill="url(#ka-glow)" />
|
||||
|
||||
<g stroke="#9a999e" fill="none">
|
||||
<circle cx={CX} cy={CY} r={RING} stroke-opacity="0.12" />
|
||||
<path d={ticks} stroke-opacity="0.22" />
|
||||
</g>
|
||||
<g class="ring-labels" fill="#9a999e" fill-opacity="0.34">
|
||||
{#each ringLabels as l (l.text)}
|
||||
<text x={fmt(l.x)} y={fmt(l.y)} text-anchor="middle" dominant-baseline="central">{l.text}</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
|
||||
<path d={orbitBack} stroke-opacity="0.22" stroke-dasharray="2 6" mask="url(#ka-behind)" />
|
||||
</g>
|
||||
|
||||
<circle cx={CX} cy={CY} r={R} fill="url(#ka-sphere)" />
|
||||
<path d={gridBack} fill="none" stroke="#5b8cff" stroke-opacity="0.06" />
|
||||
<path d={gridFront} fill="none" stroke="#7aa2ff" stroke-opacity="0.2" stroke-width="0.9" />
|
||||
<path d={equator} fill="none" stroke="#7aa2ff" stroke-opacity="0.38" />
|
||||
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#5b8cff" stroke-opacity="0.55" />
|
||||
<circle cx={CX} cy={CY} r={R + 5} fill="none" stroke="#5b8cff" stroke-opacity="0.12" />
|
||||
|
||||
<path d={route} fill="none" stroke="#5b8cff" stroke-opacity="0.75" stroke-dasharray="5 4" />
|
||||
<g class="markers">
|
||||
{#each markers as m (m.label)}
|
||||
<g transform="translate({fmt(m.x)} {fmt(m.y)})">
|
||||
<rect x="-4.5" y="-4.5" width="9" height="9" fill="#09090b" stroke="#a9c1ff" stroke-opacity="0.9" />
|
||||
<rect x="-1" y="-1" width="2" height="2" fill="#d6e2ff" />
|
||||
<path d="M5 -5L16 -16H64" fill="none" stroke="#9a999e" stroke-opacity="0.4" />
|
||||
<text x="20" y="-21" fill="#d4d3d0" fill-opacity="0.62">{m.label}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
|
||||
<path d={orbitFront} stroke-opacity="0.34" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Separate layer so the static art above never repaints. -->
|
||||
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
|
||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})">
|
||||
{#if prefersReducedMotion.current}
|
||||
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="7" fill="#5b8cff" fill-opacity="0.18" />
|
||||
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="2.5" fill="#d6e2ff" />
|
||||
{:else}
|
||||
<g mask="url(#ka-behind)">
|
||||
<g>
|
||||
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
|
||||
<circle r="2.5" fill="#d6e2ff" />
|
||||
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
|
||||
<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
|
||||
</g>
|
||||
</g>
|
||||
<g opacity="0">
|
||||
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
|
||||
<circle r="2.5" fill="#d6e2ff" />
|
||||
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
|
||||
<animate attributeName="opacity" values="0;1" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
|
||||
</g>
|
||||
{/if}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.keyart {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ring-labels,
|
||||
.markers {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0.5) 30%, rgba(9, 9, 11, 0) 52%),
|
||||
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 32%),
|
||||
linear-gradient(180deg, rgba(9, 9, 11, 0.55) 0%, rgba(9, 9, 11, 0) 18%),
|
||||
radial-gradient(130% 100% at 72% 50%, transparent 55%, rgba(9, 9, 11, 0.65) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
// Design-time switcher, only rendered on the browser mock.
|
||||
import { MOCK_SCENARIOS, type MockControls } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
mock: MockControls;
|
||||
}
|
||||
|
||||
let { mock }: Props = $props();
|
||||
|
||||
let network = $state(false);
|
||||
$effect.pre(() => {
|
||||
network = mock.online;
|
||||
});
|
||||
|
||||
function switchScenario(scenario: string) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("scenario", scenario);
|
||||
window.location.search = params.toString();
|
||||
}
|
||||
|
||||
function setNetwork(online: boolean) {
|
||||
network = online;
|
||||
mock.setOnline(online);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mock" role="group" aria-label="Mock backend">
|
||||
<span class="tag mono" title="Running in a browser: the backend is simulated">Mock</span>
|
||||
<label class="sr-only" for="mock-scenario">Scenario</label>
|
||||
<select
|
||||
id="mock-scenario"
|
||||
class="mono"
|
||||
value={mock.scenario}
|
||||
onchange={(e) => switchScenario(e.currentTarget.value)}
|
||||
>
|
||||
{#each MOCK_SCENARIOS as scenario (scenario)}
|
||||
<option value={scenario}>{scenario}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label class="net">
|
||||
<input type="checkbox" checked={network} onchange={(e) => setNetwork(e.currentTarget.checked)} />
|
||||
Network
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0 6px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--warn);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 18px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
select {
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.net {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
import { renderNotes } from "../lib/markdown";
|
||||
|
||||
type Tab = "notes" | "roadmap";
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "notes", label: "Patch notes" },
|
||||
{ id: "roadmap", label: "Roadmap" },
|
||||
];
|
||||
|
||||
let tab = $state<Tab>("notes");
|
||||
let scroller: HTMLElement | undefined = $state();
|
||||
const tabButtons: HTMLButtonElement[] = $state([]);
|
||||
|
||||
const notes = $derived(launcher.notes);
|
||||
const status = $derived(launcher.status);
|
||||
const rendered = $derived(notes.markdown !== null ? renderNotes(notes.markdown) : null);
|
||||
const versionTag = $derived.by(() => {
|
||||
if (!status || !notes.version) return null;
|
||||
if (notes.version === status.installed) return "Installed";
|
||||
return status.installed ? "Update" : null;
|
||||
});
|
||||
|
||||
// Fetch whenever the relevant version or connectivity changes.
|
||||
$effect(() => {
|
||||
if (launcher.notesKey !== null) untrack(() => void launcher.loadNotes());
|
||||
});
|
||||
|
||||
// Notes may contain links: they open in the system browser and never navigate
|
||||
// the launcher window. A middle click is swallowed.
|
||||
$effect(() => {
|
||||
const el = scroller;
|
||||
if (!el) return;
|
||||
const block = (e: MouseEvent) => {
|
||||
const link = (e.target as Element | null)?.closest("a");
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
const href = link.getAttribute("href");
|
||||
if (e.type === "click" && href && /^https?:\/\//i.test(href)) void launcher.openExternal(href);
|
||||
};
|
||||
el.addEventListener("click", block);
|
||||
el.addEventListener("auxclick", block);
|
||||
return () => {
|
||||
el.removeEventListener("click", block);
|
||||
el.removeEventListener("auxclick", block);
|
||||
};
|
||||
});
|
||||
|
||||
function select(id: Tab) {
|
||||
tab = id;
|
||||
scroller?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
function onTabKeydown(event: KeyboardEvent, index: number) {
|
||||
let next = -1;
|
||||
if (event.key === "ArrowRight") next = (index + 1) % TABS.length;
|
||||
else if (event.key === "ArrowLeft") next = (index - 1 + TABS.length) % TABS.length;
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = TABS.length - 1;
|
||||
if (next < 0) return;
|
||||
event.preventDefault();
|
||||
select(TABS[next].id);
|
||||
tabButtons[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="news glass" aria-label="News">
|
||||
<header class="head">
|
||||
<div class="tabs" role="tablist" aria-label="News">
|
||||
{#each TABS as t, i (t.id)}
|
||||
<button
|
||||
bind:this={tabButtons[i]}
|
||||
id="news-tab-{t.id}"
|
||||
class="tab"
|
||||
class:active={tab === t.id}
|
||||
role="tab"
|
||||
aria-selected={tab === t.id}
|
||||
aria-controls="news-panel"
|
||||
tabindex={tab === t.id ? 0 : -1}
|
||||
onclick={() => select(t.id)}
|
||||
onkeydown={(e) => onTabKeydown(e, i)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if notes.version}
|
||||
<span class="version mono" title="Notes for ODIAN {notes.version}">
|
||||
v{notes.version}
|
||||
{#if versionTag}<span class="tag" class:update={versionTag === "Update"}>{versionTag}</span>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div
|
||||
bind:this={scroller}
|
||||
id="news-panel"
|
||||
class="body"
|
||||
role="tabpanel"
|
||||
aria-labelledby="news-tab-{tab}"
|
||||
aria-busy={notes.loading}
|
||||
tabindex="0"
|
||||
>
|
||||
{#if notes.loading || (status === null && !notes.error)}
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<span style:width="62%" class="line title"></span>
|
||||
<span style:width="38%" class="line"></span>
|
||||
<span style:width="30%" class="line gap"></span>
|
||||
<span style:width="92%" class="line"></span>
|
||||
<span style:width="84%" class="line"></span>
|
||||
<span style:width="88%" class="line"></span>
|
||||
<span style:width="26%" class="line gap"></span>
|
||||
<span style:width="90%" class="line"></span>
|
||||
<span style:width="72%" class="line"></span>
|
||||
</div>
|
||||
<span class="sr-only">Loading notes…</span>
|
||||
{:else if notes.error}
|
||||
<div class="empty">
|
||||
<Icon name={status?.online ? "alert" : "globe"} size={20} />
|
||||
<p class="empty-title">{status?.online ? "Notes unavailable" : "No notes offline"}</p>
|
||||
<p class="empty-body">
|
||||
{status?.online
|
||||
? "The DHP didn't return patch notes. Try again in a moment."
|
||||
: "Patch notes are cached with each install. Connect to your DHP to read them."}
|
||||
</p>
|
||||
<p class="empty-detail mono selectable">{notes.error}</p>
|
||||
</div>
|
||||
{:else if rendered}
|
||||
{#if tab === "notes"}
|
||||
{#if rendered.notes.trim()}
|
||||
<div class="md selectable">{@html rendered.notes}</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p class="empty-title">No patch notes</p>
|
||||
<p class="empty-body">Nothing was published for this version.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if rendered.roadmap}
|
||||
<div class="md selectable">{@html rendered.roadmap}</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p class="empty-title">No roadmap yet</p>
|
||||
<p class="empty-body">Your DHP hasn't published a roadmap with these notes.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
{#if notes.fromCache && notes.markdown !== null}
|
||||
<Icon name="drive" size={14} /> Cached copy, read offline
|
||||
{:else if status && !status.online}
|
||||
<Icon name="globe" size={14} /> Offline
|
||||
{:else if status}
|
||||
<Icon name="globe" size={14} /> From {launcher.siteName}
|
||||
{/if}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.news {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
height: 46px;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--ink);
|
||||
}
|
||||
.tab::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
transform: scaleX(0);
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
.tab.active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
.tab:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag.update {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-5) var(--space-5) var(--space-6);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.body:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-5);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.line {
|
||||
display: block;
|
||||
height: 10px;
|
||||
background: linear-gradient(90deg, var(--surface-2), var(--line), var(--surface-2));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
}
|
||||
.line.title {
|
||||
height: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.line.gap {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty / error */
|
||||
.empty {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 6px;
|
||||
padding-top: var(--space-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-title {
|
||||
margin-top: 6px;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.empty-body {
|
||||
max-width: 34ch;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.empty-detail {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) 10px;
|
||||
border-left: 1px solid var(--line-strong);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Rendered (sanitized) Markdown */
|
||||
.md {
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.62;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.md > :global(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.md :global(h1) {
|
||||
margin: 0 0 4px;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.md :global(h1 + p) {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.md :global(h2) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 26px 0 10px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.md :global(h2)::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.md :global(h3),
|
||||
.md :global(h4) {
|
||||
margin: 18px 0 6px;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.md :global(p) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.md :global(ul),
|
||||
.md :global(ol) {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.md :global(ul) {
|
||||
list-style: none;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.md :global(li) {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.md :global(ul > li)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.78em;
|
||||
left: -16px;
|
||||
width: 6px;
|
||||
height: 1px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.md :global(strong),
|
||||
.md :global(b) {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.md :global(code) {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.md :global(pre) {
|
||||
margin: 0 0 12px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
.md :global(pre code) {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
.md :global(blockquote) {
|
||||
margin: 14px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.md :global(blockquote p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.md :global(a) {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-line);
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.md :global(hr) {
|
||||
margin: 20px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.md :global(table) {
|
||||
width: 100%;
|
||||
margin: 0 0 12px;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.md :global(th),
|
||||
.md :global(td) {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
.md :global(th) {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** 0..1, or null for indeterminate. */
|
||||
value: number | null;
|
||||
label: string;
|
||||
valueText?: string;
|
||||
}
|
||||
|
||||
let { value, label, valueText }: Props = $props();
|
||||
|
||||
const percent = $derived(value === null ? null : Math.round(Math.min(1, Math.max(0, value)) * 1000) / 10);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bar"
|
||||
class:indeterminate={percent === null}
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent ?? undefined}
|
||||
aria-valuetext={valueText}
|
||||
>
|
||||
{#if percent === null}
|
||||
<span class="sweep"></span>
|
||||
{:else}
|
||||
<span class="fill" style:width="{percent}%"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px rgba(91, 140, 255, 0.6);
|
||||
transition: width var(--dur) linear;
|
||||
}
|
||||
.fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #d6e2ff;
|
||||
}
|
||||
|
||||
.sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -35%;
|
||||
width: 35%;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
animation: sweep 1.8s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sweep {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
background: repeating-linear-gradient(90deg, var(--accent) 0 10px, transparent 10px 18px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
import type { Settings } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" };
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let draft = $state<Settings>({ ...EMPTY });
|
||||
let saved = $state(false);
|
||||
let saveError = $state<string | null>(null);
|
||||
/** Sign out forgets the operator on this PC, so it takes a second, explicit click. */
|
||||
let confirmSignOut = $state(false);
|
||||
|
||||
const s = $derived(launcher.status);
|
||||
const current = $derived(launcher.settings);
|
||||
const locked = $derived(launcher.locked);
|
||||
const saving = $derived(launcher.busy === "settings");
|
||||
const busy = $derived(launcher.busy !== null);
|
||||
|
||||
const trimmed = $derived<Settings>({
|
||||
site_url: draft.site_url.trim(),
|
||||
mcp_url: draft.mcp_url.trim(),
|
||||
install_dir: draft.install_dir.trim(),
|
||||
});
|
||||
const dirty = $derived(
|
||||
current !== null &&
|
||||
(trimmed.site_url !== current.site_url ||
|
||||
trimmed.mcp_url !== current.mcp_url ||
|
||||
trimmed.install_dir !== current.install_dir),
|
||||
);
|
||||
const siteError = $derived(urlProblem(trimmed.site_url));
|
||||
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
||||
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
||||
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null);
|
||||
|
||||
function urlProblem(value: string): string | null {
|
||||
if (!value) return "Required.";
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address.";
|
||||
} catch {
|
||||
return "Enter a full address, including http:// or https://.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetDraft() {
|
||||
draft = current ? { ...current } : { ...EMPTY };
|
||||
saved = false;
|
||||
saveError = null;
|
||||
confirmSignOut = false;
|
||||
}
|
||||
|
||||
// Keep the native modal in sync with `open`, returning focus to whatever opened it.
|
||||
let opener: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
if (!el) return;
|
||||
if (open && !el.open) {
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
untrack(resetDraft);
|
||||
el.showModal();
|
||||
} else if (!open) {
|
||||
if (el.open) el.close();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
opener = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Settings may arrive after the drawer was opened.
|
||||
$effect(() => {
|
||||
if (open && current && untrack(() => !draft.site_url && !draft.mcp_url && !draft.install_dir)) {
|
||||
untrack(resetDraft);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
if (!el) return;
|
||||
// Clicks on the ::backdrop target the dialog element itself.
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.target === el) open = false;
|
||||
};
|
||||
// The native cancel-on-Escape is skipped for some synthetic key events; be explicit.
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
el.addEventListener("click", onClick);
|
||||
el.addEventListener("keydown", onKeydown);
|
||||
return () => {
|
||||
el.removeEventListener("click", onClick);
|
||||
el.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!dirty || invalid || locked) return;
|
||||
const ok = await launcher.saveSettings({ ...trimmed });
|
||||
if (ok) {
|
||||
resetDraft();
|
||||
saved = true;
|
||||
} else {
|
||||
saveError = launcher.notice;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyboard focus for a control that replaces the one the operator just used. */
|
||||
function focusOnMount(node: HTMLElement) {
|
||||
node.focus();
|
||||
}
|
||||
|
||||
async function signOutAndForget() {
|
||||
confirmSignOut = false;
|
||||
await launcher.signOut();
|
||||
// The button that had focus is gone; put focus back inside the drawer so Escape still closes it.
|
||||
dialog?.querySelector<HTMLButtonElement>("button:not([disabled])")?.focus();
|
||||
}
|
||||
|
||||
async function browse() {
|
||||
const path = await launcher.pickFolder();
|
||||
if (path) draft.install_dir = path;
|
||||
}
|
||||
|
||||
function installFromDrive() {
|
||||
open = false;
|
||||
void launcher.installFromDrive();
|
||||
}
|
||||
|
||||
function signIn() {
|
||||
open = false;
|
||||
void launcher.signIn();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class="drawer" aria-labelledby="settings-title" onclose={() => (open = false)}>
|
||||
<form class="panel" onsubmit={save} novalidate>
|
||||
<header class="head">
|
||||
<h2 id="settings-title">Settings</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close settings" onclick={() => (open = false)}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
{#if locked}
|
||||
<p class="lock" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
Settings are locked while ODIAN is signing in, installing or running.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<fieldset class="group" disabled={locked || saving}>
|
||||
<legend class="eyebrow">DHP</legend>
|
||||
<div class="field">
|
||||
<label class="field-label" for="set-site-url">DHP URL</label>
|
||||
<input
|
||||
id="set-site-url"
|
||||
class="input"
|
||||
type="url"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
bind:value={draft.site_url}
|
||||
aria-invalid={dirty && siteError !== null}
|
||||
aria-describedby="set-site-url-help"
|
||||
/>
|
||||
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-help"}>
|
||||
{dirty && siteError ? siteError : "Update service of your DHP."}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="set-mcp-url">MCP URL</label>
|
||||
<input
|
||||
id="set-mcp-url"
|
||||
class="input"
|
||||
type="url"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
bind:value={draft.mcp_url}
|
||||
aria-invalid={dirty && mcpError !== null}
|
||||
aria-describedby="set-mcp-url-help"
|
||||
/>
|
||||
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-help"}>
|
||||
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group" disabled={locked || saving}>
|
||||
<legend class="eyebrow">Installation</legend>
|
||||
<div class="field">
|
||||
<label class="field-label" for="set-install-dir">Install folder</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="set-install-dir"
|
||||
class="input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
bind:value={draft.install_dir}
|
||||
aria-invalid={dirty && dirError !== null}
|
||||
aria-describedby="set-install-dir-help"
|
||||
/>
|
||||
<button type="button" class="btn" onclick={browse}>
|
||||
<Icon name="folder" size={14} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
|
||||
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<section class="group" aria-labelledby="set-drive-title">
|
||||
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
|
||||
<p class="text">Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.</p>
|
||||
<div>
|
||||
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
|
||||
<Icon name="drive" size={14} /> Install from drive…
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group" aria-labelledby="set-account-title">
|
||||
<h3 id="set-account-title" class="eyebrow">Account</h3>
|
||||
{#if s?.signed_in && s.operator}
|
||||
<p class="text">
|
||||
Signed in as <strong>{s.operator}</strong> on {launcher.siteName}. This PC remembers you until you sign out.
|
||||
</p>
|
||||
{#if confirmSignOut}
|
||||
<p class="text" role="alert" style="color: var(--warn)">
|
||||
Signing out makes this PC forget <strong>{s.operator}</strong>: ODIAN won't launch offline until someone
|
||||
signs in again while connected to the DHP.
|
||||
</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px">
|
||||
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={signOutAndForget}>
|
||||
<Icon name="signout" size={14} /> Sign out and forget me
|
||||
</button>
|
||||
<!-- The safe choice takes focus, so Enter right after "Sign out" keeps the operator signed in. -->
|
||||
<button type="button" class="btn" use:focusOnMount onclick={() => (confirmSignOut = false)}>
|
||||
Keep me signed in
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={() => (confirmSignOut = true)}>
|
||||
<Icon name="signout" size={14} /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text">
|
||||
{#if s?.operator}
|
||||
Not signed in. Last operator: <strong>{s.operator}</strong>{s.online ? "" : " (offline)"}.
|
||||
{:else}
|
||||
Not signed in.
|
||||
{/if}
|
||||
{#if s && !s.online}Signing in needs a connection to your DHP.{/if}
|
||||
</p>
|
||||
{#if s?.online}
|
||||
<div>
|
||||
<button type="button" class="btn" disabled={locked || busy} onclick={signIn}>
|
||||
<Icon name="key" size={14} /> Sign in
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<span class="result" role="status">
|
||||
{#if saveError}
|
||||
<span class="err">{saveError}</span>
|
||||
{:else if saved}
|
||||
<Icon name="check" size={14} /> Saved
|
||||
{/if}
|
||||
</span>
|
||||
<button type="button" class="btn" disabled={!dirty || saving} onclick={resetDraft}>Revert</button>
|
||||
<button type="submit" class="btn btn-accent" disabled={!dirty || invalid || locked || saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(440px, 100vw);
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--line-strong);
|
||||
background: var(--glass-strong);
|
||||
color: var(--ink);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
transition:
|
||||
opacity var(--dur-slow) var(--ease),
|
||||
transform var(--dur-slow) var(--ease),
|
||||
overlay var(--dur-slow) allow-discrete,
|
||||
display var(--dur-slow) allow-discrete;
|
||||
}
|
||||
.drawer[open] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.drawer::backdrop {
|
||||
background: rgba(5, 5, 7, 0);
|
||||
transition:
|
||||
background-color var(--dur-slow) var(--ease),
|
||||
overlay var(--dur-slow) allow-discrete,
|
||||
display var(--dur-slow) allow-discrete;
|
||||
}
|
||||
.drawer[open]::backdrop {
|
||||
background: rgba(5, 5, 7, 0.6);
|
||||
}
|
||||
@starting-style {
|
||||
.drawer[open] {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.drawer[open]::backdrop {
|
||||
background: rgba(5, 5, 7, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 12px 0 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 16px 24px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(245, 180, 84, 0.3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 22px 24px 24px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.group legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
}
|
||||
.group legend + * {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.text strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(9, 9, 11, 0.6);
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--ok);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.err {
|
||||
overflow: hidden;
|
||||
color: var(--danger);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import StateDot, { type DotTone } from "./StateDot.svelte";
|
||||
import { COMPONENT_DETAIL, COMPONENT_LABEL, STACK_STATE_LABEL } from "../lib/format";
|
||||
import { STACK_ORDER, type StackMap } from "../lib/launcher.svelte";
|
||||
import type { Phase, StackComponent, StackState } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
stack: StackMap;
|
||||
phase: Phase;
|
||||
}
|
||||
|
||||
let { stack, phase }: Props = $props();
|
||||
|
||||
type Shown = StackState | "waiting";
|
||||
|
||||
const TONE: Record<Shown, DotTone> = {
|
||||
waiting: "idle",
|
||||
starting: "warn",
|
||||
ready: "ok",
|
||||
stopped: "idle",
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
function shownState(component: StackComponent): Shown {
|
||||
const event = stack[component];
|
||||
if (event) return event.state;
|
||||
// No event seen yet (e.g. the UI reloaded while ODIAN was already open).
|
||||
return phase === "running" ? "ready" : "waiting";
|
||||
}
|
||||
</script>
|
||||
|
||||
<ol class="stack" aria-label="Local stack">
|
||||
{#each STACK_ORDER as component, index (component)}
|
||||
{@const state = shownState(component)}
|
||||
{@const message = stack[component]?.message}
|
||||
<li class="row {state}">
|
||||
<span class="index mono" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="dot-cell"><StateDot tone={TONE[state]} pulse={state === "starting"} /></span>
|
||||
<span class="name">
|
||||
{COMPONENT_LABEL[component]}
|
||||
<span class="detail mono">{COMPONENT_DETAIL[component]}</span>
|
||||
</span>
|
||||
<span class="state mono">{STACK_STATE_LABEL[state]}</span>
|
||||
{#if message}
|
||||
<span class="message mono selectable" title={message}>{message}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.stack {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 22px 20px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.index {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.dot-cell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-left: 6px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message {
|
||||
grid-column: 3 / 5;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ready .state {
|
||||
color: var(--ok);
|
||||
}
|
||||
.starting .state {
|
||||
color: var(--warn);
|
||||
}
|
||||
.failed .state,
|
||||
.failed .message {
|
||||
color: var(--danger);
|
||||
}
|
||||
.ready .name,
|
||||
.starting .name {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" module>
|
||||
export type DotTone = "ok" | "warn" | "danger" | "accent" | "idle";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
tone: DotTone;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
let { tone, pulse = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="dot {tone}" class:pulse aria-hidden="true"></span>
|
||||
|
||||
<style>
|
||||
.dot {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
transition:
|
||||
background-color var(--dur) var(--ease),
|
||||
box-shadow var(--dur) var(--ease);
|
||||
}
|
||||
.ok {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 3px var(--ok-soft);
|
||||
}
|
||||
.warn {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 3px var(--warn-soft);
|
||||
}
|
||||
.danger {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 3px var(--danger-soft);
|
||||
}
|
||||
.accent {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.idle {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--muted);
|
||||
}
|
||||
.pulse {
|
||||
animation: pulse 1.4s var(--ease) infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
import MockControls from "./MockControls.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
|
||||
const s = $derived(launcher.status);
|
||||
</script>
|
||||
|
||||
<footer class="statusbar">
|
||||
<button
|
||||
class="toggle"
|
||||
class:open={launcher.activityOpen}
|
||||
aria-expanded={launcher.activityOpen}
|
||||
aria-controls="activity-panel"
|
||||
onclick={() => launcher.setActivityOpen(!launcher.activityOpen)}
|
||||
>
|
||||
<Icon name="activity" size={14} />
|
||||
Activity
|
||||
{#if launcher.unseen > 0}
|
||||
<span class="count mono" class:error={launcher.unseenError} aria-hidden="true">
|
||||
{launcher.unseen > 99 ? "99+" : launcher.unseen}
|
||||
</span>
|
||||
<span class="sr-only">, {launcher.unseen} new</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if s}
|
||||
<span class="item">
|
||||
<Icon name="user" size={14} />
|
||||
{#if s.signed_in && s.operator}
|
||||
<span>Signed in as <strong>{s.operator}</strong></span>
|
||||
{:else if s.operator}
|
||||
<span>Last operator: <strong>{s.operator}</strong>{s.online ? "" : " (offline)"}</span>
|
||||
{:else}
|
||||
<span>Not signed in</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="item">
|
||||
<span>Installed</span>
|
||||
<strong class="mono">{s.installed ?? "none"}</strong>
|
||||
</span>
|
||||
<span class="item dir" title={s.install_dir}>
|
||||
<Icon name="folder" size={14} />
|
||||
<span class="mono selectable path">{s.install_dir}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if launcher.mock}
|
||||
<MockControls mock={launcher.mock} />
|
||||
{/if}
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.statusbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
height: var(--statusbar-h);
|
||||
padding-right: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(9, 9, 11, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-4);
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.toggle:hover,
|
||||
.toggle.open {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.toggle:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.count {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.count.error {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-4);
|
||||
border-right: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item strong {
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dir {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.path {
|
||||
overflow: hidden;
|
||||
color: var(--ink-2);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
let { onOpenSettings }: Props = $props();
|
||||
|
||||
let maximized = $state(false);
|
||||
|
||||
const status = $derived(launcher.status);
|
||||
const checking = $derived(launcher.refreshing || status?.phase === "checking");
|
||||
|
||||
function windowAction(task: () => Promise<void>) {
|
||||
return () => {
|
||||
task().catch((e) => console.error("window control failed", e));
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const sync = () => {
|
||||
launcher.win
|
||||
.isMaximized()
|
||||
.then((value) => (maximized = value))
|
||||
.catch(() => {});
|
||||
};
|
||||
sync();
|
||||
launcher.win
|
||||
.onResized(sync)
|
||||
.then((u) => (disposed ? u() : (unlisten = u)))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
<div class="brand" data-tauri-drag-region>
|
||||
<svg class="mark" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" data-tauri-drag-region>
|
||||
<rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="currentColor" stroke-opacity="0.45" />
|
||||
<path d="M9 3.5L14.5 9 9 14.5 3.5 9z" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="8" y="8" width="2" height="2" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="wordmark" data-tauri-drag-region>ODIAN</span>
|
||||
<span class="product" data-tauri-drag-region>Launcher</span>
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<span class="divider" aria-hidden="true" data-tauri-drag-region></span>
|
||||
<div class="site" data-tauri-drag-region>
|
||||
<span class="site-name" title={status.site.url} data-tauri-drag-region>{launcher.siteName}</span>
|
||||
<span
|
||||
class="badge"
|
||||
class:online={status.online}
|
||||
class:checking
|
||||
role="status"
|
||||
aria-busy={checking}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<span class="badge-dot" aria-hidden="true"></span>
|
||||
{status.online ? "Online" : "Offline"}
|
||||
</span>
|
||||
<button
|
||||
class="icon-btn refresh"
|
||||
aria-label="Check the connection to your DHP"
|
||||
title="Check connection"
|
||||
disabled={checking}
|
||||
onclick={() => launcher.refresh()}
|
||||
>
|
||||
<span class="refresh-icon" class:spinning={checking}><Icon name="refresh" size={14} /></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="drag" data-tauri-drag-region></div>
|
||||
|
||||
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
|
||||
<div class="window-controls">
|
||||
<button class="win" aria-label="Minimize" title="Minimize" onclick={windowAction(() => launcher.win.minimize())}>
|
||||
<Icon name="minimize" size={14} strokeWidth={1.25} />
|
||||
</button>
|
||||
<button
|
||||
class="win"
|
||||
aria-label={maximized ? "Restore" : "Maximize"}
|
||||
title={maximized ? "Restore" : "Maximize"}
|
||||
onclick={windowAction(() => launcher.win.toggleMaximize())}
|
||||
>
|
||||
<Icon name={maximized ? "restore" : "maximize"} size={14} strokeWidth={1.25} />
|
||||
</button>
|
||||
<button class="win close" aria-label="Close" title="Close" onclick={windowAction(() => launcher.win.close())}>
|
||||
<Icon name="close" size={14} strokeWidth={1.25} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.titlebar {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--titlebar-h);
|
||||
padding-left: var(--space-4);
|
||||
border-bottom: 1px solid rgba(35, 35, 38, 0.8);
|
||||
background: linear-gradient(180deg, rgba(9, 9, 11, 0.9), rgba(9, 9, 11, 0.6));
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mark {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin-right: calc(var(--wordmark-tracking) * -1);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--wordmark-tracking);
|
||||
}
|
||||
|
||||
.product {
|
||||
margin-left: 6px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 var(--space-4);
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.site {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.site-name {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(245, 180, 84, 0.3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
background-color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.badge.online {
|
||||
border-color: rgba(134, 239, 172, 0.28);
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.badge.checking .badge-dot {
|
||||
animation: pulse 1.1s var(--ease) infinite;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.refresh:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
display: grid;
|
||||
}
|
||||
.spinning {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.drag {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.settings {
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.win {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.win:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.win.close:hover {
|
||||
background: var(--danger);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.win:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
// The ONLY module that touches Tauri. Everything else talks to `backend`.
|
||||
// Inside the Tauri webview this is a thin typed wrapper over invoke/listen
|
||||
// (docs/CONTRACT.md); in a plain browser it is the timer-driven mock.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { createMockBackend } from "./mock";
|
||||
import type {
|
||||
Backend,
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
Settings,
|
||||
SignInResult,
|
||||
StackEvent,
|
||||
Status,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
export const IN_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
|
||||
function subscribe<T>(event: string, handler: (payload: T) => void): Promise<Unlisten> {
|
||||
return listen<T>(event, (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
function createTauriBackend(): Backend {
|
||||
const win = getCurrentWindow();
|
||||
|
||||
return {
|
||||
kind: "tauri",
|
||||
mock: null,
|
||||
window: {
|
||||
minimize: () => win.minimize(),
|
||||
toggleMaximize: () => win.toggleMaximize(),
|
||||
close: () => win.close(),
|
||||
isMaximized: () => win.isMaximized(),
|
||||
onResized: (handler) => win.onResized(() => handler()),
|
||||
},
|
||||
|
||||
getStatus: () => invoke<Status>("get_status"),
|
||||
refresh: () => invoke<Status>("refresh"),
|
||||
signIn: () => invoke<SignInResult>("sign_in"),
|
||||
cancelSignIn: () => invoke<void>("cancel_sign_in"),
|
||||
signOut: () => invoke<Status>("sign_out"),
|
||||
getNotes: (version) => invoke<string>("get_notes", version === undefined ? {} : { version }),
|
||||
openExternal: (url) => invoke<void>("open_external", { url }),
|
||||
getStack: () => invoke<StackEvent[]>("get_stack"),
|
||||
startInstall: () => invoke<void>("start_install"),
|
||||
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
|
||||
cancelInstall: () => invoke<void>("cancel_install"),
|
||||
launch: () => invoke<void>("launch"),
|
||||
stop: () => invoke<void>("stop"),
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
|
||||
pickFolder: () => invoke<string | null>("pick_folder"),
|
||||
|
||||
onStatus: (handler) => subscribe<Status>("status", handler),
|
||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
||||
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
||||
onLog: (handler) => subscribe<LogEvent>("log", handler),
|
||||
};
|
||||
}
|
||||
|
||||
export const backend: Backend = IN_TAURI
|
||||
? createTauriBackend()
|
||||
: createMockBackend(new URLSearchParams(window.location.search));
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { InstallStage, StackComponent, StackState } from "./types";
|
||||
|
||||
const UNITS = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
/** Binary sizes with Windows-style labels: 412 MB, 18.6 GB. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${UNITS[unit]}`;
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSec: number): string {
|
||||
return `${formatBytes(bytesPerSec)}/s`;
|
||||
}
|
||||
|
||||
/** 45 s · 4 min 12 s · 32 min · 1 h 03 min */
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "—";
|
||||
const s = Math.round(totalSeconds);
|
||||
if (s < 60) return `${s} s`;
|
||||
const minutes = Math.floor(s / 60);
|
||||
if (minutes < 10) return `${minutes} min ${String(s % 60).padStart(2, "0")} s`;
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} h ${String(minutes % 60).padStart(2, "0")} min`;
|
||||
}
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
/** Unix seconds → 24 h wall clock. */
|
||||
export function formatClock(unixSeconds: number): string {
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
return [d.getHours(), d.getMinutes(), d.getSeconds()]
|
||||
.map((n) => String(n).padStart(2, "0"))
|
||||
.join(":");
|
||||
}
|
||||
|
||||
export function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tauri rejects with a string; be forgiving about anything else. */
|
||||
export function errorText(error: unknown): string {
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String((error as { message: unknown }).message);
|
||||
}
|
||||
return "Unexpected error";
|
||||
}
|
||||
|
||||
export const STAGE_LABEL: Record<InstallStage, string> = {
|
||||
manifest: "Reading manifest",
|
||||
app: "Application",
|
||||
db: "Database snapshot",
|
||||
src: "Source data",
|
||||
tiles: "Map tiles",
|
||||
restore: "Restoring database",
|
||||
finalize: "Finalizing",
|
||||
};
|
||||
|
||||
export const COMPONENT_LABEL: Record<StackComponent, string> = {
|
||||
postgres: "Database",
|
||||
api: "Engine API",
|
||||
tiles: "Tile server",
|
||||
odian: "ODIAN",
|
||||
};
|
||||
|
||||
export const COMPONENT_DETAIL: Record<StackComponent, string> = {
|
||||
postgres: "PostgreSQL",
|
||||
api: "odian-engine-api",
|
||||
tiles: "Local tiles",
|
||||
odian: "Odian.exe",
|
||||
};
|
||||
|
||||
export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = {
|
||||
waiting: "Waiting",
|
||||
starting: "Starting",
|
||||
ready: "Ready",
|
||||
stopped: "Stopped",
|
||||
failed: "Failed",
|
||||
};
|
||||
@@ -0,0 +1,394 @@
|
||||
// App state as Svelte 5 runes. Mirrors backend Status/events and wraps every
|
||||
// command so failures surface as a notice plus an activity entry.
|
||||
import { backend } from "./backend";
|
||||
import { errorText, hostOf } from "./format";
|
||||
import { SIGN_IN_CANCELLED } from "./types";
|
||||
import type {
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
LogLevel,
|
||||
Phase,
|
||||
Settings,
|
||||
StackComponent,
|
||||
StackEvent,
|
||||
Status,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
export type ActionKind =
|
||||
| "loading"
|
||||
| "sign_in"
|
||||
| "signing_in"
|
||||
| "install"
|
||||
| "offline_unavailable"
|
||||
| "signed_out_offline"
|
||||
| "installing"
|
||||
| "restoring"
|
||||
| "update"
|
||||
| "launch"
|
||||
| "stack"
|
||||
| "error";
|
||||
|
||||
export const STACK_ORDER: readonly StackComponent[] = ["postgres", "api", "tiles", "odian"];
|
||||
|
||||
export type StackMap = Record<StackComponent, StackEvent | null>;
|
||||
|
||||
/** Which primary action the hub shows for a given status. */
|
||||
export function deriveAction(s: Status | null): ActionKind {
|
||||
if (!s) return "loading";
|
||||
switch (s.phase) {
|
||||
case "error":
|
||||
return "error";
|
||||
case "signing_in":
|
||||
return "signing_in";
|
||||
case "installing":
|
||||
return "installing";
|
||||
case "restoring":
|
||||
return "restoring";
|
||||
case "launching":
|
||||
case "running":
|
||||
return "stack";
|
||||
}
|
||||
// idle or checking
|
||||
if (s.installed === null) {
|
||||
if (!s.online) return "offline_unavailable";
|
||||
return s.signed_in ? "install" : "sign_in";
|
||||
}
|
||||
if (s.update_required) return s.signed_in ? "update" : "sign_in";
|
||||
// Launching runs ODIAN as the remembered operator; after Sign out there is none.
|
||||
if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline";
|
||||
return "launch";
|
||||
}
|
||||
|
||||
type LastOp = { kind: "install" } | { kind: "drive"; path: string } | { kind: "launch" };
|
||||
|
||||
interface NotesState {
|
||||
key: string | null;
|
||||
loading: boolean;
|
||||
markdown: string | null;
|
||||
error: string | null;
|
||||
version: string | null;
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
|
||||
"signing_in",
|
||||
"installing",
|
||||
"restoring",
|
||||
"launching",
|
||||
"running",
|
||||
]);
|
||||
const MAX_LOGS = 250;
|
||||
const FOCUS_REFRESH_MS = 30_000;
|
||||
const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null });
|
||||
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
class Launcher {
|
||||
readonly kind = backend.kind;
|
||||
readonly mock = backend.mock;
|
||||
readonly win = backend.window;
|
||||
|
||||
status = $state<Status | null>(null);
|
||||
settings = $state<Settings | null>(null);
|
||||
progress = $state<InstallProgress | null>(null);
|
||||
/** Smoothed bytes/sec for a steadier ETA. */
|
||||
speed = $state(0);
|
||||
stack = $state<StackMap>(emptyStack());
|
||||
logs = $state<LogEvent[]>([]);
|
||||
unseen = $state(0);
|
||||
unseenError = $state(false);
|
||||
activityOpen = $state(false);
|
||||
/** Last command failure, shown next to the primary action. */
|
||||
notice = $state<string | null>(null);
|
||||
/** Name of the command in flight, if any. */
|
||||
busy = $state<string | null>(null);
|
||||
refreshing = $state(false);
|
||||
stopping = $state(false);
|
||||
notes = $state<NotesState>({
|
||||
key: null,
|
||||
loading: false,
|
||||
markdown: null,
|
||||
error: null,
|
||||
version: null,
|
||||
fromCache: false,
|
||||
});
|
||||
|
||||
action: ActionKind = $derived(deriveAction(this.status));
|
||||
/** Settings can't change while something is installing or running. */
|
||||
locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false);
|
||||
siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : "");
|
||||
notesVersion = $derived(
|
||||
this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null,
|
||||
);
|
||||
notesKey = $derived(this.status ? `${this.status.online}|${this.notesVersion ?? ""}` : null);
|
||||
eta = $derived.by(() => {
|
||||
const p = this.progress;
|
||||
if (!p || p.total_bytes <= 0 || this.speed <= 0) return null;
|
||||
return Math.max(0, p.total_bytes - p.done_bytes) / this.speed;
|
||||
});
|
||||
|
||||
private lastOp: LastOp | null = null;
|
||||
private lastProbe = 0;
|
||||
|
||||
/** Subscribes to backend events and loads the first status. Returns a disposer. */
|
||||
start(): () => void {
|
||||
let disposed = false;
|
||||
const unlisteners: Unlisten[] = [];
|
||||
const keep = (p: Promise<Unlisten>) =>
|
||||
p.then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
else unlisteners.push(unlisten);
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
keep(backend.onStatus((s) => this.applyStatus(s))),
|
||||
keep(backend.onInstallProgress((p) => this.applyProgress(p))),
|
||||
keep(backend.onStack((e) => (this.stack[e.component] = e))),
|
||||
keep(backend.onLog((l) => this.pushLog(l))),
|
||||
]);
|
||||
const [status, settings] = await Promise.all([backend.getStatus(), backend.getSettings()]);
|
||||
if (disposed) return;
|
||||
this.applyStatus(status);
|
||||
this.settings = settings;
|
||||
// A UI (re)loaded mid-launch redraws the stack from the backend's last known states.
|
||||
for (const event of await backend.getStack()) this.stack[event.component] = event;
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
}
|
||||
if (!disposed) void this.refresh();
|
||||
})();
|
||||
|
||||
const onFocus = () => {
|
||||
if (this.status?.phase === "idle" && Date.now() - this.lastProbe > FOCUS_REFRESH_MS) void this.refresh();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisteners.forEach((u) => u());
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}
|
||||
|
||||
// ── event reducers ──────────────────────────────────────────────────────
|
||||
|
||||
private applyStatus(next: Status) {
|
||||
const prev = this.status;
|
||||
if (next.phase === "launching" && prev?.phase !== "launching") this.stack = emptyStack();
|
||||
if (next.phase !== "installing" && next.phase !== "restoring") {
|
||||
this.progress = null;
|
||||
this.speed = 0;
|
||||
}
|
||||
if (next.phase !== "running" && next.phase !== "launching") this.stopping = false;
|
||||
if (prev && prev.phase !== next.phase && LOCKED_PHASES.has(next.phase)) this.notice = null;
|
||||
this.status = next;
|
||||
}
|
||||
|
||||
private applyProgress(p: InstallProgress) {
|
||||
const prev = this.progress;
|
||||
const sameStage = prev !== null && prev.stage_index === p.stage_index && prev.stage === p.stage;
|
||||
if (p.bytes_per_sec <= 0) this.speed = 0;
|
||||
else if (!sameStage || this.speed <= 0) this.speed = p.bytes_per_sec;
|
||||
else this.speed = this.speed * 0.85 + p.bytes_per_sec * 0.15;
|
||||
this.progress = p;
|
||||
}
|
||||
|
||||
private pushLog(entry: LogEvent) {
|
||||
this.logs = [entry, ...this.logs].slice(0, MAX_LOGS);
|
||||
if (!this.activityOpen) {
|
||||
this.unseen++;
|
||||
if (entry.level === "error") this.unseenError = true;
|
||||
}
|
||||
}
|
||||
|
||||
private note(level: LogLevel, message: string) {
|
||||
this.pushLog({ level, message, at: nowSeconds() });
|
||||
}
|
||||
|
||||
private fail(error: unknown) {
|
||||
const message = errorText(error);
|
||||
this.notice = message;
|
||||
this.note("error", message);
|
||||
}
|
||||
|
||||
private async run(name: string, task: () => Promise<void>): Promise<boolean> {
|
||||
if (this.busy) return false;
|
||||
this.busy = name;
|
||||
this.notice = null;
|
||||
try {
|
||||
await task();
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
return false;
|
||||
} finally {
|
||||
this.busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands ────────────────────────────────────────────────────────────
|
||||
|
||||
async refresh() {
|
||||
if (this.refreshing) return;
|
||||
this.refreshing = true;
|
||||
this.lastProbe = Date.now();
|
||||
try {
|
||||
this.applyStatus(await backend.refresh());
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
} finally {
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
signIn() {
|
||||
return this.run("sign_in", async () => {
|
||||
try {
|
||||
await backend.signIn();
|
||||
} catch (e) {
|
||||
// Cancelling is the operator's choice, not a failure to show.
|
||||
if (errorText(e) !== SIGN_IN_CANCELLED) throw e;
|
||||
this.note("info", SIGN_IN_CANCELLED);
|
||||
}
|
||||
this.applyStatus(await backend.getStatus());
|
||||
});
|
||||
}
|
||||
|
||||
/** Not wrapped in `run`: the sign-in it cancels is the command in flight. */
|
||||
async cancelSignIn() {
|
||||
try {
|
||||
await backend.cancelSignIn();
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
signOut() {
|
||||
return this.run("sign_out", async () => this.applyStatus(await backend.signOut()));
|
||||
}
|
||||
|
||||
async openExternal(url: string) {
|
||||
try {
|
||||
await backend.openExternal(url);
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
install() {
|
||||
return this.run("install", async () => {
|
||||
this.lastOp = { kind: "install" };
|
||||
await backend.startInstall();
|
||||
});
|
||||
}
|
||||
|
||||
async installFromDrive() {
|
||||
const path = await this.pickFolder();
|
||||
if (!path) return false;
|
||||
return this.run("install", async () => {
|
||||
this.lastOp = { kind: "drive", path };
|
||||
await backend.startInstallFromDrive(path);
|
||||
});
|
||||
}
|
||||
|
||||
cancelInstall() {
|
||||
return this.run("cancel", () => backend.cancelInstall());
|
||||
}
|
||||
|
||||
launch() {
|
||||
return this.run("launch", async () => {
|
||||
this.lastOp = { kind: "launch" };
|
||||
await backend.launch();
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
return this.run("stop", async () => {
|
||||
this.stopping = true;
|
||||
try {
|
||||
await backend.stop();
|
||||
} catch (e) {
|
||||
this.stopping = false;
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Repeats the failed operation; without history, infers it from status. */
|
||||
retry() {
|
||||
const op = this.lastOp;
|
||||
const s = this.status;
|
||||
if (op?.kind === "drive") {
|
||||
return this.run("install", () => backend.startInstallFromDrive(op.path));
|
||||
}
|
||||
if (op?.kind === "install") return this.install();
|
||||
if (op?.kind === "launch") return this.launch();
|
||||
if (!s) return this.refresh();
|
||||
if (s.installed === null || s.update_required) {
|
||||
return s.online && s.signed_in ? this.install() : this.refresh();
|
||||
}
|
||||
return this.launch();
|
||||
}
|
||||
|
||||
saveSettings(next: Settings) {
|
||||
return this.run("settings", async () => {
|
||||
this.applyStatus(await backend.setSettings(next));
|
||||
this.settings = await backend.getSettings();
|
||||
});
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
try {
|
||||
return await backend.pickFolder();
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadNotes() {
|
||||
const key = this.notesKey;
|
||||
const s = this.status;
|
||||
if (key === null || !s) return;
|
||||
if (key === this.notes.key && (this.notes.loading || this.notes.markdown !== null)) return;
|
||||
const version = this.notesVersion ?? undefined;
|
||||
this.notes = {
|
||||
key,
|
||||
loading: true,
|
||||
markdown: null,
|
||||
error: null,
|
||||
version: version ?? null,
|
||||
fromCache: !s.online,
|
||||
};
|
||||
try {
|
||||
const markdown = await backend.getNotes(version);
|
||||
if (this.notes.key === key) this.notes = { ...this.notes, loading: false, markdown };
|
||||
} catch (e) {
|
||||
if (this.notes.key === key) this.notes = { ...this.notes, loading: false, error: errorText(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── activity panel ──────────────────────────────────────────────────────
|
||||
|
||||
setActivityOpen(open: boolean) {
|
||||
this.activityOpen = open;
|
||||
if (open) {
|
||||
this.unseen = 0;
|
||||
this.unseenError = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
this.unseen = 0;
|
||||
this.unseenError = false;
|
||||
}
|
||||
|
||||
dismissNotice() {
|
||||
this.notice = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const launcher = new Launcher();
|
||||
@@ -0,0 +1,84 @@
|
||||
// Patch notes come from the site (or its cached copy) and are untrusted:
|
||||
// Markdown is rendered with marked, then every byte of HTML goes through
|
||||
// DOMPurify with a small allowlist before it reaches {@html}.
|
||||
import { Marked, type Token, type TokensList } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const marked = new Marked({ gfm: true, breaks: false, async: false });
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"p", "br", "hr", "blockquote",
|
||||
"ul", "ol", "li",
|
||||
"strong", "em", "b", "i", "del", "s", "code", "pre", "kbd", "sub", "sup",
|
||||
"a",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"input", // GFM task-list checkboxes (forced disabled below)
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = ["href", "title", "align", "start", "type", "checked", "disabled"];
|
||||
|
||||
const SAFE_HREF = /^(https?:|mailto:|#)/i;
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
const href = node.getAttribute("href") ?? "";
|
||||
if (!SAFE_HREF.test(href.trim())) node.removeAttribute("href");
|
||||
node.removeAttribute("target");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
if (href && !node.getAttribute("title")) node.setAttribute("title", href);
|
||||
}
|
||||
if (node.tagName === "INPUT") {
|
||||
if (node.getAttribute("type") !== "checkbox") node.remove();
|
||||
else node.setAttribute("disabled", "");
|
||||
}
|
||||
});
|
||||
|
||||
function sanitize(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
});
|
||||
}
|
||||
|
||||
function render(tokens: Token[], links: TokensList["links"]): string {
|
||||
const list = Object.assign(tokens, { links }) as TokensList;
|
||||
return sanitize(marked.parser(list));
|
||||
}
|
||||
|
||||
function isRoadmapHeading(token: Token): boolean {
|
||||
return token.type === "heading" && token.depth === 2 && /^roadmap$/i.test(token.text.trim());
|
||||
}
|
||||
|
||||
export interface RenderedNotes {
|
||||
/** Everything except the Roadmap section. */
|
||||
notes: string;
|
||||
/** Contents under `## Roadmap` (heading excluded), or null when absent/empty. */
|
||||
roadmap: string | null;
|
||||
}
|
||||
|
||||
export function renderNotes(markdown: string): RenderedNotes {
|
||||
const tokens = marked.lexer(markdown);
|
||||
const notes: Token[] = [];
|
||||
const roadmap: Token[] = [];
|
||||
let inRoadmap = false;
|
||||
let sawRoadmap = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (isRoadmapHeading(token)) {
|
||||
inRoadmap = true;
|
||||
sawRoadmap = true;
|
||||
continue;
|
||||
}
|
||||
if (inRoadmap && token.type === "heading" && token.depth <= 2) inRoadmap = false;
|
||||
(inRoadmap ? roadmap : notes).push(token);
|
||||
}
|
||||
|
||||
const hasRoadmapContent = roadmap.some((t) => t.type !== "space");
|
||||
return {
|
||||
notes: render(notes, tokens.links),
|
||||
roadmap: sawRoadmap && hasRoadmapContent ? render(roadmap, tokens.links) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Sample patch notes for the browser mock. They deliberately contain markup
|
||||
// that must NOT survive sanitizing (a script, an event handler, a javascript:
|
||||
// link): if any of it shows up or runs, the sanitizer is broken.
|
||||
|
||||
export type NotesVariant = "full" | "no-roadmap";
|
||||
|
||||
const PROBES = `
|
||||
<script>alert("patch notes are untrusted")</script>
|
||||
<img src="x" onerror="alert('patch notes are untrusted')">
|
||||
`;
|
||||
|
||||
const ROADMAP = `
|
||||
## Roadmap
|
||||
|
||||
### Next — 0.53
|
||||
- Globally unique IDs across DHPs, the groundwork for merging offline edits.
|
||||
- ODIAN assistant in the Chat panel, answering from the ontology.
|
||||
- Pattern-of-life (Habits) summary available to the assistant by hex ID.
|
||||
|
||||
### Later
|
||||
- Merge offline edits back into the DHP database, git-style.
|
||||
- Search across entity documents from the map.
|
||||
- NATO MIM governance workflow for exported data.
|
||||
`;
|
||||
|
||||
const NOTES: Record<string, string> = {
|
||||
"0.52.0": `
|
||||
# ODIAN 0.52.0
|
||||
|
||||
Released 2026-09-12 · required update for all operators on this DHP.
|
||||
|
||||
## Highlights
|
||||
- **Mission assignment** — target → aircraft tasking now proposes a ranked list. The pick you make is recorded with the solve.
|
||||
- **Habits panel** — a pattern-of-life summary for each aircraft, one click from the track card.
|
||||
- **Offline map tiles** — the dark basemap \`protomaps_20260423\` ships with the install, so panning and zooming never need the network.
|
||||
|
||||
## Improvements
|
||||
- Track trails draw about 3× faster above 5,000 live contacts.
|
||||
- NATO MIM export validates against the SHACL shapes before it writes anything.
|
||||
- The Chat panel keeps its scroll position while new messages arrive.
|
||||
- Install and update resume from the last verified file.
|
||||
|
||||
## Fixes
|
||||
- Fixed a crash when a vessel report had no MMSI.
|
||||
- Vessel positions no longer freeze after an AIS feed outage.
|
||||
- The replay time slider no longer skips the final minute.
|
||||
${PROBES}
|
||||
## Known issues
|
||||
- The first launch after an install can take up to 60 s while Postgres warms its cache.
|
||||
|
||||
> Report problems from **Help → Send diagnostics** inside ODIAN, or read the [operator guide](https://docs.example.invalid/odian/0.52) and [this link](javascript:alert(1)).
|
||||
`,
|
||||
"0.51.0": `
|
||||
# ODIAN 0.51.0
|
||||
|
||||
Released 2026-08-04.
|
||||
|
||||
## Highlights
|
||||
- **Airspace layers** — restricted and danger areas draw from the DHP's AIXM import.
|
||||
- **Track history** — scrub up to 72 h of recorded positions per contact.
|
||||
|
||||
## Improvements
|
||||
- Startup is 40% faster on machines with a local database.
|
||||
- Layer panel remembers which groups you collapsed.
|
||||
|
||||
## Fixes
|
||||
- Fixed tooltips lingering after the map was dragged.
|
||||
- Fixed a rare deadlock when two operators edited the same annotation.
|
||||
`,
|
||||
};
|
||||
|
||||
export function mockNotes(version: string, variant: NotesVariant): string {
|
||||
const body = NOTES[version] ?? `# ODIAN ${version}\n\nNo notes were published for this version.\n`;
|
||||
return variant === "full" ? `${body}${ROADMAP}` : body;
|
||||
}
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
||||
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
||||
//
|
||||
// ?scenario=first-run | update-required | ready | offline-installed |
|
||||
// offline-not-installed | installing | running | error
|
||||
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
||||
// &speed=4 run timers 4x faster
|
||||
// ¬es=no-roadmap notes without a Roadmap
|
||||
//
|
||||
// Commands reject with a plain string, like Tauri does.
|
||||
import { mockNotes, type NotesVariant } from "./mock-notes";
|
||||
import {
|
||||
MOCK_SCENARIOS,
|
||||
SIGN_IN_CANCELLED,
|
||||
type Backend,
|
||||
type InstallProgress,
|
||||
type InstallStage,
|
||||
type LogEvent,
|
||||
type LogLevel,
|
||||
type MockControls,
|
||||
type MockScenario,
|
||||
type Settings,
|
||||
type SignInResult,
|
||||
type StackComponent,
|
||||
type StackEvent,
|
||||
type Status,
|
||||
type Unlisten,
|
||||
} from "./types";
|
||||
|
||||
const CURRENT = "0.52.0";
|
||||
const PREVIOUS = "0.51.0";
|
||||
const OPERATOR = "m1op";
|
||||
const SITE_URL = "http://192.168.1.41:30097";
|
||||
const SITE_NAME = "DHP Alpha";
|
||||
const MCP_URL = "http://192.168.1.41:30091";
|
||||
const DEFAULT_DIR = "D:\\ODIAN";
|
||||
|
||||
const KB = 1024;
|
||||
const MB = 1024 ** 2;
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
const FAIL_POINTS = ["sign_in", "install", "restore", "launch", "notes"] as const;
|
||||
type FailPoint = (typeof FAIL_POINTS)[number];
|
||||
|
||||
interface StagePlan {
|
||||
stage: InstallStage;
|
||||
/** Simulated wall time; the reported speed stays realistic. */
|
||||
seconds: number;
|
||||
bytes: number;
|
||||
items: number;
|
||||
bytesPerSec: number;
|
||||
message: (done: number, total: number) => string | null;
|
||||
}
|
||||
|
||||
function installPlan(kind: "fresh" | "update", source: "site" | "drive"): StagePlan[] {
|
||||
const rate = source === "drive" ? 468 * MB : 112 * MB;
|
||||
const plan: StagePlan[] = [
|
||||
{
|
||||
stage: "manifest",
|
||||
seconds: 0.8,
|
||||
bytes: 86 * KB,
|
||||
items: 1,
|
||||
bytesPerSec: source === "drive" ? 40 * MB : 2.4 * MB,
|
||||
message: () => `manifest ${CURRENT} (${source === "drive" ? "drive" : SITE_NAME})`,
|
||||
},
|
||||
{
|
||||
stage: "app",
|
||||
seconds: 3,
|
||||
bytes: kind === "update" ? 96 * MB : 412 * MB,
|
||||
items: kind === "update" ? 138 : 1284,
|
||||
bytesPerSec: rate,
|
||||
message: (d, t) => `app: ${d}/${t} files`,
|
||||
},
|
||||
{
|
||||
stage: "db",
|
||||
seconds: 4,
|
||||
bytes: kind === "update" ? 2.3 * GB : 18.6 * GB,
|
||||
items: 1,
|
||||
bytesPerSec: rate,
|
||||
message: () => (kind === "update" ? "db odian_20260912.delta" : "db odian_20260912.dump"),
|
||||
},
|
||||
];
|
||||
if (kind === "fresh") {
|
||||
plan.push(
|
||||
{
|
||||
stage: "src",
|
||||
seconds: 2.5,
|
||||
bytes: 3.4 * GB,
|
||||
items: 412,
|
||||
bytesPerSec: rate,
|
||||
message: (d, t) => `src: ${d}/${t} files`,
|
||||
},
|
||||
{
|
||||
stage: "tiles",
|
||||
seconds: 9,
|
||||
bytes: 212 * GB,
|
||||
items: 1402,
|
||||
bytesPerSec: rate,
|
||||
message: (d, t) => `tiles dark/protomaps_20260423: ${d}/${t} blocks`,
|
||||
},
|
||||
);
|
||||
}
|
||||
plan.push(
|
||||
{
|
||||
stage: "restore",
|
||||
seconds: 5,
|
||||
bytes: 0,
|
||||
items: 231,
|
||||
bytesPerSec: 0,
|
||||
message: (d, t) => `pg_restore: ${d}/${t} tables`,
|
||||
},
|
||||
{
|
||||
stage: "finalize",
|
||||
seconds: 1,
|
||||
bytes: 0,
|
||||
items: 0,
|
||||
bytesPerSec: 0,
|
||||
message: () => "writing install manifest",
|
||||
},
|
||||
);
|
||||
return plan;
|
||||
}
|
||||
|
||||
const STAGE_DONE: Record<InstallStage, string> = {
|
||||
manifest: "Manifest verified",
|
||||
app: "Application files verified",
|
||||
db: "Database snapshot verified",
|
||||
src: "Source data verified",
|
||||
tiles: "Map tiles verified",
|
||||
restore: "Database restored into local Postgres",
|
||||
finalize: "Install finalized",
|
||||
};
|
||||
|
||||
type Payloads = {
|
||||
status: Status;
|
||||
"install-progress": InstallProgress;
|
||||
stack: StackEvent;
|
||||
log: LogEvent;
|
||||
};
|
||||
type Channel = keyof Payloads;
|
||||
type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const busy = "Busy: wait for the current task to finish.";
|
||||
|
||||
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
|
||||
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
|
||||
}
|
||||
|
||||
/** Enforces the contract's derived fields. */
|
||||
function derive(s: Status): Status {
|
||||
const available = s.online ? (s.available ?? CURRENT) : null;
|
||||
const update_required = s.online && s.installed !== null && available !== null && s.installed !== available;
|
||||
return {
|
||||
...s,
|
||||
available,
|
||||
signed_in: s.online && s.signed_in,
|
||||
update_required,
|
||||
can_launch: s.installed !== null && !update_required && s.phase === "idle",
|
||||
};
|
||||
}
|
||||
|
||||
function initialStatus(scenario: MockScenario): Status {
|
||||
const s: Status = {
|
||||
online: true,
|
||||
site: { url: SITE_URL, name: SITE_NAME },
|
||||
operator: null,
|
||||
signed_in: false,
|
||||
installed: null,
|
||||
available: CURRENT,
|
||||
update_required: false,
|
||||
can_launch: false,
|
||||
phase: "idle",
|
||||
error: null,
|
||||
install_dir: DEFAULT_DIR,
|
||||
};
|
||||
switch (scenario) {
|
||||
case "first-run":
|
||||
break;
|
||||
case "update-required":
|
||||
Object.assign(s, { installed: PREVIOUS, operator: OPERATOR, signed_in: true });
|
||||
break;
|
||||
case "ready":
|
||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
||||
break;
|
||||
case "offline-installed":
|
||||
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
||||
break;
|
||||
case "offline-not-installed":
|
||||
Object.assign(s, { online: false });
|
||||
break;
|
||||
case "installing":
|
||||
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
|
||||
break;
|
||||
case "running":
|
||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running" });
|
||||
break;
|
||||
case "error":
|
||||
Object.assign(s, {
|
||||
operator: OPERATOR,
|
||||
signed_in: true,
|
||||
phase: "error",
|
||||
error:
|
||||
"Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
return derive(s);
|
||||
}
|
||||
|
||||
class MockBackend implements Backend {
|
||||
readonly kind = "mock" as const;
|
||||
readonly mock: MockControls;
|
||||
readonly window = {
|
||||
minimize: async () => this.log("info", "Minimize pressed (browser mock)"),
|
||||
toggleMaximize: async () => this.log("info", "Maximize pressed (browser mock)"),
|
||||
close: async () => this.log("info", "Close pressed (browser mock: the tab stays open)"),
|
||||
isMaximized: async () => false,
|
||||
onResized: async (): Promise<Unlisten> => () => {},
|
||||
};
|
||||
|
||||
private status: Status;
|
||||
private settings: Settings;
|
||||
private network: boolean;
|
||||
private job = 0;
|
||||
private booted = false;
|
||||
private signInCancelled = false;
|
||||
/** Latest event per component, as `get_stack` reports it. */
|
||||
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
||||
private readonly listeners: Listeners = {
|
||||
status: new Set(),
|
||||
"install-progress": new Set(),
|
||||
stack: new Set(),
|
||||
log: new Set(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly scenario: MockScenario,
|
||||
private readonly fail: FailPoint | null,
|
||||
private readonly speed: number,
|
||||
private readonly notesVariant: NotesVariant,
|
||||
) {
|
||||
this.status = initialStatus(scenario);
|
||||
this.network = this.status.online;
|
||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
|
||||
|
||||
const self = this;
|
||||
this.mock = {
|
||||
get scenario() {
|
||||
return self.scenario;
|
||||
},
|
||||
get online() {
|
||||
return self.network;
|
||||
},
|
||||
setOnline(online: boolean) {
|
||||
self.network = online;
|
||||
self.log(online ? "info" : "warn", online ? "Mock: network restored" : "Mock: network cable pulled");
|
||||
void self.refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
private emit<K extends Channel>(channel: K, payload: Payloads[K]) {
|
||||
if (channel === "stack") {
|
||||
const event = payload as StackEvent;
|
||||
this.stackStates.set(event.component, structuredClone(event));
|
||||
}
|
||||
for (const handler of this.listeners[channel]) handler(structuredClone(payload));
|
||||
}
|
||||
|
||||
private listen<K extends Channel>(channel: K, handler: (payload: Payloads[K]) => void): Promise<Unlisten> {
|
||||
this.listeners[channel].add(handler);
|
||||
return Promise.resolve(() => void this.listeners[channel].delete(handler));
|
||||
}
|
||||
|
||||
private commit(patch: Partial<Status>) {
|
||||
this.status = derive({ ...this.status, ...patch });
|
||||
this.emit("status", this.status);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, at = now()) {
|
||||
this.emit("log", { level, message, at });
|
||||
}
|
||||
|
||||
private wait(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms / this.speed));
|
||||
}
|
||||
|
||||
private snapshot(): Status {
|
||||
return structuredClone(this.status);
|
||||
}
|
||||
|
||||
/** Scenario side effects start on the first get_status, once the UI listens. */
|
||||
private boot() {
|
||||
if (this.booted) return;
|
||||
this.booted = true;
|
||||
const t = now();
|
||||
const s = this.status;
|
||||
this.log("info", "ODIAN Launcher started", t - 6);
|
||||
this.log(
|
||||
s.online ? "info" : "warn",
|
||||
s.online
|
||||
? `${SITE_NAME} answered /v1/health (current version ${CURRENT})`
|
||||
: `${SITE_URL} unreachable (2 s timeout): working offline`,
|
||||
t - 5,
|
||||
);
|
||||
if (s.update_required) {
|
||||
this.log("warn", `Installed ${s.installed} is behind the DHP (${CURRENT}): update required before launch`, t - 4);
|
||||
}
|
||||
if (this.scenario === "running") this.log("info", "Odian.exe is running (pid 14212)", t - 3);
|
||||
if (this.scenario === "error" && s.error) this.log("error", s.error, t - 2);
|
||||
if (this.scenario === "installing") {
|
||||
setTimeout(() => void this.runInstall("fresh", "site", undefined, { index: 4, fraction: 0.27 }), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands ────────────────────────────────────────────────────────────
|
||||
|
||||
async getStatus(): Promise<Status> {
|
||||
this.boot();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async refresh(): Promise<Status> {
|
||||
const wasIdle = this.status.phase === "idle";
|
||||
const wasOnline = this.status.online;
|
||||
if (wasIdle) this.commit({ phase: "checking" });
|
||||
await this.wait(this.network ? 450 : 2000);
|
||||
const online = this.network;
|
||||
this.commit({
|
||||
online,
|
||||
phase: wasIdle && this.status.phase === "checking" ? "idle" : this.status.phase,
|
||||
});
|
||||
if (online !== wasOnline) {
|
||||
this.log(
|
||||
online ? "info" : "warn",
|
||||
online ? `${SITE_NAME} is reachable again (current version ${CURRENT})` : `${SITE_NAME} unreachable: working offline`,
|
||||
);
|
||||
if (online && this.status.update_required) {
|
||||
this.log("warn", `Update to ${CURRENT} required before launch while connected`);
|
||||
}
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async signIn(): Promise<SignInResult> {
|
||||
if (!this.status.online) throw "Your DHP is unreachable. Connect to the DHP network to sign in.";
|
||||
if (this.status.phase !== "idle") throw busy;
|
||||
this.commit({ phase: "signing_in", error: null });
|
||||
this.signInCancelled = false;
|
||||
this.log("info", "Opened the system browser for Keycloak sign-in");
|
||||
// Like the real backend, a cancel ends the wait at once instead of after the browser timeout.
|
||||
for (let waited = 0; waited < 2400 && !this.signInCancelled; waited += 100) await this.wait(100);
|
||||
if (this.signInCancelled) {
|
||||
this.signInCancelled = false;
|
||||
throw SIGN_IN_CANCELLED;
|
||||
}
|
||||
// Re-read after the await: settings or network toggles may have changed the phase.
|
||||
if ((this.status.phase as string) !== "signing_in") throw "Sign-in was interrupted.";
|
||||
if (this.fail === "sign_in") {
|
||||
const message = "Sign-in timed out: the browser never called back to the launcher.";
|
||||
this.commit({ phase: "idle", error: message });
|
||||
throw message;
|
||||
}
|
||||
this.commit({ phase: "idle", signed_in: true, operator: OPERATOR });
|
||||
this.log("info", `Signed in as ${OPERATOR}`);
|
||||
return { username: OPERATOR };
|
||||
}
|
||||
|
||||
async cancelSignIn(): Promise<void> {
|
||||
if (this.status.phase !== "signing_in") return;
|
||||
this.signInCancelled = true;
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("info", "Sign-in cancelled");
|
||||
}
|
||||
|
||||
async signOut(): Promise<Status> {
|
||||
this.commit({ signed_in: false, operator: null });
|
||||
this.log("info", "Signed out: this PC no longer remembers the operator");
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async openExternal(url: string): Promise<void> {
|
||||
if (!/^https?:\/\//i.test(url)) throw "Only web links can be opened.";
|
||||
this.log("info", `Opened ${url} in the system browser (browser mock: new tab)`);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async getStack(): Promise<StackEvent[]> {
|
||||
if (this.scenario === "running" && this.stackStates.size === 0) {
|
||||
for (const component of ["postgres", "api", "tiles", "odian"] as const) {
|
||||
this.stackStates.set(component, { component, state: "ready", message: null });
|
||||
}
|
||||
}
|
||||
return [...this.stackStates.values()].map((event) => structuredClone(event));
|
||||
}
|
||||
|
||||
async getNotes(version?: string): Promise<string> {
|
||||
await this.wait(380);
|
||||
if (this.fail === "notes") throw "Could not load patch notes: the DHP answered HTTP 502.";
|
||||
if (!this.status.online) {
|
||||
if (!this.status.installed) throw "No cached notes: ODIAN is not installed on this machine.";
|
||||
return mockNotes(this.status.installed, this.notesVariant);
|
||||
}
|
||||
return mockNotes(version ?? this.status.available ?? CURRENT, this.notesVariant);
|
||||
}
|
||||
|
||||
async startInstall(): Promise<void> {
|
||||
const s = this.status;
|
||||
if (!s.online) throw "Your DHP is unreachable. Connect to install, or install from a drive.";
|
||||
if (!s.signed_in) throw "Sign in first: downloading from the DHP needs your DHP sign-in.";
|
||||
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
||||
const kind = s.installed && s.installed !== s.available ? "update" : "fresh";
|
||||
void this.runInstall(kind, "site");
|
||||
}
|
||||
|
||||
async startInstallFromDrive(path: string): Promise<void> {
|
||||
const s = this.status;
|
||||
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
||||
if (!path.trim()) throw "Pick the root folder of an ODIAN drive.";
|
||||
const kind = s.installed && s.installed !== CURRENT ? "update" : "fresh";
|
||||
void this.runInstall(kind, "drive", path);
|
||||
}
|
||||
|
||||
async cancelInstall(): Promise<void> {
|
||||
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
|
||||
if (this.status.phase !== "installing") throw "Nothing is installing.";
|
||||
this.job++;
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("warn", "Install cancelled at the next file boundary; everything already verified was kept");
|
||||
}
|
||||
|
||||
async launch(): Promise<void> {
|
||||
const s = this.status;
|
||||
if (!s.can_launch) {
|
||||
throw s.update_required
|
||||
? `Update to ${s.available} before launching while connected.`
|
||||
: "ODIAN is not ready to launch.";
|
||||
}
|
||||
void this.runLaunch();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const phase = this.status.phase;
|
||||
if (phase !== "running" && phase !== "launching") throw "ODIAN is not running.";
|
||||
void this.runStop();
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
return structuredClone(this.settings);
|
||||
}
|
||||
|
||||
async setSettings(settings: Settings): Promise<Status> {
|
||||
for (const [label, url] of [
|
||||
["DHP URL", settings.site_url],
|
||||
["MCP URL", settings.mcp_url],
|
||||
] as const) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw `${label} is not a valid URL: ${url}`;
|
||||
}
|
||||
}
|
||||
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
|
||||
const siteChanged = settings.site_url !== this.settings.site_url;
|
||||
this.settings = structuredClone(settings);
|
||||
this.commit({
|
||||
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
|
||||
install_dir: settings.install_dir,
|
||||
});
|
||||
this.log("info", "Settings saved");
|
||||
if (siteChanged) void this.refresh();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
return "E:\\ODIAN";
|
||||
}
|
||||
|
||||
onStatus(handler: (status: Status) => void) {
|
||||
return this.listen("status", handler);
|
||||
}
|
||||
onInstallProgress(handler: (progress: InstallProgress) => void) {
|
||||
return this.listen("install-progress", handler);
|
||||
}
|
||||
onStack(handler: (event: StackEvent) => void) {
|
||||
return this.listen("stack", handler);
|
||||
}
|
||||
onLog(handler: (event: LogEvent) => void) {
|
||||
return this.listen("log", handler);
|
||||
}
|
||||
|
||||
// ── flows ───────────────────────────────────────────────────────────────
|
||||
|
||||
private failFlow(message: string) {
|
||||
this.job++;
|
||||
this.commit({ phase: "error", error: message });
|
||||
this.log("error", message);
|
||||
}
|
||||
|
||||
private async runInstall(
|
||||
kind: "fresh" | "update",
|
||||
source: "site" | "drive",
|
||||
path?: string,
|
||||
resume?: { index: number; fraction: number },
|
||||
) {
|
||||
const job = ++this.job;
|
||||
const plan = installPlan(kind, source);
|
||||
this.commit({ phase: "installing", error: null });
|
||||
if (!resume) {
|
||||
const verb = kind === "update" ? `Updating to ODIAN ${CURRENT}` : `Installing ODIAN ${CURRENT}`;
|
||||
this.log("info", source === "drive" ? `${verb} from drive ${path}` : `${verb} from ${SITE_NAME}`);
|
||||
}
|
||||
|
||||
for (let i = resume?.index ?? 0; i < plan.length; i++) {
|
||||
const step = plan[i];
|
||||
const phase = step.stage === "restore" ? "restoring" : "installing";
|
||||
if (this.status.phase !== phase) this.commit({ phase });
|
||||
|
||||
const ticks = Math.max(1, Math.round(step.seconds * 10));
|
||||
const first = resume && i === resume.index ? Math.floor(ticks * resume.fraction) : 0;
|
||||
for (let t = first; t <= ticks; t++) {
|
||||
if (t > first) await this.wait(100);
|
||||
if (job !== this.job) return;
|
||||
const fraction = t / ticks;
|
||||
|
||||
if (source === "site" && !this.network) {
|
||||
return this.failFlow(
|
||||
`Connection to ${SITE_NAME} lost during ${step.stage}. Everything already verified was kept; retry when you are back online.`,
|
||||
);
|
||||
}
|
||||
if (this.fail === "install" && step.stage === (kind === "fresh" ? "tiles" : "db") && fraction >= 0.62) {
|
||||
return this.failFlow(
|
||||
step.stage === "tiles"
|
||||
? "Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume."
|
||||
: "odian_20260912.delta failed verification (sha256 mismatch). Retry to download it again.",
|
||||
);
|
||||
}
|
||||
if (this.fail === "restore" && step.stage === "restore" && fraction >= 0.4) {
|
||||
return this.failFlow("pg_restore failed on table track_point: could not extend file (No space left on device).");
|
||||
}
|
||||
|
||||
const done = Math.round(step.items * fraction);
|
||||
const wobble = 1 + 0.07 * Math.sin(t / 6) + (Math.random() - 0.5) * 0.06;
|
||||
this.emit("install-progress", {
|
||||
stage: step.stage,
|
||||
stage_index: i + 1,
|
||||
stage_count: plan.length,
|
||||
done_bytes: Math.round(step.bytes * fraction),
|
||||
total_bytes: Math.round(step.bytes),
|
||||
done_items: done,
|
||||
total_items: step.items,
|
||||
bytes_per_sec: step.bytesPerSec > 0 && t < ticks ? Math.round(step.bytesPerSec * wobble) : 0,
|
||||
message: step.message(done, step.items),
|
||||
});
|
||||
}
|
||||
this.log("info", STAGE_DONE[step.stage]);
|
||||
}
|
||||
|
||||
if (job !== this.job) return;
|
||||
this.commit({ phase: "idle", installed: CURRENT });
|
||||
this.log("info", `ODIAN ${CURRENT} is installed in ${this.status.install_dir}`);
|
||||
}
|
||||
|
||||
private async runLaunch() {
|
||||
const job = ++this.job;
|
||||
this.commit({ phase: "launching", error: null });
|
||||
this.log("info", `Starting the local stack for ODIAN ${this.status.installed}`);
|
||||
|
||||
const steps: [StackComponent, number, string][] = [
|
||||
["postgres", 1300, "listening on 127.0.0.1:55432"],
|
||||
["api", 1500, "healthy on 127.0.0.1:18080"],
|
||||
["tiles", 900, "serving 3 tilesets"],
|
||||
["odian", 1700, "Odian.exe pid 14212"],
|
||||
];
|
||||
for (const [component, ms, ready] of steps) {
|
||||
this.emit("stack", { component, state: "starting", message: null });
|
||||
await this.wait(ms);
|
||||
if (job !== this.job) return;
|
||||
if (this.fail === "launch" && component === "api") {
|
||||
this.emit("stack", { component, state: "failed", message: "exit code 1" });
|
||||
this.emit("stack", { component: "postgres", state: "stopped", message: null });
|
||||
return this.failFlow("Engine API exited with code 1: bind 127.0.0.1:18080: address already in use.");
|
||||
}
|
||||
this.emit("stack", { component, state: "ready", message: ready });
|
||||
this.log("info", `${component}: ${ready}`);
|
||||
}
|
||||
this.commit({ phase: "running" });
|
||||
}
|
||||
|
||||
private async runStop() {
|
||||
const job = ++this.job;
|
||||
this.log("info", "Stopping the local stack");
|
||||
for (const component of ["odian", "tiles", "api", "postgres"] as const) {
|
||||
await this.wait(380);
|
||||
if (job !== this.job) return;
|
||||
this.emit("stack", { component, state: "stopped", message: null });
|
||||
}
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("info", "Local stack stopped cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockBackend(params: URLSearchParams): Backend {
|
||||
const scenario = pick(params.get("scenario"), MOCK_SCENARIOS) ?? "first-run";
|
||||
const fail = pick(params.get("fail"), FAIL_POINTS);
|
||||
const speed = Math.min(20, Math.max(0.25, Number(params.get("speed")) || 1));
|
||||
const notes: NotesVariant = params.get("notes") === "no-roadmap" ? "no-roadmap" : "full";
|
||||
return new MockBackend(scenario, fail, speed, notes);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Wire types shared with the Rust backend. Mirrors docs/CONTRACT.md exactly:
|
||||
// snake_case fields, timestamps in Unix seconds.
|
||||
|
||||
export type Phase =
|
||||
| "idle" // nothing running
|
||||
| "checking" // probing the site
|
||||
| "signing_in" // browser sign-in in progress
|
||||
| "installing" // downloading + verifying a version
|
||||
| "restoring" // loading the database snapshot into local Postgres
|
||||
| "launching" // starting Postgres → API → tile server → Odian.exe
|
||||
| "running" // Odian.exe is open
|
||||
| "error";
|
||||
|
||||
export interface SiteInfo {
|
||||
url: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
/** The site's update service answered /v1/health. */
|
||||
online: boolean;
|
||||
site: SiteInfo;
|
||||
/** Last signed-in ODIAN username (cached; valid offline). */
|
||||
operator: string | null;
|
||||
/** Holds a site token right now (online only). */
|
||||
signed_in: boolean;
|
||||
/** Installed version, e.g. "0.51.0". */
|
||||
installed: string | null;
|
||||
/** The site's CURRENT version (online only). */
|
||||
available: string | null;
|
||||
/** online && installed !== available (launch is blocked). */
|
||||
update_required: boolean;
|
||||
/** installed && !update_required && phase is idle. */
|
||||
can_launch: boolean;
|
||||
phase: Phase;
|
||||
/** Last error, human readable. */
|
||||
error: string | null;
|
||||
/** e.g. "D:\\ODIAN". */
|
||||
install_dir: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
/** e.g. "http://192.168.1.41:30097". */
|
||||
site_url: string;
|
||||
/** Site MCP, for Keycloak sign-in. */
|
||||
mcp_url: string;
|
||||
install_dir: string;
|
||||
}
|
||||
|
||||
export type InstallStage =
|
||||
| "manifest"
|
||||
| "app"
|
||||
| "db"
|
||||
| "src"
|
||||
| "tiles"
|
||||
| "restore"
|
||||
| "finalize";
|
||||
|
||||
export interface InstallProgress {
|
||||
stage: InstallStage;
|
||||
/** 1-based. */
|
||||
stage_index: number;
|
||||
stage_count: number;
|
||||
done_bytes: number;
|
||||
/** 0 when unknown. */
|
||||
total_bytes: number;
|
||||
done_items: number;
|
||||
total_items: number;
|
||||
bytes_per_sec: number;
|
||||
/** e.g. "tiles dark/protomaps_20260423: 412/1402 blocks". */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export type StackComponent = "postgres" | "api" | "tiles" | "odian";
|
||||
export type StackState = "starting" | "ready" | "stopped" | "failed";
|
||||
|
||||
export interface StackEvent {
|
||||
component: StackComponent;
|
||||
state: StackState;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export type LogLevel = "info" | "warn" | "error";
|
||||
|
||||
export interface LogEvent {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
/** Unix seconds. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface SignInResult {
|
||||
username: string;
|
||||
}
|
||||
|
||||
/** The message `sign_in` rejects with after `cancel_sign_in`: not an error to show. */
|
||||
export const SIGN_IN_CANCELLED = "Sign-in was cancelled.";
|
||||
|
||||
export type Unlisten = () => void;
|
||||
|
||||
/** The frameless window's own controls (drawn by the UI title bar). */
|
||||
export interface WindowControls {
|
||||
minimize(): Promise<void>;
|
||||
toggleMaximize(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
isMaximized(): Promise<boolean>;
|
||||
onResized(handler: () => void): Promise<Unlisten>;
|
||||
}
|
||||
|
||||
export const MOCK_SCENARIOS = [
|
||||
"first-run",
|
||||
"update-required",
|
||||
"ready",
|
||||
"offline-installed",
|
||||
"offline-not-installed",
|
||||
"installing",
|
||||
"running",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
||||
|
||||
/** Design-time controls, only present on the browser mock. Not part of the contract. */
|
||||
export interface MockControls {
|
||||
readonly scenario: MockScenario;
|
||||
readonly online: boolean;
|
||||
setOnline(online: boolean): void;
|
||||
}
|
||||
|
||||
/** Everything the UI may ask of the backend. Commands and events from CONTRACT.md. */
|
||||
export interface Backend {
|
||||
readonly kind: "tauri" | "mock";
|
||||
readonly mock: MockControls | null;
|
||||
readonly window: WindowControls;
|
||||
|
||||
getStatus(): Promise<Status>;
|
||||
refresh(): Promise<Status>;
|
||||
signIn(): Promise<SignInResult>;
|
||||
/** Abandons a sign-in waiting for the browser; `signIn` then rejects with SIGN_IN_CANCELLED. */
|
||||
cancelSignIn(): Promise<void>;
|
||||
signOut(): Promise<Status>;
|
||||
getNotes(version?: string): Promise<string>;
|
||||
/** Opens an http(s) link in the system browser. */
|
||||
openExternal(url: string): Promise<void>;
|
||||
startInstall(): Promise<void>;
|
||||
startInstallFromDrive(path: string): Promise<void>;
|
||||
cancelInstall(): Promise<void>;
|
||||
launch(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Latest event per stack component, for a UI that (re)loads mid-launch. */
|
||||
getStack(): Promise<StackEvent[]>;
|
||||
getSettings(): Promise<Settings>;
|
||||
setSettings(settings: Settings): Promise<Status>;
|
||||
pickFolder(): Promise<string | null>;
|
||||
|
||||
onStatus(handler: (status: Status) => void): Promise<Unlisten>;
|
||||
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;
|
||||
onStack(handler: (event: StackEvent) => void): Promise<Unlisten>;
|
||||
onLog(handler: (event: LogEvent) => void): Promise<Unlisten>;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Fonts are bundled (the launcher must work fully offline; CSP is 'self').
|
||||
import "@fontsource/space-grotesk/latin-500.css";
|
||||
import "@fontsource/space-grotesk/latin-600.css";
|
||||
import "@fontsource/space-grotesk/latin-ext-500.css";
|
||||
import "@fontsource/space-grotesk/latin-ext-600.css";
|
||||
import "@fontsource/inter/latin-400.css";
|
||||
import "@fontsource/inter/latin-500.css";
|
||||
import "@fontsource/inter/latin-600.css";
|
||||
import "@fontsource/inter/latin-ext-400.css";
|
||||
import "@fontsource/inter/latin-ext-500.css";
|
||||
import "@fontsource/inter/latin-ext-600.css";
|
||||
import "@fontsource/ibm-plex-mono/latin-400.css";
|
||||
import "@fontsource/ibm-plex-mono/latin-500.css";
|
||||
import "@fontsource/ibm-plex-mono/latin-ext-400.css";
|
||||
import "@fontsource/ibm-plex-mono/latin-ext-500.css";
|
||||
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,321 @@
|
||||
/* Global reset and the few shared primitives (buttons, inputs, glass). */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 140, 255, 0.38);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--faint);
|
||||
}
|
||||
|
||||
/* ── utilities ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.selectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
backdrop-filter: blur(20px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(120%);
|
||||
}
|
||||
|
||||
/* ── buttons ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease),
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: #4a4a52;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
background: var(--line);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-accent:hover:not(:disabled) {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.btn-accent:active:not(:disabled) {
|
||||
border-color: var(--accent-press);
|
||||
background: var(--accent-press);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
display: inline;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-size: inherit;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-line);
|
||||
text-underline-offset: 3px;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn-link:hover:not(:disabled) {
|
||||
color: var(--accent-hover);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
.btn-link:disabled {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease),
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
border-color: var(--line);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── form fields ───────────────────────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--danger);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
.input:hover:not(:disabled) {
|
||||
border-color: #4a4a52;
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input[aria-invalid="true"] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.input:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── motion ────────────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* ODIAN dark: the single source of design tokens. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Brand palette */
|
||||
--bg: #09090b;
|
||||
--surface: #111114;
|
||||
--surface-2: #16161a;
|
||||
--line: #232326;
|
||||
--line-strong: #34343a;
|
||||
--ink: #f2f1ee;
|
||||
--ink-2: #d4d3d0;
|
||||
--muted: #9a999e;
|
||||
--faint: #76757b;
|
||||
--accent: #5b8cff;
|
||||
--accent-soft: rgba(91, 140, 255, 0.13);
|
||||
--warn: #f5b454;
|
||||
--ok: #86efac;
|
||||
--danger: #f87171;
|
||||
|
||||
/* Derived */
|
||||
--accent-hover: #7aa2ff;
|
||||
--accent-press: #4a79e8;
|
||||
--accent-line: rgba(91, 140, 255, 0.45);
|
||||
--accent-ink: #09090b;
|
||||
--warn-soft: rgba(245, 180, 84, 0.12);
|
||||
--ok-soft: rgba(134, 239, 172, 0.1);
|
||||
--danger-soft: rgba(248, 113, 113, 0.12);
|
||||
--glass: rgba(14, 14, 17, 0.78);
|
||||
--glass-strong: rgba(17, 17, 20, 0.92);
|
||||
--scrim: rgba(5, 5, 7, 0.62);
|
||||
|
||||
/* Type */
|
||||
--font-display: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
--font-body: "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 20px;
|
||||
--wordmark-tracking: 0.28em;
|
||||
|
||||
/* Shape and space */
|
||||
--radius: 2px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* Chrome */
|
||||
--titlebar-h: 40px;
|
||||
--statusbar-h: 34px;
|
||||
|
||||
/* Motion */
|
||||
--dur-fast: 150ms;
|
||||
--dur: 200ms;
|
||||
--dur-slow: 250ms;
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user