Files
SoulStream/src/components/FirstRun.svelte
T

633 lines
35 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// The first-run setup: a tour that stands in front of the app on a fresh
// install (config.setup_done unset) and asks for the things the app
// cannot guess — where music lives, the Soulseek account, whether the
// community peer runs, how the window is drawn. Every answer is applied
// the moment its step is left, so a skip halfway loses nothing, and
// Settings → Account runs the tour again. Solid surfaces on purpose:
// no backdrop-filter anywhere (dead over the frost, Library.svelte's
// note) — the scrim is a plain veil, the panel the elevated surface.
import { tick } from "svelte";
import { fade, fly, scale } from "svelte/transition";
import { cubicOut, quintOut } from "svelte/easing";
import Icon from "./Icon.svelte";
import * as api from "../lib/api.js";
import { app, connect } from "../lib/state.svelte.js";
import { applyGlass } from "../lib/glass.js";
import { firstRun, finishFirstRun } from "../lib/firstRun.svelte.js";
import {
community,
refreshFreenet,
setFreenetEnabled,
refreshPersona,
createPersona,
publishProfile,
personaKey,
} from "../lib/community.svelte.js";
const STEPS = [
{ id: "welcome", label: "Welcome", icon: "sparkle" },
{ id: "alpha", label: "Alpha key", icon: "key" },
{ id: "library", label: "Your library", icon: "library" },
{ id: "soulseek", label: "Soulseek", icon: "users" },
{ id: "community", label: "Community", icon: "globe" },
{ id: "look", label: "Look & sound", icon: "eye" },
{ id: "ready", label: "Ready", icon: "check" },
];
// Album folder formats offered as one-click presets; the field beside
// them takes anything Settings would.
const PRESETS = [
["{artist}\\{album}", "Artist › Album"],
["{artist}\\{year} - {album}", "Artist › Year - Album"],
["{artist} - {album}", "Artist - Album"],
];
let step = $state(0);
let dir = $state(1);
let settings = $state(null);
let libraryDir = $state("");
let layout = $state("");
let username = $state("");
let password = $state("");
let remember = $state(true);
let freenetOn = $state(false);
let displayName = $state("");
let glass = $state(false);
let loudness = $state(true);
// The closed alpha's read-only release key (updater.rs): typed here,
// kept by the backend in the Credential Manager, never read back.
let alphaKey = $state("");
let hasToken = $state(false);
let busy = $state(false);
let error = $state("");
let closing = $state(false);
let panelEl = $state(null);
const cur = $derived(STEPS[step]);
const online = $derived(app.connection === "online");
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const dur = (ms) => (reduced() ? 0 : ms);
$effect(() => {
if (!firstRun.open) return;
step = 0;
dir = 1;
error = "";
closing = false;
password = "";
void load();
});
async function load() {
try {
settings = await api.getSettings();
libraryDir = settings.download_dir ?? "";
layout = settings.library_layout ?? "";
username = settings.username ?? "";
glass = !!settings.glass_mode;
loudness = settings.volume_normalization !== false;
} catch (e) {
error = String(e);
}
try {
await refreshFreenet();
freenetOn = !!community.freenet?.enabled;
} catch {
freenetOn = false;
}
try {
hasToken = !!(await api.updaterStatus()).has_token;
} catch {
hasToken = false;
}
await tick();
panelEl?.querySelector(".primary")?.focus();
}
async function commit(patch) {
busy = true;
error = "";
try {
await api.setSettings(patch);
return true;
} catch (e) {
error = String(e);
return false;
} finally {
busy = false;
}
}
async function pickDir() {
const dir = await api.pickFolder().catch(() => null);
if (dir) libraryDir = dir;
}
async function signIn() {
if (!username.trim() || !password) return;
await connect(username.trim(), password, remember);
}
async function setGlass(v) {
glass = v;
// Applies live, like Settings: the acrylic and the CSS thinning land
// together, and the scrim lets the change show around the panel.
if (await commit({ glassMode: v })) applyGlass(v);
}
async function setLoudness(v) {
loudness = v;
await commit({ volumeNormalization: v });
}
async function next() {
if (busy || closing) return;
if (cur.id === "alpha") {
const k = alphaKey.trim();
if (k) {
busy = true;
error = "";
try {
await api.updaterSet({ token: k });
hasToken = true;
alphaKey = "";
// The first check can start now; the toast reports it.
void api.updaterCheck().catch(() => {});
} catch (e) {
error = String(e);
} finally {
busy = false;
}
if (error) return;
}
} else if (cur.id === "library") {
const patch = {};
if (libraryDir && libraryDir !== settings?.download_dir) patch.downloadDir = libraryDir;
const lay = layout.trim();
if (lay && lay !== settings?.library_layout) patch.libraryLayout = lay;
if (Object.keys(patch).length) {
if (!(await commit(patch))) return;
if (settings) {
if (patch.downloadDir) settings.download_dir = patch.downloadDir;
if (patch.libraryLayout) settings.library_layout = patch.libraryLayout;
}
}
} else if (cur.id === "community") {
if (freenetOn !== !!community.freenet?.enabled) {
busy = true;
error = "";
try {
await setFreenetEnabled(freenetOn);
} catch (e) {
error = String(e);
} finally {
busy = false;
}
if (error) return;
}
}
if (step < STEPS.length - 1) {
dir = 1;
step += 1;
await tick();
panelEl?.querySelector(".primary")?.focus();
}
}
function back() {
if (step === 0 || busy || closing) return;
dir = -1;
step -= 1;
}
async function finish() {
if (closing) return;
closing = true;
const name = displayName.trim();
const wantsIdentity = freenetOn && name;
await finishFirstRun();
if (wantsIdentity) {
// Off the tour, best effort: the peer may still be coming up.
// Either half can be redone from Community → You.
void (async () => {
try {
await refreshPersona();
if (!personaKey()) await createPersona();
await publishProfile(name, "");
} catch {
// Community → You shows the state and the buttons
}
})();
}
}
function onKeydown(e) {
if (!firstRun.open || closing) return;
if (e.key === "Tab") return trapTab(e);
if (e.key !== "Enter" || e.shiftKey) return;
const t = e.target;
if (t instanceof HTMLButtonElement || t instanceof HTMLTextAreaElement) return;
if (t instanceof HTMLInputElement && t.form) return; // the sign-in form submits itself
e.preventDefault();
if (cur.id === "ready") finish();
else next();
}
function trapTab(e) {
if (!panelEl) return;
const focusables = [...panelEl.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')];
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
} else if (!panelEl.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if firstRun.open}
<div class="scrim" transition:fade={{ duration: dur(260) }} role="presentation">
<div
class="panel"
bind:this={panelEl}
role="dialog"
aria-modal="true"
aria-labelledby="fr-title"
in:scale={{ start: 0.96, duration: dur(460), easing: quintOut }}
out:scale={{ start: 1.03, duration: dur(320), easing: cubicOut }}
>
<aside class="rail" aria-label="Setup steps">
<div class="brand">
<img src="/logo.png" alt="" draggable="false" />
<span>SoulStream</span>
</div>
<ol>
{#each STEPS as s, i (s.id)}
<li class:on={i === step} class:done={i < step} aria-current={i === step ? "step" : undefined}>
<span class="dot">
{#if i < step}<Icon name="check" size={11} />{:else}<Icon name={s.icon} size={12} />{/if}
</span>
<span class="lab">{s.label}</span>
</li>
{/each}
</ol>
<div class="railfoot">Everything here lives in Settings too.</div>
</aside>
<div class="stage">
<div class="steps">
{#key step}
<section
class="step"
in:fly={{ x: 36 * dir, duration: dur(380), easing: quintOut, delay: dur(60) }}
out:fly={{ x: -36 * dir, duration: dur(200), easing: cubicOut }}
>
{#if cur.id === "welcome"}
<div class="welcome">
<div class="halo" in:scale={{ start: 0.6, duration: dur(760), easing: quintOut }}>
<img class="mark" src="/logo.png" alt="" draggable="false" />
</div>
<h1 id="fr-title" in:fly={{ y: 14, duration: dur(520), delay: dur(220), easing: quintOut }}>Welcome to SoulStream</h1>
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
Your library on your own disk, music from people on Soulseek, and a community
that runs on a peer you own. Four short questions and the app is yours.
</p>
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
{#each STEPS.slice(1, 6) as s (s.id)}
<li><Icon name={s.icon} size={13} /> {s.label}</li>
{/each}
</ul>
</div>
{:else if cur.id === "alpha"}
<h1 id="fr-title">Your alpha key</h1>
<p class="lede">
SoulStream is in a closed alpha. New builds come from a private repository, and
this key — one per tester, able only to read them — is how your copy finds
updates. It goes to the Windows Credential Manager, not a plain file. Without it
the app runs, but cannot update itself.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Alpha key {#if hasToken}<span class="tag ok">set</span>{/if}</div>
<div class="hint">{hasToken ? "A key is saved on this computer. Paste another to replace it, or press Next to keep it." : "Paste the key you were given. It starts with github_pat_."}</div>
</div>
<div class="ctl"><input type="password" class="mono" bind:value={alphaKey} placeholder={hasToken ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" /></div>
</div>
</div>
{:else if cur.id === "library"}
<h1 id="fr-title">Where does your music live?</h1>
<p class="lede">
Downloads land in this folder, it is always shared on Soulseek, and albums are
filed into it by the format below. Files already in it are never moved.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Library folder</div>
<div class="path">{libraryDir || "Not chosen yet"}</div>
</div>
<div class="ctl"><button class="ax-white" onclick={pickDir} disabled={busy}>Choose folder…</button></div>
</div>
<div class="row">
<div class="label">
<div class="name">Album folder format</div>
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder.</div>
<div class="presets">
{#each PRESETS as [v, l] (v)}
<button class="preset" class:on={layout.trim() === v} onclick={() => (layout = v)}>{l}</button>
{/each}
</div>
</div>
<div class="ctl"><input class="mono" bind:value={layout} spellcheck="false" autocomplete="off" aria-label="Album folder format" /></div>
</div>
</div>
{:else if cur.id === "soulseek"}
<h1 id="fr-title">Your Soulseek account</h1>
<p class="lede">
Any username and password works — a new account is created on first sign-in,
so pick something unique. The password goes to the Windows Credential Manager,
never a plain file.
</p>
{#if online}
<div class="card">
<div class="row">
<div class="label">
<div class="name">Signed in <span class="tag ok">online</span></div>
<div class="hint">As <b>{app.username}</b>. Your library folder is being shared. You can switch accounts any time in Settings → Account.</div>
</div>
</div>
</div>
{:else}
<form class="card form" onsubmit={(e) => { e.preventDefault(); signIn(); }}>
<label class="field">
<span>Username</span>
<input bind:value={username} spellcheck="false" autocomplete="off" />
</label>
<label class="field">
<span>Password</span>
<input type="password" bind:value={password} autocomplete="off" />
</label>
<label class="check"><input type="checkbox" bind:checked={remember} /> Remember me on this computer</label>
{#if app.loginError}<div class="err">{app.loginError}</div>{/if}
<div class="formfoot">
<button class="ax-white" type="submit" disabled={app.connection === "connecting" || !username.trim() || !password}>
{app.connection === "connecting" ? "Connecting…" : "Sign in"}
</button>
<span class="hint">Or press Later and sign in when you first play something.</span>
</div>
</form>
{/if}
{:else if cur.id === "community"}
<h1 id="fr-title">A community on your own peer</h1>
<p class="lede">
The social side — an identity, a profile, shared playlists, friends and Spaces —
runs on Freenet, a peer on this machine. Off by default, and nothing is ever
published until you press a button that says so.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Run the community peer <span class="tag">experimental</span></div>
<div class="hint">Uses some network, disk and CPU in the background. Turning it off later is instant and touches nothing else in the app.</div>
</div>
<div class="ctl">
<span class="tval">{freenetOn ? "on" : "off"}</span>
<button class="toggle" class:on={freenetOn} role="switch" aria-checked={freenetOn} aria-label="Run the community peer" onclick={() => (freenetOn = !freenetOn)} disabled={busy}><i></i></button>
</div>
</div>
{#if freenetOn}
<div class="row" in:fly={{ y: -6, duration: dur(220) }}>
<div class="label">
<div class="name">Display name</div>
<div class="hint">What others see on your profile. Optional — the identity is created either way, and the profile can be written from Community → You.</div>
</div>
<div class="ctl"><input bind:value={displayName} placeholder="Your name" maxlength="48" spellcheck="false" autocomplete="off" aria-label="Display name" /></div>
</div>
{/if}
</div>
{:else if cur.id === "look"}
<h1 id="fr-title">How the window is drawn</h1>
<p class="lede">Both apply as you flip them, so look at the window behind this card.</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Dark glass</div>
<div class="hint">Whatever is behind the window glows through the app's dark surfaces with a live blur (Windows acrylic). Menus and dialogs stay solid.</div>
</div>
<div class="ctl">
<span class="tval">{glass ? "dark glass" : "solid"}</span>
<button class="toggle" class:on={glass} role="switch" aria-checked={glass} aria-label="Dark glass" onclick={() => setGlass(!glass)} disabled={busy}><i></i></button>
</div>
</div>
<div class="row">
<div class="label">
<div class="name">Loudness normalization</div>
<div class="hint">Every track plays at roughly the same level (−14 LUFS). Off plays each file as recorded.</div>
</div>
<div class="ctl">
<span class="tval">{loudness ? "on" : "off"}</span>
<button class="toggle" class:on={loudness} role="switch" aria-checked={loudness} aria-label="Loudness normalization" onclick={() => setLoudness(!loudness)} disabled={busy}><i></i></button>
</div>
</div>
</div>
{:else}
<h1 id="fr-title">You're set</h1>
<p class="lede">Here is what you chose. Every line of it lives in Settings.</p>
<ul class="summary">
{#each [
["key", "Alpha key", hasToken ? "set" : "not set — updates need it"],
["folder-open", "Library", libraryDir || "not chosen"],
["users", "Soulseek", online ? `signed in as ${app.username}` : "not signed in yet"],
["globe", "Community peer", freenetOn ? (displayName.trim() ? `on · ${displayName.trim()}` : "on") : "off"],
["eye", "Window", glass ? "dark glass" : "solid"],
["volume", "Loudness", loudness ? "normalized" : "as recorded"],
] as [icon, what, value], i (what)}
<li in:fly={{ y: 10, duration: dur(360), delay: dur(120 + i * 70), easing: quintOut }}>
<span class="ic"><Icon name={icon} size={14} /></span>
<span class="what">{what}</span>
<b class:mono={what === "Library"}>{value}</b>
</li>
{/each}
</ul>
{/if}
</section>
{/key}
</div>
<footer class="foot">
{#if step > 0}
<button class="ax-ghost" onclick={back} disabled={busy || closing}>Back</button>
{/if}
<span class="spacer"></span>
{#if error}<span class="err">{error}</span>{/if}
{#if cur.id === "welcome"}
<button class="lnk" onclick={finish} disabled={closing}>Skip setup</button>
<button class="ax-fill primary" onclick={next} disabled={closing}>Let's set it up <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "ready"}
<button class="ax-fill primary big" onclick={finish} disabled={closing}>Open SoulStream</button>
{:else}
{#if (cur.id === "soulseek" && !online) || (cur.id === "alpha" && !hasToken && !alphaKey.trim())}
<button class="lnk" onclick={next} disabled={busy}>Later</button>
{/if}
<button class="ax-fill primary" onclick={next} disabled={busy || closing}>{busy ? "Saving…" : "Next"} <Icon name="chevron-right" size={14} /></button>
{/if}
</footer>
</div>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 110;
display: grid;
place-items: center;
/* A plain veil — never a backdrop-filter (dead over the frost). */
background: rgba(0, 0, 0, 0.72);
--surf: var(--bg-elevated);
--surf-hi: var(--bg-highlight);
--hair: rgba(255, 255, 255, 0.07);
--ok: #3ddc84;
}
.panel {
width: min(780px, 94vw);
height: min(560px, 92vh);
display: grid;
grid-template-columns: 212px 1fr;
background: var(--bg-elevated);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
overflow: hidden;
color: var(--text-primary);
}
/* ── the rail ─────────────────────────────────────────────────── */
.rail {
display: flex;
flex-direction: column;
background: var(--bg-base);
border-right: 1px solid var(--hair);
padding: 22px 16px 18px;
min-width: 0;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 0 6px 22px; font-size: 15px; font-weight: 900; letter-spacing: 0.01em; }
.brand img { width: 26px; height: 26px; border-radius: 7px; }
.rail ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.rail li { display: flex; align-items: center; gap: 10px; padding: 8px 8px; border-radius: 10px; color: var(--text-muted); font-size: 12.5px; font-weight: 700; transition: color 0.2s, background 0.2s; }
.rail li.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.06); }
.rail li.done { color: var(--text-secondary); }
.dot { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; transition: background 0.2s, color 0.2s; }
.rail li.on .dot { background: var(--accent); color: #000; }
.rail li.done .dot { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.railfoot { margin-top: auto; font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 0 6px; }
/* ── the stage ────────────────────────────────────────────────── */
.stage { display: grid; grid-template-rows: 1fr auto; min-width: 0; }
.steps { position: relative; display: grid; overflow: hidden; }
/* In and out share the cell so a step leaving never pushes the next. */
.steps > .step { grid-area: 1 / 1; padding: 30px 32px 8px; overflow: auto; min-height: 0; }
h1 { font-size: 22px; font-weight: 900; letter-spacing: -0.01em; margin: 0 0 8px; line-height: 1.15; }
.lede { font-size: 13px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 18px; max-width: 58ch; }
.welcome { display: flex; flex-direction: column; align-items: center; text-align: center; padding-top: 14px; }
.welcome h1 { font-size: 26px; margin-top: 18px; }
.welcome .lede { max-width: 46ch; }
.halo {
position: relative;
width: 108px;
height: 108px;
display: grid;
place-items: center;
}
.halo::before {
content: "";
position: absolute;
inset: -18px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 0, color-mix(in srgb, var(--accent) 18%, transparent) 38%, transparent 70%);
animation: breathe 3.8s ease-in-out infinite;
}
.mark { position: relative; width: 84px; height: 84px; border-radius: 22px; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.1); opacity: 1; }
}
.pills { list-style: none; margin: 4px 0 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.pills li { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 99px; background: var(--surf-hi); font-size: 12px; font-weight: 800; color: var(--text-secondary); }
/* Settings' own card vocabulary, so the tour and the page read as one. */
.card { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--hair); }
.card > .row:first-child { border-top: none; }
.label { min-width: 0; flex: 1; }
.name { font-size: 13.5px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hint { font-size: 12px; color: var(--text-secondary); margin-top: 3px; line-height: 1.45; max-width: 60ch; }
.hint b { color: var(--text-primary); font-weight: 700; }
.path { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); word-break: break-all; margin-top: 3px; }
.ctl { flex: none; display: flex; align-items: center; gap: 8px; }
.tag { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 99px; background: var(--accent-soft); color: var(--accent-hover); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.tag.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
.tval { font-size: 12px; font-weight: 800; color: var(--text-secondary); min-width: 62px; text-align: right; }
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
.toggle.on { background: var(--accent); }
.toggle.on i { left: 20px; background: #000; }
.toggle:disabled { opacity: 0.45; cursor: default; }
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.preset { padding: 4px 10px; border-radius: 99px; font-size: 11.5px; font-weight: 800; background: var(--surf-hi); color: var(--text-secondary); }
.preset:hover { color: #fff; }
.preset.on { background: var(--accent); color: #000; }
input { height: 32px; padding: 0 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-primary); font: inherit; font-size: 13px; min-width: 0; }
input:focus { outline: none; border-color: var(--accent); }
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
.form { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
.field { display: flex; flex-direction: column; gap: 5px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
.field input { width: 100%; max-width: 360px; }
.check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-secondary); }
.check input { height: auto; accent-color: var(--accent); }
.formfoot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.err { font-size: 12px; color: #ff8d98; }
.summary { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.summary li { display: grid; grid-template-columns: 28px 130px 1fr; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px; background: var(--surf); border: 1px solid var(--hair); font-size: 13px; }
.summary .ic { display: grid; place-items: center; color: var(--accent-hover); }
.summary .what { color: var(--text-secondary); font-weight: 700; }
.summary b { font-weight: 800; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary b.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; font-weight: 600; }
/* ── the footer ───────────────────────────────────────────────── */
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
.spacer { flex: 1; }
.lnk { color: var(--text-muted); font-weight: 700; font-size: 12px; text-decoration: underline; text-underline-offset: 2px; padding: 0 6px; }
.lnk:hover:not(:disabled) { color: var(--text-primary); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; transition: background 0.18s, transform 0.15s; }
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.ax-fill.big { height: 38px; padding: 0 22px; font-size: 13.5px; }
.ax-white { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: #fff; color: #000; white-space: nowrap; transition: transform 0.15s; }
.ax-white:hover:not(:disabled) { transform: scale(1.03); }
.ax-white:disabled { opacity: 0.55; cursor: default; }
@media (prefers-reduced-motion: reduce) {
.halo::before { animation: none; }
}
</style>