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:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+495
View File
@@ -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}&nbsp;<strong>{s.available}</strong>{/if}.
{/if}
</p>
{#if !s?.installed}{@render driveLink("No access to the DHP?")}{/if}
{:else if action === "signing_in"}
{@render primary("Waiting for sign-in", "key", () => {}, true, true)}
<p class="secondary">
Complete the Keycloak sign-in in your browser, then come back here.
<button class="btn-link" onclick={() => launcher.cancelSignIn()}>Cancel sign-in</button>
</p>
{:else if action === "install"}
{@render primary("Install", "download", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
{@render driveLink("Installing needs your DHP, or an ODIAN drive.")}
{:else if action === "signed_out_offline"}
{@render primary("Connect to your DHP to sign in", "globe", () => {}, true)}
<p class="secondary">
This PC doesn't remember an operator anymore. Sign in once while connected to {site}, and ODIAN launches offline
again after that.
</p>
{:else if action === "update"}
{@render primary(`Update to ${s?.available}`, "update", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
Updating is required before launch while connected to {site}. Everything already verified is reused.
</p>
{:else if action === "launch"}
{@render primary("Launch", "play", () => launcher.launch(), blocked || !s?.can_launch, busy === "launch")}
<p class="secondary">
{#if checking}
Checking {site}…
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if}
</p>
{: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>
+129
View File
@@ -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>
+66
View File
@@ -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>
+103
View File
@@ -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>
+286
View File
@@ -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>
+87
View File
@@ -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>
+456
View File
@@ -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>
+80
View File
@@ -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>
+443
View File
@@ -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>
+127
View File
@@ -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>
+51
View File
@@ -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>
+137
View File
@@ -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>
+260
View File
@@ -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>