From a4aba05b3b3849fc40b6d1d31673608cb71efcef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:39:29 +0000 Subject: [PATCH 01/17] desktop: redesign the fullscreen player as a Material 3 Expressive now-playing view Lyrics, Queue and About replace the artwork in place while the artwork springs into a thumbnail beside the title (FLIP); the choice is remembered. Wavy seek bar that flattens when paused, morphing play button, tonal playback options, artwork-tinted background darkening towards the bottom. The queue timeline rests on the current item with History above, Clear actions on History and Playing next, and follows the current item only while the user isn't working in it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/src/renderer/components/Icon.tsx | 4 +- .../src/renderer/components/QueuePanel.tsx | 99 ++++-- desktop/src/renderer/components/WavySeek.tsx | 116 +++++++ desktop/src/renderer/lib/spring.test.ts | 26 ++ desktop/src/renderer/lib/spring.ts | 48 +++ desktop/src/renderer/main.tsx | 1 + desktop/src/renderer/store/actions.ts | 9 + desktop/src/renderer/store/app.ts | 17 + desktop/src/renderer/styles/now-playing.css | 161 +++++++++ .../src/renderer/views/FullscreenPlayer.tsx | 310 +++++++++++++++--- desktop/src/shared/strings.ts | 39 ++- 11 files changed, 754 insertions(+), 76 deletions(-) create mode 100644 desktop/src/renderer/components/WavySeek.tsx create mode 100644 desktop/src/renderer/lib/spring.test.ts create mode 100644 desktop/src/renderer/lib/spring.ts create mode 100644 desktop/src/renderer/styles/now-playing.css diff --git a/desktop/src/renderer/components/Icon.tsx b/desktop/src/renderer/components/Icon.tsx index 8cd648d..48406a8 100644 --- a/desktop/src/renderer/components/Icon.tsx +++ b/desktop/src/renderer/components/Icon.tsx @@ -6,7 +6,7 @@ import type { CSSProperties } from "react"; import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BatteryMedium, Bug, ChartColumn, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, - Cloud, CloudOff, Command, Copy, Disc3, Download, Ellipsis, ExternalLink, FastForward, Filter, Folder, GripVertical, HardDrive, Heart, HeartOff, + Cloud, CloudOff, Command, Copy, Disc3, Download, Ellipsis, EllipsisVertical, ExternalLink, FastForward, Filter, Folder, GripHorizontal, GripVertical, HardDrive, Heart, HeartOff, History, House, Infinity as InfinityIcon, Info, LayoutGrid, Library, ListEnd, ListFilterPlus, ListMusic, ListPlus, ListStart, LoaderCircle, Maximize2, MicVocal, Minus, MonitorSmartphone, Moon, Music, Network, Pause, Pencil, PictureInPicture2, Pin, PinOff, Play, Plus, Redo2, Repeat, Repeat1, Rewind, RotateCcw, Search, Settings, Shuffle, SkipBack, SkipForward, Square, SquareCheck, Star, StarOff, @@ -27,7 +27,7 @@ const ICONS: Record = { chevronDown: ChevronDown, chevronRight: ChevronRight, chevronLeft: ChevronLeft, chevronUp: ChevronUp, arrowDown: ArrowDown, arrowUp: ArrowUp, back: ArrowLeft, forwardArrow: ArrowRight, undo: Undo2, redo: Redo2, playNext: ListStart, playLater: ListEnd, pin: Pin, pinOff: PinOff, restore: History, seek: ArrowRight, sleep: Moon, bug: Bug, - selectAll: SquareCheck, grip: GripVertical, more: Ellipsis, minimize: Minus, maximize: Square, restoreWin: Copy, + selectAll: SquareCheck, grip: GripVertical, dragHandle: GripHorizontal, more: Ellipsis, moreVert: EllipsisVertical, minimize: Minus, maximize: Square, restoreWin: Copy, spinner: LoaderCircle, offline: WifiOff, warn: TriangleAlert, battery: BatteryMedium, external: ExternalLink, folder: Folder, copy: Copy, resume: RotateCcw, music: Music, cloud: Cloud, lan: Network, plus: Plus, }; diff --git a/desktop/src/renderer/components/QueuePanel.tsx b/desktop/src/renderer/components/QueuePanel.tsx index 065db77..38ec492 100644 --- a/desktop/src/renderer/components/QueuePanel.tsx +++ b/desktop/src/renderer/components/QueuePanel.tsx @@ -1,10 +1,13 @@ -// One scrollable timeline: history above, current, "Playing next", then -// "Continuing from ". Drag-and-drop reorder (dnd-kit), Delete removes, -// double-click jumps. Selection keyed by queue key. +// One scrollable timeline, like Apple Music's: it rests with the current item +// at the top (Now playing, "Playing next" with Clear, "Continue playing — +// from "), and scrolling up reveals History (with Clear). Follows +// the current item when it changes unless the user is scrolling or working +// in the list. Drag-and-drop reorder (dnd-kit), Delete removes, double-click +// jumps. Selection keyed by queue key. // Keyboard: a listbox with aria-activedescendant (one Tab stop); arrows move, // Enter plays from the item, Delete removes, Alt+Up/Down reorders (the // keyboard equivalent of dragging), Shift+F10 opens the item's menu. -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -18,8 +21,12 @@ import { Artwork } from "./Artwork"; import { Icon } from "./Icon"; import { OfflineBadge } from "./OfflineBadge"; import { fmtTime } from "../lib/format"; +import { usePrefersReducedMotion } from "../lib/media"; import { EmptyState } from "./EmptyState"; +/** How long after the last scroll, wheel or pointer activity the list may follow the current item again. */ +const IDLE_MS = 4000; + const SCOPE = "queue"; export function QueuePanel({ large = false }: { large?: boolean }) { @@ -63,6 +70,8 @@ export function QueuePanel({ large = false }: { large?: boolean }) { void openContextMenu(e, { type: "queueItems", data: { keys: ks } }); }; const onKey = (e: React.KeyboardEvent) => { + // Keys on the section buttons (Clear) are theirs. + if (e.target !== e.currentTarget) return; const idx = focusKey ? keys.indexOf(focusKey) : -1; if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown") && focusKey) { e.preventDefault(); @@ -98,22 +107,58 @@ export function QueuePanel({ large = false }: { large?: boolean }) { if (to >= 0) d({ type: "moveQueueItem", data: { key: String(active.id), to_index: to } }); }; + // Rest with the current item at the top; follow it when it changes, unless + // the user scrolled, pointed or focused into the list in the last few seconds. + const reducedMotion = usePrefersReducedMotion(); + const lastTouch = useRef(0); + const programmatic = useRef(false); + const touched = useCallback(() => { + if (programmatic.current) return; + lastTouch.current = Date.now(); + }, []); + const currentKey = queue.current?.item.key; + const settled = useRef(false); + useLayoutEffect(() => { + const list = listRef.current; + const head = list?.querySelector('[data-group="current"]'); + if (!list || !head) return; + const first = !settled.current; + settled.current = true; + if (!first && (Date.now() - lastTouch.current < IDLE_MS || list.contains(document.activeElement) && document.activeElement !== list)) return; + const top = head.offsetTop - list.offsetTop; + if (Math.abs(list.scrollTop - top) < 2) return; + programmatic.current = true; + list.scrollTo({ top, behavior: first || reducedMotion ? "auto" : "smooth" }); + window.setTimeout(() => { programmatic.current = false; }, first || reducedMotion ? 50 : 600); + }, [currentKey, reducedMotion]); + if (!queue.current && !all.length) return ; const idPrefix = large ? "fsq" : "q"; + const header = large ? : null; const optionId = (key: string) => `${idPrefix}-${key.replace(/[^A-Za-z0-9_-]/g, "_")}`; const row = (e: QueueEntry, kind: "history" | "current" | "next" | "upcoming") => ( click(ev, e.item.key)} onDoubleClick={() => d({ type: "jumpToQueueItem", data: { key: e.item.key } })} onContextMenu={(ev) => onContext(ev, e.item.key)} /> ); - const group = (key: string, label: React.ReactNode, entries: QueueEntry[], kind: "history" | "current" | "next" | "upcoming") => + // Section headers are presentational labels for their group; the Clear + // buttons in them are their own Tab stops (the list's keys ignore them). + const group = (key: string, label: React.ReactNode, entries: QueueEntry[], kind: "history" | "current" | "next" | "upcoming", extra?: { sub?: string; action?: React.ReactNode }) => entries.length ? ( -
-
{label}
+
+
+
+ {label} + {extra?.sub ? {extra.sub} : null} +
+ {extra?.action} +
{entries.map((e) => row(e, kind))}
) : null; - const upcomingLabel = queue.contextLabel ? t("queue.continuingFrom", { context: queue.contextLabel }) : t("queue.upcoming"); + const clearButton = (label: string, testId: string, onClick: () => void) => ( + + ); // Focus with no cursor yet starts on the current item. const onFocus = (e: React.FocusEvent) => { if (e.target !== e.currentTarget || focusKey) return; @@ -122,20 +167,20 @@ export function QueuePanel({ large = false }: { large?: boolean }) { }; const activeId = focusKey && keys.includes(focusKey) ? optionId(focusKey) : undefined; return ( -
- {/* "Clear playing next" lives outside the listbox: a listbox holds options only. */} - {queue.playingNext.length ?
: null} -
+
+ {header} +
- {group("history", t("queue.history"), queue.history, "history")} + {group("history", t("queue.history"), queue.history, "history", { action: clearButton(t("queue.clearHistory"), "queue-clear-history", () => { d({ type: "removeQueueItems", data: { keys: queue.history.map((e) => e.item.key) } }); publish(EMPTY_SELECTION); }) })} {queue.current ? group("current", t("queue.nowPlaying"), [queue.current], "current") : null} - {group("next", t("queue.playingNext"), queue.playingNext, "next")} - {group("upcoming", upcomingLabel, queue.upcoming, "upcoming")} + {group("next", t("queue.playingNext"), queue.playingNext, "next", { action: clearButton(t("queue.clearInsertions"), "queue-clear-next", () => d({ type: "clearInsertions" })) })} + {group("upcoming", t("queue.continuePlaying"), queue.upcoming, "upcoming", { sub: queue.contextLabel ? t("queue.fromContext", { context: queue.contextLabel }) : undefined })} + {queue.totalUpcoming > queue.upcoming.length ?
{t("queue.more", { count: queue.totalUpcoming - queue.upcoming.length })}
: null}
- {queue.totalUpcoming > queue.upcoming.length ?
{t("queue.more", { count: queue.totalUpcoming - queue.upcoming.length })}
: null} {t("a11y.queueHint")}
); @@ -149,16 +194,30 @@ function QueueRow({ id, entry, kind, large, selected, focused, sortable, onClick
- {/* Pointer drag handle; the keyboard reorders with Alt+Up/Down on the list. */} - {sortable ? :
); } + +/** Shuffle, repeat and autoplay as tonal pill toggles over the fullscreen queue. */ +function QueueHeader() { + const queue = useApp((s) => s.queue); + const d = bridge().dispatch; + const repeatLabel = queue.repeat === "off" ? t("player.repeatOff") : queue.repeat === "all" ? t("player.repeatAll") : t("player.repeatOne"); + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/renderer/components/WavySeek.tsx b/desktop/src/renderer/components/WavySeek.tsx new file mode 100644 index 0000000..1e2bcbd --- /dev/null +++ b/desktop/src/renderer/components/WavySeek.tsx @@ -0,0 +1,116 @@ +// The fullscreen player's seek bar: Material 3 Expressive's wavy progress. +// The played part is a travelling sine wave that flattens to a line while +// paused (and stands still with reduced motion); the rest is a flat track; +// the thumb is a short vertical bar. Same slider semantics and keys as the +// player bar's SeekBar. +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { t } from "@shared/strings"; +import { useApp } from "../store/app"; +import { usePosition } from "../store/position"; +import { bridge } from "../core/bridge"; +import { fmtTime } from "../lib/format"; +import { seekKeyTarget, seekValueText } from "../lib/a11y"; + +const WAVELENGTH = 32; +const AMPLITUDE = 3; +const HEIGHT = 28; +/** Gap either side of the thumb. */ +const GAP = 6; + +/** A sine wave from x0 to x1 around y = mid, as a path of quadratic segments (two per wavelength). */ +export function wavePath(x0: number, x1: number, mid: number, amplitude = AMPLITUDE, wavelength = WAVELENGTH): string { + const half = wavelength / 2; + // Start on a crest boundary so the wave lines up however far it is drawn. + const start = Math.floor(x0 / wavelength) * wavelength; + let d = `M ${start} ${mid}`; + let up = true; + for (let x = start; x < x1; x += half) { + d += ` Q ${x + half / 2} ${mid + (up ? -2 : 2) * amplitude} ${x + half} ${mid}`; + up = !up; + } + return d; +} + +export function WavySeek({ durationMs, playing }: { durationMs: number | undefined; playing: boolean }) { + const stamp = useApp((s) => s.transport.position); + const offset = useApp((s) => s.connection.clockOffsetMs); + const pos = usePosition(stamp, durationMs, offset); + const [drag, setDrag] = useState(undefined); + const [hover, setHover] = useState(undefined); + const [width, setWidth] = useState(0); + const bar = useRef(null); + const dur = durationMs ?? 0; + + useLayoutEffect(() => { + const el = bar.current; + if (!el) return; + setWidth(el.getBoundingClientRect().width); + const ro = new ResizeObserver(() => setWidth(el.getBoundingClientRect().width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const frac = (x: number) => { + const r = bar.current?.getBoundingClientRect(); + if (!r || !r.width) return 0; + return Math.max(0, Math.min(1, (x - r.left) / r.width)); + }; + const commit = useCallback((f: number) => bridge().dispatch({ type: "seekTo", data: { position_ms: Math.round(f * dur) } }), [dur]); + const dragging = drag !== undefined; + useEffect(() => { + if (!dragging) return; + const move = (e: PointerEvent) => setDrag(frac(e.clientX)); + const up = (e: PointerEvent) => { commit(frac(e.clientX)); setDrag(undefined); }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; + }, [dragging, commit]); + + const shown = drag !== undefined ? drag * dur : pos; + const f = dur ? Math.max(0, Math.min(1, shown / dur)) : 0; + const seconds = Math.floor(shown / 1000); + const onKey = (e: React.KeyboardEvent) => { + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; + const to = seekKeyTarget(e.key, shown, dur); + if (to === undefined) return; + e.preventDefault(); + e.stopPropagation(); + bridge().dispatch({ type: "seekTo", data: { position_ms: Math.round(to) } }); + }; + + const x = f * width; + const mid = HEIGHT / 2; + const playedEnd = Math.max(0, x - GAP); + const restStart = Math.min(width, x + GAP); + // The wave starts a wavelength before 0: it travels right by up to a + // wavelength (CSS), and the clip keeps it to the played part. + const clipId = "np-seek-clip"; + return ( +
+
{ if (!dur || e.button !== 0) return; e.preventDefault(); e.currentTarget.focus({ preventScroll: true }); setDrag(frac(e.clientX)); }} + onPointerMove={(e) => setHover(frac(e.clientX))} + onPointerLeave={() => setHover(undefined)} + data-testid="fs-seek-slider"> + {width ? ( + + ) : null} + {hover !== undefined && dur ?
{fmtTime(hover * dur)}
: null} +
+ +
+ ); +} diff --git a/desktop/src/renderer/lib/spring.test.ts b/desktop/src/renderer/lib/spring.test.ts new file mode 100644 index 0000000..9790479 --- /dev/null +++ b/desktop/src/renderer/lib/spring.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { SPRING_EFFECTS, SPRING_SPATIAL, spring, springAt } from "./spring"; + +const values = (easing: string) => easing.replace(/^linear\(|\)$/g, "").split(", ").map(Number); + +describe("spring easing", () => { + it("starts at rest and ends exactly at 1", () => { + const v = values(spring(300, 0.7).easing); + expect(v[0]).toBe(0); + expect(v[v.length - 1]).toBe(1); + }); + + it("overshoots when underdamped and never when critically damped", () => { + expect(Math.max(...values(SPRING_SPATIAL.easing))).toBeGreaterThan(1); + expect(Math.max(...values(SPRING_EFFECTS.easing))).toBeLessThanOrEqual(1); + }); + + it("has settled by the reported duration", () => { + for (const [k, r] of [[260, 0.72], [700, 0.6], [400, 1]] as const) { + const { duration } = spring(k, r); + expect(Math.abs(1 - springAt(duration / 1000, k, r))).toBeLessThan(0.002); + expect(duration).toBeGreaterThan(100); + expect(duration).toBeLessThan(2000); + } + }); +}); diff --git a/desktop/src/renderer/lib/spring.ts b/desktop/src/renderer/lib/spring.ts new file mode 100644 index 0000000..3975abf --- /dev/null +++ b/desktop/src/renderer/lib/spring.ts @@ -0,0 +1,48 @@ +// Spring motion for CSS and the Web Animations API: a damped spring sampled +// into a `linear()` easing, so transitions and FLIP animations overshoot and +// settle like Material 3 Expressive's spatial springs without a JS loop. + +export interface Spring { + /** CSS easing: `linear(0, …, 1)`. */ + easing: string; + /** Milliseconds until the spring has settled (within 0.1 % of rest). */ + duration: number; +} + +/** + * Position (0 → 1) of a spring released from rest at 0 towards 1, at time `t` + * seconds. `stiffness` in N/m for a unit mass; `ratio` is the damping ratio + * (1 = critically damped, below 1 overshoots). + */ +export function springAt(t: number, stiffness: number, ratio: number): number { + const w = Math.sqrt(stiffness); + if (ratio < 1) { + const wd = w * Math.sqrt(1 - ratio * ratio); + return 1 - Math.exp(-ratio * w * t) * (Math.cos(wd * t) + ((ratio * w) / wd) * Math.sin(wd * t)); + } + return 1 - Math.exp(-w * t) * (1 + w * t); +} + +/** Sample a spring into a `linear()` easing and the duration it needs. */ +export function spring(stiffness: number, ratio: number, samples = 48): Spring { + const w = Math.sqrt(stiffness); + // The envelope e^(-ζωt) (or (1+ωt)e^(-ωt) when critically damped) below 0.001. + let settle = ratio < 1 ? Math.log(1000) / (ratio * w) : 0; + if (ratio >= 1) { + settle = 0.05; + while (Math.exp(-w * settle) * (1 + w * settle) > 0.001) settle += 0.01; + } + const points: string[] = []; + for (let i = 0; i <= samples; i++) { + const v = i === samples ? 1 : springAt((settle * i) / samples, stiffness, ratio); + points.push(String(Math.round(v * 1000) / 1000)); + } + return { easing: `linear(${points.join(", ")})`, duration: Math.round(settle * 1000) }; +} + +/** Large movements (the artwork flying between its slots): a gentle overshoot. */ +export const SPRING_SPATIAL = spring(260, 0.72); +/** Small, quick shape changes (the play button morphing, a pressed button). */ +export const SPRING_FAST = spring(700, 0.6); +/** Colour and opacity: no overshoot. */ +export const SPRING_EFFECTS = spring(400, 1); diff --git a/desktop/src/renderer/main.tsx b/desktop/src/renderer/main.tsx index 86b5aeb..2df2de6 100644 --- a/desktop/src/renderer/main.tsx +++ b/desktop/src/renderer/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./styles/global.css"; +import "./styles/now-playing.css"; import "@applemusic-like-lyrics/core/style.css"; import { App } from "./App"; import { MiniApp } from "./views/MiniApp"; diff --git a/desktop/src/renderer/store/actions.ts b/desktop/src/renderer/store/actions.ts index 5a67df2..4d24f7b 100644 --- a/desktop/src/renderer/store/actions.ts +++ b/desktop/src/renderer/store/actions.ts @@ -45,6 +45,11 @@ export async function executeAction(rawId: string, target: ActionTarget = { type app.focusSearch(); return; case "toggleQueuePanel": + // In the fullscreen player Q and L switch its stage instead of the hidden side panel. + if (app.fullscreen) { + app.setNowPlayingMode(app.nowPlayingMode === "queue" ? "art" : "queue"); + return; + } if (isNarrow()) { // The drawer: open it on the queue, or close it when the queue is already showing. const showing = app.drawerOpen && !app.panels.queueCollapsed; @@ -55,6 +60,10 @@ export async function executeAction(rawId: string, target: ActionTarget = { type app.setPanels(app.panels.rightOpen && !app.panels.queueCollapsed ? { queueCollapsed: true } : { rightOpen: true, queueCollapsed: false }); return; case "toggleLyrics": + if (app.fullscreen) { + app.setNowPlayingMode(app.nowPlayingMode === "lyrics" ? "art" : "lyrics"); + return; + } if (isNarrow()) { const showing = app.drawerOpen && !app.panels.lyricsCollapsed; app.setDrawerOpen(!showing); diff --git a/desktop/src/renderer/store/app.ts b/desktop/src/renderer/store/app.ts index ebd33f7..d241c5f 100644 --- a/desktop/src/renderer/store/app.ts +++ b/desktop/src/renderer/store/app.ts @@ -21,6 +21,10 @@ export interface Route { param?: string; } +/** What the fullscreen player's stage shows: the big artwork, or one of its panes (the artwork shrinks next to the title). */ +export type NowPlayingMode = "art" | "lyrics" | "queue" | "about"; +const NOW_PLAYING_MODES: readonly NowPlayingMode[] = ["art", "lyrics", "queue", "about"]; + export interface PanelState { rightOpen: boolean; queueCollapsed: boolean; @@ -71,6 +75,8 @@ export interface UiState { /** Renderer performance mode derived from visibility/focus/battery. */ perf: "full" | "background" | "stopped"; queueTab: "queue" | "recent"; + /** Remembered on this device across closing and reopening the player. */ + nowPlayingMode: NowPlayingMode; prefs: AppPrefs; /** Narrow windows (and 200% zoom) show the side panel as a drawer, closed until asked for. */ drawerOpen: boolean; @@ -100,6 +106,7 @@ export interface AppStore extends CoreState, UiState { setWindowState(state: WindowState): void; setPageVisible(visible: boolean): void; setQueueTab(tab: "queue" | "recent"): void; + setNowPlayingMode(mode: NowPlayingMode): void; setPrefs(patch: Partial): void; setDrawerOpen(open: boolean): void; setting(key: string, fallback: T): T; @@ -129,6 +136,7 @@ export const useApp = create((set, get) => ({ pageVisible: true, perf: "full", queueTab: "queue", + nowPlayingMode: loadNowPlayingMode(), prefs: { closeToTray: true }, drawerOpen: false, @@ -216,6 +224,10 @@ export const useApp = create((set, get) => ({ setQueueTab(queueTab) { set({ queueTab }); }, + setNowPlayingMode(nowPlayingMode) { + saveLocal("nowPlayingMode", nowPlayingMode); + set({ nowPlayingMode }); + }, setPrefs(patch) { void bridge().prefs.set(patch).then((prefs) => set({ prefs })); }, @@ -237,6 +249,11 @@ export const useApp = create((set, get) => ({ }, })); +function loadNowPlayingMode(): NowPlayingMode { + const v = loadLocal("nowPlayingMode", "art"); + return (NOW_PLAYING_MODES as readonly string[]).includes(v) ? (v as NowPlayingMode) : "art"; +} + /** Whether the window is in the narrow layout (lib/media.ts NARROW). */ export function isNarrow(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 900px)").matches; diff --git a/desktop/src/renderer/styles/now-playing.css b/desktop/src/renderer/styles/now-playing.css new file mode 100644 index 0000000..0555ed4 --- /dev/null +++ b/desktop/src/renderer/styles/now-playing.css @@ -0,0 +1,161 @@ +/* The fullscreen "Now playing" view and the queue timeline. Loaded after + global.css. Motion comes from lib/spring.ts through --spring-* custom + properties set on the view (springs sampled into linear() easings). + Text over the background is white or 80 % white; FluidBackground's scrim is + sized so both reach 4.5:1, and .np-fade only ever darkens. */ + +/* ---- Queue timeline (side panel and fullscreen) ------------------------- */ +.queue-section { display: flex; align-items: center; gap: 8px; min-height: 30px; } +.queue-section-text { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.queue-section-sub { text-transform: none; letter-spacing: 0; font-weight: 400; color: var(--fg-muted); font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queue-section .queue-clear { flex: 0 0 auto; text-transform: none; letter-spacing: 0; height: 22px; border-radius: 11px; font-weight: 500; } +.queue-more { padding: 8px 12px; color: var(--fg-muted); font-size: var(--fs-xs); } +.qrow .grip { display: inline-flex; flex: 0 0 auto; } +.qrow .grip-space { width: 14px; flex: 0 0 auto; } + +/* ---- Fullscreen player --------------------------------------------------- */ +.fullscreen.np { display: block; overflow: hidden; } +.np-fade { position: absolute; inset: 0; z-index: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.12) 40%, rgba(0, 0, 0, 0.62) 100%); } +.np-stage { position: relative; z-index: 1; height: 100%; width: min(calc(100% - 48px), 680px); margin: 0 auto; padding: 18px 0 22px; display: flex; flex-direction: column; gap: 14px; } +.np[data-mode="queue"] .np-stage, .np[data-mode="lyrics"] .np-stage, .np[data-mode="about"] .np-stage { width: min(calc(100% - 48px), 760px); } + +.np-head { display: flex; align-items: center; gap: 12px; min-height: 44px; flex: 0 0 auto; } +.np-source { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.np-kicker { font-size: var(--fs-sm); color: var(--fg-muted); } +.np-source-name { font-size: var(--fs-lg); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.np-main { flex: 1 1 auto; min-height: 0; container-type: size; display: flex; } +.np-art-slot { width: 100%; height: 100%; display: grid; place-items: center; } +.np-art { width: min(100cqw, 100cqh); aspect-ratio: 1; border-radius: 28px; overflow: hidden; box-shadow: 0 28px 70px rgba(0, 0, 0, 0.55), 0 4px 14px rgba(0, 0, 0, 0.3); background: rgba(255, 255, 255, 0.06); } +.np-art-img { display: block; width: 100%; height: 100%; object-fit: cover; } +.np-art-img.placeholder { display: flex; align-items: center; justify-content: center; color: var(--fg-muted); background: rgba(255, 255, 255, 0.06); } +.np-pane { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; animation: np-pane-in var(--spring-spatial-ms, 400ms) var(--spring-spatial, var(--ease)); } +@keyframes np-pane-in { from { opacity: 0; transform: translateY(18px) scale(0.98); } } + +.np-info, .np-seek, .np-transport, .np-options, .np-modes { position: relative; z-index: 1; } +.np-info { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; min-width: 0; } +.np-thumb { flex: 0 0 auto; width: 56px; height: 56px; padding: 0; border: 0; border-radius: 12px; overflow: hidden; background: rgba(255, 255, 255, 0.06); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); color: inherit; } +.np-thumb:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: 12px; } +.np-text { flex: 1; min-width: 0; text-align: left; } +.np-text .t1 { margin: 0; font-size: 26px; line-height: 1.2; font-weight: 700; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np-text .t2 { font-size: 16px; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; } +.np-link { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; border-radius: 4px; } +.np-link:hover { text-decoration: underline; text-underline-offset: 3px; } +.np-actions { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; } + +/* Buttons: circles with a state layer; pressing squashes them a little, springing back. */ +.np-icon-btn, .np-skip, .np-play, .np-tonal, .np-mode, .np-chip, .np-thumb { + -webkit-appearance: none; appearance: none; font: inherit; cursor: default; + transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), border-radius var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), width var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), background-color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)), color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)); +} +.np-icon-btn:active, .np-skip:active, .np-tonal:active, .np-mode:active, .np-chip:active, .np-thumb:active { transform: scale(0.9); } +.np-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 22px; border: 0; background: transparent; color: #fff; padding: 0; } +.np-icon-btn.sm { width: 32px; height: 32px; } +.np-icon-btn:hover { background: rgba(255, 255, 255, 0.12); } +.np-icon-btn.heart.on { color: #ff9aae; } + +/* Wavy seek bar */ +.np-seek { flex: 0 0 auto; display: flex; flex-direction: column; gap: 2px; } +.np-seek-bar { position: relative; height: 28px; touch-action: none; border-radius: 8px; } +.np-seek-bar[aria-disabled="true"] { opacity: 0.6; } +.np-seek-bar svg { display: block; overflow: visible; } +.np-seek-bar .played { fill: none; stroke: #fff; stroke-width: 4px; stroke-linecap: round; vector-effect: non-scaling-stroke; } +.np-seek-bar .rest { stroke: rgba(255, 255, 255, 0.3); stroke-width: 4px; stroke-linecap: round; } +.np-seek-bar .thumb { fill: #fff; transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)); transform-box: fill-box; transform-origin: center; } +.np-seek-bar.dragging .thumb, .np-seek-bar:hover .thumb { transform: scaleY(1.2); } +/* The wave: flat while paused, rising with a spring when playing, travelling forward. */ +.np-seek-bar .wave { transform-box: fill-box; transform-origin: center; transform: scaleY(0.001); transition: transform var(--spring-spatial-ms, 400ms) var(--spring-spatial, var(--ease)); } +.np-seek-bar.playing .wave { transform: none; } +.np-seek-bar.playing .played { animation: np-wave 1.3s linear infinite; } +@keyframes np-wave { to { transform: translateX(32px); } } +.np-seek-bar .hover-time { position: absolute; bottom: 30px; transform: translateX(-50%); background: rgba(20, 20, 24, 0.92); color: #fff; padding: 2px 8px; border-radius: 10px; font-size: var(--fs-xs); pointer-events: none; white-space: nowrap; font-variant-numeric: tabular-nums; } +.np-seek-times { display: flex; justify-content: space-between; font-size: var(--fs-sm); color: var(--fg-muted); font-variant-numeric: tabular-nums; } + +/* Transport: the play button morphs between a pill (paused) and a rounded square (playing). */ +.np-transport { display: flex; align-items: center; justify-content: center; gap: 28px; flex: 0 0 auto; } +.np-skip { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 32px; border: 0; background: transparent; color: #fff; padding: 0; } +.np-skip:hover { background: rgba(255, 255, 255, 0.12); border-radius: 22px; } +.np-play { display: inline-flex; align-items: center; justify-content: center; height: 80px; border: 0; padding: 0; background: #fff; color: #121214; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); } +.np-play.paused { width: 104px; border-radius: 40px; } +.np-play.playing { width: 80px; border-radius: 26px; } +.np-play:hover { background: #f1f1f4; } +.np-play:active { transform: scale(0.92); } +.np-play:focus-visible { outline: 3px solid #fff; outline-offset: 3px; border-radius: 40px; } +.np-play .spin { animation: spin 1.2s linear infinite; } + +/* Playback options: tonal buttons; on, they fill and square off a little. */ +.np-options { display: flex; align-items: center; justify-content: center; gap: 10px; flex: 0 0 auto; flex-wrap: wrap; } +.np-tonal { display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 40px; border-radius: 20px; border: 0; padding: 0; background: rgba(255, 255, 255, 0.12); color: #fff; } +.np-tonal:hover { background: rgba(255, 255, 255, 0.2); } +.np-tonal.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: 12px; } +.np-tonal.on:hover { background: #fff; } + +/* Lyrics / Queue / About */ +.np-modes { display: flex; align-items: center; justify-content: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; } +.np-mode { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 20px 0 16px; border-radius: 22px; border: 1px solid rgba(255, 255, 255, 0.34); background: transparent; color: #fff; font-size: var(--fs-lg); font-weight: 500; } +.np-mode:hover { background: rgba(255, 255, 255, 0.12); } +.np-mode.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-color: transparent; border-radius: 14px; } +.np-mode.on:hover { background: #fff; } + +/* Chips (queue header, About) */ +.np-chip { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 12px; border-radius: 17px; border: 0; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: var(--fs-md); font-weight: 500; } +.np-chip:hover { background: rgba(255, 255, 255, 0.2); } +.np-chip.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: 10px; } + +/* Panes */ +.np .empty { color: var(--fg-muted); } +.np-pane-lyrics .lyrics-pane, .np-pane-lyrics .amll-host { height: 100%; } +.np-pane-lyrics .amll-host.large { mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 48px), transparent 100%); } +.np-pane-lyrics .lyrics-static { font-size: 22px; } +.np .queue-wrap { height: 100%; } +.np .queue-header { display: flex; gap: 8px; padding: 0 0 12px; flex: 0 0 auto; flex-wrap: wrap; } +.np .queue-list { border-radius: 20px; padding-bottom: 60cqh; scroll-padding-top: 0; } +.np .queue-section { background: rgba(12, 12, 16, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 14px; padding: 8px 12px; margin: 0 0 4px; font-size: var(--fs-sm); text-transform: none; letter-spacing: 0; color: #fff; } +.np .queue-section-sub { color: rgba(255, 255, 255, 0.8); } +.np .queue-group.history .qrow { opacity: 0.85; } +.np .qrow { height: 60px; border-radius: 16px; margin: 0 0 2px; padding: 0 10px; gap: 12px; } +.np .qrow .art { width: 44px; height: 44px; border-radius: 10px; } +.np .qrow .t1 { font-size: var(--fs-lg); font-weight: 500; } +.np .qrow .t2 { font-size: var(--fs-sm); } +.np .qrow.current { background: rgba(255, 255, 255, 0.14); } +.np .qrow .grip { opacity: 0.55; } +.np .qrow:hover .grip { opacity: 1; } +.np .queue-clear { color: #fff; } + +.np-about { height: 100%; min-height: 0; } +.np-about-scroll { height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; border-radius: 24px; } +.np-card { background: rgba(0, 0, 0, 0.28); border-radius: 24px; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; align-items: flex-start; } +.np-card-title { margin: 0; font-size: var(--fs-lg); font-weight: 700; } +.np-facts { margin: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px 20px; } +.np-fact { min-width: 0; } +.np-fact.wide { grid-column: 1 / -1; } +.np-fact dt { font-size: var(--fs-sm); color: var(--fg-muted); margin: 0 0 2px; } +.np-fact dd { margin: 0; font-size: var(--fs-lg); overflow-wrap: anywhere; } +.np-muted { color: var(--fg-muted); } +.np-card .plain-list { width: 100%; } +.np .related-row { display: flex; gap: 12px; padding: 8px 10px; align-items: center; border-radius: 14px; } +.np .related-row:hover { background: rgba(255, 255, 255, 0.1); } +.np .related-row .art { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; } +.np .related-row .why { font-size: var(--fs-xs); color: var(--fg-muted); } + +/* Short or narrow windows (and 200% zoom): the page scrolls rather than squashing the controls. */ +@media (max-width: 900px), (max-height: 620px) { + .fullscreen.np { overflow-y: auto; } + .np-stage { height: auto; min-height: 100%; width: min(calc(100% - 32px), 680px) !important; padding: 12px 0 16px; gap: 12px; } + .np-main { flex: 0 0 auto; height: min(70vw, 56vh); min-height: 220px; } + .np-text .t1 { font-size: 20px; } + .np-transport { gap: 16px; } + .np-mode { padding: 0 14px 0 12px; } +} + +@media (forced-colors: active) { + .np-fade { display: none; } + .np-play, .np-tonal, .np-mode, .np-chip, .np-icon-btn, .np-skip { border: 1px solid ButtonText; } + .np-play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; } + .np-tonal.on, .np-mode.on, .np-chip.on { forced-color-adjust: none; background: Highlight; color: HighlightText; border-color: Highlight; } + .np-seek-bar .played { stroke: Highlight; } + .np-seek-bar .rest { stroke: GrayText; } + .np-seek-bar .thumb { fill: CanvasText; } + .np .queue-section { background: Canvas; } + .np-card { border: 1px solid CanvasText; } +} diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index 5fcd01b..e932f80 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -1,45 +1,130 @@ -// Fullscreen player (F): Kawarp background, large cover, transport, tabbed -// side panel Up Next / Related / Lyrics. -// A modal dialog: focus moves in on open (the close button), Tab stays inside, -// Escape closes, and focus returns to what opened it. -import { useEffect, useRef, useState } from "react"; +// The fullscreen "Now playing" view (F): a blurred, artwork-coloured +// background darkening towards the bottom; "Playing from " and a +// collapse chevron; the big artwork; title and artist • album with love, +// add-to-playlist and more; the wavy seek bar; the transport; playback +// options; and Lyrics / Queue / About toggles. +// +// Choosing Lyrics, Queue or About replaces the artwork in place with that pane +// and the artwork flies (FLIP, a spring) into a thumbnail beside the title; +// choosing it again flies it back. The choice is remembered (store +// `nowPlayingMode`), so the player reopens the way it was left. On open the +// artwork flies in from the player bar's. +// +// A modal dialog: focus moves in on open (the collapse button), Tab stays +// inside, Escape closes, and focus returns to what opened it. +import { useEffect, useLayoutEffect, useRef, type CSSProperties } from "react"; import { t } from "@shared/strings"; -import { useApp } from "../store/app"; +import type { Track } from "@core/api"; +import { useApp, type NowPlayingMode } from "../store/app"; import { useQuery } from "../store/queries"; +import { executeAction } from "../store/actions"; import { bridge } from "../core/bridge"; import { FluidBackground } from "../components/FluidBackground"; import { Artwork } from "../components/Artwork"; import { Icon } from "../components/Icon"; -import { SeekBar } from "../components/PlayerBar"; +import { WavySeek } from "../components/WavySeek"; import { QueuePanel } from "../components/QueuePanel"; import { LyricsPane } from "../components/RightPanel"; -import { Heart, Stars } from "../components/Stars"; -import { fmtTime } from "../lib/format"; -import { Tabs, tabPanelProps } from "../components/Tabs"; +import { Stars } from "../components/Stars"; +import { openMenuFromButton } from "../components/ContextMenu"; +import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; import { trapTab, useReturnFocus } from "../lib/focus"; +import { usePrefersReducedMotion } from "../lib/media"; +import { SPRING_EFFECTS, SPRING_FAST, SPRING_SPATIAL } from "../lib/spring"; -type Tab = "upNext" | "related" | "lyrics"; +type Pane = Exclude; +const PANES: { key: Pane; icon: string }[] = [ + { key: "lyrics", icon: "lyrics" }, + { key: "queue", icon: "queue" }, + { key: "about", icon: "info" }, +]; +const ART_RADIUS = 28; +const THUMB_RADIUS = 12; + +/** The motion tokens the stylesheet uses, from lib/spring.ts. */ +const MOTION = { + "--spring-spatial": SPRING_SPATIAL.easing, + "--spring-spatial-ms": `${SPRING_SPATIAL.duration}ms`, + "--spring-fast": SPRING_FAST.easing, + "--spring-fast-ms": `${SPRING_FAST.duration}ms`, + "--spring-effects": SPRING_EFFECTS.easing, + "--spring-effects-ms": `${SPRING_EFFECTS.duration}ms`, +} as CSSProperties; + +/** Animate `el` from where `from` was to where it is now (FLIP), corners included. Returns where it lands. */ +function flyFrom(el: HTMLElement, from: DOMRect, fromRadius: number, toRadius: number): DOMRect { + const to = el.getBoundingClientRect(); + if (!to.width || !from.width) return to; + const sx = from.width / to.width; + const sy = from.height / to.height; + if (Math.abs(from.left - to.left) < 1 && Math.abs(from.top - to.top) < 1 && Math.abs(sx - 1) < 0.01) return to; + el.animate( + [ + { transformOrigin: "0 0", transform: `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${sx}, ${sy})`, borderRadius: `${fromRadius / sx}px / ${fromRadius / sy}px` }, + { transformOrigin: "0 0", transform: "none", borderRadius: `${toRadius}px` }, + ], + { duration: SPRING_SPATIAL.duration, easing: SPRING_SPATIAL.easing }, + ); + return to; +} export function FullscreenPlayer() { const now = useApp((s) => s.nowPlaying); const transport = useApp((s) => s.transport); const queue = useApp((s) => s.queue); + const devices = useApp((s) => s.devices); + const sleep = useApp((s) => s.sleepTimer); + const mode = useApp((s) => s.nowPlayingMode); + const setMode = useApp((s) => s.setNowPlayingMode); const setFullscreen = useApp((s) => s.setFullscreen); + const navigate = useApp((s) => s.navigate); + const openDialog = useApp((s) => s.openDialog); const covered = useApp((s) => !!s.dialog || s.paletteOpen); - const [tab, setTab] = useState("lyrics"); + const reducedMotion = usePrefersReducedMotion(); const closeRef = useRef(null); + const bigArt = useRef(null); + const thumbArt = useRef(null); + /** Where the visible artwork last came to rest, and in which mode. */ + const flight = useRef<{ rect: DOMRect; radius: number; mode: NowPlayingMode } | undefined>(undefined); useReturnFocus(true); useEffect(() => closeRef.current?.focus({ preventScroll: true }), []); const d = bridge().dispatch; const track = now?.track; const playing = transport.position.isPlaying; + const owner = devices.find((x) => x.id === transport.lease.owner); + const remote = owner && !owner.isSelf ? owner.name : undefined; + const source = queue.contextLabel ?? track?.album; useEffect(() => { bridge().window.openFullscreen(true); return () => bridge().window.openFullscreen(false); }, []); - const providerLabel = (p: string) => t(`related.provider.${p}` as never); + const visibleArt = (): HTMLElement | null => (mode === "art" ? bigArt.current : thumbArt.current); + const choose = (next: NowPlayingMode) => setMode(next); + // After every commit: when the mode changed (a click, or L/Q from the + // keyboard), fly the artwork from its last resting place into its new slot; + // on open, fly it up from the player bar's. Then remember where it rests. + useLayoutEffect(() => { + const el = visibleArt(); + const radius = mode === "art" ? ART_RADIUS : THUMB_RADIUS; + const prev = flight.current; + if (!el) { + flight.current = undefined; + return; + } + if (el.getAnimations().length && prev?.mode === mode) return; + let from: { rect: DOMRect; radius: number } | undefined; + if (!prev) { + const barArt = document.querySelector('[data-testid="player-bar"] .now .art'); + if (barArt) from = { rect: barArt.getBoundingClientRect(), radius: 4 }; + } else if (prev.mode !== mode && (prev.mode === "art" || mode === "art")) { + from = prev; + } + const rect = from && !reducedMotion ? flyFrom(el, from.rect, from.radius, radius) : el.getBoundingClientRect(); + flight.current = { rect, radius, mode }; + }); + const onKey = (e: React.KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) { e.preventDefault(); @@ -49,59 +134,178 @@ export function FullscreenPlayer() { } trapTab(e); }; + const go = (route: Parameters[0]) => { + navigate(route); + setFullscreen(false); + }; + const repeatLabel = queue.repeat === "off" ? t("player.repeatOff") : queue.repeat === "all" ? t("player.repeatAll") : t("player.repeatOne"); + const art = track ? :
; + return ( -
+
- -
- -
-

{track?.title ?? t("player.nothingPlaying")}

-
{track ? [track.artist, track.album].filter(Boolean).join(" · ") : ""}
+ -
+
{mode !== "art" ? ( From 96a8d6136fdaa57323f4b66e7fbbf35955131f5b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:42:59 +0000 Subject: [PATCH 06/17] desktop: Material Symbols (rounded) icons everywhere Icons come from Material Symbols instead of Lucide: scripts/gen-icons.mjs extracts the outlined and filled rounded glyphs the renderer uses from the Iconify dataset into material-icons.ts (only those, ~50 kB), and Icon draws them. A `filled` prop replaces the old fill-style hack: transport glyphs are filled by default, a loved heart and set stars fill, and selected states (the current sidebar destination, the player's pressed toggles and pane pills) switch to the filled glyph as Material 3 does. Drops lucide-react. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/e2e/cache.spec.ts | 4 +- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 35 ++++---- desktop/scripts/gen-icons.mjs | 79 +++++++++++++++++ desktop/src/renderer/components/AlbumGrid.tsx | 2 +- desktop/src/renderer/components/Icon.tsx | 65 ++++++-------- desktop/src/renderer/components/PlayerBar.tsx | 6 +- desktop/src/renderer/components/Sidebar.tsx | 8 +- desktop/src/renderer/components/Stars.tsx | 6 +- desktop/src/renderer/components/Tile.tsx | 2 +- .../src/renderer/components/TrackTable.tsx | 4 +- desktop/src/renderer/components/icon.test.ts | 2 +- .../src/renderer/components/material-icons.ts | 88 +++++++++++++++++++ desktop/src/renderer/views/AlbumDetail.tsx | 2 +- desktop/src/renderer/views/ArtistDetail.tsx | 2 +- desktop/src/renderer/views/Downloads.tsx | 2 +- desktop/src/renderer/views/FilterBuilder.tsx | 2 +- desktop/src/renderer/views/Filters.tsx | 2 +- .../src/renderer/views/FullscreenPlayer.tsx | 20 ++--- desktop/src/renderer/views/MiniApp.tsx | 6 +- desktop/src/renderer/views/PlaylistDetail.tsx | 2 +- 21 files changed, 249 insertions(+), 92 deletions(-) create mode 100644 desktop/scripts/gen-icons.mjs create mode 100644 desktop/src/renderer/components/material-icons.ts diff --git a/desktop/e2e/cache.spec.ts b/desktop/e2e/cache.spec.ts index bc3739a..e65c31d 100644 --- a/desktop/e2e/cache.spec.ts +++ b/desktop/e2e/cache.spec.ts @@ -163,12 +163,12 @@ test.describe("cached and downloaded badges", () => { const cached = page.getByTestId("album-tracks").locator('[data-testid="offline-badge"][data-state="cached"]'); await expect(cached.first()).toBeVisible(); await expect(cached.first().getByRole("img", { name: "Cached" })).toBeVisible(); - const cachedIcon = await cached.first().locator("svg").getAttribute("class"); + const cachedIcon = await cached.first().locator("svg").getAttribute("data-icon"); // A downloaded album: every row is Downloaded, with a different icon. await gotoAlbum(app, page, await albumWhere(page, "someDownloaded")); const dl = page.getByTestId("album-tracks").locator('[data-testid="offline-badge"][data-state="downloaded"]'); await expect(dl.first().getByRole("img", { name: "Downloaded" })).toBeVisible(); - expect(await dl.first().locator("svg").getAttribute("class")).not.toBe(cachedIcon); + expect(await dl.first().locator("svg").getAttribute("data-icon")).not.toBe(cachedIcon); for (const theme of ["light", "dark"] as const) { await setTheme(page, theme); await expectNoViolations(page, `${theme} album with downloaded badges`); diff --git a/desktop/package.json b/desktop/package.json index 269eb9b..efce957 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -30,7 +30,6 @@ "@dnd-kit/utilities": "^3.2.2", "@kawarp/core": "^1.2.2", "@tanstack/react-virtual": "^3.13.12", - "lucide-react": "^1.47.0", "react": "^19.1.1", "react-dom": "^19.1.1", "zustand": "^5.0.8" @@ -38,6 +37,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@eslint/js": "^9.36.0", + "@iconify-json/material-symbols": "^1.2.93", "@napi-rs/cli": "^3.3.1", "@playwright/test": "^1.55.0", "@types/node": "^22.18.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 17b10af..b35ec7a 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.12 version: 3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - lucide-react: - specifier: ^1.47.0 - version: 1.47.0(react@19.3.0) react: specifier: ^19.1.1 version: 19.3.0 @@ -45,9 +42,12 @@ importers: '@eslint/js': specifier: ^9.36.0 version: 9.39.5 + '@iconify-json/material-symbols': + specifier: ^1.2.93 + version: 1.2.93 '@napi-rs/cli': specifier: ^3.3.1 - version: 3.10.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@22.20.3) + version: 3.10.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.20.3) '@playwright/test': specifier: ^1.55.0 version: 1.63.0 @@ -648,6 +648,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/material-symbols@1.2.93': + resolution: {integrity: sha512-IfbpRixmEdeBrl2Bc+RtNZGb3NcTnMiPP4WausCParQYpIdezEYYbgMggJC1cYzm1q0f77p53t5tppy9PWsrvQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@inquirer/ansi@2.0.8': resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -2469,11 +2475,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lucide-react@1.47.0: - resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3701,6 +3702,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/material-symbols@1.2.93': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@inquirer/ansi@2.0.8': {} '@inquirer/checkbox@5.2.5(@types/node@22.20.3)': @@ -3858,7 +3865,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/cli@3.10.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@22.20.3)': + '@napi-rs/cli@3.10.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.20.3)': dependencies: '@inquirer/prompts': 8.7.2(@types/node@22.20.3) '@napi-rs/cross-toolchain': 1.0.3 @@ -3873,8 +3880,8 @@ snapshots: typanion: 3.14.0 typescript: 6.0.3 optionalDependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 transitivePeerDependencies: - '@napi-rs/cross-toolchain-arm64-target-aarch64' - '@napi-rs/cross-toolchain-arm64-target-armv7' @@ -5562,10 +5569,6 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@1.47.0(react@19.3.0): - dependencies: - react: 19.3.0 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 diff --git a/desktop/scripts/gen-icons.mjs b/desktop/scripts/gen-icons.mjs new file mode 100644 index 0000000..59fc6bf --- /dev/null +++ b/desktop/scripts/gen-icons.mjs @@ -0,0 +1,79 @@ +// Generates src/renderer/components/material-icons.ts: the Material Symbols +// (rounded) glyphs the renderer uses, as SVG path bodies, from the Iconify +// dataset (@iconify-json/material-symbols, Apache-2.0). Only the icons listed +// here are emitted, so the app doesn't ship the whole set. +// +// node scripts/gen-icons.mjs +// +// Each renderer name maps to a Material Symbols name; for each we emit the +// outlined and the filled rounded variant (the same glyph when the symbol has +// only one). Re-run after changing the list; the output is committed. +import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const set = require("@iconify-json/material-symbols/icons.json"); + +/** Renderer name → Material Symbols name (underscores or dashes). */ +const MAP = { + play: "play_arrow", pause: "pause", stop: "stop", next: "skip_next", previous: "skip_previous", rewind: "fast_rewind", forward: "fast_forward", + shuffle: "shuffle", repeat: "repeat", repeatOne: "repeat_one", autoplay: "all_inclusive", volume: "volume_up", mute: "volume_off", + heart: "favorite", heartOff: "heart_minus", star: "star", starOff: "star", + queue: "queue_music", lyrics: "lyrics", fullscreen: "fullscreen", mini: "picture_in_picture_alt", devices: "devices", + search: "search", command: "keyboard_command_key", settings: "settings", + home: "home", library: "library_music", album: "album", artist: "person", playlist: "playlist_play", playlistAdd: "playlist_add", song: "music_note", genre: "category", + download: "download", downloadOff: "file_download_off", cached: "hard_drive", filter: "filter_alt", filterAdd: "library_add", stats: "insights", info: "info", + remove: "remove", trash: "delete", edit: "edit", close: "close", check: "check", + chevronDown: "expand_more", chevronRight: "chevron_right", chevronLeft: "chevron_left", chevronUp: "expand_less", + arrowDown: "arrow_downward", arrowUp: "arrow_upward", back: "arrow_back", forwardArrow: "arrow_forward", undo: "undo", redo: "redo", + playNext: "queue_play_next", playLater: "add_to_queue", pin: "push_pin", pinOff: "keep_off", restore: "history", seek: "arrow_forward", sleep: "bedtime", bug: "bug_report", + selectAll: "select_all", grip: "drag_indicator", dragHandle: "drag_handle", more: "more_horiz", moreVert: "more_vert", minimize: "minimize", maximize: "crop_square", restoreWin: "filter_none", + spinner: "progress_activity", offline: "wifi_off", warn: "warning", battery: "battery_saver", external: "open_in_new", folder: "folder", + copy: "content_copy", resume: "play_circle", music: "music_note", cloud: "cloud", lan: "lan", plus: "add", +}; + +function body(name) { + let n = name; + for (let i = 0; i < 8; i++) { + const icon = set.icons[n]; + if (icon) return icon.body; + const alias = set.aliases?.[n]; + if (!alias) return undefined; + n = alias.parent; + } + return undefined; +} + +const first = (...names) => { + for (const n of names) { + const b = body(n); + if (b) return b; + } + return undefined; +}; + +const out = {}; +const missing = []; +for (const [local, material] of Object.entries(MAP)) { + const m = material.replace(/_/g, "-"); + const filled = first(`${m}-rounded`, m); + const outline = first(`${m}-outline-rounded`, `${m}-rounded`, `${m}-outline`, m); + if (!filled || !outline) missing.push(`${local} → ${material}`); + else out[local] = outline === filled ? [outline] : [outline, filled]; +} +if (missing.length) { + console.error(`missing Material Symbols: ${missing.join(", ")}`); + process.exit(1); +} + +const target = fileURLToPath(new URL("../src/renderer/components/material-icons.ts", import.meta.url)); +const lines = Object.entries(out).map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`); +writeFileSync(target, `// Generated by scripts/gen-icons.mjs from @iconify-json/material-symbols +// (Material Symbols, Apache-2.0, rounded). Do not edit; edit the script's map. +// Each entry: [outlined, filled?] SVG bodies for a 24×24 viewBox. +export const MATERIAL_ICONS: Record = { +${lines.join("\n")} +}; +`); +console.log(`[gen-icons] ${Object.keys(out).length} icons → ${target}`); diff --git a/desktop/src/renderer/components/AlbumGrid.tsx b/desktop/src/renderer/components/AlbumGrid.tsx index ca10275..0e2f3ca 100644 --- a/desktop/src/renderer/components/AlbumGrid.tsx +++ b/desktop/src/renderer/components/AlbumGrid.tsx @@ -171,7 +171,7 @@ export function AlbumGrid({ items, total, scope, targetKind, onOpen, onPlay, onN {it.subtitle ?
{it.subtitle}
: null} {it.badge ? : null} {/* Not a Tab stop: Enter opens, Shift+F10 has Play; the pointer gets the shortcut button. */} - {onPlay ? : null} + {onPlay ? : null}
); })} diff --git a/desktop/src/renderer/components/Icon.tsx b/desktop/src/renderer/components/Icon.tsx index 48406a8..be7fe8e 100644 --- a/desktop/src/renderer/components/Icon.tsx +++ b/desktop/src/renderer/components/Icon.tsx @@ -1,36 +1,17 @@ -// Icons come from lucide-react (ISC): one name → component map, keyed by the -// short names the renderer uses, plus the Material Symbols names the action -// registry emits in `ActionDescriptor.icon`. Nothing in the UI draws an icon -// from a unicode glyph; anything unknown renders as a music note so a new -// registry icon is visible (and `icon.test.ts` fails) rather than blank. +// Icons are Material Symbols (rounded; Apache-2.0), generated into +// material-icons.ts by scripts/gen-icons.mjs: one name → glyph map, keyed by +// the short names the renderer uses, plus the Material Symbols names the +// action registry emits in `ActionDescriptor.icon`. Each glyph has an +// outlined and a filled variant: outlined by default (transport glyphs are +// filled), `filled` picks explicitly (a loved heart, a set star). Nothing in +// the UI draws an icon from a unicode glyph; anything unknown renders as a +// music note so a new registry icon is visible (and `icon.test.ts` fails) +// rather than blank. import type { CSSProperties } from "react"; -import { - ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BatteryMedium, Bug, ChartColumn, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, - Cloud, CloudOff, Command, Copy, Disc3, Download, Ellipsis, EllipsisVertical, ExternalLink, FastForward, Filter, Folder, GripHorizontal, GripVertical, HardDrive, Heart, HeartOff, - History, House, Infinity as InfinityIcon, Info, LayoutGrid, Library, ListEnd, ListFilterPlus, ListMusic, ListPlus, ListStart, LoaderCircle, - Maximize2, MicVocal, Minus, MonitorSmartphone, Moon, Music, Network, Pause, Pencil, PictureInPicture2, Pin, PinOff, Play, Plus, - Redo2, Repeat, Repeat1, Rewind, RotateCcw, Search, Settings, Shuffle, SkipBack, SkipForward, Square, SquareCheck, Star, StarOff, - Trash2, TriangleAlert, Undo2, User, Volume2, VolumeX, WifiOff, X, - type LucideIcon, -} from "lucide-react"; +import { MATERIAL_ICONS } from "./material-icons"; -/** Renderer icon names → lucide components. */ -const ICONS: Record = { - play: Play, pause: Pause, stop: Square, next: SkipForward, previous: SkipBack, rewind: Rewind, forward: FastForward, - shuffle: Shuffle, repeat: Repeat, repeatOne: Repeat1, autoplay: InfinityIcon, volume: Volume2, mute: VolumeX, - heart: Heart, heartOff: HeartOff, star: Star, starOff: StarOff, - queue: ListMusic, lyrics: MicVocal, fullscreen: Maximize2, mini: PictureInPicture2, devices: MonitorSmartphone, - search: Search, command: Command, settings: Settings, - home: House, library: Library, album: Disc3, artist: User, playlist: ListMusic, playlistAdd: ListPlus, song: Music, genre: LayoutGrid, - download: Download, downloadOff: CloudOff, cached: HardDrive, filter: Filter, filterAdd: ListFilterPlus, stats: ChartColumn, info: Info, - remove: Minus, trash: Trash2, edit: Pencil, close: X, check: Check, - chevronDown: ChevronDown, chevronRight: ChevronRight, chevronLeft: ChevronLeft, chevronUp: ChevronUp, - arrowDown: ArrowDown, arrowUp: ArrowUp, back: ArrowLeft, forwardArrow: ArrowRight, undo: Undo2, redo: Redo2, - playNext: ListStart, playLater: ListEnd, pin: Pin, pinOff: PinOff, restore: History, seek: ArrowRight, sleep: Moon, bug: Bug, - selectAll: SquareCheck, grip: GripVertical, dragHandle: GripHorizontal, more: Ellipsis, moreVert: EllipsisVertical, minimize: Minus, maximize: Square, restoreWin: Copy, - spinner: LoaderCircle, offline: WifiOff, warn: TriangleAlert, battery: BatteryMedium, external: ExternalLink, folder: Folder, - copy: Copy, resume: RotateCcw, music: Music, cloud: Cloud, lan: Network, plus: Plus, -}; +/** Solid shapes read better filled; everything else defaults to outlined. */ +const FILLED_BY_DEFAULT = new Set(["play", "pause", "stop", "next", "previous", "rewind", "forward"]); /** Material Symbols names (what the registry's descriptors carry) → renderer names. */ export const MATERIAL: Record = { @@ -53,25 +34,31 @@ export interface IconProps { className?: string; style?: CSSProperties; title?: string; + /** Filled or outlined variant; defaults per glyph. */ + filled?: boolean; } /** Resolves a renderer or Material name to the renderer name that has a glyph. */ export function iconName(name: string): string { - return ICONS[name] ? name : (MATERIAL[name] ?? "music"); + return MATERIAL_ICONS[name] ? name : (MATERIAL[name] ?? "music"); } -export function Icon({ name, size = 16, className, style, title }: IconProps) { - const C = ICONS[iconName(name)] ?? Music; +const escapeText = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); + +export function Icon({ name, size = 16, className, style, title, filled }: IconProps) { + const n = iconName(name); + const [outline, solid] = MATERIAL_ICONS[n] ?? MATERIAL_ICONS.music ?? [""]; + const fill = filled ?? FILLED_BY_DEFAULT.has(n); + const body = fill ? (solid ?? outline) : outline; return ( - - {title ? {title} : null} - + ${escapeText(title)}` : "") + body }} /> ); } export function hasIcon(name: string): boolean { - return name in ICONS || name in MATERIAL; + return name in MATERIAL_ICONS || name in MATERIAL; } /** Every name the renderer can draw (for tests). */ -export const ICON_NAMES: readonly string[] = Object.keys(ICONS); +export const ICON_NAMES: readonly string[] = Object.keys(MATERIAL_ICONS); diff --git a/desktop/src/renderer/components/PlayerBar.tsx b/desktop/src/renderer/components/PlayerBar.tsx index 0c97e55..506894f 100644 --- a/desktop/src/renderer/components/PlayerBar.tsx +++ b/desktop/src/renderer/components/PlayerBar.tsx @@ -76,9 +76,9 @@ export function PlayerBar({ inert = false }: { inert?: boolean }) {
- - - + + +
diff --git a/desktop/src/renderer/components/Sidebar.tsx b/desktop/src/renderer/components/Sidebar.tsx index a9996dd..eff3fe0 100644 --- a/desktop/src/renderer/components/Sidebar.tsx +++ b/desktop/src/renderer/components/Sidebar.tsx @@ -46,7 +46,7 @@ export function Sidebar() {
{t("nav.library")}
{items.map((it) => ( { e.preventDefault(); if (it.view) navigate({ view: it.view }); else void executeAction(it.id); }} data-testid={`nav-${it.view ?? it.id}`}> - + {it.label} ))} @@ -58,7 +58,7 @@ export function Sidebar() { const name = filterName(f); return ( { e.preventDefault(); navigate(offline ? { view: "downloads", param: "offline" } : { view: "filter", id: f.id }); }} data-testid={offline ? "nav-available-offline" : undefined}> - + {name} ); @@ -66,7 +66,7 @@ export function Sidebar() { {(playlists ?? []).length ?
{t("nav.playlistsSection")}
: null} {(playlists ?? []).map((p) => ( { e.preventDefault(); navigate({ view: "playlist", id: p.id }); }} onContextMenu={(e) => void openContextMenu(e, { type: "playlists", data: { ids: [p.id] } })} title={p.isSmart ? `${p.name} · ${t("playlists.smart")}` : p.name}> - + {p.name} ))} @@ -74,7 +74,7 @@ export function Sidebar() { {/* Pinned below the scrolling list, like a native source list's footer. */} diff --git a/desktop/src/renderer/components/Stars.tsx b/desktop/src/renderer/components/Stars.tsx index 9f40934..b0f7e58 100644 --- a/desktop/src/renderer/components/Stars.tsx +++ b/desktop/src/renderer/components/Stars.tsx @@ -18,7 +18,7 @@ export function Stars({ value, onChange, size = 14, label, tabbable = true }: { if (!onChange) { return ( - {[1, 2, 3, 4, 5].map((n) => )} + {[1, 2, 3, 4, 5].map((n) => )} ); } @@ -58,7 +58,7 @@ export function Stars({ value, onChange, size = 14, label, tabbable = true }: { onKeyDown={(e) => onKey(e, n)} onClick={(e) => { e.stopPropagation(); onChange(n === value ? 0 : n); }} > - + ))} @@ -68,7 +68,7 @@ export function Stars({ value, onChange, size = 14, label, tabbable = true }: { export function Heart({ on, onToggle, size = 16, tabbable = true }: { on: boolean; onToggle?: () => void; size?: number; tabbable?: boolean }) { return ( ); } diff --git a/desktop/src/renderer/components/Tile.tsx b/desktop/src/renderer/components/Tile.tsx index 35615c2..410b1be 100644 --- a/desktop/src/renderer/components/Tile.tsx +++ b/desktop/src/renderer/components/Tile.tsx @@ -49,7 +49,7 @@ export function Tile({ title, subtitle, coverArt, round, onOpen, onActivate, onP {title} {subtitle ? {subtitle} : null} - {onPlay ? : null} + {onPlay ? : null} ); } diff --git a/desktop/src/renderer/components/TrackTable.tsx b/desktop/src/renderer/components/TrackTable.tsx index 473e2c5..484a155 100644 --- a/desktop/src/renderer/components/TrackTable.tsx +++ b/desktop/src/renderer/components/TrackTable.tsx @@ -332,9 +332,9 @@ function Cell({ col, track, index, playing, serverId, onPlay, primer }: { col: C case "index": return (
- {playing ? <>{t("a11y.playingTrack")} : {track.trackNumber ?? index + 1}} + {playing ? <>{t("a11y.playingTrack")} : {track.trackNumber ?? index + 1}} {/* Pointer shortcut (Enter plays the focused row); resting on it primes the track. */} - {!playing ? : null} + {!playing ? : null}
); case "art": diff --git a/desktop/src/renderer/components/icon.test.ts b/desktop/src/renderer/components/icon.test.ts index 06c7429..f48ef7a 100644 --- a/desktop/src/renderer/components/icon.test.ts +++ b/desktop/src/renderer/components/icon.test.ts @@ -1,5 +1,5 @@ // Every icon the action registry can emit (Material Symbols names in -// crates/hocket-core/src/actions/defs.rs) must map to a lucide glyph, and the +// crates/hocket-core/src/actions/defs.rs) must map to a Material Symbols glyph, and the // renderer's own names must all resolve; nothing falls through to the // placeholder silently. import { readFileSync } from "node:fs"; diff --git a/desktop/src/renderer/components/material-icons.ts b/desktop/src/renderer/components/material-icons.ts new file mode 100644 index 0000000..510381f --- /dev/null +++ b/desktop/src/renderer/components/material-icons.ts @@ -0,0 +1,88 @@ +// Generated by scripts/gen-icons.mjs from @iconify-json/material-symbols +// (Material Symbols, Apache-2.0, rounded). Do not edit; edit the script's map. +// Each entry: [outlined, filled?] SVG bodies for a 24×24 viewBox. +export const MATERIAL_ICONS: Record = { + play: ["",""], + pause: ["",""], + stop: ["",""], + next: ["",""], + previous: ["",""], + rewind: ["",""], + forward: ["",""], + shuffle: [""], + repeat: [""], + repeatOne: [""], + autoplay: [""], + volume: ["",""], + mute: ["",""], + heart: ["",""], + heartOff: ["",""], + star: ["",""], + starOff: ["",""], + queue: [""], + lyrics: ["",""], + fullscreen: [""], + mini: ["",""], + devices: ["",""], + search: [""], + command: [""], + settings: ["",""], + home: ["",""], + library: ["",""], + album: ["",""], + artist: ["",""], + playlist: [""], + playlistAdd: [""], + song: [""], + genre: ["",""], + download: [""], + downloadOff: [""], + cached: ["",""], + filter: ["",""], + filterAdd: ["",""], + stats: [""], + info: ["",""], + remove: [""], + trash: ["",""], + edit: ["",""], + close: [""], + check: [""], + chevronDown: [""], + chevronRight: [""], + chevronLeft: [""], + chevronUp: [""], + arrowDown: [""], + arrowUp: [""], + back: [""], + forwardArrow: [""], + undo: [""], + redo: [""], + playNext: ["",""], + playLater: ["",""], + pin: ["",""], + pinOff: ["",""], + restore: [""], + seek: [""], + sleep: ["",""], + bug: ["",""], + selectAll: [""], + grip: [""], + dragHandle: [""], + more: [""], + moreVert: [""], + minimize: [""], + maximize: ["",""], + restoreWin: ["",""], + spinner: [""], + offline: [""], + warn: ["",""], + battery: ["",""], + external: [""], + folder: ["",""], + copy: ["",""], + resume: ["",""], + music: [""], + cloud: ["",""], + lan: ["",""], + plus: [""], +}; diff --git a/desktop/src/renderer/views/AlbumDetail.tsx b/desktop/src/renderer/views/AlbumDetail.tsx index 337837a..ab3d12c 100644 --- a/desktop/src/renderer/views/AlbumDetail.tsx +++ b/desktop/src/renderer/views/AlbumDetail.tsx @@ -36,7 +36,7 @@ export function AlbumDetail({ id }: { id: string }) { · {a?.year ?? ""}{a?.genre ? ` · ${a.genre}` : ""} · {t("album.tracks", { count: a?.songCount ?? 0, duration: fmtDuration(a?.durationMs ?? 0) })}
- + {a ? bridge().dispatch({ type: "setLoved", data: { targets: [{ type: "album", data: { id: a.id } }], loved: !a.loved } })} /> : null} {a ? bridge().dispatch({ type: "setRating", data: { targets: [{ type: "album", data: { id: a.id } }], rating: r } })} /> : null} diff --git a/desktop/src/renderer/views/ArtistDetail.tsx b/desktop/src/renderer/views/ArtistDetail.tsx index 0152bc7..e7d3b84 100644 --- a/desktop/src/renderer/views/ArtistDetail.tsx +++ b/desktop/src/renderer/views/ArtistDetail.tsx @@ -29,7 +29,7 @@ export function ArtistDetail({ id }: { id: string }) {
{a ? t("artist.albumsCount", { albums: a.albumCount, songs: a.songCount }) : ""}
{a?.biography ?
{a.biography}
: null}
- + {a ? bridge().dispatch({ type: "setArtistLoved", data: { artist_id: a.id, loved: !a.loved } })} /> : null}
diff --git a/desktop/src/renderer/views/Downloads.tsx b/desktop/src/renderer/views/Downloads.tsx index f2625d4..f62928a 100644 --- a/desktop/src/renderer/views/Downloads.tsx +++ b/desktop/src/renderer/views/Downloads.tsx @@ -87,7 +87,7 @@ function AvailableOffline() {
{t("downloads.offlineHint")} {t("downloads.offlineCount", { count: total })} - +
play(i)} playingTrackId={playing} emptyMessage={t("downloads.offlineEmpty")} testId="available-offline-table" />
diff --git a/desktop/src/renderer/views/FilterBuilder.tsx b/desktop/src/renderer/views/FilterBuilder.tsx index 9f026cd..60eb260 100644 --- a/desktop/src/renderer/views/FilterBuilder.tsx +++ b/desktop/src/renderer/views/FilterBuilder.tsx @@ -82,7 +82,7 @@ export function FilterBuilder({ id }: { id: string }) {

{existing ? filterName(existing) : t("filters.builder")}

{preview ? t("filters.count", { count: preview.count }) : } - +
diff --git a/desktop/src/renderer/views/Filters.tsx b/desktop/src/renderer/views/Filters.tsx index 0565215..a186f61 100644 --- a/desktop/src/renderer/views/Filters.tsx +++ b/desktop/src/renderer/views/Filters.tsx @@ -28,7 +28,7 @@ export function Filters() { {filterName(f)}{isBuiltinFilter(f.id) ? {t("filters.builtin")} : null} {t("sort.label", { sort: t(`sort.${f.sort}` as never) })}{f.limit ? ` · ${t("filters.limit")} ${f.limit}` : ""} - + {/* Built-ins can't be deleted (the core would only drop a saved override). */} {isBuiltinFilter(f.id) ? : }
diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index 6df3761..7c62b42 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -204,7 +204,7 @@ export function FullscreenPlayer() {
{track ? (
- +
@@ -214,19 +214,19 @@ export function FullscreenPlayer() {
- + - +
- - - - - + + + + +
@@ -234,7 +234,7 @@ export function FullscreenPlayer() {
{PANES.map((p) => ( ))} diff --git a/desktop/src/renderer/views/MiniApp.tsx b/desktop/src/renderer/views/MiniApp.tsx index 8bf2c44..679bef5 100644 --- a/desktop/src/renderer/views/MiniApp.tsx +++ b/desktop/src/renderer/views/MiniApp.tsx @@ -25,9 +25,9 @@ export function MiniApp() {
{track?.artist ?? ""}
- - - + + + {track ? d({ type: "setLoved", data: { targets: [{ type: "track", data: { id: track.id } }], loved: !track.loved } })} /> : null} diff --git a/desktop/src/renderer/views/PlaylistDetail.tsx b/desktop/src/renderer/views/PlaylistDetail.tsx index e9c7dab..005f437 100644 --- a/desktop/src/renderer/views/PlaylistDetail.tsx +++ b/desktop/src/renderer/views/PlaylistDetail.tsx @@ -35,7 +35,7 @@ export function PlaylistDetail({ id }: { id: string }) {
{t("playlist.tracks", { count: p?.songCount ?? 0, duration: fmtDuration(p?.durationMs ?? 0) })}{p?.comment ? ` · ${p.comment}` : ""}
{p?.isSmart ?
{t("playlists.readOnly")}
: null}
- +
From f7be858f0b8eecd60ab21dea715d0c82f77353a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:31:41 +0000 Subject: [PATCH 07/17] desktop: condense the player controls in pane modes so the cover spans them Shuffle and repeat now flank the transport, and autoplay, device and sleep share a row with volume, in every layout. With Lyrics, Queue or About open on a wide window the controls condense further: title and its actions on one line, a smaller transport, tighter spacing. That leaves room for the stacked cover to fill the column's width (height allowing). Toasts pop in by transform only, so they are never drawn part-transparent (axe could catch one mid-fade below 4.5:1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/src/renderer/styles/global.css | 3 +- desktop/src/renderer/styles/now-playing.css | 32 ++++++++++++++++--- .../src/renderer/views/FullscreenPlayer.tsx | 17 +++++----- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/desktop/src/renderer/styles/global.css b/desktop/src/renderer/styles/global.css index 0ce3d91..8d84cea 100644 --- a/desktop/src/renderer/styles/global.css +++ b/desktop/src/renderer/styles/global.css @@ -571,7 +571,8 @@ button.qrow { width: 100%; } /* Snackbars: inverse-surface pills that spring up from below. */ .toast { display: flex; align-items: center; gap: 12px; background: var(--fg); color: var(--bg); min-height: 40px; padding: 6px 8px 6px 18px; border-radius: var(--shape-lg); box-shadow: var(--shadow); font-size: var(--fs-md); } .toast.fade-in { animation: toast-in var(--dur-spring-long) var(--spring-fast); } -@keyframes toast-in { from { opacity: 0; transform: translateY(16px) scale(0.94); } to { opacity: 1; transform: none; } } +/* Transform only: a toast is never drawn part-transparent, so its text keeps full contrast from the first frame. */ +@keyframes toast-in { from { transform: translateY(16px) scale(0.94); } to { transform: none; } } .toast:not(:has(.btn)) { padding-right: 18px; } .toast .btn { background: transparent; color: var(--accent-inverse); border-color: transparent; font-weight: 600; } .toast .btn:hover { background: color-mix(in srgb, var(--bg) 14%, transparent); } diff --git a/desktop/src/renderer/styles/now-playing.css b/desktop/src/renderer/styles/now-playing.css index 756efcb..f7cc548 100644 --- a/desktop/src/renderer/styles/now-playing.css +++ b/desktop/src/renderer/styles/now-playing.css @@ -86,7 +86,13 @@ .np-range:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: 10px; } /* Transport: the play button morphs between a pill (paused) and a rounded square (playing). */ -.np-transport { display: flex; align-items: center; justify-content: center; gap: 28px; flex: 0 0 auto; } +.np-transport { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 0 0 auto; } +/* Shuffle and repeat flank the transport: quiet circles that fill (and square off) when on. */ +.np-tonal.np-flank { width: 48px; height: 48px; border-radius: 24px; background: transparent; } +.np-tonal.np-flank:hover { background: rgba(255, 255, 255, 0.14); } +.np-tonal.np-flank.on { background: rgba(255, 255, 255, 0.92); border-radius: 16px; } +/* Autoplay, device and sleep share a row with volume (wide windows). */ +.np-row2 { display: flex; align-items: center; justify-content: center; gap: 16px; flex: 0 0 auto; flex-wrap: wrap; } .np-skip { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 32px; border: 0; background: transparent; color: #fff; padding: 0; } .np-skip:hover { background: rgba(255, 255, 255, 0.12); border-radius: 22px; } .np-play { display: inline-flex; align-items: center; justify-content: center; height: 80px; border: 0; padding: 0; background: #fff; color: #121214; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); } @@ -174,19 +180,35 @@ .np-text .t1 { font-size: clamp(28px, 2.4vw, 40px); line-height: 1.12; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .np-text .t2 { font-size: 18px; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .np-actions { grid-area: actions; margin-left: -10px; } - .np-transport { gap: 32px; } + .np-transport { justify-content: space-between; gap: 8px; } .np-play { height: 88px; } .np-play.paused { width: 120px; border-radius: 44px; } .np-play.playing { width: 88px; border-radius: 28px; } .np-skip { width: 68px; height: 68px; border-radius: 34px; } - .np-volume { display: flex; } - .np-options { justify-content: space-between; } - .np-tonal { flex: 1 1 0; } + .np-volume { display: flex; flex: 1 1 150px; min-width: 0; } + .np-row2 { justify-content: space-between; flex-wrap: nowrap; } + .np-options { flex: 0 0 auto; flex-wrap: nowrap; gap: 8px; } + .np-options .np-tonal { width: 48px; } .np-modes { justify-content: stretch; } .np-mode { flex: 1 1 0; justify-content: center; padding: 0 12px; } .np-pane { background: rgba(0, 0, 0, 0.16); border-radius: 32px; padding: 20px 20px 0; } .np-pane-lyrics { background: none; padding: 0; } .np .queue-list { padding-bottom: 40cqh; } + + /* Pane modes: the controls condense (title and actions on one line, a + smaller transport) so the cover above them can span the column. */ + .np:not([data-mode="art"]) .np-controls { gap: 12px; } + .np:not([data-mode="art"]) .np-info { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "thumb thumb" "text actions"; column-gap: 8px; row-gap: 14px; align-items: center; } + .np:not([data-mode="art"]) .np-thumb { width: clamp(160px, calc(100vh - 480px), 100%); margin: 0; justify-self: center; } + .np:not([data-mode="art"]) .np-text .t1 { font-size: 26px; -webkit-line-clamp: 1; } + .np:not([data-mode="art"]) .np-text .t2 { font-size: 16px; -webkit-line-clamp: 1; } + .np:not([data-mode="art"]) .np-actions { margin: 0 -8px 0 0; gap: 0; } + .np:not([data-mode="art"]) .np-actions .np-icon-btn { width: 40px; height: 40px; } + .np:not([data-mode="art"]) .np-play { height: 72px; } + .np:not([data-mode="art"]) .np-play.paused { width: 100px; border-radius: 36px; } + .np:not([data-mode="art"]) .np-play.playing { width: 72px; border-radius: 24px; } + .np:not([data-mode="art"]) .np-skip { width: 56px; height: 56px; } + .np:not([data-mode="art"]) .np-mode { height: 40px; } } /* Short or narrow windows (and 200% zoom): the page scrolls rather than squashing the controls. */ diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index 7c62b42..64ff117 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -214,23 +214,24 @@ export function FullscreenPlayer() {
+ +
-
- - - - - +
+
+ + + +
+
- -
{PANES.map((p) => ( +
diff --git a/desktop/src/renderer/views/FilterBuilder.tsx b/desktop/src/renderer/views/FilterBuilder.tsx index 60eb260..8f10007 100644 --- a/desktop/src/renderer/views/FilterBuilder.tsx +++ b/desktop/src/renderer/views/FilterBuilder.tsx @@ -77,7 +77,7 @@ export function FilterBuilder({ id }: { id: string }) { }; const localOnly = preview?.capability.localOnlyFields ?? []; return ( -
+

{existing ? filterName(existing) : t("filters.builder")}

@@ -89,8 +89,8 @@ export function FilterBuilder({ id }: { id: string }) {
-
- {preview?.capability.serverExpressible ? t("filters.serverExpressible") : t("filters.localOnly", { fields: localOnly.join(", ") })} +
+ {preview?.capability.serverExpressible ? t("filters.serverExpressible") : t("filters.localOnly", { fields: localOnly.join(", ") })}
setFilter({ ...filter, root })} root />
@@ -107,8 +107,8 @@ export function FilterBuilder({ id }: { id: string }) { {existing && !isBuiltinFilter(existing.id) ? : null}
-
-
{t("filters.sample")}
+
+
{t("filters.sample")}
{(preview?.sample ?? []).map((s) => (
{s.title}
{s.artist}
))} diff --git a/desktop/src/renderer/views/Filters.tsx b/desktop/src/renderer/views/Filters.tsx index a186f61..806a49b 100644 --- a/desktop/src/renderer/views/Filters.tsx +++ b/desktop/src/renderer/views/Filters.tsx @@ -14,7 +14,7 @@ export function Filters() { if (f) bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "filter", data: { filter: f } }, label: filterName(f), sort: f.sort, tracks: [] }, startIndex: 0, shuffle: false, saveOutgoing: true } } }); }; return ( -
+

{t("filters.title")}

@@ -22,13 +22,13 @@ export function Filters() {
{!filters.length ? navigate({ view: "filter", id: "new" })}>{t("filters.new")}} /> : null} {filters.map((f) => ( -
- +
+ - + {/* Built-ins can't be deleted (the core would only drop a saved override). */} {isBuiltinFilter(f.id) ? : }
diff --git a/desktop/src/renderer/views/MiniApp.tsx b/desktop/src/renderer/views/MiniApp.tsx index 679bef5..ce80f81 100644 --- a/desktop/src/renderer/views/MiniApp.tsx +++ b/desktop/src/renderer/views/MiniApp.tsx @@ -26,7 +26,7 @@ export function MiniApp() {
- + {track ? d({ type: "setLoved", data: { targets: [{ type: "track", data: { id: track.id } }], loved: !track.loved } })} /> : null} diff --git a/desktop/src/renderer/views/Settings.tsx b/desktop/src/renderer/views/Settings.tsx index a1aa9e9..f023f7c 100644 --- a/desktop/src/renderer/views/Settings.tsx +++ b/desktop/src/renderer/views/Settings.tsx @@ -21,16 +21,17 @@ import { LYRICS_SIZES, setLyricsAnimated, setLyricsSize, useLyricsAnimated, useL const SECTIONS = ["general", "audio", "transcoding", "connect", "storage", "lyrics", "appearance", "customisation", "shortcuts", "backup", "diagnostics", "about"] as const; type Section = (typeof SECTIONS)[number]; +const SECTION_ICONS: Record = { general: "tune", audio: "equalizer", transcoding: "transcode", connect: "connectCast", storage: "cached", lyrics: "lyrics", appearance: "palette", customisation: "customise", shortcuts: "keyboard", backup: "backup", diagnostics: "bug", about: "info" }; export function Settings({ section }: { section?: string }) { const navigate = useApp((s) => s.navigate); const current: Section = (SECTIONS as readonly string[]).includes(section ?? "") ? (section as Section) : "general"; return ( -
+
@@ -594,28 +595,34 @@ function Shortcuts() { bridge().dispatch({ type: "setShortcut", data: { action_id: id, shortcut: chordToString(chord) } }); setRecording(undefined); }; - let lastCat = ""; + // Rows are already sorted by category: one grouped list per category. + const groups: { category: string; rows: typeof rows }[] = []; + for (const r of rows) { + const last = groups[groups.length - 1]; + if (last && last.category === r.category) last.rows.push(r); + else groups.push({ category: r.category, rows: [r] }); + } return ( <> -
{t("settings.shortcutsHint")}
- {rows.map((r) => { - const cat = r.category !== lastCat ?

{r.category}

: null; - lastCat = r.category; - const chord = r.current ? parseChord(r.current) : undefined; - const conf = conflicts.get(r.id); - return ( -
- {cat} -
-
{r.label}{conf?.length ?
{t("settings.shortcutConflict", { action: conf.map((c) => labels.get(c) ?? c).join(", ") })}
: null}
- - -
-
- ); - })} +
{t("settings.shortcutsHint")}
+ {groups.map((g) => ( +
+

{g.category}

+ {g.rows.map((r) => { + const chord = r.current ? parseChord(r.current) : undefined; + const conf = conflicts.get(r.id); + return ( +
+
{r.label}{conf?.length ?
{t("settings.shortcutConflict", { action: conf.map((c) => labels.get(c) ?? c).join(", ") })}
: null}
+ + +
+ ); + })} +
+ ))} ); } diff --git a/desktop/src/renderer/views/Setup.tsx b/desktop/src/renderer/views/Setup.tsx index 9fef61d..f958bd1 100644 --- a/desktop/src/renderer/views/Setup.tsx +++ b/desktop/src/renderer/views/Setup.tsx @@ -4,6 +4,7 @@ import { t } from "@shared/strings"; import { useApp } from "../store/app"; import { bridge } from "../core/bridge"; import { CredentialWarning } from "../components/CredentialWarning"; +import { Icon } from "../components/Icon"; export function Setup() { const [url, setUrl] = useState(""); @@ -36,17 +37,26 @@ export function Setup() { return (
- -

{t("setup.title")}

-
{t("setup.subtitle")}
- - - - - - {error ? : null} - - +
+ + +

{t("setup.title")}

+
{t("setup.subtitle")}
+
+
+ + + + + + {error ? : null} +
+ + +
+
); diff --git a/desktop/src/renderer/views/Stats.tsx b/desktop/src/renderer/views/Stats.tsx index 6dfd048..6314212 100644 --- a/desktop/src/renderer/views/Stats.tsx +++ b/desktop/src/renderer/views/Stats.tsx @@ -4,6 +4,7 @@ import { useApp } from "../store/app"; import { useQuery } from "../store/queries"; import { Artwork } from "../components/Artwork"; import { EmptyState } from "../components/EmptyState"; +import { Icon } from "../components/Icon"; export function Stats() { const [days, setDays] = useState(30); @@ -12,7 +13,7 @@ export function Stats() { const maxHour = Math.max(1, ...(data?.playsByHour ?? [0])); const maxDay = Math.max(1, ...(data?.playsByWeekday ?? [0])); return ( -
+

{t("stats.title")}

@@ -22,19 +23,19 @@ export function Stats() {
{!loading && !data?.totalPlays ? : null} {data?.totalPlays ? ( -
-
-
{t("stats.plays", { count: "" })}
{data.totalPlays}
-
{t("stats.time", { hours: "" })}
{(data.totalMs / 3_600_000).toFixed(1)} h
+
+
+
{t("stats.plays", { count: "" })}
{data.totalPlays}
+
{t("stats.time", { hours: "" })}
{(data.totalMs / 3_600_000).toFixed(1)} h

{t("stats.byHour")}

`${i}:00 ${v}`).join(", ")}>{data.playsByHour.map((v, i) =>
)}

{t("stats.byWeekday")}

`${["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][i]} ${v}`).join(", ")}>{data.playsByWeekday.map((v, i) =>
)}
-

{t("stats.topTracks")}

    {data.topTracks.map((tr, i) =>
  1. {i + 1}
    {tr.title}
    {tr.artist}
  2. )}
-

{t("stats.topAlbums")}

    {data.topAlbums.map((a, i) =>
  1. )}
-

{t("stats.topArtists")}

    {data.topArtists.map((a, i) =>
  1. )}
+

{t("stats.topTracks")}

    {data.topTracks.map((tr, i) =>
  1. {i + 1}
    {tr.title}
    {tr.artist}
  2. )}
+

{t("stats.topAlbums")}

    {data.topAlbums.map((a, i) =>
  1. )}
+

{t("stats.topArtists")}

    {data.topArtists.map((a, i) =>
  1. )}
) : null} From 5e69638cfccf09bfca25b35cdf77ace210d4ff4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:59:29 +0000 Subject: [PATCH 12/17] desktop: M3 Expressive library views Display-size page titles with counts as tonal chips and sort as a connected button group; tiles whose art lifts and morphs into a squircle on a spring, with a large squircle play FAB that rounds into a circle; artists clipped to a scalloped cookie that morphs on hover; home carousels under bold headings with tonal cookie icons; detail heroes as tonal cards with big squircle art, a large Play FAB, Shuffle and secondary actions as tonal buttons; track tables with a pill header, rounded rows that square off when selected and a tonal playing row; genre cards with a per-genre coloured decorative shape; empty states with a glyph in a tonal cookie. Short windows (200% zoom) get a compact hero and a scrolling detail page. Reduced motion and forced colours covered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/src/renderer/components/AlbumGrid.tsx | 4 +- .../src/renderer/components/EmptyState.tsx | 6 +- desktop/src/renderer/components/Tile.tsx | 2 +- .../src/renderer/components/TrackTable.tsx | 2 +- desktop/src/renderer/styles/m3-library.css | 258 +++++++++++++++++- desktop/src/renderer/views/AlbumDetail.tsx | 4 +- desktop/src/renderer/views/Albums.tsx | 6 +- desktop/src/renderer/views/ArtistDetail.tsx | 10 +- desktop/src/renderer/views/Artists.tsx | 2 +- desktop/src/renderer/views/Genres.tsx | 17 +- desktop/src/renderer/views/Home.tsx | 15 +- desktop/src/renderer/views/PlaylistDetail.tsx | 4 +- desktop/src/renderer/views/Playlists.tsx | 4 +- desktop/src/renderer/views/SearchView.tsx | 7 +- desktop/src/renderer/views/Songs.tsx | 2 +- 15 files changed, 306 insertions(+), 37 deletions(-) diff --git a/desktop/src/renderer/components/AlbumGrid.tsx b/desktop/src/renderer/components/AlbumGrid.tsx index 0e2f3ca..ed1157c 100644 --- a/desktop/src/renderer/components/AlbumGrid.tsx +++ b/desktop/src/renderer/components/AlbumGrid.tsx @@ -138,7 +138,7 @@ export function AlbumGrid({ items, total, scope, targetKind, onOpen, onPlay, onN return (
- {total === 0 ?
{emptyMessage}
: null} + {total === 0 ?
{emptyMessage}
: null}
{vrows.map((vr) => (
@@ -171,7 +171,7 @@ export function AlbumGrid({ items, total, scope, targetKind, onOpen, onPlay, onN {it.subtitle ?
{it.subtitle}
: null} {it.badge ? : null} {/* Not a Tab stop: Enter opens, Shift+F10 has Play; the pointer gets the shortcut button. */} - {onPlay ? : null} + {onPlay ? : null}
); })} diff --git a/desktop/src/renderer/components/EmptyState.tsx b/desktop/src/renderer/components/EmptyState.tsx index 7eda29c..f5cd03b 100644 --- a/desktop/src/renderer/components/EmptyState.tsx +++ b/desktop/src/renderer/components/EmptyState.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from "react"; +import { Icon } from "./Icon"; -/** Every empty state names the one action that fills it. No illustrations. */ -export function EmptyState({ message, action, testId }: { message: string; action?: ReactNode; testId?: string }) { +/** Every empty state names the one action that fills it. No illustrations; a library view may add one glyph in a tonal shape. */ +export function EmptyState({ message, action, testId, icon }: { message: string; action?: ReactNode; testId?: string; icon?: string }) { return (
+ {icon ? : null}
{message}
{action}
diff --git a/desktop/src/renderer/components/Tile.tsx b/desktop/src/renderer/components/Tile.tsx index 410b1be..772daaf 100644 --- a/desktop/src/renderer/components/Tile.tsx +++ b/desktop/src/renderer/components/Tile.tsx @@ -49,7 +49,7 @@ export function Tile({ title, subtitle, coverArt, round, onOpen, onActivate, onP {title} {subtitle ? {subtitle} : null} - {onPlay ? : null} + {onPlay ? : null} ); } diff --git a/desktop/src/renderer/components/TrackTable.tsx b/desktop/src/renderer/components/TrackTable.tsx index 484a155..686bcab 100644 --- a/desktop/src/renderer/components/TrackTable.tsx +++ b/desktop/src/renderer/components/TrackTable.tsx @@ -280,7 +280,7 @@ export function TrackTable(props: TrackTableProps) {
- {total === 0 ?
{props.emptyMessage ?? t("songs.empty")}
: null} + {total === 0 ?
{props.emptyMessage ?? t("songs.empty")}
: null}
{items.map((vi) => { const tr = tracks[vi.index]; diff --git a/desktop/src/renderer/styles/m3-library.css b/desktop/src/renderer/styles/m3-library.css index 172eaac..07ab638 100644 --- a/desktop/src/renderer/styles/m3-library.css +++ b/desktop/src/renderer/styles/m3-library.css @@ -1 +1,257 @@ -/* m3-library: Material 3 Expressive pass (loaded after global.css, before now-playing.css). */ +/* m3-library: Material 3 Expressive pass for the library views (loaded after + global.css, before now-playing.css). Page content sits on the floating + --bg-elev content pane: display-size titles, counts as tonal chips, tiles + whose art morphs and lifts on springs, a scalloped "cookie" for artists, + squircle heroes with a large Play FAB, rounded track rows with a tonal + playing row, and genre cards carrying a coloured (decorative) shape. + Text only ever sits on --bg-elev, --bg-sunken or the contrast-checked + accent tints (--accent-soft, --surface-tonal, --selection). */ +:root { + /* Shapes from the M3 Expressive library: a 9-scallop cookie and a softer + 12-scallop one. Same point count, so clip-path morphs between them. */ + --lib-cookie-9: polygon(50% 0%, 52.17% 0.35%, 54.26% 1.36%, 56.20% 2.88%, 57.99% 4.70%, 59.62% 6.58%, 61.17% 8.30%, 62.72% 9.65%, 64.36% 10.53%, 66.19% 10.92%, 68.25% 10.87%, 70.53% 10.56%, 73% 10.16%, 75.54% 9.91%, 78.01% 10%, 80.25% 10.57%, 82.14% 11.70%, 83.57% 13.36%, 84.53% 15.47%, 85.04% 17.89%, 85.24% 20.43%, 85.28% 22.93%, 85.36% 25.24%, 85.68% 27.27%, 86.37% 29%, 87.52% 30.47%, 89.13% 31.75%, 91.08% 32.98%, 93.23% 34.27%, 95.33% 35.71%, 97.16% 37.36%, 98.52% 39.24%, 99.24% 41.32%, 99.27% 43.51%, 98.64% 45.74%, 97.49% 47.93%, 96% 50%, 94.43% 51.94%, 93.01% 53.76%, 91.94% 55.52%, 91.36% 57.29%, 91.30% 59.16%, 91.70% 61.17%, 92.41% 63.37%, 93.23% 65.73%, 93.91% 68.19%, 94.25% 70.64%, 94.08% 72.95%, 93.30% 75%, 91.91% 76.70%, 90% 78.01%, 87.71% 78.93%, 85.24% 79.57%, 82.79% 80.04%, 80.53% 80.53%, 78.58% 81.19%, 77% 82.17%, 75.75% 83.56%, 74.76% 85.36%, 73.89% 87.50%, 73% 89.84%, 71.95% 92.16%, 70.64% 94.25%, 69.02% 95.91%, 67.10% 96.98%, 64.94% 97.40%, 62.64% 97.16%, 60.29% 96.40%, 57.99% 95.30%, 55.80% 94.09%, 53.76% 93.01%, 51.85% 92.26%, 50% 92%, 48.15% 92.26%, 46.24% 93.01%, 44.20% 94.09%, 42.01% 95.30%, 39.71% 96.40%, 37.36% 97.16%, 35.06% 97.40%, 32.90% 96.98%, 30.98% 95.91%, 29.36% 94.25%, 28.05% 92.16%, 27% 89.84%, 26.11% 87.50%, 25.24% 85.36%, 24.25% 83.56%, 23% 82.17%, 21.42% 81.19%, 19.47% 80.53%, 17.21% 80.04%, 14.76% 79.57%, 12.29% 78.93%, 10% 78.01%, 8.09% 76.70%, 6.70% 75%, 5.92% 72.95%, 5.75% 70.64%, 6.09% 68.19%, 6.77% 65.73%, 7.59% 63.37%, 8.30% 61.17%, 8.70% 59.16%, 8.64% 57.29%, 8.06% 55.52%, 6.99% 53.76%, 5.57% 51.94%, 4% 50%, 2.51% 47.93%, 1.36% 45.74%, 0.73% 43.51%, 0.76% 41.32%, 1.48% 39.24%, 2.84% 37.36%, 4.67% 35.71%, 6.77% 34.27%, 8.92% 32.98%, 10.87% 31.75%, 12.48% 30.47%, 13.63% 29%, 14.32% 27.27%, 14.64% 25.24%, 14.72% 22.93%, 14.76% 20.43%, 14.96% 17.89%, 15.47% 15.47%, 16.43% 13.36%, 17.86% 11.70%, 19.75% 10.57%, 21.99% 10%, 24.46% 9.91%, 27% 10.16%, 29.47% 10.56%, 31.75% 10.87%, 33.81% 10.92%, 35.64% 10.53%, 37.28% 9.65%, 38.83% 8.30%, 40.38% 6.58%, 42.01% 4.70%, 43.80% 2.88%, 45.74% 1.36%, 47.83% 0.35%); + --lib-cookie-12: polygon(50% 0%, 52.17% 0.32%, 54.27% 1.19%, 56.27% 2.41%, 58.16% 3.71%, 60.01% 4.83%, 61.91% 5.57%, 63.91% 5.87%, 66.07% 5.83%, 68.37% 5.65%, 70.71% 5.59%, 72.96% 5.89%, 75% 6.70%, 76.72% 8.06%, 78.11% 9.86%, 79.22% 11.92%, 80.21% 14%, 81.26% 15.89%, 82.53% 17.47%, 84.11% 18.74%, 86% 19.79%, 88.08% 20.78%, 90.14% 21.89%, 91.94% 23.28%, 93.30% 25%, 94.11% 27.04%, 94.41% 29.29%, 94.35% 31.63%, 94.17% 33.93%, 94.13% 36.09%, 94.43% 38.09%, 95.17% 39.99%, 96.29% 41.84%, 97.59% 43.73%, 98.81% 45.73%, 99.68% 47.83%, 100% 50%, 99.68% 52.17%, 98.81% 54.27%, 97.59% 56.27%, 96.29% 58.16%, 95.17% 60.01%, 94.43% 61.91%, 94.13% 63.91%, 94.17% 66.07%, 94.35% 68.37%, 94.41% 70.71%, 94.11% 72.96%, 93.30% 75%, 91.94% 76.72%, 90.14% 78.11%, 88.08% 79.22%, 86% 80.21%, 84.11% 81.26%, 82.53% 82.53%, 81.26% 84.11%, 80.21% 86%, 79.22% 88.08%, 78.11% 90.14%, 76.72% 91.94%, 75% 93.30%, 72.96% 94.11%, 70.71% 94.41%, 68.37% 94.35%, 66.07% 94.17%, 63.91% 94.13%, 61.91% 94.43%, 60.01% 95.17%, 58.16% 96.29%, 56.27% 97.59%, 54.27% 98.81%, 52.17% 99.68%, 50% 100%, 47.83% 99.68%, 45.73% 98.81%, 43.73% 97.59%, 41.84% 96.29%, 39.99% 95.17%, 38.09% 94.43%, 36.09% 94.13%, 33.93% 94.17%, 31.63% 94.35%, 29.29% 94.41%, 27.04% 94.11%, 25% 93.30%, 23.28% 91.94%, 21.89% 90.14%, 20.78% 88.08%, 19.79% 86%, 18.74% 84.11%, 17.47% 82.53%, 15.89% 81.26%, 14% 80.21%, 11.92% 79.22%, 9.86% 78.11%, 8.06% 76.72%, 6.70% 75%, 5.89% 72.96%, 5.59% 70.71%, 5.65% 68.37%, 5.83% 66.07%, 5.87% 63.91%, 5.57% 61.91%, 4.83% 60.01%, 3.71% 58.16%, 2.41% 56.27%, 1.19% 54.27%, 0.32% 52.17%, 0% 50%, 0.32% 47.83%, 1.19% 45.73%, 2.41% 43.73%, 3.71% 41.84%, 4.83% 39.99%, 5.57% 38.09%, 5.87% 36.09%, 5.83% 33.93%, 5.65% 31.63%, 5.59% 29.29%, 5.89% 27.04%, 6.70% 25%, 8.06% 23.28%, 9.86% 21.89%, 11.92% 20.78%, 14% 19.79%, 15.89% 18.74%, 17.47% 17.47%, 18.74% 15.89%, 19.79% 14%, 20.78% 11.92%, 21.89% 9.86%, 23.28% 8.06%, 25% 6.70%, 27.04% 5.89%, 29.29% 5.59%, 31.63% 5.65%, 33.93% 5.83%, 36.09% 5.87%, 38.09% 5.57%, 39.99% 4.83%, 41.84% 3.71%, 43.73% 2.41%, 45.73% 1.19%, 47.83% 0.32%); + --lib-title-size: clamp(36px, 2.4vw + 18px, 52px); + --lib-hero-title-size: clamp(32px, 2.6vw + 16px, 56px); +} + +/* ---- View headers --------------------------------------------------------- */ +.view-header { align-items: baseline; flex-wrap: wrap; gap: 8px 14px; padding: 28px 28px 16px; } +.view-header h1 { + font-size: var(--lib-title-size); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; + min-width: 0; overflow-wrap: anywhere; +} +/* The item count: a small tonal chip riding the title's baseline. */ +.view-header .count-chip { + display: inline-flex; align-items: center; height: 26px; padding: 0 12px; border-radius: var(--shape-full); + background: var(--accent-soft); color: var(--accent-text); font-weight: 700; font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; position: relative; top: -6px; +} +.view-header .count-chip:empty { display: none; } +.view-header .actions { align-self: center; gap: 8px; } +.view-header .actions .btn:not(.icon) { height: 40px; padding: 0 20px 0 16px; font-size: var(--fs-md); font-weight: 600; } +.view-header .actions .btn.tonal { color: var(--fg); } + +/* Sort: a connected button group — a pill select joined to the direction + toggle, each end fully round, the inner corners tight (M3 Expressive). */ +.sort-group { display: inline-flex; align-items: center; gap: 2px; } +.sort-group .select { + height: 40px; padding: 0 30px 0 16px; border-radius: var(--shape-full) var(--shape-xs) var(--shape-xs) var(--shape-full); + background: var(--accent-soft); box-shadow: none; border-color: transparent; font-weight: 600; color: var(--fg); +} +.sort-group .select:hover { background: var(--selection); box-shadow: none; } +.sort-group .select:focus { border-color: transparent; } +.sort-group .select:active { border-radius: var(--shape-sm); } +.sort-group .btn.icon:where(:not(.player *, .fullscreen *)) { + width: 44px; height: 40px; border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); + background: var(--accent-soft); color: var(--fg); +} +.sort-group .btn.icon:where(:not(.player *, .fullscreen *)):hover { background: var(--selection); } +.sort-group .btn.icon[aria-pressed="true"]:where(:not(.player *, .fullscreen *)) { border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); color: var(--accent-text); } +.sort-group .btn.icon:where(:not(.player *, .fullscreen *)):active { border-radius: var(--shape-sm); } + +/* ---- Grids and tiles ------------------------------------------------------ */ +.view-body { padding: 4px 20px 24px; } +.view-body.no-pad { padding: 0; } +.tile { + gap: 8px; padding: 8px; border-radius: var(--shape-xl); + transition: background-color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); +} +.tile-main { gap: 8px; } +.tile:hover { background: var(--accent-soft); } +.tile.selected { background: var(--selection); } +.tile.selected:hover { background: var(--selection); } +.tile .art { + border-radius: var(--shape-lg); + transition: border-radius var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy), box-shadow var(--dur-short) var(--ease-emphasized), clip-path var(--dur-spring-long) var(--spring-fast); +} +/* Hover: the art rounds into a squircle and lifts on a bouncy spring. */ +.tile:hover .art:not(.round) { border-radius: 36px; } +.tile:hover .art { transform: translateY(-3px) scale(1.02); box-shadow: var(--shadow-2); } +/* Selected: the art settles back toward a squarer shape, like a selected control. */ +.tile.selected .art:not(.round) { border-radius: var(--shape-md); } +.tile .t1 { font-weight: 600; font-size: var(--fs-md); margin-top: 2px; } +.tile .t2 { margin-top: -6px; } +.tile-main .t2 { margin-top: -6px; } +/* Artists: a scalloped cookie that loosens into a softer 12-scallop one under the pointer. */ +.tile .art.round, .detail-head .art.round { border-radius: 0; clip-path: var(--lib-cookie-9); box-shadow: none; } +.tile:hover .art.round { clip-path: var(--lib-cookie-12); box-shadow: none; transform: scale(1.03) rotate(0deg); } + +/* The play FAB: a big squircle on the art's corner that morphs to a circle when pointed at. */ +.tile .play { + width: 48px; height: 48px; right: 18px; top: calc(100cqw - 50px); border-radius: 16px; + transform: scale(0.5) translateY(8px); +} +.tile:hover .play, .tile:focus-within .play { transform: none; } +.tile .play:hover { border-radius: 50%; box-shadow: var(--shadow); } +.tile .play:active { border-radius: var(--shape-sm); transform: scale(0.9); } +.tile-badge { top: 14px !important; left: 14px !important; padding: 5px; } + +/* ---- Home: carousels under expressive section headings ------------------- */ +.home-section { margin-bottom: 28px; } +.home-section:last-child { margin-bottom: 8px; } +.view-body .section-heading { + display: flex; align-items: center; gap: 12px; font-size: 22px; font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; margin: 0 0 10px 8px; +} +.section-icon { + display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto; + clip-path: var(--lib-cookie-9); background: var(--selection); color: var(--accent-text); + transition: clip-path var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring-long) var(--spring-bouncy); +} +.home-section:hover .section-icon { clip-path: var(--lib-cookie-12); transform: rotate(20deg); } +.home-section .scroller { + gap: 6px; padding: 6px 4px 12px; margin: -6px -4px 0; scroll-snap-type: x proximity; scroll-padding-inline: 4px; +} +.home-section .scroller .tile { width: 172px; scroll-snap-align: start; } + +/* ---- Detail heroes -------------------------------------------------------- */ +.detail-head { + margin: 12px 16px 12px; padding: 28px; gap: 28px; align-items: flex-end; border-radius: var(--shape-xl); + background-color: var(--bg-elev); + background-image: linear-gradient(135deg, var(--accent-soft) 0%, var(--surface-tonal) 72%); +} +/* Big squircle artwork. */ +.detail-head .art { width: 216px; height: 216px; border-radius: 48px; box-shadow: var(--shadow); } +.detail-head .art.round { width: 228px; height: 228px; } +.detail-head .meta { gap: 8px; flex: 1 1 auto; } +/* Eyebrow ("Albums", "Smart playlist"): a chip on the elevated surface. */ +.detail-head .meta > .muted.small:first-child { + align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: var(--shape-full); + background: var(--bg-elev); font-weight: 600; letter-spacing: 0.01em; +} +.detail-head .meta h1 { + font-size: var(--lib-hero-title-size); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; padding-bottom: 2px; + white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; +} +.detail-head .meta > div:not(.actions) { font-size: var(--fs-lg); } +.detail-head .meta > .small { font-size: var(--fs-sm); } +.detail-head .plain-link { font-weight: 700; } +.detail-head .meta .actions { gap: 10px; margin-top: 16px; flex-wrap: wrap; } +/* Play: a large extended FAB, squircle at rest, morphing to a full pill when pointed at. */ +.detail-head .meta .actions .btn.fab { + height: 56px; padding: 0 28px 0 20px; gap: 8px; border-radius: 18px; font-size: 17px; font-weight: 700; box-shadow: var(--shadow-2); +} +.detail-head .meta .actions .btn.fab:hover { border-radius: 28px; box-shadow: var(--shadow); } +.detail-head .meta .actions .btn.fab:active { border-radius: var(--shape-md); } +.detail-head .meta .actions .btn.tonal { height: 44px; padding: 0 18px 0 14px; font-size: var(--fs-lg); font-weight: 600; background: var(--bg-elev); } +.detail-head .meta .actions .btn.tonal:hover { background: var(--state-selected); } +/* Secondary actions: tonal icon buttons. */ +.detail-head .meta .actions .btn.icon { width: 44px; height: 44px; background: var(--bg-elev); } +.detail-head .meta .actions .btn.icon:hover { background: var(--state-selected); } +.detail-head .meta .actions .btn.icon svg { width: 22px; height: 22px; } +.detail-head .meta .actions .btn.icon:where(.on, [aria-pressed="true"]) { border-radius: var(--shape-md); } +.detail-head .meta .actions .stars { padding: 0 12px; height: 44px; align-items: center; border-radius: var(--shape-full); background: var(--bg-elev); } +.detail-head .meta .actions .stars svg { width: 18px; height: 18px; } +/* Artist pages: the section headings sit in the body, the top songs in a sunken well. */ +[data-testid="view-artist"] .view-body .section-heading { margin: 8px 0 10px 8px; } +[data-testid="view-artist"] .view-body .section-heading ~ .section-heading { margin-top: 28px; } + +/* ---- Track tables --------------------------------------------------------- */ +.table-head { + height: 34px; margin: 0 4px 6px; padding: 0 8px; border: 0; border-radius: var(--shape-full); + background: var(--bg-sunken); color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 600; letter-spacing: 0.01em; +} +.table-head .th.sortable:hover { background: var(--bg-hover); } +.table-head .th.sorted { color: var(--fg); } +.table-head .th.sorted .th-btn { background: var(--state-selected); color: var(--fg); } +.table-head .th.sortable .th-btn { transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); } +.table-head .th.sortable .th-btn:active { border-radius: var(--shape-sm); } +.tr { + border-radius: 15px; + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.tr:hover { background: var(--bg-hover); } +/* Selected rows morph toward a squarer shape; the playing row is filled tonally. */ +.tr.selected { background: var(--selection); border-radius: var(--shape-sm); } +.tr.playing { background: var(--accent-soft); } +.tr.playing:hover, .tr.playing.selected { background: var(--selection); } +.tr.playing .td.title { color: var(--fg); font-weight: 700; } +.tr.playing .td.index .icon { color: var(--accent-text); } +.tr .art { width: 24px; height: 24px; border-radius: 8px; } +.tr .row-play { background: var(--accent); color: var(--accent-fg); width: 24px; inset: 3px 0 3px auto; border-radius: 9px; transition: border-radius var(--dur-spring) var(--spring-fast), opacity var(--dur-short) var(--ease-emphasized); } +.tr .row-play:hover { background: var(--accent); border-radius: 50%; } +.table .empty .empty-cell { display: flex; flex-direction: column; align-items: center; gap: 12px; } + +/* ---- Genres: colourful tonal cards ---------------------------------------- */ +.genre-grid { --tile-w: 200px; gap: 12px; } +.genre-tile { + position: relative; min-height: 120px; justify-content: flex-start; gap: 4px; + padding: 18px 16px 16px 20px; border: 0; border-radius: var(--shape-xl); background: var(--bg-sunken); +} +.genre-tile:hover { background: var(--accent-soft); border-radius: var(--shape-lg); } +.genre-tile:active { border-radius: var(--shape-md); } +.genre-tile .genre-name { font-size: var(--fs-lg); font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; overflow-wrap: anywhere; } +/* Decorative only: a flex item below the text, so it never sits behind it. */ +.genre-tile::after { + content: ""; flex: 0 0 auto; align-self: flex-end; margin-top: auto; width: 44px; height: 44px; + clip-path: var(--lib-cookie-9); background: hsl(var(--genre-hue, 250) 62% 58%); + transition: transform var(--dur-spring-long) var(--spring-bouncy), clip-path var(--dur-spring-long) var(--spring-fast); +} +.genre-tile:hover::after { transform: rotate(40deg) scale(1.15); clip-path: var(--lib-cookie-12); } + +/* ---- Search --------------------------------------------------------------- */ +.search-songs .section-heading { margin-left: 8px; } + +/* ---- Empty states: one glyph in a tonal cookie ---------------------------- */ +.empty { gap: 12px; font-size: var(--fs-lg); max-width: 520px; margin: 0 auto; } +.empty-icon { + display: inline-flex; align-items: center; justify-content: center; width: 80px; height: 80px; flex: 0 0 auto; + clip-path: var(--lib-cookie-9); background: var(--accent-soft); color: var(--accent-text); +} +.empty-cell { display: flex; flex-direction: column; align-items: center; gap: 12px; } + +/* ---- Stars and hearts ----------------------------------------------------- */ +.heart { transition: transform var(--dur-spring) var(--spring-bouncy), background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); } +.heart.on svg { animation: lib-heart-pop var(--dur-spring-long) var(--spring-bouncy); } +@keyframes lib-heart-pop { from { transform: scale(0.6); } to { transform: none; } } + +/* ---- Narrow windows and 200% zoom ----------------------------------------- */ +@media (max-width: 900px) { + .view-header { padding: 16px 14px 10px; } + .view-header .actions { margin-left: 0; } + .view-body { padding: 4px 8px 16px; } + .detail-head { margin: 8px; padding: 16px; gap: 16px; align-items: flex-start; } + .detail-head .art, .detail-head .art.round { width: 128px; height: 128px; border-radius: 32px; } + .detail-head .art.round { border-radius: 0; } + .detail-head .meta { flex: 1 1 220px; } + .detail-head .meta .actions { margin-top: 8px; } + .detail-head .meta .actions .btn.fab { height: 52px; padding: 0 22px 0 18px; border-radius: 18px; } + .home-section .scroller { margin: 0; padding: 0; } + .home-section .scroller .tile { width: 150px; } + .genre-grid { --tile-w: 160px; } +} + +/* Short windows (200% zoom): a compact hero, and the page scrolls as a whole + so the hero's controls are never cut off; the track list keeps a bounded + height of its own for virtualisation. */ +@media (max-height: 640px) { + .detail-head { padding: 12px 14px; gap: 14px; margin: 6px 8px; } + .detail-head .art, .detail-head .art.round { width: 88px; height: 88px; border-radius: 24px; } + .detail-head .art.round { border-radius: 0; } + .detail-head .meta h1 { font-size: 28px; } + .detail-head .meta > div:not(.actions) { font-size: var(--fs-md); } + .detail-head .meta .actions { margin-top: 6px; gap: 8px; } + .detail-head .meta .actions .btn.fab { height: 44px; padding: 0 18px 0 14px; border-radius: 14px; font-size: var(--fs-lg); } + .detail-head .meta .actions .btn.fab:hover { border-radius: 22px; } + .detail-head .meta .actions .btn.tonal, .detail-head .meta .actions .btn.icon, .detail-head .meta .actions .stars { height: 40px; } + .detail-head .meta .actions .btn.icon { width: 40px; } + .view:has(> .detail-head) { overflow-y: auto; } + .view:has(> .detail-head) > .view-body { flex: 0 0 auto; height: max(240px, 75vh); } +} + +/* ---- Reduced motion: no lifts, spins or pops (colour changes stay) ------- */ +@media (prefers-reduced-motion: reduce) { + .tile:hover .art, .tile:hover .art.round, .tile .play, .tile:hover .play, .tile .play:active, + .home-section:hover .section-icon, .genre-tile:hover::after, .heart.on svg { transform: none !important; animation: none !important; } +} + +/* ---- Forced colours -------------------------------------------------------- */ +@media (forced-colors: active) { + .tr.selected, .tr.playing.selected, .tr.playing:hover, .tile.selected, .tile.selected:hover { + forced-color-adjust: none; background: Highlight; color: HighlightText; + } + .tr.playing:not(.selected) { outline: 1px solid CanvasText; outline-offset: -1px; } + .detail-head, .genre-tile, .table-head, .view-header .count-chip, .sort-group .select { border: 1px solid CanvasText; } + .genre-tile::after, .section-icon, .empty-icon { forced-color-adjust: none; background: GrayText; color: Canvas; } + .tr, .tile, .genre-tile, .tile .art { transition: none; } +} diff --git a/desktop/src/renderer/views/AlbumDetail.tsx b/desktop/src/renderer/views/AlbumDetail.tsx index ab3d12c..0aec169 100644 --- a/desktop/src/renderer/views/AlbumDetail.tsx +++ b/desktop/src/renderer/views/AlbumDetail.tsx @@ -36,8 +36,8 @@ export function AlbumDetail({ id }: { id: string }) { · {a?.year ?? ""}{a?.genre ? ` · ${a.genre}` : ""} · {t("album.tracks", { count: a?.songCount ?? 0, duration: fmtDuration(a?.durationMs ?? 0) })}
- - + + {a ? bridge().dispatch({ type: "setLoved", data: { targets: [{ type: "album", data: { id: a.id } }], loved: !a.loved } })} /> : null} {a ? bridge().dispatch({ type: "setRating", data: { targets: [{ type: "album", data: { id: a.id } }], rating: r } })} /> : null} diff --git a/desktop/src/renderer/views/Albums.tsx b/desktop/src/renderer/views/Albums.tsx index fa98d09..5daf63d 100644 --- a/desktop/src/renderer/views/Albums.tsx +++ b/desktop/src/renderer/views/Albums.tsx @@ -27,12 +27,14 @@ export function Albums({ artistId, genre, title }: { artistId?: string; genre?:

{title ?? t("albums.title")}

- {total} + {total}
+
- + +
navigate({ view: "album", id: it.id })} onPlay={play} onNeedRange={onNeedRange} emptyMessage={t("albums.empty")} testId="albums-grid" label={title ?? t("albums.title")} /> diff --git a/desktop/src/renderer/views/ArtistDetail.tsx b/desktop/src/renderer/views/ArtistDetail.tsx index e7d3b84..c9c4a8d 100644 --- a/desktop/src/renderer/views/ArtistDetail.tsx +++ b/desktop/src/renderer/views/ArtistDetail.tsx @@ -29,8 +29,8 @@ export function ArtistDetail({ id }: { id: string }) {
{a ? t("artist.albumsCount", { albums: a.albumCount, songs: a.songCount }) : ""}
{a?.biography ?
{a.biography}
: null}
- - + + {a ? bridge().dispatch({ type: "setArtistLoved", data: { artist_id: a.id, loved: !a.loved } })} /> : null}
@@ -38,13 +38,13 @@ export function ArtistDetail({ id }: { id: string }) {
{topList.length ? ( <> -

{t("artist.topSongs")}

-
+

{t("artist.topSongs")}

+
bridge().dispatch({ type: "playTracks", data: { server_id: serverId, track_ids: topList.map((x) => x.id), start_index: i, label: `${a?.name ?? ""} · ${t("artist.topSongs")}`, shuffle: false } })} playingTrackId={playing} testId="artist-top" />
) : null} -

{t("artist.albums")}

+

{t("artist.albums")}

{(albums.data?.items ?? []).map((al) => ( navigate({ view: "album", id: al.id })} onContextMenu={(e) => void openContextMenu(e, { type: "albums", data: { ids: [al.id] } })} testId="artist-album" /> diff --git a/desktop/src/renderer/views/Artists.tsx b/desktop/src/renderer/views/Artists.tsx index c69c046..1b22ef5 100644 --- a/desktop/src/renderer/views/Artists.tsx +++ b/desktop/src/renderer/views/Artists.tsx @@ -19,7 +19,7 @@ export function Artists() { const play = (it: GridItem) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "artist", data: { id: it.id } }, label: it.title, sort: "default", tracks: [] }, startIndex: 0, shuffle: true, saveOutgoing: true } } }); return (
-

{t("artists.title")}

{total}
+

{t("artists.title")}

{total}
navigate({ view: "artist", id: it.id })} onPlay={play} onNeedRange={onNeedRange} tileWidth={140} emptyMessage={t("artists.empty")} testId="artists-grid" label={t("artists.title")} />
); diff --git a/desktop/src/renderer/views/Genres.tsx b/desktop/src/renderer/views/Genres.tsx index dee21a2..13ca1c5 100644 --- a/desktop/src/renderer/views/Genres.tsx +++ b/desktop/src/renderer/views/Genres.tsx @@ -10,15 +10,15 @@ export function Genres() { const { data, loading } = useQuery(() => ({ type: "genres", data: { server_id: serverId } }), "genres", [serverId]); return (
-

{t("genres.title")}

{data?.length ?? ""}
+

{t("genres.title")}

{data?.length ? {data.length} : null}
- {!loading && !data?.length ? : null} + {!loading && !data?.length ? : null} {(data ?? []).map((g) => (
  • -
  • ))} @@ -27,3 +27,10 @@ export function Genres() {
    ); } + +/** A stable hue per genre name, for the decorative shape on its card (never behind text). */ +function genreHue(name: string): number { + let h = 0; + for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + return (h % 24) * 15; +} diff --git a/desktop/src/renderer/views/Home.tsx b/desktop/src/renderer/views/Home.tsx index c5a3910..5a35517 100644 --- a/desktop/src/renderer/views/Home.tsx +++ b/desktop/src/renderer/views/Home.tsx @@ -6,6 +6,7 @@ import { bridge } from "../core/bridge"; import { EmptyState } from "../components/EmptyState"; import { openContextMenu } from "../components/ContextMenu"; import { Tile, TileList } from "../components/Tile"; +import { Icon } from "../components/Icon"; import { usePlayIntent } from "../lib/use-prime"; export function Home() { @@ -29,22 +30,22 @@ export function Home() {

    {t("home.title")}

    {sync && !sync.finished ? {t("sync.syncing", { phase: sync.phase })} : null}
    - {empty ? : null} -
    {(played.data ?? []).map((h, i) => trackTile(h.track, i))}
    -
    {(recent.data?.items ?? []).map(albumTile)}
    -
    {(most.data?.items ?? []).filter((a) => a.playCount > 0).map(albumTile)}
    -
    {(random.data?.items ?? []).map(albumTile)}
    + {empty ? : null} +
    {(played.data ?? []).map((h, i) => trackTile(h.track, i))}
    +
    {(recent.data?.items ?? []).map(albumTile)}
    +
    {(most.data?.items ?? []).filter((a) => a.playCount > 0).map(albumTile)}
    +
    {(random.data?.items ?? []).map(albumTile)}
    ); } -function Section({ title, children }: { title: string; children: React.ReactNode[] }) { +function Section({ title, icon, children }: { title: string; icon: string; children: React.ReactNode[] }) { if (!children.length) return null; const id = `home-${title.replace(/\W+/g, "-").toLowerCase()}`; return (
    -

    {title}

    +

    {title}

    {children}
    ); diff --git a/desktop/src/renderer/views/PlaylistDetail.tsx b/desktop/src/renderer/views/PlaylistDetail.tsx index 005f437..93dab8e 100644 --- a/desktop/src/renderer/views/PlaylistDetail.tsx +++ b/desktop/src/renderer/views/PlaylistDetail.tsx @@ -35,8 +35,8 @@ export function PlaylistDetail({ id }: { id: string }) {
    {t("playlist.tracks", { count: p?.songCount ?? 0, duration: fmtDuration(p?.durationMs ?? 0) })}{p?.comment ? ` · ${p.comment}` : ""}
    {p?.isSmart ?
    {t("playlists.readOnly")}
    : null}
    - - + +
    diff --git a/desktop/src/renderer/views/Playlists.tsx b/desktop/src/renderer/views/Playlists.tsx index 5905456..fe1af90 100644 --- a/desktop/src/renderer/views/Playlists.tsx +++ b/desktop/src/renderer/views/Playlists.tsx @@ -16,8 +16,8 @@ export function Playlists() {

    {t("playlists.title")}

    - {items.length} -
    + {items.length} +
    navigate({ view: "playlist", id: it.id })} onPlay={play} emptyMessage={t("playlists.empty")} testId="playlists-grid" label={t("playlists.title")} />
    diff --git a/desktop/src/renderer/views/SearchView.tsx b/desktop/src/renderer/views/SearchView.tsx index 897d959..46c3bdf 100644 --- a/desktop/src/renderer/views/SearchView.tsx +++ b/desktop/src/renderer/views/SearchView.tsx @@ -20,7 +20,7 @@ export function SearchView({ query }: { query: string }) { const list = tracks.data ?? []; return (
    -

    {query}

    {list.length}
    +

    {query}

    {list.length}
    {(local.data?.albums.length || server?.albums.length) ? (
    @@ -30,9 +30,10 @@ export function SearchView({ query }: { query: string }) { ))}
    ) : null} -
    +
    +

    {t("search.tracks")}

    bridge().dispatch({ type: "playTracks", data: { server_id: serverId, track_ids: list.map((x) => x.id), start_index: i, label: query, shuffle: false } })} emptyMessage={t("search.noResults", { query })} /> -
    +
    ); diff --git a/desktop/src/renderer/views/Songs.tsx b/desktop/src/renderer/views/Songs.tsx index 6a55a91..22c807c 100644 --- a/desktop/src/renderer/views/Songs.tsx +++ b/desktop/src/renderer/views/Songs.tsx @@ -25,7 +25,7 @@ export function Songs() { }; return (
    -

    {t("songs.title")}

    {total}
    +

    {t("songs.title")}

    {total}
    { setSort(s); setDesc(d); saveLocal("songs.sort", s); saveLocal("songs.desc", d); }} onNeedRange={onNeedRange} onPlay={onPlay} playingTrackId={playing} emptyMessage={t("songs.empty")} testId="songs-table" />
    From ca34fbed78e920ad7e207b8a6d53d6ef2aee0fa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:31:27 +0000 Subject: [PATCH 13/17] desktop: custom M3 Expressive slider, switch and select everywhere Native form controls are replaced by our own, drawn to Material 3 Expressive and shared by every screen (components/controls.tsx, styles/controls.css): - Slider: thick rounded track split by a gap around a bar handle, stop dot, value bubble while dragged or focused; horizontal (volume in the player bar and fullscreen player, settings) and vertical (equaliser bands). A transparent native range input on top keeps keys, assistive tech and tests unchanged. - Switch: outlined when off; filled with an always-white handle and a check when on (role=switch over a native checkbox). - Select: a select-only combobox with an M3 menu (check + tonal fill on the chosen item, type-ahead, Home/End/Page keys), which follows its field on scroll instead of closing; same
    ); } diff --git a/desktop/src/renderer/components/controls.tsx b/desktop/src/renderer/components/controls.tsx new file mode 100644 index 0000000..550e5fc --- /dev/null +++ b/desktop/src/renderer/components/controls.tsx @@ -0,0 +1,252 @@ +// Material 3 Expressive form controls, drawn by us rather than the platform: +// +// - Slider: a thick rounded track split by a gap around a bar-shaped handle, +// a stop dot at the end, an optional value bubble. A transparent native +// lies over it, so the keyboard, assistive tech, +// form semantics and tests behave exactly as with the plain element. +// - Switch: an M3 switch (outlined when off; filled with a white handle +// carrying a check when on) over a transparent native checkbox (role +// "switch"), for the same reasons. +// - Select: a select-only combobox (ARIA 1.2 pattern) with an M3 menu of +// options, replacing the platform's onChange(Number(e.target.value))} onKeyDown={onKeyDown} aria-orientation={orientation === "vertical" ? "vertical" : undefined} {...aria} /> +
    + ); +} + +// ---- Switch --------------------------------------------------------------------------------- + +export interface SwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + id?: string; + className?: string; + "aria-label"?: string; + "aria-describedby"?: string; + "data-testid"?: string; +} + +export function Switch({ checked, onChange, disabled, id, className, ...aria }: SwitchProps) { + return ( + + onChange(e.target.checked)} {...aria} /> + + + ); +} + +// ---- Select --------------------------------------------------------------------------------- + +interface Opt { + value: string; + label: ReactNode; + text: string; + disabled: boolean; +} + +const textOf = (n: ReactNode): string => (typeof n === "string" || typeof n === "number" ? String(n) : Array.isArray(n) ? n.map(textOf).join("") : isValidElement<{ children?: ReactNode }>(n) ? textOf(n.props.children) : ""); + +function optionsOf(children: ReactNode): Opt[] { + const out: Opt[] = []; + Children.forEach(children, (c) => { + if (!isValidElement(c)) return; + const el = c as ReactElement<{ value?: string | number; children?: ReactNode; disabled?: boolean }>; + if (el.type === "option") out.push({ value: String(el.props.value ?? textOf(el.props.children)), label: el.props.children, text: textOf(el.props.children), disabled: !!el.props.disabled }); + else if (el.props.children) out.push(...optionsOf(el.props.children)); + }); + return out; +} + +export interface SelectProps { + value: string | number; + onChange: (e: { target: { value: string } }) => void; + children: ReactNode; + disabled?: boolean; + className?: string; + style?: CSSProperties; + id?: string; + "aria-label"?: string; + "aria-labelledby"?: string; + "data-testid"?: string; +} + +/** A select-only combobox with an M3 menu. Keys: Enter/Space/Alt+↓/↑/↓ open; ↑/↓/Home/End/type-ahead move; Enter/Space pick; Escape/Tab close. */ +export function Select({ value, onChange, children, disabled, className, style, id, ...aria }: SelectProps) { + const options = optionsOf(children); + const current = options.find((o) => o.value === String(value)); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const [pos, setPos] = useState<{ left: number; top?: number; bottom?: number; minWidth: number; maxHeight: number } | undefined>(undefined); + const button = useRef(null); + const list = useRef(null); + const typed = useRef({ text: "", at: 0 }); + const listId = useId(); + const optId = (i: number) => `${listId}-o${i}`; + + const place = () => { + const r = button.current?.getBoundingClientRect(); + if (!r) return; + const below = window.innerHeight - r.bottom - 12; + const above = r.top - 12; + const up = below < 220 && above > below; + setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - Math.max(r.width, 180) - 8)), minWidth: r.width, maxHeight: Math.min(360, up ? above : below), ...(up ? { bottom: window.innerHeight - r.top + 6 } : { top: r.bottom + 6 }) }); + }; + const openMenu = (at?: number) => { + if (disabled || !options.length) return; + place(); + const i = at ?? Math.max(0, options.findIndex((o) => o.value === String(value))); + setActive(i); + setOpen(true); + }; + const close = (refocus = true) => { + setOpen(false); + if (refocus) button.current?.focus({ preventScroll: true }); + }; + const pick = (i: number) => { + const o = options[i]; + if (!o || o.disabled) return; + if (o.value !== String(value)) onChange({ target: { value: o.value } }); + close(); + }; + const move = (from: number, dir: 1 | -1) => { + for (let k = 1; k <= options.length; k++) { + const i = (from + dir * k + options.length) % options.length; + if (!options[i]?.disabled) return i; + } + return from; + }; + const typeahead = (key: string) => { + const now = Date.now(); + typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : "") + key.toLowerCase(), at: now }; + const start = open ? active : Math.max(0, options.findIndex((o) => o.value === String(value))); + for (let k = 0; k < options.length; k++) { + const i = (start + (typed.current.text.length === 1 ? 1 : 0) + k) % options.length; + if (options[i]?.text.toLowerCase().startsWith(typed.current.text)) return i; + } + return undefined; + }; + + // Close on an outside pointer; follow the field on scroll and resize. + useEffect(() => { + if (!open) return; + const down = (e: PointerEvent) => { + const t = e.target as Node; + if (!list.current?.contains(t) && !button.current?.contains(t)) close(false); + }; + // Scrolling elsewhere (or a live page nudging its scroll position) keeps + // the menu with its field; it only closes once the field leaves the window. + let raf = 0; + const follow = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const r = button.current?.getBoundingClientRect(); + if (!r || r.bottom < 0 || r.top > window.innerHeight) close(false); + else place(); + }); + }; + const scroll = (e: Event) => { if (!list.current?.contains(e.target as Node)) follow(); }; + const resize = follow; + document.addEventListener("pointerdown", down, true); + window.addEventListener("scroll", scroll, true); + window.addEventListener("resize", resize); + return () => { + document.removeEventListener("pointerdown", down, true); + window.removeEventListener("scroll", scroll, true); + window.removeEventListener("resize", resize); + cancelAnimationFrame(raf); + }; + }, [open]); + useLayoutEffect(() => { + if (open) list.current?.querySelector(`#${CSS.escape(optId(active))}`)?.scrollIntoView({ block: "nearest" }); + }); + + const onKey = (e: React.KeyboardEvent) => { + if (disabled) return; + const k = e.key; + let handled = true; + if (!open) { + if (k === "Enter" || k === " " || k === "ArrowDown" || k === "ArrowUp") openMenu(); + else if (k === "Home") openMenu(0); + else if (k === "End") openMenu(options.length - 1); + else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const i = typeahead(k); if (i !== undefined) openMenu(i); } + else handled = false; + } else if (k === "ArrowDown") setActive(move(active, 1)); + else if (k === "ArrowUp") setActive(e.altKey ? active : move(active, -1)); + else if (k === "Home") setActive(0); + else if (k === "End") setActive(options.length - 1); + else if (k === "PageDown") setActive(Math.min(options.length - 1, active + 8)); + else if (k === "PageUp") setActive(Math.max(0, active - 8)); + else if (k === "Enter" || (k === " " && !typed.current.text)) pick(active); + else if (k === "Escape") close(); + else if (k === "Tab") { setOpen(false); handled = false; } + else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const i = typeahead(k); if (i !== undefined) setActive(i); } + else handled = false; + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + return ( + <> + + {open && pos ? createPortal( +
    e.preventDefault()}> + {options.map((o, i) => ( +
    setActive(i)} onClick={() => pick(i)}> + + {o.label} +
    + ))} +
    , + document.body, + ) : null} + + ); +} diff --git a/desktop/src/renderer/main.tsx b/desktop/src/renderer/main.tsx index fd917ad..8d26313 100644 --- a/desktop/src/renderer/main.tsx +++ b/desktop/src/renderer/main.tsx @@ -4,6 +4,7 @@ import "./styles/global.css"; import "./styles/m3-shell.css"; import "./styles/m3-library.css"; import "./styles/m3-pages.css"; +import "./styles/controls.css"; import "./styles/now-playing.css"; import "@applemusic-like-lyrics/core/style.css"; import { App } from "./App"; diff --git a/desktop/src/renderer/styles/controls.css b/desktop/src/renderer/styles/controls.css new file mode 100644 index 0000000..4b02682 --- /dev/null +++ b/desktop/src/renderer/styles/controls.css @@ -0,0 +1,137 @@ +/* Material 3 Expressive form controls (components/controls.tsx): slider, + switch, select menu; plus number and colour inputs. One look everywhere; + contexts (the fullscreen player, the player bar) only swap the colours. + Loaded after the m3-* sheets, before now-playing.css. */ + +/* ---- Slider ------------------------------------------------------------------ + Geometry: the handle (--hw wide) is centred at --x; the active track runs + from the start to --gap before it, the inactive track from --gap after it + to the end, with a stop dot. The transparent native input on top has a + thumb exactly --hw wide, so pointer positions map to the same values. */ +.m3-slider { + --hw: 4px; --gap: 6px; --track: 14px; --handle-h: 30px; --r-out: 7px; --r-in: 3px; + --active: var(--accent); --inactive: var(--state-selected); --handle: var(--accent); --stop: var(--accent); + --x: calc(var(--hw) / 2 + (100% - var(--hw)) * var(--f, 0)); + position: relative; display: inline-flex; align-items: center; flex: 1 1 140px; min-width: 96px; height: var(--handle-h); +} +.m3-slider.sm { --hw: 3px; --gap: 4px; --track: 6px; --handle-h: 18px; --r-out: 3px; --r-in: 1.5px; } +.m3-slider-active, .m3-slider-inactive { position: absolute; top: 50%; height: var(--track); transform: translateY(-50%); pointer-events: none; } +.m3-slider-active { left: 0; width: max(0px, calc(var(--x) - var(--hw) / 2 - var(--gap))); background: var(--active); border-radius: var(--r-out) var(--r-in) var(--r-in) var(--r-out); } +.m3-slider-inactive { left: min(100%, calc(var(--x) + var(--hw) / 2 + var(--gap))); right: 0; background: var(--inactive); border-radius: var(--r-in) var(--r-out) var(--r-out) var(--r-in); } +/* The stop indicator: a dot at the end of the inactive track. */ +.m3-slider-inactive::after { content: ""; position: absolute; right: calc((var(--track) - 4px) / 2); top: 50%; width: 4px; height: 4px; border-radius: 50%; background: var(--stop); transform: translateY(-50%); } +.m3-slider.sm .m3-slider-inactive::after { display: none; } +.m3-slider-handle { position: absolute; top: 50%; left: var(--x); width: var(--hw); height: var(--handle-h); border-radius: var(--hw); background: var(--handle); transform: translate(-50%, -50%); pointer-events: none; transition: width var(--dur-short) var(--ease-emphasized), height var(--dur-spring) var(--spring-fast); } +.m3-slider:hover .m3-slider-handle { height: calc(var(--handle-h) + 4px); } +.m3-slider:has(.m3-slider-input:active) .m3-slider-handle { width: max(2px, calc(var(--hw) - 2px)); } +.m3-slider > .m3-slider-input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; } +.m3-slider-input::-webkit-slider-runnable-track { height: 100%; background: none; border: 0; } +.m3-slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: var(--hw); height: var(--handle-h); background: none; border: 0; } +.m3-slider:has(.m3-slider-input:focus-visible) { outline: 2px solid var(--focus-ring); outline-offset: 4px; border-radius: var(--shape-sm); } +.m3-slider.disabled { opacity: 0.38; } +.m3-slider.disabled .m3-slider-input { cursor: default; } +/* The value indicator: a filled pill popping up over the handle. */ +.m3-slider-bubble { position: absolute; bottom: calc(100% + 8px); left: 50%; min-width: 36px; padding: 6px 10px; border-radius: var(--shape-full); background: var(--fg); color: var(--bg); font-size: var(--fs-sm); font-weight: 600; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; transform: translateX(-50%) scale(0); transform-origin: 50% 100%; transition: transform var(--dur-spring) var(--spring-fast); } +.m3-slider:has(.m3-slider-input:active) .m3-slider-bubble, .m3-slider:has(.m3-slider-input:focus-visible) .m3-slider-bubble { transform: translateX(-50%) scale(1); } +/* Vertical: the same parts along the y axis, growing upwards (the native + input runs bottom-to-top via its writing mode). */ +.m3-slider.vertical { --y: calc(var(--hw) / 2 + (100% - var(--hw)) * var(--f, 0)); flex: 0 0 auto; width: var(--handle-h); height: 132px; min-width: 0; } +.m3-slider.vertical .m3-slider-active, .m3-slider.vertical .m3-slider-inactive { top: auto; left: 50%; width: var(--track); height: auto; transform: translateX(-50%); } +.m3-slider.vertical .m3-slider-active { bottom: 0; height: max(0px, calc(var(--y) - var(--hw) / 2 - var(--gap))); border-radius: var(--r-in) var(--r-in) var(--r-out) var(--r-out); } +.m3-slider.vertical .m3-slider-inactive { bottom: min(100%, calc(var(--y) + var(--hw) / 2 + var(--gap))); top: 0; right: auto; border-radius: var(--r-out) var(--r-out) var(--r-in) var(--r-in); } +.m3-slider.vertical .m3-slider-inactive::after { right: auto; top: calc((var(--track) - 4px) / 2); left: 50%; transform: translateX(-50%); } +.m3-slider.vertical .m3-slider-handle { top: auto; left: 50%; bottom: var(--y); width: var(--handle-h); height: var(--hw); transform: translate(-50%, 50%); transition: height var(--dur-short) var(--ease-emphasized), width var(--dur-spring) var(--spring-fast); } +.m3-slider.vertical:hover .m3-slider-handle { width: calc(var(--handle-h) + 4px); height: var(--hw); } +.m3-slider.vertical:has(.m3-slider-input:active) .m3-slider-handle { height: max(2px, calc(var(--hw) - 2px)); } +.m3-slider.vertical > .m3-slider-input { writing-mode: vertical-lr; direction: rtl; } +.m3-slider.vertical > .m3-slider-input::-webkit-slider-thumb { width: var(--handle-h); height: var(--hw); } +.m3-slider.vertical .m3-slider-bubble { bottom: calc(100% + 10px); } + +/* In the player bar and on the fullscreen player's artwork-coloured background. */ +.player .m3-slider { --active: var(--fg); --handle: var(--fg); --inactive: var(--bg-active); flex: 0 0 auto; width: 88px; min-width: 0; } +.fullscreen .m3-slider { --active: #fff; --handle: #fff; --stop: #fff; --inactive: rgba(255, 255, 255, 0.28); } +.fullscreen .m3-slider-bubble { background: #fff; color: #121214; } + +/* ---- Switch ------------------------------------------------------------------ + Off: an outlined track with a small handle. On: a filled track with a + white handle carrying a check. The handle grows while pressed. */ +.m3-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 52px; height: 32px; vertical-align: middle; } +.m3-switch > .m3-switch-input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 1; } +.m3-switch-track { position: absolute; inset: 0; border-radius: var(--shape-full); background: var(--bg-sunken); box-shadow: inset 0 0 0 2px var(--fg-muted); transition: background-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized); } +.m3-switch-handle { position: absolute; top: 50%; left: 16px; width: 16px; height: 16px; border-radius: 50%; background: var(--fg-muted); display: grid; place-items: center; color: var(--accent-text); transform: translate(-50%, -50%); transition: left var(--dur-spring) var(--spring-fast), width var(--dur-spring) var(--spring-fast), height var(--dur-spring) var(--spring-fast), background-color var(--dur-short) var(--ease-emphasized); } +.m3-switch-handle .icon { width: 16px; height: 16px; } +.m3-switch.on .m3-switch-track { background: var(--accent); box-shadow: none; } +.m3-switch.on .m3-switch-handle { left: 36px; width: 24px; height: 24px; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); } +.m3-switch:hover .m3-switch-handle { box-shadow: 0 0 0 8px var(--bg-hover); } +.m3-switch.on:hover .m3-switch-handle { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25), 0 0 0 8px color-mix(in srgb, var(--accent) 16%, transparent); } +.m3-switch:has(.m3-switch-input:active) .m3-switch-handle { width: 28px; height: 28px; } +.m3-switch:has(.m3-switch-input:focus-visible) .m3-switch-track { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +.m3-switch.disabled { opacity: 0.38; } +.m3-switch.disabled .m3-switch-input { cursor: default; } +.m3-switch.sm { width: 40px; height: 24px; } +.m3-switch.sm .m3-switch-handle { left: 12px; width: 12px; height: 12px; } +.m3-switch.sm.on .m3-switch-handle { left: 28px; width: 18px; height: 18px; } +.m3-switch.sm .m3-switch-handle .icon { width: 12px; height: 12px; } +label.switch { display: inline-flex; align-items: center; gap: 10px; } + +/* ---- Select ------------------------------------------------------------------ + The field: a filled pill with the value and a chevron that turns when open. + The menu: an M3 menu on the elevated surface; the chosen item carries a + check and a tonal fill, the active one a state layer. */ +.m3-select { + -webkit-appearance: none; appearance: none; font: inherit; color: var(--fg); + display: inline-flex; align-items: center; justify-content: space-between; gap: 6px; max-width: 100%; + height: 36px; padding: 0 8px 0 16px; border: 0; border-radius: var(--shape-full); + background: var(--bg-sunken); box-shadow: inset 0 0 0 1px var(--border); cursor: pointer; text-align: left; + transition: border-radius var(--dur-spring) var(--spring-fast), background-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized); +} +.m3-select:hover { box-shadow: inset 0 0 0 1px var(--border-strong); background: color-mix(in srgb, var(--fg) 4%, var(--bg-sunken)); } +.m3-select.open { border-radius: var(--shape-md); box-shadow: inset 0 0 0 2px var(--accent); } +.m3-select:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +.m3-select:disabled { opacity: 0.38; cursor: default; } +.m3-select-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.m3-select-chevron { flex: 0 0 auto; color: var(--fg-muted); transition: transform var(--dur-spring) var(--spring-fast); } +.m3-select.open .m3-select-chevron { transform: rotate(180deg); color: var(--accent-text); } +.m3-menu { position: fixed; z-index: 120; overflow-y: auto; padding: 6px; background: var(--bg-elev); color: var(--fg); border-radius: var(--shape-lg); box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 2px; transform-origin: top center; animation: m3-menu-in var(--dur-spring) var(--spring-fast); } +@keyframes m3-menu-in { from { transform: scaleY(0.85); } } +.m3-option { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 16px 0 8px; border-radius: var(--shape-md); cursor: pointer; white-space: nowrap; transition: border-radius var(--dur-spring) var(--spring-fast); } +.m3-option-check { display: inline-flex; width: 18px; flex: 0 0 auto; color: var(--accent-text); } +.m3-option.active { background: var(--bg-hover); } +.m3-option.selected { background: var(--state-selected); font-weight: 600; border-radius: var(--shape-full); } +.m3-option[aria-disabled="true"] { opacity: 0.38; cursor: default; } + +/* ---- Number and colour inputs --------------------------------------------- */ +input.input[type="number"] { -moz-appearance: textfield; font-variant-numeric: tabular-nums; } +input.input[type="number"]::-webkit-inner-spin-button, input.input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +input.input[type="date"]::-webkit-calendar-picker-indicator { opacity: 0.7; cursor: pointer; border-radius: var(--shape-xs); } +input.input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; } +input[type="color"] { -webkit-appearance: none; appearance: none; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; background: none; cursor: pointer; box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--border-strong); transition: border-radius var(--dur-spring) var(--spring-fast); } +input[type="color"]:hover { border-radius: var(--shape-md); } +input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; } +input[type="color"]::-webkit-color-swatch { border: 0; border-radius: inherit; } +input[type="color"]:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 6px; } + +/* ---- Forced colours ------------------------------------------------------- */ +@media (forced-colors: active) { + .m3-slider { forced-color-adjust: none; --active: Highlight; --handle: CanvasText; --stop: CanvasText; --inactive: GrayText; } + .m3-slider-bubble { background: Canvas; color: CanvasText; border: 1px solid CanvasText; } + .m3-switch { forced-color-adjust: none; } + .m3-switch-track { background: Canvas; box-shadow: inset 0 0 0 2px CanvasText; } + .m3-switch-handle { background: CanvasText; color: Canvas; } + .m3-switch.on .m3-switch-track { background: Highlight; box-shadow: none; } + .m3-switch.on .m3-switch-handle { background: HighlightText; color: Highlight; } + .m3-select { border: 1px solid ButtonText; } + .m3-menu { border: 1px solid CanvasText; } + .m3-option.active { outline: 1px solid Highlight; } + .m3-option.selected { forced-color-adjust: none; background: Highlight; color: HighlightText; } + input[type="color"] { border: 1px solid ButtonText; } +} + +/* ---- Placement ------------------------------------------------------------ */ +.setting-row .control .m3-slider:not(.vertical) { flex: 0 0 auto; width: 220px; } +.eq .m3-slider:not(.vertical) { flex: 0 0 auto; width: 180px; } +.eq .bands .m3-slider { --track: 12px; --handle-h: 28px; --r-out: 6px; } +.eq .bands .band { gap: 8px; } +.eq label.row { white-space: nowrap; } +/* Open, the menu's outline says where focus is; no second ring. */ +.m3-select.open:focus-visible { outline: none; } diff --git a/desktop/src/renderer/styles/m3-pages.css b/desktop/src/renderer/styles/m3-pages.css index 48597fe..97b65ab 100644 --- a/desktop/src/renderer/styles/m3-pages.css +++ b/desktop/src/renderer/styles/m3-pages.css @@ -18,61 +18,11 @@ } /* Filled rounded fields on these pages: taller, softer, and squarer while focused. */ -:is(.page-settings, .page-filter-builder, .page-stats) :is(.input, .select) { height: 36px; padding: 0 14px; border-radius: var(--shape-md); } -:is(.page-settings, .page-filter-builder, .page-stats) .select { padding-right: 30px; } -:is(.page-settings, .page-filter-builder, .page-stats) :is(.input, .select):focus { border-radius: var(--shape-xs); } +:is(.page-settings, .page-filter-builder, .page-stats) .input { height: 36px; padding: 0 14px; border-radius: var(--shape-md); } +:is(.page-settings, .page-filter-builder, .page-stats) .input:focus { border-radius: var(--shape-xs); } :is(.page-settings, .page-filter-builder, .page-stats) .btn.sm { height: 32px; } -.page-stats .view-header .select { border-radius: var(--shape-full); padding-left: 18px; } -/* ---- M3 switch ------------------------------------------------------------- - A plain checkbox underneath (same role, name, keyboard and label), drawn as - a track with a handle that grows and slides when on and swells when pressed. */ -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"] { - -webkit-appearance: none; appearance: none; position: relative; flex: 0 0 auto; - width: 52px; height: 32px; margin: 0; border-radius: var(--shape-full); - background: var(--bg-elev); box-shadow: inset 0 0 0 2px var(--fg-muted); cursor: default; - transition: background-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized); -} -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]::before { - content: ""; position: absolute; top: 50%; left: 16px; width: 16px; height: 16px; border-radius: 50%; - background: var(--fg-muted); transform: translate(-50%, -50%); - transition: left var(--dur-spring) var(--spring-bouncy), width var(--dur-spring) var(--spring-bouncy), height var(--dur-spring) var(--spring-bouncy), background-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized); -} -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:checked { background: var(--accent); box-shadow: none; } -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:checked::before { left: 36px; width: 24px; height: 24px; background: var(--accent-fg); } -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:hover::before { box-shadow: 0 0 0 8px var(--bg-hover); } -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:active::before { width: 28px; height: 28px; } -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--shape-full); } -:is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:disabled { opacity: 0.38; } -/* The compact one, for the rows of the order lists. */ -.page-settings .order-list .order-item > input[type="checkbox"] { width: 40px; height: 24px; } -.page-settings .order-list .order-item > input[type="checkbox"]::before { left: 12px; width: 12px; height: 12px; } -.page-settings .order-list .order-item > input[type="checkbox"]:checked::before { left: 28px; width: 18px; height: 18px; } -.page-settings .order-list .order-item > input[type="checkbox"]:active::before { width: 20px; height: 20px; } -:is(.page-settings, .page-filter-builder) label.switch { gap: 10px; font-weight: 500; } - -/* ---- M3 Expressive slider --------------------------------------------------- - A thick rounded track; the active part and a slim bar handle are separated - by a gap in the surface colour behind (--slider-gap), and a stop dot marks - the end of the inactive track. */ -.page-settings input[type="range"] { - --slider-gap: var(--surface-tonal-sunken); - -webkit-appearance: none; appearance: none; height: 16px; width: 180px; max-width: 100%; margin: 0; - background: transparent; border-radius: var(--shape-full); overflow: hidden; -} -.page-settings input[type="range"]::-webkit-slider-runnable-track { - height: 16px; border-radius: var(--shape-full); - background: radial-gradient(circle at calc(100% - 8px) 50%, var(--accent) 2px, transparent 2.5px), var(--state-selected); -} -.page-settings input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; appearance: none; width: 4px; height: 16px; border-radius: 2px; background: var(--accent); - box-shadow: 0 0 0 4px var(--slider-gap), -100vw 0 0 100vw var(--accent); -} -.page-settings input[type="range"]:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; border-radius: var(--shape-full); } -.page-settings input[type="range"]:disabled { opacity: 0.38; } -.page-settings .eq .bands input[type="range"] { width: 100%; height: 12px; } -.page-settings .eq .bands input[type="range"]::-webkit-slider-runnable-track { height: 12px; background: radial-gradient(circle at calc(100% - 6px) 50%, var(--accent) 1.5px, transparent 2px), var(--state-selected); } -.page-settings .eq .bands input[type="range"]::-webkit-slider-thumb { height: 12px; width: 3px; box-shadow: 0 0 0 3px var(--slider-gap), -100vw 0 0 100vw var(--accent); } +/* Switches, sliders and selects: components/controls.tsx + styles/controls.css. */ /* ---- Settings: pill section nav ------------------------------------------- */ .page-settings .settings { grid-template-columns: 240px minmax(0, 1fr); } @@ -106,7 +56,7 @@ .page-settings .setting-row .badge.synced { background: var(--state-selected); color: var(--fg); } .page-settings .setting-row .badge.ok { background: var(--bg-elev); } .page-settings .setting-row .badge.warn { background: var(--bg-elev); } -.page-settings .setting-row :is(.input, .select) { background: var(--bg-elev); } +.page-settings .setting-row :is(.input, .m3-select) { background: var(--bg-elev); } .page-settings .setting-row.stacked { padding: 18px 20px 20px; gap: 14px; } /* A server: its name and details, then its actions underneath. */ .page-settings .setting-row[data-testid="server-row"] { grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 18px 20px; } @@ -253,11 +203,11 @@ .page-filter-builder .rules { gap: 10px; } /* Rule groups are tonal cards; a nested group flips to the pane surface so the nesting reads. */ .page-filter-builder .rule-group { border: 0; border-radius: var(--shape-xl); padding: 16px; gap: 10px; background: var(--surface-tonal-sunken); } -.page-filter-builder .rule-group :is(.input, .select) { background: var(--bg-elev); } +.page-filter-builder .rule-group :is(.input, .m3-select) { background: var(--bg-elev); } .page-filter-builder .rule-group .rule-group { background: var(--bg-elev); border-radius: var(--shape-lg); box-shadow: inset 0 0 0 1px var(--border); } -.page-filter-builder .rule-group .rule-group :is(.input, .select) { background: var(--bg-sunken); } +.page-filter-builder .rule-group .rule-group :is(.input, .m3-select) { background: var(--bg-sunken); } .page-filter-builder .rule-group .group-head { gap: 8px; flex-wrap: wrap; } -.page-filter-builder .rule-group .group-head .select { border-radius: var(--shape-full); font-weight: 600; } +.page-filter-builder .rule-group .group-head .m3-select { font-weight: 600; } /* "Add rule" / "Add group" are assist chips: small corners, an outline, squarer still when pressed. */ .page-filter-builder .group-head .btn.sm { height: 32px; padding: 0 12px 0 8px; border-radius: var(--shape-sm); background: transparent; border: 1px solid var(--border-strong); font-size: var(--fs-md); } .page-filter-builder .group-head .btn.sm:hover { background: var(--bg-hover); } @@ -265,7 +215,7 @@ .page-filter-builder .rule { gap: 8px; } .page-filter-builder .rule > .input:not([type]) { flex: 1 1 160px; min-width: 0; } .page-filter-builder .filter-builder > .stack > .row { flex-wrap: wrap; gap: 10px 16px; } -.page-filter-builder .rule .select, .page-filter-builder .rule .input { height: 36px; } +.page-filter-builder .rule .input { height: 36px; } .page-filter-builder .rule .btn.icon.sm { width: 32px; height: 32px; } /* The sample: a tonal side card. */ .page-filter-builder .filter-sample { background: var(--surface-tonal-sunken); border-radius: var(--shape-xl); padding: 4px 8px 12px; min-width: 0; } @@ -318,13 +268,6 @@ /* ---- Forced colours ----------------------------------------------------------- */ @media (forced-colors: active) { - :is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"] { forced-color-adjust: none; background: Canvas; box-shadow: inset 0 0 0 2px CanvasText; } - :is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]::before { background: CanvasText; box-shadow: none; } - :is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:checked { background: Highlight; box-shadow: none; } - :is(.page-settings .setting-row .control, .page-settings label.switch, .page-filter-builder label.switch, .page-settings .order-item) > input[type="checkbox"]:checked::before { background: HighlightText; } - .page-settings input[type="range"] { forced-color-adjust: none; } - .page-settings input[type="range"]::-webkit-slider-runnable-track { background: Canvas; box-shadow: inset 0 0 0 1px CanvasText; } - .page-settings input[type="range"]::-webkit-slider-thumb { background: Highlight; box-shadow: 0 0 0 4px Canvas, -100vw 0 0 100vw Highlight; } .page-settings .settings-nav .nav-item.active { outline: 2px solid Highlight; } .page-settings .setting-row, .page-settings .order-item, .page-settings .shortcut-row, :is(.page-downloads, .page-filters) .pin-row, .page-stats .stat-hero, .page-filter-builder .filter-sample, .setup-card { border: 1px solid CanvasText; } .page-settings .cache-bar::after, .page-downloads .pin-row .progress::after { forced-color-adjust: none; background: Canvas; border: 1px solid CanvasText; } @@ -351,7 +294,7 @@ .page-settings .setting-row { padding: 12px 16px; gap: 10px; } .page-settings .setting-row .control { justify-content: flex-start; } /* A lone switch stays beside its label. */ - .page-settings .setting-row:has(> .control > input[type="checkbox"]:only-child) { grid-template-columns: minmax(0, 1fr) auto; } + .page-settings .setting-row:has(> .control > .m3-switch:only-child) { grid-template-columns: minmax(0, 1fr) auto; } .page-settings .shortcut-row { padding-left: 16px; grid-template-columns: minmax(0, 1fr) auto auto; } .page-settings .shortcut-row > :last-child { grid-column: auto; } .page-settings .shortcut-row .rec { min-width: 0; } diff --git a/desktop/src/renderer/styles/now-playing.css b/desktop/src/renderer/styles/now-playing.css index a58462b..da3e072 100644 --- a/desktop/src/renderer/styles/now-playing.css +++ b/desktop/src/renderer/styles/now-playing.css @@ -78,12 +78,6 @@ /* Volume (wide layout): a thick rounded track with a gap either side of a bar thumb. */ .np-volume { display: none; align-items: center; gap: 10px; flex: 0 0 auto; } -.np-range { -webkit-appearance: none; appearance: none; flex: 1; min-width: 0; height: 28px; background: transparent; margin: 0; --v: 50%; } -.np-range::-webkit-slider-runnable-track { height: 14px; border-radius: 7px; background: linear-gradient(to right, #fff 0 max(0px, calc(var(--v) - 7px)), transparent max(0px, calc(var(--v) - 7px)) calc(var(--v) + 7px), rgba(255, 255, 255, 0.28) calc(var(--v) + 7px)); } -.np-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 5px; height: 26px; margin-top: -6px; border-radius: 3px; background: #fff; transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)); } -.np-range:hover::-webkit-slider-thumb { transform: scaleY(1.12); } -.np-range:active::-webkit-slider-thumb { transform: scale(0.7, 1.2); } -.np-range:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: 10px; } /* Transport: the play button morphs between a pill (paused) and a rounded square (playing). */ .np-transport { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 0 0 auto; } @@ -230,8 +224,6 @@ .np-seek-bar .rest { stroke: GrayText; } .np-seek-bar .thumb, .np-seek-bar .stop { fill: CanvasText; } .np-seek-bar .value-bubble { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; } - .np-range::-webkit-slider-runnable-track { forced-color-adjust: none; background: GrayText; } - .np-range::-webkit-slider-thumb { forced-color-adjust: none; background: CanvasText; } .np .queue-section { background: Canvas; } .np-card { border: 1px solid CanvasText; } } diff --git a/desktop/src/renderer/views/AlbumDetail.tsx b/desktop/src/renderer/views/AlbumDetail.tsx index 0aec169..531838e 100644 --- a/desktop/src/renderer/views/AlbumDetail.tsx +++ b/desktop/src/renderer/views/AlbumDetail.tsx @@ -23,7 +23,7 @@ export function AlbumDetail({ id }: { id: string }) { // play button primes that track. Once per visit (a remount is a new visit). useAlbumDwellPrime(a?.id); const primer = usePlayIntent(`album:${id}`); - const play = (start = 0, shuffle = false) => a && bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "album", data: { id: a.id } }, label: a.name, sort: "default", tracks: [] }, startIndex: start, shuffle, saveOutgoing: true } } }); + const play = (start = 0, shuffle = false) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "album", data: { id } }, label: a?.name ?? "", sort: "default", tracks: [] }, startIndex: start, shuffle, saveOutgoing: true } } }); return (
    a && void openContextMenu(e, { type: "albums", data: { ids: [a.id] } })}> diff --git a/desktop/src/renderer/views/Albums.tsx b/desktop/src/renderer/views/Albums.tsx index 5daf63d..ef33896 100644 --- a/desktop/src/renderer/views/Albums.tsx +++ b/desktop/src/renderer/views/Albums.tsx @@ -7,6 +7,7 @@ import { AlbumGrid, type GridItem } from "../components/AlbumGrid"; import { usePagedGrid } from "./paged"; import { loadLocal, saveLocal } from "../lib/local-settings"; import { Icon } from "../components/Icon"; +import { Select } from "../components/controls"; const SORTS: SortOrder[] = ["title", "artist", "year", "dateAdded", "rating", "playCount", "random"]; @@ -30,9 +31,9 @@ export function Albums({ artistId, genre, title }: { artistId?: string; genre?: {total}
    - { setSort(e.target.value as SortOrder); saveLocal("albums.sort", e.target.value); }} data-testid="albums-sort"> {SORTS.map((s) => )} - +
    diff --git a/desktop/src/renderer/views/ArtistDetail.tsx b/desktop/src/renderer/views/ArtistDetail.tsx index c9c4a8d..da12427 100644 --- a/desktop/src/renderer/views/ArtistDetail.tsx +++ b/desktop/src/renderer/views/ArtistDetail.tsx @@ -17,7 +17,7 @@ export function ArtistDetail({ id }: { id: string }) { const albums = useQuery(() => ({ type: "albums", data: { server_id: serverId, artist_id: id, genre: undefined, sort: "year", descending: true, page: { offset: 0, limit: 500 } } }), "albums", [id, serverId]); const top = useQuery(() => ({ type: "artistTopSongs", data: { id, count: 10 } }), "trackList", [id]); const a = artist.data; - const play = (shuffle: boolean) => a && bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "artist", data: { id: a.id } }, label: a.name, sort: "default", tracks: [] }, startIndex: 0, shuffle, saveOutgoing: true } } }); + const play = (shuffle: boolean) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "artist", data: { id } }, label: a?.name ?? "", sort: "default", tracks: [] }, startIndex: 0, shuffle, saveOutgoing: true } } }); const topList = top.data ?? []; return (
    diff --git a/desktop/src/renderer/views/FilterBuilder.tsx b/desktop/src/renderer/views/FilterBuilder.tsx index 8f10007..d95ea81 100644 --- a/desktop/src/renderer/views/FilterBuilder.tsx +++ b/desktop/src/renderer/views/FilterBuilder.tsx @@ -9,6 +9,7 @@ import { bridge } from "../core/bridge"; import { Icon } from "../components/Icon"; import { filterName, isBuiltinFilter } from "../lib/filters"; import { Artwork } from "../components/Artwork"; +import { Select, Switch } from "../components/controls"; const TEXT_FIELDS: FilterField[] = ["title", "album", "artist", "albumArtist", "genre", "filePath", "fileType", "comment", "lyrics", "key", "mood"]; const NUM_FIELDS: FilterField[] = ["year", "playCount", "rating", "duration", "bitRate", "discNumber", "trackNumber", "bpm", "energy", "localPlayCount"]; @@ -95,9 +96,9 @@ export function FilterBuilder({ id }: { id: string }) {
    setFilter({ ...filter, root })} root />
    - +
    @@ -126,10 +127,10 @@ function GroupEditor({ node, onChange, root = false }: { node: FilterNode; onCha return (
    - onChange({ type: e.target.value as "all" | "any", data: children })} aria-label="Match"> - + @@ -158,8 +159,8 @@ function RuleEditor({ rule, onChange, onRemove }: { rule: FilterRule; onChange: const fields = useMemo(() => [...FilterFieldValues], []); return (
    - - + + {v.type === "text" ? onChange({ ...rule, value: { type: "text", data: e.target.value } })} aria-label="Value" data-testid="rule-value" /> : null} {v.type === "number" ? onChange({ ...rule, value: { type: "number", data: Number(e.target.value) } })} aria-label="Value" style={{ width: 100 }} /> : null} {v.type === "days" ? onChange({ ...rule, value: { type: "days", data: Number(e.target.value) } })} aria-label="Days" style={{ width: 80 }} /> : null} diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index 64ff117..0673b6a 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -26,6 +26,7 @@ import { WavySeek } from "../components/WavySeek"; import { QueuePanel } from "../components/QueuePanel"; import { LyricsPane } from "../components/RightPanel"; import { Stars } from "../components/Stars"; +import { Slider } from "../components/controls"; import { openMenuFromButton } from "../components/ContextMenu"; import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; import { trapTab, useReturnFocus } from "../lib/focus"; @@ -357,7 +358,7 @@ function NpVolume() { return (
    - { setMuted(undefined); set(Number(e.target.value)); }} onWheel={(e) => set(Math.max(0, Math.min(1, Math.round((volume - Math.sign(e.deltaY) * 0.05) * 100) / 100)))} data-testid="fs-volume" /> + { setMuted(undefined); set(v); }} onWheel={(e) => set(Math.max(0, Math.min(1, Math.round((volume - Math.sign(e.deltaY) * 0.05) * 100) / 100)))} data-testid="fs-volume" />
    ); } diff --git a/desktop/src/renderer/views/PlaylistDetail.tsx b/desktop/src/renderer/views/PlaylistDetail.tsx index 93dab8e..6f09e14 100644 --- a/desktop/src/renderer/views/PlaylistDetail.tsx +++ b/desktop/src/renderer/views/PlaylistDetail.tsx @@ -24,7 +24,7 @@ export function PlaylistDetail({ id }: { id: string }) { // Resting on a play button primes that track's start (the album primer, per track). const primer = usePlayIntent(`playlist:${id}`); const editable = !!p && !p.isSmart && p.isMine; - const play = (start = 0, shuffle = false) => p && bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "playlist", data: { id: p.id } }, label: p.name, sort: "default", tracks: [] }, startIndex: start, shuffle, saveOutgoing: true } } }); + const play = (start = 0, shuffle = false) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "playlist", data: { id } }, label: p?.name ?? "", sort: "default", tracks: [] }, startIndex: start, shuffle, saveOutgoing: true } } }); return (
    p && void openContextMenu(e, { type: "playlists", data: { ids: [p.id] } })}> diff --git a/desktop/src/renderer/views/Settings.tsx b/desktop/src/renderer/views/Settings.tsx index f023f7c..cc9aa11 100644 --- a/desktop/src/renderer/views/Settings.tsx +++ b/desktop/src/renderer/views/Settings.tsx @@ -18,6 +18,7 @@ import { executeAction } from "../store/actions"; import { CredentialWarning } from "../components/CredentialWarning"; import { DEFAULT_ACCENT, SK } from "@shared/settings-keys"; import { LYRICS_SIZES, setLyricsAnimated, setLyricsSize, useLyricsAnimated, useLyricsSize, type LyricsSize } from "../lib/lyrics-size"; +import { Select, Slider, Switch } from "../components/controls"; const SECTIONS = ["general", "audio", "transcoding", "connect", "storage", "lyrics", "appearance", "customisation", "shortcuts", "backup", "diagnostics", "about"] as const; type Section = (typeof SECTIONS)[number]; @@ -77,7 +78,7 @@ function Toggle({ settingKey, title, desc }: { settingKey: string; title: string const set = useApp((s) => s.setSetting); return ( - set(settingKey, e.target.checked)} aria-label={title} data-testid={`setting-${settingKey}`} /> + set(settingKey, checked)} aria-label={title} data-testid={`setting-${settingKey}`} /> ); } @@ -109,18 +110,18 @@ function General() {
    ))} - setPrefs({ closeToTray: e.target.checked })} aria-label={t("settings.closeToTray")} data-testid="pref-closeToTray" /> + setPrefs({ closeToTray: checked })} aria-label={t("settings.closeToTray")} data-testid="pref-closeToTray" /> - bridge().dispatch({ type: "setSavedQueueCap", data: { cap: Number(e.target.value) } })} aria-label={t("settings.savedQueueCap", { n: cap })} /> + bridge().dispatch({ type: "setSavedQueueCap", data: { cap: v } })} aria-label={t("settings.savedQueueCap", { n: cap })} /> {cap} - { const n = Number(e.target.value); set(SK.ratingsLoveBridgeEnabled, n > 0); if (n > 0) set(SK.ratingsLoveBridgeThreshold, n); }} data-testid="setting-loveBridge"> {[3, 4, 5].map((n) => )} - + @@ -131,10 +132,10 @@ function QueueModeRow() { const mode = useApp((s) => s.queue.mode); return ( - bridge().dispatch({ type: "setQueueMode", data: { mode: e.target.value as QueueMode } })}> - + ); } @@ -151,35 +152,35 @@ function Audio() { return ( <> - update({ replayGain: e.target.value as ReplayGainMode })} data-testid="setting-replaygain"> {(["off", "track", "album", "auto"] as ReplayGainMode[]).map((m) => )} - + - update({ replayGainPreampDb: Number(e.target.value) })} aria-label={t("settings.replayGainPreamp")} /> + update({ replayGainPreampDb: v })} aria-label={t("settings.replayGainPreamp")} /> {audio.replayGainPreampDb.toFixed(1)} dB - update({ normalisation: e.target.checked })} aria-label={t("settings.normalisation")} /> - update({ gapless: e.target.checked })} aria-label={t("settings.gapless")} /> + update({ normalisation: checked })} aria-label={t("settings.normalisation")} /> + update({ gapless: checked })} aria-label={t("settings.gapless")} /> - bridge().dispatch({ type: "setOutputDevice", data: { id: e.target.value || undefined } })} data-testid="setting-output"> {list.map((d) => )} - + - update({ exclusive: e.target.checked })} aria-label={t("settings.exclusive")} /> + update({ exclusive: checked })} aria-label={t("settings.exclusive")} />
    - + - +
    setBands(b)} disabled={!audio.eq.enabled} /> @@ -187,9 +188,9 @@ function Audio() {
    {bands.map((b, i) => ( ))}
    @@ -322,10 +323,10 @@ function Transcoding() { <>
    {t("settings.transcodingNetwork")}: {network?.networkId ?? network?.kind ?? "–"}
    - + - + setCannot(e.target.value)} style={{ width: 240 }} />
    @@ -384,10 +385,10 @@ function Storage() { - bridge().dispatch({ type: "setStorageWarnThreshold", data: { bytes: Number(e.target.value) ? Number(e.target.value) * GIB : undefined } })}> {[2, 4, 5, 10, 20, 50, 100].map((g) => )} - + @@ -430,10 +431,10 @@ function CacheBudgetRow({ budget, auto }: { budget: number; auto: boolean }) { }; return ( - choose(e.target.value)} data-testid="cache-budget-mode"> - + {custom ? ( <> setDraft(e.target.value)} onBlur={() => commit(Number(draft))} onKeyDown={(e) => { if (e.key === "Enter") commit(Number(draft)); }} data-testid="cache-budget-gb" /> @@ -446,7 +447,7 @@ function CacheBudgetRow({ budget, auto }: { budget: number; auto: boolean }) { function BatterySaverRow() { const on = useApp((s) => s.batterySaver); - return bridge().dispatch({ type: "setBatterySaver", data: { enabled: e.target.checked } })} aria-label={t("settings.batterySaver")} data-testid="setting-battery-saver" />; + return bridge().dispatch({ type: "setBatterySaver", data: { enabled: checked } })} aria-label={t("settings.batterySaver")} data-testid="setting-battery-saver" />; } function LyricsSettings() { @@ -457,13 +458,13 @@ function LyricsSettings() { return ( <> - bridge().dispatch({ type: "setExternalLyricsEnabled", data: { enabled: e.target.checked } })} aria-label={t("settings.externalLyrics")} data-testid="setting-external-lyrics" /> + bridge().dispatch({ type: "setExternalLyricsEnabled", data: { enabled: checked } })} aria-label={t("settings.externalLyrics")} data-testid="setting-external-lyrics" /> - + - + ); @@ -476,7 +477,7 @@ function Appearance() { return ( <> - + set(SK.displayAccent, e.target.value)} aria-label={t("settings.accent")} /> @@ -496,7 +497,7 @@ function LyricsAnimatedRow() {
    {t("settings.lyricsAnimatedDesc")}
    - setLyricsAnimated(e.target.checked)} aria-label={t("settings.lyricsAnimated")} aria-describedby="lyrics-animated-desc" data-testid="setting-lyrics-animated" /> + setLyricsAnimated(checked)} aria-label={t("settings.lyricsAnimated")} aria-describedby="lyrics-animated-desc" data-testid="setting-lyrics-animated" />
    ); @@ -511,9 +512,9 @@ function LyricsSizeRow() {
    {t("settings.lyricsSizeDesc")}
    - setLyricsSize(e.target.value as LyricsSize)} aria-label={t("settings.lyricsSize")} data-testid="setting-lyrics-size"> {LYRICS_SIZES.map((v) => )} - +
    ); @@ -554,7 +555,7 @@ function OrderList({ surface, title }: { surface: string; title: string }) { {items.map((it, i) => (
  • setDragIdx(i)} onDragOver={(e) => e.preventDefault()} onDrop={() => { if (dragIdx !== undefined && dragIdx !== i) move(dragIdx, i); setDragIdx(undefined); }}> - commit(items.map((x, j) => (j === i ? { ...x, on: e.target.checked } : x)))} aria-label={it.a.label} /> + commit(items.map((x, j) => (j === i ? { ...x, on: checked } : x)))} aria-label={it.a.label} /> {it.a.label} @@ -647,7 +648,7 @@ function Backup() { return ( <> - + diff --git a/desktop/src/renderer/views/Stats.tsx b/desktop/src/renderer/views/Stats.tsx index 6314212..a4da073 100644 --- a/desktop/src/renderer/views/Stats.tsx +++ b/desktop/src/renderer/views/Stats.tsx @@ -5,6 +5,7 @@ import { useQuery } from "../store/queries"; import { Artwork } from "../components/Artwork"; import { EmptyState } from "../components/EmptyState"; import { Icon } from "../components/Icon"; +import { Select } from "../components/controls"; export function Stats() { const [days, setDays] = useState(30); @@ -17,7 +18,7 @@ export function Stats() {

    {t("stats.title")}

    - +
    From a7837ca286bb998b8ac8d12ea0eacb5e07ca2bf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:34:39 +0000 Subject: [PATCH 14/17] desktop: polish and consistency pass over the M3 Expressive stylesheets One layout grid, type ramp and control-size set, shared by every page: - tokens in global.css: --page-gutter/--page-title-inset/--page-pad, --title-display/--title-section/--title-card, --btn-h/-sm/-lg, --field-h - every view header/body uses them (the tool pages had 32/34px padding and 48px titles, the library 28px and a clamp); hero, track tables, grouped lists, cards and genre cards all start at the same gutter; tables no longer depend on inline "0 8px" padding (--table-inset) - one section-heading style (with the tonal icon on Home, Search and Artist pages), settings h2s match it; one card-title for cards, the filter sample and the jobs popover - text buttons 36px, small 32px, header/dialog actions 40px everywhere; text fields 36px (prompt dialog field was 28px); badges one size - one segmented tab style for Downloads and the side panel - Filters/Playlists create buttons and the builder's Play are tonal; Downloads usage text moved beside the tabs so actions stay on one line - empty states all carry a glyph (Filters, Stats, Downloads, side panel) - context menu clamps with its layout size (it overflowed the window while scaled by its entrance animation) and scrolls if taller than the window - sidebar Settings pill no longer wider than the others; settings nav wide enough for "Keyboard shortcuts"; carousel scrollbars show on hover only; narrow Home rows fill the width - removed dead/duplicated rules (per-page header/body/h1/field/button overrides, old detail-head action sizes, .home-section h2 that beat the shared heading rule, bordered pin-row/rule-group/card bases) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- .../src/renderer/components/ContextMenu.tsx | 3 +- .../src/renderer/components/QueuePanel.tsx | 2 +- .../src/renderer/components/RightPanel.tsx | 4 +- .../src/renderer/components/SavedQueues.tsx | 2 +- desktop/src/renderer/styles/global.css | 94 ++++++++++++------- desktop/src/renderer/styles/m3-library.css | 52 +++++----- desktop/src/renderer/styles/m3-pages.css | 78 +++++---------- desktop/src/renderer/styles/m3-shell.css | 16 ++-- desktop/src/renderer/views/AlbumDetail.tsx | 2 +- desktop/src/renderer/views/ArtistDetail.tsx | 4 +- desktop/src/renderer/views/Downloads.tsx | 8 +- desktop/src/renderer/views/FilterBuilder.tsx | 4 +- desktop/src/renderer/views/Filters.tsx | 6 +- desktop/src/renderer/views/PlaylistDetail.tsx | 2 +- desktop/src/renderer/views/SearchView.tsx | 5 +- desktop/src/renderer/views/Songs.tsx | 2 +- desktop/src/renderer/views/Stats.tsx | 2 +- 17 files changed, 139 insertions(+), 147 deletions(-) diff --git a/desktop/src/renderer/components/ContextMenu.tsx b/desktop/src/renderer/components/ContextMenu.tsx index 410cabf..c2668a3 100644 --- a/desktop/src/renderer/components/ContextMenu.tsx +++ b/desktop/src/renderer/components/ContextMenu.tsx @@ -65,7 +65,8 @@ export function ContextMenu() { useLayoutEffect(() => { if (!menu || !ref.current) return; - const r = ref.current.getBoundingClientRect(); + // Layout size, not getBoundingClientRect(): the menu opens scaled down by its entrance animation. + const r = { width: ref.current.offsetWidth, height: ref.current.offsetHeight }; const x = Math.min(menu.x, window.innerWidth - r.width - 6); const y = Math.min(menu.y, window.innerHeight - r.height - 6); setPos({ x: Math.max(4, x), y: Math.max(4, y) }); diff --git a/desktop/src/renderer/components/QueuePanel.tsx b/desktop/src/renderer/components/QueuePanel.tsx index 4e2b057..55c0013 100644 --- a/desktop/src/renderer/components/QueuePanel.tsx +++ b/desktop/src/renderer/components/QueuePanel.tsx @@ -131,7 +131,7 @@ export function QueuePanel({ large = false }: { large?: boolean }) { window.setTimeout(() => { programmatic.current = false; }, first || reducedMotion ? 50 : 600); }, [currentKey, reducedMotion]); - if (!queue.current && !all.length) return ; + if (!queue.current && !all.length) return ; const idPrefix = large ? "fsq" : "q"; const header = large ? : null; diff --git a/desktop/src/renderer/components/RightPanel.tsx b/desktop/src/renderer/components/RightPanel.tsx index 75fcf04..b7862e5 100644 --- a/desktop/src/renderer/components/RightPanel.tsx +++ b/desktop/src/renderer/components/RightPanel.tsx @@ -84,8 +84,8 @@ export function LyricsPane({ variant = "compact" }: { variant?: "compact" | "lar const now = useApp((s) => s.nowPlaying); const lyrics = useApp((s) => s.lyrics); const external = useApp((s) => s.settings[SK.lyricsExternalEnabled]?.value === "true"); - if (!now) return ; + if (!now) return ; const current = lyrics && lyrics.trackId === now.track.id ? lyrics.lyrics : undefined; - if (!current) return {t("lyrics.noneHint")} : undefined} testId="lyrics-empty" />; + if (!current) return {t("lyrics.noneHint")} : undefined} testId="lyrics-empty" />; return ; } diff --git a/desktop/src/renderer/components/SavedQueues.tsx b/desktop/src/renderer/components/SavedQueues.tsx index 418f2bd..674db43 100644 --- a/desktop/src/renderer/components/SavedQueues.tsx +++ b/desktop/src/renderer/components/SavedQueues.tsx @@ -12,7 +12,7 @@ import { executeAction } from "../store/actions"; export function SavedQueues() { const queues = useApp((s) => s.savedQueues); const d = bridge().dispatch; - if (!queues.length) return ; + if (!queues.length) return ; const sorted = [...queues].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastInteractedAt - a.lastInteractedAt); return (
    diff --git a/desktop/src/renderer/styles/global.css b/desktop/src/renderer/styles/global.css index 8d84cea..a0ae47c 100644 --- a/desktop/src/renderer/styles/global.css +++ b/desktop/src/renderer/styles/global.css @@ -50,6 +50,23 @@ --surface-tonal-sunken: color-mix(in srgb, var(--accent-tint) 5%, var(--bg-sunken)); --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.12), 0 1px 3px 1px rgba(0, 0, 0, 0.06); --shadow-2: 0 2px 6px 2px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.14); + /* One layout grid for every page. Filled blocks in a page body (heroes, + track-table headers, grouped lists, cards, genre cards) start at the + gutter; text that heads them (page titles, section headings) and the + content of hover-only rows and tiles sits one inset further in. */ + --page-gutter: 20px; + --page-title-inset: 8px; + --page-pad: calc(var(--page-gutter) + var(--page-title-inset)); + /* Type ramp for headings: one size per level everywhere. */ + --title-display: clamp(36px, 2.4vw + 18px, 52px); + --title-section: 22px; + --title-card: 18px; + /* One set of control sizes (M3 XS / S): text buttons, small buttons, the + larger header and dialog actions, and text fields. */ + --btn-h: 36px; + --btn-h-sm: 32px; + --btn-h-lg: 40px; + --field-h: 36px; } :root, :root[data-theme="light"] { @@ -198,7 +215,8 @@ main:focus-visible { outline: none; } transform var(--dur-spring) var(--spring-bouncy); } .btn:where(:not(.player *, .fullscreen *)):active { transform: scale(0.95); border-radius: var(--shape-sm); transition-duration: 80ms, 80ms, 80ms, 80ms, 120ms, 120ms; } -.btn.sm:where(:not(.player *, .fullscreen *)) { padding: 0 11px; } +.btn:where(:not(.icon, .player *, .fullscreen *)) { height: var(--btn-h); } +.btn.sm:where(:not(.icon, .player *, .fullscreen *)) { height: var(--btn-h-sm); padding: 0 12px; font-size: var(--fs-md); } .btn.sm:where(:not(.player *, .fullscreen *)):active { border-radius: var(--shape-xs); } .btn.primary:where(:not(.player *, .fullscreen *)) { box-shadow: var(--shadow-1); } .btn.primary:where(:not(.player *, .fullscreen *)):hover { box-shadow: var(--shadow-2); } @@ -226,6 +244,9 @@ main:focus-visible { outline: none; } .input:focus, .select:focus, .textarea:focus { border-color: var(--focus-ring); box-shadow: none; } .input:focus-visible, .select:focus-visible, .textarea:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -1px; } .input::placeholder { color: var(--fg-faint); } +/* Text fields share one height and shape (selects keep their own rules); a focused field squares off a little. */ +.input { height: var(--field-h); padding: 0 14px; border-radius: var(--shape-md); } +.input:focus { border-radius: var(--shape-sm); } .select { padding-right: 24px; appearance: auto; } input[type="range"] { accent-color: var(--accent); height: 20px; } input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; } @@ -240,24 +261,29 @@ input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; .row { display: flex; align-items: center; gap: 8px; } .grow { flex: 1 1 auto; min-width: 0; } .stack { display: flex; flex-direction: column; gap: 8px; } -.badge { display: inline-block; padding: 1px 8px; border-radius: var(--shape-full); font-size: var(--fs-xs); background: var(--bg-active); color: var(--fg-muted); line-height: 1.5; } +.badge { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 8px; border-radius: var(--shape-full); font-size: var(--fs-xs); font-weight: 600; background: var(--bg-active); color: var(--fg-muted); line-height: 1; white-space: nowrap; vertical-align: 1px; } +/* On a sunken or tonal container a neutral badge lifts onto the pane surface instead. */ +:is(.setting-row, .pin-row, .order-item, .shortcut-row) .badge:not(.synced) { background: var(--bg-elev); } .badge.synced { background: var(--accent-soft); color: var(--accent-text); } .badge.warn { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); } .badge.danger { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); } .badge.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); } .kbd { display: inline-block; padding: 0 5px; min-width: 18px; text-align: center; border-radius: var(--shape-xs); border: 1px solid var(--border-strong); border-bottom-width: 2px; font-size: var(--fs-xs); color: var(--fg-muted); background: var(--bg-elev); font-family: var(--font-ui); line-height: 16px; } .divider { height: 1px; background: var(--border); margin: 6px 0; } -.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--shape-lg); padding: 16px; } +.card { background: var(--surface-tonal-sunken); border-radius: var(--shape-xl); padding: 20px; min-width: 0; } h1, h2, h3 { margin: 0; font-weight: 600; } h1 { font-size: var(--fs-xxl); letter-spacing: -0.01em; } h2 { font-size: var(--fs-xl); } h3 { font-size: var(--fs-lg); } .section-title { font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-faint); padding: 8px 8px 4px; margin: 0; } /* Headings that keep the look of the old h3s at their new, correct level. */ -.section-heading, .card-title { font-size: var(--fs-lg); font-weight: 600; } +/* Section headings (h2 in a page body): one size and weight on every page, the + text one inset in from the blocks below, an optional tonal cookie icon. */ +.section-heading { display: flex; align-items: center; gap: 12px; font-size: var(--title-section); font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; margin: 0 0 12px var(--page-title-inset); } +/* Titles inside a card or side sheet. */ +.card-title { font-size: var(--title-card); font-weight: 700; letter-spacing: -0.005em; line-height: 1.2; margin: 0 0 14px; } .dialog-title { font-size: var(--fs-lg); font-weight: 600; margin: 0; } .pane-title { font-size: var(--fs-sm); font-weight: 600; margin: 0; } -.settings-heading { font-size: var(--fs-xl); margin-bottom: 12px; } /* ---- App shell ---------------------------------------------------------- */ .app { display: grid; grid-template-rows: auto 1fr var(--player-h); height: 100%; background: var(--bg); } @@ -326,9 +352,9 @@ h3 { font-size: var(--fs-lg); } /* Content */ .content { position: relative; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } .view { flex: 1; min-height: 0; display: flex; flex-direction: column; } -.view-header { display: flex; align-items: flex-end; gap: 12px; padding: 18px 20px 10px; } -.view-header .actions { display: flex; gap: 6px; margin-left: auto; align-items: center; } -.view-body { flex: 1; min-height: 0; overflow: auto; padding: 0 20px 20px; } +.view-header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 14px; padding: 28px var(--page-pad) 16px; } +.view-header .actions { display: flex; gap: 8px; margin-left: auto; align-items: center; align-self: center; } +.view-body { flex: 1; min-height: 0; overflow: auto; padding: 4px var(--page-gutter) 28px; } .view-body.no-pad { padding: 0; } .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 160px; color: var(--fg-muted); text-align: center; gap: 8px; padding: 20px; } .empty .btn { margin-top: 4px; } @@ -371,7 +397,9 @@ h3 { font-size: var(--fs-lg); } .tile .play:active { transform: scale(0.9); transition-duration: 80ms, 100ms, 120ms; } /* Table */ -.table { display: flex; flex-direction: column; min-height: 0; flex: 1; } +.table { display: flex; flex-direction: column; min-height: 0; flex: 1; --table-inset: 0px; } +/* A table that fills an unpadded body lines up with the page gutter like everything else. */ +.view-body.no-pad > .table { --table-inset: var(--page-gutter); } .table-head { display: grid; height: 28px; align-items: center; border-bottom: 1px solid var(--border); color: var(--fg-muted); font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.02em; padding: 0 12px; position: sticky; top: 0; background: var(--bg); z-index: 1; } .table-head .th { display: flex; align-items: center; gap: 4px; padding: 0 6px; height: 100%; white-space: nowrap; overflow: hidden; } .table-head .th.sortable { padding: 0; } @@ -381,7 +409,7 @@ h3 { font-size: var(--fs-lg); } .table-head .th.sorted { color: var(--fg); } .table-body { flex: 1; min-height: 0; overflow: auto; position: relative; } /* Rows are soft pills: rounded state layers on hover and selection, inset from the pane edge. */ -.tr { display: grid; height: var(--row-h); align-items: center; padding: 0 8px; border-radius: var(--shape-sm); position: absolute; left: 4px; right: 4px; transition: background-color var(--dur-short) var(--ease-emphasized); } +.tr { display: grid; height: var(--row-h); align-items: center; padding: 0 8px; border-radius: var(--shape-sm); position: absolute; left: var(--table-inset); right: var(--table-inset); transition: background-color var(--dur-short) var(--ease-emphasized); } .tr:hover { background: var(--bg-hover); } .tr.selected { background: var(--selection); } /* The keyboard cursor shows only while the table has focus. */ @@ -418,9 +446,6 @@ h3 { font-size: var(--fs-lg); } .detail-head .meta { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .detail-head .meta h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-head .meta .actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; } -/* The header's main actions are M3 "medium" buttons: taller pills, the primary one squarer when pressed. */ -.detail-head .meta .actions .btn:not(.icon) { height: 36px; padding: 0 18px; font-size: var(--fs-md); } -.detail-head .meta .actions .btn.icon { width: 36px; height: 36px; } /* Player bar */ .player { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(360px, 2fr) minmax(200px, 1fr); align-items: center; gap: 12px; padding: 0 14px; border-top: 1px solid var(--border); background: var(--bg-elev); position: relative; } @@ -565,7 +590,7 @@ button.qrow { width: 100%; } .info-list { margin: 0; } .info-list dt, .info-list dd { margin: 0; } .dialog .list .li:hover { background: var(--bg-hover); } -.dialog label { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-sm); color: var(--fg-muted); } +.dialog label { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-md); font-weight: 500; color: var(--fg-muted); } .toasts { position: fixed; bottom: calc(var(--player-h) + 12px); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 80; align-items: center; } /* Snackbars: inverse-surface pills that spring up from below. */ @@ -583,8 +608,7 @@ button.qrow { width: 100%; } .setup form { width: 400px; max-width: 100%; display: flex; flex-direction: column; gap: 12px; margin: auto 0; } .setup-title { font-size: var(--fs-xl); } .setup .brand { font-size: var(--fs-xxl); font-weight: 700; letter-spacing: -0.02em; } -.setup label { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-sm); color: var(--fg-muted); } -.setup .input { height: 34px; } +.setup label { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-md); font-weight: 500; color: var(--fg-muted); } .setup .error { color: var(--danger); font-size: var(--fs-sm); } .credential-warning { color: var(--fg); background: color-mix(in srgb, var(--danger) 12%, var(--bg-elev)); border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); border-radius: var(--shape-md); padding: 10px 14px; font-size: var(--fs-sm); margin-bottom: 8px; } @@ -606,18 +630,22 @@ button.qrow { width: 100%; } .cache-bar .partial { background: color-mix(in srgb, var(--accent) 45%, var(--bg-active)); } .data-saved { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; } .data-saved .figure { font-size: var(--fs-lg, 18px); font-weight: 600; font-variant-numeric: tabular-nums; } -/* Tabs under a view header (Downloads). */ -.view-tabs-row { padding: 0 20px 8px; } -/* A connected pill segment: the selected tab fills tonally, its pill springs. */ -.view-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--shape-full); background: var(--bg-sunken); box-shadow: inset 0 0 0 1px var(--border); max-width: 100%; flex-wrap: wrap; } +/* Tabs under a view header (Downloads), with the page's status text on the right. */ +.view-tabs-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; padding: 0 var(--page-gutter) 16px; } +.view-tabs-row > .muted { margin-left: auto; padding-right: var(--page-title-inset); } +/* Segmented tabs (here and in the side panel's pane heads, m3-shell.css): a + sunken pill track; the selected segment fills with the opaque selected + tone and squares off slightly on its inner edge. */ +.view-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--shape-full); background: var(--bg-sunken); max-width: 100%; flex-wrap: wrap; } .view-tabs .tab { - height: 28px; padding: 0 14px; border-radius: var(--shape-full); border: none; background: transparent; color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 500; - transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); + height: 34px; padding: 0 18px; border-radius: var(--shape-full); border: none; background: transparent; color: var(--fg-muted); font-size: var(--fs-md); font-weight: 600; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); } .view-tabs .tab:hover { background: var(--bg-hover); color: var(--fg); } -.view-tabs .tab:active { transform: scale(0.95); transition-duration: 80ms, 80ms, 120ms; } -.view-tabs .tab.active { background: var(--accent-soft); color: var(--fg); } -.view-tabs .tab.active:hover { background: var(--selection); } +.view-tabs .tab:active { transform: scale(0.95); transition-duration: 80ms, 80ms, 120ms, 120ms; } +.view-tabs .tab.active, .view-tabs .tab.active:hover { background: var(--state-selected); color: var(--fg); font-weight: 700; } +.view-tabs .tab.active:first-child { border-radius: var(--shape-full) var(--shape-sm) var(--shape-sm) var(--shape-full); } +.view-tabs .tab.active:last-child { border-radius: var(--shape-sm) var(--shape-full) var(--shape-full) var(--shape-sm); } .offline-head { padding: 4px 4px 8px; gap: 12px; align-items: center; } .eq { display: flex; flex-direction: column; gap: 8px; min-width: 0; } .eq > .row { flex-wrap: wrap; } @@ -637,10 +665,9 @@ button.qrow { width: 100%; } /* Filters builder */ .rules { display: flex; flex-direction: column; gap: 6px; } -.rule-group { border: 1px solid var(--border); border-radius: var(--shape-lg); padding: 10px; display: flex; flex-direction: column; gap: 6px; background: var(--bg-elev); } +.rule-group { display: flex; flex-direction: column; } .rule-group .group-head { display: flex; align-items: center; gap: 8px; } .rule { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -.rule .select, .rule .input { height: 26px; } /* Stats */ .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } @@ -668,9 +695,7 @@ button.qrow { width: 100%; } .mini .controls .spacer { flex: 1; } .mini .seek { font-size: 10px; } -/* Home */ -.home-section { margin-bottom: 20px; } -.home-section h2 { margin-bottom: 8px; display: flex; align-items: baseline; gap: 10px; } +/* Home (and the search page's album row): horizontal carousels. */ .home-section .scroller { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px; } .home-section .scroller .tile { width: 150px; flex: 0 0 auto; } @@ -683,8 +708,6 @@ button.qrow { width: 100%; } .genre-tile:active { transform: scale(0.97); transition-duration: 80ms, 80ms, 120ms, 120ms; } /* Downloads */ -.pin-row { display: grid; grid-template-columns: 44px 1fr auto auto; gap: 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); } -.pin-row .art { width: 44px; height: 44px; border-radius: var(--shape-sm); } .filter-builder { display: grid; grid-template-columns: minmax(420px, 1fr) 300px; gap: 20px; } .window-controls { display: flex; gap: 0; margin-left: 4px; } @@ -749,7 +772,7 @@ button.qrow { width: 100%; } rail, the side panel a drawer, the player bar two rows, and views reflow to one column so nothing scrolls sideways or is clipped. */ @media (max-width: 900px) { - h1 { font-size: 22px; } + :root { --page-gutter: 8px; --title-display: 32px; --title-section: 20px; } .app { grid-template-rows: auto minmax(0, 1fr) auto; } .app-body { grid-template-columns: 52px minmax(0, 1fr); position: relative; } .sidebar .section-title, .sidebar .nav-item span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @@ -773,8 +796,8 @@ button.qrow { width: 100%; } .volume input { width: 64px; } .toasts { bottom: 150px; max-width: calc(100vw - 24px); } .toast { flex-wrap: wrap; } - .view-header { flex-wrap: wrap; padding: 12px 12px 8px; } - .view-body { padding: 0 12px 12px; } + .view-header { padding: 16px var(--page-pad) 12px; } + .view-body { padding: 4px var(--page-gutter) 16px; } .detail-head { flex-wrap: wrap; padding: 12px; gap: 12px; } .detail-head .art { width: 120px; height: 120px; } .detail-head .meta { flex: 1 1 200px; } @@ -789,7 +812,6 @@ button.qrow { width: 100%; } .shortcut-row { grid-template-columns: minmax(0, 1fr) auto; } .shortcut-row > :last-child { grid-column: 2; } .filter-builder { grid-template-columns: minmax(0, 1fr); } - .pin-row { grid-template-columns: 44px minmax(0, 1fr) auto; } .pin-row .badge { display: none; } .dialog { max-height: calc(100vh - 24px); overflow-y: auto; } .overlay { padding-top: 12px; } diff --git a/desktop/src/renderer/styles/m3-library.css b/desktop/src/renderer/styles/m3-library.css index 07ab638..e17bfe9 100644 --- a/desktop/src/renderer/styles/m3-library.css +++ b/desktop/src/renderer/styles/m3-library.css @@ -11,14 +11,12 @@ 12-scallop one. Same point count, so clip-path morphs between them. */ --lib-cookie-9: polygon(50% 0%, 52.17% 0.35%, 54.26% 1.36%, 56.20% 2.88%, 57.99% 4.70%, 59.62% 6.58%, 61.17% 8.30%, 62.72% 9.65%, 64.36% 10.53%, 66.19% 10.92%, 68.25% 10.87%, 70.53% 10.56%, 73% 10.16%, 75.54% 9.91%, 78.01% 10%, 80.25% 10.57%, 82.14% 11.70%, 83.57% 13.36%, 84.53% 15.47%, 85.04% 17.89%, 85.24% 20.43%, 85.28% 22.93%, 85.36% 25.24%, 85.68% 27.27%, 86.37% 29%, 87.52% 30.47%, 89.13% 31.75%, 91.08% 32.98%, 93.23% 34.27%, 95.33% 35.71%, 97.16% 37.36%, 98.52% 39.24%, 99.24% 41.32%, 99.27% 43.51%, 98.64% 45.74%, 97.49% 47.93%, 96% 50%, 94.43% 51.94%, 93.01% 53.76%, 91.94% 55.52%, 91.36% 57.29%, 91.30% 59.16%, 91.70% 61.17%, 92.41% 63.37%, 93.23% 65.73%, 93.91% 68.19%, 94.25% 70.64%, 94.08% 72.95%, 93.30% 75%, 91.91% 76.70%, 90% 78.01%, 87.71% 78.93%, 85.24% 79.57%, 82.79% 80.04%, 80.53% 80.53%, 78.58% 81.19%, 77% 82.17%, 75.75% 83.56%, 74.76% 85.36%, 73.89% 87.50%, 73% 89.84%, 71.95% 92.16%, 70.64% 94.25%, 69.02% 95.91%, 67.10% 96.98%, 64.94% 97.40%, 62.64% 97.16%, 60.29% 96.40%, 57.99% 95.30%, 55.80% 94.09%, 53.76% 93.01%, 51.85% 92.26%, 50% 92%, 48.15% 92.26%, 46.24% 93.01%, 44.20% 94.09%, 42.01% 95.30%, 39.71% 96.40%, 37.36% 97.16%, 35.06% 97.40%, 32.90% 96.98%, 30.98% 95.91%, 29.36% 94.25%, 28.05% 92.16%, 27% 89.84%, 26.11% 87.50%, 25.24% 85.36%, 24.25% 83.56%, 23% 82.17%, 21.42% 81.19%, 19.47% 80.53%, 17.21% 80.04%, 14.76% 79.57%, 12.29% 78.93%, 10% 78.01%, 8.09% 76.70%, 6.70% 75%, 5.92% 72.95%, 5.75% 70.64%, 6.09% 68.19%, 6.77% 65.73%, 7.59% 63.37%, 8.30% 61.17%, 8.70% 59.16%, 8.64% 57.29%, 8.06% 55.52%, 6.99% 53.76%, 5.57% 51.94%, 4% 50%, 2.51% 47.93%, 1.36% 45.74%, 0.73% 43.51%, 0.76% 41.32%, 1.48% 39.24%, 2.84% 37.36%, 4.67% 35.71%, 6.77% 34.27%, 8.92% 32.98%, 10.87% 31.75%, 12.48% 30.47%, 13.63% 29%, 14.32% 27.27%, 14.64% 25.24%, 14.72% 22.93%, 14.76% 20.43%, 14.96% 17.89%, 15.47% 15.47%, 16.43% 13.36%, 17.86% 11.70%, 19.75% 10.57%, 21.99% 10%, 24.46% 9.91%, 27% 10.16%, 29.47% 10.56%, 31.75% 10.87%, 33.81% 10.92%, 35.64% 10.53%, 37.28% 9.65%, 38.83% 8.30%, 40.38% 6.58%, 42.01% 4.70%, 43.80% 2.88%, 45.74% 1.36%, 47.83% 0.35%); --lib-cookie-12: polygon(50% 0%, 52.17% 0.32%, 54.27% 1.19%, 56.27% 2.41%, 58.16% 3.71%, 60.01% 4.83%, 61.91% 5.57%, 63.91% 5.87%, 66.07% 5.83%, 68.37% 5.65%, 70.71% 5.59%, 72.96% 5.89%, 75% 6.70%, 76.72% 8.06%, 78.11% 9.86%, 79.22% 11.92%, 80.21% 14%, 81.26% 15.89%, 82.53% 17.47%, 84.11% 18.74%, 86% 19.79%, 88.08% 20.78%, 90.14% 21.89%, 91.94% 23.28%, 93.30% 25%, 94.11% 27.04%, 94.41% 29.29%, 94.35% 31.63%, 94.17% 33.93%, 94.13% 36.09%, 94.43% 38.09%, 95.17% 39.99%, 96.29% 41.84%, 97.59% 43.73%, 98.81% 45.73%, 99.68% 47.83%, 100% 50%, 99.68% 52.17%, 98.81% 54.27%, 97.59% 56.27%, 96.29% 58.16%, 95.17% 60.01%, 94.43% 61.91%, 94.13% 63.91%, 94.17% 66.07%, 94.35% 68.37%, 94.41% 70.71%, 94.11% 72.96%, 93.30% 75%, 91.94% 76.72%, 90.14% 78.11%, 88.08% 79.22%, 86% 80.21%, 84.11% 81.26%, 82.53% 82.53%, 81.26% 84.11%, 80.21% 86%, 79.22% 88.08%, 78.11% 90.14%, 76.72% 91.94%, 75% 93.30%, 72.96% 94.11%, 70.71% 94.41%, 68.37% 94.35%, 66.07% 94.17%, 63.91% 94.13%, 61.91% 94.43%, 60.01% 95.17%, 58.16% 96.29%, 56.27% 97.59%, 54.27% 98.81%, 52.17% 99.68%, 50% 100%, 47.83% 99.68%, 45.73% 98.81%, 43.73% 97.59%, 41.84% 96.29%, 39.99% 95.17%, 38.09% 94.43%, 36.09% 94.13%, 33.93% 94.17%, 31.63% 94.35%, 29.29% 94.41%, 27.04% 94.11%, 25% 93.30%, 23.28% 91.94%, 21.89% 90.14%, 20.78% 88.08%, 19.79% 86%, 18.74% 84.11%, 17.47% 82.53%, 15.89% 81.26%, 14% 80.21%, 11.92% 79.22%, 9.86% 78.11%, 8.06% 76.72%, 6.70% 75%, 5.89% 72.96%, 5.59% 70.71%, 5.65% 68.37%, 5.83% 66.07%, 5.87% 63.91%, 5.57% 61.91%, 4.83% 60.01%, 3.71% 58.16%, 2.41% 56.27%, 1.19% 54.27%, 0.32% 52.17%, 0% 50%, 0.32% 47.83%, 1.19% 45.73%, 2.41% 43.73%, 3.71% 41.84%, 4.83% 39.99%, 5.57% 38.09%, 5.87% 36.09%, 5.83% 33.93%, 5.65% 31.63%, 5.59% 29.29%, 5.89% 27.04%, 6.70% 25%, 8.06% 23.28%, 9.86% 21.89%, 11.92% 20.78%, 14% 19.79%, 15.89% 18.74%, 17.47% 17.47%, 18.74% 15.89%, 19.79% 14%, 20.78% 11.92%, 21.89% 9.86%, 23.28% 8.06%, 25% 6.70%, 27.04% 5.89%, 29.29% 5.59%, 31.63% 5.65%, 33.93% 5.83%, 36.09% 5.87%, 38.09% 5.57%, 39.99% 4.83%, 41.84% 3.71%, 43.73% 2.41%, 45.73% 1.19%, 47.83% 0.32%); - --lib-title-size: clamp(36px, 2.4vw + 18px, 52px); --lib-hero-title-size: clamp(32px, 2.6vw + 16px, 56px); } -/* ---- View headers --------------------------------------------------------- */ -.view-header { align-items: baseline; flex-wrap: wrap; gap: 8px 14px; padding: 28px 28px 16px; } -.view-header h1 { - font-size: var(--lib-title-size); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; +/* ---- View headers (every page, the tool pages and settings included) -------- */ +.view-header h1, .settings-heading { + font-size: var(--title-display); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; min-width: 0; overflow-wrap: anywhere; } /* The item count: a small tonal chip riding the title's baseline. */ @@ -28,8 +26,7 @@ font-variant-numeric: tabular-nums; position: relative; top: -6px; } .view-header .count-chip:empty { display: none; } -.view-header .actions { align-self: center; gap: 8px; } -.view-header .actions .btn:not(.icon) { height: 40px; padding: 0 20px 0 16px; font-size: var(--fs-md); font-weight: 600; } +.view-header .actions .btn:not(.icon) { height: var(--btn-h-lg); padding: 0 20px 0 16px; font-size: var(--fs-md); font-weight: 600; } .view-header .actions .btn.tonal { color: var(--fg); } /* Sort: a connected button group — a pill select joined to the direction @@ -51,8 +48,6 @@ .sort-group .btn.icon:where(:not(.player *, .fullscreen *)):active { border-radius: var(--shape-sm); } /* ---- Grids and tiles ------------------------------------------------------ */ -.view-body { padding: 4px 20px 24px; } -.view-body.no-pad { padding: 0; } .tile { gap: 8px; padding: 8px; border-radius: var(--shape-xl); transition: background-color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); @@ -88,25 +83,26 @@ .tile-badge { top: 14px !important; left: 14px !important; padding: 5px; } /* ---- Home: carousels under expressive section headings ------------------- */ -.home-section { margin-bottom: 28px; } +.home-section { margin-bottom: 20px; } .home-section:last-child { margin-bottom: 8px; } -.view-body .section-heading { - display: flex; align-items: center; gap: 12px; font-size: 22px; font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; margin: 0 0 10px 8px; -} .section-icon { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto; clip-path: var(--lib-cookie-9); background: var(--selection); color: var(--accent-text); transition: clip-path var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring-long) var(--spring-bouncy); } -.home-section:hover .section-icon { clip-path: var(--lib-cookie-12); transform: rotate(20deg); } +:is(.home-section, section):hover > .section-heading .section-icon { clip-path: var(--lib-cookie-12); transform: rotate(20deg); } .home-section .scroller { gap: 6px; padding: 6px 4px 12px; margin: -6px -4px 0; scroll-snap-type: x proximity; scroll-padding-inline: 4px; } +/* The carousel's scrollbar stays out of the way until the pointer is over it. */ +.home-section .scroller::-webkit-scrollbar { height: 8px; } +.home-section .scroller::-webkit-scrollbar-thumb { background: transparent; background-clip: padding-box; border: 2px solid transparent; } +.home-section .scroller:hover::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--fg) 18%, transparent); background-clip: padding-box; } .home-section .scroller .tile { width: 172px; scroll-snap-align: start; } /* ---- Detail heroes -------------------------------------------------------- */ .detail-head { - margin: 12px 16px 12px; padding: 28px; gap: 28px; align-items: flex-end; border-radius: var(--shape-xl); + margin: 12px var(--page-gutter); padding: 28px; gap: 28px; align-items: flex-end; border-radius: var(--shape-xl); background-color: var(--bg-elev); background-image: linear-gradient(135deg, var(--accent-soft) 0%, var(--surface-tonal) 72%); } @@ -142,13 +138,13 @@ .detail-head .meta .actions .btn.icon:where(.on, [aria-pressed="true"]) { border-radius: var(--shape-md); } .detail-head .meta .actions .stars { padding: 0 12px; height: 44px; align-items: center; border-radius: var(--shape-full); background: var(--bg-elev); } .detail-head .meta .actions .stars svg { width: 18px; height: 18px; } -/* Artist pages: the section headings sit in the body, the top songs in a sunken well. */ -[data-testid="view-artist"] .view-body .section-heading { margin: 8px 0 10px 8px; } +/* Artist pages: one section after another, with the page rhythm between them. */ +[data-testid="view-artist"] .view-body .section-heading { margin-top: 8px; } [data-testid="view-artist"] .view-body .section-heading ~ .section-heading { margin-top: 28px; } /* ---- Track tables --------------------------------------------------------- */ .table-head { - height: 34px; margin: 0 4px 6px; padding: 0 8px; border: 0; border-radius: var(--shape-full); + height: 34px; margin: 0 var(--table-inset) 6px; padding: 0 8px; border: 0; border-radius: var(--shape-full); background: var(--bg-sunken); color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 600; letter-spacing: 0.01em; } .table-head .th.sortable:hover { background: var(--bg-hover); } @@ -189,9 +185,6 @@ } .genre-tile:hover::after { transform: rotate(40deg) scale(1.15); clip-path: var(--lib-cookie-12); } -/* ---- Search --------------------------------------------------------------- */ -.search-songs .section-heading { margin-left: 8px; } - /* ---- Empty states: one glyph in a tonal cookie ---------------------------- */ .empty { gap: 12px; font-size: var(--fs-lg); max-width: 520px; margin: 0 auto; } .empty-icon { @@ -199,6 +192,10 @@ clip-path: var(--lib-cookie-9); background: var(--accent-soft); color: var(--accent-text); } .empty-cell { display: flex; flex-direction: column; align-items: center; gap: 12px; } +/* In the side panel the same empty state, a size down. */ +.right-panel .empty { font-size: var(--fs-md); gap: 10px; } +.right-panel .empty-icon { width: 56px; height: 56px; } +.right-panel .empty-icon svg { width: 24px; height: 24px; } /* ---- Stars and hearts ----------------------------------------------------- */ .heart { transition: transform var(--dur-spring) var(--spring-bouncy), background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); } @@ -207,17 +204,16 @@ /* ---- Narrow windows and 200% zoom ----------------------------------------- */ @media (max-width: 900px) { - .view-header { padding: 16px 14px 10px; } .view-header .actions { margin-left: 0; } - .view-body { padding: 4px 8px 16px; } - .detail-head { margin: 8px; padding: 16px; gap: 16px; align-items: flex-start; } + .detail-head { margin: 8px var(--page-gutter); padding: 16px; gap: 16px; align-items: flex-start; } .detail-head .art, .detail-head .art.round { width: 128px; height: 128px; border-radius: 32px; } .detail-head .art.round { border-radius: 0; } .detail-head .meta { flex: 1 1 220px; } .detail-head .meta .actions { margin-top: 8px; } .detail-head .meta .actions .btn.fab { height: 52px; padding: 0 22px 0 18px; border-radius: 18px; } - .home-section .scroller { margin: 0; padding: 0; } - .home-section .scroller .tile { width: 150px; } + /* The carousels wrap into the same fluid grid as the library pages, so rows fill the width. */ + .home-section .scroller { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); margin: 0; padding: 0; } + .home-section .scroller .tile { width: auto; } .genre-grid { --tile-w: 160px; } } @@ -225,7 +221,7 @@ so the hero's controls are never cut off; the track list keeps a bounded height of its own for virtualisation. */ @media (max-height: 640px) { - .detail-head { padding: 12px 14px; gap: 14px; margin: 6px 8px; } + .detail-head { padding: 12px 14px; gap: 14px; margin: 6px var(--page-gutter); } .detail-head .art, .detail-head .art.round { width: 88px; height: 88px; border-radius: 24px; } .detail-head .art.round { border-radius: 0; } .detail-head .meta h1 { font-size: 28px; } @@ -242,7 +238,7 @@ /* ---- Reduced motion: no lifts, spins or pops (colour changes stay) ------- */ @media (prefers-reduced-motion: reduce) { .tile:hover .art, .tile:hover .art.round, .tile .play, .tile:hover .play, .tile .play:active, - .home-section:hover .section-icon, .genre-tile:hover::after, .heart.on svg { transform: none !important; animation: none !important; } + :is(.home-section, section):hover > .section-heading .section-icon, .genre-tile:hover::after, .heart.on svg { transform: none !important; animation: none !important; } } /* ---- Forced colours -------------------------------------------------------- */ diff --git a/desktop/src/renderer/styles/m3-pages.css b/desktop/src/renderer/styles/m3-pages.css index 48597fe..75639e5 100644 --- a/desktop/src/renderer/styles/m3-pages.css +++ b/desktop/src/renderer/styles/m3-pages.css @@ -5,23 +5,17 @@ sunken surface, contrast-checked in global.css) and anything selected fills with the opaque --state-selected. Text only ever sits on those surfaces. */ -/* ---- Shared: display type, section headings, page padding ---------------- */ -:is(.page-downloads, .page-filters, .page-filter-builder, .page-stats) .view-header { padding: 32px 32px 18px; align-items: flex-end; } -:is(.page-downloads, .page-filters, .page-filter-builder, .page-stats) .view-header h1, -.page-settings .settings-heading { - font-size: 48px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; margin: 0; -} -:is(.page-downloads, .page-filters, .page-filter-builder, .page-stats) .view-body:not(.no-pad) { padding: 0 32px 32px; } -/* Section headings: sentence-case bold, not tiny uppercase labels. */ -:is(.page-settings, .page-filter-builder) .section-title { - font-size: 20px; font-weight: 700; letter-spacing: -0.005em; line-height: 1.2; text-transform: none; color: var(--fg); padding: 28px 4px 12px; +/* ---- Shared ---------------------------------------------------------------- + Page padding, display titles, section headings, buttons and text fields come + from the shared tokens and rules in global.css / m3-library.css. */ +/* Settings section headings (h2.section-title) read like every other page's section heading. */ +.page-settings .section-title { + font-size: var(--title-section); font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; text-transform: none; color: var(--fg); padding: 28px var(--page-title-inset) 0; } -/* Filled rounded fields on these pages: taller, softer, and squarer while focused. */ -:is(.page-settings, .page-filter-builder, .page-stats) :is(.input, .select) { height: 36px; padding: 0 14px; border-radius: var(--shape-md); } -:is(.page-settings, .page-filter-builder, .page-stats) .select { padding-right: 30px; } -:is(.page-settings, .page-filter-builder, .page-stats) :is(.input, .select):focus { border-radius: var(--shape-xs); } -:is(.page-settings, .page-filter-builder, .page-stats) .btn.sm { height: 32px; } +/* Filled rounded selects on these pages: taller, softer, and squarer while focused. */ +:is(.page-settings, .page-filter-builder, .page-stats) .select { height: 36px; padding: 0 30px 0 14px; border-radius: var(--shape-md); } +:is(.page-settings, .page-filter-builder, .page-stats) .select:focus { border-radius: var(--shape-xs); } .page-stats .view-header .select { border-radius: var(--shape-full); padding-left: 18px; } /* ---- M3 switch ------------------------------------------------------------- @@ -75,7 +69,7 @@ .page-settings .eq .bands input[type="range"]::-webkit-slider-thumb { height: 12px; width: 3px; box-shadow: 0 0 0 3px var(--slider-gap), -100vw 0 0 100vw var(--accent); } /* ---- Settings: pill section nav ------------------------------------------- */ -.page-settings .settings { grid-template-columns: 240px minmax(0, 1fr); } +.page-settings .settings { grid-template-columns: 256px minmax(0, 1fr); } .page-settings .settings-nav { border-right: 0; padding: 24px 8px 24px 16px; display: flex; flex-direction: column; gap: 2px; } .page-settings .settings-nav .nav-item { height: 44px; padding: 0 18px 0 14px; gap: 14px; font-size: 14px; color: var(--fg-muted); flex: 0 0 auto; } .page-settings .settings-nav .nav-item + .nav-item { margin-top: 0; } @@ -84,10 +78,10 @@ .page-settings .settings-nav .nav-item.active { background: var(--state-selected); color: var(--fg); font-weight: 700; } .page-settings .settings-nav .nav-item.active:hover, .page-settings .settings-nav .nav-item.active:active { background: var(--state-selected); } .page-settings .settings-nav .nav-item:active { border-radius: var(--shape-sm); } -.page-settings .settings-body { padding: 36px 40px 56px 32px; max-width: 920px; } -.page-settings .settings-heading { margin-bottom: 24px; } +.page-settings .settings-body { padding: 28px 40px 56px var(--page-gutter); max-width: 920px; } +.page-settings .settings-heading { margin: 0 0 24px; padding-left: var(--page-title-inset); } .page-settings .settings-heading + .section-title { padding-top: 4px; } -.page-settings .settings-body > .muted.small, .page-settings .settings-lede { font-size: var(--fs-md); margin-bottom: 16px !important; } +.page-settings .settings-body > .muted.small, .page-settings .settings-lede { font-size: var(--fs-md); margin-bottom: 16px !important; padding-left: var(--page-title-inset); } /* Grouped ("segmented") lists: consecutive rows share one container, split by 2px gaps, with big corners only on the ends of each run. */ @@ -102,10 +96,7 @@ .page-settings .setting-row .label .desc { font-size: var(--fs-md); } .page-settings .setting-row .control { gap: 10px; } .page-settings .setting-row .control .mono.small { font-variant-numeric: tabular-nums; } -.page-settings .setting-row .badge { background: var(--bg-elev); font-weight: 500; } .page-settings .setting-row .badge.synced { background: var(--state-selected); color: var(--fg); } -.page-settings .setting-row .badge.ok { background: var(--bg-elev); } -.page-settings .setting-row .badge.warn { background: var(--bg-elev); } .page-settings .setting-row :is(.input, .select) { background: var(--bg-elev); } .page-settings .setting-row.stacked { padding: 18px 20px 20px; gap: 14px; } /* A server: its name and details, then its actions underneath. */ @@ -196,25 +187,12 @@ .setup .spin { animation: spin 1.2s linear infinite; } /* ---- Downloads -------------------------------------------------------------- */ -.page-downloads .view-header .actions { gap: 12px; } -.page-downloads .view-tabs-row { padding: 0 32px 18px; } -/* A connected button group: outer ends round, inner corners tight; the - selected segment fills tonally and morphs into a full pill. */ -.page-downloads .view-tabs { background: none; box-shadow: none; padding: 0; gap: 2px; border-radius: 0; } -.page-downloads .view-tabs .tab { - height: 40px; padding: 0 22px; font-size: 14px; color: var(--fg); background: var(--bg-sunken); border-radius: var(--shape-xs); - transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); -} -.page-downloads .view-tabs .tab:first-child { border-radius: var(--shape-full) var(--shape-xs) var(--shape-xs) var(--shape-full); } -.page-downloads .view-tabs .tab:last-child { border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); } -.page-downloads .view-tabs .tab:hover { background: var(--bg-sunken); box-shadow: inset 0 0 0 1px var(--border-strong); color: var(--fg); } -.page-downloads .view-tabs .tab.active, .page-downloads .view-tabs .tab.active:hover { background: var(--state-selected); color: var(--fg); font-weight: 700; border-radius: var(--shape-full); box-shadow: none; } -.page-downloads .offline-head { padding: 0 24px 10px; } +.page-downloads .offline-head { padding: 0 var(--page-gutter) 10px var(--page-pad); } /* Rows of the Downloads and Filters lists: grouped rounded list items. */ -:is(.page-downloads, .page-filters) .pin-row { - grid-template-columns: 56px minmax(0, 1fr) auto auto; gap: 16px; padding: 10px 12px 10px 10px; margin-top: 2px; - background: var(--surface-tonal-sunken); border-bottom: 0; border-radius: var(--shape-xs); +.pin-row { + display: grid; align-items: center; grid-template-columns: 56px minmax(0, 1fr) auto auto; gap: 16px; padding: 10px 12px 10px 10px; margin-top: 2px; + background: var(--surface-tonal-sunken); border-radius: var(--shape-xs); transition: background-color var(--dur-short) var(--ease-emphasized); } :is(.page-downloads, .page-filters) .pin-row:not(.pin-row + .pin-row) { margin-top: 0; border-top-left-radius: var(--shape-xl); border-top-right-radius: var(--shape-xl); } @@ -224,7 +202,7 @@ .page-downloads .pin-row .art { width: 56px; height: 56px; border-radius: var(--shape-md); transition: border-radius var(--dur-spring-long) var(--spring-fast); } .page-downloads .pin-row:hover .art { border-radius: var(--shape-xl); } .page-downloads .pin-row .small.muted { margin-top: 2px; } -.page-downloads .pin-row .badge { background: var(--bg-elev); text-transform: capitalize; font-weight: 500; } +.page-downloads .pin-row .badge { text-transform: capitalize; } /* Download progress: M3 linear progress, rounded ends, a gap, a stop dot. */ .page-downloads .pin-row .progress { height: 6px; display: flex; background: none; border-radius: 0; overflow: visible; margin-top: 8px !important; max-width: 420px; } .page-downloads .pin-row .progress > div { flex: 0 1 auto; border-radius: var(--shape-full); } @@ -242,13 +220,11 @@ transition: border-radius var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring-long) var(--spring-bouncy); } .page-filters .filter-row:hover .filter-avatar { border-radius: 50%; transform: rotate(-8deg); } -.page-filters .filter-row .link-button .badge { background: var(--bg-elev); vertical-align: 1px; } .page-filters .filter-row .link-button .small { margin-top: 2px; font-weight: 400; } /* ---- Filter builder ------------------------------------------------------------ */ .page-filter-builder .filter-builder { grid-template-columns: minmax(420px, 1fr) 320px; gap: 28px; align-items: start; } .page-filter-builder .filter-builder > .stack { gap: 16px; } -.page-filter-builder [data-testid="filter-name"] { height: 48px; font-size: var(--fs-lg); padding: 0 18px; } .page-filter-builder .capability-chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px 0 10px; border-radius: var(--shape-sm); font-size: var(--fs-md); font-weight: 500; line-height: 1.2; } .page-filter-builder .rules { gap: 10px; } /* Rule groups are tonal cards; a nested group flips to the pane surface so the nesting reads. */ @@ -259,17 +235,16 @@ .page-filter-builder .rule-group .group-head { gap: 8px; flex-wrap: wrap; } .page-filter-builder .rule-group .group-head .select { border-radius: var(--shape-full); font-weight: 600; } /* "Add rule" / "Add group" are assist chips: small corners, an outline, squarer still when pressed. */ -.page-filter-builder .group-head .btn.sm { height: 32px; padding: 0 12px 0 8px; border-radius: var(--shape-sm); background: transparent; border: 1px solid var(--border-strong); font-size: var(--fs-md); } +.page-filter-builder .group-head .btn.sm { padding: 0 12px 0 8px; border-radius: var(--shape-sm); background: transparent; border: 1px solid var(--border-strong); } .page-filter-builder .group-head .btn.sm:hover { background: var(--bg-hover); } .page-filter-builder .group-head .btn.sm:active { border-radius: var(--shape-xs); } .page-filter-builder .rule { gap: 8px; } .page-filter-builder .rule > .input:not([type]) { flex: 1 1 160px; min-width: 0; } .page-filter-builder .filter-builder > .stack > .row { flex-wrap: wrap; gap: 10px 16px; } -.page-filter-builder .rule .select, .page-filter-builder .rule .input { height: 36px; } .page-filter-builder .rule .btn.icon.sm { width: 32px; height: 32px; } /* The sample: a tonal side card. */ .page-filter-builder .filter-sample { background: var(--surface-tonal-sunken); border-radius: var(--shape-xl); padding: 4px 8px 12px; min-width: 0; } -.page-filter-builder .filter-sample .section-title { padding: 16px 12px 10px; font-size: 18px; } +.page-filter-builder .filter-sample .card-title { padding: 16px 12px 0; margin-bottom: 10px; } .page-filter-builder .filter-sample .qrow { height: 48px !important; margin: 0; border-radius: var(--shape-md); padding: 0 10px; } .page-filter-builder .filter-sample .qrow .art { width: 36px; height: 36px; border-radius: var(--shape-sm); } @@ -285,8 +260,6 @@ .page-stats .stat-figure { font-size: 56px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; } .page-stats .stat-unit { font-size: 28px; font-weight: 700; letter-spacing: -0.01em; color: var(--fg-muted); } .page-stats .stat-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } -.page-stats .card { background: var(--surface-tonal-sunken); border: 0; border-radius: var(--shape-xl); padding: 20px; } -.page-stats .card-title { font-size: 20px; font-weight: 700; letter-spacing: -0.005em; margin-bottom: 14px; } .page-stats .bars { gap: 4px; height: 140px; } .page-stats .bars div { border-radius: var(--shape-full); min-height: 8px; opacity: 1; background: var(--accent); transition: background-color var(--dur-short) var(--ease-emphasized); } .page-stats .bars div:hover { background: var(--accent-text); } @@ -330,7 +303,6 @@ .page-settings .cache-bar::after, .page-downloads .pin-row .progress::after { forced-color-adjust: none; background: Canvas; border: 1px solid CanvasText; } .page-settings .cache-bar { border: 0; } .page-downloads .pin-row .progress { border: 0; } - .page-downloads .view-tabs .tab.active { outline: 2px solid Highlight; } .page-stats .bars div:hover { background: Highlight; } .setup-shapes { display: none; } .mini .btn.mini-play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; border: 1px solid ButtonText; } @@ -338,16 +310,12 @@ /* ---- Narrow windows and 200% zoom --------------------------------------------- */ @media (max-width: 900px) { - :is(.page-downloads, .page-filters, .page-filter-builder, .page-stats) .view-header { padding: 16px 16px 12px; } - :is(.page-downloads, .page-filters, .page-filter-builder, .page-stats) .view-header h1, .page-settings .settings-heading { font-size: 32px; } - :is(.page-downloads, .page-filters, .page-filter-builder, .page-stats) .view-body:not(.no-pad) { padding: 0 16px 16px; } - .page-downloads .view-tabs-row { padding: 0 16px 12px; } - .page-downloads .offline-head { padding: 0 8px 8px; flex-wrap: wrap; } + .page-downloads .offline-head { padding: 0 var(--page-gutter) 8px var(--page-pad); flex-wrap: wrap; } .page-settings .settings { grid-template-columns: minmax(0, 1fr); } .page-settings .settings-nav { flex-direction: row; flex-wrap: wrap; gap: 6px; padding: 12px 16px 4px; border-bottom: 0; } .page-settings .settings-nav .nav-item { height: 36px; padding: 0 14px 0 10px; gap: 8px; font-size: var(--fs-md); background: var(--bg-sunken); } .page-settings .settings-nav .nav-item .icon { width: 18px; height: 18px; } - .page-settings .settings-body { padding: 16px 16px 28px; } + .page-settings .settings-body { padding: 16px var(--page-gutter) 28px; } .page-settings .setting-row { padding: 12px 16px; gap: 10px; } .page-settings .setting-row .control { justify-content: flex-start; } /* A lone switch stays beside its label. */ @@ -355,7 +323,7 @@ .page-settings .shortcut-row { padding-left: 16px; grid-template-columns: minmax(0, 1fr) auto auto; } .page-settings .shortcut-row > :last-child { grid-column: auto; } .page-settings .shortcut-row .rec { min-width: 0; } - :is(.page-downloads, .page-filters) .pin-row { grid-template-columns: 56px minmax(0, 1fr) auto; gap: 12px; } + .pin-row { grid-template-columns: 56px minmax(0, 1fr) auto; gap: 12px; } .page-filter-builder .filter-builder { grid-template-columns: minmax(0, 1fr); } .page-stats .stat-figure { font-size: 44px; } .setup { padding: 16px; } diff --git a/desktop/src/renderer/styles/m3-shell.css b/desktop/src/renderer/styles/m3-shell.css index 8440610..7466e5a 100644 --- a/desktop/src/renderer/styles/m3-shell.css +++ b/desktop/src/renderer/styles/m3-shell.css @@ -29,8 +29,9 @@ /* ---- Navigation drawer / rail -------------------------------------------- */ .sidebar { background: transparent; border-right: 0; padding-right: var(--pane-gap); } -.sidebar-scroll { padding: 2px 4px 12px 10px; } -.sidebar-foot { border-top: 0; padding: 4px 4px 2px 10px; } +.sidebar-scroll { padding: 2px 4px 12px 10px; scrollbar-gutter: stable; } +/* The foot's right edge leaves room for the list's scrollbar gutter, so its pill is as wide as the ones above. */ +.sidebar-foot { border-top: 0; padding: 4px 12px 2px 10px; } .sidebar .section-title { padding: 18px 16px 6px; font-size: var(--fs-md); font-weight: 700; letter-spacing: 0; text-transform: none; color: var(--fg-muted); } @@ -101,7 +102,7 @@ top: calc(100% + 8px); width: 400px; border: 0; border-radius: var(--pane-radius); padding: 12px 8px; box-shadow: var(--shadow-2), var(--shadow); } -.popover .section-title { font-size: var(--fs-xl); font-weight: 800; letter-spacing: -0.01em; text-transform: none; color: var(--fg); padding: 4px 12px 10px; } +.popover .section-title { font-size: var(--title-card); font-weight: 750; letter-spacing: -0.01em; text-transform: none; color: var(--fg); padding: 4px 12px 10px; } .popover .section-title.row { font-size: var(--fs-lg); padding-top: 14px; } .popover .item { padding: 10px 12px; gap: 6px; border-radius: var(--shape-lg); } .popover .item + .item { border-top: 0; box-shadow: 0 -1px 0 var(--border); } @@ -130,8 +131,7 @@ height: 30px; padding: 0 16px; font-size: var(--fs-md); font-weight: 600; transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); } -.pane-head .tab.active { background: var(--selection); color: var(--fg); font-weight: 700; } -.pane-head .tab.active:hover { background: var(--selection); } +.pane-head .tab.active, .pane-head .tab.active:hover { background: var(--state-selected); color: var(--fg); font-weight: 700; } .pane-head .tab.active:first-child { border-radius: var(--shape-full) var(--shape-sm) var(--shape-sm) var(--shape-full); } .pane-head .tab.active:last-child { border-radius: var(--shape-sm) var(--shape-full) var(--shape-full) var(--shape-sm); } /* Queue rows inside the side panel (the fullscreen player styles its own). */ @@ -145,9 +145,13 @@ .sq-row { margin: 0 8px; padding: 8px 8px; border-radius: var(--shape-lg); transition: background-color var(--dur-short) var(--ease-emphasized); } .sq-row .art { width: 44px; height: 44px; border-radius: var(--shape-md); background: var(--bg-sunken); } .right-panel .lyrics-static { padding: 8px 20px 16px; } +/* The queue's per-section "Clear" stays a compact text button beside its heading. */ +.right-panel .queue-clear.btn.sm { height: 28px; padding: 0 12px; font-size: var(--fs-sm); } +/* The lyrics tools float as a small pill over the lyrics; their buttons stay compact. */ +.lyrics-tools .btn.sm:not(.icon) { height: 24px; padding: 0 8px; font-size: var(--fs-xs); } /* ---- Menus -------------------------------------------------------------- */ -.menu { border: 0; border-radius: var(--shape-lg); padding: 8px; min-width: 220px; box-shadow: var(--shadow-2), var(--shadow); } +.menu { border: 0; border-radius: var(--shape-lg); padding: 8px; min-width: 220px; box-shadow: var(--shadow-2), var(--shadow); max-height: calc(100vh - 12px); overflow-y: auto; } .menu.fade-in { animation: menu-in var(--dur-spring) var(--spring-fast); } @keyframes menu-in { from { opacity: 0; transform: scale(0.9, 0.8); } to { opacity: 1; transform: none; } } .menu .mi { diff --git a/desktop/src/renderer/views/AlbumDetail.tsx b/desktop/src/renderer/views/AlbumDetail.tsx index 0aec169..4ff46e7 100644 --- a/desktop/src/renderer/views/AlbumDetail.tsx +++ b/desktop/src/renderer/views/AlbumDetail.tsx @@ -44,7 +44,7 @@ export function AlbumDetail({ id }: { id: string }) {
  • -
    +
    play(i)} playingTrackId={playing} emptyMessage={t("songs.empty")} testId="album-tracks" primer={primer} />
    diff --git a/desktop/src/renderer/views/ArtistDetail.tsx b/desktop/src/renderer/views/ArtistDetail.tsx index c9c4a8d..8fc4e25 100644 --- a/desktop/src/renderer/views/ArtistDetail.tsx +++ b/desktop/src/renderer/views/ArtistDetail.tsx @@ -38,13 +38,13 @@ export function ArtistDetail({ id }: { id: string }) {
    {topList.length ? ( <> -

    {t("artist.topSongs")}

    +

    {t("artist.topSongs")}

    bridge().dispatch({ type: "playTracks", data: { server_id: serverId, track_ids: topList.map((x) => x.id), start_index: i, label: `${a?.name ?? ""} · ${t("artist.topSongs")}`, shuffle: false } })} playingTrackId={playing} testId="artist-top" />
    ) : null} -

    {t("artist.albums")}

    +

    {t("artist.albums")}

    {(albums.data?.items ?? []).map((al) => ( navigate({ view: "album", id: al.id })} onContextMenu={(e) => void openContextMenu(e, { type: "albums", data: { ids: [al.id] } })} testId="artist-album" /> diff --git a/desktop/src/renderer/views/Downloads.tsx b/desktop/src/renderer/views/Downloads.tsx index 7e8a1f0..a58779a 100644 --- a/desktop/src/renderer/views/Downloads.tsx +++ b/desktop/src/renderer/views/Downloads.tsx @@ -28,13 +28,13 @@ export function Downloads({ tab }: { tab?: string }) {

    {t("downloads.title")}

    - {sum ? {t("downloads.usage", { downloads: fmtBytes(sum.downloadsBytes), cache: fmtBytes(sum.cacheBytes), images: fmtBytes(sum.imagesBytes) })} : null}
    navigate({ view: "downloads", param: k === "offline" ? "offline" : undefined }, true)} tabs={[{ key: "pins", label: t("downloads.tab.pins"), testId: "downloads-tab-pins" }, { key: "offline", label: t("downloads.tab.offline"), testId: "downloads-tab-offline" }]} /> + {sum ? {t("downloads.usage", { downloads: fmtBytes(sum.downloadsBytes), cache: fmtBytes(sum.cacheBytes), images: fmtBytes(sum.imagesBytes) })} : null}
    {current === "pins" ? : }
    @@ -54,7 +54,7 @@ function Pins() { }; return (
    - {!list.length ? navigate({ view: "albums" })}>{t("nav.albums")}} /> : null} + {!list.length ? navigate({ view: "albums" })}>{t("nav.albums")}} /> : null} {list.map((p) => (
    @@ -83,11 +83,11 @@ function AvailableOffline() { const { rows, total, onNeedRange } = usePagedTracks(fetchPage, [serverId, "offline", filter.sort, filter.descending]); const play = (startIndex: number) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "filter", data: { filter } }, label: t("downloads.tab.offline"), sort: filter.sort, tracks: [] }, startIndex, shuffle: false, saveOutgoing: true } } }); return ( -
    +
    {t("downloads.offlineHint")} {t("downloads.offlineCount", { count: total })} - +
    play(i)} playingTrackId={playing} emptyMessage={t("downloads.offlineEmpty")} testId="available-offline-table" />
    diff --git a/desktop/src/renderer/views/FilterBuilder.tsx b/desktop/src/renderer/views/FilterBuilder.tsx index 8f10007..fd40a7f 100644 --- a/desktop/src/renderer/views/FilterBuilder.tsx +++ b/desktop/src/renderer/views/FilterBuilder.tsx @@ -82,7 +82,7 @@ export function FilterBuilder({ id }: { id: string }) {

    {existing ? filterName(existing) : t("filters.builder")}

    {preview ? t("filters.count", { count: preview.count }) : } - +
    @@ -108,7 +108,7 @@ export function FilterBuilder({ id }: { id: string }) {
    -
    {t("filters.sample")}
    +
    {t("filters.sample")}
    {(preview?.sample ?? []).map((s) => (
    {s.title}
    {s.artist}
    ))} diff --git a/desktop/src/renderer/views/Filters.tsx b/desktop/src/renderer/views/Filters.tsx index 806a49b..4e146f1 100644 --- a/desktop/src/renderer/views/Filters.tsx +++ b/desktop/src/renderer/views/Filters.tsx @@ -17,10 +17,10 @@ export function Filters() {

    {t("filters.title")}

    -
    +
    - {!filters.length ? navigate({ view: "filter", id: "new" })}>{t("filters.new")}} /> : null} + {!filters.length ? navigate({ view: "filter", id: "new" })}>{t("filters.new")}} /> : null} {filters.map((f) => (
    @@ -28,7 +28,7 @@ export function Filters() { {filterName(f)}{isBuiltinFilter(f.id) ? {t("filters.builtin")} : null} {t("sort.label", { sort: t(`sort.${f.sort}` as never) })}{f.limit ? ` · ${t("filters.limit")} ${f.limit}` : ""} - + {/* Built-ins can't be deleted (the core would only drop a saved override). */} {isBuiltinFilter(f.id) ? : }
    diff --git a/desktop/src/renderer/views/PlaylistDetail.tsx b/desktop/src/renderer/views/PlaylistDetail.tsx index 93dab8e..e82cd24 100644 --- a/desktop/src/renderer/views/PlaylistDetail.tsx +++ b/desktop/src/renderer/views/PlaylistDetail.tsx @@ -41,7 +41,7 @@ export function PlaylistDetail({ id }: { id: string }) {
    -
    +
    play(i)} playingTrackId={playing} context={{ playlistId: id }} onReorder={editable ? (from, to) => bridge().dispatch({ type: "playlistMove", data: { playlist_id: id, from_index: from, to_index: to } }) : undefined} onDelete={editable ? (indices) => bridge().dispatch({ type: "playlistRemove", data: { playlist_id: id, indices } }) : undefined} diff --git a/desktop/src/renderer/views/SearchView.tsx b/desktop/src/renderer/views/SearchView.tsx index 46c3bdf..8a35ff7 100644 --- a/desktop/src/renderer/views/SearchView.tsx +++ b/desktop/src/renderer/views/SearchView.tsx @@ -4,6 +4,7 @@ import { t } from "@shared/strings"; import { useApp } from "../store/app"; import { bridge } from "../core/bridge"; import { TrackTable } from "../components/TrackTable"; +import { Icon } from "../components/Icon"; import { useQuery } from "../store/queries"; import { Tile, TileList } from "../components/Tile"; @@ -24,14 +25,14 @@ export function SearchView({ query }: { query: string }) {
    {(local.data?.albums.length || server?.albums.length) ? (
    -

    {t("search.albums")}

    +

    {t("search.albums")}

    {[...(local.data?.albums ?? []), ...(server?.albums ?? [])].map((a) => ( navigate({ view: "album", id: a.id })} /> ))}
    ) : null}
    -

    {t("search.tracks")}

    +

    {t("search.tracks")}

    bridge().dispatch({ type: "playTracks", data: { server_id: serverId, track_ids: list.map((x) => x.id), start_index: i, label: query, shuffle: false } })} emptyMessage={t("search.noResults", { query })} />
    diff --git a/desktop/src/renderer/views/Songs.tsx b/desktop/src/renderer/views/Songs.tsx index 22c807c..9b5b616 100644 --- a/desktop/src/renderer/views/Songs.tsx +++ b/desktop/src/renderer/views/Songs.tsx @@ -26,7 +26,7 @@ export function Songs() { return (

    {t("songs.title")}

    {total}
    -
    +
    { setSort(s); setDesc(d); saveLocal("songs.sort", s); saveLocal("songs.desc", d); }} onNeedRange={onNeedRange} onPlay={onPlay} playingTrackId={playing} emptyMessage={t("songs.empty")} testId="songs-table" />
    diff --git a/desktop/src/renderer/views/Stats.tsx b/desktop/src/renderer/views/Stats.tsx index 6314212..8eb91a2 100644 --- a/desktop/src/renderer/views/Stats.tsx +++ b/desktop/src/renderer/views/Stats.tsx @@ -21,7 +21,7 @@ export function Stats() {
    - {!loading && !data?.totalPlays ? : null} + {!loading && !data?.totalPlays ? : null} {data?.totalPlays ? (
    From 4823879b67d7dc10e5002d3b764277ef95a9a016 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:45:52 +0000 Subject: [PATCH 15/17] desktop: sort group styles follow the new M3 select The Albums/genre sort group targeted the old native .select; with the merged .m3-select it rendered as a sunken 36px field beside a tonal 40px direction button. Target both, sized with --btn-h-lg. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/src/renderer/styles/m3-library.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/desktop/src/renderer/styles/m3-library.css b/desktop/src/renderer/styles/m3-library.css index e17bfe9..3148727 100644 --- a/desktop/src/renderer/styles/m3-library.css +++ b/desktop/src/renderer/styles/m3-library.css @@ -32,15 +32,15 @@ /* Sort: a connected button group — a pill select joined to the direction toggle, each end fully round, the inner corners tight (M3 Expressive). */ .sort-group { display: inline-flex; align-items: center; gap: 2px; } -.sort-group .select { - height: 40px; padding: 0 30px 0 16px; border-radius: var(--shape-full) var(--shape-xs) var(--shape-xs) var(--shape-full); +.sort-group :is(.select, .m3-select) { + height: var(--btn-h-lg); padding: 0 14px 0 18px; border-radius: var(--shape-full) var(--shape-xs) var(--shape-xs) var(--shape-full); background: var(--accent-soft); box-shadow: none; border-color: transparent; font-weight: 600; color: var(--fg); } -.sort-group .select:hover { background: var(--selection); box-shadow: none; } -.sort-group .select:focus { border-color: transparent; } -.sort-group .select:active { border-radius: var(--shape-sm); } +.sort-group :is(.select, .m3-select):hover { background: var(--selection); box-shadow: none; } +.sort-group :is(.select, .m3-select):focus { border-color: transparent; } +.sort-group :is(.select, .m3-select):active { border-radius: var(--shape-sm); } .sort-group .btn.icon:where(:not(.player *, .fullscreen *)) { - width: 44px; height: 40px; border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); + width: 44px; height: var(--btn-h-lg); border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); background: var(--accent-soft); color: var(--fg); } .sort-group .btn.icon:where(:not(.player *, .fullscreen *)):hover { background: var(--selection); } @@ -247,7 +247,7 @@ forced-color-adjust: none; background: Highlight; color: HighlightText; } .tr.playing:not(.selected) { outline: 1px solid CanvasText; outline-offset: -1px; } - .detail-head, .genre-tile, .table-head, .view-header .count-chip, .sort-group .select { border: 1px solid CanvasText; } + .detail-head, .genre-tile, .table-head, .view-header .count-chip, .sort-group :is(.select, .m3-select) { border: 1px solid CanvasText; } .genre-tile::after, .section-icon, .empty-icon { forced-color-adjust: none; background: GrayText; color: Canvas; } .tr, .tile, .genre-tile, .tile .art { transition: none; } } From e1a90f00e262b99c70fb74451a6ac2ca5e729c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:13:19 +0000 Subject: [PATCH 16/17] desktop: toast action hover keeps its 4.5:1 contrast The hover state layer (14 % of the page colour over the toast) lightened the background under the --accent-inverse action text to about 3.1:1; axe caught it once a toast appeared under the pointer. Hover now draws a ring in the text colour and leaves the background alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/src/renderer/styles/global.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/styles/global.css b/desktop/src/renderer/styles/global.css index a0ae47c..ff54053 100644 --- a/desktop/src/renderer/styles/global.css +++ b/desktop/src/renderer/styles/global.css @@ -600,7 +600,8 @@ button.qrow { width: 100%; } @keyframes toast-in { from { transform: translateY(16px) scale(0.94); } to { transform: none; } } .toast:not(:has(.btn)) { padding-right: 18px; } .toast .btn { background: transparent; color: var(--accent-inverse); border-color: transparent; font-weight: 600; } -.toast .btn:hover { background: color-mix(in srgb, var(--bg) 14%, transparent); } +/* The action is --accent-inverse at 4.5:1 on the toast itself; a lighter hover layer would drop it below that, so hover draws a ring in the text colour instead. */ +.toast .btn:hover, .toast .btn:active { background: transparent; box-shadow: inset 0 0 0 1.5px color-mix(in srgb, currentColor 60%, transparent); } .toast .btn:focus-visible { outline-color: var(--bg); } /* Setup */ From 309cc4ef34b6bdff621d312f02a55461a7b89e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:37:33 +0000 Subject: [PATCH 17/17] desktop: Ctrl shortcuts from the native core fire off macOS; native e2e undo is real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native core spells its default shortcuts with Ctrl ("Ctrl+Z", "Ctrl+K" …) on Linux and Windows, which parsed as the literal Ctrl modifier while chordFromEvent reports Ctrl there as the platform modifier: the keymap never matched, so page-level Ctrl shortcuts (undo, palette, find…) did nothing unless a focused widget handled the key. Off macOS the keymap now folds a Ctrl binding into Mod. native.spec's rate-then-undo passed on main only by accident: its click at the content pane's centre moved the pointer off the rating stars, and the undo it meant to test never reached the core. Here the taller album header puts the album's own rating at that point, which exposed it. The test now focuses the pane, moves the pointer away from the stars' hover preview, and presses Ctrl+Z, which really undoes the rating. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PwQBQmwS2cmw42GzN9EVyJ --- desktop/e2e/native.spec.ts | 6 +++++- desktop/src/renderer/store/keyboard.ts | 5 +++-- desktop/src/renderer/store/shortcuts.test.ts | 16 ++++++++++++++++ desktop/src/renderer/store/shortcuts.ts | 12 +++++++++--- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/desktop/e2e/native.spec.ts b/desktop/e2e/native.spec.ts index 8e31727..38588c7 100644 --- a/desktop/e2e/native.spec.ts +++ b/desktop/e2e/native.spec.ts @@ -95,7 +95,11 @@ test.describe("real core against a fake Navidrome", () => { await expect.poll(() => server.callsTo("setRating").length, { timeout: 15_000 }).toBeGreaterThan(0); // Rating leaves the queue alone, so no toast: undo from the keyboard. await expect(page.getByTestId("toast")).toHaveCount(0); - await page.getByTestId("content").click(); + // Focus the content pane rather than clicking its centre (that lands on whatever the layout + // puts there, e.g. the album's own rating), and move the pointer off the stars so the row + // shows its value, not the hover preview. + await page.mouse.move(0, 0); + await page.getByTestId("content").focus(); await page.keyboard.press("Control+z"); await expect(row.locator(".stars .star.on")).toHaveCount(0, { timeout: 10_000 }); diff --git a/desktop/src/renderer/store/keyboard.ts b/desktop/src/renderer/store/keyboard.ts index 63f94c2..4b8b59e 100644 --- a/desktop/src/renderer/store/keyboard.ts +++ b/desktop/src/renderer/store/keyboard.ts @@ -10,12 +10,13 @@ import { openContextMenuFromKeyboard } from "../components/ContextMenu"; export function useKeymap(): Keymap { const shortcuts = useApp((s) => s.shortcuts); + const platform = useApp((s) => s.meta?.platform ?? "linux"); return useMemo(() => { const merged = new Map(); for (const d of DEFAULT_KEYMAP) merged.set(d.actionId, d.shortcut); for (const s of shortcuts) merged.set(canonicalActionId(s.actionId), s.shortcut ?? undefined); - return new Keymap([...merged.entries()].map(([actionId, shortcut]) => ({ actionId, shortcut }))); - }, [shortcuts]); + return new Keymap([...merged.entries()].map(([actionId, shortcut]) => ({ actionId, shortcut })), platform); + }, [shortcuts, platform]); } /** diff --git a/desktop/src/renderer/store/shortcuts.test.ts b/desktop/src/renderer/store/shortcuts.test.ts index dc36e4d..306d94f 100644 --- a/desktop/src/renderer/store/shortcuts.test.ts +++ b/desktop/src/renderer/store/shortcuts.test.ts @@ -42,6 +42,22 @@ describe("shortcut parser", () => { expect(km.conflicts()).toEqual([{ chord: "mod+k", actionIds: ["a", "b"] }]); }); + it("treats a Ctrl binding as the platform modifier off macOS (the core spells its defaults that way)", () => { + const bindings = [{ actionId: "undo", shortcut: "Ctrl+Z" }, { actionId: "palette", shortcut: "Mod+K" }]; + const ev = (platform: "macOs" | "linux" | "windows", e: { key: string; code: string; ctrlKey?: boolean; metaKey?: boolean }) => + chordFromEvent({ altKey: false, shiftKey: false, ctrlKey: false, metaKey: false, ...e }, platform)!; + for (const platform of ["linux", "windows"] as const) { + const km = new Keymap(bindings, platform); + expect(km.lookup(ev(platform, { key: "z", code: "KeyZ", ctrlKey: true }))).toEqual(["undo"]); + expect(km.lookup(ev(platform, { key: "k", code: "KeyK", ctrlKey: true }))).toEqual(["palette"]); + } + // On macOS Ctrl stays Control; Cmd is the modifier. + const mac = new Keymap(bindings, "macOs"); + expect(mac.lookup(ev("macOs", { key: "z", code: "KeyZ", ctrlKey: true }))).toEqual(["undo"]); + expect(mac.lookup(ev("macOs", { key: "z", code: "KeyZ", metaKey: true }))).toEqual([]); + expect(mac.lookup(ev("macOs", { key: "k", code: "KeyK", metaKey: true }))).toEqual(["palette"]); + }); + it("recognises text inputs", () => { expect(isTextInput(null)).toBe(false); expect(isTextInput({ tagName: "INPUT", type: "text" } as unknown as HTMLElement)).toBe(true); diff --git a/desktop/src/renderer/store/shortcuts.ts b/desktop/src/renderer/store/shortcuts.ts index 5440792..ad4793c 100644 --- a/desktop/src/renderer/store/shortcuts.ts +++ b/desktop/src/renderer/store/shortcuts.ts @@ -131,11 +131,17 @@ export interface Binding { export class Keymap { private byChord = new Map(); - constructor(bindings: { actionId: string; shortcut: string | undefined }[]) { + /** + * Off macOS, Ctrl *is* the platform modifier: chordFromEvent reports it as + * `mod`, so a binding written "Ctrl+Z" (how the core spells its defaults + * there) is folded into "Mod+Z" to match. + */ + constructor(bindings: { actionId: string; shortcut: string | undefined }[], platform: "macOs" | "linux" | "windows" = "linux") { for (const b of bindings) { if (!b.shortcut) continue; - const c = parseChord(b.shortcut); - if (!c) continue; + const parsed = parseChord(b.shortcut); + if (!parsed) continue; + const c = platform !== "macOs" && parsed.ctrl ? { ...parsed, ctrl: false, mod: true } : parsed; const k = chordToString(c).toLowerCase(); this.byChord.set(k, [...(this.byChord.get(k) ?? []), b.actionId]); }