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 index 035a0df2..43572990 100644 --- a/webapp/src/styles/fluid.css +++ b/webapp/src/styles/fluid.css @@ -29,1760 +29,10 @@ (written by `applyTheme`, from the same `themeSurfaces` the Settings cards are drawn with), and the light ground takes the theme's tint. */ -/* Drawn only beside the rail, where the player bar has room for it. */ -.progress-wave { - display: none; -} - -/* ── Tokens ──────────────────────────────────────────────────── */ - -/* The cover's light, registered as colours so a new record's light can fade - in. A gradient does not interpolate, so a transition on the backgrounds - that read these did nothing and the light jumped; a typed property does, - and every gradient built from it follows. */ -@property --glow { - syntax: ""; - inherits: true; - initial-value: #1f5a48; -} - -@property --glow-deep { - syntax: ""; - inherits: true; - initial-value: #0f2b22; -} - -:root { - transition: - --glow 900ms var(--fe-ease), - --glow-deep 900ms var(--fe-ease); -} - -/* What both materials share. Doubled to `.dark` and `:not(.dark)` so it - weighs what the refresh's light tokens weigh, and wins by coming later. */ -:root.dark, -:root:not(.dark) { - /* The cover's light. `CoverLight` writes the sampled pair on the root's - own style, which outranks these; until the first sample lands, the - theme's accent stands in, deepened the way a cover would be. */ - --glow: color-mix(in srgb, var(--accent-700) 58%, #0b0b0e); - --glow-deep: color-mix(in srgb, var(--accent-700) 30%, #0b0b0e); - - --fe-ease: cubic-bezier(0.22, 1, 0.36, 1); - --fe-font: - "DM Sans Variable", system-ui, -apple-system, "Segoe UI", sans-serif; - --fe-font-editorial: "Playfair Display", Georgia, serif; - - --fe-display: clamp(2.25rem, 3vw, 3.5rem); - --fe-banner: clamp(3rem, 5vw, 5.25rem); - --fe-title: 1.5rem; - --fe-body: 0.9375rem; - --fe-label: 0.6875rem; - --fe-section-gap: 3.5rem; - --fe-rail-gap: 1.75rem; - --fe-radius-card: 0.875rem; - --fe-radius-hero: 1.25rem; - --fe-radius-tile: 0.625rem; - --fe-radius-input: 0.625rem; - /* The progress thumb, which the wave and the line both measure from. */ - --fe-thumb: 0.625rem; - - /* The mark: one sine-like stroke in a 40 × 10 box, used as a mask so it - takes whatever colour it is painted with. `--fe-wave` is the same - stroke as one full period, so it tiles without a seam. */ - --fe-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 10'%3E%3Cpath d='M0.5 5 C 5 0.5, 9 0.5, 13.5 5 S 22 9.5, 26.5 5 S 35 0.5, 39.5 5' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); - --fe-wave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 10'%3E%3Cpath d='M0 5 C 3.3 0.5, 6.7 0.5, 10 5 S 16.7 9.5, 20 5' fill='none' stroke='black' stroke-width='1.6'/%3E%3C/svg%3E"); - - /* The names the rest of the stylesheet already reads, pointed at the - material's values. */ - --bg: var(--fe-bg); - --panel: var(--fe-surface); - --panel-soft: var(--fe-surface); - --panel-strong: var(--fe-surface-2); - --line: var(--fe-hairline); - --line-strong: var(--fe-hairline-strong); - --text: var(--fe-ink); - --muted: var(--fe-ink-3); - --font-display: var(--fe-font); - --font-body: var(--fe-font); - --sidebar-width: 16rem; -} - -/* The guide's material: tone over a near-black, no blur, hairlines. The - near-black is the default; a dark preset brings its own three surfaces, - and everything below that is a translucent white — the hairlines, the - tones, the rules — tints with them. */ -:root.dark { - --fe-bg: var(--theme-ground, #0b0b0e); - --fe-surface: var(--theme-surface, #121216); - --fe-surface-2: var(--theme-raised, #17171d); - --fe-ink: #f3f1ec; - --fe-ink-2: #b5b2aa; - /* #8e8b83 until the presets brought their own grounds: on Lavender's and - Ocean's deepest tone it read 4.49 and 4.46:1. One step lighter holds - 4.70 on the worst (`themes-contrast.test.ts`). */ - --fe-ink-3: #928f87; - --fe-hairline: rgb(255 255 255 / 7%); - --fe-hairline-strong: rgb(255 255 255 / 12%); - --fe-tone-1: rgb(255 255 255 / 4%); - --fe-tone-2: rgb(255 255 255 / 6%); - --fe-tone-3: rgb(255 255 255 / 8%); - --fe-accent-ink: #0b0b0e; - /* Rows parted inside a list or a table. */ - --fe-rule: rgb(255 255 255 / 5%); - - /* The cover's light as the structure spends it: at full strength here. - Until a cover is sampled the accent stands in, deepened into this - theme's own ground rather than the default near-black, so Midnight's - stand-in light is a blue night and not a grey one. 40 % of the accent, - not the 58 % both materials shared: under the login story's 60 % pool - Sunset's stand-in read 3.84:1 against the muted ink, and 40 % holds - 4.65. */ - --glow: color-mix(in srgb, var(--accent-700) 40%, var(--fe-bg)); - --glow-deep: color-mix(in srgb, var(--accent-700) 30%, var(--fe-bg)); - --fe-glow: var(--glow); - --fe-glow-deep: var(--glow-deep); - - /* What a head lays over a blurred cover so its text stays legible: the - ground itself, at the strengths each head asks for. Then the text set - over that wash, and the glass of the buttons that sit on it. */ - --fe-wash: var(--fe-bg); - --fe-on-wash: #fff; - --fe-on-wash-2: rgb(255 255 255 / 76%); - --fe-on-wash-tone: rgb(0 0 0 / 38%); - --fe-on-wash-line: rgb(255 255 255 / 22%); - - /* Shadows in three strengths — the player's record and a sheet, a cover, - a popover — and the inset highlight along a popover's top edge. */ - --fe-shadow: rgb(0 0 0 / 50%); - --fe-shadow-soft: rgb(0 0 0 / 45%); - --fe-shadow-deep: rgb(0 0 0 / 55%); - --fe-highlight: rgb(255 255 255 / 6%); - - --accent: var(--accent-400); - --accent-soft: color-mix(in srgb, var(--accent-400) 14%, transparent); - --shadow: 0 1.5rem 3rem rgb(0 0 0 / 45%); -} - -/* The light material. A ground of warm paper tinted by the theme, surfaces a - step towards white, dark ink in three strengths that each hold AA on the - ground and on its deepest tone, and the accent's 700 stop, dark enough to - carry white ink and to be read as text. */ -:root:not(.dark) { - --fe-bg: color-mix(in srgb, var(--ambient-bg) 40%, #ebe8e1); - --fe-surface: color-mix(in srgb, var(--fe-bg) 25%, white); - --fe-surface-2: #fff; - --fe-ink: #1b1a17; - --fe-ink-2: #46433d; - /* #625e56 until the tinted grounds were measured: on Midnight, Lavender - and Crimson's deepest tone it fell to 4.41–4.47:1. One step darker holds - 4.62 on the worst of them (`themes-contrast.test.ts`). */ - --fe-ink-3: #5f5b53; - --fe-hairline: rgb(27 26 23 / 9%); - --fe-hairline-strong: rgb(27 26 23 / 16%); - --fe-tone-1: rgb(27 26 23 / 4%); - --fe-tone-2: rgb(27 26 23 / 6.5%); - --fe-tone-3: rgb(27 26 23 / 9%); - --fe-accent-ink: #fff; - --fe-rule: rgb(27 26 23 / 7%); - - /* The sampled colour is deepened for a dark page; at full strength it - would muddy this one, so it arrives washed into the ground. Washed hard: - a dark cover pulls the paper towards the dark ink, and at the 34 % and - 26 % this once took, the muted ink under the strongest pools (the login - story's 60 %, the lower pool's 70 %) fell to 3.42:1 on Midnight light. - 13 % and 11 % keep it at 4.64 for a cover as dark as black, on every - light preset (`themes-contrast.test.ts`). */ - --fe-glow: color-mix(in srgb, var(--glow) 13%, var(--fe-bg)); - --fe-glow-deep: color-mix(in srgb, var(--glow-deep) 11%, var(--fe-bg)); - - --fe-wash: var(--fe-bg); - --fe-on-wash: var(--fe-ink); - --fe-on-wash-2: var(--fe-ink-2); - --fe-on-wash-tone: color-mix(in srgb, var(--fe-surface) 72%, transparent); - --fe-on-wash-line: var(--fe-hairline-strong); - - --fe-shadow: rgb(43 38 28 / 22%); - --fe-shadow-soft: rgb(43 38 28 / 16%); - --fe-shadow-deep: rgb(43 38 28 / 20%); - --fe-highlight: rgb(255 255 255 / 80%); - - --accent: var(--accent-700); - --accent-soft: color-mix(in srgb, var(--accent-700) 10%, transparent); - --shadow: 0 1.5rem 3rem rgb(43 38 28 / 14%); -} - -/* Sunset's amber 700 is the lightest of the light accents and its ground the - warmest: together they read 4.29:1, short of AA for the links and kickers - the accent is spent on. A fifth of the 800 stop takes it to 4.58 and keeps - the orange; the palette itself stays the desktop's. */ -: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; - } -} +@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 new file mode 100644 index 00000000..7a3e6499 --- /dev/null +++ b/webapp/src/styles/fluid/tokens.css @@ -0,0 +1,192 @@ +/* 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 { + display: none; +} + +/* ── Tokens ──────────────────────────────────────────────────── */ + +/* The cover's light, registered as colours so a new record's light can fade + in. A gradient does not interpolate, so a transition on the backgrounds + that read these did nothing and the light jumped; a typed property does, + and every gradient built from it follows. */ +@property --glow { + syntax: ""; + inherits: true; + initial-value: #1f5a48; +} + +@property --glow-deep { + syntax: ""; + inherits: true; + initial-value: #0f2b22; +} + +:root { + transition: + --glow 900ms var(--fe-ease), + --glow-deep 900ms var(--fe-ease); +} + +/* What both materials share. Doubled to `.dark` and `:not(.dark)` so it + weighs what the refresh's light tokens weigh, and wins by coming later. */ +:root.dark, +:root:not(.dark) { + /* The cover's light. `CoverLight` writes the sampled pair on the root's + own style, which outranks these; until the first sample lands, the + theme's accent stands in, deepened the way a cover would be. */ + --glow: color-mix(in srgb, var(--accent-700) 58%, #0b0b0e); + --glow-deep: color-mix(in srgb, var(--accent-700) 30%, #0b0b0e); + + --fe-ease: cubic-bezier(0.22, 1, 0.36, 1); + --fe-font: + "DM Sans Variable", system-ui, -apple-system, "Segoe UI", sans-serif; + --fe-font-editorial: "Playfair Display", Georgia, serif; + + --fe-display: clamp(2.25rem, 3vw, 3.5rem); + --fe-banner: clamp(3rem, 5vw, 5.25rem); + --fe-title: 1.5rem; + --fe-body: 0.9375rem; + --fe-label: 0.6875rem; + --fe-section-gap: 3.5rem; + --fe-rail-gap: 1.75rem; + --fe-radius-card: 0.875rem; + --fe-radius-hero: 1.25rem; + --fe-radius-tile: 0.625rem; + --fe-radius-input: 0.625rem; + /* The progress thumb, which the wave and the line both measure from. */ + --fe-thumb: 0.625rem; + + /* The mark: one sine-like stroke in a 40 × 10 box, used as a mask so it + takes whatever colour it is painted with. `--fe-wave` is the same + stroke as one full period, so it tiles without a seam. */ + --fe-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 10'%3E%3Cpath d='M0.5 5 C 5 0.5, 9 0.5, 13.5 5 S 22 9.5, 26.5 5 S 35 0.5, 39.5 5' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); + --fe-wave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 10'%3E%3Cpath d='M0 5 C 3.3 0.5, 6.7 0.5, 10 5 S 16.7 9.5, 20 5' fill='none' stroke='black' stroke-width='1.6'/%3E%3C/svg%3E"); + + /* The names the rest of the stylesheet already reads, pointed at the + material's values. */ + --bg: var(--fe-bg); + --panel: var(--fe-surface); + --panel-soft: var(--fe-surface); + --panel-strong: var(--fe-surface-2); + --line: var(--fe-hairline); + --line-strong: var(--fe-hairline-strong); + --text: var(--fe-ink); + --muted: var(--fe-ink-3); + --font-display: var(--fe-font); + --font-body: var(--fe-font); + --sidebar-width: 16rem; +} + +/* The guide's material: tone over a near-black, no blur, hairlines. The + near-black is the default; a dark preset brings its own three surfaces, + and everything below that is a translucent white — the hairlines, the + tones, the rules — tints with them. */ +:root.dark { + --fe-bg: var(--theme-ground, #0b0b0e); + --fe-surface: var(--theme-surface, #121216); + --fe-surface-2: var(--theme-raised, #17171d); + --fe-ink: #f3f1ec; + --fe-ink-2: #b5b2aa; + /* #8e8b83 until the presets brought their own grounds: on Lavender's and + Ocean's deepest tone it read 4.49 and 4.46:1. One step lighter holds + 4.70 on the worst (`themes-contrast.test.ts`). */ + --fe-ink-3: #928f87; + --fe-hairline: rgb(255 255 255 / 7%); + --fe-hairline-strong: rgb(255 255 255 / 12%); + --fe-tone-1: rgb(255 255 255 / 4%); + --fe-tone-2: rgb(255 255 255 / 6%); + --fe-tone-3: rgb(255 255 255 / 8%); + --fe-accent-ink: #0b0b0e; + /* Rows parted inside a list or a table. */ + --fe-rule: rgb(255 255 255 / 5%); + + /* The cover's light as the structure spends it: at full strength here. + Until a cover is sampled the accent stands in, deepened into this + theme's own ground rather than the default near-black, so Midnight's + stand-in light is a blue night and not a grey one. 40 % of the accent, + not the 58 % both materials shared: under the login story's 60 % pool + Sunset's stand-in read 3.84:1 against the muted ink, and 40 % holds + 4.65. */ + --glow: color-mix(in srgb, var(--accent-700) 40%, var(--fe-bg)); + --glow-deep: color-mix(in srgb, var(--accent-700) 30%, var(--fe-bg)); + --fe-glow: var(--glow); + --fe-glow-deep: var(--glow-deep); + + /* What a head lays over a blurred cover so its text stays legible: the + ground itself, at the strengths each head asks for. Then the text set + over that wash, and the glass of the buttons that sit on it. */ + --fe-wash: var(--fe-bg); + --fe-on-wash: #fff; + --fe-on-wash-2: rgb(255 255 255 / 76%); + --fe-on-wash-tone: rgb(0 0 0 / 38%); + --fe-on-wash-line: rgb(255 255 255 / 22%); + + /* Shadows in three strengths — the player's record and a sheet, a cover, + a popover — and the inset highlight along a popover's top edge. */ + --fe-shadow: rgb(0 0 0 / 50%); + --fe-shadow-soft: rgb(0 0 0 / 45%); + --fe-shadow-deep: rgb(0 0 0 / 55%); + --fe-highlight: rgb(255 255 255 / 6%); + + --accent: var(--accent-400); + --accent-soft: color-mix(in srgb, var(--accent-400) 14%, transparent); + --shadow: 0 1.5rem 3rem rgb(0 0 0 / 45%); +} + +/* The light material. A ground of warm paper tinted by the theme, surfaces a + step towards white, dark ink in three strengths that each hold AA on the + ground and on its deepest tone, and the accent's 700 stop, dark enough to + carry white ink and to be read as text. */ +:root:not(.dark) { + --fe-bg: color-mix(in srgb, var(--ambient-bg) 40%, #ebe8e1); + --fe-surface: color-mix(in srgb, var(--fe-bg) 25%, white); + --fe-surface-2: #fff; + --fe-ink: #1b1a17; + --fe-ink-2: #46433d; + /* #625e56 until the tinted grounds were measured: on Midnight, Lavender + and Crimson's deepest tone it fell to 4.41–4.47:1. One step darker holds + 4.62 on the worst of them (`themes-contrast.test.ts`). */ + --fe-ink-3: #5f5b53; + --fe-hairline: rgb(27 26 23 / 9%); + --fe-hairline-strong: rgb(27 26 23 / 16%); + --fe-tone-1: rgb(27 26 23 / 4%); + --fe-tone-2: rgb(27 26 23 / 6.5%); + --fe-tone-3: rgb(27 26 23 / 9%); + --fe-accent-ink: #fff; + --fe-rule: rgb(27 26 23 / 7%); + + /* The sampled colour is deepened for a dark page; at full strength it + would muddy this one, so it arrives washed into the ground. Washed hard: + a dark cover pulls the paper towards the dark ink, and at the 34 % and + 26 % this once took, the muted ink under the strongest pools (the login + story's 60 %, the lower pool's 70 %) fell to 3.42:1 on Midnight light. + 13 % and 11 % keep it at 4.64 for a cover as dark as black, on every + light preset (`themes-contrast.test.ts`). */ + --fe-glow: color-mix(in srgb, var(--glow) 13%, var(--fe-bg)); + --fe-glow-deep: color-mix(in srgb, var(--glow-deep) 11%, var(--fe-bg)); + + --fe-wash: var(--fe-bg); + --fe-on-wash: var(--fe-ink); + --fe-on-wash-2: var(--fe-ink-2); + --fe-on-wash-tone: color-mix(in srgb, var(--fe-surface) 72%, transparent); + --fe-on-wash-line: var(--fe-hairline-strong); + + --fe-shadow: rgb(43 38 28 / 22%); + --fe-shadow-soft: rgb(43 38 28 / 16%); + --fe-shadow-deep: rgb(43 38 28 / 20%); + --fe-highlight: rgb(255 255 255 / 80%); + + --accent: var(--accent-700); + --accent-soft: color-mix(in srgb, var(--accent-700) 10%, transparent); + --shadow: 0 1.5rem 3rem rgb(43 38 28 / 14%); +} + +/* Sunset's amber 700 is the lightest of the light accents and its ground the + warmest: together they read 4.29:1, short of AA for the links and kickers + the accent is spent on. A fifth of the 800 stop takes it to 4.58 and keeps + the orange; the palette itself stays the desktop's. */ +:root:not(.dark)[data-theme="sunset-light"] { + --accent: color-mix(in srgb, var(--accent-700) 80%, var(--accent-800)); +} diff --git a/webapp/src/styles/refresh.css b/webapp/src/styles/refresh.css index 2eef373c..9d32300f 100644 --- a/webapp/src/styles/refresh.css +++ b/webapp/src/styles/refresh.css @@ -1,1229 +1,8 @@ -/* 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 { - --bg: color-mix(in srgb, var(--ambient-bg) 66%, #1b1e1b 34%); - --panel: color-mix(in srgb, var(--bg) 87%, white 13%); - --panel-soft: color-mix(in srgb, var(--bg) 95%, white 5%); - --panel-strong: color-mix(in srgb, var(--bg) 80%, white 20%); - --line: color-mix(in srgb, white 9%, transparent); - --line-strong: color-mix(in srgb, white 16%, transparent); - --text: #f0f1eb; - --muted: #a5ada4; - --accent: color-mix(in srgb, var(--accent-400) 66%, #d8e0d1 34%); - --accent-soft: color-mix(in srgb, var(--accent) 13%, transparent); - --shadow: 0 1rem 3rem color-mix(in srgb, #080c09 30%, transparent); - --font-display: - "Segoe UI Variable Display", "Avenir Next", "Segoe UI", sans-serif; - --font-body: "Segoe UI Variable Text", "Avenir Next", "Segoe UI", sans-serif; -} - -:root:not(.dark) { - --bg: color-mix(in srgb, var(--ambient-bg) 54%, #f2f0ea 46%); - --panel: color-mix(in srgb, var(--bg) 24%, white 76%); - --panel-soft: color-mix(in srgb, var(--bg) 82%, white 18%); - --panel-strong: color-mix(in srgb, var(--bg) 72%, #dfddd5 28%); - --line: color-mix(in srgb, #2c392f 10%, transparent); - --line-strong: color-mix(in srgb, #2c392f 19%, transparent); - --text: #202721; - --muted: #657068; - --accent: color-mix(in srgb, var(--accent-700) 76%, #304b39 24%); - --accent-soft: color-mix(in srgb, var(--accent) 10%, transparent); - --shadow: 0 1rem 3rem color-mix(in srgb, #374139 11%, transparent); -} - -body { - background: var(--bg); - font-size: var(--text-base); - font-weight: 400; - line-height: 1.5; -} - -body::before { - display: none; -} - -h1, -h2, -h3 { - font-family: var(--font-display); - font-weight: 650; - letter-spacing: -0.045em; -} - -button, -input, -select, -.correction-field textarea { - border-radius: 0.45rem; -} - -button { - border-radius: 999px; - border-color: var(--line-strong); - background: transparent; - font-size: var(--text-sm); - font-weight: 650; -} - -button:hover { - border-color: var(--accent); - background: var(--accent-soft); -} - -button[type="submit"], -button.action-primary { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - border-color: var(--accent); - border-radius: 999px; - background: var(--accent); - color: var(--bg); -} - -button[type="submit"]:hover, -button.action-primary:hover { - border-color: color-mix(in srgb, var(--accent) 82%, var(--text) 18%); - background: color-mix(in srgb, var(--accent) 82%, var(--text) 18%); -} - -button.action-secondary { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - border-radius: 999px; - padding-inline: var(--space-3); - border-color: transparent; - background: transparent; - color: var(--text); -} - -button.action-secondary:hover { - border-color: transparent; - background: var(--accent-soft); - color: var(--accent); -} - -input, -select, -.correction-field textarea { - background: transparent; -} - -/* Windows can paint a native select menu white even inside a dark page. - Give its popup an explicit light scheme and dark option text. */ -select { - color-scheme: light; -} - -select option { - background-color: #fff; - color: #202721; -} - -.radio-form { - max-width: 34rem; - gap: var(--space-4); - margin: 0 0 var(--space-8); -} - -.radio-form > label { - display: grid; - gap: var(--space-2); - color: var(--text); - 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; - } -} +/* 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 new file mode 100644 index 00000000..2a469c13 --- /dev/null +++ b/webapp/src/styles/refresh/base.css @@ -0,0 +1,140 @@ +/* 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 { + --bg: color-mix(in srgb, var(--ambient-bg) 66%, #1b1e1b 34%); + --panel: color-mix(in srgb, var(--bg) 87%, white 13%); + --panel-soft: color-mix(in srgb, var(--bg) 95%, white 5%); + --panel-strong: color-mix(in srgb, var(--bg) 80%, white 20%); + --line: color-mix(in srgb, white 9%, transparent); + --line-strong: color-mix(in srgb, white 16%, transparent); + --text: #f0f1eb; + --muted: #a5ada4; + --accent: color-mix(in srgb, var(--accent-400) 66%, #d8e0d1 34%); + --accent-soft: color-mix(in srgb, var(--accent) 13%, transparent); + --shadow: 0 1rem 3rem color-mix(in srgb, #080c09 30%, transparent); + --font-display: + "Segoe UI Variable Display", "Avenir Next", "Segoe UI", sans-serif; + --font-body: "Segoe UI Variable Text", "Avenir Next", "Segoe UI", sans-serif; +} + +:root:not(.dark) { + --bg: color-mix(in srgb, var(--ambient-bg) 54%, #f2f0ea 46%); + --panel: color-mix(in srgb, var(--bg) 24%, white 76%); + --panel-soft: color-mix(in srgb, var(--bg) 82%, white 18%); + --panel-strong: color-mix(in srgb, var(--bg) 72%, #dfddd5 28%); + --line: color-mix(in srgb, #2c392f 10%, transparent); + --line-strong: color-mix(in srgb, #2c392f 19%, transparent); + --text: #202721; + --muted: #657068; + --accent: color-mix(in srgb, var(--accent-700) 76%, #304b39 24%); + --accent-soft: color-mix(in srgb, var(--accent) 10%, transparent); + --shadow: 0 1rem 3rem color-mix(in srgb, #374139 11%, transparent); +} + +body { + background: var(--bg); + font-size: var(--text-base); + font-weight: 400; + line-height: 1.5; +} + +body::before { + display: none; +} + +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: 650; + letter-spacing: -0.045em; +} + +button, +input, +select, +.correction-field textarea { + border-radius: 0.45rem; +} + +button { + border-radius: 999px; + border-color: var(--line-strong); + background: transparent; + font-size: var(--text-sm); + font-weight: 650; +} + +button:hover { + border-color: var(--accent); + background: var(--accent-soft); +} + +button[type="submit"], +button.action-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border-color: var(--accent); + border-radius: 999px; + background: var(--accent); + color: var(--bg); +} + +button[type="submit"]:hover, +button.action-primary:hover { + border-color: color-mix(in srgb, var(--accent) 82%, var(--text) 18%); + background: color-mix(in srgb, var(--accent) 82%, var(--text) 18%); +} + +button.action-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border-radius: 999px; + padding-inline: var(--space-3); + border-color: transparent; + background: transparent; + color: var(--text); +} + +button.action-secondary:hover { + border-color: transparent; + background: var(--accent-soft); + color: var(--accent); +} + +input, +select, +.correction-field textarea { + background: transparent; +} + +/* Windows can paint a native select menu white even inside a dark page. + Give its popup an explicit light scheme and dark option text. */ +select { + color-scheme: light; +} + +select option { + background-color: #fff; + color: #202721; +} + +.radio-form { + max-width: 34rem; + gap: var(--space-4); + margin: 0 0 var(--space-8); +} + +.radio-form > label { + display: grid; + gap: var(--space-2); + color: var(--text); + font-size: var(--text-sm); + font-weight: 600; +} 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 index 7cf9b858..24e121a8 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -1,4582 +1,16 @@ -:root { - --accent-50: #ecfdf5; - --accent-100: #d1fae5; - --accent-300: #6ee7b7; - --accent-400: #34d399; - --accent-500: #10b981; - --accent-700: #047857; - --ambient-bg: #101311; - --color-surface-dark: #121513; - --color-surface-dark-elevated: #191d1a; - --color-surface-light: #fff; - --color-surface-light-elevated: #fafafa; - --bg: var(--ambient-bg); - --panel: color-mix(in srgb, var(--color-surface-dark-elevated) 92%, white 8%); - --panel-soft: color-mix(in srgb, var(--color-surface-dark) 93%, white 7%); - --panel-strong: color-mix( - in srgb, - var(--color-surface-dark-elevated) 82%, - white 18% - ); - --line: color-mix(in srgb, white 11%, transparent); - --line-strong: color-mix(in srgb, white 19%, transparent); - --text: #f3f1ea; - --muted: #9faaa2; - --accent: var(--accent-400); - --accent-soft: color-mix(in srgb, var(--accent-500) 18%, transparent); - --danger: #ef9a9a; - /* Between the two: a state that is not an error and is not fine either. - Added for the scrobbling queue's `degraded`, which reads as "nothing is - moving" and had no colour that said so. */ - --warn: #e0bd7a; - --shadow: 0 1.5rem 4rem color-mix(in srgb, var(--bg) 76%, transparent); - --sidebar-width: 15.5rem; - - /* **The rhythm, named once.** Before this the stylesheet carried 49 distinct - spacing values and no variable at all, 24 of them used once or twice — so - nothing said what a gap between two things ought to be and every screen - answered for itself. That is what reads as cardboard: not a wrong value - anywhere, an absence of agreement everywhere. - - Nine steps on a quarter-rem grid. Ties resolve upwards, deliberately: the - complaint this answers was that things sit too close together, so 0.65 - becomes 0.75 rather than 0.5. - - A `rem` in a width, a height, a radius or an offset is left alone. Those - are dimensions, not steps in a rhythm, and a scale that swallowed them - would be renaming what it does not govern. */ - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-6: 1.5rem; - --space-8: 2rem; - --space-12: 3rem; - --space-16: 4rem; - --space-20: 5rem; - - /* **And the type, which was worse.** 37 distinct sizes, seven of them - between 0.70 and 0.86rem — the same size spelled seven ways, which is - exactly why nothing on a screen looked more important than anything else. - - Seven body steps and three display ones. The display tier is not body - copy enlarged: the letter standing in for a missing cover, the 404 and the - first-load screen live there, and folding them onto the top body step took - 5rem down to 2.5 and erased them. */ - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.25rem; - --text-xl: 1.5rem; - --text-2xl: 2rem; - --text-3xl: 2.5rem; - --text-4xl: 3rem; - --text-5xl: 4rem; - --text-6xl: 5rem; - - /* The two faces of the interface, named once. The serif is not the login - screen's ornament it was taken for: it carries every page title, the - brand, the letter standing in for a missing cover, and the 404. It was - spelled four different ways to say that, which is what kept it looking - like an accident. */ - --font-display: "Iowan Old Style", "Palatino Linotype", Georgia, serif; - --font-body: "Avenir Next", "Segoe UI Variable Text", "Segoe UI", sans-serif; - - color-scheme: dark; - font-synthesis: none; -} - -:root:not(.dark) { - --bg: var(--ambient-bg); - --panel: color-mix( - in srgb, - var(--color-surface-light-elevated) 92%, - black 8% - ); - --panel-soft: color-mix(in srgb, var(--color-surface-light) 95%, black 5%); - --panel-strong: color-mix( - in srgb, - var(--color-surface-light-elevated) 84%, - black 16% - ); - --line: color-mix(in srgb, #1d2420 13%, transparent); - --line-strong: color-mix(in srgb, #1d2420 23%, transparent); - --text: #161a17; - --muted: #626d66; - --accent: var(--accent-700); - --accent-soft: color-mix(in srgb, var(--accent-300) 27%, transparent); - --danger: #a53d3d; - --warn: #8a6314; - --shadow: 0 1.5rem 4rem color-mix(in srgb, #59645d 19%, transparent); - - color-scheme: light; -} - -* { - box-sizing: border-box; -} - -html { - min-width: 20rem; - scroll-behavior: smooth; -} - -body { - margin: 0; - min-height: 100dvh; - overflow-x: hidden; - background: - radial-gradient(circle at 78% -12%, var(--accent-soft), transparent 35rem), - var(--bg); - color: var(--text); - font: 500 0.94rem / 1.55 var(--font-body); - text-rendering: optimizelegibility; -} - -body::before { - position: fixed; - z-index: 20; - inset: 0; - pointer-events: none; - opacity: 0.035; - content: ""; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.88' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.65'/%3E%3C/svg%3E"); -} - -button, -input, -select { - font: inherit; -} - -a { - color: inherit; - text-decoration: none; -} - -button { - 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); - color: var(--text); - cursor: pointer; - transition: - transform 180ms ease, - border-color 180ms ease, - background-color 180ms ease, - color 180ms ease; -} - -button:hover { - border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); - background: color-mix(in srgb, var(--panel-strong) 88%, var(--accent) 12%); -} - -button:active { - transform: translateY(1px) scale(0.985); -} - -button:disabled { - cursor: not-allowed; - opacity: 0.42; -} - -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - -input, -select { - min-height: 2.65rem; - 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); -} - -input::placeholder { - color: color-mix(in srgb, var(--muted) 72%, transparent); -} - -/* The native select was the most visible unfinished edge of an interface that - is otherwise styled by hand. The element itself stays — it carries keyboard - handling and screen-reader semantics a custom listbox would have to rebuild, - and rebuilding them is how the accessibility gate starts failing. Only its - chrome is replaced: the platform arrow goes, and a chevron drawn in CSS - takes its place, inheriting the theme through `currentColor`. */ -select { - padding-right: var(--space-8); - appearance: none; -} - -/* The chevron shares the select's grid cell instead of being offset from the - label's edge, so it stays centred whatever height the select is given. Both - are placed explicitly: an auto-placed select would be pushed into a third row - by the pseudo-element already sitting in the second. */ -.control select, -.theme-picker select, -.language-picker select, -.library-picker select { - grid-area: 2 / 1; -} - -.control:has(select)::after, -.theme-picker::after, -.language-picker::after, -.library-picker::after { - z-index: 1; - width: 0.4rem; - height: 0.4rem; - border-right: 1.5px solid currentcolor; - border-bottom: 1.5px solid currentcolor; - margin-right: var(--space-3); - margin-bottom: 0.12rem; - color: var(--muted); - content: ""; - grid-area: 2 / 1; - pointer-events: none; - place-self: center end; - rotate: 45deg; -} - -.control:has(select):hover::after, -.theme-picker:hover::after, -.language-picker:hover::after, -.library-picker:hover::after { - color: var(--text); -} - -h1, -h2, -h3, -p { - text-wrap: pretty; -} - -h1, -h2, -h3 { - font-family: var(--font-display); - font-weight: 600; - letter-spacing: -0.035em; -} - -.muted { - color: var(--muted); -} - -.error, -.player-track small { - color: var(--danger); -} - -.notice, -.secret-output { - color: var(--accent); -} - -.eyebrow { - display: block; - margin-bottom: var(--space-2); - color: var(--accent); - font-size: var(--text-xs); - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -.skip-link { - position: fixed; - z-index: 30; - top: 0.75rem; - left: 0.75rem; - padding: var(--space-3) var(--space-4); - transform: translateY(-150%); - border-radius: 0.5rem; - background: var(--text); - color: var(--bg); -} - -.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; - } -} +/* 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 new file mode 100644 index 00000000..63382dfc --- /dev/null +++ b/webapp/src/styles/styles/base.css @@ -0,0 +1,308 @@ +/* Tokens, the reset, form controls and type: what every page stands on. */ + +:root { + --accent-50: #ecfdf5; + --accent-100: #d1fae5; + --accent-300: #6ee7b7; + --accent-400: #34d399; + --accent-500: #10b981; + --accent-700: #047857; + --ambient-bg: #101311; + --color-surface-dark: #121513; + --color-surface-dark-elevated: #191d1a; + --color-surface-light: #fff; + --color-surface-light-elevated: #fafafa; + --bg: var(--ambient-bg); + --panel: color-mix(in srgb, var(--color-surface-dark-elevated) 92%, white 8%); + --panel-soft: color-mix(in srgb, var(--color-surface-dark) 93%, white 7%); + --panel-strong: color-mix( + in srgb, + var(--color-surface-dark-elevated) 82%, + white 18% + ); + --line: color-mix(in srgb, white 11%, transparent); + --line-strong: color-mix(in srgb, white 19%, transparent); + --text: #f3f1ea; + --muted: #9faaa2; + --accent: var(--accent-400); + --accent-soft: color-mix(in srgb, var(--accent-500) 18%, transparent); + --danger: #ef9a9a; + /* Between the two: a state that is not an error and is not fine either. + Added for the scrobbling queue's `degraded`, which reads as "nothing is + moving" and had no colour that said so. */ + --warn: #e0bd7a; + --shadow: 0 1.5rem 4rem color-mix(in srgb, var(--bg) 76%, transparent); + --sidebar-width: 15.5rem; + + /* **The rhythm, named once.** Before this the stylesheet carried 49 distinct + spacing values and no variable at all, 24 of them used once or twice — so + nothing said what a gap between two things ought to be and every screen + answered for itself. That is what reads as cardboard: not a wrong value + anywhere, an absence of agreement everywhere. + + Nine steps on a quarter-rem grid. Ties resolve upwards, deliberately: the + complaint this answers was that things sit too close together, so 0.65 + becomes 0.75 rather than 0.5. + + A `rem` in a width, a height, a radius or an offset is left alone. Those + are dimensions, not steps in a rhythm, and a scale that swallowed them + would be renaming what it does not govern. */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + + /* **And the type, which was worse.** 37 distinct sizes, seven of them + between 0.70 and 0.86rem — the same size spelled seven ways, which is + exactly why nothing on a screen looked more important than anything else. + + Seven body steps and three display ones. The display tier is not body + copy enlarged: the letter standing in for a missing cover, the 404 and the + first-load screen live there, and folding them onto the top body step took + 5rem down to 2.5 and erased them. */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.25rem; + --text-xl: 1.5rem; + --text-2xl: 2rem; + --text-3xl: 2.5rem; + --text-4xl: 3rem; + --text-5xl: 4rem; + --text-6xl: 5rem; + + /* The two faces of the interface, named once. The serif is not the login + screen's ornament it was taken for: it carries every page title, the + brand, the letter standing in for a missing cover, and the 404. It was + spelled four different ways to say that, which is what kept it looking + like an accident. */ + --font-display: "Iowan Old Style", "Palatino Linotype", Georgia, serif; + --font-body: "Avenir Next", "Segoe UI Variable Text", "Segoe UI", sans-serif; + + color-scheme: dark; + font-synthesis: none; +} + +:root:not(.dark) { + --bg: var(--ambient-bg); + --panel: color-mix( + in srgb, + var(--color-surface-light-elevated) 92%, + black 8% + ); + --panel-soft: color-mix(in srgb, var(--color-surface-light) 95%, black 5%); + --panel-strong: color-mix( + in srgb, + var(--color-surface-light-elevated) 84%, + black 16% + ); + --line: color-mix(in srgb, #1d2420 13%, transparent); + --line-strong: color-mix(in srgb, #1d2420 23%, transparent); + --text: #161a17; + --muted: #626d66; + --accent: var(--accent-700); + --accent-soft: color-mix(in srgb, var(--accent-300) 27%, transparent); + --danger: #a53d3d; + --warn: #8a6314; + --shadow: 0 1.5rem 4rem color-mix(in srgb, #59645d 19%, transparent); + + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 20rem; + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100dvh; + overflow-x: hidden; + background: + radial-gradient(circle at 78% -12%, var(--accent-soft), transparent 35rem), + var(--bg); + color: var(--text); + font: 500 0.94rem / 1.55 var(--font-body); + text-rendering: optimizelegibility; +} + +body::before { + position: fixed; + z-index: 20; + inset: 0; + pointer-events: none; + opacity: 0.035; + content: ""; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.88' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.65'/%3E%3C/svg%3E"); +} + +button, +input, +select { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + 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); + color: var(--text); + cursor: pointer; + transition: + transform 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease; +} + +button:hover { + border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); + background: color-mix(in srgb, var(--panel-strong) 88%, var(--accent) 12%); +} + +button:active { + transform: translateY(1px) scale(0.985); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +input, +select { + min-height: 2.65rem; + 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); +} + +input::placeholder { + color: color-mix(in srgb, var(--muted) 72%, transparent); +} + +/* The native select was the most visible unfinished edge of an interface that + is otherwise styled by hand. The element itself stays — it carries keyboard + handling and screen-reader semantics a custom listbox would have to rebuild, + and rebuilding them is how the accessibility gate starts failing. Only its + chrome is replaced: the platform arrow goes, and a chevron drawn in CSS + takes its place, inheriting the theme through `currentColor`. */ +select { + padding-right: var(--space-8); + appearance: none; +} + +/* The chevron shares the select's grid cell instead of being offset from the + label's edge, so it stays centred whatever height the select is given. Both + are placed explicitly: an auto-placed select would be pushed into a third row + by the pseudo-element already sitting in the second. */ +.control select, +.theme-picker select, +.language-picker select, +.library-picker select { + grid-area: 2 / 1; +} + +.control:has(select)::after, +.theme-picker::after, +.language-picker::after, +.library-picker::after { + z-index: 1; + width: 0.4rem; + height: 0.4rem; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + margin-right: var(--space-3); + margin-bottom: 0.12rem; + color: var(--muted); + content: ""; + grid-area: 2 / 1; + pointer-events: none; + place-self: center end; + rotate: 45deg; +} + +.control:has(select):hover::after, +.theme-picker:hover::after, +.language-picker:hover::after, +.library-picker:hover::after { + color: var(--text); +} + +h1, +h2, +h3, +p { + text-wrap: pretty; +} + +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -0.035em; +} + +.muted { + color: var(--muted); +} + +.error, +.player-track small { + color: var(--danger); +} + +.notice, +.secret-output { + color: var(--accent); +} + +.eyebrow { + display: block; + margin-bottom: var(--space-2); + color: var(--accent); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.skip-link { + position: fixed; + z-index: 30; + top: 0.75rem; + left: 0.75rem; + padding: var(--space-3) var(--space-4); + transform: translateY(-150%); + border-radius: 0.5rem; + background: var(--text); + color: var(--bg); +} + +.skip-link:focus { + transform: translateY(0); +} 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; + } +}