diff --git a/CLAUDE.md b/CLAUDE.md index 83c553fe8..9a6aaac0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Names in `commands/`, `audio/` and `src/components/` are predictable — read th | Playlists | [`playlists.md`](docs/features/playlists.md) · [`smart-playlists.md`](docs/features/smart-playlists.md) | CRUD, sorting, auto-covers, M3U, Daily Mix + On Repeat generators, rule tree | | Integrations | [`integrations.md`](docs/features/integrations.md) | Deezer, Last.fm, TheAudioDB, lyrics providers + editor, artist overrides, Discord RPC, OS notifications, scrobbling | | Plugins | [`plugins.md`](docs/features/plugins.md) | WASM host + sandbox, store, options, `source` / `metadata` / `ui` / `canvas` worlds, Web Radio + offline catalogue | -| UI & UX | [`ui.md`](docs/features/ui.md) | layout, 5 skins × 14 themes + custom / cover accent, immersive view, Canvas, cover slideshow, artist hero, mini-player, desktop lyrics, Wrapped, profiles, onboarding, settings, updater, backups | +| UI & UX | [`ui.md`](docs/features/ui.md) | layout, 6 skins × 14 themes + custom / cover accent, immersive view, Canvas, cover slideshow, artist hero, mini-player, desktop lyrics, Wrapped, profiles, onboarding, settings, updater, backups | | LAN servers | [`dlna.md`](docs/features/dlna.md) · [`mpd.md`](docs/features/mpd.md) | opt-in MediaServer and MPD control surface | ## Conventions diff --git a/README.md b/README.md index b5acdc038..25ca41916 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ WaveFlow is a desktop music player for the audio files you already own. It scans ### 🎨 Interface · [docs](docs/features/ui.md) -- 5 skins × 14 themes, dark mode, a high-contrast mode +- 6 skins × 14 themes, dark mode, a high-contrast mode - Immersive now-playing with Canvas clips, animated covers and a cover slideshow - Always-on-top mini-player, system tray, Windows taskbar buttons - Statistics and **WaveFlow Wrapped**, all computed locally diff --git a/docs/features/ui.md b/docs/features/ui.md index 3a5bb7390..3de4cf51f 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -527,27 +527,29 @@ The first paint is handled by the bootstrap script in `index.html` alongside the ## Skins -Skins are an **orthogonal axis to the 14 colour themes**: a skin re-skins surfaces, typography, motion and signature elements (e.g. Editorial's drop caps, Pulse's vinyl-spin cover); the theme picks the OKLCH accent. Every skin × theme combination is valid → **5 × 14 = 70 visual identities**. +Skins are an **orthogonal axis to the 14 colour themes**: a skin re-skins surfaces, typography, motion and signature elements (e.g. Editorial's drop caps, Pulse's vinyl-spin cover); the theme picks the OKLCH accent. Every skin × theme combination is valid → **6 × 14 = 84 visual identities**. -| Skin | Direction | Signature | -| ----------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `studio` | Apple Music baseline | Inter / system-ui, soft shadows, rounded-12 — the calm default | -| `editorial` | Broadsheet "WaveFlow Gazette" | Playfair Display 900 + Lora body, `` with locale-aware `Intl.DateTimeFormat`, `::first-letter` drop caps, halftone N&B covers via `mix-blend-luminosity`, ruler-tick progress bar via `repeating-linear-gradient`, `counter-reset` page numbering ("P. 1", "P. 2") | -| `lounge` | "Listening Room" warm-burgundy glass | Inter, [`SkinAmbientBackdrop`](../../src/components/layout/SkinAmbientBackdrop.tsx) blurs the cover (`filter: blur(140px) saturate(280%)`) into the body, white-overlay tint over the cover dominates the chrome | -| `pulse` | OLED "club control panel" | Space Grotesk + Space Mono, dual-neon magenta/cyan aurora blobs, `///` mono pills, vinyl-spin cover via [`SkinPlayingState`](../../src/components/layout/SkinPlayingState.tsx) mirroring `usePlayer().isPlaying` to `[data-is-playing]`, floating pill PlayerBar | -| `liquid` | Apple Vibrancy material | DM Sans Variable (`opsz` axis 9..40), 8-layer inset `box-shadow` recipe `--liquid-glass` for "real glass" surfaces, aurora drift `body::before` (28 s), theme-aware light/dark token swap via `:not(.dark)`, `--liq-action` cyan/blue swap for contrast | +| Skin | Direction | Signature | +| ----------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `studio` | Apple Music baseline | Inter / system-ui, soft shadows, rounded-12 — the calm default | +| `editorial` | Broadsheet "WaveFlow Gazette" | Playfair Display 900 + Lora body, `` with locale-aware `Intl.DateTimeFormat`, `::first-letter` drop caps, halftone N&B covers via `mix-blend-luminosity`, ruler-tick progress bar via `repeating-linear-gradient`, `counter-reset` page numbering ("P. 1", "P. 2") | +| `lounge` | "Listening Room" warm-burgundy glass | Inter, [`SkinAmbientBackdrop`](../../src/components/layout/SkinAmbientBackdrop.tsx) blurs the cover (`filter: blur(140px) saturate(280%)`) into the body, white-overlay tint over the cover dominates the chrome | +| `pulse` | OLED "club control panel" | Space Grotesk + Space Mono, dual-neon magenta/cyan aurora blobs, `///` mono pills, vinyl-spin cover via [`SkinPlayingState`](../../src/components/layout/SkinPlayingState.tsx) mirroring `usePlayer().isPlaying` to `[data-is-playing]`, floating pill PlayerBar | +| `liquid` | Apple Vibrancy material | DM Sans Variable (`opsz` axis 9..40), 8-layer inset `box-shadow` recipe `--liquid-glass` for "real glass" surfaces, aurora drift `body::before` (28 s), theme-aware light/dark token swap via `:not(.dark)`, `--liq-action` cyan/blue swap for contrast | +| `fluid` | Cinematic editorial, music first | DM Sans Variable + Playfair italic on the greeting only; depth by tone (no borders); the playing cover's light behind the Home ([`useCoverGlow`](../../src/components/views/home/fluid/useCoverGlow.ts) → `--fe-glow`, the accent stays `--accent-400` so the #895 accent modes drive it); the flow-line mark under section titles; a **different Home** ([`FluidHome`](../../src/components/views/home/fluid/FluidHome.tsx): listening canvas, [`MoodSpectrum`](../../src/components/views/home/MoodSpectrum.tsx) — Mood Radio as a tempo band, editorial mosaic, shelf, history list) and the progress as the player bar's top edge (`ProgressBar variant="edge"`). Dark-only like Lounge / Pulse | **Architecture** : - Token system + `SkinId` union live in [`src/lib/skins.ts`](../../src/lib/skins.ts); `applySkin()` writes `data-skin` on ``. -- Per-skin overrides live in `src/styles/skins/{editorial,lounge,pulse,liquid}.css` (Studio = no overrides, baseline). The four files are imported from `src/app.css`. Three of them also carry a high-contrast block (see above) — a skin that adds a glass surface has to add it there too, or that surface becomes unreadable in the mode. -- [`src/app.css`](../../src/app.css) extends the Tailwind `dark` variant via `@custom-variant dark (.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *)` — Lounge/Pulse fire `dark:*` utilities automatically because they're always "dark" by design (Liquid stays theme-aware). +- Per-skin overrides live in `src/styles/skins/{editorial,lounge,pulse,liquid,fluid}.css` (Studio = no overrides, baseline). The five files are imported from `src/app.css`. Four of them also carry a high-contrast block (see above) — a skin that adds a glass surface has to add it there too, or that surface becomes unreadable in the mode. +- [`src/app.css`](../../src/app.css) extends the Tailwind `dark` variant via `@custom-variant dark (.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *, :root[data-skin="fluid"] *)` — Lounge/Pulse/Fluid fire `dark:*` utilities automatically because they're always "dark" by design (Liquid stays theme-aware). - **Local-first typography** — all skin fonts are bundled via `@fontsource` / `@fontsource-variable` (Playfair Display, Lora, Space Grotesk, Space Mono, DM Sans Variable) and imported at the top of [`src/main.tsx`](../../src/main.tsx). Zero network at runtime — no Google Fonts request. -- **Motion** — each skin declares its own `MotionConfig` spring in `SkinMotionWrapper`. Skins with strong identity (Pulse, Editorial) override the spring; calmer skins (Studio, Lounge, Liquid) inherit the soft default. +- **Motion** — each skin declares its own `MotionConfig` spring in `SkinMotionWrapper`. Skins with strong identity (Pulse, Editorial) override the spring; calmer skins (Studio, Lounge, Liquid, Fluid) inherit the soft default. +- **A skin may recompose a page, not only restyle it.** `HomeView` is a thin switch: under Fluid it renders `FluidHome`, which loads its own data (recent plays, albums by `added_at`, the all-time top album from `statsTopAlbums`, the listening total from `statsOverview`, the queue after the cursor from `playerGetQueue` for "up next"); every other skin keeps the classic view. `MoodRadioGrid` and `PlayerBar` branch the same way, from `useSkin()`, and keep every action — the spectrum calls the same `start_mood_radio`, the edge progress is the same slider with the same keyboard contract. The picker lives in Settings → Appearance → Theme and readability via [`SkinPickerCard`](../../src/components/views/settings/SkinPickerCard.tsx), alongside the theme picker and the contrast setting. View-Transitions API also drives skin swaps (radial reveal on click), with the same try/catch fallback used by the theme picker for WebKitGTK builds. diff --git a/index.html b/index.html index fee3d2801..d2c7e1506 100644 --- a/index.html +++ b/index.html @@ -88,6 +88,7 @@ lounge: 1, pulse: 1, liquid: 1, + fluid: 1, }; // The native startup path adds this flag before creating the // webview. It must beat the cached skin on the very first paint. diff --git a/src-tauri/crates/app/src/skin_recovery.rs b/src-tauri/crates/app/src/skin_recovery.rs index 51ea68402..ff71b365b 100644 --- a/src-tauri/crates/app/src/skin_recovery.rs +++ b/src-tauri/crates/app/src/skin_recovery.rs @@ -221,7 +221,7 @@ fn path() -> Result<&'static Path, String> { /// launches from older versions become guarded too. #[tauri::command] pub fn record_skin(skin_id: String) -> Result<(), String> { - if !["studio", "editorial", "lounge", "pulse", "liquid"].contains(&skin_id.as_str()) { + if !["studio", "editorial", "lounge", "pulse", "liquid", "fluid"].contains(&skin_id.as_str()) { return Err("unknown skin".into()); } if !cfg!(target_os = "linux") { diff --git a/src/app.css b/src/app.css index 1639eb4e9..b0b713182 100644 --- a/src/app.css +++ b/src/app.css @@ -7,7 +7,7 @@ needed. Studio + Editorial stay theme-mode-driven (light or dark theme decides). The `data-skin` attribute is on documentElement so `[data-skin="lounge"] *` covers every element in the document. */ -@custom-variant dark (&:where(.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *)); +@custom-variant dark (&:where(.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *, :root[data-skin="fluid"] *)); /* ============================================================ Layer scale (issue #390) @@ -144,7 +144,8 @@ :root[data-contrast="high"]:where( .dark, [data-skin="lounge"], - [data-skin="pulse"] + [data-skin="pulse"], + [data-skin="fluid"] ) { --wf-zinc-300: oklch(95% 0.004 286.286); --wf-zinc-400: oklch(89% 0.006 286.067); @@ -243,8 +244,17 @@ --track-row-hover: rgb(255 45 121 / 0.12); } +:root[data-skin="fluid"] { + --track-row-hover: rgb(255 255 255 / 0.045); +} + /* Override substring-based surface rules only for unselected, idle rows. */ -:root:is([data-skin="pulse"], [data-skin="liquid"], [data-skin="lounge"]) +:root:is( + [data-skin="pulse"], + [data-skin="liquid"], + [data-skin="lounge"], + [data-skin="fluid"] + ) [data-track-table] [data-track-row]:not([class*="bg-blue-500"]):not( [class*="bg-emerald-50"] @@ -252,7 +262,12 @@ background-color: var(--track-row-hover) !important; } -:root:is([data-skin="pulse"], [data-skin="liquid"], [data-skin="lounge"]) +:root:is( + [data-skin="pulse"], + [data-skin="liquid"], + [data-skin="lounge"], + [data-skin="fluid"] + ) [data-playlist-card] [data-playlist-caption] { padding: 0 0.875rem 0.875rem; @@ -405,6 +420,7 @@ @import "./styles/skins/lounge.css"; @import "./styles/skins/pulse.css"; @import "./styles/skins/liquid.css"; +@import "./styles/skins/fluid.css"; /* The desktop lyrics window (#582) is transparent at the OS level, and the document has to be too, or it paints an opaque box over the @@ -793,13 +809,15 @@ html.mini-player-window #root { tone (dark thumb on a dark glass body) = invisible. */ .dark ::-webkit-scrollbar-thumb, :root[data-skin="lounge"] ::-webkit-scrollbar-thumb, -:root[data-skin="pulse"] ::-webkit-scrollbar-thumb { +:root[data-skin="pulse"] ::-webkit-scrollbar-thumb, +:root[data-skin="fluid"] ::-webkit-scrollbar-thumb { background-color: rgba(244, 244, 245, 0.12); } .dark ::-webkit-scrollbar-thumb:hover, :root[data-skin="lounge"] ::-webkit-scrollbar-thumb:hover, -:root[data-skin="pulse"] ::-webkit-scrollbar-thumb:hover { +:root[data-skin="pulse"] ::-webkit-scrollbar-thumb:hover, +:root[data-skin="fluid"] ::-webkit-scrollbar-thumb:hover { background-color: rgba(244, 244, 245, 0.25); } @@ -811,7 +829,8 @@ html.mini-player-window #root { .dark *, :root[data-skin="lounge"] *, -:root[data-skin="pulse"] * { +:root[data-skin="pulse"] *, +:root[data-skin="fluid"] * { scrollbar-color: rgba(244, 244, 245, 0.12) transparent; } @@ -925,7 +944,8 @@ html.mini-player-window #root { :root[data-contrast="high"]:where( .dark, [data-skin="lounge"], - [data-skin="pulse"] + [data-skin="pulse"], + [data-skin="fluid"] ), :root[data-webkitgtk-safe][data-skin="liquid"].dark { --wf-contrast-surface: var(--color-surface-dark); diff --git a/src/components/player/PlayerBar.tsx b/src/components/player/PlayerBar.tsx index e0d67d659..6fe0efc24 100644 --- a/src/components/player/PlayerBar.tsx +++ b/src/components/player/PlayerBar.tsx @@ -29,6 +29,7 @@ import { MoreActionsMenu } from "./MoreActionsMenu"; import { AudioQualityFooter } from "./AudioQualityFooter"; import { ImmersiveView } from "./ImmersiveView"; import { useLikedTracks } from "../../hooks/useLikedTracks"; +import { useSkin } from "../../hooks/useSkin"; interface PlayerBarProps { onNavigateToArtist: (artistId: number) => void; @@ -66,6 +67,11 @@ export function PlayerBar({ onNavigateToArtist }: PlayerBarProps) { // match the historical hard-coded behaviour so the upgrade is // invisible until the user visits Settings → Playback. const layout = usePlayerBarLayout(); + // Fluid draws the progress as the bar's top edge — a full-width wave + // with the times at its ends — and keeps only the transport in the + // centre column. Every control stays; only the progress moves. + const { skin } = useSkin(); + const isFluid = skin.id === "fluid"; // Subscribe to the backend's track-ended event so the sleep timer // in "end of track" mode triggers when the current track finishes @@ -125,6 +131,7 @@ export function PlayerBar({ onNavigateToArtist }: PlayerBarProps) { return ( <>