Something went wrong. Try again.
playlist generator
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793:root { color-scheme: dark; --bg: #0b0b0d; --panel: #131317; --line: #24242b; --text: #e8e8ea; --dim: #8a8a95; --accent: #ffffff;}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif;}
.shell { display: flex; height: 100vh; }
.rail { width: 300px; flex: none; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel);}
/* The settings column: the whole DialKit panel (seed, Generate, Reshuffle, Save playlist, Knobs, Filters), mirroring `.rail`'s width and border on the opposite edge of `.stage`. See the media query below for how it becomes a phone drawer instead of a permanent column. */.settings { width: 300px; flex: none; height: 100vh; display: flex; flex-direction: column; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel);}
/* The project name, outside the dial panel's own title — sits above sign-in and saved playlists, both of which now read as the side navigation's contents rather than the panel's. Below the phone breakpoint the app bar carries the same name instead, so this copy is hidden there; see the media query at the bottom of this file. */.rail-title { flex: none; padding: 16px 16px 4px; font-size: 15px; font-weight: 700; letter-spacing: -0.01em;}
/* The toggle header for the saved-playlists section — the same `.rail-playlists` markup on desktop (a permanent column) and on a phone (behind the hamburger; see the media query at the bottom of this file). */.playlists-toggle { display: flex; width: 100%; justify-content: space-between; align-items: center; background: transparent; border: none; border-radius: 0; padding: 12px 16px; font-size: 13px; font-weight: 600; cursor: pointer; color: var(--text);}.playlists-toggle:hover { color: var(--accent); }.playlists-toggle .caret { color: var(--dim); font-size: 11px; }
/* Saved playlists, at the top of the side navigation rather than in the main content area — see .playlists-list below for the row styling. */.rail-playlists { flex: none; }.rail-playlists .playlists-empty { margin: 0; padding: 0 16px 12px; }.rail-playlists .playlists-list { max-height: 34vh; overflow-y: auto; padding: 0 16px 12px; }
/* The close button for each phone drawer (`.rail`, `.settings`) — a bare back-arrow icon rather than a full-width bar, hidden on desktop where both drawers are permanent columns; see the media query below. */.rail-close, .settings-close { display: none; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: none; border-radius: 6px; background: transparent; color: var(--dim); cursor: pointer;}.rail-close:hover, .settings-close:hover { color: var(--accent); }.rail-close:focus-visible, .settings-close:focus-visible { color: var(--accent); outline: 2px solid var(--accent); outline-offset: 2px;}
/* The dial panel takes whatever height `.settings` leaves it — its only sibling on a phone is the close button above — and scrolls its own content within that; see the height chain below, which mirrors the fix for the panel's fold-clipping bug. `.rail-panel-inner` keeps its name from when the panel lived inside `.rail`, since it is what the DialKit theming below keys off. */.rail-panel-inner { flex: 1 1 auto; min-height: 0; height: 100%; overflow: hidden; }
/* * dialkit's inline-mode panel only ever sets `max-height` down this chain, * never an explicit `height` — and a percentage `max-height` resolves against * a containing block only when that block's own `height` is set explicitly, * not merely clamped by its own max-height. With every level's `height` left * at `auto`, the percentage chain silently resolves to `none` at the first * link, the panel grows to fit its content instead of the rail, and nothing * above the innermost layer (all `overflow: visible`) has a box to scroll * within. Forcing an explicit `height: 100%` at each level turns the chain * back on; `.dialkit-panel-inner.dialkit-panel-inline` already carries * `overflow-y: auto` from dialkit's own styles, so once it has a real bound * height it becomes the actual scroll container. */.rail-panel-inner .dialkit-root,.rail-panel-inner .dialkit-panel[data-mode='inline'],.rail-panel-inner .dialkit-panel-wrapper,.rail-panel-inner .dialkit-panel-inner.dialkit-panel-inline { height: 100%;}
/* DialKit has no option to leave the root panel unlabeled (see the comment above useDialKitController in App.tsx), so its title is hidden here instead — `.rail-title` above is the project name's only appearance now. `:has()` picks out only the root title's row: a folder inside the panel (Knobs, Filters, ...) renders its own title in the same `.dialkit-folder-title-row`, without the `-root` span this targets, and must keep showing. */.dialkit-folder-title-row:has(.dialkit-folder-title-root) { display: none; }
/* The panel's own rendering of the artist field, replaced by `.panel-artist` above it (see the comment on that field in App.tsx) — `dialkit-text-control` is otherwise unused, every other control in the schema being a slider, select or button, so hiding the class is as good as hiding the one field. */.rail-panel-inner .dialkit-text-control { display: none; }
/* * dialkit ships its own dark glass theme: white text at various opacities on * a blurred, bordered panel, with borderless filled fields and no accent * colour anywhere. The custom properties it themes itself with take the * app's own colours and type here, so the seed/knobs/filters editor reads as * the same product as the rest of the app rather than a plugin dropped into * it. Surfaces reuse the same panel/line pairing .handle-suggestions already * draws its own dropdown in — a field rests at the same shade as the column * around it and a highlighted or selected one lifts to --line, so a value * slider's fill, a selected dropdown option and a lit suggestion all read as * the same kind of "this one" cue. --dial-border-hover, which dialkit uses * for its own active/checked indicators (a filled toggle track, an active * slider fill), becomes --accent so "on" reads the same way here as it does * in a current playlist or a lit chip. */.rail-panel-inner .dialkit-root { --dial-surface: var(--panel); --dial-surface-hover: var(--panel); --dial-surface-active: var(--line); --dial-surface-subtle: var(--line);
--dial-text-root: var(--text); --dial-text-section: var(--dim); --dial-text-label: var(--dim); --dial-text-focus: var(--text); --dial-text-primary: var(--text); --dial-text-secondary: var(--dim); --dial-text-tertiary: color-mix(in srgb, var(--dim) 65%, transparent);
--dial-border: var(--line); --dial-border-hover: var(--accent);
--dial-glass-bg: var(--panel); --dial-dropdown-bg: var(--panel); --dial-backdrop-blur: 0px; --dial-radius: 6px; --dial-shadow: none; --dial-shadow-collapsed: none; --dial-shadow-dropdown: none;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;}
/* * Every field-like control here — the artist text box, the two selects, a * slider's track, the on/off row a boolean renders as, the action buttons, * the version toolbar — draws its edge with a background fill and no * border, where the rest of the app draws it with a border that brightens * to --accent on hover. An inset shadow adds that edge without touching the * box itself, which dialkit's own layout and drag math is already sized * against. */.rail-panel-inner .dialkit-slider,.rail-panel-inner .dialkit-select-trigger,.rail-panel-inner .dialkit-text-control,.rail-panel-inner .dialkit-labeled-control,.rail-panel-inner .dialkit-button,.rail-panel-inner .dialkit-toolbar-add,.rail-panel-inner .dialkit-toolbar-copy,.rail-panel-inner .dialkit-preset-trigger { box-shadow: inset 0 0 0 1px var(--line);}.rail-panel-inner .dialkit-select-trigger:hover,.rail-panel-inner .dialkit-labeled-control:hover,.rail-panel-inner .dialkit-button:hover,.rail-panel-inner .dialkit-toolbar-add:hover,.rail-panel-inner .dialkit-toolbar-copy:hover,.rail-panel-inner .dialkit-preset-trigger:hover,.rail-panel-inner .dialkit-select-trigger[data-open='true'],.rail-panel-inner .dialkit-preset-trigger[data-open='true'] { box-shadow: inset 0 0 0 1px var(--accent); color: var(--accent);}
/* The two numeric readouts hardcode 'Geist Mono' rather than reading a property, so they get the app's own monospace stack directly. */.rail-panel-inner .dialkit-slider-value,.rail-panel-inner .dialkit-slider-input { font-family: ui-monospace, SFMono-Regular, monospace;}
/* A folder header is this panel's own toggle-header (.playlists-toggle): the title accents on hover, its chevron icon stays dim the same way .caret does there. */.rail-panel-inner .dialkit-folder-header:hover .dialkit-folder-title { color: var(--accent);}
.stage { flex: 1; overflow-y: auto; padding: 32px 40px; }
/* Above the phone breakpoint further down, `.stage` is the column that gives way — narrowing the settings column here keeps it from being the one that crowds the content pane down to nothing first. */@media (max-width: 1100px) { .settings { width: 240px; }}
h1 { margin: 0; font-size: 28px; letter-spacing: -0.02em;}
.sub, .status, .drops, .error, .warn { color: var(--dim); font-size: 12px; margin: 6px 0; }/* Track names in the status line vary widely in length and must not reflow the page. */.status { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.error { color: #ff6b6b; }.warn { color: #e0a030; }.drops { font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 8px; margin: 16px 0 8px; }
/* A generate's worth of past playlists, so the strip wraps rather than pushing the track list off the bottom of the header. */.earlier { flex-wrap: wrap; align-items: center; margin-top: 8px; }.earlier button { padding: 3px 8px; font-size: 12px; }.earlier button.current { border-color: var(--accent); color: var(--accent); }
/* Lives in the 300px-wide rail rather than the main content area it started in, so it stacks and left-aligns instead of sitting right-justified in a wide row. */.auth { flex: none; align-items: flex-start; margin: 0; padding: 8px 16px 16px; border-bottom: 1px solid var(--line);}.auth .sub { margin: 0; }/* The one-click re-login offer above the sign-in field: an avatar + handle row in the same 20px-round language as the suggestions below it, with a quiet ✕ for "that's not me". Sits on its own full-width line so the two ways in (the remembered account, and the field) don't read as one form. */.last-account { display: flex; align-items: center; gap: 8px; width: 100%; margin-bottom: 8px; }.last-account .continue-as { flex: 1 1 auto; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 5px 10px; text-align: left;}.last-account .continue-as .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}.last-account img { flex: none; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; background: var(--line); }.last-account .not-me { flex: none; padding: 5px 9px; color: var(--dim); }.last-account .not-me:hover:not(:disabled) { color: #ff6b6b; border-color: transparent; }/* The signed-in account's own avatar, beside its handle — the same 20px round treatment as the sign-in field's suggestion rows, so both read as "an account" the same way. Inline-block sized onto the 12px sub line. */.auth .avatar { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; background: var(--line);}.signin { position: relative; display: flex; flex-wrap: wrap; gap: 8px; width: 100%; }.signin .handle { flex: 1 1 auto; min-width: 0; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; font: inherit; font-size: 13px;}
/* Handle typeahead: sits over whatever's below the rail's own auth row rather than pushing it down, the same reason a dropdown always floats. */.handle-suggestions { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; margin: 4px 0 0; padding: 4px; list-style: none; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; max-height: 240px; overflow-y: auto;}.handle-suggestions li { margin: 0; }.handle-suggestions button { display: flex; align-items: center; gap: 8px; width: 100%; border: none; border-radius: 4px; padding: 6px 8px; text-align: left; font: inherit;}.handle-suggestions li.active button,.handle-suggestions button:hover { background: var(--line); border-color: transparent; color: var(--text); }.handle-suggestions img { flex: none; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }.handle-suggestions .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.handle-suggestions .at { flex: none; margin-left: auto; color: var(--dim); font-size: 12px; }
/* ArtistTypeahead's own field and dropdown — the rail's "new playlist from an artist" field and the settings panel's replacement for DialKit's own artist control, sharing one implementation (see src/ArtistTypeahead.tsx). Laid out the same way `.signin`/`.handle` are, so the two typeaheads in the rail read as one family. */.artist-field { position: relative; display: flex; gap: 8px; width: 100%; }.artist-input { flex: 1 1 auto; min-width: 0; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; font: inherit; font-size: 13px;}
/* Same dropdown as `.handle-suggestions`, minus the secondary `.at` column — an artist has only the one name to show. */.artist-suggestions { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; margin: 4px 0 0; padding: 4px; list-style: none; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; max-height: 240px; overflow-y: auto;}.artist-suggestions li { margin: 0; }.artist-suggestions button { display: flex; align-items: center; gap: 8px; width: 100%; border: none; border-radius: 4px; padding: 6px 8px; text-align: left; font: inherit;}.artist-suggestions li.active button,.artist-suggestions button:hover { background: var(--line); border-color: transparent; color: var(--text); }.artist-suggestions img, .artist-suggestions .artist-avatar { flex: none; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; background: var(--line); }.artist-suggestions .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The rail field's own section, styled like `.auth` above it — flush to the rail's edges, with the same divider below separating it from the list it feeds. */.rail-new { flex: none; padding: 8px 16px 16px; border-bottom: 1px solid var(--line); }
/* The settings panel's replacement for DialKit's own artist field (hidden above via `.dialkit-text-control`) — sits in `.settings`'s column outside `.rail-panel-inner` rather than inside it, so it never disturbs the height chain that section's own comment relies on. Padded and bordered to land where DialKit's own field used to sit, flush with the rest of the panel's edges. */.panel-artist { flex: none; padding: 0 16px 12px; margin: 0 0 4px; border-bottom: 1px solid var(--line); }
button { background: transparent; color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; font: inherit; cursor: pointer;}button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }button:disabled { opacity: 0.35; cursor: default; }
.player-bar { display: flex; align-items: center; gap: 16px; margin: 20px 0 0;}/* * The YouTube iframe is parked off-screen at the size the player is built with * rather than hidden, because a frame that is display:none or zero-sized is * grounds for a browser to stop the audio. Fixed positioning keeps it out of * the bar's layout entirely. */.player-offscreen { position: fixed; left: -9999px; top: 0; width: 200px; height: 113px; }.player-art { flex: none; width: 113px; height: 113px; background: var(--panel); border-radius: 6px; overflow: hidden; }.player-art img { display: block; width: 100%; height: 100%; object-fit: cover; }.player-controls { display: flex; align-items: center; gap: 8px; }/* The one control that works before a track is picked, so it carries the emphasis. */.play-all { font-weight: 600; padding-inline: 12px; }/* The playing track's Favorites star, in the transport cluster — the same toggle as a row's star (`.links .choice.star` above), relocated because the row is not always in view while its track plays. Filled and accented while the track is in Favorites. */.star-toggle { font-size: 16px; padding-inline: 9px; }.star-toggle.active { color: var(--accent); border-color: var(--accent); }
/* * The whole list is one grid and each row is a subgrid of it. Giving every row * its own grid instead lets the auto-width metadata column size per row, so a * track carrying a bpm widens its own row and pushes the title and artist * columns out of line with every other row. */.tracks { list-style: none; margin: 24px 0 0; padding: 0; display: grid; grid-template-columns: 28px 40px 1fr 1fr auto auto; column-gap: 12px;}
.tracks li { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--line);}.tracks li.current { background: color-mix(in srgb, var(--accent) 10%, transparent); }.tracks li.current .title { color: var(--accent); }.tracks li.failed .title, .tracks li.failed .artist { color: #ff6b6b; opacity: 0.7; }
.play { padding: 2px 6px; font-size: 11px; }.n { color: var(--dim); font-variant-numeric: tabular-nums; font-size: 12px; }.title { font-weight: 500; }.artist { color: var(--dim); }/* Right-aligned so the shorter metadata strings stay against the links column rather than trailing off after the widest row's bpm. */.meta { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right;}.links { display: flex; gap: 8px; align-items: center; font-size: 11px; }.links a { color: var(--dim); text-decoration: none; }.links a:hover { color: var(--accent); }.links .choice { border: 0; background: none; padding: 2px; color: var(--dim); font-size: 14px; line-height: 1; }.links .choice:hover, .links .choice.active { color: var(--accent); }.links .choice.star { font-size: 19px; }@media (max-width: 720px) { .tracks { grid-template-columns: 28px 30px minmax(0, 1fr) auto; column-gap: 6px; } .tracks li { grid-template-rows: auto auto auto; } .tracks .play { grid-column: 1; grid-row: 1 / 3; } .tracks .n { grid-column: 2; grid-row: 1 / 3; } .tracks .title { grid-column: 3 / 5; grid-row: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tracks .artist { grid-column: 3 / 5; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tracks .meta { display: none; } .tracks .links { grid-column: 3 / 5; grid-row: 3; gap: 6px; margin-top: 5px; } .tracks .links .choice { padding: 8px; }}/* Sits in the links column rather than beside the artist name, so the row's grid columns stay as they were and the left of the row stays playback-only. */.reseed { border: none; padding: 0; color: var(--dim); font-size: 13px; line-height: 1; }.reseed:hover:not(:disabled) { color: var(--accent); }/* Also in the links column, beside .reseed. Destructive, so its hover colour diverges from the rest of the row's accent-on-hover convention. */.remove { border: none; padding: 0; color: var(--dim); font-size: 13px; line-height: 1; }.remove:hover:not(:disabled) { color: #ff6b6b; }
/* * The stage while a Generate runs: the playlist being replaced has already * been archived, so its rows step aside for placeholder ones that pulse while * requests are in flight. Row height and the top margin match .tracks, so the * finished list swapping in reads as the same slot filling up rather than a * layout change. */.building-list { margin: 24px 0 0; }.building-row { display: flex; align-items: center; gap: 12px; height: 35px; /* .tracks li's 7px padding either side of a 21px line */ border-bottom: 1px solid var(--line);}.building-row .bar { height: 12px; border-radius: 6px; background: var(--panel); animation: building-pulse 1.6s ease-in-out infinite;}/* Title bar where the title column sits, artist bar pushed out towards where the artist column ends — same columns the real rows use. */.building-row .bar.title { flex: none; width: 34%; }.building-row .bar.artist { flex: none; width: 18%; margin-left: auto; }/* A gentle ripple down the list rather than one block blinking in unison. */.building-row:nth-child(2) .bar { animation-delay: 0.12s; }.building-row:nth-child(3) .bar { animation-delay: 0.24s; }.building-row:nth-child(4) .bar { animation-delay: 0.36s; }.building-row:nth-child(5) .bar { animation-delay: 0.48s; }.building-row:nth-child(6) .bar { animation-delay: 0.6s; }.building-row:nth-child(7) .bar { animation-delay: 0.72s; }.building-row:nth-child(8) .bar { animation-delay: 0.84s; }@keyframes building-pulse { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; }}/* The header's one-line stand-in for the counts it hides during a build — carries its own pulse so the page reads as working, not stalled. */.building::before { content: '●'; color: var(--dim); font-size: 9px; vertical-align: 1px; margin-right: 7px; animation: building-pulse 1.6s ease-in-out infinite;}/* Kept last so its animation:none beats the equal-specificity rules above by source order, not just media match. */@media (prefers-reduced-motion: reduce) { .building-row .bar, .building::before { animation: none; opacity: 0.7; }}
/* About & roadmap. Narrower than the track list, which is a table and wants the width; prose does not. */.about { max-width: 620px; margin: 0 0 80px; }.about h1 { margin-bottom: 12px; }.about h2 { font-size: 15px; margin: 32px 0 4px; }.about p { margin: 0 0 12px; }.about .sub { font-size: 13px; }.back { margin: 0 0 24px; padding: 3px 8px; font-size: 12px; }.about .back:last-child { margin: 32px 0 0; }
.legend { display: flex; gap: 18px; flex-wrap: wrap; margin: 20px 0 0; color: var(--dim); font-size: 12px; }.legend span { display: inline-flex; align-items: center; gap: 7px; }/* The legend reuses .dot inline, so it has to undo the absolute positioning the timeline relies on. */.legend .dot { position: static; }
/* * One line down the left with a status dot per row. The spine is inset top and * bottom so it starts and ends at a dot rather than overhanging the list, and * each dot's centre sits on it: the dot is 16px wide at left 0, the spine 2px at * left 7px. */.timeline { list-style: none; margin: 4px 0 0; padding: 0; position: relative; }.timeline::before { content: ''; position: absolute; top: 8px; bottom: 8px; left: 7px; width: 2px; background: var(--line);}.timeline li { position: relative; padding: 10px 0 10px 34px; }.dot { position: absolute; left: 0; top: 13px; width: 16px; height: 16px; border-radius: 50%; box-sizing: border-box; border: 2px solid var(--line); background: var(--bg);}/* Filled means shipped, hollow means not yet. Planned titles keep full contrast — they are not lesser, only later. */.done .dot { background: var(--accent); border-color: var(--accent); }.planned .dot { background: var(--bg); border-color: var(--dim); }.item-title { font-weight: 600; }.item-desc { color: var(--dim); font-size: 12px; margin-top: 2px; }
/* Sits at the bottom of the side navigation, under the saved-playlists list. */.about-link a { color: var(--dim); text-decoration: none; }.about-link a:hover { color: var(--accent); text-decoration: underline; }
/* Pinned to the bottom of `.rail`'s column with `margin-top: auto` — nothing in `.rail` grows to fill the column the way `.rail-panel` did before the settings panel moved out into its own `.settings` aside, so there is no flex-grow sibling left to absorb the space above this instead. The same rule reaches both the desktop column and the phone drawer, since `.rail` is the one element CSS repositions between them. */.rail-about { flex: none; margin-top: auto; padding: 12px 16px; border-top: 1px solid var(--line); font-size: 12px; }
.chip { padding: 2px 7px; font-size: 11px; margin-right: 4px; border-radius: 999px;}
/* Rows for the saved-playlists collection — shared by the side navigation on desktop (.rail-playlists) and the default view on a phone. One line per row: the name is a plain label, not a bordered button, and fills every pixel the row's actions aren't using, truncating only once it truly runs out of room; the track count and save date live in its title tooltip instead of a second line. Where hovering is how a pointer usually points at something, the actions collapse to zero width rather than merely turning invisible, so the name reflows to use the space they'd otherwise reserve — a convention that doesn't apply on touch, which can't hover, so there the actions stay visible (and the name correspondingly narrower) by default instead. */.playlists-list { list-style: none; margin: 0; padding: 0; }.playlists-list li { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line);}.playlists-list li:last-child { border-bottom: none; }.playlists-list .name { flex: 1 1 auto; min-width: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 6px; font-size: 13px; line-height: 1.3; text-align: left; background: transparent; border: none; border-radius: 6px;}.playlists-list li:hover .name,.playlists-list .name:focus-visible { background: var(--line); }/* Comes after the hover rule so a current playlist keeps reading as current even while the row it's in is also hovered. */.playlists-list li.current .name { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent);}.playlists-list .row-actions { display: flex; flex: none; gap: 4px; }.playlists-list .rename { flex: 1 1 auto; min-width: 0; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; font: inherit; font-size: 13px;}
/* A touch screen never fires :hover, so the actions this collapses stay at their natural size there by default rather than needing a tap that reveals nothing to tap. Collapsing width (not just opacity) is what lets the name above claim the space back rather than merely uncovering it. */@media (hover: hover) and (pointer: fine) { .playlists-list .row-actions { max-width: 0; opacity: 0; overflow: hidden; transition: max-width 0.12s ease, opacity 0.12s ease; } .playlists-list li:hover .row-actions, .playlists-list li:focus-within .row-actions { max-width: 160px; opacity: 1; }}
/* Hidden above the phone breakpoint below: on desktop `.rail` is a permanent column, so there is nothing for the hamburger to open, and `.rail-title` already carries the project name the app bar repeats on a phone. */.app-bar { display: none; }
/* Hidden above the same breakpoint: on desktop the settings column is always visible, so this playlist-header shortcut to it has nothing to do. */.edit-playlist { display: none; }
/* * Below this width `.rail` and `.settings` stop being permanent columns and * become two full-screen drawers instead, each opened by its own top-bar * button and closed by its own button (or Escape) — see the `mobileMenuOpen` * and `settingsOpen` state in App.tsx. None of this swaps out the React * tree — `.rail`, `.settings` and `.stage` are exactly the elements they are * above the breakpoint too, just repositioned by CSS, so `.player-offscreen` * two levels into `.stage` is never affected by crossing it. */@media (max-width: 720px) { .shell { flex-direction: column; height: auto; min-height: 100vh; }
.rail, .settings { display: none; width: 100%; flex-direction: column; position: fixed; inset: 0; z-index: 100; height: 100vh; padding: 16px; background: var(--panel); } .rail.open, .settings.open { display: flex; } .rail-close, .settings-close { display: flex; flex: none; margin: 0 0 16px; } .rail-title { display: none; }
/* The app bar: hamburger and project name together, in normal flow above the header rather than as floating buttons. */ .app-bar { display: flex; align-items: center; gap: 12px; margin: 0 0 16px; } .app-bar-title { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; } .hamburger { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; font-size: 18px; background: var(--panel); }
/* The playlist header's own opener for the settings drawer, next to its title rather than up in the app bar — see `.edit-playlist` above. */ .playlist-title-row { display: flex; align-items: center; gap: 12px; } .edit-playlist { display: inline-flex; flex: none; padding: 4px 10px; font-size: 12px; color: var(--dim); }
.stage { padding: 20px; }
/* Export buttons and the "Earlier" strip are sized for a row that fits a desktop-width header; without wrapping, five-across squeezes each button's own label onto two or three lines instead. */ .row { flex-wrap: wrap; }}