feat(shell): show background jobs in the phone's layout, not a popover
The header indicator is a disclosure anchored to a bar 3.25em tall on a screen 439 CSS px tall, and it was reported as unreadable behind other UI. Background work is the one thing a phone should not make you open something to see, and #57 deletes the bar it hangs from and is blocked on it having somewhere else to live. Below 600px the indicator stands down and <job-band> takes over. It is the existing job-panel at `kinds="*"`, so pause, cancel, Details and the log come along, and so does applyJobControl. **It is in the layout, not over it**, and that was measured rather than assumed. The first version put the panel in notification-host's fixed band: it renders correctly, sits on top and stays inside the viewport, and is unusable -- at 424x439 a compact panel showing two jobs is ~216px of a 439px screen, drawn over the content and swallowing every tap under it. Four e2e specs caught it, and none of them was about jobs: two phone-shell journeys and the header's action menu, all failing on clicks the band was intercepting. As a grid row above the main panel it pushes instead, which is #24's one sentence deciding a layout question -- a band that hides the app to say the app is busy has traded the popover's fault for a worse one. It renders nothing above 600px, from matchMedia rather than a media query, because that decides whether the element exists: Settings already holds four job-panels and a fifth answering for every kind is bottom-nav's "resolved to 2 elements" trap again. index.css keeps it display:none off the phone for a second reason -- an in-flow grid child with no named area is auto-placed into one of the shell's rows, which is what the skip link is absolutely positioned to avoid. top-bar-fit's 390px case asserted the indicator was up, so that it could not pass by measuring the idle case under another name. At phone width it is now deliberately away, so the assertion takes the other branch of the same rule -- the indicator is hidden, the band has the row, and the bar still has nothing hanging out of it -- rather than the width being quietly dropped from the list. The report's own symptom is deliberately not asserted anywhere: it did not reproduce in this tier. Measured at 424x439 the popover was neither clipped nor covered, so a spec claiming a stacking fix would be asserting something that was never true here. The spec says so. Closes #62
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The phone's view of background work (#62).
|
||||
*
|
||||
* The header `job-indicator` is a *popover*, anchored to a bar 3.25em
|
||||
* tall on a screen 439 CSS px tall, and it was reported as unreadable
|
||||
* behind other UI. Two things are wrong with it there regardless of
|
||||
* that symptom: a popover is a **disclosure**, and background work is
|
||||
* the one thing a phone should not make you open something to see; and
|
||||
* #57 deletes the bar it is anchored to, and is blocked on this issue
|
||||
* precisely because the indicator needs somewhere else to live first.
|
||||
*
|
||||
* This is that somewhere. Below 600px the indicator stands down
|
||||
* (`index.css`) and its work appears here instead.
|
||||
*
|
||||
* Four things about it are load-bearing.
|
||||
*
|
||||
* **It is the existing `job-panel`, not a second job UI.** Pause,
|
||||
* cancel, Details and the log all come along — and, more to the point,
|
||||
* so does `applyJobControl`, which is what carries the "you will
|
||||
* discard hours of downloading" confirmation for an index build. A
|
||||
* host drawing its own buttons drops that silently, which is the trap
|
||||
* #27 already named.
|
||||
*
|
||||
* **It is in the layout, not over it**, and that was measured rather
|
||||
* than assumed. The first version of this put the panel in
|
||||
* `notification-host`'s fixed band, which reads fine in a screenshot
|
||||
* and is unusable: at 424x439 a compact panel is ~200px of a 439px
|
||||
* screen, and it *covers* what is under it. Four e2e specs failed —
|
||||
* two phone-shell journeys and the header's action menu — because the
|
||||
* panel was intercepting the taps. A band that hides the app to tell
|
||||
* you the app is busy is worse than the popover it replaced. In flow
|
||||
* it pushes instead, so nothing is covered and nothing is unreachable,
|
||||
* which is #24's one sentence across all three bands.
|
||||
*
|
||||
* **It shows active work only.** A finished row that lingers is a
|
||||
* banner that stays after the work is done, which is the opposite of
|
||||
* what #62 asks for ("dismissed automatically on completion") and, in
|
||||
* flow, is furniture that keeps the content pushed down. Finished jobs
|
||||
* are still shown where the work was started, which is #27's rule and
|
||||
* unaffected.
|
||||
*
|
||||
* **It renders nothing at all above 600px**, from `matchMedia` rather
|
||||
* than a media query, because this decides whether the element
|
||||
* *exists*. `bottom-nav` learned that the expensive way: rendering its
|
||||
* duplicate `<app-sidebar>` unconditionally put a second copy of every
|
||||
* `nav-*` testid in the DOM and broke 30 specs on a viewport where it
|
||||
* was not even visible. Settings already holds four `job-panel`s, so a
|
||||
* fifth answering for *every* kind is the same trap.
|
||||
*/
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import { jobStore } from '@store/job-store';
|
||||
import { isTerminal } from '@store/job-store';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { PHONE_QUERY } from '../../utils/breakpoints';
|
||||
import './job-panel';
|
||||
|
||||
@customElement('job-band')
|
||||
export class JobBand extends LitElement {
|
||||
@state() private phone = false;
|
||||
|
||||
@state() private active = 0;
|
||||
|
||||
private media?: MediaQueryList;
|
||||
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The panel's own margin is for a settings section; here the
|
||||
band owns the spacing. */
|
||||
job-panel {
|
||||
margin-top: 0;
|
||||
padding: 0 0.5em 0.5em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private onMedia = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
this.phone = e.matches;
|
||||
};
|
||||
|
||||
private onJobs = () => {
|
||||
this.active = jobStore.jobs.filter((job) => !isTerminal(job)).length;
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.media = window.matchMedia(PHONE_QUERY);
|
||||
this.phone = this.media.matches;
|
||||
this.media.addEventListener('change', this.onMedia);
|
||||
|
||||
// The band decides whether to render *at all*, and a panel that
|
||||
// hides itself cannot tell its host that.
|
||||
this.unsubscribe = jobStore.subscribe(this.onJobs);
|
||||
this.onJobs();
|
||||
void jobStore.init();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribe?.();
|
||||
this.media?.removeEventListener('change', this.onMedia);
|
||||
}
|
||||
|
||||
override render() {
|
||||
// `hidden` rather than an empty render, so the grid row this
|
||||
// sits in costs nothing at all while there is no work -- the
|
||||
// rule `job-panel` already follows one layer down.
|
||||
this.hidden = !(this.phone && this.active > 0);
|
||||
|
||||
if (this.hidden) return nothing;
|
||||
|
||||
return html`
|
||||
<job-panel kinds="*" density="compact" active-only></job-panel>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'job-band': JobBand;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user