Files
yellowjacket/frontend/src/components/jobs/job-indicator.ts
T
logan 57fbbdf0d2
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
feat(ui): a shell a phone can be held in
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.
2026-08-16 23:19:26 -04:00

474 lines
15 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import {
jobIcon,
stateTone,
stateLabel,
jobStateStyles,
} from './job-format';
/** Circumference of the progress ring at r=9. */
const RING_CIRCUMFERENCE = 2 * Math.PI * 9;
/**
* Persistent background-job indicator for the top bar.
*
* Hidden entirely when nothing is running, so it costs no attention in
* the common case. When work is in flight it shows a determinate ring
* for a single job, or a count badge for several. Clicking opens a
* popover with inline pause/stop controls; "Details" opens the drawer.
*/
@customElement('job-indicator')
export class JobIndicator extends LitElement {
@state()
private jobs: Job[] = [];
@state()
private popoverOpen = false;
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
srOnly,
jobStateStyles,
css`
:host {
display: inline-flex;
align-items: center;
position: relative;
}
:host([hidden]) {
display: none;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.3em 0.7em 0.3em 0.35em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-size: var(--yj-text-sm);
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.trigger:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.trigger:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.ring-wrap {
position: relative;
width: 22px;
height: 22px;
flex-shrink: 0;
}
svg {
width: 22px;
height: 22px;
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 2.5;
}
.ring-value {
fill: none;
stroke: var(--job-tone);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 240ms ease;
}
/* Indeterminate work spins the whole ring instead of
* advancing it, so it never implies false precision. */
.ring-wrap.spin svg {
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(270deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ring-wrap.spin svg {
animation-duration: 3s;
}
}
.ring-glyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--job-tone);
font-variant-numeric: tabular-nums;
}
.label {
white-space: nowrap;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
/* On a phone the ring is the whole indicator: "3 background
jobs" is 114px of a 360px header, and it pushed the
header past the viewport. Only the *visible* label
goes -- the live region in render() is what announces
this, and it is unaffected, so the ring keeps its
accessible name and screen readers keep hearing the
state change. */
@media (max-width: 599px) {
.label {
display: none;
}
}
.alert-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff8787;
flex-shrink: 0;
}
.panel {
width: 24rem;
max-width: 92vw;
background: var(--yj-surface, #212529);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 0.4em;
max-height: 70vh;
overflow-y: auto;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4em 0.6em 0.5em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.panel-header button {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
padding: 0.15em 0.4em;
border-radius: 5px;
}
.panel-header button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.job-entry {
border-radius: 8px;
}
.job-entry + .job-entry {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.details-link {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--yj-accent-text, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0 0.75em 0.6em 3.2em;
}
.details-link:hover {
text-decoration: underline;
}
.empty {
padding: 0.8em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => this.syncJobs());
void jobStore.init();
this.syncJobs();
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onKeydown);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
document.removeEventListener('click', this.onDocumentClick);
document.removeEventListener('keydown', this.onKeydown);
}
private syncJobs() {
this.jobs = jobStore.jobs;
// The drawer stays mounted so it can animate closed; the pill
// itself disappears once nothing is happening.
this.hidden = !jobStore.shouldShowIndicator && !this.drawerOpen;
if (this.hidden) this.popoverOpen = false;
}
private onDocumentClick = (e: MouseEvent) => {
if (!this.popoverOpen) return;
if (e.composedPath().includes(this)) return;
this.popoverOpen = false;
};
private onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.popoverOpen) this.popoverOpen = false;
};
private onTriggerClick = (e: Event) => {
e.stopPropagation();
this.popoverOpen = !this.popoverOpen;
};
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
this.popoverOpen = false;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
this.syncJobs();
};
private async clearFinished(e: Event) {
e.stopPropagation();
await jobStore.clearFinished();
}
/** The job whose progress the ring represents. */
private get primaryJob(): Job | null {
const working = jobStore.workingJobs;
if (working.length > 0) return working[0] ?? null;
const active = jobStore.activeJobs;
return active[0] ?? null;
}
private renderRing(job: Job | null) {
const activeCount = jobStore.activeJobs.length;
const fraction = job ? progressFraction(job) : null;
const tone = job ? stateTone(job) : 'success';
// Only spin for work that is actually moving. A paused or
// queued job spinning would say "busy" when nothing is running.
const spin = Boolean(
job && job.state === 'running' && isIndeterminate(job),
);
const offset =
fraction === null
? RING_CIRCUMFERENCE * 0.72
: RING_CIRCUMFERENCE * (1 - fraction);
return html`
<div class="ring-wrap tone-${tone} ${spin ? 'spin' : ''}">
<svg viewBox="0 0 22 22" aria-hidden="true">
<circle class="ring-track" cx="11" cy="11" r="9"></circle>
<circle
class="ring-value"
cx="11"
cy="11"
r="9"
stroke-dasharray=${RING_CIRCUMFERENCE}
stroke-dashoffset=${offset}
></circle>
</svg>
<div class="ring-glyph">
${activeCount > 1
? activeCount
: html`<wa-icon
name=${job ? jobIcon(job) : 'check'}
></wa-icon>`}
</div>
</div>
`;
}
/**
* What the trigger says. Extracted so the live region can announce
* the same sentence — it swung between "Scanning Music", "3
* background jobs" and "Finished" in silence (a11y.12).
*/
private triggerLabel(): string {
const job = this.primaryJob;
const activeCount = jobStore.activeJobs.length;
if (activeCount > 1) return `${activeCount} background jobs`;
if (job && job.state === 'running') return job.title;
// "Scanning Music" would be a lie for a job that is paused or
// queued, so lead with the state instead.
if (job) return `${stateLabel(job)} · ${job.title}`;
return 'Finished';
}
private renderTrigger() {
const job = this.primaryJob;
const hasFailure = jobStore.failedJobs.length > 0;
const label = this.triggerLabel();
return html`
<button
class="trigger"
aria-haspopup="true"
aria-expanded=${this.popoverOpen}
title="Background jobs"
@click=${this.onTriggerClick}
>
${this.renderRing(job)}
<span class="label">${label}</span>
${hasFailure
? html`<span
class="alert-dot"
role="img"
aria-label="A background job failed"
></span>`
: nothing}
</button>
`;
}
private renderPanel() {
const finished = jobStore.finishedJobs;
return html`
<!-- Not role="dialog": nothing moves focus into this, traps
Tab or handles Escape, so announcing a dialog that never
receives focus was a promise it does not keep (a11y.17). -->
<div class="panel" role="group" aria-label="Background jobs">
<div class="panel-header">
<span>Background jobs</span>
${finished.length > 0
? html`<button @click=${this.clearFinished}>
Clear finished
</button>`
: nothing}
</div>
${this.jobs.length === 0
? html`<div class="empty">Nothing running.</div>`
: this.jobs.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="compact"
open-on-click
@job-control=${applyJobControl}
@job-open=${() =>
this.openDetails(job.id)}
></job-row>
<button
class="details-link"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount} warning${job.warnCount === 1 ? '' : 's'}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
override render() {
return html`
<div class="sr-only" role="status" aria-live="polite">
${this.triggerLabel()}
</div>
<wa-popup
placement="bottom-end"
distance="8"
?active=${this.popoverOpen}
>
<span slot="anchor">${this.renderTrigger()}</span>
${this.popoverOpen ? this.renderPanel() : nothing}
</wa-popup>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-indicator': JobIndicator;
}
}