zz-meridian 0.31.0 → 0.31.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -556,7 +556,21 @@ export const RELEASE_MIGRATIONS = [
556
556
  instructions: 'The release\'s `app/layout.tsx` shows `app.description` as the document\'s description — the line a search engine and a link preview put under the name — where it used to write a sentence of the template\'s own. `src/app.config.ts` is yours and an update does not touch it, so the key is missing there until you add it. Add `description: \'<one sentence about your product>\',` to the `app` object beside `name`. A project that keeps its own `app/layout.tsx` needs the key all the same, because the layout a project brings over from the release reads it.',
557
557
  checks: ['gate'],
558
558
  },
559
+ {
560
+ id: 'live-stream-shutdown',
561
+ since: '0.31.1',
562
+ applies: liveStreamKeptOnStop,
563
+ summary: 'The live stream ends every open stream when the server is told to stop, so tabs reconnect to the server that replaces it.',
564
+ instructions: '`src/data/live-stream.ts` is yours, so an update does not touch it, and the copy you have never ends a stream. A server told to stop (`next start` on SIGTERM or SIGINT, as a deploy or a restart does) closes its port and waits for its open connections: the old process stays up holding every tab\'s stream while the new one serves the port, so no tab hears a change hint again and every change waits for its next safety poll. Bring the block that begins `Every stream still open` over from `references/live.md` (the `open` set, `endLiveStreams` and `endOnShutdown`), call `open.delete(close)` first in the stream\'s `close`, and `endOnShutdown(close)` after the stream listens for its abort. A project that serves its stream some other way ends its streams on the signal the same way.',
565
+ checks: ['gate'],
566
+ },
559
567
  ];
568
+ /** 0.31.1: a live stream starter that does not end its streams when the server is told to stop. */
569
+ function liveStreamKeptOnStop(root) {
570
+ const rel = 'src/data/live-stream.ts';
571
+ const body = text(root, rel);
572
+ return body !== null && /export function liveStream\b/.test(body) && !/endLiveStreamsOnSignal/.test(body) ? [rel] : null;
573
+ }
560
574
  /** The `app` object of a project's configuration, as `src/app.config.ts` writes it. */
561
575
  const APP_OBJECT = /^export const app = \{[\s\S]*?^\} as const;$/m;
562
576
  /** 0.30.0: the release's layout reads `app.description`, and a project's own configuration has no such key. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,23 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.31.2] · 2026-10-27
6
+
7
+ ### Fixed
8
+
9
+ - **Home and Revenue no longer drop a frame when the period changes to 7D on a slow phone** (the known issue of 0.31.0). At 7D the year is 52 windows, drawn in two column charts and a trend, and the tap committed about a thousand elements in one frame: on the weekly runner's phone its longest gap was 83 ms against the 50 ms floor. Measured at the emulated phone's rate times 1.6, which reproduces the runner, the gap is now 50 ms in six of six runs (style and layout 31 to 36 ms, from 63 to 72).
10
+ - **A dense column chart draws a finished column as one element.** Past 30 columns, a plain chart's finished column is the `[data-column]` element alone, its bar the element's `::before` with the same share of the scale, rounded top and 2px floor, and the row of columns grows as one instead of a column at a time. A running column, a column with no data, a stacked or a signed chart is drawn as before. A finished plain column of any chart is its own fill, one element fewer.
11
+ - **A chart's data table and axis labels follow its marks a frame later when the period changes.** ColumnChart's and TrendChart's `sr-only` table, and ColumnChart's `aria-hidden` axis labels, render one frame after the set of columns or dates changes; the first render has them at once, and the label row keeps its height for that frame.
12
+
13
+ ## [0.31.1] · 2026-10-27
14
+
15
+ ### Fixed
16
+
17
+ - **A server that is stopped lets go of its live streams.** `next start` told to stop (SIGTERM or SIGINT, as a deploy or a restart does) closes its port and then waits for its open connections, and a live stream never ended of itself. So the old process stayed up holding every tab's stream while the new one served the port: no tab heard a change hint again, and every change waited for the tab's next safety poll (the weekly live check: `a server restart: B did not show a write made after the restart within 5000 ms`). `liveStream` now ends every open stream when the process is told to stop; each tab reconnects to the new server within about a second and hears hints again (a write after a restart reached the other tab in 540 ms, where it took 2.9 s). Where nothing else listens for the signal, the process still stops as it did.
18
+ - **What a team does.** `src/data/live-stream.ts` is yours, so an update does not replace it: run `update`, which reports `migration:live-stream-shutdown` for a copy that never ends its streams, and bring the block that begins `Every stream still open` over from `references/live.md`.
19
+ - **Doing nothing.** Nothing fails; a deploy or restart keeps the old server up while a tab is open, and changes reach other tabs only at their next poll until they reload.
20
+ - **A live tab tries its stream again as soon as the server answers after it did not.** A tab that had given its stream up for polling tried it again only on every fifth poll. A poll that answers after one that failed (the server is back) now opens the stream at once, so hints resume; a stream a proxy blocks, whose polls never fail, is still tried on every fifth poll.
21
+
5
22
  ## [0.31.0] · 2026-10-26
6
23
 
7
24
  ### New
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -81,7 +81,7 @@ function walk(dir: string, ext: RegExp, out: string[] = []) {
81
81
  const css = read('src/styles/tokens.css') + read('src/styles/base.css') + read('src/styles/motion.css');
82
82
  const defined = new Set([...css.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1]));
83
83
  /** Local custom properties a component sets for itself, and the host's bridged variables. */
