Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 13 additions & 11 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

<!-- markdownlint-disable MD060 -->

| 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, `<EditorialMasthead>` 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, `<EditorialMasthead>` 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 |

<!-- markdownlint-enable MD060 -->

**Architecture** :

- Token system + `SkinId` union live in [`src/lib/skins.ts`](../../src/lib/skins.ts); `applySkin()` writes `data-skin` on `<html>`.
- 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.

Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/crates/app/src/skin_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
36 changes: 28 additions & 8 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -243,16 +244,30 @@
--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"]
):hover {
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
Loading