525 lines
30 KiB
Svelte
525 lines
30 KiB
Svelte
<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>
|