Two issues over one bar, because they are one relayout. #42's own findings say so: giving wa-slider a label grows it 6px to 14px and moves the transport, which is #23's subject, so doing them in sequence means measuring the bar twice and throwing the first set away. The bar was `320px 1fr auto`, so the transport sat in the middle of what the metadata and the queue button did not use — its centre was ~140px right of the window's at every width. The outer two tracks are the same expression now, so the middle is centred by construction. The side width is the metadata's, capped at a quarter of the bar, and the cap was measured as a regression before it was a decision: reserving the full `--now-playing-width` on both sides is perfectly centred and takes the seek bar's track from 257px to 61px at 800px, and to 0 at 200% text. The control you drag was paying for the symmetry. With the cap it is 246, which is parity. It is a `min()` rather than a breakpoint because that variable is user state — the metadata has a drag handle — and tying both sides to it is also what keeps dragging meaningful; a plain `1fr … 1fr` centres just as well and silently makes the handle a no-op. The volume moved out of `audio-player` into the bar because the transport column has to hold the transport and nothing else, and it joins the queue button in one cell rather than a second column, since the centring compares columns. It is a slider by default and a popup by setting. The stored flag names the *popup*, which is this config's polarity rule — the zero value has to be the intended answer, so an existing config.toml gets the new default with no migration. Inline, the icon is the mute toggle and is named after that action rather than the state, because with the slider beside it there is nothing to disclose; the component tier now covers both presentations rather than whichever is default. Three nested rules in this block began with a bare element selector, which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** — including the ellipsis on the bar's own title and artist, which has therefore never truncated on the device. They are `&`-prefixed now. Filed as #154 for the class and for a check. `bottom-bar.spec.ts` pins both halves separately on purpose: an uncapped build is perfectly centred and fails only the seek-bar width, so a spec asserting centring alone would have passed the regression above. Both were verified by mutation. Closes #23 Closes #42
525 lines
18 KiB
CSS
525 lines
18 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;
|
||
}
|
||
|
||
/* What the bar gives up when it does not fit is decided by measuring
|
||
it (`services/top-bar-fit.ts`, #143). Two rules here are what make
|
||
that measurement mean anything.
|
||
|
||
**Nothing but the search box may shrink.** `scrollWidth` reports a
|
||
perfect fit while a child quietly truncates -- #69's trap, one
|
||
component over -- and the indicator's label is `text-overflow:
|
||
ellipsis`, so it would have absorbed the deficit and hidden it. The
|
||
search box is exempt because it shrinks between its 320px basis and
|
||
the 200px floor its own stylesheet sets, and a narrower input hides
|
||
nothing it was showing. */
|
||
.top-bar hgroup,
|
||
.top-bar library-filter,
|
||
.top-bar job-indicator {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* **The wordmark yields its width, not its existence.** It is the
|
||
app's top-level heading as well as its brand, and `display: none`
|
||
would take a document from one `h1` to none at exactly the widths
|
||
where the view's own header is the only thing left saying where you
|
||
are. This is `styles/sr-only.css.ts`'s recipe, written out because
|
||
that one is a `CSSResult` for shadow roots and this is the light
|
||
DOM. */
|
||
.top-bar hgroup.yj-collapsed {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip-path: inset(50%);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
|
||
/* The bar is `justify-content: space-between`, which with four children
|
||
spreads them evenly and left back/forward floating in the middle of
|
||
nothing. Collecting the free space *after* this one puts the pair
|
||
beside the brand, where a browser keeps them, and leaves the
|
||
right-hand group exactly as it was. */
|
||
.top-bar nav-history {
|
||
margin-right: auto;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/* Back/forward is Desktop-band chrome (#6), and 900 is the same
|
||
line the sidebar's labels and the subtitle are already given up
|
||
at -- below it the shell is narrow enough that the header is
|
||
what runs out of room first. Measured at 600, the bottom of the
|
||
Compact band: the bar is 611px inside a 600px viewport *before*
|
||
this component exists (filed separately), and 695px with it, so
|
||
keeping it here would be widening a violation of the promise
|
||
that nothing scrolls sideways at a supported size.
|
||
|
||
Nothing is unreachable as a result, which is the rule that
|
||
decides it: Alt+Left / Alt+Right are global and every width has
|
||
them, the detail views keep their own back buttons, and the
|
||
phone additionally has the platform's gesture. */
|
||
.top-bar nav-history {
|
||
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;
|
||
/* Three columns whose outer two are the *same* width, which is what
|
||
centres the middle one (#23). It was `var(--now-playing-width) 1fr
|
||
auto`, so the transport's centre sat at `W/2 + 140px` — in the
|
||
middle of the space left over, which is not the same thing and
|
||
reads as an alignment mistake at every window size.
|
||
|
||
The outer width is still `--now-playing-width`, so **the metadata
|
||
panel's drag handle keeps meaning something**: widening it takes
|
||
room from the transport on both sides at once, symmetrically. An
|
||
`1fr … 1fr` pair would have centred the transport just as well and
|
||
silently made that handle a no-op.
|
||
|
||
**The cap is what stops that being a regression**, and it was
|
||
measured as one first. Reserving the full metadata width on both
|
||
sides costs the transport twice: at 800px the outer pair wanted
|
||
640 of 800, and the seek bar's track went from 257px to 61px
|
||
(and to 0 at 200% text) — the control you drag, squeezed out to
|
||
centre the buttons above it. So the side tracks are the metadata
|
||
width *or a quarter of the bar*, whichever is smaller, which
|
||
leaves the drag handle meaningful everywhere it has room to be
|
||
and hands the difference to the transport where it does not.
|
||
|
||
`minmax(0, …)` on the outer tracks and `min-content` on the middle
|
||
decide who yields when even that is not enough: the metadata and
|
||
the end group shrink (both truncate; neither loses an action), and
|
||
the transport keeps at least its buttons. Without the `min-content`
|
||
floor the middle collapses first, because a `1fr` track's minimum
|
||
is `auto` only until something else insists. */
|
||
--bar-side: min(var(--now-playing-width, 320px), 25%);
|
||
grid-template-columns:
|
||
minmax(0, var(--bar-side))
|
||
minmax(min-content, 1fr)
|
||
minmax(0, var(--bar-side));
|
||
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`, not `p`. **A nested rule that begins with a bare
|
||
element selector is silently dropped before Chrome 120**
|
||
(relaxed nesting), and the phone this app runs on renders
|
||
in Chrome 113 -- so this ellipsis, and the two rules
|
||
below, have never applied on the device. Nothing fails;
|
||
the text simply overflows there. The `&` form is valid in
|
||
both, which is why it is used for every element selector
|
||
in this file's nested blocks. */
|
||
& p {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
}
|
||
}
|
||
|
||
& now-playing {
|
||
overflow: hidden;
|
||
}
|
||
|
||
& audio-player {
|
||
margin: 0.5em 1em;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* The right-hand group, and the thing the left column is matched
|
||
against. It is one grid cell rather than two columns because the
|
||
centring rule above compares *columns*: volume and the queue
|
||
button in separate tracks would make the outer pair unequal by
|
||
whatever the volume happens to measure. */
|
||
.bar-end {
|
||
justify-self: end;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25em;
|
||
min-width: 0;
|
||
}
|
||
|
||
#queue-button {
|
||
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.
|
||
|
||
`nav-history` is already gone from 899 down. It would belong
|
||
here anyway and for a stronger reason than width: the phone has
|
||
Back as a gesture or a button the OS owns, and this app hooks it
|
||
(`popstate`), so a second Back in the chrome duplicates a
|
||
control the platform provides. */
|
||
.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) {
|
||
/* The phone keeps the two-part bar it had: metadata, then the
|
||
transport and the queue button. There is no third column to
|
||
balance because the centring the desktop does is a luxury of
|
||
having room — at 360px the metadata needs all of the space the
|
||
controls do not. */
|
||
.bottom-bar {
|
||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||
gap: 0.25em;
|
||
}
|
||
|
||
.bottom-bar audio-player {
|
||
margin: 0.25em;
|
||
}
|
||
|
||
/* Volume stands down here whatever the setting says, because this
|
||
is about room and about the platform rather than about
|
||
preference: the hardware keys own volume on a phone, which is
|
||
also why mediacontrols' Android handler implements no volume
|
||
callback. It moved from `audio-player`'s own media query when
|
||
#42 moved the control into the bar — same rule, and now stated
|
||
where the element actually is.
|
||
|
||
`.bottom-bar volume-control`, not the one in
|
||
`now-playing-view`: that view is the phone's transport and is
|
||
where a slider does belong. */
|
||
.bottom-bar volume-control {
|
||
display: none;
|
||
}
|
||
}
|