84
- const LOCAL = /^(i|w|m-len|cols(-(xl|lg|md|sm))?|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
84
+ const LOCAL = /^(i|w|m-len|bar-(h|at)|cols(-(xl|lg|md|sm))?|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
85
85
 
86
86
  // ── Cards ────────────────────────────────────────────────────────────────────────────────────────────
87
87
  const CARD_SECTIONS = ['## Surfaces', '## Agents', '## Accessibility'];
@@ -115,6 +115,33 @@ const MAX_QUEUED = 100;
115
115
 
116
116
  let noted = false;
117
117
 
118
+ /**
119
+ * Every stream still open. A server told to stop (`next start` on SIGTERM or SIGINT) closes its port and then waits for
120
+ * its open connections, and a stream never ends of itself: the old process kept every tab's stream while the new one
121
+ * served the port, so no tab heard a hint again until its next poll. Ending them lets the old process go and each tab
122
+ * connect to the new one.
123
+ */
124
+ const open = new Set<() => void>();
125
+ let watching = false;
126
+
127
+ /** Ends every open stream; each tab reconnects (the stream's `retry`), to whichever server then holds the port. */
128
+ function endLiveStreams() {
129
+ for (const close of [...open]) close();
130
+ }
131
+
132
+ function endOnShutdown(close: () => void) {
133
+ open.add(close);
134
+ if (watching || typeof process === 'undefined' || typeof process.once !== 'function') return;
135
+ watching = true;
136
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
137
+ process.once(sig, function endLiveStreamsOnSignal() {
138
+ endLiveStreams();
139
+ // A listener takes the place of the signal's default: where nothing else listens, the process stops as it would have.
140
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
141
+ });
142
+ }
143
+ }
144
+
118
145
  type LiveStreamOptions = {
119
146
  /** The requested names, already authorized once; the first frame greets with what moved. */
120
147
  names: string[];
@@ -141,6 +168,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
141
168
  function close() {
142
169
  if (closed) return;
143
170
  closed = true;
171
+ open.delete(close);
144
172
  clearInterval(timer);
145
173
  for (const off of unsubscribe) off();
146
174
  unsubscribe.length = 0;
@@ -195,6 +223,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
195
223
  controller = c;
196
224
  if (signal.aborted) return close();
197
225
  signal.addEventListener('abort', close);
226
+ endOnShutdown(close);
198
227
  // Subscribe first, then compare: a write after the page's read either moves a revision the greeting names or
199
228
  // arrives as a hint.
200
229
  collections.forEach((collection, i) => {
@@ -191,6 +191,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
191
191
  | `no-data-mark` (0.19.0) | a test of the team's importing the Column chart and expecting a `.bg-fill-track` in a period with no data, such as `optional:tests/column-chart-no-data.test.tsx` as the template wrote it | a `null` period draws a dashed hairline on the baseline since 0.19.0, not a track the height of the scale, which was the tallest mark on the chart: bring `optional:tests/column-chart-no-data.test.tsx` over from the release as it has it, and find `[data-no-data-mark]` where a test of your own found `.bg-fill-track` or `[data-track]`; checked by the gate |
192
192
  | `table-grid` (0.26.0) | a file of the team's importing the Table or the DataTable that expects a `grow` column's `@min-[672px]:min-w-64`, such as `optional:tests/table-cut-cell.test.tsx` as the template wrote it, or passes a `colSpan` to a `TableCell` | a table is a grid since 0.26.0, its spare shared equally between the gaps, and a `grow` column's floor is `min-w-40` at every width: bring `optional:tests/table-cut-cell.test.tsx` over from the release as it has it, expect `min-w-40` alone where a test of your own expected the wider floor, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`, since a cell takes one track; checked by the gate |
193
193
  | `four-layers` (0.28.0) | a file of the team's importing a module that has left `optional:src/views/` or `optional:src/data/` for `src/logic/` — the migration names the file and, in its instruction, every path the module left — or importing `afterStart` from `src/components/patterns/incident-card` or `summarise` from `src/components/patterns/status-list`, which no longer export them | thirty-eight modules are in `src/logic/` since 0.28.0, thirty-six of them from `optional:src/views/`, and each import is rewritten to the path the module has: `afterStart` is `src/logic/after-start.ts` and `summarise` is `src/logic/summarise.ts`, and those two must move because the cards are Meridian's and the update replaces them, while every path under `optional:src/views/` and `optional:src/data/` is the team's and stays as it is; a created project brings the release's `src/logic/` over with the pages that import it; the standard the layers come from is `data-layers.md`; checked by the gate and a build |
194
+ | `live-stream-shutdown` (0.31.1) | `optional:src/data/live-stream.ts` that never ends its streams when the server is told to stop | a stopped `next start` closes its port and waits for its open connections, so the old process kept every tab's stream while the new one served the port and no tab heard a hint again until its next poll: bring the block that begins `Every stream still open` over from `live.md`, delete the stream from the open set first in its `close`, and register it after it listens for its abort; checked by the gate |
194
195
 
195
196
  A created project that never changed the template's pages brings the release's version of each affected file over, with
196
197
  the files that arrived with them: the view contexts `optional:src/logic/home-context.ts` and its siblings, the view
@@ -5,6 +5,7 @@ import { cn } from '@/lib/cn';
5
5
  import { useMeridian } from '@/components/charts/meridian';
6
6
  import { Readout } from '@/components/charts/readout';
7
7
  import { niceTicks, SERIES_VAR, stackPart, ticksBetween } from '@/components/charts/scale';
8
+ import { useSettled } from '@/components/charts/use-settled';
8
9
  import { useSize } from '@/components/charts/use-size';
9
10
  import { columnFold } from '@/logic/derivations';
10
11
 
@@ -58,6 +59,12 @@ export type ColumnChartProps = Shared & (
58
59
  * narrowest column that keeps its value at rest.
59
60
  */
60
61
  const GAP = 2, THIN = 1, REST = 36;
62
+ /**
63
+ * Past this many columns a plain chart draws each finished column as one element, its bar the element's own `::before`,
64
+ * and grows them as one row: a year of 52 windows at 7D, three such charts on Home, committed about a thousand
65
+ * elements in the frame of the period tap and dropped it on a mid-range phone.
66
+ */
67
+ const DENSE = 30;
61
68
  /** About the width of one character of a value at rest (`text-2xs`), in px: enough to tell whether a label fits its column. */
62
69
  const CH = 6.5;
63
70
  /**
@@ -153,6 +160,8 @@ export function ColumnChart({
153
160
  const set = (i: number | null) => { (timed ? meridian.setIndex : setOwn)(i); setLocal(i !== null); };
154
161
  const fill = height === 'fill';
155
162
  const soFar = running ? n - 1 : -1;
163
+ // The axis labels and the table follow the columns a frame later when the set of them changes (`useSettled`).
164
+ const settled = useSettled(bars.map((b) => b.key).join('\n'));
156
165
 
157
166
  // A period with no data has no height, so the scale is the periods that have one. Plain, a value under zero is a
158
167
  // fall: the scale goes under zero for it and the column hangs from the zero line (#78). Stacked, parts make a whole.
@@ -175,6 +184,7 @@ export function ColumnChart({
175
184
  // A column wide enough for a figure keeps its value above it at rest; a narrower one shows it when pointed at.
176
185
  const column = n ? (width - left - gap * (n - 1)) / n : 0;
177
186
  const rest = column >= REST;
187
+ const dense = n > DENSE && !signed;
178
188
  // Whatever is centred on a column and may be wider than it starts with the first column and ends with the last, so
179
189
  // nothing runs past the plot; inside those, a narrower one is still centred.
180
190
  /** A value as it reads; a period with no data reads the product's word, and "so far" with it while it is running. */
@@ -231,7 +241,7 @@ export function ColumnChart({
231
241
  <span className={cn('h-px flex-1', t === 0 ? 'bg-chart-axis' : 'bg-chart-grid')} />
232
242
  </div>
233
243
  ))}
234
- <div className="absolute inset-y-0 right-0 flex" style={{ left, columnGap: gapOf(n) }}>
244
+ <div className={cn('absolute inset-y-0 right-0 flex', dense && 'grow-y')} style={{ left, columnGap: gapOf(n) }}>
235
245
  {bars.map((b, i) => {
236
246
  const pointed = active === i;
237
247
  const hot = pointed || (active === null && b.key === highlight);
@@ -245,6 +255,49 @@ export function ColumnChart({
245
255
  // never "No data…" cut mid-word; the tooltip, the readout, the announcement and the table say it whole (#71).
246
256
  const full = totalOf(b, i);
247
257
  const text = !tip && b.none && unfinished && full.length * CH > column ? 'So far' : full;
258
+ const plainFill = !b.none && !drawn && !unfinished;
259
+ const valueLabel = rest || pointed ? (
260
+ // One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
261
+ // column; the tooltip, and a total shown only on pointing, may be wider than theirs.
262
+ <span aria-hidden className={cn('pointer-events-none absolute', fall ? 'top-full mt-1' : 'bottom-full mb-1', rest && 'fade-in', tip || !rest ? cn('z-10', placed(i)) : 'inset-x-0')}>
263
+ <span
264
+ className={cn(
265
+ 't-num block text-2xs leading-4',
266
+ tip
267
+ ? 'rounded-sm bg-surface-inverse px-1.5 py-1 font-medium whitespace-nowrap text-ink-inverse shadow-overlay'
268
+ : rest
269
+ ? cn('truncate text-center', pointed ? 'font-medium text-ink' : 'text-ink-3')
270
+ : 'font-medium whitespace-nowrap text-ink',
271
+ )}
272
+ >
273
+ <span data-value>{text}</span>
274
+ {tip && unfinished && !b.none ? ' · so far' : null}
275
+ </span>
276
+ </span>
277
+ ) : null;
278
+ const share = `${(Math.abs(b.total) / (top - low)) * 100}%`;
279
+ if (dense && plainFill) {
280
+ // One element: the bar is its ::before, its share of the scale and never under 2px; the value, when it shows,
281
+ // stands on a box of the bar's height.
282
+ return (
283
+ <div
284
+ key={b.key}
285
+ data-column={b.key}
286
+ data-active={pointed ? '' : undefined}
287
+ title={[rest && text.length * 7 > column ? text : null, b.key === highlight ? highlightLabel : null].filter(Boolean).join(' · ') || undefined}
288
+ className={cn(
289
+ 'group/col relative h-full min-w-0 flex-1 before:absolute before:inset-x-0 before:bottom-(--bar-at) before:h-(--bar-h) before:rounded-t-[4px] before:transition-colors before:duration-(--dur-hover)',
290
+ b.total !== 0 && 'before:min-h-[2px]',
291
+ hot ? 'before:bg-accent' : 'before:bg-chart-neutral hover:before:bg-chart-neutral-strong',
292
+ )}
293
+ style={{ ['--bar-h' as string]: share, ['--bar-at' as string]: `${at100(0)}%` }}
294
+ onPointerEnter={() => set(i)}
295
+ onPointerDown={() => set(i)}
296
+ >
297
+ {valueLabel ? <div className={cn('absolute inset-x-0', b.total !== 0 && 'min-h-[2px]')} style={{ bottom: `${at100(0)}%`, height: share }}>{valueLabel}</div> : null}
298
+ </div>
299
+ );
300
+ }
248
301
  return (
249
302
  <div
250
303
  key={b.key}
@@ -269,14 +322,17 @@ export function ColumnChart({
269
322
  className="absolute inset-x-0"
270
323
  style={{
271
324
  ...(fall ? { top: `${100 - at100(0)}%` } : { bottom: `${at100(0)}%` }),
272
- height: b.none ? '0%' : `${(Math.abs(b.total) / (top - low)) * 100}%`,
325
+ height: b.none ? '0%' : share,
273
326
  minHeight: (drawn ? Math.max(0, shown.length * 4 - 1) : b.total !== 0 ? 2 : 0) + (unfinished ? 2 : 0),
274
327
  }}
275
328
  >
276
329
  <div
277
330
  className={cn(
278
- 'grow-y relative h-full',
331
+ // A dense chart grows its row as one; a column of its own grows only in a chart that is not.
332
+ !dense && 'grow-y', 'relative h-full',
279
333
  fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]',
334
+ // A finished plain column is its own fill: one element fewer a column, in a chart of 52 of them.
335
+ plainFill && cn('w-full transition-colors duration-(--dur-hover)', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong'),
280
336
  drawn && 'transition-[box-shadow] duration-(--dur-hover)',
281
337
  drawn && hot && 'z-1 ring-2 ring-ink',
282
338
  // A period still running: a dashed outline, open at the baseline, around a fill at half strength. The
@@ -304,29 +360,11 @@ export function ColumnChart({
304
360
  />
305
361
  ))}
306
362
  </div>
307
- ) : (
308
- <div className={cn('h-full w-full transition-colors duration-(--dur-hover)', fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong', unfinished && 'opacity-50')} />
363
+ ) : plainFill ? null : (
364
+ <div className={cn('h-full w-full transition-colors duration-(--dur-hover)', fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong', 'opacity-50')} />
309
365
  )}
310
366
  </div>
311
- {rest || pointed ? (
312
- // One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
313
- // column; the tooltip, and a total shown only on pointing, may be wider than theirs.
314
- <span aria-hidden className={cn('pointer-events-none absolute', fall ? 'top-full mt-1' : 'bottom-full mb-1', rest && 'fade-in', tip || !rest ? cn('z-10', placed(i)) : 'inset-x-0')}>
315
- <span
316
- className={cn(
317
- 't-num block text-2xs leading-4',
318
- tip
319
- ? 'rounded-sm bg-surface-inverse px-1.5 py-1 font-medium whitespace-nowrap text-ink-inverse shadow-overlay'
320
- : rest
321
- ? cn('truncate text-center', pointed ? 'font-medium text-ink' : 'text-ink-3')
322
- : 'font-medium whitespace-nowrap text-ink',
323
- )}
324
- >
325
- <span data-value>{text}</span>
326
- {tip && unfinished && !b.none ? ' · so far' : null}
327
- </span>
328
- </span>
329
- ) : null}
367
+ {valueLabel}
330
368
  </div>
331
369
  </div>
332
370
  );
@@ -351,8 +389,9 @@ export function ColumnChart({
351
389
  </Readout>
352
390
  ) : null}
353
391
  </div>
354
- <div aria-hidden className="mt-2 flex text-2xs text-ink-3" style={{ paddingLeft: left, columnGap: gapOf(n) }}>
355
- {bars.map((b, i) => (
392
+ {/* The row keeps its height for the frame its labels wait, so nothing under it moves. */}
393
+ <div aria-hidden className="mt-2 flex min-h-4 text-2xs text-ink-3" style={{ paddingLeft: left, columnGap: gapOf(n) }}>
394
+ {settled && bars.map((b, i) => (
356
395
  <span key={b.key} className="relative h-4 min-w-0 flex-1">
357
396
  {active === i ? (
358
397
  <span className={cn('absolute z-10', placed(i))}><span className="rounded-xs bg-surface px-1 whitespace-nowrap text-ink">{b.label}</span></span>
@@ -365,7 +404,7 @@ export function ColumnChart({
365
404
  ))}
366
405
  </div>
367
406
  <p className="sr-only" aria-live="polite">{reading}</p>
368
- <table className="sr-only">
407
+ {settled ? <table className="sr-only">
369
408
  <caption>{label}{highlight && highlightLabel ? `. Accented: ${highlightLabel}` : ''}</caption>
370
409
  {drawn ? <thead><tr><th scope="col">Period</th>{drawn.map((p) => <th key={p.key} scope="col">{p.label}</th>)}<th scope="col">Total</th></tr></thead> : null}
371
410
  <tbody>
@@ -377,7 +416,7 @@ export function ColumnChart({
377
416
  </tr>
378
417
  ))}
379
418
  </tbody>
380
- </table>
419
+ </table> : null}
381
420
  </figure>
382
421
  );
383
422
  }
@@ -7,6 +7,7 @@ import { formatDate } from '@/lib/format-date';
7
7
  import { useMeridian } from '@/components/charts/meridian';
8
8
  import { Readout } from '@/components/charts/readout';
9
9
  import { monotonePath, ticksBetween, SERIES_VAR, stackPart } from '@/components/charts/scale';
10
+ import { useSettled } from '@/components/charts/use-settled';
10
11
  import { useSize } from '@/components/charts/use-size';
11
12
  import { trendTotals } from '@/logic/derivations';
12
13
 
@@ -205,6 +206,8 @@ export function TrendChart({
205
206
  const n = dates.length;
206
207
  // The point still running, if there is one: everything solid stops at the point before it.
207
208
  const soFar = running ? n - 1 : -1;
209
+ // The table follows the line a frame later when the dates change (`useSettled`).
210
+ const settled = useSettled(dates.join('\n'));
208
211
  const x = (i: number) => pad.l + (n <= 1 ? W / 2 : (i / (n - 1)) * W);
209
212
  const y = (v: number) => pad.t + H - ((v - low) / (top - low)) * H;
210
213
 
@@ -474,11 +477,11 @@ export function TrendChart({
474
477
  ) : null}
475
478
  </Readout>
476
479
  ) : null}
477
- <table className="sr-only">
480
+ {settled ? <table className="sr-only">
478
481
  <caption>{label}{band ? `. ${band.label}` : ''}{marks ? `. Marked: ${marks.label}` : ''}{baseline ? `. ${baseline.label}` : ''}{running ? `. ${SO_FAR}` : ''}</caption>
479
482
  <thead><tr><th>Date</th>{series.map((s) => <th key={s.key}>{s.label}</th>)}{totals ? <th>Total</th> : null}</tr></thead>
480
483
  <tbody>{dates.map((d, i) => <tr key={d}><td>{d}{i === soFar ? ', so far' : null}</td>{series.map((s) => <td key={s.key}>{fmt(s.values[i])}</td>)}{totals ? <td>{fmt(totals[i])}</td> : null}</tr>)}</tbody>
481
- </table>
484
+ </table> : null}
482
485
  </div>
483
486
  );
484
487
  }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ /**
6
+ * Whether `key` was already the chart's at the last frame: false for the one frame after it changes, true on the first
7
+ * render and ever after. A chart draws its marks at once and its data table, which nobody sees, a frame later, so a
8
+ * change of period commits the marks without the table's rows: Home's year at 7D, three charts of 52 windows, dropped a
9
+ * frame on a mid-range phone when every row came in the same frame as the columns.
10
+ */
11
+ export function useSettled(key: string): boolean {
12
+ const [settled, setSettled] = useState(key);
13
+ useEffect(() => {
14
+ if (settled === key) return;
15
+ const id = requestAnimationFrame(() => setSettled(key));
16
+ return () => cancelAnimationFrame(id);
17
+ }, [key, settled]);
18
+ return settled === key;
19
+ }
@@ -10,6 +10,33 @@ const MAX_QUEUED = 100;
10
10
 
11
11
  let noted = false;
12
12
 
13
+ /**
14
+ * Every stream still open. A server told to stop (`next start` on SIGTERM or SIGINT) closes its port and then waits for
15
+ * its open connections, and a stream never ends of itself: the old process kept every tab's stream while the new one
16
+ * served the port, so no tab heard a hint again until its next poll. Ending them lets the old process go and each tab
17
+ * connect to the new one.
18
+ */
19
+ const open = new Set<() => void>();
20
+ let watching = false;
21
+
22
+ /** Ends every open stream; each tab reconnects (the stream's `retry`), to whichever server then holds the port. */
23
+ function endLiveStreams() {
24
+ for (const close of [...open]) close();
25
+ }
26
+
27
+ function endOnShutdown(close: () => void) {
28
+ open.add(close);
29
+ if (watching || typeof process === 'undefined' || typeof process.once !== 'function') return;
30
+ watching = true;
31
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
32
+ process.once(sig, function endLiveStreamsOnSignal() {
33
+ endLiveStreams();
34
+ // A listener takes the place of the signal's default: where nothing else listens, the process stops as it would have.
35
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
36
+ });
37
+ }
38
+ }
39
+
13
40
  type LiveStreamOptions = {
14
41
  /** The requested names, already authorized once; the first frame greets with what moved. */
15
42
  names: string[];
@@ -36,6 +63,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
36
63
  function close() {
37
64
  if (closed) return;
38
65
  closed = true;
66
+ open.delete(close);
39
67
  clearInterval(timer);
40
68
  for (const off of unsubscribe) off();
41
69
  unsubscribe.length = 0;
@@ -90,6 +118,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
90
118
  controller = c;
91
119
  if (signal.aborted) return close();
92
120
  signal.addEventListener('abort', close);
121
+ endOnShutdown(close);
93
122
  // Subscribe first, then compare: a write after the page's read either moves a revision the greeting names or
94
123
  // arrives as a hint.
95
124
  collections.forEach((collection, i) => {
@@ -56,6 +56,8 @@ export function createLiveClient({ refresh, endpoint = '/api/live', pollMs = 300
56
56
  let disposed = false;
57
57
  let paused = false;
58
58
  let polling = false;
59
+ /** The last refresh failed for a reason other than access: the server did not answer. */
60
+ let unreachable = false;
59
61
  let state: LiveState | null = null;
60
62
  /** Bumped whenever results in flight must stop counting: the page hid, or the client ended. */
61
63
  let epoch = 0;
@@ -122,9 +124,18 @@ export function createLiveClient({ refresh, endpoint = '/api/live', pollMs = 300
122
124
  if (full) synced = true;
123
125
  if (synced && opened) emit('live');
124
126
  else if (polling) emit('polling');
127
+ // A server that answers again after it did not (a restart, a deploy) is tried for its stream at once, not on the
128
+ // fifth poll: until then its hints are lost and every change waits for a poll. A refresh that never failed does
129
+ // not count, so a stream a proxy blocks is still tried only on every fifth poll.
130
+ if (unreachable && polling) {
131
+ attempts = MAX_ATTEMPTS - 1;
132
+ connect();
133
+ }
134
+ unreachable = false;
125
135
  }
126
136
  } catch (e) {
127
137
  if (mine === epoch && !disposed) {
138
+ if (!isAuthError(e)) unreachable = true;
128
139
  if (isAuthError(e)) {
129
140
  paused = true;
130
141
  closeSource();
@@ -158,6 +158,25 @@ describe('the live client', () => {
158
158
  expect(FakeSource.all.length).toBe(opened + 1);
159
159
  c.dispose();
160
160
  });
161
+ it('tries the stream at once when a poll answers after one that did not, as a restarted server does', async () => {
162
+ let down = false;
163
+ const refresh = vi.fn(async () => { if (down) throw new Error('network'); });
164
+ const { c, states } = client(refresh, 1000);
165
+ c.setNames(['members']);
166
+ for (let i = 0; i < 3; i++) { live().at(-1)!.fail(); await flush(1100); }
167
+ expect(states.at(-1)).toBe('polling');
168
+ down = true;
169
+ await flush(1000);
170
+ const opened = FakeSource.all.length;
171
+ down = false;
172
+ await flush(1000);
173
+ // The poll that answered opened a stream; without the rule the next try would wait for the fifth poll.
174
+ expect(FakeSource.all.length).toBe(opened + 1);
175
+ live().at(-1)!.open();
176
+ await flush(600);
177
+ expect(states.at(-1)).toBe('live');
178
+ c.dispose();
179
+ });
161
180
  it('pauses on an authorization failure and goes stale on any other', async () => {
162
181
  const denied = client(async () => { throw Object.assign(new Error('no'), { status: 403 }); });
163
182
  denied.c.setNames(['members']);