The panel is flex-shrink: 0 in the flow of .content-area, so an open queue was paid for by the main panel rather than covering it. Measured on Playlists: 379px of content left at 900x600 with all three of the page header's actions clipped, 69px at 390px, and 0px at 320px — where the content was not degraded but gone. It goes to an overlay with a scrim when the content cannot spare the width, and the rule is computed rather than breakpointed: `available - panelWidth < 480`, where available is .content-area's width and so already accounts for the sidebar's collapse at 900. A media query cannot express this, which is the reason for the property: the panel is drag-resizable between 200 and 500px and persisted, so a viewport breakpoint silently assumes the default 320 and is wrong by up to 180px for a user who widened it — in the direction that hurts, since a wider queue is exactly when the content can least afford it. 480 is a judgement and the comment says so: there is no cliff to derive it from (the track list rescales continuously, 213px to 124px columns with no row overflow), so it is anchored to keep the default 1100px window inline while putting every measured-broken case on the overlay side. The overlay is a presentation and not a fork — #55 asks for one component with two mount points — so the roving tab stop, Alt+Arrow reorder, drag reorder and selection semantics are untouched. Escape closes it and returns focus, attached only while the overlay is up: it is a dismissal rather than a shortcut, which is why it is not a panel-scoped binding. The scrim covers the content area only, not the sidebar or the transport, because the queue is not modal. Refs #24
384 lines
11 KiB
CSS
384 lines
11 KiB
CSS
*,
|
||
*::before,
|
||
*::after {
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
html {
|
||
height: 100%;
|
||
}
|
||
|
||
body {
|
||
background-color: var(--yj-bg-base, black);
|
||
color: var(--yj-text-primary, white);
|
||
margin: 0;
|
||
height: 100vh;
|
||
display: grid;
|
||
grid-template: "top-bar top-bar" 4em "sidebar main-panel" 1fr "bottom-bar bottom-bar" 4em / auto 1fr;
|
||
|
||
/* a11y.21 (WCAG 1.4.10), measured rather than taken as filed.
|
||
|
||
The finding's mechanism is vertical — "the 4em bars grow while
|
||
the viewport does not, and anything that no longer fits is
|
||
clipped with no scrollbar" — and that is not what happens. The
|
||
middle row is `1fr`, so it absorbs the bars exactly: at 200%
|
||
text on an 800×600 window the bars go 64px → 128px and the main
|
||
panel goes 472px → 344px, with the footer's bottom still landing
|
||
on 600. Nothing is clipped, and Settings stays reachable because
|
||
the sidebar scrolls (007 phase 5).
|
||
|
||
What is real is the other axis, which the finding does not
|
||
mention: at 200% text the shell is 1014px wide in an 800px
|
||
viewport, and at 320px (400% page zoom of 1280) it is 611px —
|
||
291px of the app unreachable behind `overflow: hidden`. So the
|
||
horizontal axis scrolls and the vertical one stays fixed, which
|
||
is also what keeps the transport bar where a desktop player's
|
||
transport bar belongs. At every size this app promises
|
||
(800×600 and up, default text) there is no overflow on either
|
||
axis and no scrollbar appears. */
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
|
||
|
||
|
||
/* Above the phone breakpoint the tab bar does not exist. It is in the
|
||
markup unconditionally and eagerly, for the reason notification-host
|
||
is: navigation that has to fetch a chunk before it can navigate is
|
||
not navigation. */
|
||
@media (min-width: 600px) {
|
||
bottom-nav {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
/* I want to set paragraph margins myself */
|
||
}
|
||
|
||
/* a11y.30. Out of flow in both states, because `body` is a grid with
|
||
named areas and an in-flow extra child is auto-placed into one of
|
||
them — the link would silently take a row from the shell. It is not
|
||
`display: none`: a skip link that is not focusable is not a skip
|
||
link. */
|
||
.skip-link {
|
||
position: absolute;
|
||
left: -9999px;
|
||
top: 0;
|
||
z-index: 100;
|
||
padding: 0.5em 1em;
|
||
background-color: var(--yj-accent);
|
||
color: var(--yj-accent-fg);
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
border-radius: 0 0 4px 0;
|
||
}
|
||
|
||
.skip-link:focus {
|
||
left: 0;
|
||
}
|
||
|
||
/* The target of that link, so it can take focus at all. `main` is not
|
||
focusable by default, and a fragment link to an unfocusable element
|
||
moves the scroll and leaves the tab sequence exactly where it was —
|
||
which is the whole thing the link exists to change. */
|
||
.main-panel:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.top-bar {
|
||
grid-area: top-bar;
|
||
height: 100%;
|
||
padding-left: 2em;
|
||
padding-right: 2em;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background-color: var(--yj-bg-elevated, #343a40);
|
||
gap: 1em;
|
||
}
|
||
|
||
.top-bar search-bar {
|
||
flex: 0 1 320px;
|
||
}
|
||
|
||
ul {
|
||
list-style-type: none;
|
||
}
|
||
|
||
.title {
|
||
font-size: 1.5em;
|
||
/* Both margins, not just the bottom one. The pair is flex-centred
|
||
in a 4em bar and the UA gives an h1 a 0.67em top margin, so the
|
||
hgroup measured 67px inside 64 and the subtitle's descenders were
|
||
clipped by the bar. That was pre-existing; a11y.29 made it
|
||
visible by taking the h3's bottom margin away with it, which
|
||
shortened the block and moved the whole pair down into the clip.
|
||
This is the state that fix landed in. */
|
||
margin-block: 0;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 0.8em;
|
||
margin-top: 0;
|
||
}
|
||
|
||
/* The same breakpoint the sidebar collapses at (AUTO_COLLAPSE_VIEWPORT
|
||
in app-sidebar.ts). The subtitle wrapped to two lines below ~780 px,
|
||
which made the title block 98 px tall inside a 4em bar and pushed it
|
||
down into the nav (H-11). */
|
||
@media (max-width: 899px) {
|
||
.subtitle {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
body div.sidebar {
|
||
grid-area: sidebar;
|
||
background-color: var(--yj-bg-surface, #212529);
|
||
overflow: hidden;
|
||
contain: layout style paint;
|
||
}
|
||
|
||
|
||
|
||
.bottom-bar {
|
||
grid-area: bottom-bar;
|
||
padding: 0.25em;
|
||
background-color: var(--yj-bg-elevated, #343a40);
|
||
display: grid;
|
||
grid-template-columns: var(--now-playing-width, 320px) 1fr auto;
|
||
align-items: center;
|
||
contain: layout style;
|
||
|
||
#now-playing-info {
|
||
justify-self: start;
|
||
display: flex;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
|
||
#album-art {
|
||
width: 3.5em;
|
||
min-width: 3.5em;
|
||
height: 3.5em;
|
||
min-height: 3.5em;
|
||
border-radius: 0.25em;
|
||
background-color: var(--yj-accent, #ffd43b);
|
||
}
|
||
|
||
#track-info {
|
||
display: flex;
|
||
margin-left: 1em;
|
||
flex-direction: column;
|
||
font-size: 0.75em;
|
||
justify-content: center;
|
||
text-wrap-mode: nowrap;
|
||
overflow: hidden;
|
||
|
||
p {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
}
|
||
}
|
||
|
||
now-playing {
|
||
overflow: hidden;
|
||
}
|
||
|
||
audio-player {
|
||
margin: 0.5em 1em;
|
||
}
|
||
|
||
#queue-button {
|
||
justify-self: end;
|
||
background: none;
|
||
border: none;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
padding: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
#queue-button:hover {
|
||
color: var(--yj-accent, #ffd43b);
|
||
}
|
||
|
||
/* An open queue is a state this button can be in, and it used to
|
||
look exactly like the closed one -- so the only way to tell what
|
||
pressing it would do was to look at the other side of the window
|
||
and infer it. `aria-expanded` is the same fact for anyone not
|
||
looking at all, and it points at the panel it controls. */
|
||
#queue-button[aria-expanded='true'] {
|
||
color: var(--yj-accent, #ffd43b);
|
||
background: var(--yj-bg-overlay, #404040);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
#queue-button.drag-over {
|
||
color: var(--yj-accent, #ffd43b);
|
||
outline: 2px dashed var(--yj-accent, #ffd43b);
|
||
outline-offset: -2px;
|
||
border-radius: 4px;
|
||
}
|
||
}
|
||
|
||
.content-area {
|
||
grid-area: main-panel;
|
||
display: flex;
|
||
overflow: hidden;
|
||
contain: layout style;
|
||
|
||
/* The containing block for the queue panel's overlay mode (plan
|
||
018, #24), which spans this box rather than taking width from
|
||
the main panel beside it. `contain: layout` already establishes
|
||
one; this says so on purpose, so that removing the containment
|
||
for a paint reason does not silently reparent the overlay to the
|
||
viewport. */
|
||
position: relative;
|
||
}
|
||
|
||
.main-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: var(--yj-bg-surface, #212529);
|
||
overflow: hidden;
|
||
contain: layout style paint;
|
||
}
|
||
|
||
.main-panel > * {
|
||
flex: 1;
|
||
min-height: 0;
|
||
box-sizing: border-box;
|
||
contain: layout style paint;
|
||
}
|
||
|
||
/* Hidden cached views: use visibility+size collapse instead of
|
||
display:none so scroll containers preserve their scrollTop.
|
||
display:none discards scroll state in WebKitGTK. */
|
||
.main-panel > .view-hidden {
|
||
visibility: hidden !important;
|
||
flex: 0 0 0px !important;
|
||
min-height: 0 !important;
|
||
max-height: 0 !important;
|
||
height: 0 !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
border: none !important;
|
||
overflow: hidden !important;
|
||
pointer-events: none !important;
|
||
contain: strict !important;
|
||
}
|
||
|
||
|
||
/* ===================================================================
|
||
The phone shell (plan 016 B2).
|
||
|
||
**This section is last on purpose.** A media query adds no
|
||
specificity, so `@media (max-width: 599px) { .title { … } }` placed
|
||
above the plain `.title` rule loses to it -- which is exactly what
|
||
happened when this landed in the middle of the file: the header kept
|
||
its 2em gutters, its 16px gap and its 24px title on a 390px phone,
|
||
and every one of these declarations was dead. Nothing failed,
|
||
because the shell fits for a different reason (the `min-width: 0`
|
||
below and each component's own media query), so a screenshot was
|
||
what caught it.
|
||
|
||
600px, not the sidebar's 900: 900 is a *laptop* and the response to
|
||
it is a narrower sidebar, which is still a sidebar. Below 600 there
|
||
is no room for one at all -- 360px of viewport over a 200px nav is
|
||
not a layout -- so the navigation moves to the bottom, where a thumb
|
||
is, and the eleven-item list moves into `bottom-nav`'s drawer.
|
||
=================================================================== */
|
||
@media (max-width: 599px) {
|
||
body {
|
||
grid-template:
|
||
"top-bar" 3.25em
|
||
"main-panel" 1fr
|
||
"bottom-bar" auto
|
||
"bottom-nav" auto
|
||
/ 1fr;
|
||
/* Nothing may scroll sideways here. On a desktop the shell is
|
||
allowed to overflow a zoomed-in window (a11y.21 above); a
|
||
phone *is* the small viewport, so the shell has to fit it. */
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
body div.sidebar {
|
||
display: none;
|
||
}
|
||
|
||
bottom-nav {
|
||
grid-area: bottom-nav;
|
||
}
|
||
|
||
/* The 2em gutters are half a thumb each at this width, and the
|
||
subtitle is already gone from 900 down.
|
||
|
||
`min-width: 0` is the load-bearing half. A grid item's implicit
|
||
minimum is `auto` -- its content -- so a header whose children
|
||
ask for 580px makes the *body* 580px wide inside a 360px
|
||
viewport, and `overflow-x: hidden` then hides the right-hand
|
||
third of the app rather than fitting it. Every box between the
|
||
viewport and the content that must shrink needs this. */
|
||
.top-bar {
|
||
padding-left: 0.75em;
|
||
padding-right: 0.75em;
|
||
gap: 0.5em;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.content-area,
|
||
.main-panel,
|
||
.bottom-bar {
|
||
min-width: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
/* The search box is the one header control worth its width; the
|
||
library filter is a rarely-changed setting and reachable from
|
||
the drawer's Settings. */
|
||
.top-bar library-filter {
|
||
display: none;
|
||
}
|
||
|
||
/* The full-screen now-playing view *is* the transport, so the bar
|
||
repeating it underneath is 4em of a small screen spent saying
|
||
the same thing twice -- visible in a screenshot, invisible to
|
||
every assertion about either one.
|
||
|
||
`:has()` rather than a class toggled from index.ts: which view
|
||
is showing is already published as an attribute, and a second
|
||
expression of the same fact is a second thing to keep in step.
|
||
The view carries its own queue button, because this is where
|
||
that one lived. */
|
||
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
|
||
display: none;
|
||
}
|
||
|
||
.top-bar search-bar {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 599px) {
|
||
.bottom-bar {
|
||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||
gap: 0.25em;
|
||
}
|
||
|
||
.bottom-bar audio-player {
|
||
margin: 0.25em;
|
||
}
|
||
}
|