Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
343 lines
9.4 KiB
CSS
343 lines
9.4 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;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------
|
||
The phone shell (plan 016 B2).
|
||
|
||
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.
|
||
|
||
The grid loses its sidebar column rather than hiding the element in
|
||
place: a named area with nothing in it still reserves its track.
|
||
--------------------------------------------------------------- */
|
||
@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;
|
||
}
|
||
|
||
.top-bar search-bar {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* The transport at phone width: art, title and the controls, with the
|
||
seek bar and volume dropped by the components that own them. The
|
||
desktop's three fixed columns start with a 320px now-playing, which
|
||
at 360px of viewport leaves the controls 40px. */
|
||
@media (max-width: 599px) {
|
||
.bottom-bar {
|
||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||
gap: 0.25em;
|
||
}
|
||
|
||
.bottom-bar audio-player {
|
||
margin: 0.25em;
|
||
}
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
#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;
|
||
}
|
||
|
||
.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;
|
||
}
|