Files
SoulStream/setup/src/Setup.svelte
T

525 lines
30 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>
// SoulStream Setup: the first-run tour's design, standing in for the
// Windows installer. Welcome → the alpha key (the gate: verified against
// GitHub, nothing installs without one) → the music folder → the look →
// Install (the latest release downloaded with the key, its signature
// checked, the NSIS installer run silently; phases from the backend) →
// Done, Open SoulStream. Every choice is written into the app's config
// before the install runs, the key into the Credential Manager, so the
// app opens straight into itself.
import { onMount, tick } from "svelte";
import { fly, scale } from "svelte/transition";
import { cubicOut, quintOut } from "svelte/easing";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import Icon from "../../src/components/Icon.svelte";
const STEPS = [
{ id: "welcome", label: "Welcome", icon: "sparkle" },
{ id: "key", label: "Alpha key", icon: "key" },
{ id: "library", label: "Your library", icon: "library" },
{ id: "look", label: "Look & sound", icon: "eye" },
{ id: "install", label: "Install", icon: "download" },
{ id: "done", label: "Ready", icon: "check" },
];
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 d = $state(null);
let alphaKey = $state("");
let hasToken = $state(false);
// What the key can see: set only by a successful check, cleared by a failed one.
let keyOk = $state(null);
let verifying = $state(false);
let libraryDir = $state("");
let layout = $state("");
let glass = $state(false);
let loudness = $state(true);
let busy = $state(false);
let error = $state("");
let phase = $state({ phase: "", detail: "", received: 0, total: 0 });
const pct = $derived(phase.total > 0 ? Math.min(100, Math.round((phase.received / phase.total) * 100)) : null);
let installedAt = $state("");
let panelEl = $state(null);
const cur = $derived(STEPS[step]);
const installing = $derived(cur.id === "install" && phase.phase !== "done" && phase.phase !== "failed");
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const dur = (ms) => (reduced() ? 0 : ms);
onMount(async () => {
try {
d = await invoke("defaults");
libraryDir = d.library_dir;
layout = d.library_layout;
glass = !!d.glass_mode;
loudness = d.loudness !== false;
hasToken = !!d.has_token;
} catch (e) {
error = String(e);
}
if (hasToken) {
verifying = true;
try {
keyOk = await invoke("check_saved_key");
} catch (e) {
hasToken = false;
keyOk = null;
error = String(e);
} finally {
verifying = false;
}
}
const un = await listen("setup:phase", (e) => {
phase = e.payload ?? { phase: "", detail: "", received: 0, total: 0 };
if (phase.phase === "done") {
installedAt = phase.detail;
go(1);
} else if (phase.phase === "failed") {
error = phase.detail;
}
});
await tick();
panelEl?.querySelector(".primary")?.focus();
return () => un();
});
async function go(delta) {
dir = delta;
step = Math.max(0, Math.min(STEPS.length - 1, step + delta));
await tick();
panelEl?.querySelector(".primary")?.focus();
}
async function verify() {
const k = alphaKey.trim();
if (!k || verifying) return;
verifying = true;
error = "";
try {
keyOk = await invoke("verify_key", { token: k });
hasToken = true;
alphaKey = "";
} catch (e) {
keyOk = null;
error = String(e);
} finally {
verifying = false;
}
}
async function pickDir() {
const p = await invoke("pick_folder").catch(() => null);
if (p) libraryDir = p;
}
async function next() {
if (busy || installing) return;
error = "";
if (cur.id === "key") {
if (alphaKey.trim() && !keyOk) {
await verify();
if (!keyOk) return;
}
if (!keyOk) {
error = "An accepted alpha key is needed to install.";
return;
}
}
if (cur.id === "look") {
// The choices land first, then the installer runs.
busy = true;
try {
await invoke("write_config", { libraryDir, libraryLayout: layout, glassMode: glass, loudness });
} catch (e) {
error = String(e);
} finally {
busy = false;
}
if (error) return;
await go(1);
phase = { phase: "resolving", detail: "", received: 0, total: 0 };
void invoke("install").catch((e) => {
error = String(e);
phase = { phase: "failed", detail: String(e), received: 0, total: 0 };
});
return;
}
if (cur.id === "done") return launch();
await go(1);
}
function back() {
if (step === 0 || busy || installing || cur.id === "done") return;
go(-1);
}
async function launch() {
try {
await invoke("launch");
} catch (e) {
error = String(e);
}
}
function quit() {
void invoke("quit");
}
function onKeydown(e) {
if (e.key !== "Enter" || e.shiftKey) return;
const t = e.target;
if (t instanceof HTMLButtonElement) return;
e.preventDefault();
next();
}
const win = () => getCurrentWindow();
</script>
<svelte:window onkeydown={onKeydown} />
<div class="panel" bind:this={panelEl} role="dialog" aria-modal="true" aria-labelledby="fr-title">
<div class="chrome" data-tauri-drag-region>
<span class="wtitle" data-tauri-drag-region>SoulStream Setup</span>
<button class="wbtn" onclick={() => win().minimize()} title="Minimize" aria-label="Minimize"><Icon name="minus" size={14} /></button>
<button class="wbtn x" onclick={() => (installing ? null : win().close())} title="Close" aria-label="Close" disabled={installing}><Icon name="x" size={14} /></button>
</div>
<aside class="rail" data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
<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">{d ? `SoulStream ${d.version}` : ""}<br />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 }}>
{d?.installed_version ? "Set SoulStream up again" : "Welcome to SoulStream"}
</h1>
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
{#if d?.installed_version}
SoulStream {d.installed_version} is already on this computer. This installs
the latest release in its place and keeps your music and settings. About a minute,
no admin prompt.
{:else}
Your library on your own disk, music from people on Soulseek, and a community
that runs on a peer you own. Your alpha key, two short questions, then about a
minute to download and install — no admin prompt, and your music never lives
inside the program.
{/if}
</p>
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
{#each STEPS.slice(1, 4) as s (s.id)}
<li><Icon name={s.icon} size={13} /> {s.label}</li>
{/each}
</ul>
</div>
{:else if cur.id === "key"}
<h1 id="fr-title">Your alpha key</h1>
<p class="lede">
SoulStream is in a closed alpha. This key — one per tester, able only to read the
private repository — is how Setup fetches the app and how the app finds updates
later. It goes to the Windows Credential Manager, not a plain file. Nothing installs
without it.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Alpha key {#if keyOk}<span class="tag ok">accepted</span>{:else if verifying}<span class="tag">checking…</span>{/if}</div>
<div class="hint">
{#if keyOk}
SoulStream <b>{keyOk.version}</b> is what it will install{#if keyOk.installer_size} — {(keyOk.installer_size / 1e6).toFixed(1)} MB{/if}. Paste another key to replace it.
{:else}
Paste the key you were given (it starts with github_pat_) and press Verify. Setup asks GitHub whether the key can read the release.
{/if}
</div>
</div>
<div class="ctl">
<input type="password" class="mono" bind:value={alphaKey} placeholder={keyOk ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); verify(); } }} />
<button class="ax-white" onclick={verify} disabled={verifying || !alphaKey.trim()}>{verifying ? "Checking…" : "Verify"}</button>
</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 === "look"}
<h1 id="fr-title">How the window is drawn</h1>
<p class="lede">Both can be changed any time in Settings.</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={() => (glass = !glass)}><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={() => (loudness = !loudness)}><i></i></button>
</div>
</div>
</div>
{:else if cur.id === "install"}
<h1 id="fr-title">{phase.phase === "failed" ? "That did not go through" : "Installing SoulStream"}</h1>
<p class="lede">
{#if phase.phase === "failed"}
{phase.detail}
{:else if phase.phase === "downloading"}
Downloading {phase.detail}{pct != null ? ` · ${pct}%` : "…"}
{:else if phase.phase === "verifying"}
{phase.detail}
{:else if phase.phase === "installing"}
{phase.detail} Nothing to click; this takes a moment.
{:else}
{phase.detail || "Starting…"}
{/if}
</p>
<div class="progress" class:failed={phase.phase === "failed"} class:busy={pct == null && phase.phase !== "failed"} aria-hidden="true"><i style={pct != null ? `width: ${pct}%` : ""}></i></div>
<ul class="summary">
<li><span class="ic"><Icon name="key" size={14} /></span><span class="what">Alpha key</span><b>{keyOk ? `accepted · SoulStream ${keyOk.version}` : "not set"}</b></li>
<li><span class="ic"><Icon name="folder-open" size={14} /></span><span class="what">Library</span><b class="mono">{libraryDir}</b></li>
<li><span class="ic"><Icon name="eye" size={14} /></span><span class="what">Window</span><b>{glass ? "dark glass" : "solid"}</b></li>
</ul>
{:else}
<h1 id="fr-title">SoulStream is ready</h1>
<p class="lede">Installed to <span class="mono">{installedAt}</span>. It lives in your Start menu now; your music stays where you chose.</p>
<ul class="summary">
{#each [
["key", "Alpha key", keyOk ? "accepted" : "set"],
["folder-open", "Library", libraryDir],
["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 && cur.id !== "done" && !installing}
<button class="ax-ghost" onclick={back} disabled={busy}>Back</button>
{/if}
<span class="spacer"></span>
{#if error && cur.id !== "install"}<span class="err">{error}</span>{/if}
{#if cur.id === "welcome"}
<button class="ax-fill primary" onclick={next} disabled={!d}>Let's set it up <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "key"}
<button class="ax-fill primary" onclick={next} disabled={busy || verifying || !keyOk}>Next <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "look"}
<button class="ax-fill primary big" onclick={next} disabled={busy}><Icon name="download" size={13} /> {busy ? "Saving…" : "Install SoulStream"}</button>
{:else if cur.id === "install"}
{#if phase.phase === "failed"}
<button class="ax-ghost" onclick={quit}>Close</button>
<button class="ax-fill primary" onclick={() => { error = ""; phase = { phase: "resolving", detail: "", received: 0, total: 0 }; void invoke("install").catch((e) => { error = String(e); phase = { phase: "failed", detail: String(e), received: 0, total: 0 }; }); }}>Try again</button>
{/if}
{:else if cur.id === "done"}
<button class="lnk" onclick={quit}>Close</button>
<button class="ax-fill primary big" onclick={launch}>Open SoulStream</button>
{:else}
<button class="ax-fill primary" onclick={next} disabled={busy}>Next <Icon name="chevron-right" size={14} /></button>
{/if}
</footer>
</div>
</div>
<style>
:global(html, body) { height: 100%; margin: 0; background: #121212; overflow: hidden; }
:global(#app) { height: 100%; }
.panel {
height: 100vh;
display: grid;
grid-template-columns: 212px 1fr;
grid-template-rows: 30px 1fr;
background: var(--bg-elevated);
color: var(--text-primary);
--surf: var(--bg-elevated);
--surf-hi: var(--bg-highlight);
--hair: rgba(255, 255, 255, 0.07);
--ok: #3ddc84;
user-select: none;
}
/* ── our own title strip ──────────────────────────────────────── */
.chrome {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 2px;
padding: 0 4px 0 14px;
background: var(--bg-base);
border-bottom: 1px solid var(--hair);
}
.wtitle { flex: 1; font-size: 11.5px; font-weight: 800; color: var(--text-muted); letter-spacing: 0.02em; }
.wbtn { width: 34px; height: 24px; border-radius: 6px; display: grid; place-items: center; color: var(--text-secondary); }
.wbtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
.wbtn.x:hover:not(:disabled) { background: #c42b1c; color: #fff; }
.wbtn:disabled { opacity: 0.35; }
/* ── 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; }
.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; }
.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
.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: 48ch; }
.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); }
.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; }
.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; }
.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; user-select: text; }
input:focus { outline: none; border-color: var(--accent); }
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
.progress { height: 6px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin: 6px 0 18px; max-width: 420px; }
.progress i { display: block; height: 100%; width: 0; border-radius: 99px; background: var(--accent); transition: width 0.2s; }
.progress.busy i { width: 38%; animation: slide 1.4s ease-in-out infinite; }
.progress.failed i { animation: none; width: 100%; background: #ff8d98; }
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(280%); }
}
.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-weight: 600; }
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
.spacer { flex: 1; }
.err { font-size: 12px; color: #ff8d98; max-width: 40ch; }
.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; }
.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, .progress i { animation: none; }
}
</style>