android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy

The editor the desktop drives with a mouse is better under a thumb: the
song plays, the line to time sits large in the middle of the screen with
the one behind and the one ahead faint around it, and a tap anywhere
stamps it. One layer the back gesture peels, and a set left mid-tap is
offered back by the row that opened it; Save writes the track's own
document, lines never tapped left out of it and the button saying how
many are kept. The lyrics sheet also gains the per-track auto-sync switch
and the desktop's reduced-vocals copy — the same title, artist and
duration, so the words keep following it, and one row puts the original
back.
This commit is contained in:
KeelrLeek committed 2026-09-13 23:04:49 -05:00
1 parent e693dfb868
commit 24c6857847
9 files changed
+434 -11

No files matched your search

+17 -1
View File
@@ -124,12 +124,14 @@ function plTouch(p) {
setTimeout(() => mockEmit("soulseek:playlists-changed", {}), 30);
}
const plOf = (args) => PL.find((p) => p.id === Number(args?.id ?? args?.playlistId));
/* the per-track auto-sync switch the lyrics sheet flips */
let autoSyncOff = false;
/* every track has three timed lines; the sheet's tools have something to act on */
function mockLyrics(title) {
return {
status: "synced", source: "lrclib",
synced: [{ time_ms: 0, text: "Here is " + (title || "a song") }, { time_ms: 4000, text: "Every word in time" }, { time_ms: 9000, text: "And the last one, late" }],
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false,
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: autoSyncOff,
};
}
function mockPlaylists(cmd, args) {
@@ -714,6 +716,20 @@ function mockNet(cmd, args) {
const synced = timed.every(Boolean);
return Promise.resolve({ status: synced ? "synced" : "plain", source: "pasted", synced: synced ? timed : [], plain: synced ? null : lines.join("\n"), offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false });
}
if (cmd === "lyrics_set_auto_sync") { autoSyncOff = !args?.enabled; return Promise.resolve(); }
if (cmd === "lyrics_save_lines") {
/* the Rust drops the empty lines and sorts by time — a half-tapped
set is stored as the lines that HAVE one */
const lines = (args?.lines || []).filter((l) => String(l?.text || "").trim())
.map((l) => ({ time_ms: l.time_ms, text: l.text })).sort((a, b) => a.time_ms - b.time_ms);
if (!lines.length) return Promise.reject(new Error("Nothing to save"));
/* the Rust's payload_from does NOT carry the entry's auto-sync flag
back from a write — false here, faithfully, so the walk notices if
the store ever applies a save over the switch */
return Promise.resolve({ status: "synced", source: "user", synced: lines, plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false });
}
/* the vocal-reduced copy: a render takes a beat, and it is a new file */
if (cmd === "lyrics_prepare_instrumental") return new Promise((r) => setTimeout(() => r({ path: String(args?.path || "") + ".instrumental.wav", cached: false, durationSecs: 214 }), 400));
if (cmd === "lyrics_clear") return Promise.resolve();
if (cmd === "clear_play_history") return Promise.resolve();
if (cmd === "refresh_album_art") return new Promise((r) => setTimeout(() => r("C:\\mock\\cover.jpg"), 200));
+21 -1
View File
@@ -16,7 +16,7 @@
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js";
import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
@@ -69,6 +69,11 @@
}
return null;
});
/* the lyrics sheet: a tap-sync set left mid-tap for the playing track,
and whether a vocal-reduced copy is what is playing */
const tapLeft = $derived(kind === "lyrics" ? tapProgress() : null);
const canReduce = $derived(!!player.track?.path && !player.track?.stream);
const onReduced = $derived(!!lyr.reduced && player.track?.path === lyr.reduced.to);
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
@@ -281,6 +286,14 @@
</button>
{/snippet}
{#snippet swrow(icon, t, m, on, fn)}
<button class="row" onclick={fn}>
<span class="lead"><Icon name={icon} size={20} /></span>
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
<span class="sw" class:on><span></span></span>
</button>
{/snippet}
<div class="halfsheet" class:up={!!nav.half}>
<div class="grab"></div>
<div class="hs-body">
@@ -621,10 +634,17 @@
{#if lyr.lines.length}
{@render row("skip-back", "Lyrics earlier", "half a second, kept for this track", () => nudge(-500))}
{@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}
{@render swrow("history", "Auto-sync this track", lyr.autoOffset ? `correcting by ${lyr.autoOffset > 0 ? "+" : ""}${(lyr.autoOffset / 1000).toFixed(1)} s` : "let the app correct this track's timing", !lyr.autoSyncOff, () => { void setAutoSync(lyr.autoSyncOff); })}
<div class="sheet-hair"></div>
{/if}
{@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
{@render row("file-text", "Paste lyrics…", "plain text, or LRC with [mm:ss] timestamps", () => openHalf("prompt", { title: "Paste lyrics", placeholder: "[00:12.50] The first line…", ok: "Save", multiline: true, onsubmit: (v) => { void pasteLyrics(v); } }))}
{#if lyr.lines.length || lyr.plain}
{@render row("clock", lyr.lines.length ? "Re-time by hand" : "Sync these lyrics", tapLeft ? `${tapLeft.done} of ${tapLeft.total} lines timed — keep going` : "play the song and tap as each line begins", act(openTapSync))}
{/if}
{#if canReduce}
{@render row("music", onReduced ? "Back to the original" : "Play with reduced vocals", onReduced ? "the recording as it is" : lyr.reducing ? "making the copy now — a moment" : "a rough centre cancel, made on this phone", act(() => { void (onReduced ? backToOriginal() : reduceVocals()); }))}
{/if}
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
</div>
{/if}
+2
View File
@@ -16,6 +16,7 @@
import Sheet from "./Sheet.svelte";
import HalfSheet from "./HalfSheet.svelte";
import Viewer from "./Viewer.svelte";
import SyncRoom from "./SyncRoom.svelte";
import Home from "./screens/Home.svelte";
import Search from "./screens/Search.svelte";
import Library from "./screens/Library.svelte";
@@ -306,6 +307,7 @@
<HalfSheet />
<Sheet />
<Viewer />
<SyncRoom />
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
+110
View File
@@ -0,0 +1,110 @@
<script>
/* The tap-to-sync room — the desktop's correction editor
(LyricsSyncEditor.svelte) in the phone's idiom. The song plays and you
tap once as each line begins; the taps become the timing. One line
large, the line behind and the line ahead faint around it, and the
whole middle of the screen is the tap. Saving writes the track's own
document; lines never tapped are left out of it, and the button says
how many are kept. */
import Icon from "../components/Icon.svelte";
import { nav } from "./nav.svelte.js";
import { player, toggle, seekSecs } from "./player.svelte.js";
import { tapSync, saveTapSync, closeTapSync } from "./lyrics.svelte.js";
import { mmss } from "./util.js";
/* the ✕ asks twice once there is work to lose; the back gesture keeps
the taps instead (the lyrics sheet offers them back). The room is
mounted for the life of the shell, so the question is put down when
it closes — otherwise the next visit opens already asking. */
let confirmDiscard = $state(false);
$effect(() => { if (!nav.timing) confirmDiscard = false; });
/* position anchors land a couple of times a second, so a tap between
them reads the last anchor plus the wall clock — the stamp carries
the tap's own precision rather than the tick's. Plain lets, never
read back inside the effect that writes them. */
let lastPos = 0;
let lastAt = 0;
$effect(() => {
void player.status;
lastPos = player.position ?? 0;
lastAt = performance.now();
});
function nowMs() {
const base = player.status === "playing"
? lastPos + (performance.now() - lastAt) / 1000
: (player.position ?? 0);
return Math.max(0, Math.round(base * 1000));
}
const rows = $derived(tapSync.rows);
const done = $derived(rows.filter((r) => r.time_ms != null).length);
const at = $derived(Math.min(tapSync.cursor, rows.length));
const playing = $derived(player.status === "playing" || player.status === "buffering");
/* playback moved on: a stamp would land on another song's clock */
const wrong = $derived(!!tapSync.identity && !!player.track && player.track.title !== tapSync.identity.title);
function stamp() {
if (wrong || tapSync.saving || at >= rows.length) return;
tapSync.rows[at].time_ms = nowMs();
tapSync.cursor = at + 1;
confirmDiscard = false;
}
function undo() {
const last = tapSync.cursor - 1;
if (last < 0) return;
tapSync.rows[last].time_ms = null;
tapSync.cursor = last;
}
function back5() { seekSecs(Math.max(0, (player.position ?? 0) - 5)); }
function close() {
if (done > 0 && !confirmDiscard) { confirmDiscard = true; return; }
closeTapSync();
}
/* a stamp reads to a tenth — the precision a thumb actually has */
function mark(ms) {
if (ms == null) return "";
return `${mmss(Math.floor(ms / 1000))}.${Math.floor((ms % 1000) / 100)}`;
}
</script>
{#if nav.timing}
<div class="syncroom">
<div class="sy-head">
<button class="iconbtn sy-close" onclick={close} aria-label={confirmDiscard ? "Discard the timing" : "Close"}>
{#if confirmDiscard}<span class="sy-discard">Discard?</span>{:else}<Icon name="x" size={22} />{/if}
</button>
<span class="sy-who"><b>Tap in time</b><i>{tapSync.identity?.title ?? ""}</i></span>
<span class="sy-count">{done} / {rows.length}</span>
</div>
{#if wrong}
<div class="sy-warn">Another track is playing — a tap follows what plays. Play “{tapSync.identity?.title}” again to keep going.</div>
{:else if tapSync.error}
<div class="sy-warn">{tapSync.error}</div>
{/if}
<button class="sy-pad" onclick={stamp} disabled={wrong}>
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
{#if at < rows.length}
<span class="sy-now">{rows[at].text}</span>
<span class="sy-next">{at + 1 < rows.length ? rows[at + 1].text : ""}</span>
<span class="sy-hint">Tap anywhere as the line begins</span>
{:else}
<span class="sy-now">That’s every line — save the timing.</span>
<span class="sy-next"></span>
<span class="sy-hint">Undo puts the last one back</span>
{/if}
<span class="sy-mark">{at > 0 ? mark(rows[at - 1].time_ms) : mmss(player.position)}</span>
</button>
<div class="sy-foot">
<button class="iconbtn sy-undo" onclick={undo} disabled={tapSync.cursor === 0} aria-label="Undo the last tap"><Icon name="history" size={22} /></button>
<button class="iconbtn sy-back5" onclick={back5} aria-label="Back five seconds">−5 s</button>
<button class="disc" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={22} /></button>
<button class="btn btn-fill sy-save" onclick={() => { void saveTapSync(); }} disabled={!done || tapSync.saving}>
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : "Save timing")}
</button>
</div>
</div>
{/if}
+163 -4
View File
@@ -4,7 +4,9 @@
lookup forgotten and done again. One store, read by the player sheet's
lyrics face and by the lyrics sheet. */
import * as api from "../lib/api.js";
import { player } from "./player.svelte.js";
import { player, current, playQueueAt } from "./player.svelte.js";
import { note } from "./notice.svelte.js";
import { openTiming, closeTiming } from "./nav.svelte.js";
export const lyr = $state({
key: null, // the track's path — the identity the words belong to
@@ -17,6 +19,9 @@ export const lyr = $state({
autoOffset: 0,
candidates: null, // LRCLIB candidates while choosing, else null
searching: false,
autoSyncOff: false, // the user turned the automatic correction off here
reducing: false, // a vocal-reduced copy is being made
reduced: null, // { from, to } once one has been made for this track
});
let gen = 0;
@@ -32,13 +37,14 @@ export function identity() {
key: t.path,
};
}
function apply(p) {
function apply(p, full = false) {
lyr.status = p?.status || "none";
lyr.source = p?.source || "";
lyr.lines = p?.synced || [];
lyr.plain = p?.plain || "";
lyr.userOffset = p?.offset_ms || 0;
lyr.autoOffset = p?.auto_offset_ms || 0;
if (full) lyr.autoSyncOff = !!p?.auto_sync_off;
lyr.offset = lyr.userOffset + lyr.autoOffset;
}
/** The words for the playing track; `force` asks the network again. */
@@ -47,11 +53,11 @@ export async function loadLyrics(force = false) {
if (!id) return;
if (!force && lyr.key === id.key) return;
const g = ++gen;
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, candidates: null, searching: false });
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, autoSyncOff: false, candidates: null, searching: false });
try {
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: force ? true : null });
if (g !== gen) return;
apply(p);
apply(p, true);
} catch {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" });
}
@@ -120,3 +126,156 @@ export async function pasteLyrics(text) {
return false;
}
}
/** Re-read the payload for the loaded track without emptying the panel —
* a switch flips and the words stay on screen. The cache only, never the
* network. */
async function reread() {
const id = identity();
if (!id) return;
const g = gen;
try {
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: null });
if (g === gen) apply(p, true);
} catch { /* what is showing stands */ }
}
/** The app's automatic timing correction, on or off for this track. The
* switch flips at once; the backend's own number comes with the re-read. */
export async function setAutoSync(enabled) {
const id = identity();
if (!id) return;
const before = lyr.autoSyncOff;
lyr.autoSyncOff = !enabled;
try {
await api.lyricsSetAutoSync(id.title, id.artist, id.durationSecs, enabled);
} catch (e) {
lyr.autoSyncOff = before;
player.error = String(e);
return;
}
await reread();
}
/* ── the vocal-reduced copy ──────────────────────────────────────────────
The desktop's offline DSP: a band-limited centre cancel, not a learned
separator, and the wording stays honest about that. The copy plays under
the SAME title, artist and duration, so the lyric identity is still the
recording's and the words keep following it. The track starts again from
the top — there is no seeking a file the engine has only just been given. */
async function swapPath(e, to) {
/* a COPY of the entry's track, never the row itself: tracksOf caches one
array per record and hands the SAME objects to the record page and to
the queue, so writing the path in place would leave the record pointing
at a cache file the LRU drops out from under it. The desktop does not
rewrite its queue item either — it plays the copy as its own thing. */
e.t = { ...e.t, path: to };
await playQueueAt(player.qi);
}
export async function reduceVocals() {
const e = current();
const t = e?.t;
if (!t?.path || t.stream || lyr.reducing) return false;
const from = t.path;
lyr.reducing = true;
note("Reducing the vocals — this can take a moment");
try {
const res = await api.lyricsPrepareInstrumental(from);
/* the render takes seconds: starting the OLD track's copy now would
hijack whatever the queue has moved on to */
const now = current();
if (!res?.path || now?.t?.path !== from) {
note("The track changed while preparing — try again");
return false;
}
lyr.reduced = { from, to: res.path };
await swapPath(now, res.path);
return true;
} catch (err) {
player.error = String(err);
return false;
} finally {
lyr.reducing = false;
}
}
export async function backToOriginal() {
const r = lyr.reduced;
const e = current();
if (!r || e?.t?.path !== r.to) return false;
await swapPath(e, r.from);
lyr.reduced = null;
return true;
}
/* ── the tap-to-sync room ────────────────────────────────────────────────
The song plays and one tap lands per line; the taps ARE the timing.
Saving writes the track's own document (lyrics_save_lines), the top of
the waterfall, the same shelf a paste lands on — and the backend drops
every line without a time, so a half-tapped set is stored as the lines
that have one. */
export const tapSync = $state({
rows: [], // [{ time_ms: number|null, text }]
cursor: 0, // the row the next tap stamps
identity: null, // the track the rows belong to, captured at open
saving: false,
error: "",
});
const plainLines = (text) => String(text ?? "").split("\n").map((l) => l.trim())
.filter((l) => l && !/^\[.*\]$/.test(l));
/** Open the room for the playing track. An already-timed set is re-tapped
* from scratch: the room shows three lines, not a list, so it has no
* per-line handle to nudge — it has the tap. */
export function openTapSync() {
const id = identity();
if (!id) return;
/* peeled by the back gesture mid-tap: the same track's stamps come back
rather than the room starting over */
if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { openTiming(); return; }
const texts = (lyr.lines.length ? lyr.lines.map((l) => l.text) : plainLines(lyr.plain))
.filter((t) => String(t || "").trim());
if (!texts.length) return;
tapSync.rows = texts.map((text) => ({ time_ms: null, text }));
tapSync.cursor = 0;
tapSync.identity = id;
tapSync.saving = false;
tapSync.error = "";
openTiming();
}
export function closeTapSync() {
closeTiming();
tapSync.rows = [];
tapSync.cursor = 0;
tapSync.identity = null;
tapSync.saving = false;
tapSync.error = "";
}
/** What the lyrics sheet says under the row when a set was left mid-tap. */
export function tapProgress() {
const id = identity();
if (!id || tapSync.identity?.key !== id.key) return null;
const done = tapSync.rows.filter((r) => r.time_ms != null).length;
return done ? { done, total: tapSync.rows.length } : null;
}
/** The taps, as this track's own timed document. */
export async function saveTapSync() {
const id = tapSync.identity;
const rows = tapSync.rows.filter((r) => r.time_ms != null && r.text.trim());
if (!id || !rows.length || tapSync.saving) return false;
tapSync.saving = true;
tapSync.error = "";
try {
const p = await api.lyricsSaveLines(id.title, id.artist, id.durationSecs,
rows.map((r) => ({ time_ms: Math.max(0, Math.round(r.time_ms)), text: r.text })));
/* the same track is still playing: show the result at once */
if (identity()?.key === id.key && p && p.status) apply(p);
closeTapSync();
return true;
} catch (e) {
tapSync.error = String(e).replace(/^Error:\s*/, "").slice(0, 140);
return false;
} finally {
tapSync.saving = false;
}
}
+17 -3
View File
@@ -37,6 +37,8 @@ export const nav = $state({
/* the artwork viewer, and the art key it shows */
viewer: false,
viewerKey: null,
/* the tap-to-sync room — over everything, peeled first */
timing: false,
});
/** Where the container transform starts — the mini bar's art, measured the
@@ -57,7 +59,7 @@ export function isRoot() {
}
function backables() {
return (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
return (nav.timing ? 1 : 0) + (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
}
@@ -96,7 +98,8 @@ export function bootNav() {
window.addEventListener("popstate", () => {
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
depth = Math.max(0, depth - 1);
if (nav.viewer) nav.viewer = false;
if (nav.timing) nav.timing = false;
else if (nav.viewer) nav.viewer = false;
else if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
@@ -149,7 +152,8 @@ export function push(n, c = {}, screenEl = null) {
/** The in-app back chevron. Same order as the system gesture. */
export function back() {
if (nav.viewer) nav.viewer = false;
if (nav.timing) nav.timing = false;
else if (nav.viewer) nav.viewer = false;
else if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
@@ -200,6 +204,16 @@ export function closeViewer() {
nav.viewer = false;
sync();
}
export function openTiming() {
if (nav.timing) return;
nav.timing = true;
sync();
}
export function closeTiming() {
if (!nav.timing) return;
nav.timing = false;
sync();
}
export function openHalf(kind, arg = null) {
nav.half = { kind, arg };
sync();
+22
View File
@@ -1393,3 +1393,25 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
.tastechips { flex-wrap: wrap; overflow: visible; }
.tastechips .tchip svg { opacity: 0.62; margin-right: -3px; }
.tastechips .chip:disabled { opacity: 0.5; }
/* ── the tap-to-sync room: the song plays, the screen is the tap ───────── */
.syncroom { position: fixed; inset: 0; z-index: 150; display: flex; flex-direction: column; background: radial-gradient(120% 70% at 20% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 58%), var(--bg-base); padding-top: var(--inset-top); padding-bottom: var(--inset-bottom); animation: viewer-in 160ms ease both; }
.sy-head { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 2px; flex: none; }
.sy-close { width: auto; min-width: var(--tap); padding: 0 10px; border-radius: var(--r-pill); }
.sy-discard { font-size: 12.5px; font-weight: 800; color: #f15e6c; }
.sy-who { flex: 1; min-width: 0; text-align: center; }
.sy-who b { display: block; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--accent-hover); }
.sy-who i { display: block; font-style: normal; font-size: 13px; font-weight: 700; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sy-count { flex: none; min-width: 54px; text-align: right; font-size: 13px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.sy-warn { margin: 4px var(--pad) 0; padding: 9px 13px; border-radius: 10px; background: rgba(232, 163, 61, 0.14); color: #e8a33d; font-size: 12.5px; font-weight: 700; flex: none; }
.sy-pad { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px var(--pad); text-align: center; }
.sy-pad:active { background: rgba(255, 255, 255, 0.05); }
.sy-pad:disabled { opacity: 0.5; }
.sy-prev, .sy-next { display: block; width: 100%; min-height: 20px; font-size: 15px; font-weight: 650; line-height: 1.35; color: rgba(255, 255, 255, 0.3); overflow: hidden; }
.sy-now { display: block; width: 100%; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.25; color: #fff; text-shadow: 0 0 26px color-mix(in srgb, var(--accent) 50%, transparent); }
.sy-hint { display: block; margin-top: 6px; font-size: 12px; font-weight: 650; color: var(--text-muted); }
.sy-mark { display: block; font-size: 13px; font-weight: 800; color: var(--accent-hover); font-variant-numeric: tabular-nums; }
.sy-foot { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 16px; flex: none; }
.sy-foot .iconbtn { width: auto; min-width: var(--tap); padding: 0 12px; border-radius: var(--r-pill); font-size: 13px; font-weight: 750; }
.sy-foot .iconbtn:disabled { opacity: 0.4; }
.sy-save { flex: 1; }