From 26d3ac6fbbbd9a6df720d96a9002b9e4f4a588e8 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 23:28:01 +0100 Subject: [PATCH 1/2] refactor(webapp): move the three stylesheets under their parts directories The move alone, so that history follows each sheet into its first part. The next commit splits them and puts the three entry files back as ordered lists of @import. Signed-off-by: InstaZDLL --- webapp/src/styles/{fluid.css => fluid/tokens.css} | 0 webapp/src/styles/{refresh.css => refresh/base.css} | 0 webapp/src/styles/{styles.css => styles/base.css} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename webapp/src/styles/{fluid.css => fluid/tokens.css} (100%) rename webapp/src/styles/{refresh.css => refresh/base.css} (100%) rename webapp/src/styles/{styles.css => styles/base.css} (100%) diff --git a/webapp/src/styles/fluid.css b/webapp/src/styles/fluid/tokens.css similarity index 100% rename from webapp/src/styles/fluid.css rename to webapp/src/styles/fluid/tokens.css diff --git a/webapp/src/styles/refresh.css b/webapp/src/styles/refresh/base.css similarity index 100% rename from webapp/src/styles/refresh.css rename to webapp/src/styles/refresh/base.css diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles/base.css similarity index 100% rename from webapp/src/styles/styles.css rename to webapp/src/styles/styles/base.css From be45b9987705037385b950770fe39e6efe82afd7 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 23:31:07 +0100 Subject: [PATCH 2/2] refactor(webapp): split the three stylesheets into ordered parts styles.css, refresh.css and fluid.css keep their names and their place in main.tsx, and each is now an ordered list of @import over the parts under styles/, refresh/ and fluid/. Concatenated in that order, the parts are the old sheets byte for byte apart from the @import lines and each part's one-line header, and the built CSS bundle is byte-identical. The style tests read the sheets through sheets.ts, which inlines each entry's parts in order and throws if a part is imported by no entry or an entry names a part that does not exist: read raw, an entry is a list of paths and not one rule. Signed-off-by: InstaZDLL --- .../design-tokens/themes-contrast.test.ts | 4 +- webapp/src/styles/fluid.css | 38 + webapp/src/styles/fluid/base.css | 249 + .../src/styles/fluid/menus-sign-in-motion.css | 203 + webapp/src/styles/fluid/page.css | 404 ++ webapp/src/styles/fluid/player-bar.css | 259 + webapp/src/styles/fluid/rail.css | 235 + webapp/src/styles/fluid/theme-picker.css | 223 + webapp/src/styles/fluid/tokens.css | 1598 +----- webapp/src/styles/refresh.css | 8 + webapp/src/styles/refresh/album.css | 323 ++ webapp/src/styles/refresh/base.css | 1093 +---- .../refresh/login-player-responsive.css | 143 + webapp/src/styles/refresh/queue.css | 203 + webapp/src/styles/refresh/shell.css | 163 + webapp/src/styles/refresh/surfaces.css | 264 + webapp/src/styles/sheets.ts | 44 + webapp/src/styles/styles.css | 16 + webapp/src/styles/styles.test.ts | 2 +- webapp/src/styles/styles/admin.css | 473 ++ webapp/src/styles/styles/artist-hero.css | 220 + webapp/src/styles/styles/base.css | 4278 +---------------- webapp/src/styles/styles/catalog.css | 510 ++ webapp/src/styles/styles/folders.css | 352 ++ webapp/src/styles/styles/maintenance.css | 225 + webapp/src/styles/styles/menus.css | 266 + webapp/src/styles/styles/mixes-and-stats.css | 331 ++ webapp/src/styles/styles/panels.css | 211 + webapp/src/styles/styles/phone.css | 258 + webapp/src/styles/styles/player.css | 192 + webapp/src/styles/styles/playing.css | 412 ++ webapp/src/styles/styles/shell.css | 523 ++ webapp/src/styles/styles/workspace.css | 316 ++ 33 files changed, 7071 insertions(+), 6968 deletions(-) create mode 100644 webapp/src/styles/fluid.css create mode 100644 webapp/src/styles/fluid/base.css create mode 100644 webapp/src/styles/fluid/menus-sign-in-motion.css create mode 100644 webapp/src/styles/fluid/page.css create mode 100644 webapp/src/styles/fluid/player-bar.css create mode 100644 webapp/src/styles/fluid/rail.css create mode 100644 webapp/src/styles/fluid/theme-picker.css create mode 100644 webapp/src/styles/refresh.css create mode 100644 webapp/src/styles/refresh/album.css create mode 100644 webapp/src/styles/refresh/login-player-responsive.css create mode 100644 webapp/src/styles/refresh/queue.css create mode 100644 webapp/src/styles/refresh/shell.css create mode 100644 webapp/src/styles/refresh/surfaces.css create mode 100644 webapp/src/styles/sheets.ts create mode 100644 webapp/src/styles/styles.css create mode 100644 webapp/src/styles/styles/admin.css create mode 100644 webapp/src/styles/styles/artist-hero.css create mode 100644 webapp/src/styles/styles/catalog.css create mode 100644 webapp/src/styles/styles/folders.css create mode 100644 webapp/src/styles/styles/maintenance.css create mode 100644 webapp/src/styles/styles/menus.css create mode 100644 webapp/src/styles/styles/mixes-and-stats.css create mode 100644 webapp/src/styles/styles/panels.css create mode 100644 webapp/src/styles/styles/phone.css create mode 100644 webapp/src/styles/styles/player.css create mode 100644 webapp/src/styles/styles/playing.css create mode 100644 webapp/src/styles/styles/shell.css create mode 100644 webapp/src/styles/styles/workspace.css diff --git a/webapp/src/styles/design-tokens/themes-contrast.test.ts b/webapp/src/styles/design-tokens/themes-contrast.test.ts index fb1a40fc..9a0a9e91 100644 --- a/webapp/src/styles/design-tokens/themes-contrast.test.ts +++ b/webapp/src/styles/design-tokens/themes-contrast.test.ts @@ -1,9 +1,7 @@ import postcss from "postcss"; import { describe, expect, it } from "vitest"; -import fluid from "../fluid.css?raw"; -import refresh from "../refresh.css?raw"; -import styles from "../styles.css?raw"; +import { fluid, refresh, styles } from "../sheets"; import { applyTheme } from "./apply"; import { composite, diff --git a/webapp/src/styles/fluid.css b/webapp/src/styles/fluid.css new file mode 100644 index 00000000..43572990 --- /dev/null +++ b/webapp/src/styles/fluid.css @@ -0,0 +1,38 @@ +/* ============================================================ + Fluid Editorial — the structure, and its two materials + ============================================================ + WaveFlow's design language since 2.0, written down in the desktop + repository's `docs/design/fluid-editorial.md`; this sheet carries it to + the web client. Cinematic, music first: the cover lights the page, the + type carries the editorial voice, the wave is the mark. + + **One structure, two materials.** The guide separates the structure (the + rail, the heads, the bar, the type, the mark, the rhythm, the ease) from + the material a skin lays over it, and lets a material be light. Every + rule below is the structure and reads only tokens; the tokens are the + material, declared twice: the guide's own dark one under `:root.dark`, + and a light one under `:root:not(.dark)`. The light material keeps the + guide's rule not to borrow the dark inks: its ground takes the theme's + ambient tint, its tones are dark ink at a low alpha, its accent is the + theme's 700 stop under white ink, and the cover's light reaches it + washed out to a pastel. + + **Wrapped in `:where()`.** The prefix adds no specificity, so a rule + here weighs exactly what the rule it replaces weighs and wins by coming + last. A plain `:root button` would outrank `.player-controls button` + and dress every bare icon button as a pill. + + The theme chooses the accent and the cover chooses the light (`--glow`, + written by `CoverLight`). The inks are the material's. The surfaces are + the theme's where it names them: a dark preset's ground, panel and raised + surface arrive as `--theme-ground`, `--theme-surface` and `--theme-raised` + (written by `applyTheme`, from the same `themeSurfaces` the Settings + cards are drawn with), and the light ground takes the theme's tint. */ + +@import "./fluid/tokens.css"; +@import "./fluid/base.css"; +@import "./fluid/rail.css"; +@import "./fluid/page.css"; +@import "./fluid/player-bar.css"; +@import "./fluid/menus-sign-in-motion.css"; +@import "./fluid/theme-picker.css"; diff --git a/webapp/src/styles/fluid/base.css b/webapp/src/styles/fluid/base.css new file mode 100644 index 00000000..93748998 --- /dev/null +++ b/webapp/src/styles/fluid/base.css @@ -0,0 +1,249 @@ +/* Ground and atmosphere, type, and the controls. */ + +/* ── Ground and atmosphere ───────────────────────────────────── */ + +:where(:root) body { + background: var(--fe-bg); + color: var(--fe-ink); + font-family: var(--fe-font); + font-size: var(--fe-body); + font-optical-sizing: auto; + font-weight: 480; + -webkit-font-smoothing: antialiased; +} + +/* Two soft pools of the cover's colour behind the page. Fixed, so nothing + repaints on scroll; the fade comes from the registered `--glow`. */ +:where(:root) body::before { + display: block; + position: fixed; + z-index: 0; + inset: -20%; + opacity: 1; + background: + radial-gradient( + ellipse 42% 38% at 72% 8%, + color-mix(in srgb, var(--fe-glow) 40%, transparent), + transparent 70% + ), + radial-gradient( + ellipse 36% 30% at 18% 92%, + color-mix(in srgb, var(--fe-glow-deep) 70%, transparent), + transparent 72% + ); + content: ""; + pointer-events: none; +} + +:where(:root) #root { + position: relative; + z-index: 1; +} + +:where(:root) ::selection { + background: color-mix(in srgb, var(--accent) 35%, transparent); + color: var(--fe-ink); +} + +/* ── Type ────────────────────────────────────────────────────── */ + +:where(:root) :is(h1, h2, h3) { + font-family: var(--fe-font); + font-weight: 560; + letter-spacing: -0.022em; +} + +:where(:root) h3 { + font-size: var(--fe-title); + font-weight: 540; +} + +:where(:root) .muted { + color: var(--fe-ink-3); +} + +:where(:root) .eyebrow, +:where(:root) .page-kicker, +:where(:root) .nav-label, +:where(:root) .album-type { + display: block; + color: var(--fe-ink-3); + font-size: var(--fe-label); + font-weight: 540; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +/* The mark under a section title: the one ornament the language allows, + and it marks rather than fills. */ +:where(:root) + :is( + .page-heading h2, + .album-related h3, + .artist-played h3, + .artist-discography h3, + .artist-similar h3 + )::after { + display: block; + width: 2.5rem; + height: 0.625rem; + margin-top: 0.75rem; + background: var(--accent); + content: ""; + mask: var(--fe-mark) no-repeat left center / contain; +} + +/* ── Controls ────────────────────────────────────────────────── */ + +:where(:root) :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* A secondary action is a ghost pill: a tone of white, no line. */ +:where(:root) button { + border-color: transparent; + border-radius: 999px; + background: var(--fe-tone-2); + color: var(--fe-ink); + font-weight: 540; + transition: + background-color 180ms var(--fe-ease), + color 180ms var(--fe-ease), + filter 180ms var(--fe-ease), + transform 320ms var(--fe-ease); +} + +:where(:root) button:hover { + border-color: transparent; + background: var(--fe-tone-3); +} + +:where(:root) button:active { + transform: translateY(1px); +} + +/* The primary action is the accent, spent once, with dark ink on it. */ +:where(:root) :is(button[type="submit"], button.action-primary) { + border-color: transparent; + background: var(--accent); + color: var(--fe-accent-ink); +} + +:where(:root) :is(button[type="submit"], button.action-primary):hover { + border-color: transparent; + background: var(--accent); + filter: brightness(1.06); + transform: translateY(-1px); +} + +:where(:root) button.action-secondary { + background: var(--fe-tone-2); + color: var(--fe-ink); +} + +:where(:root) button.action-secondary:hover { + background: var(--fe-tone-3); + color: var(--fe-ink); +} + +:where(:root) button.danger { + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); +} + +/* A field is a tone with a hairline ring drawn inside it, so the ring never + changes the box; focus brings the accent ring and the cover's halo. */ +:where(:root) + :is( + input:not([type="range"], [type="checkbox"], [type="radio"]), + select, + textarea + ) { + border: 0; + border-radius: var(--fe-radius-input); + background: var(--fe-tone-1); + box-shadow: inset 0 0 0 1px var(--fe-hairline-strong); + color: var(--fe-ink); + transition: + box-shadow 180ms var(--fe-ease), + background-color 180ms var(--fe-ease); +} + +:where(:root) + :is( + input:not([type="range"], [type="checkbox"], [type="radio"]), + select, + textarea + ):hover { + background: var(--fe-tone-2); +} + +:where(:root) + :is( + input:not([type="range"], [type="checkbox"], [type="radio"]), + select, + textarea + ):focus-visible { + outline: none; + box-shadow: + inset 0 0 0 1px var(--accent), + 0 0 0 4px color-mix(in srgb, var(--fe-glow) 55%, transparent); +} + +:where(:root) input::placeholder { + color: var(--fe-ink-3); +} + +:where(:root) + :is(input[type="range"], input[type="checkbox"], input[type="radio"]) { + accent-color: var(--accent); +} + +/* The native popup follows the ground, not a white Windows sheet. */ +:where(:root.dark) select { + color-scheme: dark; +} + +:where(:root) select option { + background-color: var(--fe-surface-2); + color: var(--fe-ink); +} + +/* The header's filters keep the underline the refresh gave them, in the + guide's hairline. */ +:where(:root) .page-header .control :is(input, select) { + border-radius: 0; + background: transparent; + box-shadow: inset 0 -1px 0 var(--fe-hairline-strong); +} + +:where(:root) .page-header .control :is(input, select):hover, +:where(:root) .page-header .control :is(input, select):focus-visible { + background: transparent; + box-shadow: inset 0 -1px 0 var(--accent); +} + +:where(:root) + :is( + .control > span, + .theme-picker span, + .language-picker span, + .library-picker > span + ) { + color: var(--fe-ink-3); + font-size: var(--fe-label); + font-weight: 540; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* Links that are rows in a pill or a sheet. Ahead of the rail, whose tiles + are links too and weigh more. */ +:where(:root) .view-switcher a { + border-radius: 999px; +} + +:where(:root) .more-sheet nav a { + border-radius: 0.5rem; +} diff --git a/webapp/src/styles/fluid/menus-sign-in-motion.css b/webapp/src/styles/fluid/menus-sign-in-motion.css new file mode 100644 index 00000000..4b01ba6d --- /dev/null +++ b/webapp/src/styles/fluid/menus-sign-in-motion.css @@ -0,0 +1,203 @@ +/* Menus and sheets, sign-in, phones, motion, and the track menu. */ + +/* ── Menus and sheets ───────────────────────────────────────── */ + +:where(:root) .more-sheet { + border: 0; + border-radius: var(--fe-radius-card); + background: var(--fe-surface-2); + box-shadow: + inset 0 1px 0 var(--fe-highlight), + 0 2rem 4rem var(--fe-shadow-deep); +} + +:where(:root) .immersive { + background: var(--fe-bg); +} + +/* ── Sign-in ─────────────────────────────────────────────────── */ + +:where(:root) .login-shell { + border: 0; + border-radius: var(--fe-radius-hero); + background: var(--fe-surface); + box-shadow: + inset 0 0 0 1px var(--fe-hairline), + 0 2rem 5rem var(--fe-shadow-soft); +} + +:where(:root) .login-story { + background: + radial-gradient( + ellipse 80% 70% at 20% 10%, + color-mix(in srgb, var(--fe-glow) 60%, transparent), + transparent 70% + ), + var(--fe-bg); +} + +:where(:root) .login-story h1 { + font-weight: 580; + letter-spacing: -0.04em; + line-height: 0.96; +} + +/* The serif italic, once on the page: the line that greets. */ +:where(:root) .login-story p { + color: var(--fe-ink-2); + font-family: var(--fe-font-editorial); + font-size: 1.375rem; + font-style: italic; + font-weight: 400; + letter-spacing: -0.01em; +} + +:where(:root) .signal { + width: 7.5rem; + height: 0.625rem; + background: var(--accent); + opacity: 1; + mask: var(--fe-wave) repeat-x left center / 1.25rem 0.625rem; + /* The light theme cuts its bars into a jagged line with a clip-path; on the + wave it cut the wave itself, and the motif read as broken. */ + clip-path: none; +} + +/* ── Phones ──────────────────────────────────────────────────── */ + +@media (max-width: 820px) { + :where(:root) .mobile-header { + border-bottom: 0; + background: color-mix(in srgb, var(--fe-surface) 88%, transparent); + box-shadow: inset 0 -1px 0 var(--fe-hairline); + } + + :where(:root) .mobile-navigation { + border: 0; + border-radius: var(--fe-radius-card); + background: color-mix(in srgb, var(--fe-surface-2) 94%, transparent); + box-shadow: + inset 0 1px 0 var(--fe-highlight), + 0 1rem 2.5rem var(--fe-shadow); + } + + :where(:root) :is(.mobile-navigation a, .mobile-navigation .more-button) { + color: var(--fe-ink-3); + } + + /* The open item carries the accent's edge, here along its foot. */ + :where(:root) .mobile-navigation a[data-status="active"] { + background: var(--fe-tone-2); + color: var(--fe-ink); + box-shadow: inset 0 -2px 0 var(--accent); + } + + :where(:root) .player { + border-radius: var(--fe-radius-card); + } +} + +/* ── Motion ──────────────────────────────────────────────────── */ + +/* A page rises ten pixels and fades in, block after block. */ +@keyframes fe-rise { + from { + opacity: 0; + translate: 0 10px; + } +} + +:where(:root) main > section > * { + animation: fe-rise 640ms var(--fe-ease) both; +} + +:where(:root) main > section > :nth-child(2) { + animation-delay: 70ms; +} + +:where(:root) main > section > :nth-child(3) { + animation-delay: 140ms; +} + +:where(:root) main > section > :nth-child(n + 4) { + animation-delay: 210ms; +} + +@media (prefers-reduced-motion: reduce) { + :where(:root) main > section > * { + animation: none; + } + + /* The new record's light arrives at once. */ + :root { + transition: none; + } + + :where(:root) .grid li:hover .cover, + :where(:root) :is(button[type="submit"], button.action-primary):hover, + :where(:root) .album-actions .album-play:hover:not(:disabled) { + transform: none; + } +} + +/* ── The track menu and its properties ──────────────────────── */ + +/* A menu is a popover: `surface-2`, the card radius, no border, an inset + top highlight and a deep soft shadow; rows with their own radius. */ +:where(:root) .track-menu { + border: 0; + border-radius: var(--fe-radius-card); + background: var(--fe-surface-2); + box-shadow: + inset 0 1px 0 var(--fe-highlight), + 0 1.5rem 3rem var(--fe-shadow-deep); +} + +:where(:root) .track-menu-item { + border-radius: 0.5rem; + background: transparent; + color: var(--fe-ink-2); +} + +:where(:root) .track-menu-item:hover, +:where(:root) .track-menu-item:focus-visible { + background: var(--fe-tone-2); + color: var(--fe-ink); +} + +:where(:root) .track-properties[open] { + border: 0; + border-radius: var(--fe-radius-hero); + background: var(--fe-surface); + box-shadow: + inset 0 0 0 1px var(--fe-hairline), + 0 2rem 5rem var(--fe-shadow-deep); +} + +:where(:root) .track-properties h3 { + color: var(--fe-ink-3); + font-size: var(--fe-label); + font-weight: 540; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +:where(:root) .property-row { + border-bottom-color: var(--fe-rule); +} + +:where(:root) .property-list dt { + color: var(--fe-ink-3); +} + +:where(:root) .property-list dd { + color: var(--fe-ink); +} + +:where(:root) .properties-cover { + border-radius: var(--fe-radius-tile); +} + +:where(:root) .properties-close { + background: var(--fe-tone-2); +} diff --git a/webapp/src/styles/fluid/page.css b/webapp/src/styles/fluid/page.css new file mode 100644 index 00000000..dced1c50 --- /dev/null +++ b/webapp/src/styles/fluid/page.css @@ -0,0 +1,404 @@ +/* The page: gutters, heads, tiles, tables, surfaces, album and artist heads. */ + +/* ── The page ────────────────────────────────────────────────── */ + +:where(:root) main { + width: min(100%, 105rem); +} + +/* The guide's gutters, from 1024 px. Below that the refresh's fluid gutter + and the phone's stay. One variable, because the heads that bleed to the + edge pull back by exactly the gutter, and a second spelling of it is how + the page grew a horizontal scroll. */ +@media (width >= 64rem) { + :root { + --fe-gutter: 2rem; + } + + :where(:root) main { + padding-inline: var(--fe-gutter); + } + + :where(:root) .album-hero { + margin-inline: calc(var(--fe-gutter) * -1); + } + + :where(:root) .album-hero-inner { + padding-inline: var(--fe-gutter); + } + + :where(:root) :is(.detail-header, .artist-hero) { + margin-inline: calc(var(--fe-gutter) * -1); + padding-inline: var(--fe-gutter); + } +} + +@media (width >= 80rem) { + :root { + --fe-gutter: 3rem; + } +} + +@media (width >= 96rem) { + :root { + --fe-gutter: 3.5rem; + } +} + +/* Kicker, display title, one meta line; the primary action as a pill. No + rule under it: blocks are told apart by spacing. */ +:where(:root) .page-header { + margin-bottom: var(--fe-section-gap); + padding-bottom: 0; + border-bottom: 0; +} + +:where(:root) .page-header h2 { + max-width: 22ch; + font-size: var(--fe-display); + font-weight: 580; + letter-spacing: -0.038em; + line-height: 0.96; + text-wrap: balance; +} + +:where(:root) .page-header p { + color: var(--fe-ink-2); + font-size: var(--fe-body); +} + +:where(:root) .page-heading { + gap: var(--space-3); +} + +/* ── Tiles ───────────────────────────────────────────────────── */ + +:where(:root) .grid { + gap: var(--space-8) var(--space-6); +} + +:where(:root) .cover { + border: 0; + border-radius: var(--fe-radius-card); + background: var(--fe-surface); + transition: + transform 320ms var(--fe-ease), + box-shadow 320ms var(--fe-ease); +} + +:where(:root) .cover-fallback { + background: var(--fe-surface-2); +} + +:where(:root) .cover-fallback::before { + border-color: var(--fe-hairline-strong); + background: repeating-radial-gradient( + circle, + var(--fe-tone-2) 0 1px, + transparent 1px 6px + ); +} + +:where(:root) .cover-fallback::after { + border-color: var(--fe-hairline-strong); + background: var(--fe-surface); +} + +:where(:root) .cover-fallback span { + font-family: var(--fe-font); +} + +:where(:root) .album-related-grid .cover { + border-radius: var(--fe-radius-tile); +} + +/* A tile rises three pixels with the cover's light as its shadow. */ +:where(:root) .grid li:hover .cover { + transform: translateY(-3px); + box-shadow: 0 1rem 2.25rem color-mix(in srgb, var(--fe-glow) 70%, transparent); +} + +:where(:root) .grid strong { + color: var(--fe-ink); + font-weight: 540; + letter-spacing: -0.01em; +} + +:where(:root) .grid span { + color: var(--fe-ink-2); +} + +:where(:root) .card-action { + border-radius: 50%; + background: color-mix(in srgb, var(--fe-bg) 70%, transparent); + color: var(--fe-ink); +} + +:where(:root) .card-action:hover:not(:disabled) { + background: var(--accent); + color: var(--fe-accent-ink); +} + +/* ── Tables ──────────────────────────────────────────────────── */ + +:where(:root) .songs th { + border-bottom: 1px solid var(--fe-hairline); + color: var(--fe-ink-3); + font-size: var(--fe-label); + font-weight: 540; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +:where(:root) .songs td { + border-bottom-color: var(--fe-rule); +} + +:where(:root) .songs tr:hover { + background: var(--fe-tone-1); +} + +/* The playing row: a tone and the accent's edge. The text stays ink; the + accent is the edge, not the words. */ +:where(:root) .songs tr.active { + background: var(--fe-tone-2); + color: var(--fe-ink); + box-shadow: inset 2px 0 0 var(--accent); +} + +:where(:root) .songs tr.active .song-title, +:where(:root) .album-songs tr.active .song-title { + color: var(--fe-ink); +} + +:where(:root) .songs .song-title small { + color: var(--fe-ink-2); +} + +:where(:root) .song-cover { + border-radius: 0.375rem; +} + +/* ── Surfaces: panels, cards, lists ─────────────────────────── */ + +:where(:root) + :is( + .collection, + .admin-panel, + .user-row, + .empty-state, + .error-state, + .settings-panel, + .import-panel + ) { + border: 0; + border-radius: var(--fe-radius-card); + background: var(--fe-surface); +} + +:where(:root) + .admin-page + :is(.admin-grid > .admin-panel, .user-row, .now-playing-panel) { + background: var(--fe-surface); +} + +:where(:root) .settings-page .settings-panel { + border-radius: 0; + background: transparent; + box-shadow: inset 0 1px 0 var(--fe-hairline); +} + +:where(:root) .admin-page .credential-form > button[type="submit"] { + background: var(--fe-tone-2); + color: var(--fe-ink); +} + +:where(:root) + :is(.list li, .resource-list li, .queue-list li, .bookmark-list li) { + border-bottom-color: var(--fe-rule); +} + +/* Rows with room inside them: the open one is a tone with the accent's edge, + and a cover pressed against that edge read as cut off. */ +:where(:root) :is(.queue-list li, .bookmark-list li) { + padding-inline: var(--space-3); +} + +:where(:root) .queue-list li.active { + background: var(--fe-tone-2); + box-shadow: inset 2px 0 0 var(--accent); +} + +:where(:root) :is(.queue-cover, .bookmark-cover) { + border-radius: var(--fe-radius-tile); +} + +/* The row's own button is the row, not a ghost pill inside it: hovered, it + took the pill's tone and full radius and drew a second card in the first. */ +:where(:root) :is(.queue-track, .bookmark-track), +:where(:root) :is(.queue-track, .bookmark-track):hover { + border-radius: 0; + background: transparent; +} + +:where(:root) .view-switcher { + border: 0; + border-radius: 999px; + background: var(--fe-tone-1); +} + +:where(:root) .view-switcher a[aria-current="page"] { + background: var(--fe-tone-3); +} + +:where(:root) .section-menu { + border: 0; + border-radius: 999px; + background: var(--fe-tone-1); +} + +:where(:root) .section-menu-link { + border-radius: 999px; + color: var(--fe-ink-2); +} + +:where(:root) .section-menu-link:hover { + color: var(--fe-ink); +} + +:where(:root) .section-menu-link[aria-current="page"] { + background: var(--fe-tone-3); + color: var(--fe-ink); +} + +@media (max-width: 820px) { + :where(:root) .section-menu { + border-radius: var(--fe-radius-card); + } + + :where(:root) .section-menu-link { + border-radius: var(--fe-radius-tile); + } +} + +/* ── Album and artist heads ─────────────────────────────────── */ + +/* The cover lights its own page: the blurred copy behind, darkened so a + pale sleeve never pales the ground, and the record on its own light. */ +:where(:root) .album-hero { + background: var(--fe-bg); + color: var(--fe-ink); +} + +:where(:root) .album-hero-ambient.cover { + filter: blur(5rem) saturate(1.3); + opacity: 0.55; +} + +:where(:root) .album-hero::before { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--fe-wash) 35%, transparent) 0%, + color-mix(in srgb, var(--fe-wash) 70%, transparent) 70%, + var(--fe-bg) 100% + ); +} + +:where(:root) .album-hero-cover.cover { + border: 0; + border-radius: var(--fe-radius-hero); + box-shadow: 0 1.5rem 3.5rem var(--fe-shadow); +} + +:where(:root) .album-type { + color: var(--fe-ink-2); +} + +:where(:root) .album-hero h2 { + font-size: var(--fe-banner); + font-weight: 600; + letter-spacing: -0.04em; + line-height: 0.95; +} + +:where(:root) .album-meta { + color: var(--fe-ink-2); +} + +:where(:root) .album-meta > * + *::before { + color: var(--fe-ink-3); +} + +:where(:root) .album-artist-link, +:where(:root) .album-artist-link:hover { + color: var(--fe-ink); + font-weight: 560; +} + +:where(:root) .album-actions .album-play { + border-color: transparent; + background: var(--accent); + color: var(--fe-accent-ink); +} + +:where(:root) .album-actions .album-play:hover:not(:disabled) { + border-color: transparent; + background: var(--accent); + filter: brightness(1.06); + transform: translateY(-1px); +} + +:where(:root) .album-actions .album-enqueue { + border-color: transparent; + background: var(--fe-tone-2); + color: var(--fe-ink); +} + +:where(:root) .album-actions .album-enqueue:hover:not(:disabled) { + border-color: transparent; + background: var(--fe-tone-3); + color: var(--fe-ink); +} + +:where(:root) .album-songs th { + border-bottom-color: var(--fe-hairline); +} + +:where(:root) .album-songs .song-title:hover { + color: var(--accent); +} + +:where(:root) .album-related h3 { + font-size: var(--fe-title); +} + +:where(:root) .album-related-grid span { + color: var(--fe-ink-2); +} + +:where(:root) :is(.detail-header, .artist-hero) { + border-bottom: 0; + background: radial-gradient( + ellipse 60% 90% at 20% 0%, + color-mix(in srgb, var(--fe-glow) 55%, transparent), + transparent 70% + ); +} + +/* The head's own title, not the title of a dialog the head opened: the + Deezer picker lives inside the artist hero in the tree, and its heading + was set at banner size. */ +:where(:root) :is(.detail-header, .artist-hero) h2:not(:where(dialog *)) { + font-size: var(--fe-banner); + font-weight: 600; + letter-spacing: -0.04em; + line-height: 0.95; +} + +:where(:root) :is(.cover.large, .artist-portrait) { + box-shadow: 0 1.5rem 3.5rem var(--fe-shadow); +} + +:where(:root) .cover.large { + border-radius: var(--fe-radius-hero); +} diff --git a/webapp/src/styles/fluid/player-bar.css b/webapp/src/styles/fluid/player-bar.css new file mode 100644 index 00000000..0c5231a1 --- /dev/null +++ b/webapp/src/styles/fluid/player-bar.css @@ -0,0 +1,259 @@ +/* The player bar, lit from its top edge by the cover. */ + +/* ── The player bar ─────────────────────────────────────────── */ + +/* One surface, lit from its top edge by the cover. */ +:where(:root) .player { + border: 0; + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--fe-glow) 32%, transparent), + transparent 70% + ), + color-mix(in srgb, var(--fe-surface) 92%, transparent); + box-shadow: inset 0 0 0 1px var(--fe-hairline); + backdrop-filter: blur(1.5rem) saturate(120%); +} + +:where(:root) .player-cover { + border: 0; + border-radius: var(--fe-radius-tile); + box-shadow: 0 0.5rem 1.25rem var(--fe-shadow-soft); +} + +/* A station's or a channel's image in its list, and a channel's above its + episodes: tiles, as the bar's cover is. */ +:where(:root) :is(.resource-cover, .podcast-cover) { + border-radius: var(--fe-radius-tile); +} + +:where(:root) .player-track strong { + color: var(--fe-ink); + font-weight: 560; +} + +:where(:root) .player-track span { + color: var(--fe-ink-2); +} + +:where(:root) .player-controls button { + background: transparent; + color: var(--fe-ink-2); +} + +:where(:root) .player-controls button:hover { + background: var(--fe-tone-2); + color: var(--fe-ink); +} + +:where(:root) .player-controls button.mode.on { + color: var(--accent); +} + +:where(:root) .player-controls button.primary { + background: var(--fe-ink); + color: var(--fe-bg); +} + +:where(:root) .player-controls button.primary:hover { + background: var(--fe-ink); + color: var(--fe-bg); +} + +:where(:root) .player-progress { + color: var(--fe-ink-3); +} + +:where(:root) .player-aside button { + background: transparent; +} + +:where(:root) .player-aside .nav-action { + border: 0; + border-radius: 999px; + background: var(--fe-tone-2); + color: var(--fe-ink-2); +} + +:where(:root) .player-aside .nav-action:hover { + background: var(--fe-tone-3); + color: var(--fe-ink); +} + +/* Beside the rail: one bar across the window, with the transport centred on + the window rather than on what is left of it, and the line under it. */ +@media (width > 820px) { + :where(:root) .player { + right: 0; + bottom: 0; + left: 0; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 38rem) minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: var(--space-6); + row-gap: var(--space-1); + min-height: 5.25rem; + padding: var(--space-3) var(--space-6); + border-radius: 0; + box-shadow: inset 0 1px 0 var(--fe-hairline); + } + + /* The record and its names share the first column, so the middle one + stays the middle of the window. */ + :where(:root) .player > a { + grid-area: 1 / 1 / 3 / 2; + align-self: center; + justify-self: start; + width: 3.75rem; + } + + :where(:root) .player-cover { + width: 3.75rem; + } + + /* A cover with no album to open is the bar's own child rather than a + link's, and needs the same place: every cell is claimed, so left to the + auto-placement it opened a third row under the bar. A station or an + episode is drawn this way, and so is a track without an album. */ + :where(:root) .player > .player-cover { + grid-area: 1 / 1 / 3 / 2; + align-self: center; + justify-self: start; + } + + /* Set beside the cover by a margin, not a padding: a padded box still + covers the cover it sits over, and took every click aimed at the middle + of it — only the strip above the names reached the album link. */ + :where(:root) .player-track { + grid-area: 1 / 1 / 3 / 2; + align-self: center; + margin-left: calc(3.75rem + var(--space-3)); + } + + :where(:root) .player-track strong { + font-size: var(--fe-body); + } + + :where(:root) .player-controls { + grid-area: 1 / 2; + } + + :where(:root) .player-controls button.primary { + width: 2.75rem; + min-height: 2.75rem; + } + + :where(:root) .player-controls button.primary:hover { + transform: scale(1.04); + } + + /* Where the heard part ends: the thumb's centre, which travels half its + width in from each end of the track rather than from edge to edge. + Declared here, where the player writes `--played`, so the share is read + from it; the `100%` resolves where it is used, against the wave's box + and against the track's. */ + :where(:root) .player-progress { + --fe-played-at: calc( + var(--fe-thumb) / + 2 + + var(--played, 0) * + (100% - var(--fe-thumb)) + ); + + position: relative; + grid-area: 2 / 2; + } + + :where(:root) .player-aside { + grid-area: 1 / 3 / 3 / 4; + justify-self: end; + } + + /* The line: straight where it is still to come, the mark's wave where it + has been heard. The range on top keeps the seeking. */ + :where(:root) .progress-wave { + display: block; + position: absolute; + top: 50%; + left: 0; + /* Both lines named: an absolutely placed grid item with an `auto` end + runs to the container's padding edge, so `grid-column: 2` alone took + in the gap and the duration's column, and the wave outran the thumb + further the later the track — a ninth too long at the end. */ + grid-column: 2 / 3; + grid-row: 1; + width: var(--fe-played-at); + height: 0.5rem; + background: var(--accent); + mask: var(--fe-wave) repeat-x left center / 1rem 0.5rem; + pointer-events: none; + translate: 0 -50%; + } + + :where(:root) .player-progress input { + position: relative; + z-index: 1; + height: 1rem; + margin: 0; + background: transparent; + appearance: none; + } + + :where(:root) .player-progress input::-webkit-slider-runnable-track { + height: 1px; + background: linear-gradient( + 90deg, + transparent var(--fe-played-at), + var(--fe-hairline-strong) 0 + ); + } + + :where(:root) .player-progress input::-moz-range-track { + height: 1px; + background: linear-gradient( + 90deg, + transparent var(--fe-played-at), + var(--fe-hairline-strong) 0 + ); + } + + :where(:root) .player-progress input::-webkit-slider-thumb { + width: var(--fe-thumb); + height: var(--fe-thumb); + margin-top: calc(var(--fe-thumb) / -2 + 0.5px); + border: 0; + border-radius: 50%; + background: var(--fe-ink); + opacity: 0; + transition: opacity 180ms var(--fe-ease); + appearance: none; + } + + :where(:root) .player-progress input::-moz-range-thumb { + width: var(--fe-thumb); + height: var(--fe-thumb); + border: 0; + border-radius: 50%; + background: var(--fe-ink); + opacity: 0; + transition: opacity 180ms var(--fe-ease); + } + + :where(:root) .player-progress:hover input::-webkit-slider-thumb, + :where(:root) .player-progress input:focus-visible::-webkit-slider-thumb { + opacity: 1; + } + + :where(:root) .player-progress:hover input::-moz-range-thumb, + :where(:root) .player-progress input:focus-visible::-moz-range-thumb { + opacity: 1; + } +} + +/* The aside leaves below 62rem; the transport keeps the middle column and + the record takes the rest. */ +@media (width > 820px) and (width <= 62rem) { + :where(:root) .player { + grid-template-columns: minmax(0, 1fr) minmax(16rem, 1.4fr); + } +} diff --git a/webapp/src/styles/fluid/rail.css b/webapp/src/styles/fluid/rail.css new file mode 100644 index 00000000..03b82892 --- /dev/null +++ b/webapp/src/styles/fluid/rail.css @@ -0,0 +1,235 @@ +/* The rail: the navigation beside the page, denser in a shorter window. */ + +/* ── The rail ────────────────────────────────────────────────── */ + +:where(:root) .sidebar { + width: var(--sidebar-width); + border-right: 0; + background: color-mix(in srgb, var(--fe-surface) 82%, transparent); + box-shadow: inset -1px 0 0 var(--fe-hairline); + backdrop-filter: blur(1.5rem) saturate(115%); +} + +/* The player bar runs under the rail's foot now, so the rail ends above it. */ +@media (width > 820px) { + :where(:root) .sidebar { + padding-bottom: calc(5.75rem + var(--space-4)); + } +} + +:where(:root) .brand { + font-family: var(--fe-font); + font-weight: 600; + letter-spacing: -0.03em; +} + +:where(:root) .brand small { + color: var(--fe-ink-3); + font-size: var(--fe-label); + letter-spacing: 0.12em; +} + +:where(:root) .primary-navigation { + gap: var(--fe-rail-gap); + margin-top: var(--space-6); +} + +:where(:root) .nav-group { + display: grid; + gap: 0.125rem; +} + +:where(:root) .nav-label { + padding: 0 var(--space-3) var(--space-2); +} + +:where(:root) :is(.primary-navigation a, .nav-action) { + min-height: 2.375rem; + border-radius: var(--fe-radius-tile); + color: var(--fe-ink-2); + font-weight: 500; + transition: + background-color 180ms var(--fe-ease), + color 180ms var(--fe-ease); +} + +:where(:root) .primary-navigation a:hover:not([data-status="active"]), +:where(:root) .nav-action:hover { + background: var(--fe-tone-1); + color: var(--fe-ink); +} + +/* The open row: a tone and a 2 px edge in the accent, with a faint glow. */ +:where(:root) .primary-navigation a[data-status="active"], +:where(:root) .sidebar-footer a[data-status="active"] { + position: relative; + background: var(--fe-tone-2); + color: var(--fe-ink); + font-weight: 560; +} + +:where(:root) .primary-navigation a[data-status="active"]::after, +:where(:root) .sidebar-footer a[data-status="active"]::after { + position: absolute; + top: 22%; + bottom: 22%; + left: 0; + width: 2px; + height: auto; + margin: 0; + border-radius: 2px; + background: var(--accent); + box-shadow: 0 0 0.75rem color-mix(in srgb, var(--accent) 60%, transparent); +} + +/* The four places reached for most, as tiles two to a row: the icon on a + tone, the name under it, the open one ringed in the accent. */ +:where(:root) .nav-group[data-group="pinned"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +:where(:root) .nav-group[data-group="pinned"] a { + display: grid; + min-height: 4.25rem; + align-content: center; + justify-items: start; + gap: var(--space-2); + padding: var(--space-3); + border-radius: var(--fe-radius-card); + background: var(--fe-tone-1); + font-size: var(--text-sm); +} + +:where(:root) + .nav-group[data-group="pinned"] + a:hover:not([data-status="active"]) { + background: var(--fe-tone-2); +} + +:where(:root) .nav-group[data-group="pinned"] a[data-status="active"] { + background: var(--fe-tone-2); + box-shadow: + inset 0 0 0 1px var(--accent), + 0 0.5rem 1.5rem color-mix(in srgb, var(--fe-glow) 45%, transparent); +} + +:where(:root) .nav-group[data-group="pinned"] a[data-status="active"]::after { + display: none; +} + +:where(:root) .nav-group[data-group="pinned"] a svg { + color: var(--fe-ink-3); +} + +:where(:root) .nav-group[data-group="pinned"] a[data-status="active"] svg { + color: var(--accent); +} + +:where(:root) .sidebar-footer { + border-top: 0; + box-shadow: inset 0 1px 0 var(--fe-hairline); +} + +:where(:root) .account-chip { + border: 0; + border-radius: var(--fe-radius-card); + background: var(--fe-tone-1); +} + +:where(:root) .account-chip > span { + border-radius: 50%; + background: var(--accent); + color: var(--fe-accent-ink); +} + +/* A shorter window draws a denser rail, so that every destination fits + without the rail scrolling. Two steps rather than one: a window of + 1080 px or so needs only the spacing taken in, and keeps the tiles and + the rows' height; from about 1070 px down the rows are tightened as well, + to fit at a laptop's 768. A tall window keeps the rail as designed. + + Every row stays at least 28 px tall, above the 24 px WCAG 2.5.8 asks of a + target, and the type keeps its size: what is taken in is space between + things, never the things. + + The thresholds are where each layout stops fitting, measured for the + longest rail an account sees: the designed one needs a window of about + 1140 px, the first step about 1060 px, and each step starts a little + above that so a font that loads late cannot tip it over. */ +@media (width > 820px) and (max-height: 72rem) { + :where(:root) .sidebar { + padding-top: var(--space-4); + } + + :where(:root) .primary-navigation { + gap: var(--space-4); + margin-top: var(--space-4); + } + + :where(:root) .nav-group[data-group="pinned"] a { + min-height: 3.5rem; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + } + + :where(:root) :is(.primary-navigation a, .nav-action) { + min-height: 2.125rem; + } +} + +@media (width > 820px) and (max-height: 67rem) { + :where(:root) .sidebar { + padding-top: var(--space-3); + } + + :where(:root) .brand { + min-height: 2rem; + } + + :where(:root) .primary-navigation { + gap: var(--space-2); + margin-top: var(--space-2); + } + + :where(:root) .nav-group { + gap: 0; + } + + :where(:root) .nav-label { + padding-bottom: 0.125rem; + } + + /* The four tiles become rows of two: the icon beside the name rather than + over it. */ + :where(:root) .nav-group[data-group="pinned"] { + gap: var(--space-1); + } + + :where(:root) .nav-group[data-group="pinned"] a { + display: flex; + min-height: 2rem; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + } + + :where(:root) :is(.primary-navigation a, .nav-action) { + min-height: 1.75rem; + padding-block: 0; + } + + :where(:root) .sidebar-footer { + gap: 0; + padding-top: var(--space-2); + } + + :where(:root) .account-chip { + padding: var(--space-1) var(--space-2); + } + + :where(:root) .account-chip > span { + width: 1.5rem; + height: 1.5rem; + } +} diff --git a/webapp/src/styles/fluid/theme-picker.css b/webapp/src/styles/fluid/theme-picker.css new file mode 100644 index 00000000..4c04deaf --- /dev/null +++ b/webapp/src/styles/fluid/theme-picker.css @@ -0,0 +1,223 @@ +/* The theme picker: a card per preset, as the desktop draws them. */ + +/* ── The theme picker ────────────────────────────────────────── */ + +/* A card per preset, as the desktop draws them: a miniature of the app in + the preset's own colours, its name and its mode. The miniature reads only + the `--preview-*` properties the card carries inline, so each card paints + its own theme whichever one is applied; everything around it is the + material's. */ +.theme-gallery { + display: grid; + gap: var(--space-4); + max-width: 60rem; +} + +.theme-gallery-heading, +.theme-group-heading { + margin: 0; + color: var(--fe-ink-3); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.theme-follow { + display: grid; + gap: var(--space-1); +} + +.theme-groups { + display: grid; + gap: var(--space-6); +} + +/* A fieldset for its grouping, without its frame. `min-inline-size` matters: + a fieldset is as wide as its content by default, and the grid inside it + would push the page sideways on a phone instead of reflowing. */ +.theme-group { + display: grid; + gap: var(--space-3); + min-inline-size: 0; + margin: 0; + padding: 0; + border: 0; +} + +/* Reflows rather than scrolls: two cards abreast on a phone, as many as fit + on a desktop, never narrower than a miniature stays legible. */ +.theme-grid { + display: grid; + gap: var(--space-3); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 8.5rem), 1fr)); +} + +.theme-card { + position: relative; + display: grid; + gap: var(--space-1); + padding: var(--space-2); + border: 1px solid var(--fe-hairline-strong); + border-radius: var(--fe-radius-card); + background: var(--fe-surface); + cursor: pointer; + transition: + border-color 180ms var(--fe-ease), + box-shadow 180ms var(--fe-ease), + transform 320ms var(--fe-ease); +} + +.theme-card:hover { + transform: translateY(-1px); +} + +/* The radio the card labels: laid over the whole card and see-through, so it + keeps the focus, the keyboard and the checked state the card only draws, + and a press anywhere on the card is a press on it. */ +.theme-card-input { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +/* The chosen card: the accent's edge, doubled so it reads as a state and not + as a hairline, and the check in its corner. */ +.theme-card:has(.theme-card-input:checked) { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* The focus ring the hidden radio cannot show, drawn around its card. */ +.theme-card:has(.theme-card-input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.theme-card-preview { + display: grid; + overflow: hidden; + aspect-ratio: 16 / 10; + grid-template-columns: 28% 1fr; + grid-template-rows: 1fr auto; + border: 1px solid color-mix(in srgb, var(--preview-ink) 10%, transparent); + border-radius: var(--fe-radius-tile); + background: var(--preview-ground); +} + +.theme-card-sidebar { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2) var(--space-1); + background: var(--preview-sidebar); +} + +.theme-card-content { + display: grid; + align-content: start; + gap: var(--space-1); + padding: var(--space-2); +} + +.theme-card-dot { + flex: none; + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--preview-accent); +} + +.theme-card-line { + height: 0.1875rem; + border-radius: 999px; + background: color-mix(in srgb, var(--preview-ink) 22%, transparent); +} + +.theme-card-line:last-child { + width: 60%; +} + +.theme-card-bar { + height: 0.875rem; + border-radius: 0.1875rem; + background: linear-gradient( + 100deg, + var(--preview-accent), + var(--preview-accent-deep) + ); +} + +.theme-card-tiles { + display: grid; + gap: var(--space-1); + grid-template-columns: repeat(3, 1fr); +} + +.theme-card-tiles > span { + aspect-ratio: 1; + border-radius: 0.1875rem; + background: color-mix(in srgb, var(--preview-raised) 86%, var(--preview-ink)); +} + +.theme-card-player { + display: flex; + align-items: center; + grid-column: 1 / -1; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-top: 1px solid color-mix(in srgb, var(--preview-ink) 10%, transparent); + background: var(--preview-raised); +} + +.theme-card-progress { + flex: 1; + height: 0.125rem; + border-radius: 999px; + background: linear-gradient( + 90deg, + var(--preview-accent) 0 40%, + color-mix(in srgb, var(--preview-ink) 20%, transparent) 40% 100% + ); +} + +.theme-card-name { + color: var(--fe-ink); + font-size: var(--text-sm); + font-weight: 560; + line-height: 1.3; +} + +.theme-card-mode { + color: var(--fe-ink-3); + font-size: var(--text-xs); +} + +.theme-card-check { + position: absolute; + top: var(--space-1); + right: var(--space-1); + display: grid; + width: 1.25rem; + height: 1.25rem; + place-items: center; + border-radius: 50%; + background: var(--accent); + color: var(--fe-accent-ink); + box-shadow: 0 0 0 2px var(--fe-surface); +} + +@media (prefers-reduced-motion: reduce) { + .theme-card { + transition: none; + } + + .theme-card:hover { + transform: none; + } +} diff --git a/webapp/src/styles/fluid/tokens.css b/webapp/src/styles/fluid/tokens.css index 035a0df2..7a3e6499 100644 --- a/webapp/src/styles/fluid/tokens.css +++ b/webapp/src/styles/fluid/tokens.css @@ -1,33 +1,4 @@ -/* ============================================================ - Fluid Editorial — the structure, and its two materials - ============================================================ - WaveFlow's design language since 2.0, written down in the desktop - repository's `docs/design/fluid-editorial.md`; this sheet carries it to - the web client. Cinematic, music first: the cover lights the page, the - type carries the editorial voice, the wave is the mark. - - **One structure, two materials.** The guide separates the structure (the - rail, the heads, the bar, the type, the mark, the rhythm, the ease) from - the material a skin lays over it, and lets a material be light. Every - rule below is the structure and reads only tokens; the tokens are the - material, declared twice: the guide's own dark one under `:root.dark`, - and a light one under `:root:not(.dark)`. The light material keeps the - guide's rule not to borrow the dark inks: its ground takes the theme's - ambient tint, its tones are dark ink at a low alpha, its accent is the - theme's 700 stop under white ink, and the cover's light reaches it - washed out to a pastel. - - **Wrapped in `:where()`.** The prefix adds no specificity, so a rule - here weighs exactly what the rule it replaces weighs and wins by coming - last. A plain `:root button` would outrank `.player-controls button` - and dress every bare icon button as a pill. - - The theme chooses the accent and the cover chooses the light (`--glow`, - written by `CoverLight`). The inks are the material's. The surfaces are - the theme's where it names them: a dark preset's ground, panel and raised - surface arrive as `--theme-ground`, `--theme-surface` and `--theme-raised` - (written by `applyTheme`, from the same `themeSurfaces` the Settings - cards are drawn with), and the light ground takes the theme's tint. */ +/* The tokens: what both materials share, then the dark and the light one. */ /* Drawn only beside the rail, where the player bar has room for it. */ .progress-wave { @@ -219,1570 +190,3 @@ :root:not(.dark)[data-theme="sunset-light"] { --accent: color-mix(in srgb, var(--accent-700) 80%, var(--accent-800)); } - -/* ── Ground and atmosphere ───────────────────────────────────── */ - -:where(:root) body { - background: var(--fe-bg); - color: var(--fe-ink); - font-family: var(--fe-font); - font-size: var(--fe-body); - font-optical-sizing: auto; - font-weight: 480; - -webkit-font-smoothing: antialiased; -} - -/* Two soft pools of the cover's colour behind the page. Fixed, so nothing - repaints on scroll; the fade comes from the registered `--glow`. */ -:where(:root) body::before { - display: block; - position: fixed; - z-index: 0; - inset: -20%; - opacity: 1; - background: - radial-gradient( - ellipse 42% 38% at 72% 8%, - color-mix(in srgb, var(--fe-glow) 40%, transparent), - transparent 70% - ), - radial-gradient( - ellipse 36% 30% at 18% 92%, - color-mix(in srgb, var(--fe-glow-deep) 70%, transparent), - transparent 72% - ); - content: ""; - pointer-events: none; -} - -:where(:root) #root { - position: relative; - z-index: 1; -} - -:where(:root) ::selection { - background: color-mix(in srgb, var(--accent) 35%, transparent); - color: var(--fe-ink); -} - -/* ── Type ────────────────────────────────────────────────────── */ - -:where(:root) :is(h1, h2, h3) { - font-family: var(--fe-font); - font-weight: 560; - letter-spacing: -0.022em; -} - -:where(:root) h3 { - font-size: var(--fe-title); - font-weight: 540; -} - -:where(:root) .muted { - color: var(--fe-ink-3); -} - -:where(:root) .eyebrow, -:where(:root) .page-kicker, -:where(:root) .nav-label, -:where(:root) .album-type { - display: block; - color: var(--fe-ink-3); - font-size: var(--fe-label); - font-weight: 540; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -/* The mark under a section title: the one ornament the language allows, - and it marks rather than fills. */ -:where(:root) - :is( - .page-heading h2, - .album-related h3, - .artist-played h3, - .artist-discography h3, - .artist-similar h3 - )::after { - display: block; - width: 2.5rem; - height: 0.625rem; - margin-top: 0.75rem; - background: var(--accent); - content: ""; - mask: var(--fe-mark) no-repeat left center / contain; -} - -/* ── Controls ────────────────────────────────────────────────── */ - -:where(:root) :focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* A secondary action is a ghost pill: a tone of white, no line. */ -:where(:root) button { - border-color: transparent; - border-radius: 999px; - background: var(--fe-tone-2); - color: var(--fe-ink); - font-weight: 540; - transition: - background-color 180ms var(--fe-ease), - color 180ms var(--fe-ease), - filter 180ms var(--fe-ease), - transform 320ms var(--fe-ease); -} - -:where(:root) button:hover { - border-color: transparent; - background: var(--fe-tone-3); -} - -:where(:root) button:active { - transform: translateY(1px); -} - -/* The primary action is the accent, spent once, with dark ink on it. */ -:where(:root) :is(button[type="submit"], button.action-primary) { - border-color: transparent; - background: var(--accent); - color: var(--fe-accent-ink); -} - -:where(:root) :is(button[type="submit"], button.action-primary):hover { - border-color: transparent; - background: var(--accent); - filter: brightness(1.06); - transform: translateY(-1px); -} - -:where(:root) button.action-secondary { - background: var(--fe-tone-2); - color: var(--fe-ink); -} - -:where(:root) button.action-secondary:hover { - background: var(--fe-tone-3); - color: var(--fe-ink); -} - -:where(:root) button.danger { - background: color-mix(in srgb, var(--danger) 12%, transparent); - color: var(--danger); -} - -/* A field is a tone with a hairline ring drawn inside it, so the ring never - changes the box; focus brings the accent ring and the cover's halo. */ -:where(:root) - :is( - input:not([type="range"], [type="checkbox"], [type="radio"]), - select, - textarea - ) { - border: 0; - border-radius: var(--fe-radius-input); - background: var(--fe-tone-1); - box-shadow: inset 0 0 0 1px var(--fe-hairline-strong); - color: var(--fe-ink); - transition: - box-shadow 180ms var(--fe-ease), - background-color 180ms var(--fe-ease); -} - -:where(:root) - :is( - input:not([type="range"], [type="checkbox"], [type="radio"]), - select, - textarea - ):hover { - background: var(--fe-tone-2); -} - -:where(:root) - :is( - input:not([type="range"], [type="checkbox"], [type="radio"]), - select, - textarea - ):focus-visible { - outline: none; - box-shadow: - inset 0 0 0 1px var(--accent), - 0 0 0 4px color-mix(in srgb, var(--fe-glow) 55%, transparent); -} - -:where(:root) input::placeholder { - color: var(--fe-ink-3); -} - -:where(:root) - :is(input[type="range"], input[type="checkbox"], input[type="radio"]) { - accent-color: var(--accent); -} - -/* The native popup follows the ground, not a white Windows sheet. */ -:where(:root.dark) select { - color-scheme: dark; -} - -:where(:root) select option { - background-color: var(--fe-surface-2); - color: var(--fe-ink); -} - -/* The header's filters keep the underline the refresh gave them, in the - guide's hairline. */ -:where(:root) .page-header .control :is(input, select) { - border-radius: 0; - background: transparent; - box-shadow: inset 0 -1px 0 var(--fe-hairline-strong); -} - -:where(:root) .page-header .control :is(input, select):hover, -:where(:root) .page-header .control :is(input, select):focus-visible { - background: transparent; - box-shadow: inset 0 -1px 0 var(--accent); -} - -:where(:root) - :is( - .control > span, - .theme-picker span, - .language-picker span, - .library-picker > span - ) { - color: var(--fe-ink-3); - font-size: var(--fe-label); - font-weight: 540; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -/* Links that are rows in a pill or a sheet. Ahead of the rail, whose tiles - are links too and weigh more. */ -:where(:root) .view-switcher a { - border-radius: 999px; -} - -:where(:root) .more-sheet nav a { - border-radius: 0.5rem; -} - -/* ── The rail ────────────────────────────────────────────────── */ - -:where(:root) .sidebar { - width: var(--sidebar-width); - border-right: 0; - background: color-mix(in srgb, var(--fe-surface) 82%, transparent); - box-shadow: inset -1px 0 0 var(--fe-hairline); - backdrop-filter: blur(1.5rem) saturate(115%); -} - -/* The player bar runs under the rail's foot now, so the rail ends above it. */ -@media (width > 820px) { - :where(:root) .sidebar { - padding-bottom: calc(5.75rem + var(--space-4)); - } -} - -:where(:root) .brand { - font-family: var(--fe-font); - font-weight: 600; - letter-spacing: -0.03em; -} - -:where(:root) .brand small { - color: var(--fe-ink-3); - font-size: var(--fe-label); - letter-spacing: 0.12em; -} - -:where(:root) .primary-navigation { - gap: var(--fe-rail-gap); - margin-top: var(--space-6); -} - -:where(:root) .nav-group { - display: grid; - gap: 0.125rem; -} - -:where(:root) .nav-label { - padding: 0 var(--space-3) var(--space-2); -} - -:where(:root) :is(.primary-navigation a, .nav-action) { - min-height: 2.375rem; - border-radius: var(--fe-radius-tile); - color: var(--fe-ink-2); - font-weight: 500; - transition: - background-color 180ms var(--fe-ease), - color 180ms var(--fe-ease); -} - -:where(:root) .primary-navigation a:hover:not([data-status="active"]), -:where(:root) .nav-action:hover { - background: var(--fe-tone-1); - color: var(--fe-ink); -} - -/* The open row: a tone and a 2 px edge in the accent, with a faint glow. */ -:where(:root) .primary-navigation a[data-status="active"], -:where(:root) .sidebar-footer a[data-status="active"] { - position: relative; - background: var(--fe-tone-2); - color: var(--fe-ink); - font-weight: 560; -} - -:where(:root) .primary-navigation a[data-status="active"]::after, -:where(:root) .sidebar-footer a[data-status="active"]::after { - position: absolute; - top: 22%; - bottom: 22%; - left: 0; - width: 2px; - height: auto; - margin: 0; - border-radius: 2px; - background: var(--accent); - box-shadow: 0 0 0.75rem color-mix(in srgb, var(--accent) 60%, transparent); -} - -/* The four places reached for most, as tiles two to a row: the icon on a - tone, the name under it, the open one ringed in the accent. */ -:where(:root) .nav-group[data-group="pinned"] { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2); -} - -:where(:root) .nav-group[data-group="pinned"] a { - display: grid; - min-height: 4.25rem; - align-content: center; - justify-items: start; - gap: var(--space-2); - padding: var(--space-3); - border-radius: var(--fe-radius-card); - background: var(--fe-tone-1); - font-size: var(--text-sm); -} - -:where(:root) - .nav-group[data-group="pinned"] - a:hover:not([data-status="active"]) { - background: var(--fe-tone-2); -} - -:where(:root) .nav-group[data-group="pinned"] a[data-status="active"] { - background: var(--fe-tone-2); - box-shadow: - inset 0 0 0 1px var(--accent), - 0 0.5rem 1.5rem color-mix(in srgb, var(--fe-glow) 45%, transparent); -} - -:where(:root) .nav-group[data-group="pinned"] a[data-status="active"]::after { - display: none; -} - -:where(:root) .nav-group[data-group="pinned"] a svg { - color: var(--fe-ink-3); -} - -:where(:root) .nav-group[data-group="pinned"] a[data-status="active"] svg { - color: var(--accent); -} - -:where(:root) .sidebar-footer { - border-top: 0; - box-shadow: inset 0 1px 0 var(--fe-hairline); -} - -:where(:root) .account-chip { - border: 0; - border-radius: var(--fe-radius-card); - background: var(--fe-tone-1); -} - -:where(:root) .account-chip > span { - border-radius: 50%; - background: var(--accent); - color: var(--fe-accent-ink); -} - -/* A shorter window draws a denser rail, so that every destination fits - without the rail scrolling. Two steps rather than one: a window of - 1080 px or so needs only the spacing taken in, and keeps the tiles and - the rows' height; from about 1070 px down the rows are tightened as well, - to fit at a laptop's 768. A tall window keeps the rail as designed. - - Every row stays at least 28 px tall, above the 24 px WCAG 2.5.8 asks of a - target, and the type keeps its size: what is taken in is space between - things, never the things. - - The thresholds are where each layout stops fitting, measured for the - longest rail an account sees: the designed one needs a window of about - 1140 px, the first step about 1060 px, and each step starts a little - above that so a font that loads late cannot tip it over. */ -@media (width > 820px) and (max-height: 72rem) { - :where(:root) .sidebar { - padding-top: var(--space-4); - } - - :where(:root) .primary-navigation { - gap: var(--space-4); - margin-top: var(--space-4); - } - - :where(:root) .nav-group[data-group="pinned"] a { - min-height: 3.5rem; - gap: var(--space-1); - padding: var(--space-2) var(--space-3); - } - - :where(:root) :is(.primary-navigation a, .nav-action) { - min-height: 2.125rem; - } -} - -@media (width > 820px) and (max-height: 67rem) { - :where(:root) .sidebar { - padding-top: var(--space-3); - } - - :where(:root) .brand { - min-height: 2rem; - } - - :where(:root) .primary-navigation { - gap: var(--space-2); - margin-top: var(--space-2); - } - - :where(:root) .nav-group { - gap: 0; - } - - :where(:root) .nav-label { - padding-bottom: 0.125rem; - } - - /* The four tiles become rows of two: the icon beside the name rather than - over it. */ - :where(:root) .nav-group[data-group="pinned"] { - gap: var(--space-1); - } - - :where(:root) .nav-group[data-group="pinned"] a { - display: flex; - min-height: 2rem; - align-items: center; - gap: var(--space-2); - padding: 0 var(--space-3); - } - - :where(:root) :is(.primary-navigation a, .nav-action) { - min-height: 1.75rem; - padding-block: 0; - } - - :where(:root) .sidebar-footer { - gap: 0; - padding-top: var(--space-2); - } - - :where(:root) .account-chip { - padding: var(--space-1) var(--space-2); - } - - :where(:root) .account-chip > span { - width: 1.5rem; - height: 1.5rem; - } -} - -/* ── The page ────────────────────────────────────────────────── */ - -:where(:root) main { - width: min(100%, 105rem); -} - -/* The guide's gutters, from 1024 px. Below that the refresh's fluid gutter - and the phone's stay. One variable, because the heads that bleed to the - edge pull back by exactly the gutter, and a second spelling of it is how - the page grew a horizontal scroll. */ -@media (width >= 64rem) { - :root { - --fe-gutter: 2rem; - } - - :where(:root) main { - padding-inline: var(--fe-gutter); - } - - :where(:root) .album-hero { - margin-inline: calc(var(--fe-gutter) * -1); - } - - :where(:root) .album-hero-inner { - padding-inline: var(--fe-gutter); - } - - :where(:root) :is(.detail-header, .artist-hero) { - margin-inline: calc(var(--fe-gutter) * -1); - padding-inline: var(--fe-gutter); - } -} - -@media (width >= 80rem) { - :root { - --fe-gutter: 3rem; - } -} - -@media (width >= 96rem) { - :root { - --fe-gutter: 3.5rem; - } -} - -/* Kicker, display title, one meta line; the primary action as a pill. No - rule under it: blocks are told apart by spacing. */ -:where(:root) .page-header { - margin-bottom: var(--fe-section-gap); - padding-bottom: 0; - border-bottom: 0; -} - -:where(:root) .page-header h2 { - max-width: 22ch; - font-size: var(--fe-display); - font-weight: 580; - letter-spacing: -0.038em; - line-height: 0.96; - text-wrap: balance; -} - -:where(:root) .page-header p { - color: var(--fe-ink-2); - font-size: var(--fe-body); -} - -:where(:root) .page-heading { - gap: var(--space-3); -} - -/* ── Tiles ───────────────────────────────────────────────────── */ - -:where(:root) .grid { - gap: var(--space-8) var(--space-6); -} - -:where(:root) .cover { - border: 0; - border-radius: var(--fe-radius-card); - background: var(--fe-surface); - transition: - transform 320ms var(--fe-ease), - box-shadow 320ms var(--fe-ease); -} - -:where(:root) .cover-fallback { - background: var(--fe-surface-2); -} - -:where(:root) .cover-fallback::before { - border-color: var(--fe-hairline-strong); - background: repeating-radial-gradient( - circle, - var(--fe-tone-2) 0 1px, - transparent 1px 6px - ); -} - -:where(:root) .cover-fallback::after { - border-color: var(--fe-hairline-strong); - background: var(--fe-surface); -} - -:where(:root) .cover-fallback span { - font-family: var(--fe-font); -} - -:where(:root) .album-related-grid .cover { - border-radius: var(--fe-radius-tile); -} - -/* A tile rises three pixels with the cover's light as its shadow. */ -:where(:root) .grid li:hover .cover { - transform: translateY(-3px); - box-shadow: 0 1rem 2.25rem color-mix(in srgb, var(--fe-glow) 70%, transparent); -} - -:where(:root) .grid strong { - color: var(--fe-ink); - font-weight: 540; - letter-spacing: -0.01em; -} - -:where(:root) .grid span { - color: var(--fe-ink-2); -} - -:where(:root) .card-action { - border-radius: 50%; - background: color-mix(in srgb, var(--fe-bg) 70%, transparent); - color: var(--fe-ink); -} - -:where(:root) .card-action:hover:not(:disabled) { - background: var(--accent); - color: var(--fe-accent-ink); -} - -/* ── Tables ──────────────────────────────────────────────────── */ - -:where(:root) .songs th { - border-bottom: 1px solid var(--fe-hairline); - color: var(--fe-ink-3); - font-size: var(--fe-label); - font-weight: 540; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -:where(:root) .songs td { - border-bottom-color: var(--fe-rule); -} - -:where(:root) .songs tr:hover { - background: var(--fe-tone-1); -} - -/* The playing row: a tone and the accent's edge. The text stays ink; the - accent is the edge, not the words. */ -:where(:root) .songs tr.active { - background: var(--fe-tone-2); - color: var(--fe-ink); - box-shadow: inset 2px 0 0 var(--accent); -} - -:where(:root) .songs tr.active .song-title, -:where(:root) .album-songs tr.active .song-title { - color: var(--fe-ink); -} - -:where(:root) .songs .song-title small { - color: var(--fe-ink-2); -} - -:where(:root) .song-cover { - border-radius: 0.375rem; -} - -/* ── Surfaces: panels, cards, lists ─────────────────────────── */ - -:where(:root) - :is( - .collection, - .admin-panel, - .user-row, - .empty-state, - .error-state, - .settings-panel, - .import-panel - ) { - border: 0; - border-radius: var(--fe-radius-card); - background: var(--fe-surface); -} - -:where(:root) - .admin-page - :is(.admin-grid > .admin-panel, .user-row, .now-playing-panel) { - background: var(--fe-surface); -} - -:where(:root) .settings-page .settings-panel { - border-radius: 0; - background: transparent; - box-shadow: inset 0 1px 0 var(--fe-hairline); -} - -:where(:root) .admin-page .credential-form > button[type="submit"] { - background: var(--fe-tone-2); - color: var(--fe-ink); -} - -:where(:root) - :is(.list li, .resource-list li, .queue-list li, .bookmark-list li) { - border-bottom-color: var(--fe-rule); -} - -/* Rows with room inside them: the open one is a tone with the accent's edge, - and a cover pressed against that edge read as cut off. */ -:where(:root) :is(.queue-list li, .bookmark-list li) { - padding-inline: var(--space-3); -} - -:where(:root) .queue-list li.active { - background: var(--fe-tone-2); - box-shadow: inset 2px 0 0 var(--accent); -} - -:where(:root) :is(.queue-cover, .bookmark-cover) { - border-radius: var(--fe-radius-tile); -} - -/* The row's own button is the row, not a ghost pill inside it: hovered, it - took the pill's tone and full radius and drew a second card in the first. */ -:where(:root) :is(.queue-track, .bookmark-track), -:where(:root) :is(.queue-track, .bookmark-track):hover { - border-radius: 0; - background: transparent; -} - -:where(:root) .view-switcher { - border: 0; - border-radius: 999px; - background: var(--fe-tone-1); -} - -:where(:root) .view-switcher a[aria-current="page"] { - background: var(--fe-tone-3); -} - -:where(:root) .section-menu { - border: 0; - border-radius: 999px; - background: var(--fe-tone-1); -} - -:where(:root) .section-menu-link { - border-radius: 999px; - color: var(--fe-ink-2); -} - -:where(:root) .section-menu-link:hover { - color: var(--fe-ink); -} - -:where(:root) .section-menu-link[aria-current="page"] { - background: var(--fe-tone-3); - color: var(--fe-ink); -} - -@media (max-width: 820px) { - :where(:root) .section-menu { - border-radius: var(--fe-radius-card); - } - - :where(:root) .section-menu-link { - border-radius: var(--fe-radius-tile); - } -} - -/* ── Album and artist heads ─────────────────────────────────── */ - -/* The cover lights its own page: the blurred copy behind, darkened so a - pale sleeve never pales the ground, and the record on its own light. */ -:where(:root) .album-hero { - background: var(--fe-bg); - color: var(--fe-ink); -} - -:where(:root) .album-hero-ambient.cover { - filter: blur(5rem) saturate(1.3); - opacity: 0.55; -} - -:where(:root) .album-hero::before { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--fe-wash) 35%, transparent) 0%, - color-mix(in srgb, var(--fe-wash) 70%, transparent) 70%, - var(--fe-bg) 100% - ); -} - -:where(:root) .album-hero-cover.cover { - border: 0; - border-radius: var(--fe-radius-hero); - box-shadow: 0 1.5rem 3.5rem var(--fe-shadow); -} - -:where(:root) .album-type { - color: var(--fe-ink-2); -} - -:where(:root) .album-hero h2 { - font-size: var(--fe-banner); - font-weight: 600; - letter-spacing: -0.04em; - line-height: 0.95; -} - -:where(:root) .album-meta { - color: var(--fe-ink-2); -} - -:where(:root) .album-meta > * + *::before { - color: var(--fe-ink-3); -} - -:where(:root) .album-artist-link, -:where(:root) .album-artist-link:hover { - color: var(--fe-ink); - font-weight: 560; -} - -:where(:root) .album-actions .album-play { - border-color: transparent; - background: var(--accent); - color: var(--fe-accent-ink); -} - -:where(:root) .album-actions .album-play:hover:not(:disabled) { - border-color: transparent; - background: var(--accent); - filter: brightness(1.06); - transform: translateY(-1px); -} - -:where(:root) .album-actions .album-enqueue { - border-color: transparent; - background: var(--fe-tone-2); - color: var(--fe-ink); -} - -:where(:root) .album-actions .album-enqueue:hover:not(:disabled) { - border-color: transparent; - background: var(--fe-tone-3); - color: var(--fe-ink); -} - -:where(:root) .album-songs th { - border-bottom-color: var(--fe-hairline); -} - -:where(:root) .album-songs .song-title:hover { - color: var(--accent); -} - -:where(:root) .album-related h3 { - font-size: var(--fe-title); -} - -:where(:root) .album-related-grid span { - color: var(--fe-ink-2); -} - -:where(:root) :is(.detail-header, .artist-hero) { - border-bottom: 0; - background: radial-gradient( - ellipse 60% 90% at 20% 0%, - color-mix(in srgb, var(--fe-glow) 55%, transparent), - transparent 70% - ); -} - -/* The head's own title, not the title of a dialog the head opened: the - Deezer picker lives inside the artist hero in the tree, and its heading - was set at banner size. */ -:where(:root) :is(.detail-header, .artist-hero) h2:not(:where(dialog *)) { - font-size: var(--fe-banner); - font-weight: 600; - letter-spacing: -0.04em; - line-height: 0.95; -} - -:where(:root) :is(.cover.large, .artist-portrait) { - box-shadow: 0 1.5rem 3.5rem var(--fe-shadow); -} - -:where(:root) .cover.large { - border-radius: var(--fe-radius-hero); -} - -/* ── The player bar ─────────────────────────────────────────── */ - -/* One surface, lit from its top edge by the cover. */ -:where(:root) .player { - border: 0; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--fe-glow) 32%, transparent), - transparent 70% - ), - color-mix(in srgb, var(--fe-surface) 92%, transparent); - box-shadow: inset 0 0 0 1px var(--fe-hairline); - backdrop-filter: blur(1.5rem) saturate(120%); -} - -:where(:root) .player-cover { - border: 0; - border-radius: var(--fe-radius-tile); - box-shadow: 0 0.5rem 1.25rem var(--fe-shadow-soft); -} - -/* A station's or a channel's image in its list, and a channel's above its - episodes: tiles, as the bar's cover is. */ -:where(:root) :is(.resource-cover, .podcast-cover) { - border-radius: var(--fe-radius-tile); -} - -:where(:root) .player-track strong { - color: var(--fe-ink); - font-weight: 560; -} - -:where(:root) .player-track span { - color: var(--fe-ink-2); -} - -:where(:root) .player-controls button { - background: transparent; - color: var(--fe-ink-2); -} - -:where(:root) .player-controls button:hover { - background: var(--fe-tone-2); - color: var(--fe-ink); -} - -:where(:root) .player-controls button.mode.on { - color: var(--accent); -} - -:where(:root) .player-controls button.primary { - background: var(--fe-ink); - color: var(--fe-bg); -} - -:where(:root) .player-controls button.primary:hover { - background: var(--fe-ink); - color: var(--fe-bg); -} - -:where(:root) .player-progress { - color: var(--fe-ink-3); -} - -:where(:root) .player-aside button { - background: transparent; -} - -:where(:root) .player-aside .nav-action { - border: 0; - border-radius: 999px; - background: var(--fe-tone-2); - color: var(--fe-ink-2); -} - -:where(:root) .player-aside .nav-action:hover { - background: var(--fe-tone-3); - color: var(--fe-ink); -} - -/* Beside the rail: one bar across the window, with the transport centred on - the window rather than on what is left of it, and the line under it. */ -@media (width > 820px) { - :where(:root) .player { - right: 0; - bottom: 0; - left: 0; - grid-template-columns: minmax(0, 1fr) minmax(18rem, 38rem) minmax(0, 1fr); - grid-template-rows: auto auto; - column-gap: var(--space-6); - row-gap: var(--space-1); - min-height: 5.25rem; - padding: var(--space-3) var(--space-6); - border-radius: 0; - box-shadow: inset 0 1px 0 var(--fe-hairline); - } - - /* The record and its names share the first column, so the middle one - stays the middle of the window. */ - :where(:root) .player > a { - grid-area: 1 / 1 / 3 / 2; - align-self: center; - justify-self: start; - width: 3.75rem; - } - - :where(:root) .player-cover { - width: 3.75rem; - } - - /* A cover with no album to open is the bar's own child rather than a - link's, and needs the same place: every cell is claimed, so left to the - auto-placement it opened a third row under the bar. A station or an - episode is drawn this way, and so is a track without an album. */ - :where(:root) .player > .player-cover { - grid-area: 1 / 1 / 3 / 2; - align-self: center; - justify-self: start; - } - - /* Set beside the cover by a margin, not a padding: a padded box still - covers the cover it sits over, and took every click aimed at the middle - of it — only the strip above the names reached the album link. */ - :where(:root) .player-track { - grid-area: 1 / 1 / 3 / 2; - align-self: center; - margin-left: calc(3.75rem + var(--space-3)); - } - - :where(:root) .player-track strong { - font-size: var(--fe-body); - } - - :where(:root) .player-controls { - grid-area: 1 / 2; - } - - :where(:root) .player-controls button.primary { - width: 2.75rem; - min-height: 2.75rem; - } - - :where(:root) .player-controls button.primary:hover { - transform: scale(1.04); - } - - /* Where the heard part ends: the thumb's centre, which travels half its - width in from each end of the track rather than from edge to edge. - Declared here, where the player writes `--played`, so the share is read - from it; the `100%` resolves where it is used, against the wave's box - and against the track's. */ - :where(:root) .player-progress { - --fe-played-at: calc( - var(--fe-thumb) / - 2 + - var(--played, 0) * - (100% - var(--fe-thumb)) - ); - - position: relative; - grid-area: 2 / 2; - } - - :where(:root) .player-aside { - grid-area: 1 / 3 / 3 / 4; - justify-self: end; - } - - /* The line: straight where it is still to come, the mark's wave where it - has been heard. The range on top keeps the seeking. */ - :where(:root) .progress-wave { - display: block; - position: absolute; - top: 50%; - left: 0; - /* Both lines named: an absolutely placed grid item with an `auto` end - runs to the container's padding edge, so `grid-column: 2` alone took - in the gap and the duration's column, and the wave outran the thumb - further the later the track — a ninth too long at the end. */ - grid-column: 2 / 3; - grid-row: 1; - width: var(--fe-played-at); - height: 0.5rem; - background: var(--accent); - mask: var(--fe-wave) repeat-x left center / 1rem 0.5rem; - pointer-events: none; - translate: 0 -50%; - } - - :where(:root) .player-progress input { - position: relative; - z-index: 1; - height: 1rem; - margin: 0; - background: transparent; - appearance: none; - } - - :where(:root) .player-progress input::-webkit-slider-runnable-track { - height: 1px; - background: linear-gradient( - 90deg, - transparent var(--fe-played-at), - var(--fe-hairline-strong) 0 - ); - } - - :where(:root) .player-progress input::-moz-range-track { - height: 1px; - background: linear-gradient( - 90deg, - transparent var(--fe-played-at), - var(--fe-hairline-strong) 0 - ); - } - - :where(:root) .player-progress input::-webkit-slider-thumb { - width: var(--fe-thumb); - height: var(--fe-thumb); - margin-top: calc(var(--fe-thumb) / -2 + 0.5px); - border: 0; - border-radius: 50%; - background: var(--fe-ink); - opacity: 0; - transition: opacity 180ms var(--fe-ease); - appearance: none; - } - - :where(:root) .player-progress input::-moz-range-thumb { - width: var(--fe-thumb); - height: var(--fe-thumb); - border: 0; - border-radius: 50%; - background: var(--fe-ink); - opacity: 0; - transition: opacity 180ms var(--fe-ease); - } - - :where(:root) .player-progress:hover input::-webkit-slider-thumb, - :where(:root) .player-progress input:focus-visible::-webkit-slider-thumb { - opacity: 1; - } - - :where(:root) .player-progress:hover input::-moz-range-thumb, - :where(:root) .player-progress input:focus-visible::-moz-range-thumb { - opacity: 1; - } -} - -/* The aside leaves below 62rem; the transport keeps the middle column and - the record takes the rest. */ -@media (width > 820px) and (width <= 62rem) { - :where(:root) .player { - grid-template-columns: minmax(0, 1fr) minmax(16rem, 1.4fr); - } -} - -/* ── Menus and sheets ───────────────────────────────────────── */ - -:where(:root) .more-sheet { - border: 0; - border-radius: var(--fe-radius-card); - background: var(--fe-surface-2); - box-shadow: - inset 0 1px 0 var(--fe-highlight), - 0 2rem 4rem var(--fe-shadow-deep); -} - -:where(:root) .immersive { - background: var(--fe-bg); -} - -/* ── Sign-in ─────────────────────────────────────────────────── */ - -:where(:root) .login-shell { - border: 0; - border-radius: var(--fe-radius-hero); - background: var(--fe-surface); - box-shadow: - inset 0 0 0 1px var(--fe-hairline), - 0 2rem 5rem var(--fe-shadow-soft); -} - -:where(:root) .login-story { - background: - radial-gradient( - ellipse 80% 70% at 20% 10%, - color-mix(in srgb, var(--fe-glow) 60%, transparent), - transparent 70% - ), - var(--fe-bg); -} - -:where(:root) .login-story h1 { - font-weight: 580; - letter-spacing: -0.04em; - line-height: 0.96; -} - -/* The serif italic, once on the page: the line that greets. */ -:where(:root) .login-story p { - color: var(--fe-ink-2); - font-family: var(--fe-font-editorial); - font-size: 1.375rem; - font-style: italic; - font-weight: 400; - letter-spacing: -0.01em; -} - -:where(:root) .signal { - width: 7.5rem; - height: 0.625rem; - background: var(--accent); - opacity: 1; - mask: var(--fe-wave) repeat-x left center / 1.25rem 0.625rem; - /* The light theme cuts its bars into a jagged line with a clip-path; on the - wave it cut the wave itself, and the motif read as broken. */ - clip-path: none; -} - -/* ── Phones ──────────────────────────────────────────────────── */ - -@media (max-width: 820px) { - :where(:root) .mobile-header { - border-bottom: 0; - background: color-mix(in srgb, var(--fe-surface) 88%, transparent); - box-shadow: inset 0 -1px 0 var(--fe-hairline); - } - - :where(:root) .mobile-navigation { - border: 0; - border-radius: var(--fe-radius-card); - background: color-mix(in srgb, var(--fe-surface-2) 94%, transparent); - box-shadow: - inset 0 1px 0 var(--fe-highlight), - 0 1rem 2.5rem var(--fe-shadow); - } - - :where(:root) :is(.mobile-navigation a, .mobile-navigation .more-button) { - color: var(--fe-ink-3); - } - - /* The open item carries the accent's edge, here along its foot. */ - :where(:root) .mobile-navigation a[data-status="active"] { - background: var(--fe-tone-2); - color: var(--fe-ink); - box-shadow: inset 0 -2px 0 var(--accent); - } - - :where(:root) .player { - border-radius: var(--fe-radius-card); - } -} - -/* ── Motion ──────────────────────────────────────────────────── */ - -/* A page rises ten pixels and fades in, block after block. */ -@keyframes fe-rise { - from { - opacity: 0; - translate: 0 10px; - } -} - -:where(:root) main > section > * { - animation: fe-rise 640ms var(--fe-ease) both; -} - -:where(:root) main > section > :nth-child(2) { - animation-delay: 70ms; -} - -:where(:root) main > section > :nth-child(3) { - animation-delay: 140ms; -} - -:where(:root) main > section > :nth-child(n + 4) { - animation-delay: 210ms; -} - -@media (prefers-reduced-motion: reduce) { - :where(:root) main > section > * { - animation: none; - } - - /* The new record's light arrives at once. */ - :root { - transition: none; - } - - :where(:root) .grid li:hover .cover, - :where(:root) :is(button[type="submit"], button.action-primary):hover, - :where(:root) .album-actions .album-play:hover:not(:disabled) { - transform: none; - } -} - -/* ── The track menu and its properties ──────────────────────── */ - -/* A menu is a popover: `surface-2`, the card radius, no border, an inset - top highlight and a deep soft shadow; rows with their own radius. */ -:where(:root) .track-menu { - border: 0; - border-radius: var(--fe-radius-card); - background: var(--fe-surface-2); - box-shadow: - inset 0 1px 0 var(--fe-highlight), - 0 1.5rem 3rem var(--fe-shadow-deep); -} - -:where(:root) .track-menu-item { - border-radius: 0.5rem; - background: transparent; - color: var(--fe-ink-2); -} - -:where(:root) .track-menu-item:hover, -:where(:root) .track-menu-item:focus-visible { - background: var(--fe-tone-2); - color: var(--fe-ink); -} - -:where(:root) .track-properties[open] { - border: 0; - border-radius: var(--fe-radius-hero); - background: var(--fe-surface); - box-shadow: - inset 0 0 0 1px var(--fe-hairline), - 0 2rem 5rem var(--fe-shadow-deep); -} - -:where(:root) .track-properties h3 { - color: var(--fe-ink-3); - font-size: var(--fe-label); - font-weight: 540; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -:where(:root) .property-row { - border-bottom-color: var(--fe-rule); -} - -:where(:root) .property-list dt { - color: var(--fe-ink-3); -} - -:where(:root) .property-list dd { - color: var(--fe-ink); -} - -:where(:root) .properties-cover { - border-radius: var(--fe-radius-tile); -} - -:where(:root) .properties-close { - background: var(--fe-tone-2); -} - -/* ── The theme picker ────────────────────────────────────────── */ - -/* A card per preset, as the desktop draws them: a miniature of the app in - the preset's own colours, its name and its mode. The miniature reads only - the `--preview-*` properties the card carries inline, so each card paints - its own theme whichever one is applied; everything around it is the - material's. */ -.theme-gallery { - display: grid; - gap: var(--space-4); - max-width: 60rem; -} - -.theme-gallery-heading, -.theme-group-heading { - margin: 0; - color: var(--fe-ink-3); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.theme-follow { - display: grid; - gap: var(--space-1); -} - -.theme-groups { - display: grid; - gap: var(--space-6); -} - -/* A fieldset for its grouping, without its frame. `min-inline-size` matters: - a fieldset is as wide as its content by default, and the grid inside it - would push the page sideways on a phone instead of reflowing. */ -.theme-group { - display: grid; - gap: var(--space-3); - min-inline-size: 0; - margin: 0; - padding: 0; - border: 0; -} - -/* Reflows rather than scrolls: two cards abreast on a phone, as many as fit - on a desktop, never narrower than a miniature stays legible. */ -.theme-grid { - display: grid; - gap: var(--space-3); - grid-template-columns: repeat(auto-fill, minmax(min(100%, 8.5rem), 1fr)); -} - -.theme-card { - position: relative; - display: grid; - gap: var(--space-1); - padding: var(--space-2); - border: 1px solid var(--fe-hairline-strong); - border-radius: var(--fe-radius-card); - background: var(--fe-surface); - cursor: pointer; - transition: - border-color 180ms var(--fe-ease), - box-shadow 180ms var(--fe-ease), - transform 320ms var(--fe-ease); -} - -.theme-card:hover { - transform: translateY(-1px); -} - -/* The radio the card labels: laid over the whole card and see-through, so it - keeps the focus, the keyboard and the checked state the card only draws, - and a press anywhere on the card is a press on it. */ -.theme-card-input { - position: absolute; - z-index: 1; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - opacity: 0; - cursor: pointer; -} - -/* The chosen card: the accent's edge, doubled so it reads as a state and not - as a hairline, and the check in its corner. */ -.theme-card:has(.theme-card-input:checked) { - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent); -} - -/* The focus ring the hidden radio cannot show, drawn around its card. */ -.theme-card:has(.theme-card-input:focus-visible) { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - -.theme-card-preview { - display: grid; - overflow: hidden; - aspect-ratio: 16 / 10; - grid-template-columns: 28% 1fr; - grid-template-rows: 1fr auto; - border: 1px solid color-mix(in srgb, var(--preview-ink) 10%, transparent); - border-radius: var(--fe-radius-tile); - background: var(--preview-ground); -} - -.theme-card-sidebar { - display: flex; - flex-direction: column; - gap: var(--space-1); - padding: var(--space-2) var(--space-1); - background: var(--preview-sidebar); -} - -.theme-card-content { - display: grid; - align-content: start; - gap: var(--space-1); - padding: var(--space-2); -} - -.theme-card-dot { - flex: none; - width: 0.375rem; - height: 0.375rem; - border-radius: 50%; - background: var(--preview-accent); -} - -.theme-card-line { - height: 0.1875rem; - border-radius: 999px; - background: color-mix(in srgb, var(--preview-ink) 22%, transparent); -} - -.theme-card-line:last-child { - width: 60%; -} - -.theme-card-bar { - height: 0.875rem; - border-radius: 0.1875rem; - background: linear-gradient( - 100deg, - var(--preview-accent), - var(--preview-accent-deep) - ); -} - -.theme-card-tiles { - display: grid; - gap: var(--space-1); - grid-template-columns: repeat(3, 1fr); -} - -.theme-card-tiles > span { - aspect-ratio: 1; - border-radius: 0.1875rem; - background: color-mix(in srgb, var(--preview-raised) 86%, var(--preview-ink)); -} - -.theme-card-player { - display: flex; - align-items: center; - grid-column: 1 / -1; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - border-top: 1px solid color-mix(in srgb, var(--preview-ink) 10%, transparent); - background: var(--preview-raised); -} - -.theme-card-progress { - flex: 1; - height: 0.125rem; - border-radius: 999px; - background: linear-gradient( - 90deg, - var(--preview-accent) 0 40%, - color-mix(in srgb, var(--preview-ink) 20%, transparent) 40% 100% - ); -} - -.theme-card-name { - color: var(--fe-ink); - font-size: var(--text-sm); - font-weight: 560; - line-height: 1.3; -} - -.theme-card-mode { - color: var(--fe-ink-3); - font-size: var(--text-xs); -} - -.theme-card-check { - position: absolute; - top: var(--space-1); - right: var(--space-1); - display: grid; - width: 1.25rem; - height: 1.25rem; - place-items: center; - border-radius: 50%; - background: var(--accent); - color: var(--fe-accent-ink); - box-shadow: 0 0 0 2px var(--fe-surface); -} - -@media (prefers-reduced-motion: reduce) { - .theme-card { - transition: none; - } - - .theme-card:hover { - transform: none; - } -} diff --git a/webapp/src/styles/refresh.css b/webapp/src/styles/refresh.css new file mode 100644 index 00000000..9d32300f --- /dev/null +++ b/webapp/src/styles/refresh.css @@ -0,0 +1,8 @@ +/* The refresh over the base sheet, in parts. Their order is the cascade. */ + +@import "./refresh/base.css"; +@import "./refresh/shell.css"; +@import "./refresh/surfaces.css"; +@import "./refresh/queue.css"; +@import "./refresh/album.css"; +@import "./refresh/login-player-responsive.css"; diff --git a/webapp/src/styles/refresh/album.css b/webapp/src/styles/refresh/album.css new file mode 100644 index 00000000..c9ccd70a --- /dev/null +++ b/webapp/src/styles/refresh/album.css @@ -0,0 +1,323 @@ +/* The album page, coloured by its sleeve. */ + +/* Album pages borrow their colour from the sleeve. The art remains the focal + point while a blurred copy gives the header depth without a fixed grey card. */ +.album-hero { + position: relative; + isolation: isolate; + overflow: hidden; + margin: calc(var(--space-12) * -1) + calc(clamp(var(--space-6), 4vw, var(--space-16)) * -1) 0; + background: #242b29; + color: #fff; +} + +.album-hero-ambient.cover { + position: absolute; + z-index: -2; + inset: -35%; + width: 170%; + height: 170%; + max-width: none; + border: 0; + border-radius: 0; + object-fit: cover; + filter: blur(5rem) saturate(1.5); + opacity: 0.85; + pointer-events: none; +} + +.album-hero::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient( + 90deg, + rgb(11 16 16 / 36%) 0%, + rgb(11 16 16 / 56%) 58%, + rgb(11 16 16 / 69%) 100% + ); + content: ""; +} + +.album-hero-inner { + display: flex; + min-height: 21rem; + align-items: end; + gap: clamp(1.5rem, 4vw, 3.5rem); + padding: 3.5rem clamp(var(--space-6), 4vw, var(--space-16)) 2.75rem; +} + +.album-hero-cover.cover { + width: clamp(11rem, 22vw, 17rem); + flex: 0 0 auto; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 0.35rem; + box-shadow: 0 1.4rem 3.5rem rgb(0 0 0 / 35%); +} + +.album-hero-copy { + min-width: 0; + padding-bottom: 0.2rem; +} + +.album-type { + display: block; + margin-bottom: 0.55rem; + color: rgb(255 255 255 / 83%); + font-size: var(--text-xs); + font-weight: 750; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.album-hero h2 { + max-width: 20ch; + margin: 0 0 1rem; + font-size: clamp(3rem, 6vw, 5.75rem); + line-height: 0.98; + overflow-wrap: anywhere; + text-wrap: balance; +} + +.album-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0; + margin: 0; + color: rgb(255 255 255 / 85%); + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; +} + +.album-meta > * + *::before { + margin: 0 0.55rem; + color: rgb(255 255 255 / 58%); + content: "·"; +} + +.album-artist-link { + color: #fff; + font-weight: 750; + text-decoration: none; +} + +.album-artist-link:hover { + color: #fff; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.album-content { + padding-top: var(--space-6); +} + +.album-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); + padding-bottom: var(--space-6); +} + +.album-actions button { + display: inline-flex; + min-height: 2.9rem; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: 0 var(--space-6); +} + +.album-actions .album-play { + border-color: var(--accent); + background: var(--accent); + color: var(--bg); +} + +.album-actions .album-play:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 82%, white 18%); + background: color-mix(in srgb, var(--accent) 82%, white 18%); +} + +.album-actions .album-enqueue { + border-color: var(--line-strong); + color: var(--text); +} + +.album-actions .album-enqueue:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.album-songs { + table-layout: fixed; +} + +.album-songs th { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--line-strong); + color: var(--muted); + font-size: var(--text-xs); + font-weight: 600; + text-align: left; +} + +.album-songs th.index, +.album-songs td.index { + width: 3.25rem; + text-align: center; +} + +.album-songs .album-duration-heading, +.album-songs .song-duration { + width: 5.5rem; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.album-songs .album-actions-heading, +.album-songs .song-marks { + width: 11.5rem; + text-align: right; +} + +.album-songs tbody tr { + height: 3.85rem; +} + +.album-songs td { + border-bottom-color: color-mix(in srgb, var(--line) 62%, transparent); +} + +.album-songs .song-title { + max-width: 100%; + color: var(--text); + font-weight: 650; +} + +.album-songs .song-title > span { + min-width: 0; + text-align: left; +} + +.album-songs .song-title small { + font-weight: 400; +} + +.album-songs .song-title:hover, +.album-songs tr.active .song-title { + color: var(--accent); +} + +.album-related { + margin-top: var(--space-16); +} + +.album-related h3 { + margin: 0 0 var(--space-6); + font-size: var(--text-2xl); +} + +.album-related-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); + gap: var(--space-6); +} + +.album-related-grid a { + display: grid; + align-content: start; + gap: var(--space-1); + min-width: 0; + color: var(--text); + text-decoration: none; +} + +.album-related-grid a:hover strong { + color: var(--accent); +} + +.album-related-grid .cover { + width: 100%; + margin-bottom: var(--space-2); + border-radius: 0.35rem; +} + +.album-related-grid strong { + overflow: hidden; + font-size: var(--text-sm); + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.album-related-grid span { + color: var(--muted); + font-size: var(--text-xs); +} + +@media (max-width: 820px) { + .album-hero { + margin: calc(var(--space-6) * -1) calc(var(--space-4) * -1) 0; + } + + .album-hero-inner { + min-height: 0; + align-items: center; + padding: var(--space-12) var(--space-4) var(--space-8); + } + + .album-hero-cover.cover { + width: clamp(7.5rem, 28vw, 11rem); + } + + .album-hero h2 { + font-size: clamp(2.25rem, 7vw, 4rem); + } + + .songs.album-songs td.song-duration { + display: table-cell; + } +} + +@media (width <= 35rem) { + .album-hero-inner { + display: grid; + justify-items: start; + gap: var(--space-6); + } + + .album-hero-cover.cover { + width: min(50vw, 10rem); + } + + .album-hero h2 { + margin-bottom: var(--space-3); + } + + .album-songs .album-duration-heading, + .album-songs .song-duration { + display: table-cell; + width: 3.7rem; + } + + .album-songs { + min-width: 22rem; + } + + .album-songs .album-actions-heading, + .album-songs .song-marks { + width: 9.25rem; + } + + .album-songs .album-actions-heading { + font-size: 0; + } + + .album-songs th.index, + .album-songs td.index { + width: 2rem; + } +} diff --git a/webapp/src/styles/refresh/base.css b/webapp/src/styles/refresh/base.css index 2eef373c..2a469c13 100644 --- a/webapp/src/styles/refresh/base.css +++ b/webapp/src/styles/refresh/base.css @@ -1,3 +1,5 @@ +/* The quieter frame's tokens, type and controls. */ + /* A quieter frame for the catalogue. Theme presets still supply the ambient colour and accent; these rules define how that colour is used by the web UI. */ :root { @@ -136,1094 +138,3 @@ select option { font-size: var(--text-sm); font-weight: 600; } - -.shell { - padding-bottom: 7.5rem; -} - -.sidebar { - display: flex; - flex-direction: column; - min-height: 0; - padding: var(--space-6) var(--space-4) var(--space-4); - background: var(--panel-soft); - backdrop-filter: none; -} - -.brand { - gap: var(--space-2); - padding-inline: var(--space-3); - font-family: var(--font-display); - font-size: var(--text-lg); - font-weight: 700; - letter-spacing: -0.05em; -} - -.brand small { - font-size: 0.65rem; - font-weight: 600; - letter-spacing: 0.1em; -} - -.library-picker { - margin: var(--space-2) 0 var(--space-3); -} - -.primary-navigation { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--line-strong) transparent; - margin: 0 calc(var(--space-2) * -1) 0 0; - padding-right: var(--space-2); - align-content: start; - gap: 0; -} - -.primary-navigation a, -.nav-action { - min-height: 2.6rem; - border-radius: 0.45rem; - font-weight: 550; -} - -.primary-navigation a[data-status="active"], -.sidebar-footer a[data-status="active"] { - background: var(--accent-soft); - color: var(--text); - font-weight: 700; -} - -.primary-navigation a:hover:not([data-status="active"]), -.more-sheet nav a:hover:not([data-status="active"]) { - background: color-mix(in srgb, var(--text) 5%, transparent); - color: var(--text); -} - -.primary-navigation a[aria-busy="true"], -.sidebar-footer a[aria-busy="true"], -.mobile-navigation a[aria-busy="true"], -.more-sheet nav a[aria-busy="true"] { - color: var(--accent); - text-decoration: underline; - text-decoration-color: var(--accent); - text-underline-offset: 0.35rem; -} - -.primary-navigation a[data-status="active"]::after, -.sidebar-footer a[data-status="active"]::after { - width: 0.2rem; - height: 1rem; - border-radius: 0.2rem; - background: var(--accent); -} - -.sidebar-footer { - flex: 0 0 auto; - gap: var(--space-2); - padding-top: var(--space-3); - border-top: 1px solid var(--line); - margin-top: 0; -} - -.account-chip { - padding: var(--space-2); - border: 0; -} - -main { - width: min(100%, 86rem); - min-width: 0; - padding: var(--space-12) clamp(var(--space-6), 4vw, var(--space-16)) - var(--space-20); -} - -main > section { - min-width: 0; -} - -.page-header { - align-items: end; - gap: var(--space-6); - margin-bottom: var(--space-8); - padding-bottom: var(--space-6); -} - -.page-header h2 { - font-size: clamp(var(--text-2xl), 3.3vw, var(--text-4xl)); - line-height: 1.04; -} - -.page-header p { - font-size: var(--text-sm); -} - -.page-controls { - max-width: 100%; - gap: var(--space-3); -} - -.page-controls > .action-primary { - align-self: flex-end; -} - -.control > span, -.theme-picker span, -.language-picker span, -.library-picker > span { - font-size: var(--text-xs); - font-weight: 650; - letter-spacing: 0.06em; - text-transform: none; -} - -.control select, -.control input { - min-height: 2.65rem; -} - -.page-header .control input, -.page-header .control select { - border: 0; - border-bottom: 1px solid var(--line-strong); - border-radius: 0; - background: transparent; - padding-left: 0; -} - -.page-header .control input:hover, -.page-header .control select:hover, -.page-header .control input:focus, -.page-header .control select:focus { - border-bottom-color: var(--accent); -} - -.grid, -.skeleton-grid { - grid-template-columns: repeat(auto-fill, minmax(10.75rem, 1fr)); - gap: var(--space-8) var(--space-6); -} - -.grid a { - gap: 0; -} - -.grid strong { - margin-top: var(--space-3); - font-size: var(--text-base); - font-weight: 650; -} - -.grid span { - margin-top: var(--space-1); - font-size: var(--text-sm); -} - -.cover { - border: 1px solid var(--line); - border-radius: 0.55rem; - background: var(--panel-soft); - box-shadow: none; -} - -.cover-fallback { - position: relative; - isolation: isolate; - background: color-mix(in srgb, var(--panel) 94%, var(--accent) 6%); -} - -.cover-fallback::before { - position: absolute; - width: 70%; - aspect-ratio: 1; - border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); - border-radius: 50%; - background: repeating-radial-gradient( - circle, - color-mix(in srgb, var(--text) 8%, transparent) 0 1px, - transparent 1px 6px - ); - content: ""; -} - -.cover-fallback::after { - position: absolute; - width: 17%; - aspect-ratio: 1; - border: 1px solid color-mix(in srgb, var(--text) 20%, transparent); - border-radius: 50%; - background: var(--panel-soft); - content: ""; -} - -.cover-fallback span { - position: absolute; - right: var(--space-3); - bottom: var(--space-2); - z-index: 1; - margin: 0; - color: var(--text); - font-family: var(--font-display); - font-size: var(--text-xs); - font-weight: 700; - opacity: 0.55; -} - -.grid li:hover .cover { - box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, #060a07 16%, transparent); -} - -.card-actions { - top: var(--space-2); - right: var(--space-2); -} - -.card-action { - background: color-mix(in srgb, var(--bg) 88%, transparent); -} - -.collection, -.admin-panel, -.user-row, -.empty-state, -.error-state, -.settings-panel, -.import-panel { - border: 1px solid var(--line); - border-radius: 0.65rem; - background: var(--panel); -} - -.collection, -.admin-panel, -.user-row, -.settings-panel { - padding: var(--space-6); -} - -.settings-grid, -.admin-grid { - gap: var(--space-6); - margin-bottom: var(--space-6); -} - -.admin-page .admin-grid { - align-items: start; - gap: var(--space-8); - margin-bottom: var(--space-8); -} - -.admin-page .admin-grid > .admin-panel, -.admin-page .user-row, -.admin-page .now-playing-panel { - background: var(--panel-soft); -} - -.admin-page .stacked-form > button[type="submit"] { - justify-self: start; - padding-inline: var(--space-6); -} - -.admin-page .credential-form > button[type="submit"] { - border-color: var(--line-strong); - border-radius: 999px; - background: transparent; - color: var(--text); -} - -.admin-page .credential-form > button[type="submit"]:hover { - border-color: var(--accent); - background: var(--accent-soft); -} - -.admin-page > .stack { - gap: var(--space-6); - margin-bottom: var(--space-8); -} - -.admin-page .now-playing-panel > h3, -.admin-page .admin-grid > .admin-panel > h3 { - margin-bottom: var(--space-4); -} - -.admin-page .token-panel { - margin-top: var(--space-4); - border-color: var(--line); - background: transparent; -} - -/* The outer list's generic row rule must not add a second divider beneath - the membership list's own final row. */ -.admin-page .resource-list > .member-row { - padding: 0; - border-bottom: 0; -} - -.settings-panel { - gap: var(--space-3); -} - -.settings-panel p { - margin: 0; -} - -.settings-panel .inline-form { - margin: var(--space-2) 0; -} - -.settings-page .settings-grid { - align-items: start; - gap: var(--space-8); - margin-bottom: var(--space-12); -} - -.settings-page .settings-panel { - gap: var(--space-4); - padding: var(--space-6) 0 0; - border: 0; - border-top: 1px solid var(--line-strong); - border-radius: 0; - background: transparent; -} - -.settings-page > .settings-panel + .settings-panel { - margin-top: var(--space-12); -} - -.settings-page .settings-panel p { - max-width: 52rem; -} - -.settings-page .theme-picker, -.settings-page .language-picker { - max-width: 25rem; -} - -/* ReplayGain (RFC-022): the mode is a picker like the theme; the preamp is a - slider beside it, labelled like the pickers so the panel reads as one form, - with its value in ordinary case — "+3 dB", not "+3 DB". */ -.replay-gain-picker { - display: grid; - gap: var(--space-3); -} - -.replay-gain-preamp { - display: grid; - max-width: 25rem; - gap: var(--space-1); -} - -.replay-gain-preamp-heading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-3); -} - -.replay-gain-preamp-heading label { - color: var(--muted); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.replay-gain-preamp-heading output { - color: var(--muted); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -.settings-page .settings-panel > a { - justify-self: start; - color: var(--accent); - text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); - text-underline-offset: 0.3rem; -} - -.settings-page .settings-panel > a:hover { - text-decoration-color: currentcolor; -} - -.settings-page .inline-form { - max-width: 46rem; -} - -.settings-page .settings-panel > button.danger { - justify-self: start; -} - -.list li, -.queue-list li, -.resource-list li { - padding-top: var(--space-4); - padding-bottom: var(--space-4); -} - -/* Saved playback positions read as a compact track list. The fixed time - column keeps every timestamp aligned regardless of title length. */ -.playing-bookmarks { - margin-top: var(--space-8); - padding-top: var(--space-6); - border-top: 1px solid var(--line-strong); -} - -.playing-bookmarks-header { - margin-bottom: var(--space-4); -} - -.playing-bookmarks-title, -.playing-bookmarks-detail { - margin: 0; -} - -.playing-bookmarks-detail { - margin-top: var(--space-1); -} - -.bookmark-list { - padding: 0; - margin: 0; - list-style: none; -} - -.bookmark-list li { - display: grid; - grid-template-columns: minmax(0, 1fr) 4.5rem 5rem; - align-items: center; - gap: var(--space-4); - min-height: 4.75rem; - padding: var(--space-2) 0; - border-bottom: 1px solid var(--line); -} - -.bookmark-track { - display: flex; - align-items: center; - gap: var(--space-3); - min-width: 0; - padding: 0; - border: 0; - background: transparent; - color: var(--text); - text-align: left; -} - -.bookmark-cover { - width: 3rem; - height: 3rem; - flex: 0 0 3rem; - border-radius: 0.4rem; -} - -.bookmark-cover.cover-fallback span { - font-size: var(--text-xl); -} - -.bookmark-copy { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.bookmark-title, -.bookmark-artist { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bookmark-artist { - font-size: var(--text-xs); -} - -.bookmark-track:hover .bookmark-title { - color: var(--accent); -} - -.bookmark-position { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.bookmark-forget { - min-height: 2.5rem; - padding: 0 var(--space-2); - border: 0; - background: transparent; - color: var(--danger); - font-size: var(--text-sm); - text-align: right; -} - -.bookmark-forget:hover { - background: transparent; - text-decoration: underline; - text-underline-offset: 0.2rem; -} - -/* Queue rows use the same compact artwork rhythm as saved positions. Artist - lives with the title so the duration and action stay in stable columns. */ -.queue-list li { - grid-template-columns: minmax(0, 1fr) 4.5rem 5rem; - gap: var(--space-4); - min-height: 4.75rem; - padding: var(--space-2) 0; -} - -.queue-track { - display: flex; - align-items: center; - gap: var(--space-3); - min-width: 0; - padding: 0; - border: 0; - background: transparent; - color: var(--text); - text-align: left; -} - -.queue-cover { - width: 3rem; - height: 3rem; - flex: 0 0 3rem; - border-radius: 0.4rem; -} - -.queue-cover.cover-fallback span { - font-size: var(--text-xl); -} - -.queue-copy { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.queue-song-title, -.queue-artist { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.queue-artist { - font-size: var(--text-xs); -} - -.queue-track:hover .queue-song-title, -.queue-list li.active .queue-song-title { - color: var(--accent); -} - -.queue-duration { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.queue-remove { - min-height: 2.5rem; - padding: 0 var(--space-2); - border: 0; - background: transparent; - color: var(--danger); - font-size: var(--text-sm); - text-align: right; -} - -.queue-remove:hover { - background: transparent; - text-decoration: underline; - text-underline-offset: 0.2rem; -} - -@media (width <= 35rem) { - .bookmark-list li, - .queue-list li { - grid-template-columns: minmax(0, 1fr) 3.25rem auto; - gap: var(--space-2); - } - - .bookmark-cover, - .queue-cover { - width: 2.5rem; - height: 2.5rem; - flex-basis: 2.5rem; - } -} - -.songs tr.active .muted, -.songs tr.active small { - color: var(--text); -} - -.detail-header, -.artist-hero { - background: var(--panel-soft); -} - -/* Album pages borrow their colour from the sleeve. The art remains the focal - point while a blurred copy gives the header depth without a fixed grey card. */ -.album-hero { - position: relative; - isolation: isolate; - overflow: hidden; - margin: calc(var(--space-12) * -1) - calc(clamp(var(--space-6), 4vw, var(--space-16)) * -1) 0; - background: #242b29; - color: #fff; -} - -.album-hero-ambient.cover { - position: absolute; - z-index: -2; - inset: -35%; - width: 170%; - height: 170%; - max-width: none; - border: 0; - border-radius: 0; - object-fit: cover; - filter: blur(5rem) saturate(1.5); - opacity: 0.85; - pointer-events: none; -} - -.album-hero::before { - position: absolute; - z-index: -1; - inset: 0; - background: linear-gradient( - 90deg, - rgb(11 16 16 / 36%) 0%, - rgb(11 16 16 / 56%) 58%, - rgb(11 16 16 / 69%) 100% - ); - content: ""; -} - -.album-hero-inner { - display: flex; - min-height: 21rem; - align-items: end; - gap: clamp(1.5rem, 4vw, 3.5rem); - padding: 3.5rem clamp(var(--space-6), 4vw, var(--space-16)) 2.75rem; -} - -.album-hero-cover.cover { - width: clamp(11rem, 22vw, 17rem); - flex: 0 0 auto; - border: 1px solid rgb(255 255 255 / 12%); - border-radius: 0.35rem; - box-shadow: 0 1.4rem 3.5rem rgb(0 0 0 / 35%); -} - -.album-hero-copy { - min-width: 0; - padding-bottom: 0.2rem; -} - -.album-type { - display: block; - margin-bottom: 0.55rem; - color: rgb(255 255 255 / 83%); - font-size: var(--text-xs); - font-weight: 750; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.album-hero h2 { - max-width: 20ch; - margin: 0 0 1rem; - font-size: clamp(3rem, 6vw, 5.75rem); - line-height: 0.98; - overflow-wrap: anywhere; - text-wrap: balance; -} - -.album-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.25rem 0; - margin: 0; - color: rgb(255 255 255 / 85%); - font-size: var(--text-sm); - font-variant-numeric: tabular-nums; -} - -.album-meta > * + *::before { - margin: 0 0.55rem; - color: rgb(255 255 255 / 58%); - content: "·"; -} - -.album-artist-link { - color: #fff; - font-weight: 750; - text-decoration: none; -} - -.album-artist-link:hover { - color: #fff; - text-decoration: underline; - text-underline-offset: 0.2rem; -} - -.album-content { - padding-top: var(--space-6); -} - -.album-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-4); - padding-bottom: var(--space-6); -} - -.album-actions button { - display: inline-flex; - min-height: 2.9rem; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: 0 var(--space-6); -} - -.album-actions .album-play { - border-color: var(--accent); - background: var(--accent); - color: var(--bg); -} - -.album-actions .album-play:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--accent) 82%, white 18%); - background: color-mix(in srgb, var(--accent) 82%, white 18%); -} - -.album-actions .album-enqueue { - border-color: var(--line-strong); - color: var(--text); -} - -.album-actions .album-enqueue:hover:not(:disabled) { - border-color: var(--accent); - color: var(--accent); -} - -.album-songs { - table-layout: fixed; -} - -.album-songs th { - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--line-strong); - color: var(--muted); - font-size: var(--text-xs); - font-weight: 600; - text-align: left; -} - -.album-songs th.index, -.album-songs td.index { - width: 3.25rem; - text-align: center; -} - -.album-songs .album-duration-heading, -.album-songs .song-duration { - width: 5.5rem; - text-align: right; - font-variant-numeric: tabular-nums; -} - -.album-songs .album-actions-heading, -.album-songs .song-marks { - width: 11.5rem; - text-align: right; -} - -.album-songs tbody tr { - height: 3.85rem; -} - -.album-songs td { - border-bottom-color: color-mix(in srgb, var(--line) 62%, transparent); -} - -.album-songs .song-title { - max-width: 100%; - color: var(--text); - font-weight: 650; -} - -.album-songs .song-title > span { - min-width: 0; - text-align: left; -} - -.album-songs .song-title small { - font-weight: 400; -} - -.album-songs .song-title:hover, -.album-songs tr.active .song-title { - color: var(--accent); -} - -.album-related { - margin-top: var(--space-16); -} - -.album-related h3 { - margin: 0 0 var(--space-6); - font-size: var(--text-2xl); -} - -.album-related-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); - gap: var(--space-6); -} - -.album-related-grid a { - display: grid; - align-content: start; - gap: var(--space-1); - min-width: 0; - color: var(--text); - text-decoration: none; -} - -.album-related-grid a:hover strong { - color: var(--accent); -} - -.album-related-grid .cover { - width: 100%; - margin-bottom: var(--space-2); - border-radius: 0.35rem; -} - -.album-related-grid strong { - overflow: hidden; - font-size: var(--text-sm); - line-height: 1.3; - text-overflow: ellipsis; - white-space: nowrap; -} - -.album-related-grid span { - color: var(--muted); - font-size: var(--text-xs); -} - -@media (max-width: 820px) { - .album-hero { - margin: calc(var(--space-6) * -1) calc(var(--space-4) * -1) 0; - } - - .album-hero-inner { - min-height: 0; - align-items: center; - padding: var(--space-12) var(--space-4) var(--space-8); - } - - .album-hero-cover.cover { - width: clamp(7.5rem, 28vw, 11rem); - } - - .album-hero h2 { - font-size: clamp(2.25rem, 7vw, 4rem); - } - - .songs.album-songs td.song-duration { - display: table-cell; - } -} - -@media (width <= 35rem) { - .album-hero-inner { - display: grid; - justify-items: start; - gap: var(--space-6); - } - - .album-hero-cover.cover { - width: min(50vw, 10rem); - } - - .album-hero h2 { - margin-bottom: var(--space-3); - } - - .album-songs .album-duration-heading, - .album-songs .song-duration { - display: table-cell; - width: 3.7rem; - } - - .album-songs { - min-width: 22rem; - } - - .album-songs .album-actions-heading, - .album-songs .song-marks { - width: 9.25rem; - } - - .album-songs .album-actions-heading { - font-size: 0; - } - - .album-songs th.index, - .album-songs td.index { - width: 2rem; - } -} - -.login-shell { - border-radius: 0.7rem; - background: var(--panel); -} - -.login-story { - background: var(--panel-soft); -} - -.login-story::after { - display: none; -} - -.signal { - height: 1.6rem; - background: repeating-linear-gradient( - 90deg, - var(--accent) 0 2px, - transparent 2px 9px - ); - opacity: 0.5; -} - -.player { - border-radius: 0.7rem; - background: var(--panel); - backdrop-filter: none; -} - -.player-track { - line-height: 1.35; -} - -.player-track strong { - font-weight: 700; -} - -@media (width <= 72rem) and (width > 820px) { - .page-header { - flex-direction: column; - align-items: stretch; - } -} - -@media (max-width: 820px) { - .settings-page .settings-grid { - grid-template-columns: minmax(0, 1fr); - } - - .sidebar { - display: none; - } - - .shell { - padding: var(--space-20) 0 14rem; - } - - .mobile-header { - background: var(--panel-soft); - backdrop-filter: none; - } - - main { - padding: var(--space-6) var(--space-4) var(--space-16); - } - - .page-header { - display: grid; - gap: var(--space-4); - } - - .page-controls { - justify-content: start; - } - - .grid, - .skeleton-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-8) var(--space-4); - } - - .mobile-navigation { - border-radius: 0.7rem; - background: var(--panel); - backdrop-filter: none; - } - - .player { - grid-template-columns: 3.2rem minmax(0, 1fr) auto; - grid-template-rows: minmax(3.2rem, auto) auto; - gap: var(--space-2); - min-height: 0; - padding: var(--space-3); - } - - .player-progress { - position: static; - display: block; - grid-column: 1 / -1; - grid-row: 2; - min-width: 0; - height: 1rem; - } - - .player-progress input { - display: block; - min-height: 1rem; - } - - .player-track small { - display: -webkit-box; - max-height: 2.7em; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - white-space: normal; - } -} - -@media (max-width: 40rem) { - .credential-form > input, - .inline-form > input { - min-width: 0; - } - - .page-controls > .control { - flex: 1 1 10rem; - } - - .page-controls .control select, - .page-controls .control input { - width: 100%; - } -} - -@media (prefers-reduced-motion: reduce) { - .grid li:hover .cover { - transform: none; - } -} diff --git a/webapp/src/styles/refresh/login-player-responsive.css b/webapp/src/styles/refresh/login-player-responsive.css new file mode 100644 index 00000000..39f6e76e --- /dev/null +++ b/webapp/src/styles/refresh/login-player-responsive.css @@ -0,0 +1,143 @@ +/* Sign-in, the player bar, then the narrow layouts and reduced motion. */ + +.login-shell { + border-radius: 0.7rem; + background: var(--panel); +} + +.login-story { + background: var(--panel-soft); +} + +.login-story::after { + display: none; +} + +.signal { + height: 1.6rem; + background: repeating-linear-gradient( + 90deg, + var(--accent) 0 2px, + transparent 2px 9px + ); + opacity: 0.5; +} + +.player { + border-radius: 0.7rem; + background: var(--panel); + backdrop-filter: none; +} + +.player-track { + line-height: 1.35; +} + +.player-track strong { + font-weight: 700; +} + +@media (width <= 72rem) and (width > 820px) { + .page-header { + flex-direction: column; + align-items: stretch; + } +} + +@media (max-width: 820px) { + .settings-page .settings-grid { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar { + display: none; + } + + .shell { + padding: var(--space-20) 0 14rem; + } + + .mobile-header { + background: var(--panel-soft); + backdrop-filter: none; + } + + main { + padding: var(--space-6) var(--space-4) var(--space-16); + } + + .page-header { + display: grid; + gap: var(--space-4); + } + + .page-controls { + justify-content: start; + } + + .grid, + .skeleton-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-8) var(--space-4); + } + + .mobile-navigation { + border-radius: 0.7rem; + background: var(--panel); + backdrop-filter: none; + } + + .player { + grid-template-columns: 3.2rem minmax(0, 1fr) auto; + grid-template-rows: minmax(3.2rem, auto) auto; + gap: var(--space-2); + min-height: 0; + padding: var(--space-3); + } + + .player-progress { + position: static; + display: block; + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; + height: 1rem; + } + + .player-progress input { + display: block; + min-height: 1rem; + } + + .player-track small { + display: -webkit-box; + max-height: 2.7em; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + white-space: normal; + } +} + +@media (max-width: 40rem) { + .credential-form > input, + .inline-form > input { + min-width: 0; + } + + .page-controls > .control { + flex: 1 1 10rem; + } + + .page-controls .control select, + .page-controls .control input { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .grid li:hover .cover { + transform: none; + } +} diff --git a/webapp/src/styles/refresh/queue.css b/webapp/src/styles/refresh/queue.css new file mode 100644 index 00000000..fc4e7bd6 --- /dev/null +++ b/webapp/src/styles/refresh/queue.css @@ -0,0 +1,203 @@ +/* Saved playback positions and the queue's rows. */ + +/* Saved playback positions read as a compact track list. The fixed time + column keeps every timestamp aligned regardless of title length. */ +.playing-bookmarks { + margin-top: var(--space-8); + padding-top: var(--space-6); + border-top: 1px solid var(--line-strong); +} + +.playing-bookmarks-header { + margin-bottom: var(--space-4); +} + +.playing-bookmarks-title, +.playing-bookmarks-detail { + margin: 0; +} + +.playing-bookmarks-detail { + margin-top: var(--space-1); +} + +.bookmark-list { + padding: 0; + margin: 0; + list-style: none; +} + +.bookmark-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) 4.5rem 5rem; + align-items: center; + gap: var(--space-4); + min-height: 4.75rem; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--line); +} + +.bookmark-track { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; +} + +.bookmark-cover { + width: 3rem; + height: 3rem; + flex: 0 0 3rem; + border-radius: 0.4rem; +} + +.bookmark-cover.cover-fallback span { + font-size: var(--text-xl); +} + +.bookmark-copy { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.bookmark-title, +.bookmark-artist { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bookmark-artist { + font-size: var(--text-xs); +} + +.bookmark-track:hover .bookmark-title { + color: var(--accent); +} + +.bookmark-position { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.bookmark-forget { + min-height: 2.5rem; + padding: 0 var(--space-2); + border: 0; + background: transparent; + color: var(--danger); + font-size: var(--text-sm); + text-align: right; +} + +.bookmark-forget:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +/* Queue rows use the same compact artwork rhythm as saved positions. Artist + lives with the title so the duration and action stay in stable columns. */ +.queue-list li { + grid-template-columns: minmax(0, 1fr) 4.5rem 5rem; + gap: var(--space-4); + min-height: 4.75rem; + padding: var(--space-2) 0; +} + +.queue-track { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; +} + +.queue-cover { + width: 3rem; + height: 3rem; + flex: 0 0 3rem; + border-radius: 0.4rem; +} + +.queue-cover.cover-fallback span { + font-size: var(--text-xl); +} + +.queue-copy { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.queue-song-title, +.queue-artist { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-artist { + font-size: var(--text-xs); +} + +.queue-track:hover .queue-song-title, +.queue-list li.active .queue-song-title { + color: var(--accent); +} + +.queue-duration { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.queue-remove { + min-height: 2.5rem; + padding: 0 var(--space-2); + border: 0; + background: transparent; + color: var(--danger); + font-size: var(--text-sm); + text-align: right; +} + +.queue-remove:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +@media (width <= 35rem) { + .bookmark-list li, + .queue-list li { + grid-template-columns: minmax(0, 1fr) 3.25rem auto; + gap: var(--space-2); + } + + .bookmark-cover, + .queue-cover { + width: 2.5rem; + height: 2.5rem; + flex-basis: 2.5rem; + } +} + +.songs tr.active .muted, +.songs tr.active small { + color: var(--text); +} + +.detail-header, +.artist-hero { + background: var(--panel-soft); +} diff --git a/webapp/src/styles/refresh/shell.css b/webapp/src/styles/refresh/shell.css new file mode 100644 index 00000000..434a251f --- /dev/null +++ b/webapp/src/styles/refresh/shell.css @@ -0,0 +1,163 @@ +/* The shell, the sidebar and the page header. */ + +.shell { + padding-bottom: 7.5rem; +} + +.sidebar { + display: flex; + flex-direction: column; + min-height: 0; + padding: var(--space-6) var(--space-4) var(--space-4); + background: var(--panel-soft); + backdrop-filter: none; +} + +.brand { + gap: var(--space-2); + padding-inline: var(--space-3); + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: -0.05em; +} + +.brand small { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.1em; +} + +.library-picker { + margin: var(--space-2) 0 var(--space-3); +} + +.primary-navigation { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--line-strong) transparent; + margin: 0 calc(var(--space-2) * -1) 0 0; + padding-right: var(--space-2); + align-content: start; + gap: 0; +} + +.primary-navigation a, +.nav-action { + min-height: 2.6rem; + border-radius: 0.45rem; + font-weight: 550; +} + +.primary-navigation a[data-status="active"], +.sidebar-footer a[data-status="active"] { + background: var(--accent-soft); + color: var(--text); + font-weight: 700; +} + +.primary-navigation a:hover:not([data-status="active"]), +.more-sheet nav a:hover:not([data-status="active"]) { + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text); +} + +.primary-navigation a[aria-busy="true"], +.sidebar-footer a[aria-busy="true"], +.mobile-navigation a[aria-busy="true"], +.more-sheet nav a[aria-busy="true"] { + color: var(--accent); + text-decoration: underline; + text-decoration-color: var(--accent); + text-underline-offset: 0.35rem; +} + +.primary-navigation a[data-status="active"]::after, +.sidebar-footer a[data-status="active"]::after { + width: 0.2rem; + height: 1rem; + border-radius: 0.2rem; + background: var(--accent); +} + +.sidebar-footer { + flex: 0 0 auto; + gap: var(--space-2); + padding-top: var(--space-3); + border-top: 1px solid var(--line); + margin-top: 0; +} + +.account-chip { + padding: var(--space-2); + border: 0; +} + +main { + width: min(100%, 86rem); + min-width: 0; + padding: var(--space-12) clamp(var(--space-6), 4vw, var(--space-16)) + var(--space-20); +} + +main > section { + min-width: 0; +} + +.page-header { + align-items: end; + gap: var(--space-6); + margin-bottom: var(--space-8); + padding-bottom: var(--space-6); +} + +.page-header h2 { + font-size: clamp(var(--text-2xl), 3.3vw, var(--text-4xl)); + line-height: 1.04; +} + +.page-header p { + font-size: var(--text-sm); +} + +.page-controls { + max-width: 100%; + gap: var(--space-3); +} + +.page-controls > .action-primary { + align-self: flex-end; +} + +.control > span, +.theme-picker span, +.language-picker span, +.library-picker > span { + font-size: var(--text-xs); + font-weight: 650; + letter-spacing: 0.06em; + text-transform: none; +} + +.control select, +.control input { + min-height: 2.65rem; +} + +.page-header .control input, +.page-header .control select { + border: 0; + border-bottom: 1px solid var(--line-strong); + border-radius: 0; + background: transparent; + padding-left: 0; +} + +.page-header .control input:hover, +.page-header .control select:hover, +.page-header .control input:focus, +.page-header .control select:focus { + border-bottom-color: var(--accent); +} diff --git a/webapp/src/styles/refresh/surfaces.css b/webapp/src/styles/refresh/surfaces.css new file mode 100644 index 00000000..b6eb2d55 --- /dev/null +++ b/webapp/src/styles/refresh/surfaces.css @@ -0,0 +1,264 @@ +/* Tiles, panels, the admin and settings pages, and list rows. */ + +.grid, +.skeleton-grid { + grid-template-columns: repeat(auto-fill, minmax(10.75rem, 1fr)); + gap: var(--space-8) var(--space-6); +} + +.grid a { + gap: 0; +} + +.grid strong { + margin-top: var(--space-3); + font-size: var(--text-base); + font-weight: 650; +} + +.grid span { + margin-top: var(--space-1); + font-size: var(--text-sm); +} + +.cover { + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--panel-soft); + box-shadow: none; +} + +.cover-fallback { + position: relative; + isolation: isolate; + background: color-mix(in srgb, var(--panel) 94%, var(--accent) 6%); +} + +.cover-fallback::before { + position: absolute; + width: 70%; + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + border-radius: 50%; + background: repeating-radial-gradient( + circle, + color-mix(in srgb, var(--text) 8%, transparent) 0 1px, + transparent 1px 6px + ); + content: ""; +} + +.cover-fallback::after { + position: absolute; + width: 17%; + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--text) 20%, transparent); + border-radius: 50%; + background: var(--panel-soft); + content: ""; +} + +.cover-fallback span { + position: absolute; + right: var(--space-3); + bottom: var(--space-2); + z-index: 1; + margin: 0; + color: var(--text); + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: 700; + opacity: 0.55; +} + +.grid li:hover .cover { + box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, #060a07 16%, transparent); +} + +.card-actions { + top: var(--space-2); + right: var(--space-2); +} + +.card-action { + background: color-mix(in srgb, var(--bg) 88%, transparent); +} + +.collection, +.admin-panel, +.user-row, +.empty-state, +.error-state, +.settings-panel, +.import-panel { + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--panel); +} + +.collection, +.admin-panel, +.user-row, +.settings-panel { + padding: var(--space-6); +} + +.settings-grid, +.admin-grid { + gap: var(--space-6); + margin-bottom: var(--space-6); +} + +.admin-page .admin-grid { + align-items: start; + gap: var(--space-8); + margin-bottom: var(--space-8); +} + +.admin-page .admin-grid > .admin-panel, +.admin-page .user-row, +.admin-page .now-playing-panel { + background: var(--panel-soft); +} + +.admin-page .stacked-form > button[type="submit"] { + justify-self: start; + padding-inline: var(--space-6); +} + +.admin-page .credential-form > button[type="submit"] { + border-color: var(--line-strong); + border-radius: 999px; + background: transparent; + color: var(--text); +} + +.admin-page .credential-form > button[type="submit"]:hover { + border-color: var(--accent); + background: var(--accent-soft); +} + +.admin-page > .stack { + gap: var(--space-6); + margin-bottom: var(--space-8); +} + +.admin-page .now-playing-panel > h3, +.admin-page .admin-grid > .admin-panel > h3 { + margin-bottom: var(--space-4); +} + +.admin-page .token-panel { + margin-top: var(--space-4); + border-color: var(--line); + background: transparent; +} + +/* The outer list's generic row rule must not add a second divider beneath + the membership list's own final row. */ +.admin-page .resource-list > .member-row { + padding: 0; + border-bottom: 0; +} + +.settings-panel { + gap: var(--space-3); +} + +.settings-panel p { + margin: 0; +} + +.settings-panel .inline-form { + margin: var(--space-2) 0; +} + +.settings-page .settings-grid { + align-items: start; + gap: var(--space-8); + margin-bottom: var(--space-12); +} + +.settings-page .settings-panel { + gap: var(--space-4); + padding: var(--space-6) 0 0; + border: 0; + border-top: 1px solid var(--line-strong); + border-radius: 0; + background: transparent; +} + +.settings-page > .settings-panel + .settings-panel { + margin-top: var(--space-12); +} + +.settings-page .settings-panel p { + max-width: 52rem; +} + +.settings-page .theme-picker, +.settings-page .language-picker { + max-width: 25rem; +} + +/* ReplayGain (RFC-022): the mode is a picker like the theme; the preamp is a + slider beside it, labelled like the pickers so the panel reads as one form, + with its value in ordinary case — "+3 dB", not "+3 DB". */ +.replay-gain-picker { + display: grid; + gap: var(--space-3); +} + +.replay-gain-preamp { + display: grid; + max-width: 25rem; + gap: var(--space-1); +} + +.replay-gain-preamp-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); +} + +.replay-gain-preamp-heading label { + color: var(--muted); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.replay-gain-preamp-heading output { + color: var(--muted); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; +} + +.settings-page .settings-panel > a { + justify-self: start; + color: var(--accent); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); + text-underline-offset: 0.3rem; +} + +.settings-page .settings-panel > a:hover { + text-decoration-color: currentcolor; +} + +.settings-page .inline-form { + max-width: 46rem; +} + +.settings-page .settings-panel > button.danger { + justify-self: start; +} + +.list li, +.queue-list li, +.resource-list li { + padding-top: var(--space-4); + padding-bottom: var(--space-4); +} diff --git a/webapp/src/styles/sheets.ts b/webapp/src/styles/sheets.ts new file mode 100644 index 00000000..2b9b8bf9 --- /dev/null +++ b/webapp/src/styles/sheets.ts @@ -0,0 +1,44 @@ +// The three stylesheets as the tests read them: each entry with its parts inlined. + +/** + * An entry sheet is only a list of `@import`s, which Vite resolves at build + * time and `?raw` does not: read raw, `styles.css` would hand a test a list of + * paths and not one rule. So each entry is rebuilt here from its parts, in the + * order it imports them, which is the order the cascade sees. + * + * **Every part is read, or this throws.** A part no entry imports ships + * nowhere and would be checked by nothing; one an entry names that does not + * exist would leave a hole. Both fail at import, before any assertion runs. + */ +const entries = import.meta.glob("./*.css", { + query: "?raw", + import: "default", + eager: true, +}); +const parts = import.meta.glob("./*/*.css", { + query: "?raw", + import: "default", + eager: true, +}); + +const IMPORT = /^@import "(\.\/[^"]+)";$/gm; +const unread = new Set(Object.keys(parts)); + +function inline(entry: string): string { + const source = entries[entry]; + if (source === undefined) throw new Error(`no stylesheet ${entry}`); + return source.replace(IMPORT, (_line, path: string) => { + const part = parts[path]; + if (part === undefined) throw new Error(`${entry} imports ${path}`); + unread.delete(path); + return part; + }); +} + +export const styles = inline("./styles.css"); +export const refresh = inline("./refresh.css"); +export const fluid = inline("./fluid.css"); + +if (unread.size > 0) { + throw new Error(`no entry imports ${[...unread].join(", ")}`); +} diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css new file mode 100644 index 00000000..24e121a8 --- /dev/null +++ b/webapp/src/styles/styles.css @@ -0,0 +1,16 @@ +/* The base sheet, in parts. The cascade is their order: keep it. */ + +@import "./styles/base.css"; +@import "./styles/shell.css"; +@import "./styles/catalog.css"; +@import "./styles/panels.css"; +@import "./styles/player.css"; +@import "./styles/phone.css"; +@import "./styles/playing.css"; +@import "./styles/admin.css"; +@import "./styles/menus.css"; +@import "./styles/folders.css"; +@import "./styles/artist-hero.css"; +@import "./styles/maintenance.css"; +@import "./styles/mixes-and-stats.css"; +@import "./styles/workspace.css"; diff --git a/webapp/src/styles/styles.test.ts b/webapp/src/styles/styles.test.ts index 415faf5b..cb983c35 100644 --- a/webapp/src/styles/styles.test.ts +++ b/webapp/src/styles/styles.test.ts @@ -1,7 +1,7 @@ import postcss from "postcss"; import { describe, expect, it } from "vitest"; -import css from "./styles.css?raw"; +import { styles as css } from "./sheets"; /** * Inside the scale's range, the scale is what is used. diff --git a/webapp/src/styles/styles/admin.css b/webapp/src/styles/styles/admin.css new file mode 100644 index 00000000..feeed2ec --- /dev/null +++ b/webapp/src/styles/styles/admin.css @@ -0,0 +1,473 @@ +/* Library admin: scans, tokens, the tag editor, uploads, canvas, scrobbling. */ + +/* The scan panel sits above the grid: while one runs, it is the thing the + operator came for. */ +.scan-panel { + margin-bottom: var(--space-6); +} + +.scan-progress { + display: grid; + gap: var(--space-2); +} + +.scan-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-4); + font-size: var(--text-sm); +} + +.scan-progress progress { + width: 100%; + height: 0.4rem; + border: 0; + border-radius: 0.2rem; + accent-color: var(--accent); +} + +.scan-tally { + margin: 0; + font-size: var(--text-xs); +} + +/* A path is long and moves fast; it stays on one line and truncates rather + than reflowing the panel on every file. `direction: rtl` is what keeps the + ellipsis at the *start*, so the file name — the part that says where the + scan is — stays readable. + `unicode-bidi: plaintext` is what makes that safe: an RTL paragraph + reorders neutral characters, and a path is mostly neutrals. Without it the + separators and a trailing extension drift, so `/music/a.flac` could be shown + with its slashes in the wrong places. Plaintext takes the direction from the + first strong character in the string instead, which for a path is its first + letter. */ +.scan-path { + overflow: hidden; + margin: 0; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: var(--text-xs); + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + +.admin-panel .resource-list li > div { + display: grid; + min-width: 0; +} + +.admin-panel .resource-list small { + font-size: var(--text-xs); +} + +/* The token list is closed until asked for. Its heading is the control that + opens it, so the button has to read as the heading it sits in. */ +.token-panel h3 { + margin: 0; +} + +.token-panel h3 button { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + min-height: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; +} + +.token-panel h3 button::before { + color: var(--muted); + content: "▸"; + font-size: var(--text-xs); + transition: rotate 160ms ease; +} + +.token-panel h3 button[aria-expanded="true"]::before { + rotate: 90deg; +} + +.token-panel h3 button:hover { + background: none; + color: var(--accent); +} + +@media (prefers-reduced-motion: reduce) { + .token-panel h3 button::before { + transition: none; + } +} + +/* The membership panel is a row of the library list rather than a column of + its own: it belongs to one library, and there is one of them per library. */ +.member-row { + display: block; + padding: 0; + border: 0; +} + +.member-row .admin-panel { + padding: var(--space-2) 0; + border: 0; + background: none; +} + +.member-row .control { + min-width: 8rem; +} + +/* A correction is reached from the row it corrects. A link rather than a + button, because it goes somewhere, and in the marks cell beside the rating + and the heart because the table has no room for another column. */ +.row-action { + display: inline-flex; + align-items: center; + color: var(--muted); +} + +.row-action:hover { + color: var(--accent); +} + +/* The tag editor. One column: each field carries a second line — what the + file says beneath the correction — and two columns of those read as a table + nobody asked for. */ +.correction-page { + max-width: 44rem; +} + +.correction-detail { + margin: 0 0 var(--space-6); +} + +.correction-form { + display: grid; + gap: var(--space-4); +} + +.correction-field { + display: grid; + gap: var(--space-2); +} + +.correction-label { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--muted); + font-size: var(--text-xs); +} + +/* The base field rule predates any multi-line input, so the textarea is not in + it. It takes the same chrome, and grows downward only: it holds a list of + names, one per line. */ +.correction-field textarea { + min-height: 5.5rem; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--line-strong); + border-radius: 0.55rem; + background: color-mix(in srgb, var(--bg) 88%, transparent); + color: var(--text); + font: inherit; + resize: vertical; +} + +.correction-field :disabled { + opacity: 0.6; +} + +.correction-badge { + padding: 0.05rem var(--space-2); + border: 1px solid currentColor; + border-radius: 999px; + color: var(--accent); + font-size: var(--text-xs); + font-weight: 700; +} + +.correction-provenance { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1) var(--space-3); + margin: 0; + font-size: var(--text-xs); +} + +.correction-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); + margin-top: var(--space-2); +} + +.correction-actions p { + margin: 0; +} + +/* Uploads. The rows are the list's own; the progress bar takes the width the + name does not need, and a long file name wraps rather than pushing the bar + out of its row. */ +.upload-detail { + max-width: 44rem; + margin: 0 0 var(--space-6); +} + +.upload-pick { + display: inline-grid; + gap: var(--space-2); + color: var(--muted); + font-size: var(--text-xs); +} + +/* The playlist import, on the playlists page: a panel rather than a page, + because it is one file and one decision. */ +.import-panel { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-8); + padding: var(--space-4); + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--panel-soft); +} + +.import-panel h3, +.import-panel p { + margin: 0; +} + +.import-preview { + display: grid; + gap: var(--space-2); + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--line); +} + +/* The list of what would be lost. Scrollable rather than endless: a file from + another player can disagree with this catalogue on hundreds of lines, and a + report that pushes the Import button off the screen cannot be acted on. */ +.import-unresolved { + max-height: 14rem; + overflow-y: auto; + display: grid; + gap: var(--space-1); + font-size: var(--text-xs); +} + +.import-unresolved li { + display: flex; + align-items: baseline; + gap: var(--space-2); + flex-wrap: wrap; +} + +.import-unresolved code { + overflow-wrap: anywhere; +} + +.import-line { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.upload-formats { + margin: var(--space-2) 0 var(--space-6); + font-size: var(--text-xs); +} + +.upload-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(6rem, 12rem); + align-items: center; +} + +.upload-name { + overflow-wrap: anywhere; +} + +.upload-status { + color: var(--muted); + font-size: var(--text-sm); + white-space: nowrap; +} + +.upload-item.done .upload-status, +.upload-item.present .upload-status { + color: var(--accent); +} + +.upload-item.refused .upload-status, +.upload-item.failed .upload-status { + color: var(--danger); + white-space: normal; +} + +.upload-list progress { + width: 100%; + accent-color: var(--accent); +} + +@media (max-width: 40rem) { + .upload-list li { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-1); + } +} + +/* The canvas. On the playing page the loop lies over the cover, which keeps + the image's name and shows through until the loop can play; in the editor it + is a preview, shown whole rather than cropped to a square. */ +.canvas-stage { + position: relative; + flex: 0 0 auto; +} + +.canvas-video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 1.1rem; + object-fit: cover; + opacity: 0; + pointer-events: none; + transition: opacity 600ms ease; +} + +.canvas-video.ready { + opacity: 1; +} + +.canvas-panel { + display: grid; + gap: var(--space-3); + margin-top: var(--space-12); + padding-top: var(--space-6); + border-top: 1px solid var(--line); +} + +.canvas-panel h2 { + margin: 0; + font-size: var(--text-lg); +} + +.canvas-panel > p { + margin: 0; +} + +.canvas-preview { + width: min(100%, 14rem); + max-height: 22rem; + border: 1px solid var(--line); + border-radius: 0.85rem; + background: var(--panel); + object-fit: contain; +} + +.canvas-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); +} + +.canvas-actions p { + margin: 0; +} + +/* External scrobbling — RFC-010. + A row per instance: who it is, how its queue is doing, and the one gesture + it offers. The health word carries a colour of its own because three states + need three, and `--muted` would say "less important" about the one state + that is asking for attention. */ +.scrobble-row { + display: grid; + gap: var(--space-2); + align-items: start; + grid-template-columns: minmax(0, 2fr) minmax(0, 1.4fr) auto; +} + +.scrobble-identity, +.scrobble-counters { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.scrobble-counters { + font-size: var(--text-sm); +} + +.scrobble-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + justify-content: flex-end; +} + +/* The form inside a row carries the list's own spacing, not the bottom margin + `.inline-form` gives a form standing alone above a list. */ +.scrobble-row .inline-form { + margin-bottom: 0; +} + +.scrobble-row .inline-form input { + flex: 1 1 10rem; +} + +.badge { + justify-self: start; + padding: 0.1rem var(--space-2); + border: 1px solid currentcolor; + border-radius: 999px; + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.health-healthy { + color: var(--accent); +} + +.health-degraded { + color: var(--warn); +} + +.health-broken { + color: var(--danger); +} + +.scrobble-uncertain { + margin-top: var(--space-12); + padding-top: var(--space-6); + border-top: 1px solid var(--line); +} + +.scrobble-uncertain h3 { + margin: 0 0 var(--space-2); +} + +@media (width <= 60rem) { + /* One column on a phone. The three-column row would put a password field and + two buttons on the same line as the health word, and the buttons are what + the screen is for. */ + .scrobble-row { + grid-template-columns: minmax(0, 1fr); + } + + .scrobble-actions { + justify-content: flex-start; + } +} diff --git a/webapp/src/styles/styles/artist-hero.css b/webapp/src/styles/styles/artist-hero.css new file mode 100644 index 00000000..5c3876f6 --- /dev/null +++ b/webapp/src/styles/styles/artist-hero.css @@ -0,0 +1,220 @@ +/* The artist header's backdrop, its image controls and the Deezer picker. */ + +/* The artist header's backdrop (RFC-013): the banner chosen by hand, framed at + a fixed point as the desktop frames it, or else the portrait, blurred. The + gradient keeps the title legible over any picture. + + The portrait's blurred copy is only light, and takes the material's wash + (`--fe-wash`, in fluid.css): the dark ground and light text on a dark + theme, the paper and dark ink on a light one. The banner is a photograph, + which no wash of paper makes legible, so over it the scrim is dark and the + text light whatever the theme — and stronger than the portrait's, because + a pale sky under a 15 % scrim left the corner buttons and the kicker at + half the contrast AA asks for. + + A dialog opened from the header sits inside it in the tree and carries its + own colours, so nothing here reaches into one. */ +.artist-hero { + --hero-wash-from: 15%; + --hero-wash-mid: 50%; + --hero-wash-to: 82%; + --hero-wash-at: 55%; + + position: relative; + isolation: isolate; + overflow: hidden; +} + +:root:not(.dark) .artist-hero, +.artist-hero.has-banner { + --hero-wash-from: 30%; + --hero-wash-mid: 62%; + --hero-wash-to: 86%; + --hero-wash-at: 40%; +} + +.artist-hero.has-banner { + --fe-wash: #0b0b0e; + --fe-on-wash: #fff; + --fe-on-wash-2: rgb(255 255 255 / 88%); + --fe-on-wash-tone: rgb(0 0 0 / 58%); + --fe-on-wash-line: rgb(255 255 255 / 22%); + + min-height: clamp(18rem, 32vw, 27rem); +} + +.artist-hero.has-backdrop { + color: var(--fe-on-wash); +} + +.artist-hero.has-backdrop :is(.muted, .eyebrow):not(:where(dialog *)) { + color: var(--fe-on-wash-2); +} + +.artist-banner.cover { + position: absolute; + z-index: -2; + inset: 0; + width: 100%; + height: 100%; + max-width: none; + border: 0; + border-radius: 0; + object-fit: cover; + object-position: center 30%; + pointer-events: none; +} + +.artist-hero-ambient.cover { + position: absolute; + z-index: -2; + inset: -35%; + width: 170%; + height: 170%; + max-width: none; + border: 0; + border-radius: 0; + object-fit: cover; + filter: blur(5rem) saturate(1.3); + opacity: 0.6; + pointer-events: none; +} + +.artist-hero.has-backdrop::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--fe-wash) var(--hero-wash-from), transparent) 0%, + color-mix(in srgb, var(--fe-wash) var(--hero-wash-mid), transparent) + var(--hero-wash-at), + color-mix(in srgb, var(--fe-wash) var(--hero-wash-to), transparent) 100% + ); + content: ""; +} + +/* Out of the flow on a wide screen, in the header's top corner, so the + controls never narrow the title beside the portrait. */ +.artist-image-controls { + position: absolute; + top: var(--space-6); + right: clamp(var(--space-6), 4vw, var(--space-12)); + display: grid; + justify-items: end; + gap: var(--space-2); + max-width: 50%; +} + +.artist-image-button { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 2.25rem; + padding: var(--space-1) var(--space-3); + border-radius: 999px; + font-size: var(--text-sm); +} + +.artist-hero.has-backdrop .artist-image-button { + border: 1px solid var(--fe-on-wash-line); + background: var(--fe-on-wash-tone); + color: var(--fe-on-wash); + backdrop-filter: blur(6px); +} + +/* Editorial pages: the image actions behind one button. It holds the corner + the row of five used to fill, and says it is a menu by its dots as much as + by its name. */ +.artist-image-menu { + gap: var(--space-1); + padding-inline: var(--space-3) var(--space-4); +} + +.artist-image-notice { + margin: 0; + font-size: var(--text-sm); +} + +/* The Deezer picker borrows the folder picker's dialog, wider for a grid of + faces. */ +.deezer-picker[open] { + width: min(52rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); +} + +.deezer-search { + display: flex; + gap: var(--space-2); +} + +.deezer-search-input { + flex: 1; + min-width: 0; +} + +.deezer-search-button { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.deezer-candidates { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.deezer-candidate { + display: grid; + justify-items: center; + gap: var(--space-1); + width: 100%; + padding: var(--space-2); + text-align: center; +} + +.deezer-candidate-preview { + width: 100%; + aspect-ratio: 1; + border-radius: 50%; + object-fit: cover; +} + +.deezer-candidate-name { + max-width: 100%; + overflow-wrap: anywhere; + font-weight: 600; +} + +@media (max-width: 820px) { + .artist-hero { + flex-wrap: wrap; + } + + /* Stacked, the controls go under the name and the hero doubles in height + or more, as their rows wrap: the kicker then sits a fifth of the way down, + where the wash had barely begun. It reaches its middle strength there + instead — and, where the text is held to the stronger wash, starts at it, + since how far down the kicker lands depends on how many rows wrap. */ + .artist-hero, + :root:not(.dark) .artist-hero, + .artist-hero.has-banner { + --hero-wash-at: 22%; + } + + :root:not(.dark) .artist-hero, + .artist-hero.has-banner { + --hero-wash-from: var(--hero-wash-mid); + } + + .artist-image-controls { + position: static; + width: 100%; + max-width: none; + justify-items: start; + } +} diff --git a/webapp/src/styles/styles/base.css b/webapp/src/styles/styles/base.css index 7cf9b858..63382dfc 100644 --- a/webapp/src/styles/styles/base.css +++ b/webapp/src/styles/styles/base.css @@ -1,3 +1,5 @@ +/* Tokens, the reset, form controls and type: what every page stands on. */ + :root { --accent-50: #ecfdf5; --accent-100: #d1fae5; @@ -304,4279 +306,3 @@ h3 { .skip-link:focus { transform: translateY(0); } - -.shell { - display: grid; - grid-template-columns: var(--sidebar-width) minmax(0, 1fr); - min-height: 100dvh; - padding-bottom: 6.4rem; -} - -.sidebar { - position: fixed; - z-index: 5; - inset: 0 auto 0 0; - display: flex; - width: var(--sidebar-width); - flex-direction: column; - padding: var(--space-6) var(--space-4) 7.5rem; - border-right: 1px solid var(--line); - background: color-mix(in srgb, var(--panel-soft) 88%, transparent); - backdrop-filter: blur(1.5rem) saturate(120%); -} - -.brand { - display: flex; - align-items: center; - gap: var(--space-3); - min-height: 2.6rem; - padding-inline: var(--space-2); - font-family: var(--font-display); - font-size: var(--text-lg); - font-weight: 600; - letter-spacing: -0.03em; -} - -.brand small { - margin-left: auto; - color: var(--muted); - font-family: var(--font-body); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -/* The logo is the kit's file, never redrawn here: sizing and the choice of - version only. 2rem tall puts it at 131px wide, above the guide's 120px - minimum for the horizontal version, and its own viewBox carries the clear - space above and below the symbol. No filter, shadow or animation: the guide - rules all three out. - - The colour version on a light theme and the dark one on a dark theme, by the - class `applyTheme` sets on the root — the reader's choice, which the system - colour scheme does not know about. */ -.brand-logo { - width: auto; - height: 2rem; - flex: none; -} - -:root.dark .brand-logo-light, -:root:not(.dark) .brand-logo-dark { - display: none; -} - -/** - * What the file is, under the track's names on the playing screen. - * - * Five discrete steps rather than a filled proportion: the bands are a - * summary of fields that do not share one scale — a bitrate and a bit depth - * are not points on the same line — and a continuous bar would be claiming a - * precision none of them carry. - */ -.file-quality { - display: grid; - gap: var(--space-1); - margin: var(--space-3) 0; -} - -.quality-meter { - display: flex; - gap: var(--space-1); - align-items: center; -} - -.quality-meter span { - width: 1.4rem; - height: 0.3rem; - border-radius: 0.15rem; - background: color-mix(in srgb, var(--text) 16%, transparent); -} - -.quality-meter span.on { - background: var(--accent-400); -} - -.file-facts { - margin: 0; - font-size: var(--text-sm); -} - -/** - * The Settings page: two short panels side by side, and the clients below - * them at full width because that list is the reason to come here. - */ -.settings-grid { - display: grid; - gap: var(--space-4); - grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); - margin-bottom: var(--space-4); -} - -.settings-panel { - background: var(--panel-soft); - border: 1px solid var(--line); - border-radius: 0.75rem; - padding: var(--space-4); - display: grid; - gap: var(--space-2); - align-content: start; -} - -.settings-panel h3 { - margin: 0; -} - -.primary-navigation { - display: grid; - gap: var(--space-1); - margin-top: var(--space-8); -} - -.primary-navigation a, -.nav-action { - display: flex; - min-height: 2.7rem; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-3); - border: 0; - border-radius: 0.62rem; - background: transparent; - color: var(--muted); - font-size: var(--text-sm); - text-align: left; -} - -.primary-navigation a:hover, -.nav-action:hover { - background: var(--accent-soft); - color: var(--text); -} - -.primary-navigation a[data-status="active"], -.sidebar-footer a[data-status="active"] { - background: var(--accent-soft); - color: var(--accent); -} - -.primary-navigation a[data-status="active"]::after, -.sidebar-footer a[data-status="active"]::after { - width: 0.26rem; - height: 0.26rem; - margin-left: auto; - border-radius: 50%; - background: currentcolor; - content: ""; -} - -.sidebar-footer { - display: grid; - gap: var(--space-3); - margin-top: auto; -} - -.theme-picker { - display: grid; - gap: var(--space-1); -} - -/* Sits under the brand, above the navigation, because it frames everything - below it rather than being one setting among the others at the foot. */ -.library-picker { - display: grid; - gap: var(--space-1); - margin: var(--space-4) 0 var(--space-2); - padding-inline: var(--space-2); -} - -.library-picker > span { - color: var(--muted); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.library-picker select { - width: 100%; - min-height: 2.35rem; - padding-block: var(--space-1); - font-size: var(--text-sm); -} - -.scope-note { - margin: calc(var(--space-4) * -1) 0 var(--space-6); - font-size: var(--text-xs); -} - -.language-picker { - display: grid; - gap: var(--space-1); -} - -.theme-picker span, -.language-picker span { - color: var(--muted); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.theme-picker select, -.language-picker select { - width: 100%; - min-height: 2.35rem; - padding-block: var(--space-1); - font-size: var(--text-xs); - text-transform: capitalize; -} - -.not-found { - position: relative; - width: min(100%, 38rem); - padding: clamp(var(--space-8), 6vw, var(--space-20)); - overflow: hidden; - border: 1px solid var(--line); - border-radius: 1.2rem; - background: var(--panel); - box-shadow: var(--shadow); -} - -.not-found h1 { - max-width: 12ch; - margin: 0 0 var(--space-3); - font-size: clamp(var(--text-3xl), 8vw, var(--text-6xl)); - line-height: 0.95; -} - -.not-found a { - display: inline-block; - margin-top: var(--space-4); - color: var(--accent); - font-weight: 700; -} - -.not-found-code { - position: absolute; - right: -0.4rem; - bottom: -2rem; - color: var(--accent-soft); - font-family: var(--font-display); - font-size: 12rem; - font-weight: 700; - line-height: 1; -} - -/* What a share link opens (RFC-020): one column, no shell around it. */ -.share-page { - display: grid; - width: min(100%, 44rem); - gap: var(--space-6); - margin: 0 auto; - padding: clamp(var(--space-6), 6vw, var(--space-16)) var(--space-4); -} - -.share-header h1 { - margin: var(--space-1) 0 var(--space-2); - font-size: clamp(var(--text-2xl), 6vw, var(--text-5xl)); - line-height: 1; -} - -.share-audio { - width: 100%; -} - -.share-tracks { - display: grid; - gap: var(--space-1); - padding: 0; - margin: 0; - list-style: none; -} - -.share-tracks button { - display: grid; - width: 100%; - grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border: 1px solid var(--line); - border-radius: 0.7rem; - background: transparent; - color: inherit; - text-align: start; -} - -.share-tracks li[aria-current="true"] button { - border-color: var(--accent); -} - -.share-track-cover { - width: 40px; - height: 40px; - flex: none; - border-radius: 6px; - object-fit: cover; - background: var(--line); -} - -.playlist-entry-actions { - display: inline-flex; - gap: 2px; -} - -.share-track-title { - overflow: hidden; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.share-track-duration { - font-variant-numeric: tabular-nums; -} - -.checkbox-label { - display: inline-flex; - align-items: center; - gap: var(--space-2); -} - -.account-chip { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3); - border: 1px solid var(--line); - border-radius: 0.7rem; -} - -.account-chip > span { - display: grid; - width: 2rem; - height: 2rem; - flex: 0 0 auto; - place-items: center; - border-radius: 0.48rem; - background: var(--accent-soft); - color: var(--accent); - font-weight: 700; -} - -.account-chip div { - display: grid; - min-width: 0; -} - -.account-chip strong, -.account-chip small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.account-chip small { - color: var(--muted); - font-size: var(--text-xs); - text-transform: capitalize; -} - -.mobile-header, -.mobile-navigation { - display: none; -} - -main { - grid-column: 2; - width: min(100%, 90rem); - margin: 0 auto; - padding: var(--space-12) clamp(var(--space-6), 4vw, var(--space-20)) - var(--space-20); -} - -.centered { - display: grid; - width: 100%; - min-height: 100dvh; - max-width: none; - place-items: center; - padding: var(--space-8); -} - -.login-shell { - display: grid; - width: min(100%, 67rem); - min-height: 38rem; - grid-template-columns: minmax(0, 1.2fr) minmax(20rem, 0.8fr); - overflow: hidden; - border: 1px solid var(--line); - border-radius: 1.4rem; - background: color-mix(in srgb, var(--panel-soft) 80%, transparent); - box-shadow: var(--shadow); -} - -.login-story { - position: relative; - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: clamp(var(--space-8), 6vw, var(--space-20)); - overflow: hidden; - background: - radial-gradient(circle at 25% 25%, var(--accent-soft), transparent 16rem), - var(--panel-soft); -} - -.login-story::after { - position: absolute; - inset: 1.4rem; - border: 1px solid var(--line); - border-radius: 1rem; - content: ""; -} - -.login-story > * { - z-index: 1; -} - -.login-story h1 { - max-width: 12ch; - margin: 0 0 var(--space-4); - font-size: clamp(var(--text-3xl), 6vw, var(--text-6xl)); - line-height: 0.93; -} - -.login-story p { - max-width: 29rem; - color: var(--muted); -} - -.signal { - width: 70%; - height: 3.5rem; - margin-block: var(--space-6); - background: repeating-linear-gradient( - 90deg, - var(--accent) 0 2px, - transparent 2px 8px - ); - clip-path: polygon( - 0 45%, - 5% 35%, - 10% 58%, - 15% 20%, - 20% 68%, - 25% 32%, - 30% 45%, - 35% 25%, - 40% 68%, - 45% 38%, - 50% 50%, - 55% 30%, - 60% 64%, - 65% 24%, - 70% 50%, - 75% 38%, - 80% 60%, - 85% 32%, - 90% 47%, - 95% 40%, - 100% 50%, - 100% 55%, - 0 55% - ); - opacity: 0.72; -} - -.card { - display: grid; - align-content: center; - gap: var(--space-4); - min-width: 20rem; - padding: clamp(var(--space-8), 5vw, var(--space-16)); - background: var(--panel); -} - -.card h1 { - margin: 0 0 var(--space-2); - font-size: var(--text-2xl); -} - -.card p { - margin: 0; -} - -.card label { - display: grid; - gap: var(--space-2); - color: var(--muted); - font-size: var(--text-xs); -} - -.login-card button[type="submit"] { - margin-top: var(--space-1); - border-color: transparent; - background: var(--accent); - /* The material's ink for an accent fill. Drawn from the page's ground, it - was a near-black on the dark accent and a pale grey on the light one. */ - color: var(--fe-accent-ink); - font-weight: 700; -} - -.page-header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: var(--space-6); - margin-bottom: var(--space-8); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--line); -} - -.page-header h2, -.page-header p, -.collection h3, -.admin-panel h3 { - margin: 0; -} - -.page-header h2 { - font-size: clamp(var(--text-2xl), 4vw, var(--text-5xl)); - line-height: 1; -} - -.page-heading { - display: grid; - gap: var(--space-2); - min-width: 0; -} - -.page-header p { - max-width: 28rem; -} - -.page-controls { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--space-3); -} - -.control { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.control > span { - overflow: hidden; - color: var(--muted); - font-size: var(--text-xs); - letter-spacing: 0.04em; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.control select, -.control input { - min-height: 2.4rem; - padding-block: var(--space-2); - font-size: var(--text-sm); -} - -.control input { - width: min(14rem, 40vw); -} - -@media (width <= 52rem) { - .page-header { - flex-direction: column; - align-items: stretch; - } - - .page-controls { - align-items: stretch; - } - - .control input { - width: 100%; - } -} - -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); - gap: clamp(var(--space-4), 2.5vw, var(--space-8)); - padding: 0; - margin: 0; - list-style: none; -} - -.grid a { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.grid span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Two lines rather than one: single-line truncation cut nearly every title in - a real catalogue, and the second line costs a fixed 2.4em whether it is used - or not, so the covers below stay on a common baseline. */ -.grid strong { - display: -webkit-box; - overflow: hidden; - min-height: 2.4em; - margin-top: var(--space-2); - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - font-size: var(--text-sm); - line-height: 1.2; -} - -.grid span { - font-size: var(--text-xs); -} - -/* The card is a positioning context for its actions; the link stays the whole - card, and the buttons sit above it rather than inside it — a button nested in - an anchor is neither valid nor operable by keyboard. */ -.grid li { - position: relative; -} - -.card-actions { - position: absolute; - top: 0.5rem; - right: 0.5rem; - display: flex; - gap: var(--space-1); - opacity: 0; - transition: - opacity 160ms ease, - translate 240ms ease; -} - -.card-action { - display: grid; - min-height: 2.1rem; - width: 2.1rem; - padding: 0; - border-color: transparent; - border-radius: 0.5rem; - background: color-mix(in srgb, var(--bg) 72%, transparent); - backdrop-filter: blur(0.5rem); - place-items: center; -} - -.card-action:hover:not(:disabled) { - border-color: var(--accent); - color: var(--accent); -} - -.grid li:hover .card-actions, -.grid li:focus-within .card-actions { - opacity: 1; -} - -/* Travel with the cover so the buttons stay on it, not beside it. Keyboard - focus reveals them without the lift, which is what the cover does too. */ -.grid li:hover .card-actions { - translate: 0 -0.28rem; -} - -/* Pointer-only reveal hides the actions from touch, where there is no hover to - reveal them with. */ -@media (hover: none) { - .card-actions { - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .card-actions { - transition: none; - } -} - -.cover { - display: grid; - width: 100%; - aspect-ratio: 1; - place-items: center; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 0.85rem; - background: var(--panel); - object-fit: cover; - transition: - transform 240ms ease, - box-shadow 240ms ease; -} - -.cover-fallback { - background: - radial-gradient(circle at 70% 20%, var(--accent-soft), transparent 60%), - var(--panel-strong); - color: var(--accent); -} - -.cover-fallback span { - font-family: var(--font-display); - font-size: clamp(var(--text-2xl), 5vw, var(--text-6xl)); - font-weight: 600; - opacity: 0.75; -} - -.cover.large { - width: clamp(10rem, 23vw, 18rem); - flex: 0 0 auto; - border-radius: 1.1rem; - box-shadow: var(--shadow); -} - -.grid li:hover .cover { - transform: translateY(-0.28rem); - box-shadow: var(--shadow); -} - -.detail-header, -.artist-hero { - display: flex; - align-items: flex-end; - gap: clamp(var(--space-6), 4vw, var(--space-16)); - margin: calc(var(--space-12) * -1) - calc(clamp(var(--space-6), 4vw, var(--space-20)) * -1) var(--space-8); - padding: var(--space-20) clamp(var(--space-6), 4vw, var(--space-20)) - var(--space-12); - border-bottom: 1px solid var(--line); - background: radial-gradient( - circle at 22% 15%, - var(--accent-soft), - transparent 30rem - ); -} - -.detail-header h2, -.artist-hero h2 { - max-width: 18ch; - margin: 0 0 var(--space-2); - font-size: clamp(var(--text-3xl), 6vw, var(--text-6xl)); - line-height: 0.94; - text-wrap: balance; -} - -.artist-portrait { - width: clamp(10rem, 23vw, 18rem); - flex: 0 0 auto; - border-radius: 50%; - box-shadow: var(--shadow); -} - -/* The local history sits between the hero and the discography, so it takes the - rhythm a section has everywhere else rather than the browser default. */ -.artist-played { - display: grid; - gap: var(--space-3); - margin-bottom: var(--space-8); -} - -.artist-played h3 { - margin: 0; - font-size: var(--text-xl); -} - -.artist-about, -.artist-discography, -.artist-similar { - display: grid; - gap: var(--space-3); - margin-bottom: var(--space-8); -} - -.artist-about h3, -.artist-discography h3, -.artist-similar h3 { - margin: 0; - font-size: var(--text-xl); -} - -/* Paragraphs arrive as line breaks in plain text, not as markup. */ -.artist-bio { - max-width: 70ch; - margin: 0; - white-space: pre-line; -} - -.artist-source { - margin: 0; - font-size: var(--text-sm); -} - -.list, -.queue-list, -.resource-list { - padding: 0; - margin: 0; - list-style: none; -} - -.list li { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - padding: var(--space-3) var(--space-1); - border-bottom: 1px solid var(--line); -} - -.artist-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 var(--space-8); -} - -.artist-list a { - display: flex; - min-width: 0; - align-items: center; - gap: var(--space-3); -} - -.artist-avatar { - width: 3.2rem; - flex: 0 0 auto; - border-radius: 50%; -} - -.artist-avatar span { - font-size: var(--text-lg); -} - -.songs-scroll { - width: 100%; - overflow-x: auto; -} - -.songs { - width: 100%; - border-collapse: collapse; -} - -.songs tr { - transition: background-color 180ms ease; -} - -.songs tr:hover { - background: var(--accent-soft); -} - -.songs tr.active { - background: var(--accent-soft); - color: var(--accent); -} - -.songs td { - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--line); - vertical-align: middle; -} - -/* Each credited artist is a link; at rest it reads as the muted text it was, - and says it can be followed only when pointed at or focused. */ -.song-artists a:hover, -.song-artists a:focus-visible { - color: var(--text); - text-decoration: underline; -} - -.songs .index { - width: 2.5rem; - color: var(--muted); - font-variant-numeric: tabular-nums; - text-align: center; -} - -button.link, -button.star { - min-height: 0; - padding: 0; - border: 0; - background: none; - text-align: left; -} - -button.link:hover, -button.star:hover { - background: none; - color: var(--accent); -} - -.song-title { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.song-title > span { - display: grid; -} - -.song-title small { - color: var(--muted); - font-size: var(--text-xs); -} - -.song-cover { - width: 2.7rem; - flex: 0 0 auto; - border-radius: 0.42rem; -} - -.song-cover span { - font-size: var(--text-base); -} - -button.star { - display: grid; - color: var(--muted); - font-size: var(--text-lg); - place-items: center; -} - -button[aria-pressed="true"].star { - color: var(--accent); -} - -/* Favourite and rating share a cell. They are different questions — "keep - this" and "how good is it" — and the table has no room for two columns. - The flex box is the inner div, not the cell: a `td` set to `display: flex` - stops being a table cell and its row's borders drift out of line. */ -.song-marks { - width: 1%; - white-space: nowrap; -} - -.song-marks > div { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-2); -} - -/* A radio group, so one of five is the shape the assistive layer sees and - arrow keys select. The inputs stay in the accessibility tree and out of the - picture; the stars beside them carry the paint. */ -.rating { - display: flex; - padding: 0; - border: 0; - margin: 0; - gap: 0.05rem; -} - -.rating label { - position: relative; - display: grid; - color: color-mix(in srgb, var(--muted) 45%, transparent); - cursor: pointer; - font-size: var(--text-base); - line-height: 1; - place-items: center; -} - -.rating label.on { - color: var(--accent); -} - -.rating:hover label { - color: color-mix(in srgb, var(--muted) 70%, transparent); -} - -.rating input { - position: absolute; - min-height: 0; - width: 100%; - height: 100%; - padding: 0; - border: 0; - margin: 0; - appearance: none; - background: none; - cursor: pointer; - inset: 0; -} - -.rating input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* The stars a hovered rating would set, so the target is visible before the - click commits it. `:has` walks back up from the hovered label. */ -.rating:has(label:nth-child(1):hover) label:nth-child(-n + 1), -.rating:has(label:nth-child(2):hover) label:nth-child(-n + 2), -.rating:has(label:nth-child(3):hover) label:nth-child(-n + 3), -.rating:has(label:nth-child(4):hover) label:nth-child(-n + 4), -.rating:has(label:nth-child(5):hover) label:nth-child(-n + 5) { - color: var(--accent); -} - -button.danger { - color: var(--danger); -} - -.search, -.inline-form, -.actions, -.credential-form { - display: flex; - align-items: center; - gap: var(--space-3); - flex-wrap: wrap; -} - -.search, -.inline-form { - margin-bottom: var(--space-6); -} - -.search input, -.inline-form input { - flex: 1 1 18rem; -} - -.stack { - display: grid; - gap: var(--space-4); -} - -.collection, -.admin-panel, -.user-row, -.empty-state, -.error-state { - padding: var(--space-6); - border: 1px solid var(--line); - border-radius: 0.9rem; - background: color-mix(in srgb, var(--panel) 82%, transparent); -} - -/* A grid item grows to its content's minimum width unless told otherwise, and - a playlist's table with its entry controls is wider than a phone: the card - pushed past the screen instead of letting the table scroll inside it. */ -.stack > .collection { - min-width: 0; -} - -.collection-header, -.user-row > header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - margin-bottom: var(--space-4); -} - -.collection-header > div:first-child, -.user-row header > div, -.resource-list li > div { - display: grid; - min-width: 0; -} - -.section-actions { - margin-bottom: var(--space-4); -} - -.queue-list li, -.resource-list li { - display: grid; - grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 0.7fr) auto auto; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) 0; - border-bottom: 1px solid var(--line); -} - -.queue-list li.active { - color: var(--accent); -} - -.resource-url { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.resource-list li { - grid-template-columns: minmax(12rem, 1fr) auto auto; -} - -/* A station or a channel with its image beside its name: the image the - server fetched into the artwork store, or the generic symbol. */ -.resource-with-cover { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: var(--space-3); - min-width: 0; -} - -.resource-copy { - display: grid; - min-width: 0; -} - -.resource-cover { - width: 3rem; - flex: 0 0 3rem; - border-radius: 0.5rem; -} - -.podcast-cover { - width: 5rem; - border-radius: 0.75rem; -} - -.empty-state, -.error-state { - min-height: 11rem; - place-items: center; - color: var(--muted); - text-align: center; -} - -.empty-state, -.error-state { - display: grid; -} - -.empty-state p, -.error-state p { - max-width: 42rem; -} - -.admin-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4); - margin-bottom: var(--space-4); -} - -.stacked-form { - display: grid; - gap: var(--space-3); - margin: var(--space-4) 0; -} - -.compact li { - grid-template-columns: minmax(10rem, 1fr) auto; -} - -.credential-form input { - flex: 1 1 16rem; -} - -.secret-output { - flex-basis: 100%; - word-break: break-all; -} - -.consent { - max-width: 34rem; -} - -.consent code { - word-break: break-all; -} - -.consent-actions { - display: flex; - gap: var(--space-3); - margin-top: var(--space-4); -} - -.skeleton-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); - gap: var(--space-6); -} - -.skeleton-card { - display: grid; - gap: var(--space-2); -} - -.skeleton-card span, -.skeleton-card i { - display: block; - border-radius: 0.55rem; - background: var(--panel-strong); - animation: pulse 1.6s ease-in-out infinite alternate; -} - -.skeleton-card span { - aspect-ratio: 1; -} - -.skeleton-card i { - width: 80%; - height: 0.7rem; -} - -.skeleton-card i:last-child { - width: 55%; - opacity: 0.72; -} - -@keyframes pulse { - to { - opacity: 0.45; - } -} - -.player { - position: fixed; - z-index: 10; - right: 1rem; - bottom: 1rem; - left: calc(var(--sidebar-width) + 1rem); - display: grid; - grid-template-columns: - 3.8rem minmax(8rem, 0.6fr) auto minmax(11rem, 1.1fr) - auto; - align-items: center; - gap: var(--space-4); - min-height: 5.1rem; - padding: var(--space-3) var(--space-4) var(--space-3) var(--space-3); - border: 1px solid var(--line-strong); - border-radius: 1rem; - background: color-mix(in srgb, var(--panel) 88%, transparent); - box-shadow: var(--shadow); - backdrop-filter: blur(1.7rem) saturate(135%); -} - -.player-cover { - width: 3.8rem; - border-radius: 0.65rem; -} - -.player-cover span { - font-size: var(--text-xl); -} - -.player-track { - display: grid; - min-width: 0; - font-size: var(--text-sm); -} - -.player-track strong, -.player-track span, -.player-track small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.player-track span { - color: var(--muted); - font-size: var(--text-xs); -} - -.player-track small { - font-size: var(--text-xs); -} - -/* A station has no artist to name. Where the artist would be, a badge says - that it is live — the one thing about it a transport with no line and no - next button has to explain. */ -.player-track .live-badge { - margin-top: var(--space-1); - color: var(--accent); - line-height: 1.4; -} - -.player-controls { - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-1); -} - -.player-controls button { - display: grid; - width: 2.55rem; - min-height: 2.55rem; - padding: 0; - place-items: center; - border: 0; - border-radius: 50%; - background: transparent; -} - -/* Transport is always available; shuffle and repeat are modes, so they read as - off until they are on. Addressed by class and not by position: the transport - buttons were `nth-child` until a mode button was inserted ahead of them and - the filled play styling landed on "previous". */ -.player-controls button.mode { - color: var(--muted); -} - -.player-controls button.mode.on { - color: var(--accent); -} - -/* The cover doubles as the way back to the album. */ -.player > a { - display: grid; - border-radius: 0.65rem; -} - -.player > a:hover .player-cover { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.player-aside { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.player-aside button { - display: grid; - width: 2.1rem; - min-height: 2.1rem; - padding: 0; - border: 0; - background: transparent; - color: var(--muted); - place-items: center; -} - -.player-aside button:hover, -.player-aside button[aria-pressed="true"] { - color: var(--accent); -} - -.volume { - width: 4.5rem; - min-height: 1rem; - padding: 0; - border: 0; - accent-color: var(--accent); - background: none; -} - -.player-aside .nav-action { - display: flex; - align-items: center; - gap: var(--space-2); - min-height: 2.1rem; - padding: 0 var(--space-2); - border: 1px solid var(--line-strong); - border-radius: 0.5rem; - color: var(--muted); - font-size: var(--text-xs); - white-space: nowrap; -} - -.player-aside .nav-action:hover { - border-color: var(--accent); - color: var(--accent); -} - -/* Below the wide layout the aside would crowd the scrubber out; the queue is - still one tap away in the mobile navigation, and the level belongs to the - device's own keys there. */ -@media (width <= 62rem) { - .player-aside { - display: none; - } - - /* Drop the column with it, or the bar keeps a gap where it was. */ - .player { - grid-template-columns: 3.8rem minmax(8rem, 0.6fr) auto minmax(11rem, 1.1fr); - } -} - -.player-controls button.primary { - width: 3rem; - min-height: 3rem; - background: var(--text); - color: var(--bg); -} - -.player-progress { - display: grid; - grid-template-columns: 2.8rem minmax(5rem, 1fr) 2.8rem; - align-items: center; - gap: var(--space-2); - color: var(--muted); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -.player-progress input { - width: 100%; - min-height: 1rem; - padding: 0; - border: 0; - accent-color: var(--accent); -} - -@media (max-width: 820px) { - .shell { - display: block; - padding: var(--space-20) 0 11.5rem; - } - - .sidebar { - display: none; - } - - .mobile-header { - position: fixed; - z-index: 8; - inset: 0 0 auto; - display: flex; - min-height: 4.2rem; - align-items: center; - justify-content: space-between; - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--line); - background: color-mix(in srgb, var(--panel-soft) 88%, transparent); - backdrop-filter: blur(1.4rem); - } - - /* What a phone hides: every label whose control is already legible - without it, and the two the layout has no room for. One rule rather - than three spread through the blocks below, with its selectors running - from the least specific to the most: the same declaration for each, so - the order cannot change what any element gets, and none of them sits - behind a selector that already outranks it. */ - .player-progress span, - .mobile-navigation a span, - .mobile-navigation .more-button span, - .mobile-header .library-picker span, - .mobile-header .theme-picker span, - .mobile-header .language-picker span, - .mobile-header .brand small { - display: none; - } - - /* The sidebar is out of reach here, so the scope moves into the header - rather than becoming unreachable on a phone. */ - .mobile-header .library-picker { - margin: 0; - padding: 0; - } - - .mobile-header .library-picker select { - width: 7rem; - } - .mobile-header .theme-picker select { - width: 6.5rem; - } - .mobile-header .language-picker select { - width: 5.7rem; - } - - main { - width: 100%; - padding: var(--space-6) var(--space-4) var(--space-12); - } - - .mobile-navigation { - position: fixed; - z-index: 9; - right: 0.6rem; - bottom: 0.6rem; - left: 0.6rem; - display: grid; - /* As many equal columns as there are slots, counted by the browser. - This said `repeat(5, 1fr)` from the first design, and a sixth primary - entry was added later without touching it — so the bar had been - wrapping onto a second row ever since, which the suite could not see - because it measures horizontal overflow and a second row overflows - downwards. A number kept in a second place goes stale; this one cannot. */ - grid-auto-flow: column; - grid-auto-columns: 1fr; - padding: var(--space-2); - border: 1px solid var(--line-strong); - border-radius: 1rem; - background: color-mix(in srgb, var(--panel) 91%, transparent); - box-shadow: var(--shadow); - backdrop-filter: blur(1.5rem); - } - - .mobile-navigation a, - .mobile-navigation .more-button { - display: grid; - min-height: 3rem; - place-items: center; - border-radius: 0.7rem; - color: var(--muted); - } - - /* The seventh slot is a button and not a link, so it has to be undressed - back to what the six beside it look like. */ - .mobile-navigation .more-button { - min-width: 0; - padding: 0; - border: 0; - background: none; - } - - .mobile-navigation a[data-status="active"] { - background: var(--accent-soft); - color: var(--accent); - } - - .player { - right: 0.8rem; - bottom: 4.85rem; - left: 0.8rem; - grid-template-columns: 3.2rem minmax(0, 1fr) auto; - min-height: 4.35rem; - padding: var(--space-2); - } - - .player-cover { - width: 3.2rem; - } - - .player-progress { - position: absolute; - right: 0.85rem; - bottom: 0.15rem; - left: 0.85rem; - display: block; - } - - /* Narrow: the modes and the skip buttons give way to the transport. */ - .player-controls button.mode { - display: none; - } - - .player-controls button.primary { - width: 2.75rem; - min-height: 2.75rem; - } - - .page-header { - display: block; - } - .page-header h2 { - margin-bottom: var(--space-2); - } - .page-header p { - text-align: left; - } - - .detail-header, - .artist-hero { - align-items: flex-end; - margin: calc(var(--space-6) * -1) calc(var(--space-4) * -1) var(--space-6); - padding: var(--space-16) var(--space-4) var(--space-6); - } - - .detail-header h2, - .artist-hero h2 { - font-size: clamp(var(--text-2xl), 9vw, var(--text-5xl)); - } - - .cover.large, - .artist-portrait { - width: min(35vw, 9rem); - } - - .artist-list, - .admin-grid { - grid-template-columns: 1fr; - } - - .songs td:nth-child(3) { - display: none; - } - .songs td { - padding-inline: var(--space-1); - } - - .collection-header, - .user-row > header { - align-items: flex-start; - flex-direction: column; - } - - .resource-list li { - grid-template-columns: minmax(0, 1fr) auto; - } - - .resource-list li > div { - grid-column: 1 / -1; - } - - .credential-form { - align-items: stretch; - flex-direction: column; - } -} - -@media (max-width: 680px) { - .centered { - padding: 0; - } - - .login-shell { - min-height: 100dvh; - grid-template-columns: 1fr; - border: 0; - border-radius: 0; - } - - .login-story { - min-height: 38dvh; - padding: var(--space-8); - } - - .login-story::after { - inset: 0.7rem; - } - .login-story h1 { - font-size: clamp(var(--text-3xl), 13vw, var(--text-5xl)); - } - - .signal, - .login-story p { - display: none; - } - - .card { - min-width: 0; - padding: var(--space-8) var(--space-6) var(--space-12); - } - - .grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .song-cover { - display: none; - } -} - -@media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - - .skeleton-card span, - .skeleton-card i { - animation: none; - } - - button, - .cover, - .songs tr { - transition-duration: 0.01ms; - } -} - -/* Genres are a two-column list like artists: a name is short, and one column - of them wastes the width the grid pages use. */ -.genre-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 var(--space-8); -} - -.genre-list a { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.genre-list a:hover strong { - color: var(--accent); -} - -.playing-detail { - min-width: 0; - flex: 1 1 auto; -} - -.playing-detail h2 { - margin: var(--space-1) 0 var(--space-1); - font-size: clamp(var(--text-xl), 3vw, var(--text-3xl)); - line-height: 1.1; -} - -.lyrics { - margin-top: var(--space-8); -} - -.lyrics h3 { - display: flex; - align-items: baseline; - gap: var(--space-2); - margin: 0 0 var(--space-3); - font-size: var(--text-base); -} - -.lyrics h3 small { - color: var(--muted); - font-size: var(--text-xs); -} - -.lyrics ol { - max-height: 26rem; - padding: 0; - margin: 0; - list-style: none; - overflow-y: auto; -} - -.lyrics li { - padding: var(--space-1) 0; - color: var(--muted); - line-height: 1.5; - transition: color 180ms ease; -} - -/* The line a synced sheet is on. An unsynced sheet never sets it, so the - whole text stays evenly weighted rather than pretending to follow. */ -.lyrics li.on { - color: var(--text); - font-weight: 600; -} - -/* ── Editorial pages: the playing screen in two columns ───────────────────── - The track and its words. In the middle range the cover sits beside the - names with the words under both, as this screen always read; on a wide - screen the track becomes a column of its own — a larger cover over the - names — and the words a tall one beside it (`.playing-split`). A track with - no words keeps one column, centred (`.playing-solo`), rather than half a - screen held open for nothing. Narrow, everything stacks, cover first. */ -.playing { - display: grid; - gap: clamp(var(--space-6), 4vw, var(--space-16)); - margin-bottom: var(--space-8); -} - -/* The same gutter between the cover and its names as between the columns: - inherited, so the fluid range is written once. */ -.playing-track { - display: flex; - align-items: flex-start; - gap: inherit; - min-width: 0; -} - -.playing-lyrics { - min-width: 0; -} - -.playing-lyrics .lyrics { - margin-top: 0; -} - -@media (width > 64rem) { - .playing-split { - grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); - align-items: start; - } - - .playing-split .playing-track, - .playing-solo .playing-track { - flex-direction: column; - gap: var(--space-6); - width: 100%; - max-width: 26rem; - } - - .playing-split .playing-track .canvas-stage, - .playing-solo .playing-track .canvas-stage, - .playing-split .cover.large, - .playing-solo .cover.large { - width: 100%; - } - - .playing-split .playing-detail, - .playing-solo .playing-detail { - width: 100%; - } - - /* Tall enough to read a verse without scrolling, and set larger than the - strip it was: the words are half the screen now, not a footnote. */ - .playing-split .lyrics ol { - max-height: min(70vh, 44rem); - scroll-padding: 40% 0; - } - - .playing-split .lyrics li { - font-size: var(--text-lg); - line-height: 1.5; - } - - .playing-solo { - justify-items: center; - } - - .playing-solo .playing-track { - align-items: center; - text-align: center; - } - - .playing-solo .quality-meter { - justify-content: center; - } -} - -/* ── End of the playing screen's columns ── */ - -/* The three ways to look at the track, in the page header beside the bookmark - button. Links and not buttons, because each one is a location. */ -.view-switcher { - display: flex; - gap: var(--space-1); - padding: var(--space-1); - border: 1px solid var(--line); - border-radius: 0.7rem; - background: var(--panel-soft); -} - -.view-switcher a { - padding: var(--space-1) var(--space-3); - border-radius: 0.5rem; - color: var(--muted); - font-size: var(--text-xs); - text-decoration: none; - white-space: nowrap; -} - -.view-switcher a:hover { - color: var(--text); -} - -/* The one being looked at. `aria-current` carries the meaning, so the styling - reads it rather than keeping a second class in step with it. */ -.view-switcher a[aria-current="page"] { - background: var(--panel-strong); - color: var(--text); -} - -/* A hub's sections, for Settings and for Maintenance: a row of tabs over the - open section. They are links, one route each, and the open one is the - router's `aria-current`. */ -.section-menu { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - width: fit-content; - max-width: 100%; - margin-bottom: var(--space-8); - padding: var(--space-1); - border: 1px solid var(--line); - border-radius: 0.7rem; - background: var(--panel-soft); -} - -.section-menu-link { - display: flex; - align-items: center; - min-height: 2.25rem; - padding: var(--space-1) var(--space-4); - border-radius: 0.5rem; - color: var(--muted); - font-size: var(--text-sm); - font-weight: 600; - text-decoration: none; - white-space: nowrap; -} - -.section-menu-link:hover { - color: var(--text); -} - -.section-menu-link[aria-current="page"] { - background: var(--panel-strong); - color: var(--text); -} - -/* On a phone the row would not fit and a scrolling strip hides what is past - its edge, so the same links become a list, two to a row, each a full - touch target. */ -@media (max-width: 820px) { - .section-menu { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - width: auto; - margin-bottom: var(--space-6); - } - - .section-menu-link { - min-height: 2.75rem; - white-space: normal; - } -} - -/* The words as the subject: one column, centred, wide type, and the sung line - held in the middle of it by the view itself. */ -.lyrics-view { - display: grid; - gap: var(--space-6); - max-width: 44rem; - margin: 0 auto var(--space-8); -} - -.lyrics-view header { - text-align: center; -} - -.lyrics-view h2 { - margin: 0 0 var(--space-1); - font-size: clamp(var(--text-xl), 2.6vw, var(--text-2xl)); - line-height: 1.15; -} - -.lyrics-view p { - margin: 0; -} - -.lyrics-view ol { - max-height: min(60vh, 34rem); - padding: var(--space-2) 0; - margin: 0; - list-style: none; - overflow-y: auto; - text-align: center; - /* Room above and below so the first and last lines can reach the middle, - which is where the scroll puts the sung one. */ - scroll-padding: 40% 0; -} - -.lyrics-view li { - padding: var(--space-1) 0; - color: var(--muted); - font-size: clamp(var(--text-base), 2.2vw, var(--text-xl)); - line-height: 1.45; - transition: color 180ms ease; -} - -.lyrics-view li.on { - color: var(--text); - font-weight: 600; -} - -/* A seekable line is a button and has to look like the text it is, not like a - control: what makes it pressable is already said by its label. The strip - beside the cover seeks the same way, so it shares the rule. */ -.lyrics .link, -.lyrics-view .link { - display: block; - width: 100%; - padding: 0; - border: 0; - background: none; - color: inherit; - font: inherit; - text-align: inherit; - cursor: pointer; -} - -.lyrics .link:hover, -.lyrics-view .link:hover { - color: var(--text); - text-decoration: underline; -} - -/* The artwork over everything else. A dialog, so the focus trap, Escape and - the inertness behind come from the platform. - - **`[open]`, or the closed dialog is laid out.** `display: flex` from an - author sheet outranks the `dialog:not([open]) { display: none }` the browser - brings, and the element stays mounted between the close and the navigation - that follows it — so it would appear in the page's own flow, 100vw by 100vh - and outside the top layer, for as long as that took. */ -.immersive[open] { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: clamp(var(--space-4), 3vw, var(--space-8)); - width: 100vw; - max-width: 100vw; - height: 100vh; - max-height: 100vh; - padding: clamp(var(--space-4), 4vw, var(--space-12)); - border: 0; - margin: 0; - background: var(--bg); - color: var(--text); -} - -.immersive::backdrop { - background: var(--bg); -} - -.immersive-stage { - display: flex; - min-height: 0; - justify-content: center; -} - -.immersive-cover { - width: auto; - max-width: min(78vw, 60vh); - height: auto; - max-height: 60vh; - aspect-ratio: 1; - object-fit: cover; -} - -/* An image has its own size to start from; the stand-in for a missing cover - has none, because its letter is positioned out of flow. With `width: auto` - in a centred column it shrank to its border — two pixels — and a borderless - theme took even those away. */ -.immersive-cover.cover-fallback { - width: min(78vw, 60vh); -} - -.immersive-detail { - display: grid; - justify-items: center; - gap: var(--space-1); - max-width: 44rem; - text-align: center; -} - -.immersive-detail h2 { - margin: 0; - font-size: clamp(var(--text-xl), 3.4vw, var(--text-3xl)); - line-height: 1.1; -} - -.immersive-detail p { - margin: 0; -} - -/* The transport, because this view covers the player bar. A mode that fills - the screen and leaves no way to pause is a trap rather than a mode. */ -.immersive-transport { - display: flex; - align-items: center; - gap: var(--space-2); - margin-top: var(--space-3); -} - -.immersive-close { - position: absolute; - top: clamp(0.6rem, 2vw, 1.4rem); - right: clamp(0.6rem, 2vw, 1.4rem); -} - -@media (prefers-reduced-motion: reduce) { - .lyrics li, - .lyrics-view li { - transition: none; - } -} - -@media (width <= 52rem) { - .genre-list { - grid-template-columns: 1fr; - } - - .playing-track { - flex-direction: column; - } -} - -/* The scan panel sits above the grid: while one runs, it is the thing the - operator came for. */ -.scan-panel { - margin-bottom: var(--space-6); -} - -.scan-progress { - display: grid; - gap: var(--space-2); -} - -.scan-line { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-4); - font-size: var(--text-sm); -} - -.scan-progress progress { - width: 100%; - height: 0.4rem; - border: 0; - border-radius: 0.2rem; - accent-color: var(--accent); -} - -.scan-tally { - margin: 0; - font-size: var(--text-xs); -} - -/* A path is long and moves fast; it stays on one line and truncates rather - than reflowing the panel on every file. `direction: rtl` is what keeps the - ellipsis at the *start*, so the file name — the part that says where the - scan is — stays readable. - `unicode-bidi: plaintext` is what makes that safe: an RTL paragraph - reorders neutral characters, and a path is mostly neutrals. Without it the - separators and a trailing extension drift, so `/music/a.flac` could be shown - with its slashes in the wrong places. Plaintext takes the direction from the - first strong character in the string instead, which for a path is its first - letter. */ -.scan-path { - overflow: hidden; - margin: 0; - font-family: ui-monospace, "SF Mono", Menlo, monospace; - font-size: var(--text-xs); - text-overflow: ellipsis; - white-space: nowrap; - direction: rtl; - text-align: left; - unicode-bidi: plaintext; -} - -.admin-panel .resource-list li > div { - display: grid; - min-width: 0; -} - -.admin-panel .resource-list small { - font-size: var(--text-xs); -} - -/* The token list is closed until asked for. Its heading is the control that - opens it, so the button has to read as the heading it sits in. */ -.token-panel h3 { - margin: 0; -} - -.token-panel h3 button { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - min-height: 0; - padding: 0; - border: 0; - background: none; - color: inherit; - font: inherit; - text-align: left; -} - -.token-panel h3 button::before { - color: var(--muted); - content: "▸"; - font-size: var(--text-xs); - transition: rotate 160ms ease; -} - -.token-panel h3 button[aria-expanded="true"]::before { - rotate: 90deg; -} - -.token-panel h3 button:hover { - background: none; - color: var(--accent); -} - -@media (prefers-reduced-motion: reduce) { - .token-panel h3 button::before { - transition: none; - } -} - -/* The membership panel is a row of the library list rather than a column of - its own: it belongs to one library, and there is one of them per library. */ -.member-row { - display: block; - padding: 0; - border: 0; -} - -.member-row .admin-panel { - padding: var(--space-2) 0; - border: 0; - background: none; -} - -.member-row .control { - min-width: 8rem; -} - -/* A correction is reached from the row it corrects. A link rather than a - button, because it goes somewhere, and in the marks cell beside the rating - and the heart because the table has no room for another column. */ -.row-action { - display: inline-flex; - align-items: center; - color: var(--muted); -} - -.row-action:hover { - color: var(--accent); -} - -/* The tag editor. One column: each field carries a second line — what the - file says beneath the correction — and two columns of those read as a table - nobody asked for. */ -.correction-page { - max-width: 44rem; -} - -.correction-detail { - margin: 0 0 var(--space-6); -} - -.correction-form { - display: grid; - gap: var(--space-4); -} - -.correction-field { - display: grid; - gap: var(--space-2); -} - -.correction-label { - display: flex; - align-items: center; - gap: var(--space-2); - color: var(--muted); - font-size: var(--text-xs); -} - -/* The base field rule predates any multi-line input, so the textarea is not in - it. It takes the same chrome, and grows downward only: it holds a list of - names, one per line. */ -.correction-field textarea { - min-height: 5.5rem; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--line-strong); - border-radius: 0.55rem; - background: color-mix(in srgb, var(--bg) 88%, transparent); - color: var(--text); - font: inherit; - resize: vertical; -} - -.correction-field :disabled { - opacity: 0.6; -} - -.correction-badge { - padding: 0.05rem var(--space-2); - border: 1px solid currentColor; - border-radius: 999px; - color: var(--accent); - font-size: var(--text-xs); - font-weight: 700; -} - -.correction-provenance { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--space-1) var(--space-3); - margin: 0; - font-size: var(--text-xs); -} - -.correction-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-4); - margin-top: var(--space-2); -} - -.correction-actions p { - margin: 0; -} - -/* Uploads. The rows are the list's own; the progress bar takes the width the - name does not need, and a long file name wraps rather than pushing the bar - out of its row. */ -.upload-detail { - max-width: 44rem; - margin: 0 0 var(--space-6); -} - -.upload-pick { - display: inline-grid; - gap: var(--space-2); - color: var(--muted); - font-size: var(--text-xs); -} - -/* The playlist import, on the playlists page: a panel rather than a page, - because it is one file and one decision. */ -.import-panel { - display: grid; - gap: var(--space-2); - margin-bottom: var(--space-8); - padding: var(--space-4); - border: 1px solid var(--line); - border-radius: 0.75rem; - background: var(--panel-soft); -} - -.import-panel h3, -.import-panel p { - margin: 0; -} - -.import-preview { - display: grid; - gap: var(--space-2); - margin-top: var(--space-3); - padding-top: var(--space-3); - border-top: 1px solid var(--line); -} - -/* The list of what would be lost. Scrollable rather than endless: a file from - another player can disagree with this catalogue on hundreds of lines, and a - report that pushes the Import button off the screen cannot be acted on. */ -.import-unresolved { - max-height: 14rem; - overflow-y: auto; - display: grid; - gap: var(--space-1); - font-size: var(--text-xs); -} - -.import-unresolved li { - display: flex; - align-items: baseline; - gap: var(--space-2); - flex-wrap: wrap; -} - -.import-unresolved code { - overflow-wrap: anywhere; -} - -.import-line { - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.upload-formats { - margin: var(--space-2) 0 var(--space-6); - font-size: var(--text-xs); -} - -.upload-list li { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(6rem, 12rem); - align-items: center; -} - -.upload-name { - overflow-wrap: anywhere; -} - -.upload-status { - color: var(--muted); - font-size: var(--text-sm); - white-space: nowrap; -} - -.upload-item.done .upload-status, -.upload-item.present .upload-status { - color: var(--accent); -} - -.upload-item.refused .upload-status, -.upload-item.failed .upload-status { - color: var(--danger); - white-space: normal; -} - -.upload-list progress { - width: 100%; - accent-color: var(--accent); -} - -@media (max-width: 40rem) { - .upload-list li { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-1); - } -} - -/* The canvas. On the playing page the loop lies over the cover, which keeps - the image's name and shows through until the loop can play; in the editor it - is a preview, shown whole rather than cropped to a square. */ -.canvas-stage { - position: relative; - flex: 0 0 auto; -} - -.canvas-video { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - border-radius: 1.1rem; - object-fit: cover; - opacity: 0; - pointer-events: none; - transition: opacity 600ms ease; -} - -.canvas-video.ready { - opacity: 1; -} - -.canvas-panel { - display: grid; - gap: var(--space-3); - margin-top: var(--space-12); - padding-top: var(--space-6); - border-top: 1px solid var(--line); -} - -.canvas-panel h2 { - margin: 0; - font-size: var(--text-lg); -} - -.canvas-panel > p { - margin: 0; -} - -.canvas-preview { - width: min(100%, 14rem); - max-height: 22rem; - border: 1px solid var(--line); - border-radius: 0.85rem; - background: var(--panel); - object-fit: contain; -} - -.canvas-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-4); -} - -.canvas-actions p { - margin: 0; -} - -/* External scrobbling — RFC-010. - A row per instance: who it is, how its queue is doing, and the one gesture - it offers. The health word carries a colour of its own because three states - need three, and `--muted` would say "less important" about the one state - that is asking for attention. */ -.scrobble-row { - display: grid; - gap: var(--space-2); - align-items: start; - grid-template-columns: minmax(0, 2fr) minmax(0, 1.4fr) auto; -} - -.scrobble-identity, -.scrobble-counters { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.scrobble-counters { - font-size: var(--text-sm); -} - -.scrobble-actions { - display: flex; - align-items: center; - gap: var(--space-2); - flex-wrap: wrap; - justify-content: flex-end; -} - -/* The form inside a row carries the list's own spacing, not the bottom margin - `.inline-form` gives a form standing alone above a list. */ -.scrobble-row .inline-form { - margin-bottom: 0; -} - -.scrobble-row .inline-form input { - flex: 1 1 10rem; -} - -.badge { - justify-self: start; - padding: 0.1rem var(--space-2); - border: 1px solid currentcolor; - border-radius: 999px; - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.health-healthy { - color: var(--accent); -} - -.health-degraded { - color: var(--warn); -} - -.health-broken { - color: var(--danger); -} - -.scrobble-uncertain { - margin-top: var(--space-12); - padding-top: var(--space-6); - border-top: 1px solid var(--line); -} - -.scrobble-uncertain h3 { - margin: 0 0 var(--space-2); -} - -@media (width <= 60rem) { - /* One column on a phone. The three-column row would put a password field and - two buttons on the same line as the health word, and the buttons are what - the screen is for. */ - .scrobble-row { - grid-template-columns: minmax(0, 1fr); - } - - .scrobble-actions { - justify-content: flex-start; - } -} - -/* The sheet the seventh slot of the mobile bar opens. - A native ``: the focus trap, the Escape key and the inertness of - the page behind it come from the element rather than from code, which is - the difference between an accessibility gate that holds and one that has - to be rewritten each time the markup moves. */ -.more-sheet { - width: min(100%, 26rem); - max-width: 100%; - padding: 0; - border: 1px solid var(--line-strong); - border-radius: 1rem 1rem 0 0; - margin: auto auto 0; - background: var(--panel); - color: var(--text); - box-shadow: var(--shadow); -} - -.more-sheet::backdrop { - background: color-mix(in srgb, var(--bg) 62%, transparent); - backdrop-filter: blur(0.3rem); -} - -.more-sheet header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-4); - padding: var(--space-4) var(--space-4) var(--space-3); - border-bottom: 1px solid var(--line); -} - -.more-sheet h2 { - margin: 0; - font-size: var(--text-lg); -} - -.more-sheet header p { - margin: var(--space-1) 0 0; - font-size: var(--text-sm); -} - -.more-sheet nav { - display: grid; - /* One column, and its own scroll: eight entries plus a header can outgrow a - short phone held in landscape, and a sheet whose last row cannot be - reached is the problem it was opened to solve. */ - max-height: min(60dvh, 28rem); - overflow-y: auto; - padding: var(--space-2); -} - -.more-sheet nav a { - display: flex; - align-items: center; - gap: var(--space-3); - min-height: 3rem; - padding: 0 var(--space-3); - border-radius: 0.7rem; -} - -.more-sheet nav a[data-status="active"] { - background: var(--accent-soft); - color: var(--accent); -} - -/* The right click on a track (RFC-012 decision 4). Fixed to the viewport at - the pointer, and moved back inside it by the component when it would hang - off an edge. */ -.track-menu { - position: fixed; - z-index: 40; - display: grid; - min-width: 13rem; - /* A track credited to many artists lists one entry each: capped at the - window, the menu scrolls on its own rather than running off the screen. */ - max-height: calc(100dvh - 2rem); - overflow-y: auto; - overscroll-behavior: contain; - padding: var(--space-1); - border: 1px solid var(--line-strong); - border-radius: 0.75rem; - background: var(--panel-strong); - box-shadow: var(--shadow); -} - -.track-menu-item { - display: flex; - align-items: center; - gap: var(--space-3); - min-height: 2.25rem; - padding: var(--space-2) var(--space-3); - border: 0; - border-radius: 0.5rem; - background: transparent; - color: var(--text); - font-size: var(--text-sm); - text-align: left; -} - -.track-menu-item:hover, -.track-menu-item:focus-visible { - background: var(--accent-soft); -} - -/* The arrow keys move focus through the menu, so where it is must be seen - on its own: a tint alone is too faint against the light themes' panel. */ -.track-menu-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -/* A track's properties: a sheet of facts, read-only. */ -.track-properties[open] { - display: grid; - gap: var(--space-6); - width: min(42rem, calc(100vw - 2rem)); - max-height: min(48rem, calc(100dvh - 2rem)); - padding: var(--space-6); - overflow-y: auto; - border: 1px solid var(--line-strong); - border-radius: 1rem; - background: var(--panel); - color: var(--text); - box-shadow: var(--shadow); -} - -.track-properties::backdrop { - background: color-mix(in srgb, black 55%, transparent); -} - -.track-properties header { - display: grid; - grid-template-columns: 4.5rem minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-4); -} - -.properties-cover { - width: 4.5rem; -} - -.track-properties h2 { - margin: 0; - font-size: var(--text-xl); - overflow-wrap: anywhere; -} - -.track-properties header p { - margin: var(--space-1) 0 0; -} - -.properties-close { - display: grid; - width: 2.25rem; - min-height: 2.25rem; - padding: 0; - place-items: center; -} - -.track-properties h3 { - margin: 0 0 var(--space-3); - font-size: var(--text-base); -} - -.property-list { - display: grid; - margin: 0; -} - -.property-row { - display: grid; - grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr); - gap: var(--space-4); - padding: var(--space-2) 0; - border-bottom: 1px solid var(--line); -} - -.property-list dt { - color: var(--muted); - font-size: var(--text-sm); -} - -.property-list dd { - display: grid; - gap: var(--space-1); - margin: 0; - font-size: var(--text-sm); - font-variant-numeric: tabular-nums; - overflow-wrap: anywhere; -} - -.property-list code { - font-family: ui-monospace, "SF Mono", Menlo, monospace; - font-size: var(--text-xs); -} - -.property-corrected { - color: var(--accent); - font-size: var(--text-xs); -} - -@media (width <= 35rem) { - .property-row { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-1); - } -} - -/* A download that could not start, said where it is seen: above the player, - whatever the length of the table the menu was opened from. */ -.track-menu-alert { - position: fixed; - z-index: 40; - bottom: 7.5rem; - left: 50%; - display: flex; - align-items: center; - gap: var(--space-3); - max-width: min(32rem, calc(100vw - 2rem)); - padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); - border: 1px solid var(--line-strong); - border-radius: 0.75rem; - background: var(--panel-strong); - color: var(--danger); - box-shadow: var(--shadow); - font-size: var(--text-sm); - translate: -50% 0; -} - -.track-menu-alert-dismiss { - display: grid; - width: 2rem; - min-height: 2rem; - padding: 0; - place-items: center; - border: 0; - background: transparent; - color: var(--text); -} - -/* The lyrics in a track's properties: each sheet with its source, and what - may be done with them under the list. */ -.lyrics-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-top: var(--space-3); -} - -.lyrics-action { - display: inline-flex; - align-items: center; - gap: var(--space-2); -} - -.lyrics-remove { - justify-self: start; - min-height: 1.75rem; - padding: 0 var(--space-2); - border: 0; - background: transparent; - color: var(--danger); - font-size: var(--text-xs); -} - -/* The folders page (RFC-012). The trail first, each crumb a link back up; the - folder being looked at says so through `aria-current`. */ -.folder-crumbs ol { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - margin: 0 0 var(--space-3); - padding: 0; - list-style: none; - font-size: var(--text-sm); -} - -.folder-crumbs li + li::before { - margin-right: var(--space-1); - color: var(--muted); - content: "/"; -} - -.folder-crumbs li a { - color: var(--muted); - overflow-wrap: anywhere; -} - -.folder-crumbs li a:hover, -.folder-crumbs li a[aria-current="page"] { - color: var(--text); -} - -.folder-note { - margin: 0 0 var(--space-4); - font-size: var(--text-sm); -} - -.folder-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); - gap: var(--space-2); - margin: 0 0 var(--space-6); - padding: 0; - list-style: none; -} - -.folder-row { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - column-gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--line); - border-radius: 0.7rem; - background: var(--panel-soft); - transition: - border-color 180ms ease, - background-color 180ms ease; -} - -.folder-row:hover { - border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); - background: var(--panel-strong); -} - -.folder-row svg { - grid-row: span 2; - color: var(--accent); -} - -.folder-row span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* A link that leaves for the upload screen, drawn as the buttons beside it. */ -.folder-upload { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-height: 2.55rem; - padding: var(--space-2) var(--space-4); - border: 1px solid var(--line-strong); - border-radius: 0.55rem; - background: var(--panel-strong); -} - -.folder-upload:hover { - border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); -} - -/* The filed tree (RFC-012 decision 2). A folder a drag hovers over says it - will take the drop. */ -.folder-row.drop-target { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 14%, var(--panel-soft)); -} - -.folder-form { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--space-2); - margin: 0 0 var(--space-4); -} - -.folder-form-field { - display: grid; - flex: 1 1 14rem; - gap: var(--space-1); -} - -.folder-notice { - margin: 0 0 var(--space-3); -} - -/* The spacing tokens go 4 then 6: there is no --space-5, and a padding naming - it is no padding at all — which is how this panel first shipped. */ -.folder-picker[open] { - display: grid; - gap: var(--space-6); - width: min(34rem, calc(100vw - 2rem)); - max-height: min(40rem, calc(100dvh - 2rem)); - padding: var(--space-6); - overflow-y: auto; - border: 1px solid var(--line-strong); - border-radius: 1rem; - background: var(--panel); - color: var(--text); - box-shadow: var(--shadow); -} - -.folder-picker::backdrop { - background: color-mix(in srgb, black 55%, transparent); -} - -.folder-picker header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-3); -} - -.folder-picker h2 { - margin: 0; - font-size: var(--text-lg); - overflow-wrap: anywhere; -} - -.folder-pick-list { - display: grid; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; -} - -.folder-pick { - display: flex; - align-items: center; - gap: var(--space-3); - width: 100%; - min-height: 3rem; - padding-block: var(--space-3); - padding-inline-end: var(--space-4); - text-align: start; -} - -.folder-pick span { - flex: 1; - min-width: 0; - overflow-wrap: anywhere; -} - -.upload-into { - margin: 0 0 var(--space-3); -} - -/* The way back from a page reached from somewhere else, above its header. */ -.page-back { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-height: 2.25rem; - margin: 0 0 var(--space-3); - padding: var(--space-1) var(--space-2); - border: 0; - border-radius: 0.5rem; - background: transparent; - color: var(--muted); -} - -.page-back:hover { - background: var(--panel-soft); - color: var(--text); -} - -/* A list WaveFlow keeps itself, not a folder anyone made. */ -.folder-badge { - margin-inline-start: var(--space-2); - padding: 0.1rem var(--space-2); - border: 1px solid var(--line-strong); - border-radius: 999px; - color: var(--muted); - font-size: var(--text-xs); - vertical-align: middle; -} - -/* "Needs attention" (RFC-015). The lists read as the folder cards do, one - per row of a wrapping grid; an empty list stays where it is, greyed, so the - page keeps its shape as lists empty, as on the desktop. */ -.attention-categories { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); - gap: var(--space-2); - margin: 0 0 var(--space-6); - padding: 0; - list-style: none; -} - -.attention-category { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--line); - border-radius: 0.7rem; - background: var(--panel-soft); - transition: - border-color 180ms ease, - background-color 180ms ease; -} - -.attention-category:hover { - border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); - background: var(--panel-strong); -} - -.attention-category[aria-current="page"] { - border-color: var(--accent); - background: var(--accent-soft); -} - -.attention-count { - font-variant-numeric: tabular-nums; -} - -.attention-category.empty { - color: var(--muted); -} - -.attention-note, -.attention-notice { - margin: 0 0 var(--space-4); - font-size: var(--text-sm); -} - -.attention-views { - display: flex; - gap: var(--space-2); - margin: 0 0 var(--space-4); -} - -.attention-views button[aria-pressed="true"] { - border-color: var(--accent); - background: var(--accent-soft); -} - -.attention-rows { - display: grid; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; -} - -.attention-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2) var(--space-4); - padding: var(--space-3); - border: 1px solid var(--line); - border-radius: 0.7rem; - background: var(--panel-soft); -} - -.attention-row-main { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.attention-credit { - overflow-wrap: anywhere; - font-weight: 600; -} - -.attention-fragments { - display: flex; - flex-wrap: wrap; - grid-column: 1; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; -} - -.attention-fragment { - display: inline-block; - padding: var(--space-1) var(--space-2); - border: 1px solid var(--line-strong); - border-radius: 999px; - font-size: var(--text-sm); -} - -/* A fragment the library already knows is the strongest hint a split is - right, so it reads as a link to that artist. */ -.attention-fragment.known { - border-color: var(--accent); - background: var(--accent-soft); -} - -.attention-actions { - display: flex; - flex-wrap: wrap; - grid-column: 2; - grid-row: 1 / span 2; - gap: var(--space-2); -} - -@media (max-width: 40rem) { - .attention-actions { - grid-column: 1; - grid-row: auto; - } -} - -.attention-group h3 { - margin: var(--space-4) 0 var(--space-2); - font-size: var(--text-sm); -} - -.artist-split { - margin-top: var(--space-2); -} - -.artist-image-button.armed { - border-color: var(--accent); - background: var(--accent-soft); -} - -/* The artist header's backdrop (RFC-013): the banner chosen by hand, framed at - a fixed point as the desktop frames it, or else the portrait, blurred. The - gradient keeps the title legible over any picture. - - The portrait's blurred copy is only light, and takes the material's wash - (`--fe-wash`, in fluid.css): the dark ground and light text on a dark - theme, the paper and dark ink on a light one. The banner is a photograph, - which no wash of paper makes legible, so over it the scrim is dark and the - text light whatever the theme — and stronger than the portrait's, because - a pale sky under a 15 % scrim left the corner buttons and the kicker at - half the contrast AA asks for. - - A dialog opened from the header sits inside it in the tree and carries its - own colours, so nothing here reaches into one. */ -.artist-hero { - --hero-wash-from: 15%; - --hero-wash-mid: 50%; - --hero-wash-to: 82%; - --hero-wash-at: 55%; - - position: relative; - isolation: isolate; - overflow: hidden; -} - -:root:not(.dark) .artist-hero, -.artist-hero.has-banner { - --hero-wash-from: 30%; - --hero-wash-mid: 62%; - --hero-wash-to: 86%; - --hero-wash-at: 40%; -} - -.artist-hero.has-banner { - --fe-wash: #0b0b0e; - --fe-on-wash: #fff; - --fe-on-wash-2: rgb(255 255 255 / 88%); - --fe-on-wash-tone: rgb(0 0 0 / 58%); - --fe-on-wash-line: rgb(255 255 255 / 22%); - - min-height: clamp(18rem, 32vw, 27rem); -} - -.artist-hero.has-backdrop { - color: var(--fe-on-wash); -} - -.artist-hero.has-backdrop :is(.muted, .eyebrow):not(:where(dialog *)) { - color: var(--fe-on-wash-2); -} - -.artist-banner.cover { - position: absolute; - z-index: -2; - inset: 0; - width: 100%; - height: 100%; - max-width: none; - border: 0; - border-radius: 0; - object-fit: cover; - object-position: center 30%; - pointer-events: none; -} - -.artist-hero-ambient.cover { - position: absolute; - z-index: -2; - inset: -35%; - width: 170%; - height: 170%; - max-width: none; - border: 0; - border-radius: 0; - object-fit: cover; - filter: blur(5rem) saturate(1.3); - opacity: 0.6; - pointer-events: none; -} - -.artist-hero.has-backdrop::before { - position: absolute; - z-index: -1; - inset: 0; - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--fe-wash) var(--hero-wash-from), transparent) 0%, - color-mix(in srgb, var(--fe-wash) var(--hero-wash-mid), transparent) - var(--hero-wash-at), - color-mix(in srgb, var(--fe-wash) var(--hero-wash-to), transparent) 100% - ); - content: ""; -} - -/* Out of the flow on a wide screen, in the header's top corner, so the - controls never narrow the title beside the portrait. */ -.artist-image-controls { - position: absolute; - top: var(--space-6); - right: clamp(var(--space-6), 4vw, var(--space-12)); - display: grid; - justify-items: end; - gap: var(--space-2); - max-width: 50%; -} - -.artist-image-button { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-height: 2.25rem; - padding: var(--space-1) var(--space-3); - border-radius: 999px; - font-size: var(--text-sm); -} - -.artist-hero.has-backdrop .artist-image-button { - border: 1px solid var(--fe-on-wash-line); - background: var(--fe-on-wash-tone); - color: var(--fe-on-wash); - backdrop-filter: blur(6px); -} - -/* Editorial pages: the image actions behind one button. It holds the corner - the row of five used to fill, and says it is a menu by its dots as much as - by its name. */ -.artist-image-menu { - gap: var(--space-1); - padding-inline: var(--space-3) var(--space-4); -} - -.artist-image-notice { - margin: 0; - font-size: var(--text-sm); -} - -/* The Deezer picker borrows the folder picker's dialog, wider for a grid of - faces. */ -.deezer-picker[open] { - width: min(52rem, calc(100vw - 2rem)); - max-height: min(48rem, calc(100dvh - 2rem)); -} - -.deezer-search { - display: flex; - gap: var(--space-2); -} - -.deezer-search-input { - flex: 1; - min-width: 0; -} - -.deezer-search-button { - display: inline-flex; - align-items: center; - gap: var(--space-2); -} - -.deezer-candidates { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); - gap: var(--space-3); - margin: 0; - padding: 0; - list-style: none; -} - -.deezer-candidate { - display: grid; - justify-items: center; - gap: var(--space-1); - width: 100%; - padding: var(--space-2); - text-align: center; -} - -.deezer-candidate-preview { - width: 100%; - aspect-ratio: 1; - border-radius: 50%; - object-fit: cover; -} - -.deezer-candidate-name { - max-width: 100%; - overflow-wrap: anywhere; - font-weight: 600; -} - -@media (max-width: 820px) { - .artist-hero { - flex-wrap: wrap; - } - - /* Stacked, the controls go under the name and the hero doubles in height - or more, as their rows wrap: the kicker then sits a fifth of the way down, - where the wash had barely begun. It reaches its middle strength there - instead — and, where the text is held to the stronger wash, starts at it, - since how far down the kicker lands depends on how many rows wrap. */ - .artist-hero, - :root:not(.dark) .artist-hero, - .artist-hero.has-banner { - --hero-wash-at: 22%; - } - - :root:not(.dark) .artist-hero, - .artist-hero.has-banner { - --hero-wash-from: var(--hero-wash-mid); - } - - .artist-image-controls { - position: static; - width: 100%; - max-width: none; - justify-items: start; - } -} - -/* The quality page (RFC-016). The verdicts read as the lists of "Needs - attention" do, one card per verdict in a wrapping grid, an empty one - greyed; the verdict itself is a pill whose colour says how much it accuses - the file of, and the words say the rest. */ -.quality-help { - max-width: 62ch; - margin: 0 0 var(--space-4); - font-size: var(--text-sm); -} - -.quality-verdicts { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); - gap: var(--space-2); - margin: 0 0 var(--space-4); - padding: 0; - list-style: none; -} - -/* Where the analysis stands. The dot pulses while it works, and is still for - whoever asked the system for less motion: the words say the same thing. */ -.quality-status { - display: flex; - align-items: center; - gap: var(--space-2); - margin: 0 0 var(--space-3); - font-size: var(--text-sm); -} - -.quality-status.idle, -.quality-status.off { - color: var(--muted); -} - -.quality-status-dot { - flex: none; - width: 0.55rem; - height: 0.55rem; - border-radius: 50%; - background: var(--accent); - animation: quality-pulse 1.4s ease-in-out infinite; -} - -.quality-status.waiting_for_scan .quality-status-dot, -.quality-status.queued .quality-status-dot { - background: var(--muted); -} - -@keyframes quality-pulse { - 50% { - opacity: 0.35; - } -} - -@media (prefers-reduced-motion: reduce) { - .quality-status-dot { - animation: none; - } -} - -.quality-format { - display: inline-flex; - align-items: center; - gap: var(--space-2); - margin: 0 0 var(--space-4); - font-size: var(--text-sm); -} - -.quality-verdict { - display: inline-block; - padding: 0.1rem var(--space-2); - border: 1px solid var(--line-strong); - border-radius: 999px; - font-size: var(--text-xs); - white-space: nowrap; -} - -.quality-verdict.finding { - border-color: var(--danger); - color: var(--danger); -} - -.quality-verdict.mild { - border-color: var(--warn); - color: var(--warn); -} - -.quality-verdict.neutral { - color: var(--muted); -} - -.quality-highs { - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.quality-analyse { - display: grid; - justify-items: end; - gap: var(--space-1); -} - -.quality-analyse-notice { - margin: 0; - font-size: var(--text-sm); -} - -.quality-explain { - margin: 0 0 var(--space-3); - font-size: var(--text-sm); -} - -/* Editorial pages: the caution under the measured bit depth, set as the - explanation above the figures is. */ -.quality-note { - max-width: 62ch; - margin: var(--space-2) 0 var(--space-3); - font-size: var(--text-xs); -} - -.quality-spectrum { - margin: var(--space-3) 0; -} - -.quality-spectrum svg { - display: block; - width: 100%; - height: 9rem; - overflow: visible; -} - -.quality-spectrum figcaption { - margin-top: var(--space-1); - font-size: var(--text-xs); -} - -.quality-curve { - fill: none; - stroke: var(--accent); - stroke-width: 1.5; - vector-effect: non-scaling-stroke; -} - -.quality-wall { - stroke: var(--danger); - stroke-width: 1.5; - stroke-dasharray: 4 3; - vector-effect: non-scaling-stroke; -} - -.quality-gridline line { - stroke: var(--line-strong); - stroke-width: 1; - vector-effect: non-scaling-stroke; -} - -.quality-gridline text { - fill: var(--muted); - font-size: 10px; -} - -.quality-spectrogram { - margin: var(--space-3) 0 0; -} - -.quality-spectrogram img { - display: block; - width: 100%; - height: auto; - border-radius: 0.5rem; -} - -.quality-spectrogram-action { - margin-top: var(--space-3); -} - -/* "Missing files" (RFC-021): a row is a checkbox and what it names, then when - it went and what a purge would take. */ -.missing-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin: var(--space-3) 0; -} - -.missing-list li { - grid-template-columns: minmax(12rem, 1fr) auto; -} - -.missing-row { - display: flex; - align-items: flex-start; - gap: var(--space-3); - min-width: 0; -} - -.missing-text, -.missing-facts { - display: grid; - min-width: 0; -} - -.missing-facts { - justify-items: end; - text-align: end; -} - -.missing-path { - overflow-wrap: anywhere; -} - -/* The deletion of a library sits apart from the form that edits it, so the - button that saves a name is never the one beside the one that deletes. */ -.danger-zone { - margin-top: var(--space-4); - padding-top: var(--space-3); - border-top: 1px solid var(--line); -} - -.danger-note { - margin: 0; - font-size: var(--text-sm); -} - -/* A setting that is on or off, said in a sentence beside its box. */ -.toggle-row { - display: flex; - align-items: center; - gap: var(--space-2); - color: var(--text); - font-size: var(--text-sm); -} - -.toggle-box { - flex: none; - width: 1.1rem; - min-height: 1.1rem; - padding: 0; -} - -/* Smart playlists (RFC-025): beside the ordinary ones, set apart by a rule - above them and a word after their name. */ -.smart-playlists { - margin-top: var(--space-8); - padding-top: var(--space-6); - border-top: 1px solid var(--line); -} - -.smart-badge { - font-size: var(--text-sm); - color: var(--accent); -} - -.smart-conditions { - display: grid; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; -} - -/* A condition reads as a sentence: field, comparison, value. */ -.smart-condition { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); -} - -.smart-editor .smart-condition > input { - flex: 1 1 10rem; - min-width: 0; -} - -.smart-preview ol { - margin: var(--space-2) 0 0; - padding-left: var(--space-6); -} - -.podcast-episodes { - margin-top: var(--space-6); -} - -/* The mixes the server drew today (RFC-024), one shelf over the albums. A row - that scrolls sideways rather than a grid that wraps: a shelf is a glance at - what is on offer, and the albums below are what the page is for. */ -.mix-shelf { - margin-bottom: var(--space-8); -} - -.mix-shelf h3 { - margin: 0; - font-size: var(--text-lg); -} - -.mix-shelf ul { - display: grid; - grid-auto-columns: minmax(9rem, 11rem); - grid-auto-flow: column; - gap: var(--space-4); - margin: 0; - padding: 0 0 var(--space-2); - overflow-x: auto; - list-style: none; - overscroll-behavior-x: contain; -} - -.mix-shelf li { - position: relative; - min-width: 0; -} - -.mix-card { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.mix-card-title { - margin-top: var(--space-2); - overflow: hidden; - font-size: var(--text-sm); - text-overflow: ellipsis; - white-space: nowrap; -} - -.mix-card-detail { - font-size: var(--text-xs); -} - -.mix-shelf li:hover .card-actions, -.mix-shelf li:focus-within .card-actions { - opacity: 1; -} - -/* ── Editorial pages: the mixes set apart from the albums ─────────────────── - The shelf names itself and says what a mix is; each card sits on a tinted - ground of its own, its cover is a mosaic of four sleeves where the mix has - four, and a "Mix" marker rides on the cover — so nobody takes a generated - playlist for another record. The albums under it carry their own heading. */ -.mix-shelf-header { - display: grid; - gap: var(--space-1); - margin-bottom: var(--space-4); -} - -.mix-shelf-detail { - margin: 0; - font-size: var(--text-sm); -} - -.mix-shelf .mix-card { - padding: var(--space-2) var(--space-2) var(--space-3); - border: 1px solid var(--line); - border-radius: 1rem; - background: color-mix(in srgb, var(--accent-soft) 60%, var(--panel-soft)); -} - -.mix-cover { - position: relative; - display: block; -} - -.mix-mosaic { - display: grid; - overflow: hidden; - grid-template-columns: repeat(2, minmax(0, 1fr)); - aspect-ratio: 1; - border: 1px solid var(--line); - border-radius: 0.85rem; -} - -/* Square tiles meeting edge to edge; the mosaic rounds the four together. - Two classes deep, or the material's own `.cover` radius, loaded later, - wins the tie. */ -.mix-cover .mix-mosaic > * { - border: 0; - border-radius: 0; -} - -.mix-badge { - position: absolute; - bottom: 0.5rem; - left: 0.5rem; - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - border-radius: 999px; - background: var(--text); - color: var(--bg); - font-size: var(--text-xs); - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.albums-grid-title { - margin: 0 0 var(--space-4); - font-size: var(--text-lg); -} - -/* ── End of the mixes shelf ── */ - -/* Statistics (RFC-024): five figures, a timeline of bars, four top lists. */ -.stats-totals { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); - gap: var(--space-3); - margin: 0 0 var(--space-2); -} - -.stats-total { - padding: var(--space-3) var(--space-4); - border: 1px solid var(--line); - border-radius: 0.75rem; - background: var(--panel-soft); -} - -.stats-totals dt { - color: var(--muted); - font-size: var(--text-xs); -} - -.stats-totals dd { - margin: var(--space-1) 0 0; - font-size: var(--text-xl); - font-variant-numeric: tabular-nums; -} - -.stats-page h3 { - margin: var(--space-6) 0 var(--space-3); - font-size: var(--text-lg); -} - -/* Each bar is its bucket's share of the busiest one; an empty bucket keeps a - hairline so the period still reads as a row of days. */ -.stats-timeline { - display: flex; - align-items: flex-end; - gap: var(--space-1); - height: 8rem; - margin: 0; - padding: 0; - list-style: none; -} - -.stats-timeline li { - display: flex; - flex: 1 1 0; - align-items: flex-end; - height: 100%; - min-width: 0.25rem; -} - -.stats-timeline span { - width: 100%; - height: max(2px, calc(var(--share, 0) * 100%)); - border-radius: 0.25rem 0.25rem 0 0; - background: var(--accent); -} - -.stats-columns { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); - gap: 0 var(--space-8); -} - -.stats-list { - display: grid; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; -} - -.stats-list li { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - min-width: 0; -} - -/* The cover, then the names stacked beside it. */ -.stats-entry { - display: flex; - align-items: center; - gap: var(--space-3); - min-width: 0; - padding: 0; - border: 0; - background: none; - color: inherit; - text-align: left; -} - -.stats-art { - width: 2.75rem; - flex: 0 0 auto; - border-radius: 0.45rem; -} - -/* An artist is a portrait, round as on the artists page. */ -.stats-art-round { - border-radius: 50%; -} - -.stats-art span { - font-size: var(--text-base); -} - -.stats-text { - display: grid; - min-width: 0; -} - -.stats-name, -.stats-sub { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stats-sub { - font-size: var(--text-xs); -} - -.stats-count { - flex: none; - color: var(--muted); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -.stats-moments { - display: grid; - gap: var(--space-2); - margin: var(--space-6) 0 0; -} - -.stats-moments dt { - color: var(--muted); - font-size: var(--text-xs); -} - -.stats-moments dd { - margin: 0; -} - -.lyrics-credit { - margin-top: var(--space-2); -} - -/* ── Workspace pages ─────────────────────────────────────────── - Radio, Podcasts, Folders and the Maintenance hub manage something rather - than present it, and a wide window is room for more of their cards and - rows. Their routes mark `main` with `.workspace` (see `staticData.layout` - in `main.tsx`); every other page keeps the contained column. The class - outranks every sheet's plain `main` rule, and on a phone `min()` resolves - to the full width those rules already give, gutters untouched. */ -main.workspace { - width: min(100%, 150rem); -} - -/* The radio page: one card per station, its image, its name and the button - that plays it; the rest behind the card's "⋯" menu. */ -.station-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 12.5rem), 1fr)); - gap: var(--space-6); - margin: 0; - padding: 0; - list-style: none; -} - -/* Two to a row on a phone: one card the width of the screen is a poster, - and a short list of stations should be seen at a glance. */ -@media (max-width: 820px) { - .station-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4); - } - - .station-art .station-play { - right: var(--space-2); - bottom: var(--space-2); - width: 2.75rem; - min-height: 2.75rem; - } -} - -.station-card { - display: grid; - align-content: start; - gap: var(--space-3); - min-width: 0; -} - -.station-art { - position: relative; -} - -.station-cover { - width: 100%; -} - -/* On the image, where the eye already is, as a cover's play button is. */ -.station-art .station-play { - position: absolute; - right: var(--space-3); - bottom: var(--space-3); - display: grid; - width: 3rem; - min-height: 3rem; - padding: 0; - place-items: center; - border-color: transparent; - border-radius: 999px; - background: var(--accent); - color: var(--fe-accent-ink, var(--bg)); - box-shadow: var(--shadow); -} - -.station-art .station-play:hover { - background: var(--accent); - filter: brightness(1.08); -} - -.station-copy { - display: grid; - gap: var(--space-1); - min-width: 0; -} - -.station-title { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); -} - -.station-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.station-url { - overflow: hidden; - font-size: var(--text-xs); - text-overflow: ellipsis; - white-space: nowrap; -} - -.station-note { - font-size: var(--text-xs); -} - -.station-link { - justify-self: start; - color: var(--accent); - font-size: var(--text-xs); -} - -/* A card's "⋯": quiet until it is pointed at, a full target all the same. */ -.action-menu-button { - display: grid; - width: 2.25rem; - min-height: 2.25rem; - padding: 0; - place-items: center; - border-color: transparent; - border-radius: 999px; - background: transparent; - color: var(--muted); -} - -.action-menu-button:hover, -.action-menu-button[aria-expanded="true"] { - color: var(--text); -} - -.track-menu-item.danger { - background: transparent; - color: var(--danger); -} - -.track-menu-item.danger:hover, -.track-menu-item.danger:focus-visible { - background: color-mix(in srgb, var(--danger) 12%, transparent); -} - -/* The station form, in a dialog drawn as the folder picker is. */ -.station-dialog[open] { - display: grid; - gap: var(--space-4); - width: min(34rem, calc(100vw - 2rem)); - max-height: min(48rem, calc(100dvh - 2rem)); - padding: var(--space-6); - overflow-y: auto; - border: 1px solid var(--line-strong); - border-radius: 1rem; - background: var(--panel); - color: var(--text); - box-shadow: var(--shadow); -} - -/* A page's blocks rise into place as it opens (`fe-rise` in fluid.css), and - a dialog mounted later as one of them rose with them: invisible for the - delay, then fading in under the reader's click. A dialog opens at once. */ -main > section > dialog { - animation: none; -} - -.station-dialog::backdrop { - background: color-mix(in srgb, black 55%, transparent); -} - -.station-dialog header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-3); -} - -.station-dialog h2 { - margin: 0; - font-size: var(--text-lg); - overflow-wrap: anywhere; -} - -.station-dialog .radio-form { - margin: 0; -} - -/* The form's notes sit in its grid gap, without margins of their own. */ -.station-dialog .radio-form > :where(p) { - margin: 0; -} - -/* An empty page that is new to somebody: a discreet symbol, what the page - is for, and the first step when the reader may take it. */ -.empty-state-rich { - align-content: center; - gap: var(--space-3); - padding: var(--space-8) var(--space-6); -} - -.empty-state-icon { - display: grid; - width: 3.5rem; - height: 3.5rem; - place-items: center; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent); -} - -.empty-state-title { - margin: 0; - color: var(--text); - font-weight: 600; -} - -.empty-state-detail { - margin: 0; - max-width: 36rem; - font-size: var(--text-sm); -} - -.empty-state-actions { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: var(--space-3); - margin-top: var(--space-2); -} - -/* The folders page: To sort apart from the folders it does not count. */ -.folder-none { - margin: 0 0 var(--space-6); -} - -.folder-system { - display: grid; - gap: var(--space-2); - margin-bottom: var(--space-6); -} - -.folder-system h3 { - margin: 0; - font-size: var(--text-base); -} - -.folder-system-note { - max-width: 48rem; - margin: 0 0 var(--space-2); - font-size: var(--text-sm); -} - -.folder-system .folder-list { - margin-bottom: 0; -} - -/* Drawn in outline, not filled: a tray WaveFlow keeps, not a folder. */ -.folder-row.system { - border-style: dashed; - background: transparent; -} - -/* Read by a screen reader, not drawn: a status the eye gets from the page. */ -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - border: 0; - clip-path: inset(50%); - white-space: nowrap; -} - -/* The podcasts page in two panels from 1024 px of window: the shows on the - left, the open show's episodes beside them. The same breakpoint as - `TWO_PANELS` in `podcasts-page.tsx`, which also opens the first show when - none is named. Below it the episodes stack under the list, as before. */ -@media (width >= 64rem) { - .podcast-workspace { - display: grid; - /* A third of the room for the shows, never more than 22rem: at 1024 px - the episodes, which carry the buttons, keep the larger share. */ - grid-template-columns: minmax(14rem, min(22rem, 34%)) minmax(0, 1fr); - align-items: start; - gap: var(--space-6); - } - - /* A narrow column: the name over the buttons, rather than beside them. */ - .podcast-workspace .podcast-channels li { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2) var(--space-3); - padding-inline: var(--space-3); - } - - .podcast-workspace .podcast-channels li > .resource-with-cover { - flex: 1 1 100%; - } - - .podcast-workspace .podcast-channels li.selected { - background: var(--fe-tone-2, var(--panel-strong)); - box-shadow: inset 2px 0 0 var(--accent); - } - - /* Held in view while the list scrolls, and scrolled on its own when it is - longer than the window, above the player bar: a long feed never pushes - the shows out of reach. */ - .podcast-workspace .podcast-episodes { - position: sticky; - top: var(--space-6); - max-height: calc(100dvh - 9rem); - margin-top: 0; - overflow-y: auto; - overscroll-behavior: contain; - } -} diff --git a/webapp/src/styles/styles/catalog.css b/webapp/src/styles/styles/catalog.css new file mode 100644 index 00000000..2efab33e --- /dev/null +++ b/webapp/src/styles/styles/catalog.css @@ -0,0 +1,510 @@ +/* Page headers, the cover grid, detail heads, lists and the song table. */ + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-6); + margin-bottom: var(--space-8); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--line); +} + +.page-header h2, +.page-header p, +.collection h3, +.admin-panel h3 { + margin: 0; +} + +.page-header h2 { + font-size: clamp(var(--text-2xl), 4vw, var(--text-5xl)); + line-height: 1; +} + +.page-heading { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.page-header p { + max-width: 28rem; +} + +.page-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-3); +} + +.control { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.control > span { + overflow: hidden; + color: var(--muted); + font-size: var(--text-xs); + letter-spacing: 0.04em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.control select, +.control input { + min-height: 2.4rem; + padding-block: var(--space-2); + font-size: var(--text-sm); +} + +.control input { + width: min(14rem, 40vw); +} + +@media (width <= 52rem) { + .page-header { + flex-direction: column; + align-items: stretch; + } + + .page-controls { + align-items: stretch; + } + + .control input { + width: 100%; + } +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); + gap: clamp(var(--space-4), 2.5vw, var(--space-8)); + padding: 0; + margin: 0; + list-style: none; +} + +.grid a { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.grid span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Two lines rather than one: single-line truncation cut nearly every title in + a real catalogue, and the second line costs a fixed 2.4em whether it is used + or not, so the covers below stay on a common baseline. */ +.grid strong { + display: -webkit-box; + overflow: hidden; + min-height: 2.4em; + margin-top: var(--space-2); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + font-size: var(--text-sm); + line-height: 1.2; +} + +.grid span { + font-size: var(--text-xs); +} + +/* The card is a positioning context for its actions; the link stays the whole + card, and the buttons sit above it rather than inside it — a button nested in + an anchor is neither valid nor operable by keyboard. */ +.grid li { + position: relative; +} + +.card-actions { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: flex; + gap: var(--space-1); + opacity: 0; + transition: + opacity 160ms ease, + translate 240ms ease; +} + +.card-action { + display: grid; + min-height: 2.1rem; + width: 2.1rem; + padding: 0; + border-color: transparent; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--bg) 72%, transparent); + backdrop-filter: blur(0.5rem); + place-items: center; +} + +.card-action:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.grid li:hover .card-actions, +.grid li:focus-within .card-actions { + opacity: 1; +} + +/* Travel with the cover so the buttons stay on it, not beside it. Keyboard + focus reveals them without the lift, which is what the cover does too. */ +.grid li:hover .card-actions { + translate: 0 -0.28rem; +} + +/* Pointer-only reveal hides the actions from touch, where there is no hover to + reveal them with. */ +@media (hover: none) { + .card-actions { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .card-actions { + transition: none; + } +} + +.cover { + display: grid; + width: 100%; + aspect-ratio: 1; + place-items: center; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 0.85rem; + background: var(--panel); + object-fit: cover; + transition: + transform 240ms ease, + box-shadow 240ms ease; +} + +.cover-fallback { + background: + radial-gradient(circle at 70% 20%, var(--accent-soft), transparent 60%), + var(--panel-strong); + color: var(--accent); +} + +.cover-fallback span { + font-family: var(--font-display); + font-size: clamp(var(--text-2xl), 5vw, var(--text-6xl)); + font-weight: 600; + opacity: 0.75; +} + +.cover.large { + width: clamp(10rem, 23vw, 18rem); + flex: 0 0 auto; + border-radius: 1.1rem; + box-shadow: var(--shadow); +} + +.grid li:hover .cover { + transform: translateY(-0.28rem); + box-shadow: var(--shadow); +} + +.detail-header, +.artist-hero { + display: flex; + align-items: flex-end; + gap: clamp(var(--space-6), 4vw, var(--space-16)); + margin: calc(var(--space-12) * -1) + calc(clamp(var(--space-6), 4vw, var(--space-20)) * -1) var(--space-8); + padding: var(--space-20) clamp(var(--space-6), 4vw, var(--space-20)) + var(--space-12); + border-bottom: 1px solid var(--line); + background: radial-gradient( + circle at 22% 15%, + var(--accent-soft), + transparent 30rem + ); +} + +.detail-header h2, +.artist-hero h2 { + max-width: 18ch; + margin: 0 0 var(--space-2); + font-size: clamp(var(--text-3xl), 6vw, var(--text-6xl)); + line-height: 0.94; + text-wrap: balance; +} + +.artist-portrait { + width: clamp(10rem, 23vw, 18rem); + flex: 0 0 auto; + border-radius: 50%; + box-shadow: var(--shadow); +} + +/* The local history sits between the hero and the discography, so it takes the + rhythm a section has everywhere else rather than the browser default. */ +.artist-played { + display: grid; + gap: var(--space-3); + margin-bottom: var(--space-8); +} + +.artist-played h3 { + margin: 0; + font-size: var(--text-xl); +} + +.artist-about, +.artist-discography, +.artist-similar { + display: grid; + gap: var(--space-3); + margin-bottom: var(--space-8); +} + +.artist-about h3, +.artist-discography h3, +.artist-similar h3 { + margin: 0; + font-size: var(--text-xl); +} + +/* Paragraphs arrive as line breaks in plain text, not as markup. */ +.artist-bio { + max-width: 70ch; + margin: 0; + white-space: pre-line; +} + +.artist-source { + margin: 0; + font-size: var(--text-sm); +} + +.list, +.queue-list, +.resource-list { + padding: 0; + margin: 0; + list-style: none; +} + +.list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) var(--space-1); + border-bottom: 1px solid var(--line); +} + +.artist-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 var(--space-8); +} + +.artist-list a { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); +} + +.artist-avatar { + width: 3.2rem; + flex: 0 0 auto; + border-radius: 50%; +} + +.artist-avatar span { + font-size: var(--text-lg); +} + +.songs-scroll { + width: 100%; + overflow-x: auto; +} + +.songs { + width: 100%; + border-collapse: collapse; +} + +.songs tr { + transition: background-color 180ms ease; +} + +.songs tr:hover { + background: var(--accent-soft); +} + +.songs tr.active { + background: var(--accent-soft); + color: var(--accent); +} + +.songs td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +/* Each credited artist is a link; at rest it reads as the muted text it was, + and says it can be followed only when pointed at or focused. */ +.song-artists a:hover, +.song-artists a:focus-visible { + color: var(--text); + text-decoration: underline; +} + +.songs .index { + width: 2.5rem; + color: var(--muted); + font-variant-numeric: tabular-nums; + text-align: center; +} + +button.link, +button.star { + min-height: 0; + padding: 0; + border: 0; + background: none; + text-align: left; +} + +button.link:hover, +button.star:hover { + background: none; + color: var(--accent); +} + +.song-title { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.song-title > span { + display: grid; +} + +.song-title small { + color: var(--muted); + font-size: var(--text-xs); +} + +.song-cover { + width: 2.7rem; + flex: 0 0 auto; + border-radius: 0.42rem; +} + +.song-cover span { + font-size: var(--text-base); +} + +button.star { + display: grid; + color: var(--muted); + font-size: var(--text-lg); + place-items: center; +} + +button[aria-pressed="true"].star { + color: var(--accent); +} + +/* Favourite and rating share a cell. They are different questions — "keep + this" and "how good is it" — and the table has no room for two columns. + The flex box is the inner div, not the cell: a `td` set to `display: flex` + stops being a table cell and its row's borders drift out of line. */ +.song-marks { + width: 1%; + white-space: nowrap; +} + +.song-marks > div { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} + +/* A radio group, so one of five is the shape the assistive layer sees and + arrow keys select. The inputs stay in the accessibility tree and out of the + picture; the stars beside them carry the paint. */ +.rating { + display: flex; + padding: 0; + border: 0; + margin: 0; + gap: 0.05rem; +} + +.rating label { + position: relative; + display: grid; + color: color-mix(in srgb, var(--muted) 45%, transparent); + cursor: pointer; + font-size: var(--text-base); + line-height: 1; + place-items: center; +} + +.rating label.on { + color: var(--accent); +} + +.rating:hover label { + color: color-mix(in srgb, var(--muted) 70%, transparent); +} + +.rating input { + position: absolute; + min-height: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + margin: 0; + appearance: none; + background: none; + cursor: pointer; + inset: 0; +} + +.rating input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* The stars a hovered rating would set, so the target is visible before the + click commits it. `:has` walks back up from the hovered label. */ +.rating:has(label:nth-child(1):hover) label:nth-child(-n + 1), +.rating:has(label:nth-child(2):hover) label:nth-child(-n + 2), +.rating:has(label:nth-child(3):hover) label:nth-child(-n + 3), +.rating:has(label:nth-child(4):hover) label:nth-child(-n + 4), +.rating:has(label:nth-child(5):hover) label:nth-child(-n + 5) { + color: var(--accent); +} + +button.danger { + color: var(--danger); +} diff --git a/webapp/src/styles/styles/folders.css b/webapp/src/styles/styles/folders.css new file mode 100644 index 00000000..9711cce8 --- /dev/null +++ b/webapp/src/styles/styles/folders.css @@ -0,0 +1,352 @@ +/* The folders page and its picker, and the "Needs attention" page. */ + +/* The folders page (RFC-012). The trail first, each crumb a link back up; the + folder being looked at says so through `aria-current`. */ +.folder-crumbs ol { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin: 0 0 var(--space-3); + padding: 0; + list-style: none; + font-size: var(--text-sm); +} + +.folder-crumbs li + li::before { + margin-right: var(--space-1); + color: var(--muted); + content: "/"; +} + +.folder-crumbs li a { + color: var(--muted); + overflow-wrap: anywhere; +} + +.folder-crumbs li a:hover, +.folder-crumbs li a[aria-current="page"] { + color: var(--text); +} + +.folder-note { + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.folder-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); + gap: var(--space-2); + margin: 0 0 var(--space-6); + padding: 0; + list-style: none; +} + +.folder-row { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel-soft); + transition: + border-color 180ms ease, + background-color 180ms ease; +} + +.folder-row:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + background: var(--panel-strong); +} + +.folder-row svg { + grid-row: span 2; + color: var(--accent); +} + +.folder-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A link that leaves for the upload screen, drawn as the buttons beside it. */ +.folder-upload { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 2.55rem; + padding: var(--space-2) var(--space-4); + border: 1px solid var(--line-strong); + border-radius: 0.55rem; + background: var(--panel-strong); +} + +.folder-upload:hover { + border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); +} + +/* The filed tree (RFC-012 decision 2). A folder a drag hovers over says it + will take the drop. */ +.folder-row.drop-target { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, var(--panel-soft)); +} + +.folder-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-2); + margin: 0 0 var(--space-4); +} + +.folder-form-field { + display: grid; + flex: 1 1 14rem; + gap: var(--space-1); +} + +.folder-notice { + margin: 0 0 var(--space-3); +} + +/* The spacing tokens go 4 then 6: there is no --space-5, and a padding naming + it is no padding at all — which is how this panel first shipped. */ +.folder-picker[open] { + display: grid; + gap: var(--space-6); + width: min(34rem, calc(100vw - 2rem)); + max-height: min(40rem, calc(100dvh - 2rem)); + padding: var(--space-6); + overflow-y: auto; + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +.folder-picker::backdrop { + background: color-mix(in srgb, black 55%, transparent); +} + +.folder-picker header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); +} + +.folder-picker h2 { + margin: 0; + font-size: var(--text-lg); + overflow-wrap: anywhere; +} + +.folder-pick-list { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.folder-pick { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + min-height: 3rem; + padding-block: var(--space-3); + padding-inline-end: var(--space-4); + text-align: start; +} + +.folder-pick span { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.upload-into { + margin: 0 0 var(--space-3); +} + +/* The way back from a page reached from somewhere else, above its header. */ +.page-back { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 2.25rem; + margin: 0 0 var(--space-3); + padding: var(--space-1) var(--space-2); + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--muted); +} + +.page-back:hover { + background: var(--panel-soft); + color: var(--text); +} + +/* A list WaveFlow keeps itself, not a folder anyone made. */ +.folder-badge { + margin-inline-start: var(--space-2); + padding: 0.1rem var(--space-2); + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--muted); + font-size: var(--text-xs); + vertical-align: middle; +} + +/* "Needs attention" (RFC-015). The lists read as the folder cards do, one + per row of a wrapping grid; an empty list stays where it is, greyed, so the + page keeps its shape as lists empty, as on the desktop. */ +.attention-categories { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-2); + margin: 0 0 var(--space-6); + padding: 0; + list-style: none; +} + +.attention-category { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel-soft); + transition: + border-color 180ms ease, + background-color 180ms ease; +} + +.attention-category:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + background: var(--panel-strong); +} + +.attention-category[aria-current="page"] { + border-color: var(--accent); + background: var(--accent-soft); +} + +.attention-count { + font-variant-numeric: tabular-nums; +} + +.attention-category.empty { + color: var(--muted); +} + +.attention-note, +.attention-notice { + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.attention-views { + display: flex; + gap: var(--space-2); + margin: 0 0 var(--space-4); +} + +.attention-views button[aria-pressed="true"] { + border-color: var(--accent); + background: var(--accent-soft); +} + +.attention-rows { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.attention-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2) var(--space-4); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel-soft); +} + +.attention-row-main { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.attention-credit { + overflow-wrap: anywhere; + font-weight: 600; +} + +.attention-fragments { + display: flex; + flex-wrap: wrap; + grid-column: 1; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.attention-fragment { + display: inline-block; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--line-strong); + border-radius: 999px; + font-size: var(--text-sm); +} + +/* A fragment the library already knows is the strongest hint a split is + right, so it reads as a link to that artist. */ +.attention-fragment.known { + border-color: var(--accent); + background: var(--accent-soft); +} + +.attention-actions { + display: flex; + flex-wrap: wrap; + grid-column: 2; + grid-row: 1 / span 2; + gap: var(--space-2); +} + +@media (max-width: 40rem) { + .attention-actions { + grid-column: 1; + grid-row: auto; + } +} + +.attention-group h3 { + margin: var(--space-4) 0 var(--space-2); + font-size: var(--text-sm); +} + +.artist-split { + margin-top: var(--space-2); +} + +.artist-image-button.armed { + border-color: var(--accent); + background: var(--accent-soft); +} diff --git a/webapp/src/styles/styles/maintenance.css b/webapp/src/styles/styles/maintenance.css new file mode 100644 index 00000000..7934d521 --- /dev/null +++ b/webapp/src/styles/styles/maintenance.css @@ -0,0 +1,225 @@ +/* Maintenance: the quality page, missing files, a library's danger zone. */ + +/* The quality page (RFC-016). The verdicts read as the lists of "Needs + attention" do, one card per verdict in a wrapping grid, an empty one + greyed; the verdict itself is a pill whose colour says how much it accuses + the file of, and the words say the rest. */ +.quality-help { + max-width: 62ch; + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.quality-verdicts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-2); + margin: 0 0 var(--space-4); + padding: 0; + list-style: none; +} + +/* Where the analysis stands. The dot pulses while it works, and is still for + whoever asked the system for less motion: the words say the same thing. */ +.quality-status { + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0 0 var(--space-3); + font-size: var(--text-sm); +} + +.quality-status.idle, +.quality-status.off { + color: var(--muted); +} + +.quality-status-dot { + flex: none; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--accent); + animation: quality-pulse 1.4s ease-in-out infinite; +} + +.quality-status.waiting_for_scan .quality-status-dot, +.quality-status.queued .quality-status-dot { + background: var(--muted); +} + +@keyframes quality-pulse { + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .quality-status-dot { + animation: none; + } +} + +.quality-format { + display: inline-flex; + align-items: center; + gap: var(--space-2); + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.quality-verdict { + display: inline-block; + padding: 0.1rem var(--space-2); + border: 1px solid var(--line-strong); + border-radius: 999px; + font-size: var(--text-xs); + white-space: nowrap; +} + +.quality-verdict.finding { + border-color: var(--danger); + color: var(--danger); +} + +.quality-verdict.mild { + border-color: var(--warn); + color: var(--warn); +} + +.quality-verdict.neutral { + color: var(--muted); +} + +.quality-highs { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.quality-analyse { + display: grid; + justify-items: end; + gap: var(--space-1); +} + +.quality-analyse-notice { + margin: 0; + font-size: var(--text-sm); +} + +.quality-explain { + margin: 0 0 var(--space-3); + font-size: var(--text-sm); +} + +/* Editorial pages: the caution under the measured bit depth, set as the + explanation above the figures is. */ +.quality-note { + max-width: 62ch; + margin: var(--space-2) 0 var(--space-3); + font-size: var(--text-xs); +} + +.quality-spectrum { + margin: var(--space-3) 0; +} + +.quality-spectrum svg { + display: block; + width: 100%; + height: 9rem; + overflow: visible; +} + +.quality-spectrum figcaption { + margin-top: var(--space-1); + font-size: var(--text-xs); +} + +.quality-curve { + fill: none; + stroke: var(--accent); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.quality-wall { + stroke: var(--danger); + stroke-width: 1.5; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; +} + +.quality-gridline line { + stroke: var(--line-strong); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.quality-gridline text { + fill: var(--muted); + font-size: 10px; +} + +.quality-spectrogram { + margin: var(--space-3) 0 0; +} + +.quality-spectrogram img { + display: block; + width: 100%; + height: auto; + border-radius: 0.5rem; +} + +.quality-spectrogram-action { + margin-top: var(--space-3); +} + +/* "Missing files" (RFC-021): a row is a checkbox and what it names, then when + it went and what a purge would take. */ +.missing-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: var(--space-3) 0; +} + +.missing-list li { + grid-template-columns: minmax(12rem, 1fr) auto; +} + +.missing-row { + display: flex; + align-items: flex-start; + gap: var(--space-3); + min-width: 0; +} + +.missing-text, +.missing-facts { + display: grid; + min-width: 0; +} + +.missing-facts { + justify-items: end; + text-align: end; +} + +.missing-path { + overflow-wrap: anywhere; +} + +/* The deletion of a library sits apart from the form that edits it, so the + button that saves a name is never the one beside the one that deletes. */ +.danger-zone { + margin-top: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--line); +} + +.danger-note { + margin: 0; + font-size: var(--text-sm); +} diff --git a/webapp/src/styles/styles/menus.css b/webapp/src/styles/styles/menus.css new file mode 100644 index 00000000..aa0c9a7e --- /dev/null +++ b/webapp/src/styles/styles/menus.css @@ -0,0 +1,266 @@ +/* The more sheet, the track menu and a track's properties. */ + +/* The sheet the seventh slot of the mobile bar opens. + A native ``: the focus trap, the Escape key and the inertness of + the page behind it come from the element rather than from code, which is + the difference between an accessibility gate that holds and one that has + to be rewritten each time the markup moves. */ +.more-sheet { + width: min(100%, 26rem); + max-width: 100%; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 1rem 1rem 0 0; + margin: auto auto 0; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +.more-sheet::backdrop { + background: color-mix(in srgb, var(--bg) 62%, transparent); + backdrop-filter: blur(0.3rem); +} + +.more-sheet header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4) var(--space-4) var(--space-3); + border-bottom: 1px solid var(--line); +} + +.more-sheet h2 { + margin: 0; + font-size: var(--text-lg); +} + +.more-sheet header p { + margin: var(--space-1) 0 0; + font-size: var(--text-sm); +} + +.more-sheet nav { + display: grid; + /* One column, and its own scroll: eight entries plus a header can outgrow a + short phone held in landscape, and a sheet whose last row cannot be + reached is the problem it was opened to solve. */ + max-height: min(60dvh, 28rem); + overflow-y: auto; + padding: var(--space-2); +} + +.more-sheet nav a { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 3rem; + padding: 0 var(--space-3); + border-radius: 0.7rem; +} + +.more-sheet nav a[data-status="active"] { + background: var(--accent-soft); + color: var(--accent); +} + +/* The right click on a track (RFC-012 decision 4). Fixed to the viewport at + the pointer, and moved back inside it by the component when it would hang + off an edge. */ +.track-menu { + position: fixed; + z-index: 40; + display: grid; + min-width: 13rem; + /* A track credited to many artists lists one entry each: capped at the + window, the menu scrolls on its own rather than running off the screen. */ + max-height: calc(100dvh - 2rem); + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--space-1); + border: 1px solid var(--line-strong); + border-radius: 0.75rem; + background: var(--panel-strong); + box-shadow: var(--shadow); +} + +.track-menu-item { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 2.25rem; + padding: var(--space-2) var(--space-3); + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--text); + font-size: var(--text-sm); + text-align: left; +} + +.track-menu-item:hover, +.track-menu-item:focus-visible { + background: var(--accent-soft); +} + +/* The arrow keys move focus through the menu, so where it is must be seen + on its own: a tint alone is too faint against the light themes' panel. */ +.track-menu-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* A track's properties: a sheet of facts, read-only. */ +.track-properties[open] { + display: grid; + gap: var(--space-6); + width: min(42rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); + padding: var(--space-6); + overflow-y: auto; + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +.track-properties::backdrop { + background: color-mix(in srgb, black 55%, transparent); +} + +.track-properties header { + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-4); +} + +.properties-cover { + width: 4.5rem; +} + +.track-properties h2 { + margin: 0; + font-size: var(--text-xl); + overflow-wrap: anywhere; +} + +.track-properties header p { + margin: var(--space-1) 0 0; +} + +.properties-close { + display: grid; + width: 2.25rem; + min-height: 2.25rem; + padding: 0; + place-items: center; +} + +.track-properties h3 { + margin: 0 0 var(--space-3); + font-size: var(--text-base); +} + +.property-list { + display: grid; + margin: 0; +} + +.property-row { + display: grid; + grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr); + gap: var(--space-4); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--line); +} + +.property-list dt { + color: var(--muted); + font-size: var(--text-sm); +} + +.property-list dd { + display: grid; + gap: var(--space-1); + margin: 0; + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.property-list code { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: var(--text-xs); +} + +.property-corrected { + color: var(--accent); + font-size: var(--text-xs); +} + +@media (width <= 35rem) { + .property-row { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-1); + } +} + +/* A download that could not start, said where it is seen: above the player, + whatever the length of the table the menu was opened from. */ +.track-menu-alert { + position: fixed; + z-index: 40; + bottom: 7.5rem; + left: 50%; + display: flex; + align-items: center; + gap: var(--space-3); + max-width: min(32rem, calc(100vw - 2rem)); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); + border: 1px solid var(--line-strong); + border-radius: 0.75rem; + background: var(--panel-strong); + color: var(--danger); + box-shadow: var(--shadow); + font-size: var(--text-sm); + translate: -50% 0; +} + +.track-menu-alert-dismiss { + display: grid; + width: 2rem; + min-height: 2rem; + padding: 0; + place-items: center; + border: 0; + background: transparent; + color: var(--text); +} + +/* The lyrics in a track's properties: each sheet with its source, and what + may be done with them under the list. */ +.lyrics-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.lyrics-action { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.lyrics-remove { + justify-self: start; + min-height: 1.75rem; + padding: 0 var(--space-2); + border: 0; + background: transparent; + color: var(--danger); + font-size: var(--text-xs); +} diff --git a/webapp/src/styles/styles/mixes-and-stats.css b/webapp/src/styles/styles/mixes-and-stats.css new file mode 100644 index 00000000..7d2c0296 --- /dev/null +++ b/webapp/src/styles/styles/mixes-and-stats.css @@ -0,0 +1,331 @@ +/* Toggles, smart playlists, the mixes shelf and the statistics page. */ + +/* A setting that is on or off, said in a sentence beside its box. */ +.toggle-row { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--text); + font-size: var(--text-sm); +} + +.toggle-box { + flex: none; + width: 1.1rem; + min-height: 1.1rem; + padding: 0; +} + +/* Smart playlists (RFC-025): beside the ordinary ones, set apart by a rule + above them and a word after their name. */ +.smart-playlists { + margin-top: var(--space-8); + padding-top: var(--space-6); + border-top: 1px solid var(--line); +} + +.smart-badge { + font-size: var(--text-sm); + color: var(--accent); +} + +.smart-conditions { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +/* A condition reads as a sentence: field, comparison, value. */ +.smart-condition { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.smart-editor .smart-condition > input { + flex: 1 1 10rem; + min-width: 0; +} + +.smart-preview ol { + margin: var(--space-2) 0 0; + padding-left: var(--space-6); +} + +.podcast-episodes { + margin-top: var(--space-6); +} + +/* The mixes the server drew today (RFC-024), one shelf over the albums. A row + that scrolls sideways rather than a grid that wraps: a shelf is a glance at + what is on offer, and the albums below are what the page is for. */ +.mix-shelf { + margin-bottom: var(--space-8); +} + +.mix-shelf h3 { + margin: 0; + font-size: var(--text-lg); +} + +.mix-shelf ul { + display: grid; + grid-auto-columns: minmax(9rem, 11rem); + grid-auto-flow: column; + gap: var(--space-4); + margin: 0; + padding: 0 0 var(--space-2); + overflow-x: auto; + list-style: none; + overscroll-behavior-x: contain; +} + +.mix-shelf li { + position: relative; + min-width: 0; +} + +.mix-card { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.mix-card-title { + margin-top: var(--space-2); + overflow: hidden; + font-size: var(--text-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.mix-card-detail { + font-size: var(--text-xs); +} + +.mix-shelf li:hover .card-actions, +.mix-shelf li:focus-within .card-actions { + opacity: 1; +} + +/* ── Editorial pages: the mixes set apart from the albums ─────────────────── + The shelf names itself and says what a mix is; each card sits on a tinted + ground of its own, its cover is a mosaic of four sleeves where the mix has + four, and a "Mix" marker rides on the cover — so nobody takes a generated + playlist for another record. The albums under it carry their own heading. */ +.mix-shelf-header { + display: grid; + gap: var(--space-1); + margin-bottom: var(--space-4); +} + +.mix-shelf-detail { + margin: 0; + font-size: var(--text-sm); +} + +.mix-shelf .mix-card { + padding: var(--space-2) var(--space-2) var(--space-3); + border: 1px solid var(--line); + border-radius: 1rem; + background: color-mix(in srgb, var(--accent-soft) 60%, var(--panel-soft)); +} + +.mix-cover { + position: relative; + display: block; +} + +.mix-mosaic { + display: grid; + overflow: hidden; + grid-template-columns: repeat(2, minmax(0, 1fr)); + aspect-ratio: 1; + border: 1px solid var(--line); + border-radius: 0.85rem; +} + +/* Square tiles meeting edge to edge; the mosaic rounds the four together. + Two classes deep, or the material's own `.cover` radius, loaded later, + wins the tie. */ +.mix-cover .mix-mosaic > * { + border: 0; + border-radius: 0; +} + +.mix-badge { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-radius: 999px; + background: var(--text); + color: var(--bg); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.albums-grid-title { + margin: 0 0 var(--space-4); + font-size: var(--text-lg); +} + +/* ── End of the mixes shelf ── */ + +/* Statistics (RFC-024): five figures, a timeline of bars, four top lists. */ +.stats-totals { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: var(--space-3); + margin: 0 0 var(--space-2); +} + +.stats-total { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--panel-soft); +} + +.stats-totals dt { + color: var(--muted); + font-size: var(--text-xs); +} + +.stats-totals dd { + margin: var(--space-1) 0 0; + font-size: var(--text-xl); + font-variant-numeric: tabular-nums; +} + +.stats-page h3 { + margin: var(--space-6) 0 var(--space-3); + font-size: var(--text-lg); +} + +/* Each bar is its bucket's share of the busiest one; an empty bucket keeps a + hairline so the period still reads as a row of days. */ +.stats-timeline { + display: flex; + align-items: flex-end; + gap: var(--space-1); + height: 8rem; + margin: 0; + padding: 0; + list-style: none; +} + +.stats-timeline li { + display: flex; + flex: 1 1 0; + align-items: flex-end; + height: 100%; + min-width: 0.25rem; +} + +.stats-timeline span { + width: 100%; + height: max(2px, calc(var(--share, 0) * 100%)); + border-radius: 0.25rem 0.25rem 0 0; + background: var(--accent); +} + +.stats-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 0 var(--space-8); +} + +.stats-list { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.stats-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; +} + +/* The cover, then the names stacked beside it. */ +.stats-entry { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + text-align: left; +} + +.stats-art { + width: 2.75rem; + flex: 0 0 auto; + border-radius: 0.45rem; +} + +/* An artist is a portrait, round as on the artists page. */ +.stats-art-round { + border-radius: 50%; +} + +.stats-art span { + font-size: var(--text-base); +} + +.stats-text { + display: grid; + min-width: 0; +} + +.stats-name, +.stats-sub { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stats-sub { + font-size: var(--text-xs); +} + +.stats-count { + flex: none; + color: var(--muted); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; +} + +.stats-moments { + display: grid; + gap: var(--space-2); + margin: var(--space-6) 0 0; +} + +.stats-moments dt { + color: var(--muted); + font-size: var(--text-xs); +} + +.stats-moments dd { + margin: 0; +} + +.lyrics-credit { + margin-top: var(--space-2); +} diff --git a/webapp/src/styles/styles/panels.css b/webapp/src/styles/styles/panels.css new file mode 100644 index 00000000..6d7ff4c1 --- /dev/null +++ b/webapp/src/styles/styles/panels.css @@ -0,0 +1,211 @@ +/* Forms, panels, resource lists, empty states and loading skeletons. */ + +.search, +.inline-form, +.actions, +.credential-form { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.search, +.inline-form { + margin-bottom: var(--space-6); +} + +.search input, +.inline-form input { + flex: 1 1 18rem; +} + +.stack { + display: grid; + gap: var(--space-4); +} + +.collection, +.admin-panel, +.user-row, +.empty-state, +.error-state { + padding: var(--space-6); + border: 1px solid var(--line); + border-radius: 0.9rem; + background: color-mix(in srgb, var(--panel) 82%, transparent); +} + +/* A grid item grows to its content's minimum width unless told otherwise, and + a playlist's table with its entry controls is wider than a phone: the card + pushed past the screen instead of letting the table scroll inside it. */ +.stack > .collection { + min-width: 0; +} + +.collection-header, +.user-row > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.collection-header > div:first-child, +.user-row header > div, +.resource-list li > div { + display: grid; + min-width: 0; +} + +.section-actions { + margin-bottom: var(--space-4); +} + +.queue-list li, +.resource-list li { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 0.7fr) auto auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--line); +} + +.queue-list li.active { + color: var(--accent); +} + +.resource-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resource-list li { + grid-template-columns: minmax(12rem, 1fr) auto auto; +} + +/* A station or a channel with its image beside its name: the image the + server fetched into the artwork store, or the generic symbol. */ +.resource-with-cover { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.resource-copy { + display: grid; + min-width: 0; +} + +.resource-cover { + width: 3rem; + flex: 0 0 3rem; + border-radius: 0.5rem; +} + +.podcast-cover { + width: 5rem; + border-radius: 0.75rem; +} + +.empty-state, +.error-state { + min-height: 11rem; + place-items: center; + color: var(--muted); + text-align: center; +} + +.empty-state, +.error-state { + display: grid; +} + +.empty-state p, +.error-state p { + max-width: 42rem; +} + +.admin-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.stacked-form { + display: grid; + gap: var(--space-3); + margin: var(--space-4) 0; +} + +.compact li { + grid-template-columns: minmax(10rem, 1fr) auto; +} + +.credential-form input { + flex: 1 1 16rem; +} + +.secret-output { + flex-basis: 100%; + word-break: break-all; +} + +.consent { + max-width: 34rem; +} + +.consent code { + word-break: break-all; +} + +.consent-actions { + display: flex; + gap: var(--space-3); + margin-top: var(--space-4); +} + +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); + gap: var(--space-6); +} + +.skeleton-card { + display: grid; + gap: var(--space-2); +} + +.skeleton-card span, +.skeleton-card i { + display: block; + border-radius: 0.55rem; + background: var(--panel-strong); + animation: pulse 1.6s ease-in-out infinite alternate; +} + +.skeleton-card span { + aspect-ratio: 1; +} + +.skeleton-card i { + width: 80%; + height: 0.7rem; +} + +.skeleton-card i:last-child { + width: 55%; + opacity: 0.72; +} + +@keyframes pulse { + to { + opacity: 0.45; + } +} diff --git a/webapp/src/styles/styles/phone.css b/webapp/src/styles/styles/phone.css new file mode 100644 index 00000000..c20a9f3f --- /dev/null +++ b/webapp/src/styles/styles/phone.css @@ -0,0 +1,258 @@ +/* The narrow layouts, from 820 px down, and reduced motion. */ + +@media (max-width: 820px) { + .shell { + display: block; + padding: var(--space-20) 0 11.5rem; + } + + .sidebar { + display: none; + } + + .mobile-header { + position: fixed; + z-index: 8; + inset: 0 0 auto; + display: flex; + min-height: 4.2rem; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--panel-soft) 88%, transparent); + backdrop-filter: blur(1.4rem); + } + + /* What a phone hides: every label whose control is already legible + without it, and the two the layout has no room for. One rule rather + than three spread through the blocks below, with its selectors running + from the least specific to the most: the same declaration for each, so + the order cannot change what any element gets, and none of them sits + behind a selector that already outranks it. */ + .player-progress span, + .mobile-navigation a span, + .mobile-navigation .more-button span, + .mobile-header .library-picker span, + .mobile-header .theme-picker span, + .mobile-header .language-picker span, + .mobile-header .brand small { + display: none; + } + + /* The sidebar is out of reach here, so the scope moves into the header + rather than becoming unreachable on a phone. */ + .mobile-header .library-picker { + margin: 0; + padding: 0; + } + + .mobile-header .library-picker select { + width: 7rem; + } + .mobile-header .theme-picker select { + width: 6.5rem; + } + .mobile-header .language-picker select { + width: 5.7rem; + } + + main { + width: 100%; + padding: var(--space-6) var(--space-4) var(--space-12); + } + + .mobile-navigation { + position: fixed; + z-index: 9; + right: 0.6rem; + bottom: 0.6rem; + left: 0.6rem; + display: grid; + /* As many equal columns as there are slots, counted by the browser. + This said `repeat(5, 1fr)` from the first design, and a sixth primary + entry was added later without touching it — so the bar had been + wrapping onto a second row ever since, which the suite could not see + because it measures horizontal overflow and a second row overflows + downwards. A number kept in a second place goes stale; this one cannot. */ + grid-auto-flow: column; + grid-auto-columns: 1fr; + padding: var(--space-2); + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: color-mix(in srgb, var(--panel) 91%, transparent); + box-shadow: var(--shadow); + backdrop-filter: blur(1.5rem); + } + + .mobile-navigation a, + .mobile-navigation .more-button { + display: grid; + min-height: 3rem; + place-items: center; + border-radius: 0.7rem; + color: var(--muted); + } + + /* The seventh slot is a button and not a link, so it has to be undressed + back to what the six beside it look like. */ + .mobile-navigation .more-button { + min-width: 0; + padding: 0; + border: 0; + background: none; + } + + .mobile-navigation a[data-status="active"] { + background: var(--accent-soft); + color: var(--accent); + } + + .player { + right: 0.8rem; + bottom: 4.85rem; + left: 0.8rem; + grid-template-columns: 3.2rem minmax(0, 1fr) auto; + min-height: 4.35rem; + padding: var(--space-2); + } + + .player-cover { + width: 3.2rem; + } + + .player-progress { + position: absolute; + right: 0.85rem; + bottom: 0.15rem; + left: 0.85rem; + display: block; + } + + /* Narrow: the modes and the skip buttons give way to the transport. */ + .player-controls button.mode { + display: none; + } + + .player-controls button.primary { + width: 2.75rem; + min-height: 2.75rem; + } + + .page-header { + display: block; + } + .page-header h2 { + margin-bottom: var(--space-2); + } + .page-header p { + text-align: left; + } + + .detail-header, + .artist-hero { + align-items: flex-end; + margin: calc(var(--space-6) * -1) calc(var(--space-4) * -1) var(--space-6); + padding: var(--space-16) var(--space-4) var(--space-6); + } + + .detail-header h2, + .artist-hero h2 { + font-size: clamp(var(--text-2xl), 9vw, var(--text-5xl)); + } + + .cover.large, + .artist-portrait { + width: min(35vw, 9rem); + } + + .artist-list, + .admin-grid { + grid-template-columns: 1fr; + } + + .songs td:nth-child(3) { + display: none; + } + .songs td { + padding-inline: var(--space-1); + } + + .collection-header, + .user-row > header { + align-items: flex-start; + flex-direction: column; + } + + .resource-list li { + grid-template-columns: minmax(0, 1fr) auto; + } + + .resource-list li > div { + grid-column: 1 / -1; + } + + .credential-form { + align-items: stretch; + flex-direction: column; + } +} + +@media (max-width: 680px) { + .centered { + padding: 0; + } + + .login-shell { + min-height: 100dvh; + grid-template-columns: 1fr; + border: 0; + border-radius: 0; + } + + .login-story { + min-height: 38dvh; + padding: var(--space-8); + } + + .login-story::after { + inset: 0.7rem; + } + .login-story h1 { + font-size: clamp(var(--text-3xl), 13vw, var(--text-5xl)); + } + + .signal, + .login-story p { + display: none; + } + + .card { + min-width: 0; + padding: var(--space-8) var(--space-6) var(--space-12); + } + + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .song-cover { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .skeleton-card span, + .skeleton-card i { + animation: none; + } + + button, + .cover, + .songs tr { + transition-duration: 0.01ms; + } +} diff --git a/webapp/src/styles/styles/player.css b/webapp/src/styles/styles/player.css new file mode 100644 index 00000000..c8c3e23c --- /dev/null +++ b/webapp/src/styles/styles/player.css @@ -0,0 +1,192 @@ +/* The player bar: the track, the transport, the aside and the scrubber. */ + +.player { + position: fixed; + z-index: 10; + right: 1rem; + bottom: 1rem; + left: calc(var(--sidebar-width) + 1rem); + display: grid; + grid-template-columns: + 3.8rem minmax(8rem, 0.6fr) auto minmax(11rem, 1.1fr) + auto; + align-items: center; + gap: var(--space-4); + min-height: 5.1rem; + padding: var(--space-3) var(--space-4) var(--space-3) var(--space-3); + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: color-mix(in srgb, var(--panel) 88%, transparent); + box-shadow: var(--shadow); + backdrop-filter: blur(1.7rem) saturate(135%); +} + +.player-cover { + width: 3.8rem; + border-radius: 0.65rem; +} + +.player-cover span { + font-size: var(--text-xl); +} + +.player-track { + display: grid; + min-width: 0; + font-size: var(--text-sm); +} + +.player-track strong, +.player-track span, +.player-track small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-track span { + color: var(--muted); + font-size: var(--text-xs); +} + +.player-track small { + font-size: var(--text-xs); +} + +/* A station has no artist to name. Where the artist would be, a badge says + that it is live — the one thing about it a transport with no line and no + next button has to explain. */ +.player-track .live-badge { + margin-top: var(--space-1); + color: var(--accent); + line-height: 1.4; +} + +.player-controls { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-1); +} + +.player-controls button { + display: grid; + width: 2.55rem; + min-height: 2.55rem; + padding: 0; + place-items: center; + border: 0; + border-radius: 50%; + background: transparent; +} + +/* Transport is always available; shuffle and repeat are modes, so they read as + off until they are on. Addressed by class and not by position: the transport + buttons were `nth-child` until a mode button was inserted ahead of them and + the filled play styling landed on "previous". */ +.player-controls button.mode { + color: var(--muted); +} + +.player-controls button.mode.on { + color: var(--accent); +} + +/* The cover doubles as the way back to the album. */ +.player > a { + display: grid; + border-radius: 0.65rem; +} + +.player > a:hover .player-cover { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.player-aside { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.player-aside button { + display: grid; + width: 2.1rem; + min-height: 2.1rem; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + place-items: center; +} + +.player-aside button:hover, +.player-aside button[aria-pressed="true"] { + color: var(--accent); +} + +.volume { + width: 4.5rem; + min-height: 1rem; + padding: 0; + border: 0; + accent-color: var(--accent); + background: none; +} + +.player-aside .nav-action { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 2.1rem; + padding: 0 var(--space-2); + border: 1px solid var(--line-strong); + border-radius: 0.5rem; + color: var(--muted); + font-size: var(--text-xs); + white-space: nowrap; +} + +.player-aside .nav-action:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Below the wide layout the aside would crowd the scrubber out; the queue is + still one tap away in the mobile navigation, and the level belongs to the + device's own keys there. */ +@media (width <= 62rem) { + .player-aside { + display: none; + } + + /* Drop the column with it, or the bar keeps a gap where it was. */ + .player { + grid-template-columns: 3.8rem minmax(8rem, 0.6fr) auto minmax(11rem, 1.1fr); + } +} + +.player-controls button.primary { + width: 3rem; + min-height: 3rem; + background: var(--text); + color: var(--bg); +} + +.player-progress { + display: grid; + grid-template-columns: 2.8rem minmax(5rem, 1fr) 2.8rem; + align-items: center; + gap: var(--space-2); + color: var(--muted); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; +} + +.player-progress input { + width: 100%; + min-height: 1rem; + padding: 0; + border: 0; + accent-color: var(--accent); +} diff --git a/webapp/src/styles/styles/playing.css b/webapp/src/styles/styles/playing.css new file mode 100644 index 00000000..c146eb19 --- /dev/null +++ b/webapp/src/styles/styles/playing.css @@ -0,0 +1,412 @@ +/* The playing screen: lyrics, its columns, the view switcher, immersive. */ + +/* Genres are a two-column list like artists: a name is short, and one column + of them wastes the width the grid pages use. */ +.genre-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 var(--space-8); +} + +.genre-list a { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.genre-list a:hover strong { + color: var(--accent); +} + +.playing-detail { + min-width: 0; + flex: 1 1 auto; +} + +.playing-detail h2 { + margin: var(--space-1) 0 var(--space-1); + font-size: clamp(var(--text-xl), 3vw, var(--text-3xl)); + line-height: 1.1; +} + +.lyrics { + margin-top: var(--space-8); +} + +.lyrics h3 { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin: 0 0 var(--space-3); + font-size: var(--text-base); +} + +.lyrics h3 small { + color: var(--muted); + font-size: var(--text-xs); +} + +.lyrics ol { + max-height: 26rem; + padding: 0; + margin: 0; + list-style: none; + overflow-y: auto; +} + +.lyrics li { + padding: var(--space-1) 0; + color: var(--muted); + line-height: 1.5; + transition: color 180ms ease; +} + +/* The line a synced sheet is on. An unsynced sheet never sets it, so the + whole text stays evenly weighted rather than pretending to follow. */ +.lyrics li.on { + color: var(--text); + font-weight: 600; +} + +/* ── Editorial pages: the playing screen in two columns ───────────────────── + The track and its words. In the middle range the cover sits beside the + names with the words under both, as this screen always read; on a wide + screen the track becomes a column of its own — a larger cover over the + names — and the words a tall one beside it (`.playing-split`). A track with + no words keeps one column, centred (`.playing-solo`), rather than half a + screen held open for nothing. Narrow, everything stacks, cover first. */ +.playing { + display: grid; + gap: clamp(var(--space-6), 4vw, var(--space-16)); + margin-bottom: var(--space-8); +} + +/* The same gutter between the cover and its names as between the columns: + inherited, so the fluid range is written once. */ +.playing-track { + display: flex; + align-items: flex-start; + gap: inherit; + min-width: 0; +} + +.playing-lyrics { + min-width: 0; +} + +.playing-lyrics .lyrics { + margin-top: 0; +} + +@media (width > 64rem) { + .playing-split { + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + align-items: start; + } + + .playing-split .playing-track, + .playing-solo .playing-track { + flex-direction: column; + gap: var(--space-6); + width: 100%; + max-width: 26rem; + } + + .playing-split .playing-track .canvas-stage, + .playing-solo .playing-track .canvas-stage, + .playing-split .cover.large, + .playing-solo .cover.large { + width: 100%; + } + + .playing-split .playing-detail, + .playing-solo .playing-detail { + width: 100%; + } + + /* Tall enough to read a verse without scrolling, and set larger than the + strip it was: the words are half the screen now, not a footnote. */ + .playing-split .lyrics ol { + max-height: min(70vh, 44rem); + scroll-padding: 40% 0; + } + + .playing-split .lyrics li { + font-size: var(--text-lg); + line-height: 1.5; + } + + .playing-solo { + justify-items: center; + } + + .playing-solo .playing-track { + align-items: center; + text-align: center; + } + + .playing-solo .quality-meter { + justify-content: center; + } +} + +/* ── End of the playing screen's columns ── */ + +/* The three ways to look at the track, in the page header beside the bookmark + button. Links and not buttons, because each one is a location. */ +.view-switcher { + display: flex; + gap: var(--space-1); + padding: var(--space-1); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel-soft); +} + +.view-switcher a { + padding: var(--space-1) var(--space-3); + border-radius: 0.5rem; + color: var(--muted); + font-size: var(--text-xs); + text-decoration: none; + white-space: nowrap; +} + +.view-switcher a:hover { + color: var(--text); +} + +/* The one being looked at. `aria-current` carries the meaning, so the styling + reads it rather than keeping a second class in step with it. */ +.view-switcher a[aria-current="page"] { + background: var(--panel-strong); + color: var(--text); +} + +/* A hub's sections, for Settings and for Maintenance: a row of tabs over the + open section. They are links, one route each, and the open one is the + router's `aria-current`. */ +.section-menu { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + width: fit-content; + max-width: 100%; + margin-bottom: var(--space-8); + padding: var(--space-1); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel-soft); +} + +.section-menu-link { + display: flex; + align-items: center; + min-height: 2.25rem; + padding: var(--space-1) var(--space-4); + border-radius: 0.5rem; + color: var(--muted); + font-size: var(--text-sm); + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} + +.section-menu-link:hover { + color: var(--text); +} + +.section-menu-link[aria-current="page"] { + background: var(--panel-strong); + color: var(--text); +} + +/* On a phone the row would not fit and a scrolling strip hides what is past + its edge, so the same links become a list, two to a row, each a full + touch target. */ +@media (max-width: 820px) { + .section-menu { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: auto; + margin-bottom: var(--space-6); + } + + .section-menu-link { + min-height: 2.75rem; + white-space: normal; + } +} + +/* The words as the subject: one column, centred, wide type, and the sung line + held in the middle of it by the view itself. */ +.lyrics-view { + display: grid; + gap: var(--space-6); + max-width: 44rem; + margin: 0 auto var(--space-8); +} + +.lyrics-view header { + text-align: center; +} + +.lyrics-view h2 { + margin: 0 0 var(--space-1); + font-size: clamp(var(--text-xl), 2.6vw, var(--text-2xl)); + line-height: 1.15; +} + +.lyrics-view p { + margin: 0; +} + +.lyrics-view ol { + max-height: min(60vh, 34rem); + padding: var(--space-2) 0; + margin: 0; + list-style: none; + overflow-y: auto; + text-align: center; + /* Room above and below so the first and last lines can reach the middle, + which is where the scroll puts the sung one. */ + scroll-padding: 40% 0; +} + +.lyrics-view li { + padding: var(--space-1) 0; + color: var(--muted); + font-size: clamp(var(--text-base), 2.2vw, var(--text-xl)); + line-height: 1.45; + transition: color 180ms ease; +} + +.lyrics-view li.on { + color: var(--text); + font-weight: 600; +} + +/* A seekable line is a button and has to look like the text it is, not like a + control: what makes it pressable is already said by its label. The strip + beside the cover seeks the same way, so it shares the rule. */ +.lyrics .link, +.lyrics-view .link { + display: block; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: inherit; + cursor: pointer; +} + +.lyrics .link:hover, +.lyrics-view .link:hover { + color: var(--text); + text-decoration: underline; +} + +/* The artwork over everything else. A dialog, so the focus trap, Escape and + the inertness behind come from the platform. + + **`[open]`, or the closed dialog is laid out.** `display: flex` from an + author sheet outranks the `dialog:not([open]) { display: none }` the browser + brings, and the element stays mounted between the close and the navigation + that follows it — so it would appear in the page's own flow, 100vw by 100vh + and outside the top layer, for as long as that took. */ +.immersive[open] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: clamp(var(--space-4), 3vw, var(--space-8)); + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + padding: clamp(var(--space-4), 4vw, var(--space-12)); + border: 0; + margin: 0; + background: var(--bg); + color: var(--text); +} + +.immersive::backdrop { + background: var(--bg); +} + +.immersive-stage { + display: flex; + min-height: 0; + justify-content: center; +} + +.immersive-cover { + width: auto; + max-width: min(78vw, 60vh); + height: auto; + max-height: 60vh; + aspect-ratio: 1; + object-fit: cover; +} + +/* An image has its own size to start from; the stand-in for a missing cover + has none, because its letter is positioned out of flow. With `width: auto` + in a centred column it shrank to its border — two pixels — and a borderless + theme took even those away. */ +.immersive-cover.cover-fallback { + width: min(78vw, 60vh); +} + +.immersive-detail { + display: grid; + justify-items: center; + gap: var(--space-1); + max-width: 44rem; + text-align: center; +} + +.immersive-detail h2 { + margin: 0; + font-size: clamp(var(--text-xl), 3.4vw, var(--text-3xl)); + line-height: 1.1; +} + +.immersive-detail p { + margin: 0; +} + +/* The transport, because this view covers the player bar. A mode that fills + the screen and leaves no way to pause is a trap rather than a mode. */ +.immersive-transport { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.immersive-close { + position: absolute; + top: clamp(0.6rem, 2vw, 1.4rem); + right: clamp(0.6rem, 2vw, 1.4rem); +} + +@media (prefers-reduced-motion: reduce) { + .lyrics li, + .lyrics-view li { + transition: none; + } +} + +@media (width <= 52rem) { + .genre-list { + grid-template-columns: 1fr; + } + + .playing-track { + flex-direction: column; + } +} diff --git a/webapp/src/styles/styles/shell.css b/webapp/src/styles/styles/shell.css new file mode 100644 index 00000000..d6d4a791 --- /dev/null +++ b/webapp/src/styles/styles/shell.css @@ -0,0 +1,523 @@ +/* The shell and its sidebar; the sign-in, not-found and share pages. */ + +.shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 100dvh; + padding-bottom: 6.4rem; +} + +.sidebar { + position: fixed; + z-index: 5; + inset: 0 auto 0 0; + display: flex; + width: var(--sidebar-width); + flex-direction: column; + padding: var(--space-6) var(--space-4) 7.5rem; + border-right: 1px solid var(--line); + background: color-mix(in srgb, var(--panel-soft) 88%, transparent); + backdrop-filter: blur(1.5rem) saturate(120%); +} + +.brand { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 2.6rem; + padding-inline: var(--space-2); + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 600; + letter-spacing: -0.03em; +} + +.brand small { + margin-left: auto; + color: var(--muted); + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +/* The logo is the kit's file, never redrawn here: sizing and the choice of + version only. 2rem tall puts it at 131px wide, above the guide's 120px + minimum for the horizontal version, and its own viewBox carries the clear + space above and below the symbol. No filter, shadow or animation: the guide + rules all three out. + + The colour version on a light theme and the dark one on a dark theme, by the + class `applyTheme` sets on the root — the reader's choice, which the system + colour scheme does not know about. */ +.brand-logo { + width: auto; + height: 2rem; + flex: none; +} + +:root.dark .brand-logo-light, +:root:not(.dark) .brand-logo-dark { + display: none; +} + +/** + * What the file is, under the track's names on the playing screen. + * + * Five discrete steps rather than a filled proportion: the bands are a + * summary of fields that do not share one scale — a bitrate and a bit depth + * are not points on the same line — and a continuous bar would be claiming a + * precision none of them carry. + */ +.file-quality { + display: grid; + gap: var(--space-1); + margin: var(--space-3) 0; +} + +.quality-meter { + display: flex; + gap: var(--space-1); + align-items: center; +} + +.quality-meter span { + width: 1.4rem; + height: 0.3rem; + border-radius: 0.15rem; + background: color-mix(in srgb, var(--text) 16%, transparent); +} + +.quality-meter span.on { + background: var(--accent-400); +} + +.file-facts { + margin: 0; + font-size: var(--text-sm); +} + +/** + * The Settings page: two short panels side by side, and the clients below + * them at full width because that list is the reason to come here. + */ +.settings-grid { + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + margin-bottom: var(--space-4); +} + +.settings-panel { + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 0.75rem; + padding: var(--space-4); + display: grid; + gap: var(--space-2); + align-content: start; +} + +.settings-panel h3 { + margin: 0; +} + +.primary-navigation { + display: grid; + gap: var(--space-1); + margin-top: var(--space-8); +} + +.primary-navigation a, +.nav-action { + display: flex; + min-height: 2.7rem; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border: 0; + border-radius: 0.62rem; + background: transparent; + color: var(--muted); + font-size: var(--text-sm); + text-align: left; +} + +.primary-navigation a:hover, +.nav-action:hover { + background: var(--accent-soft); + color: var(--text); +} + +.primary-navigation a[data-status="active"], +.sidebar-footer a[data-status="active"] { + background: var(--accent-soft); + color: var(--accent); +} + +.primary-navigation a[data-status="active"]::after, +.sidebar-footer a[data-status="active"]::after { + width: 0.26rem; + height: 0.26rem; + margin-left: auto; + border-radius: 50%; + background: currentcolor; + content: ""; +} + +.sidebar-footer { + display: grid; + gap: var(--space-3); + margin-top: auto; +} + +.theme-picker { + display: grid; + gap: var(--space-1); +} + +/* Sits under the brand, above the navigation, because it frames everything + below it rather than being one setting among the others at the foot. */ +.library-picker { + display: grid; + gap: var(--space-1); + margin: var(--space-4) 0 var(--space-2); + padding-inline: var(--space-2); +} + +.library-picker > span { + color: var(--muted); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.library-picker select { + width: 100%; + min-height: 2.35rem; + padding-block: var(--space-1); + font-size: var(--text-sm); +} + +.scope-note { + margin: calc(var(--space-4) * -1) 0 var(--space-6); + font-size: var(--text-xs); +} + +.language-picker { + display: grid; + gap: var(--space-1); +} + +.theme-picker span, +.language-picker span { + color: var(--muted); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.theme-picker select, +.language-picker select { + width: 100%; + min-height: 2.35rem; + padding-block: var(--space-1); + font-size: var(--text-xs); + text-transform: capitalize; +} + +.not-found { + position: relative; + width: min(100%, 38rem); + padding: clamp(var(--space-8), 6vw, var(--space-20)); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 1.2rem; + background: var(--panel); + box-shadow: var(--shadow); +} + +.not-found h1 { + max-width: 12ch; + margin: 0 0 var(--space-3); + font-size: clamp(var(--text-3xl), 8vw, var(--text-6xl)); + line-height: 0.95; +} + +.not-found a { + display: inline-block; + margin-top: var(--space-4); + color: var(--accent); + font-weight: 700; +} + +.not-found-code { + position: absolute; + right: -0.4rem; + bottom: -2rem; + color: var(--accent-soft); + font-family: var(--font-display); + font-size: 12rem; + font-weight: 700; + line-height: 1; +} + +/* What a share link opens (RFC-020): one column, no shell around it. */ +.share-page { + display: grid; + width: min(100%, 44rem); + gap: var(--space-6); + margin: 0 auto; + padding: clamp(var(--space-6), 6vw, var(--space-16)) var(--space-4); +} + +.share-header h1 { + margin: var(--space-1) 0 var(--space-2); + font-size: clamp(var(--text-2xl), 6vw, var(--text-5xl)); + line-height: 1; +} + +.share-audio { + width: 100%; +} + +.share-tracks { + display: grid; + gap: var(--space-1); + padding: 0; + margin: 0; + list-style: none; +} + +.share-tracks button { + display: grid; + width: 100%; + grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: transparent; + color: inherit; + text-align: start; +} + +.share-tracks li[aria-current="true"] button { + border-color: var(--accent); +} + +.share-track-cover { + width: 40px; + height: 40px; + flex: none; + border-radius: 6px; + object-fit: cover; + background: var(--line); +} + +.playlist-entry-actions { + display: inline-flex; + gap: 2px; +} + +.share-track-title { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.share-track-duration { + font-variant-numeric: tabular-nums; +} + +.checkbox-label { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.account-chip { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: 0.7rem; +} + +.account-chip > span { + display: grid; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.48rem; + background: var(--accent-soft); + color: var(--accent); + font-weight: 700; +} + +.account-chip div { + display: grid; + min-width: 0; +} + +.account-chip strong, +.account-chip small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-chip small { + color: var(--muted); + font-size: var(--text-xs); + text-transform: capitalize; +} + +.mobile-header, +.mobile-navigation { + display: none; +} + +main { + grid-column: 2; + width: min(100%, 90rem); + margin: 0 auto; + padding: var(--space-12) clamp(var(--space-6), 4vw, var(--space-20)) + var(--space-20); +} + +.centered { + display: grid; + width: 100%; + min-height: 100dvh; + max-width: none; + place-items: center; + padding: var(--space-8); +} + +.login-shell { + display: grid; + width: min(100%, 67rem); + min-height: 38rem; + grid-template-columns: minmax(0, 1.2fr) minmax(20rem, 0.8fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 1.4rem; + background: color-mix(in srgb, var(--panel-soft) 80%, transparent); + box-shadow: var(--shadow); +} + +.login-story { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: clamp(var(--space-8), 6vw, var(--space-20)); + overflow: hidden; + background: + radial-gradient(circle at 25% 25%, var(--accent-soft), transparent 16rem), + var(--panel-soft); +} + +.login-story::after { + position: absolute; + inset: 1.4rem; + border: 1px solid var(--line); + border-radius: 1rem; + content: ""; +} + +.login-story > * { + z-index: 1; +} + +.login-story h1 { + max-width: 12ch; + margin: 0 0 var(--space-4); + font-size: clamp(var(--text-3xl), 6vw, var(--text-6xl)); + line-height: 0.93; +} + +.login-story p { + max-width: 29rem; + color: var(--muted); +} + +.signal { + width: 70%; + height: 3.5rem; + margin-block: var(--space-6); + background: repeating-linear-gradient( + 90deg, + var(--accent) 0 2px, + transparent 2px 8px + ); + clip-path: polygon( + 0 45%, + 5% 35%, + 10% 58%, + 15% 20%, + 20% 68%, + 25% 32%, + 30% 45%, + 35% 25%, + 40% 68%, + 45% 38%, + 50% 50%, + 55% 30%, + 60% 64%, + 65% 24%, + 70% 50%, + 75% 38%, + 80% 60%, + 85% 32%, + 90% 47%, + 95% 40%, + 100% 50%, + 100% 55%, + 0 55% + ); + opacity: 0.72; +} + +.card { + display: grid; + align-content: center; + gap: var(--space-4); + min-width: 20rem; + padding: clamp(var(--space-8), 5vw, var(--space-16)); + background: var(--panel); +} + +.card h1 { + margin: 0 0 var(--space-2); + font-size: var(--text-2xl); +} + +.card p { + margin: 0; +} + +.card label { + display: grid; + gap: var(--space-2); + color: var(--muted); + font-size: var(--text-xs); +} + +.login-card button[type="submit"] { + margin-top: var(--space-1); + border-color: transparent; + background: var(--accent); + /* The material's ink for an accent fill. Drawn from the page's ground, it + was a near-black on the dark accent and a pale grey on the light one. */ + color: var(--fe-accent-ink); + font-weight: 700; +} diff --git a/webapp/src/styles/styles/workspace.css b/webapp/src/styles/styles/workspace.css new file mode 100644 index 00000000..802f78a6 --- /dev/null +++ b/webapp/src/styles/styles/workspace.css @@ -0,0 +1,316 @@ +/* The workspace pages: radio, podcasts, folders and the Maintenance hub. */ + +/* ── Workspace pages ─────────────────────────────────────────── + Radio, Podcasts, Folders and the Maintenance hub manage something rather + than present it, and a wide window is room for more of their cards and + rows. Their routes mark `main` with `.workspace` (see `staticData.layout` + in `main.tsx`); every other page keeps the contained column. The class + outranks every sheet's plain `main` rule, and on a phone `min()` resolves + to the full width those rules already give, gutters untouched. */ +main.workspace { + width: min(100%, 150rem); +} + +/* The radio page: one card per station, its image, its name and the button + that plays it; the rest behind the card's "⋯" menu. */ +.station-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 12.5rem), 1fr)); + gap: var(--space-6); + margin: 0; + padding: 0; + list-style: none; +} + +/* Two to a row on a phone: one card the width of the screen is a poster, + and a short list of stations should be seen at a glance. */ +@media (max-width: 820px) { + .station-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + + .station-art .station-play { + right: var(--space-2); + bottom: var(--space-2); + width: 2.75rem; + min-height: 2.75rem; + } +} + +.station-card { + display: grid; + align-content: start; + gap: var(--space-3); + min-width: 0; +} + +.station-art { + position: relative; +} + +.station-cover { + width: 100%; +} + +/* On the image, where the eye already is, as a cover's play button is. */ +.station-art .station-play { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + display: grid; + width: 3rem; + min-height: 3rem; + padding: 0; + place-items: center; + border-color: transparent; + border-radius: 999px; + background: var(--accent); + color: var(--fe-accent-ink, var(--bg)); + box-shadow: var(--shadow); +} + +.station-art .station-play:hover { + background: var(--accent); + filter: brightness(1.08); +} + +.station-copy { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.station-title { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); +} + +.station-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.station-url { + overflow: hidden; + font-size: var(--text-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.station-note { + font-size: var(--text-xs); +} + +.station-link { + justify-self: start; + color: var(--accent); + font-size: var(--text-xs); +} + +/* A card's "⋯": quiet until it is pointed at, a full target all the same. */ +.action-menu-button { + display: grid; + width: 2.25rem; + min-height: 2.25rem; + padding: 0; + place-items: center; + border-color: transparent; + border-radius: 999px; + background: transparent; + color: var(--muted); +} + +.action-menu-button:hover, +.action-menu-button[aria-expanded="true"] { + color: var(--text); +} + +.track-menu-item.danger { + background: transparent; + color: var(--danger); +} + +.track-menu-item.danger:hover, +.track-menu-item.danger:focus-visible { + background: color-mix(in srgb, var(--danger) 12%, transparent); +} + +/* The station form, in a dialog drawn as the folder picker is. */ +.station-dialog[open] { + display: grid; + gap: var(--space-4); + width: min(34rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); + padding: var(--space-6); + overflow-y: auto; + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +/* A page's blocks rise into place as it opens (`fe-rise` in fluid.css), and + a dialog mounted later as one of them rose with them: invisible for the + delay, then fading in under the reader's click. A dialog opens at once. */ +main > section > dialog { + animation: none; +} + +.station-dialog::backdrop { + background: color-mix(in srgb, black 55%, transparent); +} + +.station-dialog header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); +} + +.station-dialog h2 { + margin: 0; + font-size: var(--text-lg); + overflow-wrap: anywhere; +} + +.station-dialog .radio-form { + margin: 0; +} + +/* The form's notes sit in its grid gap, without margins of their own. */ +.station-dialog .radio-form > :where(p) { + margin: 0; +} + +/* An empty page that is new to somebody: a discreet symbol, what the page + is for, and the first step when the reader may take it. */ +.empty-state-rich { + align-content: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-6); +} + +.empty-state-icon { + display: grid; + width: 3.5rem; + height: 3.5rem; + place-items: center; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); +} + +.empty-state-title { + margin: 0; + color: var(--text); + font-weight: 600; +} + +.empty-state-detail { + margin: 0; + max-width: 36rem; + font-size: var(--text-sm); +} + +.empty-state-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-3); + margin-top: var(--space-2); +} + +/* The folders page: To sort apart from the folders it does not count. */ +.folder-none { + margin: 0 0 var(--space-6); +} + +.folder-system { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-6); +} + +.folder-system h3 { + margin: 0; + font-size: var(--text-base); +} + +.folder-system-note { + max-width: 48rem; + margin: 0 0 var(--space-2); + font-size: var(--text-sm); +} + +.folder-system .folder-list { + margin-bottom: 0; +} + +/* Drawn in outline, not filled: a tray WaveFlow keeps, not a folder. */ +.folder-row.system { + border-style: dashed; + background: transparent; +} + +/* Read by a screen reader, not drawn: a status the eye gets from the page. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + border: 0; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The podcasts page in two panels from 1024 px of window: the shows on the + left, the open show's episodes beside them. The same breakpoint as + `TWO_PANELS` in `podcasts-page.tsx`, which also opens the first show when + none is named. Below it the episodes stack under the list, as before. */ +@media (width >= 64rem) { + .podcast-workspace { + display: grid; + /* A third of the room for the shows, never more than 22rem: at 1024 px + the episodes, which carry the buttons, keep the larger share. */ + grid-template-columns: minmax(14rem, min(22rem, 34%)) minmax(0, 1fr); + align-items: start; + gap: var(--space-6); + } + + /* A narrow column: the name over the buttons, rather than beside them. */ + .podcast-workspace .podcast-channels li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-3); + padding-inline: var(--space-3); + } + + .podcast-workspace .podcast-channels li > .resource-with-cover { + flex: 1 1 100%; + } + + .podcast-workspace .podcast-channels li.selected { + background: var(--fe-tone-2, var(--panel-strong)); + box-shadow: inset 2px 0 0 var(--accent); + } + + /* Held in view while the list scrolls, and scrolled on its own when it is + longer than the window, above the player bar: a long feed never pushes + the shows out of reach. */ + .podcast-workspace .podcast-episodes { + position: sticky; + top: var(--space-6); + max-height: calc(100dvh - 9rem); + margin-top: 0; + overflow-y: auto; + overscroll-behavior: contain; + } +}