From af1b9661f537505c881d069d9cc263906527897d Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 9 May 2026 21:50:52 +0200 Subject: [PATCH] fix(orbit): three interlocking guest playback bugs (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(orbit): guest short-circuits queue-exhaustion fallback paths When a guest's local queue runs out (single-track queue from `syncToHost` empties on `audio:ended`), the player walks the standard fallback chain in `next()`: radio top-up → infinite-queue → stop. The infinite-queue branch builds a 6-track queue and calls `playTrack`, which trips `orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal. Hitting Cancel leaves playback frozen; "Add them all" injects unrelated tracks into the host's shared queue. In an active Orbit guest session the host owns the queue. Skip the fallback paths entirely and just stop — the next `useOrbitGuest` pull tick will sync to whatever the host advanced to. Bonus side-effect: kills the deferred-promise race where a `buildInfiniteQueueCandidates().then(...)` from a guest's track end could resolve *after* a Catch Up replaced the queue and pop the modal a second time against the now-current 1-track queue. * fix(orbit): treat natural track-end as not-diverged in guest sync When a guest's track ended naturally before the host advanced, the divergence-detection branch read `player.isPlaying === false` and classified it as the user manually paused — so it refused to load the host's next track. The guest sat silent until they clicked Catch Up. `handleAudioEnded` keeps `currentTrack` pinned to the just-ended track and resets `currentTime` to 0, while a real manual pause leaves `currentTime` somewhere mid-track. Use the 0-position discriminator to classify natural-end as not-diverged so the host's new track loads. Confirmed via the captured guest log buffer: 18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged (player.isPlaying=false ≠ last.isPlaying=true) — guest stuck for ~33s until Catch Up was pressed. * fix(orbit): Catch Up polls until engine is ready before seeking The 400 ms blind setTimeout in `onCatchUp` was too short for an HTTP-streamed cold-start on high-latency links. If the audio engine wasn't ready by then, `seek(fraction)` silently no-oped and playback started at 0:00, making Catch Up effectively useless on exactly the slow links where it's needed. Captured log shows a Catch Up bringing the guest to posSec=30, then 5 s later the guest was at posSec=6 (playback restarted from the head). Replace with the same poll-until-ready pattern `syncToHost` already uses: check every 100 ms, fire the seek as soon as the engine reports playing, fall back to a blind apply at the 4 s deadline. * docs(changelog): add orbit guest playback fixes entry * fix(orbit): debounce Catch Up button + match bar item height Two follow-on UX fixes after PR #525's three primary bugs landed: 1. **Debounce visibility.** Drift is computed from an asymmetric signal: guest's `currentTime` updates in coarse ~5 s chunks, while host's position is extrapolated linearly via `(nowMs - posAt)`. Even on a perfectly-synced session the diff swings ±5 s every tick, so the button flickered in and out continuously. Show only after drift has stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement noise is filtered out, real sustained drift still surfaces in time. 2. **Match neighbour height.** The button was 32 px tall against 26 px for the other action buttons (.orbit-bar__settings) so every flicker shifted the entire bar height. Set `height: 26 px` and tighten the padding/font so the layout is stable regardless of visibility. * fix(orbit): tighten queue-extension lockout + reliable initial-sync seek Two follow-on fixes after the 4-bug umbrella: **1. Local queue-extension paths fully off during Orbit.** Phase check broadened from `active` to cover `starting` / `joining` / `active` so a fetch-then-join race can't pop the bulk-add modal *after* the join. The proactive infinite-queue topper inside `next()` (which fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each async `.then()` callback in the radio + infinite-queue paths re-checks at resolution time. A `playTrack(... 6-track queue ...)` after the user joined Orbit was the path that re-triggered the "Add 5 tracks?" modal on a freshly-joined guest. **2. `syncToHost` only seeks once the engine reports playing.** The previous 2 s deadline-fallback applied the seek even when the engine hadn't started, where the seek silently no-ops and the track plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 % forward" — that's the seek finally landing because the engine is now ready, the initial-sync seek had already failed silently. New deadline is 5 s, and on timeout we return `false` so the outer pull tick keeps `lastAppliedRef` null and the 500 ms fast-poll retries. * fix(orbit): double-click play button + hide preview during session Two cucadmuh-flagged gaps: **1. Double-click on the inline play button now reaches the orbit-add path.** The album-track row's onDoubleClick already routes to `addTrackToOrbit` when in Orbit, but the inline play button stopped propagation on click — so clicking it twice just fired the "double- click to add" hint toast and never touched the orbit queue. Add an onDoubleClick on the button itself that delegates to the parent's `onDoubleClickSong`. **2. Track preview is suppressed during an Orbit session.** Preview shares the Rust audio engine with the shared playback, so starting one as a guest yanks the host's track out from under everyone. A new `[data-orbit-active]` attribute on `` (set whenever role is host/guest and phase is starting/joining/active) hides every preview button via a single CSS rule, and `previewStore.startPreview` short-circuits as a defensive guard for keyboard shortcuts and any programmatic callers. --- CHANGELOG.md | 11 +++++ src/App.tsx | 16 ++++++++ src/components/AlbumTrackList.tsx | 1 + src/components/OrbitSessionBar.tsx | 64 ++++++++++++++++++++++-------- src/hooks/useOrbitGuest.ts | 26 +++++++++--- src/store/playerStore.ts | 53 ++++++++++++++++++++++++- src/store/previewStore.ts | 11 +++++ src/styles/components.css | 18 ++++++++- 8 files changed, 175 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8ceea3..691a2ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,6 +221,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Orbit — guest playback fixes + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by nzxl + RavingGrob, PR [#525](https://github.com/Psychotoxical/psysonic/pull/525)** + +* **All local queue-extension paths are suppressed for the entire Orbit session lifecycle**, not just while the session is `active`. Radio top-up, infinite-queue top-up, queue-exhaustion fallback, and the proactive "≤ 2 auto-tracks ahead" topper all refuse to extend the local queue while role is host or guest and phase is `starting` / `joining` / `active`. Even fetch promises that were already in flight re-check at resolution time, so a click-Join racing with a fetch can't pop the bulk-add modal *after* the join completes. Without this lockout the guest got a "Add 5 tracks to the Orbit queue?" prompt on track-end (offering to inject unrelated suggestions into the host's shared queue) and the local queue silently drifted off the host's playlist. +* **Natural track-end no longer reads as "the guest manually paused"** — the divergence check in the guest pull tick now distinguishes the two via the player's `currentTime` (which `handleAudioEnded` resets to 0, while a real pause leaves it mid-track). Without the discriminator the guest sat silent on every host-driven track change that arrived in the 0–2.5 s window after the guest's own track had ended. +* **Initial-sync and Catch Up both now wait for the audio engine to actually report playing before seeking** (up to 5 s on initial-sync, 4 s on Catch Up). The previous timeout-and-fire approach would drop a seek onto a not-yet-ready engine, where it silently no-oped — guest played from 0:00 while believing they were synced. The visible symptom was "I clicked Catch Up and the song jumped 50 % forward": the second click finally caught the engine ready, so the seek that should have happened on join finally landed. +* **Catch Up button no longer flickers and no longer changes the bar height.** Drift is computed from a noisy signal (guest's `currentTime` updates in coarse chunks while host's position is extrapolated linearly), so the diff swings ±5 s on a normal session even when sync is fine — and the button popping in/out shifted the whole Orbit bar up and down because it was 6 px taller than the other action buttons. Visibility is now debounced (must stay over the threshold for ≥ 3 s before the button appears) and the button matches the 26 px height of its neighbours so the bar's vertical layout is stable. +* **Double-clicking the inline play button on an album-track row now suggests/enqueues to the host's queue**, matching the existing double-click-on-row behaviour. Previously the button stopped propagation on click, so its double-click never reached the row's orbit-aware handler — clicking it twice just bounced the "double-click to add" hint toast. +* **Track preview is now hidden + blocked during an Orbit session.** The preview path runs through the same Rust audio engine as the shared playback, so starting a preview as a guest would clobber the host's track in the local player. The preview button is hidden across all surfaces (album / artist / favourites / playlist / random-mix track lists) via a global `[data-orbit-active]` CSS rule, and `previewStore.startPreview` no-ops as a defensive guard for keyboard shortcuts and any programmatic callers. + ### Context menu — render above the floating player bar **By [@Psychotoxical](https://github.com/Psychotoxical), reported by Prymz, PR [#522](https://github.com/Psychotoxical/psysonic/pull/522)** diff --git a/src/App.tsx b/src/App.tsx index cda08a6e..7b625469 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -186,6 +186,22 @@ function AppShell() { useOrbitHost(); useOrbitGuest(); + // Body-level marker so global CSS can hide controls that don't make sense + // in an Orbit session (e.g. track preview — the preview engine and the + // shared playback would step on each other). Active for any role + any + // pre-`active` phase so the marker covers the whole join lifecycle. + const orbitRole = useOrbitStore(s => s.role); + const orbitPhase = useOrbitStore(s => s.phase); + useEffect(() => { + const inOrbit = (orbitRole === 'host' || orbitRole === 'guest') + && (orbitPhase === 'active' || orbitPhase === 'joining' || orbitPhase === 'starting'); + if (inOrbit) { + document.documentElement.setAttribute('data-orbit-active', 'true'); + } else { + document.documentElement.removeAttribute('data-orbit-active'); + } + }, [orbitRole, orbitPhase]); + useEffect(() => { if (!IS_LINUX) return; invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 8a0d8e8f..b97eeb0d 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -150,6 +150,7 @@ const TrackRow = React.memo(function TrackRow({ type="button" className="playlist-suggestion-play-btn" onClick={e => { e.stopPropagation(); onPlaySong(song); }} + onDoubleClick={onDoubleClickSong ? e => { e.stopPropagation(); onDoubleClickSong(song); } : undefined} data-tooltip={t('common.play')} aria-label={t('common.play')} > diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index 7e5d5baf..92c22112 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -66,6 +66,36 @@ export default function OrbitSessionBar() { return () => window.clearInterval(id); }, [state, phase]); + // ── Catch Up button visibility — debounced ──────────────────────────── + // The raw drift signal is noisy: guest's `currentTime` updates in coarse + // ~5 s chunks while host's position is extrapolated linearly via + // `(nowMs - posAt)`, so the diff swings ±5 s every tick on a normal + // session even when both sides are perfectly synced. Without debounce + // the button flickered in and out and (worse) shifted the bar height. + // Require ≥ 3 s of sustained over-threshold drift before showing. + const [showCatchUp, setShowCatchUp] = useState(false); + const overSinceRef = useRef(null); + useEffect(() => { + if (role !== 'guest' || !state || !state.isPlaying || !state.currentTrack) { + overSinceRef.current = null; + setShowCatchUp(false); + return; + } + const player = usePlayerStore.getState(); + const localPositionMs = Math.round((player.currentTime ?? 0) * 1000); + const driftMs = player.currentTrack?.id === state.currentTrack.trackId + ? computeOrbitDriftMs(state, localPositionMs, nowMs) + : null; + const isOver = driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS; + if (isOver) { + if (overSinceRef.current === null) overSinceRef.current = Date.now(); + if (Date.now() - overSinceRef.current >= 3000) setShowCatchUp(true); + } else { + overSinceRef.current = null; + setShowCatchUp(false); + } + }, [role, state, nowMs]); + // Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed. const shouldShowBar = !!state && ( phase === 'active' @@ -78,17 +108,6 @@ export default function OrbitSessionBar() { const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs); - // Guest-only: detect drift from the host's estimated live position. - const guestPlayback = usePlayerStore.getState(); - const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000); - const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId - ? computeOrbitDriftMs(state, localPositionMs, nowMs) - : null; - const showCatchUp = role === 'guest' - && state.isPlaying - && state.currentTrack - && (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS); - const performExit = async () => { try { if (role === 'host') await endOrbitSession(); @@ -128,14 +147,25 @@ export default function OrbitSessionBar() { if (hostPlaying && !player.isPlaying) player.resume(); else if (!hostPlaying && player.isPlaying) player.pause(); } else { - // Different track: play + seek on next tick once engine is ready. + // Different track: play + seek once the engine reports the track + // loaded. The previous 400 ms blind delay was too short for an + // HTTP-streamed cold-start on a transcontinental link, so the seek + // would silently no-op and playback started at 0:00 — making Catch + // Up effectively useless on the very latency where it matters most. + // Mirrors the poll-until-ready pattern used by `syncToHost`. player.playTrack(track, [track]); - window.setTimeout(() => { + const deadline = Date.now() + 4000; + const poll = () => { const p = usePlayerStore.getState(); - if (p.currentTrack?.id !== trackId) return; - p.seek(fraction); - if (!hostPlaying && p.isPlaying) p.pause(); - }, 400); + if (p.currentTrack?.id !== trackId) return; // user changed tracks + if (p.isPlaying || Date.now() >= deadline) { + p.seek(fraction); + if (!hostPlaying && p.isPlaying) p.pause(); + return; + } + window.setTimeout(poll, 100); + }; + window.setTimeout(poll, 100); } } catch { // silent — if the track is gone from the host's library, nothing we can do. diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index f295968a..38fb06d6 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -103,16 +103,22 @@ export function useOrbitGuest(): void { player.playTrack(track, [track]); - // Poll until the engine has the track loaded; fall back to a blind - // apply after 2 s so a stuck load doesn't leave us spinning forever. + // Poll until the engine actually reports the track playing — the + // earlier "blind apply at deadline" path could fire a seek into a + // not-yet-ready engine, where the seek silently no-ops and the + // guest plays from 0 while believing they're synced (the visible + // 50 % jump-on-Catch-Up symptom). Wait for `p.isPlaying === true` + // up to 5 s, then give up and let the outer pull tick retry — + // the syncToHost-failed path keeps `lastAppliedRef` null so the + // 500 ms fast-poll in `tick` will try again immediately. return await new Promise(resolve => { - const deadline = Date.now() + 2000; + const deadline = Date.now() + 5000; const poll = () => { if (cancelled) { resolve(false); return; } const p = usePlayerStore.getState(); const trackReady = p.currentTrack?.id === trackId; if (trackReady && p.isPlaying) { resolve(applyMirror()); return; } - if (Date.now() >= deadline) { resolve(applyMirror()); return; } + if (Date.now() >= deadline) { resolve(false); return; } window.setTimeout(poll, 100); }; window.setTimeout(poll, 100); @@ -228,7 +234,17 @@ export function useOrbitGuest(): void { lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying }; } } else if (last.trackId !== hostTrackId) { - const diverged = player.isPlaying !== last.isPlaying; + // Distinguish "user manually paused" (true divergence) from "track + // ended naturally" (NOT divergence — guest just needs the host's + // next track loaded). Both leave `player.isPlaying === false`, but + // `handleAudioEnded` keeps `currentTrack` pinned to the just-ended + // track and resets `currentTime` to 0; a manual pause leaves + // `currentTime` somewhere mid-track. The 0-position discriminator + // separates them. + const naturalEnd = !player.isPlaying + && player.currentTrack?.id === last.trackId + && (player.currentTime ?? 0) < 0.5; + const diverged = !naturalEnd && player.isPlaying !== last.isPlaying; if (diverged) { // Guest is running their own show (typically: paused while host // kept going). Do not load/start the host's new track — just diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index b141b3e0..0ff56ec7 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -412,6 +412,20 @@ let playGeneration = 0; let infiniteQueueFetching = false; // Guard against concurrent radio top-up fetches. let radioFetching = false; + +/** True when the user is part of an Orbit session (any role, any phase + * short of `idle` / `error` / `ended`). Used by `next()` and its async + * fallback callbacks to suppress local queue-extension paths (radio + * top-up, infinite-queue, queue-exhausted refill) — those would either + * pop the orbitBulkGuard modal or silently inject tracks the host + * didn't pick. The helper is also called inside in-flight `.then()` + * callbacks so a fetch scheduled just before the user joined Orbit + * doesn't fire a `playTrack` after the join. */ +function isInOrbitSession(): boolean { + const o = useOrbitStore.getState(); + if (o.role !== 'host' && o.role !== 'guest') return false; + return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting'; +} // Artist ID used to start the current radio session — persists across track // advances so proactive loading works even when songs lack artistId. let currentRadioArtistId: string | null = null; @@ -3013,13 +3027,20 @@ export const usePlayerStore = create()( get().playTrack(queue[nextIdx], queue, manual, false, nextIdx); // Proactively top up auto-added tracks when ≤ 2 remain ahead, // so the queue never runs dry without a visible loading pause. + // Skipped while in Orbit — the host's queue is the source of + // truth there, and any silent local extension would either + // drift this client off the host or pop the bulk-add modal at + // the next track-end fallback. const { infiniteQueueEnabled } = useAuthStore.getState(); - if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching) { + if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching && !isInOrbitSession()) { const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length; if (remainingAuto <= 2) { infiniteQueueFetching = true; const existingIds = new Set(get().queue.map(t => t.id)); buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { + // Re-check at resolution time — the user may have joined + // an Orbit session between scheduling and resolving. + if (isInOrbitSession()) return; if (newTracks.length > 0) { set(state => ({ queue: [...state.queue, ...newTracks] })); } @@ -3076,6 +3097,20 @@ export const usePlayerStore = create()( } else if (repeatMode === 'all' && queue.length > 0) { get().playTrack(queue[0], queue, manual, false, 0); } else { + // ── Orbit short-circuit ── + // The host owns the shared queue. The radio / infinite-queue + // fallbacks below would either pop the orbitBulkGuard modal (with a + // 6-track add) or silently inject unrelated tracks into the local + // player and drift the guest off the host. Stop instead and let the + // next pull tick in `useOrbitGuest` sync to the host's next track. + // Covers any active orbit phase (`active` / `joining` / `starting`) + // so a fetch scheduled mid-join doesn't slip through. + if (isInOrbitSession()) { + invoke('audio_stop').catch(console.error); + isAudioPaused = false; + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } // Queue exhausted. Check radio first (independent of infinite queue setting), // then infinite queue, then stop. if (currentTrack?.radioAdded && !radioFetching) { @@ -3085,6 +3120,14 @@ export const usePlayerStore = create()( Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)]) .then(([similar, top]) => { radioFetching = false; + // The user may have joined an Orbit session while this + // fetch was in flight — bail without touching the queue. + if (isInOrbitSession()) { + invoke('audio_stop').catch(console.error); + isAudioPaused = false; + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } const existingIds = new Set(get().queue.map(t => t.id)); // Same source preference + dedup contract as the proactive // top-up: similar first, top only as a fallback (issue #500). @@ -3123,6 +3166,14 @@ export const usePlayerStore = create()( const existingIds = new Set(get().queue.map(t => t.id)); buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { infiniteQueueFetching = false; + // The user may have joined an Orbit session while this + // fetch was in flight — bail without invoking playTrack. + if (isInOrbitSession()) { + invoke('audio_stop').catch(console.error); + isAudioPaused = false; + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } if (newTracks.length === 0) { invoke('audio_stop').catch(console.error); isAudioPaused = false; diff --git a/src/store/previewStore.ts b/src/store/previewStore.ts index 42a5c174..a9344e3a 100644 --- a/src/store/previewStore.ts +++ b/src/store/previewStore.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { buildStreamUrl } from '../api/subsonic'; import { usePlayerStore } from './playerStore'; import { useAuthStore, type TrackPreviewLocation } from './authStore'; +import { useOrbitStore } from './orbitStore'; /** Minimal track info needed to surface the preview in the player bar UI. */ export interface PreviewingTrack { @@ -75,6 +76,16 @@ export const usePreviewStore = create((set, get) => ({ if (!auth.trackPreviewsEnabled) return; if (!auth.trackPreviewLocations[location]) return; + // Block preview during any Orbit session — the preview path runs + // through the same Rust audio engine as the shared playback, and a + // preview started by a guest would yank the host's track out from + // under them. UI buttons are hidden via `[data-orbit-active]` CSS; + // this guards keyboard shortcuts / programmatic callers. + const orbit = useOrbitStore.getState(); + const inOrbit = (orbit.role === 'host' || orbit.role === 'guest') + && (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting'); + if (inOrbit) return; + const current = get().previewingId; if (current === song.id) { await get().stopPreview(); diff --git a/src/styles/components.css b/src/styles/components.css index cf548fc4..2de59241 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1907,6 +1907,14 @@ transition: opacity var(--transition-fast), color var(--transition-fast); } +/* Hide track preview controls inside an active Orbit session — preview + playback shares the audio engine with the orbit-driven main player and + the two would step on each other. The defensive guard in + `previewStore.startPreview` catches keyboard / programmatic callers. */ +[data-orbit-active] .playlist-suggestion-preview-btn { + display: none; +} + .playlist-suggestions .track-row:hover .playlist-suggestion-preview-btn, .playlist-suggestion-preview-btn:hover, .playlist-suggestion-preview-btn.is-previewing { @@ -12579,12 +12587,18 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c display: inline-flex; align-items: center; gap: 6px; - padding: 6px 14px; + /* Match the 26 px height of the other bar icon-buttons (.orbit-bar__settings) + so the bar's vertical layout is stable whether or not catch-up is showing. + The button used to be ~32 px tall, so it pushed the bar height up when it + flickered in/out — visually annoying on a high-latency setup where the + trigger fires intermittently. */ + height: 26px; + padding: 0 12px; border-radius: 999px; background: var(--accent); border: 1px solid var(--accent); color: #fff; - font-size: 12px; + font-size: 11.5px; font-weight: 700; letter-spacing: 0.02em; cursor: pointer;