first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again
This commit is contained in:
1 parent
07810f7543
commit
cd2391198e
19 files changed
+2010
-15
No files matched your search
@@ -0,0 +1,586 @@
|
||||
<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: "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);
|
||||
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;
|
||||
}
|
||||
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 === "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, 5) as s (s.id)}
|
||||
<li><Icon name={s.icon} size={13} /> {s.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</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 [
|
||||
["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}
|
||||
<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>
|
||||
Reference in new issue
Block a user