Files
yellowjacket/frontend/index.css
T
logan ae85df0dad fix(shell): give the top bar a measured fit at every width
The bar was 611px inside a 600px viewport at the bottom of the Compact
band, and 862px while a scan with a real library's title ran, because
`job-indicator` is `hidden` when idle and 235px wide when it is not.
`body` is `overflow-x: auto`, so a user got a horizontal scrollbar on a
shell #24 promised would not need one — and the band is 600 to 899 with
work in flight, not the 600 to 610 the idle measurement suggested.

`services/top-bar-fit.ts` is `page-header`'s treatment one bar up: a
ResizeObserver, every pass starting from all-visible, hiding the
lowest-priority child until it fits. Measured rather than breakpointed
because three of the five children are as wide as their content — the
library filter by the longest library name, the indicator by the
running job's title, the search box by its view-scoped placeholder — so
any width picked is right for one library, one job and one view.

What yields is decided by #24's own sentence, which rules out the two
cheapest candidates in the Direction. Hiding the library filter takes
away an action, since it is the only control in the app that selects a
library (filed as #148, which is the phone already doing it), and
collapsing search to an icon is #57's, which is blocked behind #62. So
the wordmark yields first — a brand the window title bar repeats, and
visually-hidden rather than `display: none` because that h1 is the
document's heading — and then the indicator's label, leaving the ring,
which the component already does below 600px and whose live region
announces the state either way.

"Fits" is the children against the content box, not `scrollWidth`
against `clientWidth`: `scrollWidth` counts the left padding and not
the right, so the first version read 700/700 with the indicator sitting
in the whole right gutter. And the bar does not resize when a job
starts, which is the case this is for, so every child is observed too.

Pinned before it was fixed, as the issue asks. On the unfixed build the
new spec fails at 600 idle and at 600, 800 and 900 with a job, and
passes at 390, 899 and 1440; `layout-overflow.spec.ts` gains 600x600
and failed there. That spec asserts on the *shell*, so it was green
throughout this defect — the per-child measurement is #69's lesson, and
it is what caught the gutter case above.

Closes #143
2026-08-19 21:35:03 -04:00

452 lines
14 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
*,
*::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;
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.
`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) {
.bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em;
}
.bottom-bar audio-player {
margin: 0.25em;
}
}