@adrrr/tarmac 0.9.0 → 0.10.0

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.
package/dist/render.js CHANGED
@@ -8,9 +8,10 @@
8
8
  // they are written side by side, and the suite can reach both.
9
9
  import { formatDuration } from './config.js';
10
10
  import { HISTORY_MAX_BYTES } from './history-store.js';
11
- import { buildMap, INTERACTIVE, stateOf } from './map.js';
11
+ import { buildMap, stateOf } from './map.js';
12
+ import { INTERACTIVE } from './sessions.js';
12
13
  import { HISTORY_CSS, HISTORY_PHONE_CSS, HISTORY_PALETTE, HISTORY_TOUCH_CSS, historyScript, renderHistoryView, } from './history-view.js';
13
- import { schemaNotice } from './schema.js';
14
+ import { schemaNoteParts, schemaNotice } from './schema.js';
14
15
  import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
15
16
  import { accountLimits, busyOnStaleFleet } from './fleet.js';
16
17
  /**
@@ -202,14 +203,18 @@ export function renderTable({ rows, health }) {
202
203
  warns.push(`! snapshots unavailable — ${health.snapshotsError}`);
203
204
  else if (health.schemaBroken)
204
205
  warns.push('! every snapshot drifted — the statusline payload schema moved');
205
- else if (health.covered < health.sessions)
206
- warns.push(
206
+ else if (health.covered < health.chainable) {
207
207
  // The count travels, for the same reason `unreadable` does one line up: without it
208
208
  // this line reads as "run install", and for a session id the wrapper declines to file
209
- // that is advice already taken which can never work.
210
- health.unfilable > 0
211
- ? `! statusline chained on ${health.covered}/${health.sessions} sessions ${health.unfilable} session(s) with an id tarmac never files`
212
- : `! statusline chained on ${health.covered}/${health.sessions} sessions`);
209
+ // that is advice already taken which can never work. And when the denominator is
210
+ // smaller than the fleet, the line says who it left out: "1/2 sessions" over a
211
+ // four-row table is a comparison the reader should not have to resolve alone.
212
+ const excluded = health.sessions - health.chainable;
213
+ const counted = `${health.covered}/${health.chainable} sessions${excluded > 0 ? ` (${excluded} agent(s) draw no frame)` : ''}`;
214
+ warns.push(health.unfilable > 0
215
+ ? `! statusline chained on ${counted} — ${health.unfilable} session(s) with an id tarmac never files`
216
+ : `! statusline chained on ${counted}`);
217
+ }
213
218
  if (health.stale > 0)
214
219
  warns.push(`! ${health.stale} reading(s) marked "!" are older than ${formatDuration(health.staleAfterMs)} (--stale-after)`);
215
220
  const skewed = rows.filter(ahead).length;
@@ -379,13 +384,18 @@ export function renderLive(fleet) {
379
384
  else if (health.schemaBroken) {
380
385
  warnings.push(`Every snapshot drifted — Claude Code's statusline schema has probably moved. Context readings are dead until the payload shape is re-checked.`);
381
386
  }
382
- else if (health.covered < health.sessions) {
383
- const blind = health.sessions - health.covered;
387
+ else if (health.covered < health.chainable) {
388
+ const blind = health.chainable - health.covered;
389
+ // Same naming as the table line: the denominator is the chainable population, and the
390
+ // rows below list agents as "not chained" too — without the parenthesis the banner and
391
+ // the rows disagree on how many are missing.
392
+ const excluded = health.sessions - health.chainable;
393
+ const counted = `${health.covered}/${health.chainable} sessions${excluded > 0 ? ` (${excluded} agent(s) draw no frame)` : ''}`;
384
394
  warnings.push(health.unfilable === 0
385
- ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`
395
+ ? `Statusline chained on ${counted} — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`
386
396
  : health.unfilable >= blind
387
- ? `Statusline chained on ${health.covered}/${health.sessions} sessions — the rest carry a session id that is not the UUID tarmac files snapshots under, so no frame will ever produce one. Installing again will not change that.`
388
- : `Statusline chained on ${health.covered}/${health.sessions} sessions — ${blind} report no context, and ${health.unfilable} of them will never be filed: the session id is not the UUID tarmac files snapshots under. For the others, run \`tarmac install\` and give them one TUI frame.`);
397
+ ? `Statusline chained on ${counted} — the rest carry a session id that is not the UUID tarmac files snapshots under, so no frame will ever produce one. Installing again will not change that.`
398
+ : `Statusline chained on ${counted} — ${blind} report no context, and ${health.unfilable} of them will never be filed: the session id is not the UUID tarmac files snapshots under. For the others, run \`tarmac install\` and give them one TUI frame.`);
389
399
  }
390
400
  if (health.unknownStatus > 0) {
391
401
  warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
@@ -410,14 +420,25 @@ export function renderLive(fleet) {
410
420
  // banner above could not take the number with it. The second is a maintainer's line: it
411
421
  // stands for every user of a released tarmac until the next release ships the fixture, so
412
422
  // amber would mean amber forever. Both keep every word they had.
423
+ // Each one in two halves: the fact, printed, and what follows from it behind a disclosure.
424
+ // Between them they keep every word these notes have ever had — what changed is that the
425
+ // page no longer ends in four to nine lines of prose a reader did not scroll there for.
413
426
  const notes = [];
414
427
  // Not under the stall banner, which names the same threshold two lines up: the pair reads as
415
428
  // the alarm followed by its own excuse, and the excuse is the reading the alarm exists to
416
429
  // tell you not to accept.
417
430
  if (health.stale > 0 && stalled === 0) {
418
- notes.push(`Readings past the ${formatDuration(health.staleAfterMs)} freshness threshold are dated where they sit — a statusline is only written when its terminal draws a frame, so an idle session's number is "as of" its last one. Set another with --stale-after.`);
431
+ notes.push({
432
+ key: 'stale',
433
+ // The legend for the mark, and the only half of this a reader ever has to be handed: it
434
+ // is what makes `! 3h ago` on a row something they can argue with.
435
+ lead: `Readings past the ${formatDuration(health.staleAfterMs)} freshness threshold are dated where they sit.`,
436
+ // Why that is so, and the flag that moves it. Every word it had; one join that was an
437
+ // em dash is a full stop.
438
+ rest: `A statusline is only written when its terminal draws a frame, so an idle session's number is "as of" its last one. Set another with --stale-after.`,
439
+ });
419
440
  }
420
- const schema = schemaNotice(health.schemaGuard);
441
+ const schema = schemaNoteParts(health.schemaGuard);
421
442
  if (schema)
422
443
  notes.push(schema);
423
444
  // Both views, every time, out of the one reading the page just asked for. The tabs are
@@ -445,7 +466,24 @@ export function renderLive(fleet) {
445
466
  <div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)}<span class="stamp"> · ${esc(new Date(health.generatedAt).toISOString())}</span></div>
446
467
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
447
468
  ${body}
448
- <div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
469
+ <div id="fleet-notes">${notes.map(renderNote).join('')}</div>`;
470
+ }
471
+ /**
472
+ * A footnote, folded.
473
+ *
474
+ * `<details>` and not a script: the page's one honest claim is that it is readable without
475
+ * JavaScript, and a disclosure the browser owns is open to a keyboard, to a screen reader and
476
+ * to a find-in-page that reaches inside a closed one. Shut, because open it is the wall of
477
+ * prose this was written to fold — and the LEAD is outside the fold, so what a reader is
478
+ * handed by `aria-describedby` is the fact, never a promise that there is one somewhere.
479
+ *
480
+ * A note with nothing behind its lead is not a disclosure at all: an empty fold is a control
481
+ * that answers a press with nothing.
482
+ */
483
+ function renderNote(n) {
484
+ return n.rest === ''
485
+ ? `<div class="note">${esc(n.lead)}</div>`
486
+ : `<details class="note" id="note-${esc(n.key)}"><summary>${esc(n.lead)}</summary>${esc(n.rest)}</details>`;
449
487
  }
450
488
  /**
451
489
  * The account's two windows, for the page's header.
@@ -596,44 +634,169 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false, demo
596
634
  <style>
597
635
  /* --wait is a fourth hue rather than the warning one: a session blocked on a human is not
598
636
  a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
599
- :root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
600
- @media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
637
+ /* Two planes, not one. Everything on this page used to be a hairline on the same white:
638
+ a card inside a berth was two identical rules nested, and nothing was ever ON anything.
639
+ --bg is now the FLOOR and --surface the panels standing on it, which is the whole of what
640
+ makes a table read as a panel rather than as a document. The old names all survive, so
641
+ every rule written against them still resolves. */
642
+ :root { color-scheme: light dark;
643
+ --fg:#0f1218; --dim:#5f6875;
644
+ --bg:#f3f4f6; --surface:#fff; --surface-2:#f7f8fa;
645
+ /* A hairline is decoration; a control's edge is not. The second is darker and is
646
+ still under the 3:1 a non-text control would need to be identified by — so no
647
+ control on this page is identified by its border alone: the active tab keeps its
648
+ ink and its weight, the scrubber's thumb is ringed in --fg, states keep their hue. */
649
+ --line:#e2e5ea; --line-strong:#b9c1cb;
650
+ --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8;
651
+ --shadow-1:0 1px 2px rgba(16,24,40,.06);
652
+ --shadow-2:0 1px 2px rgba(16,24,40,.06), 0 12px 28px -14px rgba(16,24,40,.22);
653
+ /* The lit top edge of a raised surface. It is a dark-mode trick and reads as dirt in
654
+ light, so in light it is nothing — declared all the same, because every rule that
655
+ wants it lists it unconditionally. */
656
+ --edge:0 0 0 0 transparent;
657
+ --r-sm:8px; --r-md:12px; --r-lg:14px; --r-pill:99px;
658
+ --gutter:1.5rem; --maxw:80rem;
659
+ /* The face the numbers wear. Values only — a table set in mono is a terminal dump,
660
+ not an instrument. */
661
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,"Cascadia Mono","Segoe UI Mono","Roboto Mono",monospace;
662
+ /* How faint a chart's line goes when another one is being read. Here rather than in
663
+ the script because it is the one drawing value that differs by theme, and the
664
+ script has no business asking a browser which theme it is in. */
665
+ --fade:.72; --fade-iso:.3; }
666
+ @media (prefers-color-scheme: dark) { :root {
667
+ --fg:#e6eaf0; --dim:#8b97a6;
668
+ --bg:#0a0d11; --surface:#12171e; --surface-2:#171d25;
669
+ --line:#232b35; --line-strong:#454f5e;
670
+ --warn:#fbbf24; --warnbg:color-mix(in srgb,#fbbf24 10%,#12171e); --busy:#34d399; --wait:#93c5fd;
671
+ --shadow-1:0 1px 2px rgba(0,0,0,.55);
672
+ --shadow-2:0 1px 2px rgba(0,0,0,.55), 0 12px 28px -14px rgba(0,0,0,.75);
673
+ --edge:inset 0 1px 0 rgba(255,255,255,.045);
674
+ --fade:.6; } }
601
675
  /* Eight categorical hues for the history view, kept apart from the four above: those four
602
676
  say what a session is DOING, and a chart that borrowed one would be colouring a project
603
677
  with the word for busy. */${HISTORY_PALETTE}
604
- body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
678
+ /* The floor is the html element's, not the body's: the body is now a column with a width, so
679
+ its own background would stop at the column's edge and leave white gutters. */
680
+ html { background:var(--bg); }
681
+ /* A page, not a document. Unbounded, the table spread eight columns over 1440px and the map
682
+ left 400px of dead white on the right — nothing framed the content, so nothing read as a
683
+ product. */
684
+ body { margin-inline:auto; max-width:var(--maxw); padding:1.5rem var(--gutter) 2rem;
685
+ background:var(--bg); color:var(--fg);
605
686
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
606
- header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
607
- h1 { font-size:1.1rem; margin:0; letter-spacing:.02em; }
687
+ /* The header is a panel like the rest, rather than six unrelated objects on a line. A card
688
+ and not a full-bleed band: a band stopped at the column's width leaves two cut corners at
689
+ the top of the page, and a real full-bleed one needs 100vw, which opens a horizontal
690
+ scrollbar on any OS that draws classic scroll bars. */
691
+ header { display:flex; align-items:center; gap:.85rem; flex-wrap:wrap; margin:0 0 1rem;
692
+ background:var(--surface); border:1px solid var(--line); border-radius:var(--r-lg);
693
+ box-shadow:var(--shadow-1), var(--edge); padding:.6rem .9rem; }
694
+ /* The wordmark in the face the numbers wear. It was the smallest thing on the page that
695
+ mattered — 15.4px beside a 20px dial and an amber badge — and mono separates the name of
696
+ the product from the UI around it for zero bytes and zero claim. */
697
+ h1 { font-family:var(--mono); font-size:1.05rem; font-weight:600; margin:0; letter-spacing:.02em; }
608
698
  .meta { color:var(--dim); font-size:.85rem; }${demo
609
699
  ? `
610
700
  /* The demo marker. It borrows the warning's hues rather than the dim chrome the tabs use:
611
701
  what it says is not chrome, and a badge a reader's eye files with the furniture is a badge
612
702
  that is not in the screenshot as far as anybody looking at the screenshot is concerned.
613
703
  Shipped only on a demo, so a plain serve is byte-for-byte the page it always was. */
614
- .demo-tag { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:99px;
704
+ /* Filled rather than outlined. It is the one element on this page whose job is to shout, and
705
+ the page around it got quieter: a hairline pill on the header's own white no longer reads
706
+ as a stamp. */
707
+ .demo-tag { background:color-mix(in srgb, var(--warn) 12%, var(--surface)); color:var(--warn); border:1px solid currentColor; border-radius:99px;
615
708
  padding:.05rem .55rem; font-size:.75rem; font-weight:600; letter-spacing:.02em; white-space:nowrap; }`
616
709
  : ''}
617
710
  /* Honest, and out of the way of the fleet: three of these stacked at full padding pushed
618
711
  the table below the fold on a laptop, which is its own kind of hidden. */
619
- .warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
620
- padding:.35rem .65rem; margin:.3rem 0; font-size:.8rem; line-height:1.45; }
712
+ /* One box, worn by whichever line is up. The replay banner and the line saying the page is
713
+ live take turns in the same place in the flow, so their box is declared once: two rules
714
+ would only ever have to disagree by a pixel of padding for entering a replay to move the
715
+ whole page down, which is the jump this pair exists to remove. The minimum is there
716
+ because only one of the two carries a button, and a button's own line box is taller than
717
+ a line of this text. */
718
+ .warn, .live-state { border:1px solid currentColor; border-radius:var(--r-sm);
719
+ padding:.4rem .7rem; margin:.3rem 0; font-size:.8rem; line-height:1.45; }
720
+ /* And the floor on the two that take turns, never on the box: put on the shared rule it grew
721
+ the offline banner and the noscript warning by 5.5px each — a page redrawn to settle an
722
+ argument between two other elements. It clears the taller of the pair's own contents, which
723
+ is the banner's button: 12px of text in a line box its padding and border take past 21px,
724
+ against 18.56px for a line of the live text. */
725
+ .replaying-note, .live-state { min-height:1.5rem; }
726
+ /* An accent down the left edge instead of a frame at full intensity. What these say is true
727
+ and worth keeping — "1 session reports a status tarmac does not know" — and it is not
728
+ urgent: a full-width amber box above the fleet made the first thing a reader saw a
729
+ warning about a footnote. The mark stays, the shout goes. */
730
+ .warn { background:var(--warnbg); color:var(--fg); border:0;
731
+ border-left:3px solid var(--warn); border-radius:0 var(--r-sm) var(--r-sm) 0; }
732
+ .warn strong { color:var(--warn); }
621
733
  .warn:last-of-type { margin-bottom:.9rem; }
734
+ /* The live half: the same box with none of the alarm. Grey on nothing, its border spent on
735
+ holding the size rather than on drawing one — what is normal reads as chrome, and the page
736
+ raises its voice only for the minute that is not now. */
737
+ .live-state:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap; }
738
+ .live-state { color:var(--dim); border-color:transparent; }
739
+ .live-state strong { font-weight:600; }
740
+ body.replaying .live-state { display:none; }
741
+ /* On the map and nowhere else, like the scrubber it belongs to: the table and the curves have
742
+ no replay, so a line telling them apart from one answers a question their reader cannot
743
+ ask. Written on the body rather than shipped conditionally, so one shell serves all three. */
744
+ body:not([data-view="map"]) #live-state { display:none; }
622
745
  /* The footnote: same words, none of the weight. Dim, small, below the fleet and with no box
623
746
  around it, because what it carries is true rather than urgent — the threshold that dated a
624
747
  reading, the payload shapes nobody has captured yet. It reads as chrome to someone
625
748
  scanning their sessions and as an answer to someone who came looking for it. */
626
- .note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
749
+ /* 72ch and a rule above it. At 95ch the paragraph ran the width of a 1440px screen, which is
750
+ past the measure anybody reads at, and the page ended on a grey slab. A hairline and a
751
+ column make it the footnote it always was. */
752
+ .note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:1.5rem 0 0; max-width:72ch;
753
+ border-top:1px solid var(--line); padding-top:.75rem; }
754
+ /* One rule for the block, not one per note: two paragraphs each with a line over them is a
755
+ table of contents. */
756
+ /* The margin between two of them is also the clearance between two tap targets: each summary
757
+ draws an overlay .45rem above and below itself, so anything under .9rem here has the first
758
+ note's own bottom edge opening the second. Pinned by test/phone-view. */
759
+ #fleet-notes .note + .note { border-top:0; padding-top:0; margin-top:.95rem; }
760
+ .note code { background:var(--surface-2); border:1px solid var(--line); border-radius:4px; padding:.05rem .3rem; }
761
+ /* The fold. The fact is the summary and stays on the page; what follows from it is one press
762
+ away. The marker is the browser's own — a disclosure a reader already knows how to work
763
+ beats a caret this page would have had to teach. */
764
+ /* The padding is not decoration: it is the half of the 44px thumb target that can be drawn.
765
+ Spent on the overlay instead, the overlay reaches so far past the summary that two stacked
766
+ notes trade taps — the tabs' 7px bug, on a control set in the smallest type here. */
767
+ .note summary { font-size:.75rem; padding:.4rem 0; cursor:pointer; }
768
+ .note summary:focus-visible { outline:2px solid var(--wait); outline-offset:2px; border-radius:4px; }
769
+ details.note[open] summary { margin-bottom:.3rem; }
627
770
  /* Two marks, one weight: a reading that has gone cold, and a reading picked out of several
628
771
  that were not about the same window. Both say the number beside them may not be what the
629
772
  reader takes it for, so neither may end up quieter than the other. */
630
773
  .stale, .mixed { color:var(--warn); font-weight:600; }
631
- .wrap { overflow-x:auto; }
774
+ /* The one place on the page that had no panel. overflow hidden is also what clips the 3px
775
+ state accent on the first cell to the panel's corners. */
776
+ .wrap { overflow-x:auto; background:var(--surface); border:1px solid var(--line);
777
+ border-radius:var(--r-lg); box-shadow:var(--shadow-1), var(--edge); }
632
778
  table { border-collapse:collapse; width:100%; min-width:44rem; }
633
- th { text-align:left; font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em;
634
- color:var(--dim); padding:.4rem .6rem; border-bottom:1px solid var(--line); white-space:nowrap; }
779
+ /* A shaded head and a row that answers the pointer: the difference between a <table> and a
780
+ view. The head is the nested surface, one step off the panel it sits in. */
781
+ th { text-align:left; font-weight:600; font-size:.7rem; text-transform:uppercase; letter-spacing:.08em;
782
+ color:var(--dim); padding:.45rem .6rem; background:var(--surface-2);
783
+ border-bottom:1px solid var(--line); white-space:nowrap; }
784
+ th:first-child, td:first-child { padding-left:.85rem; }
785
+ th:last-child, td:last-child { padding-right:.85rem; }
635
786
  td { padding:.5rem .6rem; border-bottom:1px solid var(--line); white-space:nowrap; }
787
+ /* The panel draws the bottom edge; a last row that draws its own puts two lines there. */
788
+ tbody tr:last-child td { border-bottom:0; }
789
+ /* Only where there is a pointer to hover with. On a touchscreen :hover sticks to whatever was
790
+ tapped last, which would leave one row shaded for as long as the reader looks at it. */
791
+ @media (hover:hover) and (pointer:fine) { tbody tr:hover td { background:var(--surface-2); } }
636
792
  td.num { font-variant-numeric:tabular-nums; }
793
+ /* ── the face of a number ────────────────────────────────────────────────────────────
794
+ The values, and only the values. Not a project, not a session name, not a model, not a
795
+ label: a dashboard set entirely in mono is a terminal dump, and the discipline of putting
796
+ the instrument face on the measurements alone is the whole of what separates the two.
797
+ .why is deliberately absent — it holds words ("no reading"), not a reading. */
798
+ .pct, td.num, .gauge .num, .chart-stat, #replay-at, .key .k-val, .asof {
799
+ font-family:var(--mono); font-variant-numeric:tabular-nums; letter-spacing:0; }
637
800
  .dim { color:var(--dim); }
638
801
  /* Shape + word + border, so the state survives a reader who cannot tell our two hues
639
802
  apart, and a print. */
@@ -657,6 +820,10 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false, demo
657
820
  .bar > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
658
821
  .empty { color:var(--dim); }
659
822
  .freshness { margin-left:auto; color:var(--dim); font-size:.8rem; font-variant-numeric:tabular-nums; }
823
+ /* A line break in the header, and nothing else: it exists only on a phone, where the six
824
+ things up there have to be told which two rows they belong to. Off at every other width,
825
+ where they fit on one. */
826
+ .hdr-break { display:none; }
660
827
  .pulse { display:inline-block; width:.4rem; height:.4rem; border-radius:99px; background:var(--busy); margin-right:.4rem; vertical-align:middle; }
661
828
  /* The failing state is carried by the banner's words; the dashed frame only repeats it. */
662
829
  body.failing .pulse { background:var(--warn); }
@@ -664,10 +831,15 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false, demo
664
831
  .offline strong { white-space:nowrap; }
665
832
  /* The tabs, and what they hide. The shell owns the choice — not the fragment — so a poll
666
833
  that swaps the fleet underneath cannot put the reader back on a view they left. */
667
- nav { display:flex; gap:.15rem; }
834
+ /* A segmented control: a sunk track with a raised chip on the tab you are reading, rather
835
+ than three words of which one has a ring round it. The chip is DECORATION — --line-strong
836
+ and the shadow are both under the 3:1 a border would need to identify a control on its
837
+ own, so the ink and the weight stay the signal, exactly as before. */
838
+ nav { display:flex; gap:2px; background:var(--surface-2); border:1px solid var(--line);
839
+ border-radius:var(--r-pill); padding:2px; }
668
840
  nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
669
- letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
670
- nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
841
+ letter-spacing:.06em; padding:.2rem .6rem; border-radius:var(--r-pill); border:0; }
842
+ nav a[aria-current="page"] { color:var(--fg); background:var(--surface); box-shadow:var(--shadow-1); }
671
843
  /* A finger is not a cursor. Every control on this page is a pill sized for a pointer that
672
844
  lands on a single pixel — about 26px of box against the 44 a thumb is asked to hit — and
673
845
  the fix cannot be more padding: that would redraw the page for everyone to solve a problem
@@ -685,9 +857,15 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false, demo
685
857
  Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
686
858
  the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
687
859
  Map because Map's pseudo paints later. */
688
- nav a, .replay button, .replaying-note button, .hist-range button, .to-now, .key { position:relative; }
860
+ nav a, .replay button, .replaying-note button, .hist-range button, .to-now, .key,
861
+ .note summary { position:relative; }
689
862
  @media (pointer: coarse) {
690
863
  nav a::after, .replay button::after { content:''; position:absolute; inset:-.7rem 0; }
864
+ /* The footnote's fold is a control like the rest of them. Its inset is the smallest here
865
+ because two of them can stand one above the other and the margin between them is the
866
+ whole of their clearance: what 44px needs beyond the summary's own box is bought in
867
+ padding above, not in overlay. */
868
+ .note summary::after { content:''; position:absolute; inset:-.45rem 0; }
691
869
  .replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }${HISTORY_TOUCH_CSS} }
692
870
  body[data-view="table"] .view-map { display:none; }
693
871
  body[data-view="map"] .view-table { display:none; }
@@ -698,12 +876,17 @@ ${HISTORY_CSS}
698
876
  the fleet is what the page is about, and these two numbers are the weather it flies in.
699
877
  Laid out with flex behind the same :not([hidden]) guard the replay containers carry —
700
878
  the replayed pair ships hidden, and a display in a stylesheet beats the attribute. */
879
+ /* Separated from the tabs by a rule rather than by a gap the eye reads as another gap: the
880
+ header holds two groups that have nothing to do with each other, and a line is the cheapest
881
+ way to say so. In the header only — the replayed pair below the banner has no neighbour to
882
+ be divided from. */
701
883
  .limits:not([hidden]) { display:flex; gap:1rem; flex-wrap:wrap; align-items:center; }
884
+ header .limits:not([hidden]) { padding-left:.9rem; border-left:1px solid var(--line); }
702
885
  .gauge { display:flex; align-items:baseline; gap:.35rem; font-size:.8rem; }
703
886
  /* Not upper-cased, alone among the small labels on this page: "5H" is not an hour, and a
704
887
  unit that has been shouted reads as a different unit. */
705
888
  .gauge .lbl { color:var(--dim); font-weight:600; letter-spacing:.04em; }
706
- .gauge .num { font-variant-numeric:tabular-nums; font-weight:650; }
889
+ .gauge .num { font-variant-numeric:tabular-nums; font-weight:600; }
707
890
  .gauge .reset { color:var(--dim); }
708
891
  /* Same bargain as the row bars: a glance at a magnitude, in the quiet ink of a secondary
709
892
  fact, beside the number that is the authority. Its own class rather than .bar — that one
@@ -746,21 +929,79 @@ ${HISTORY_CSS}
746
929
  label for the button rather than for the pair, and take width from the slider to do it. */
747
930
  .replay .replay-name { flex-basis:100%; font-size:.7rem; font-weight:700; letter-spacing:.07em;
748
931
  text-transform:uppercase; color:var(--dim); }
749
- .replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
750
- border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
751
- .replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
752
- .replay input[type="range"]:disabled { opacity:.4; }
932
+ /* The one filled button on the whole dashboard, and the only place the page spends maximum
933
+ contrast: everything else here is a reading, and this is the single thing a reader is
934
+ invited to press. */
935
+ .replay button { font:inherit; font-size:.8rem; font-weight:600; color:var(--bg); background:var(--fg);
936
+ border:1px solid var(--fg); border-radius:var(--r-pill); padding:.2rem .95rem; cursor:pointer; }
937
+ .replay button:hover:not(:disabled) { opacity:.88; }
938
+ /* Pressed, and it looks it. The WORD on this button says which of the two it does next —
939
+ "Pause" while the day walks — which is right and is also the one thing a glance cannot
940
+ catch: a screenshot of a stopped replay reads exactly like a screenshot of a running one.
941
+ The attribute carries the other fact, the one that can be painted. */
942
+ /* Inverted, now that the resting button is the filled one. The pair is what it always was —
943
+ the WORD says which of the two the next press does, the ATTRIBUTE says which of them is
944
+ happening, and only the second survives a screenshot — so the two paints simply swapped
945
+ ends. Stopped is the solid invitation; running is the same button hollowed out. */
946
+ #play[data-playing="true"] { background:var(--surface); color:var(--fg); border-color:var(--fg); }
947
+ .replay button:disabled { opacity:.4; cursor:default; }
948
+ /* The handle. A range input is drawn by the browser until appearance:none hands its two
949
+ shadow parts over — and what the browser drew was a fat grey groove belonging to no page,
950
+ on the one control a reader of this view spends the most time touching. Both engines are
951
+ spelled out because they name the same two parts differently and neither falls back to the
952
+ other; the values are one pair, so a thumb retuned in one is retuned in both.
953
+ The rail is the track's own hue, the thumb the page's ink, ringed in the background so it
954
+ reads as an object ON the rail rather than a lump of it. */
955
+ /* accent-color goes: it was the last thing on this page a browser drew for us, and on the one
956
+ screen anybody will screenshot. --p is the share of the range already walked, written by the
957
+ script on every input and every step of a play — it is what makes the bar FILL as the day
958
+ runs, which is the whole of the motion in a recording of this view. Firefox has a track
959
+ part for that and needs no variable; webkit has none, so the fill is a gradient stop. */
960
+ .replay input[type="range"] { flex:1; min-width:10rem; -webkit-appearance:none; appearance:none;
961
+ background:transparent; height:1.5rem; margin:0; cursor:pointer; accent-color:auto; }
962
+ .replay input[type="range"]::-webkit-slider-runnable-track { height:6px; border-radius:var(--r-pill);
963
+ background:linear-gradient(to right, var(--dim) 0 var(--p,0%), var(--line) var(--p,0%) 100%); }
964
+ .replay input[type="range"]::-moz-range-track { height:6px; border-radius:var(--r-pill); background:var(--line); }
965
+ .replay input[type="range"]::-moz-range-progress { height:6px; border-radius:var(--r-pill); background:var(--dim); }
966
+ /* The margin is what centres a webkit thumb on its track: that engine lays the thumb out from
967
+ the top of the track box, so half the difference of the two heights is what is owed back.
968
+ Ringed in --fg rather than filled with it: 18:1 against the page, which is what identifies
969
+ the handle — the track's own edge is nowhere near the 3:1 a control would need. */
970
+ .replay input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none;
971
+ width:16px; height:16px; margin-top:-5px; border-radius:var(--r-pill);
972
+ background:var(--surface); border:2px solid var(--fg); box-shadow:var(--shadow-1); cursor:grab; }
973
+ .replay input[type="range"]::-moz-range-thumb { width:16px; height:16px; border-radius:var(--r-pill);
974
+ background:var(--surface); border:2px solid var(--fg); box-shadow:var(--shadow-1); cursor:grab; }
975
+ .replay input[type="range"]:active::-webkit-slider-thumb { cursor:grabbing; }
976
+ .replay input[type="range"]:active::-moz-range-thumb { cursor:grabbing; }
977
+ .replay input[type="range"]:disabled { opacity:.45; cursor:default; }
978
+ /* What appearance:none took away and the page owes back: a control that can be reached by
979
+ tab and not seen once it is there is a control a keyboard reader loses. The hue is the one
980
+ this page already spends on "a human is being waited for", which is what a focus ring is. */
981
+ .replay input[type="range"]:focus-visible, .replay button:focus-visible,
982
+ .replaying-note button:focus-visible { outline:2px solid var(--wait); outline-offset:2px; }
753
983
  /* The two things the reader has to be able to read while dragging: the minute under the
754
984
  handle, and what the whole range covers. Tabular, so neither jitters as it counts. */
755
985
  #replay-at { font-variant-numeric:tabular-nums; font-weight:600; }
986
+ /* One line, and the pointer is told there is more behind it. What came off this line is a
987
+ paragraph of standing prose about the record, which is now the title and a span for a
988
+ screen reader — moved, never dropped: it is what keeps an ungrouped map from reading as a
989
+ rendering that broke. */
756
990
  .replay .covers { flex-basis:100%; color:var(--dim); font-size:.75rem; }
991
+ .replay .covers[title] { cursor:help; }
757
992
  /* The banner wears the warning style on purpose: a page showing a past minute as though it were the
758
993
  fleet is the worst thing this dashboard could do, so it wears the loudest thing it has. */
759
994
  /* Sticky, because the handle is at the bottom of a map that can be taller than the
760
995
  viewport: a reader dragging with the "this is the past" banner scrolled off the top is
761
996
  a reader the banner is not warning. */
997
+ /* The one box on this page still allowed to shout. The banners above it gave up their frame
998
+ for a left accent; this one keeps the whole frame, takes the deeper shadow and lifts off
999
+ the page — because a dashboard showing a past minute as though it were the fleet is the
1000
+ worst thing this product could do. */
762
1001
  .replaying-note:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap;
763
- position:sticky; top:0; z-index:1; }
1002
+ position:sticky; top:0; z-index:1;
1003
+ border:1px solid var(--warn); border-left-width:3px; border-radius:var(--r-sm);
1004
+ box-shadow:var(--shadow-2); }
764
1005
  .replaying-note button { font:inherit; font-size:.75rem; font-weight:600; color:inherit;
765
1006
  background:transparent; border:1px solid currentColor; border-radius:99px;
766
1007
  padding:.05rem .7rem; cursor:pointer; }
@@ -779,7 +1020,28 @@ ${HISTORY_CSS}
779
1020
  flat grid this view was before, which is the honest drawing of what it holds. */
780
1021
  .map { gap:.9rem; }
781
1022
  .map.berths { display:flex; flex-wrap:wrap; align-items:flex-start; }
782
- .map.flat { display:grid; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr)); }
1023
+ /* One cell per node, and every cell the same: the replay is the one surface where the fleet
1024
+ changes under a still hand — each position of the handle is another minute, and sessions
1025
+ come and go between two of them. A grid whose rows are the size of what is in them redraws
1026
+ the page at every step of a scrub, which is what made a drag look like a page breaking.
1027
+ The floor has to clear the TALLEST card the record can produce, or it is decoration: a row
1028
+ is minmax(floor,auto), so one node taller than the floor pushes its own row and every row
1029
+ under it — the jump, by the other road. The card that decides it is a waiting session:
1030
+ a dial, a name, a caption clamped to two lines and the two numbers under it, 214px in
1031
+ Chrome. One value at every width: the columns narrow on a phone and the cards do not, so a
1032
+ floor cut to match them would sit under the cards it is supposed to hold up. */
1033
+ /* 14rem and not 13.5: the floor is measured against the tallest card the record can draw,
1034
+ and C5's dial takes that card from 215.9px to 223.9. Left at 13.5 it would clear its own
1035
+ floor and push its row and every row under it at the minute it appears — the jump this
1036
+ grid exists to remove, re-entered by a stroke width. The two are one change. */
1037
+ .map.flat { display:grid; grid-template-columns:repeat(auto-fill,minmax(10.5rem,1fr));
1038
+ grid-auto-rows:minmax(14rem,auto); }
1039
+ /* And what is in a cell sits in the middle of it. A session card hung from the top left 67px
1040
+ of white under its last line — 31% of the cell — which inside a bordered box reads as a
1041
+ render that failed rather than as air. The agent already had this from #170; the cards
1042
+ were the half that never got it. Where a cell is the height of its own content, which is
1043
+ every card in a berth, the rule changes nothing. */
1044
+ .map.flat .node { justify-content:center; }
783
1045
  /* The berth: a frame around the nodes read in one directory, and the label is the whole of
784
1046
  what it claims. Quiet on purpose — a hairline and a caption in the grey the rest of the
785
1047
  page uses for a heading, because the loud thing on this view is a session's state, and a
@@ -796,7 +1058,13 @@ ${HISTORY_CSS}
796
1058
  letter-spacing:.06em; color:var(--dim); }
797
1059
  /* The cards side by side at their own width, wrapping inside the frame when the directory
798
1060
  holds more of them than the row can take. */
799
- .berth-cards { display:flex; flex-wrap:wrap; gap:.6rem; align-items:stretch; }
1061
+ /* flex-start, not stretch. Two cards with captions of different lengths were drawn to one
1062
+ height and the shorter one carried 18.2px of white under its last line — C11.1's problem
1063
+ at berth scale. Each card takes its own height instead. The DIALS stay level, which is the
1064
+ line a reader scans a row along, and that is why a berth is not centred the way a replay
1065
+ cell is: filled cards with a shadow read as two objects of two heights, not as one that
1066
+ failed to fill. */
1067
+ .berth-cards { display:flex; flex-wrap:wrap; gap:.6rem; align-items:flex-start; }
800
1068
  .berth-cards .node { width:10.5rem; }
801
1069
  /* And the strips docked underneath, full width of the frame, one under the other: a strip is
802
1070
  a line of text, and a line of text in a column half a card wide is an ellipsis where the
@@ -804,33 +1072,69 @@ ${HISTORY_CSS}
804
1072
  directory's background work, under the terminals someone is sitting at — and NOT because
805
1073
  one of those terminals dispatched it, which nothing here knows. */
806
1074
  .berth-strips { display:flex; flex-direction:column; gap:.4rem; margin-top:.6rem; }
807
- .node { border:1px solid var(--line); border-radius:10px; padding:.8rem .85rem .7rem;
1075
+ /* A card standing on the berth's floor rather than a second hairline drawn inside a first.
1076
+ Three planes now — floor, berth, card — where there used to be one white and two identical
1077
+ borders. The berth keeps no fill and no shadow of its own: a shadow inside a shadow reads
1078
+ as neither. */
1079
+ .node { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-md);
1080
+ box-shadow:var(--shadow-1), var(--edge); padding:.8rem .85rem .7rem;
808
1081
  display:flex; flex-direction:column; align-items:center; text-align:center; }
809
- .node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line)); }
810
- .node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line)); }
1082
+ /* A wash of the state's own hue at 4%, under the border that already carries it. It invents
1083
+ no information same hue, same node, quieter than the edge — and it is what makes "these
1084
+ four are working" readable across a berth without reading a single word. */
1085
+ .node[data-state="busy"] { border-color:color-mix(in srgb, var(--busy) 45%, var(--line));
1086
+ background:color-mix(in srgb, var(--busy) 4%, var(--surface)); }
1087
+ .node[data-state="waiting"] { border-color:color-mix(in srgb, var(--wait) 45%, var(--line));
1088
+ background:color-mix(in srgb, var(--wait) 4%, var(--surface)); }
811
1089
  /* An agent is not a smaller session — it is a strip. It was a card at three quarters scale,
812
1090
  which put a dial on a session that has no terminal to draw a statusline frame with: a ring
813
1091
  that can never fill, captioned with the words of a fault someone could go and repair. The
814
1092
  honest form is the one the table already speaks in — text on a line, left-aligned, its
815
1093
  state in the same glyph and in a three-pixel accent down the left edge. */
816
- /* align-self, never the grid's own align-items: a strip is half the height of the card
817
- beside it and must not be stretched to match, but the CARDS in a row still share one
818
- heighttelling the grid to stop stretching would have changed every session on the page
819
- to make room for this one. Scoped to the flat grid, which is the only place a strip has a
820
- card beside it: docked in a berth it is a full-width band, and "start" in that column
821
- would shrink it to the width of its own prompt. */
822
- .map.flat .node[data-role="agent"] { align-self:start; }
823
- .node[data-role="agent"] { align-items:stretch; text-align:left;
824
- padding:.5rem .7rem .55rem; border-radius:8px;
825
- background:color-mix(in srgb, var(--line) 18%, transparent);
1094
+ /* Behind the scrubber an agent fills its cell like every other node. It kept its own height
1095
+ for as long as the grid's rows did a strip at half a card, sitting at the top of its
1096
+ rowand that is exactly what made the map dance: an agent appearing between two minutes
1097
+ of a scrub moved every dial under it. The SHAPE stays different, which is the honest part
1098
+ (no dial, no arc that could never fill, its text left-aligned); the CELL is the same. */
1099
+ /* Its text sits in the middle of whatever box it is given, which in the flat grid is the
1100
+ height of a dial: hung from the top of one, two lines of text read as a cell that failed
1101
+ to draw. Docked in a berth the box is the text's own height and this does nothing. */
1102
+ .node[data-role="agent"] { align-items:stretch; justify-content:center; text-align:left;
1103
+ padding:.5rem .7rem .55rem; border-radius:var(--r-sm);
826
1104
  /* The box goes back to the neutral line the tinted rule above gave it: the accent is
827
1105
  the channel that carries state here, and a strip outlined in its hue as well was
828
1106
  the same fact said twice, in two weights, on a shape half the size of a card. */
829
1107
  border-color:var(--line); border-left-width:3px; border-left-color:var(--dim); }
1108
+ /* The recessed fill belongs to the DOCKED strip, not to the agent. In a berth it is an object
1109
+ nested under the cards and reads as one; behind the scrubber it is a cell beside other
1110
+ cells, and --surface-2 there sinks it in light and lifts it in dark — the same object read
1111
+ two opposite ways depending on the theme. In the grid it takes --surface, the shadow and
1112
+ the 4% state wash like its neighbours, and the SHAPE goes on being what tells it apart. */
1113
+ .berth-strips .node[data-role="agent"] { background:var(--surface-2); box-shadow:none; }
830
1114
  .node[data-role="agent"][data-state="busy"] { border-left-color:var(--busy); }
831
1115
  .node[data-role="agent"][data-state="waiting"] { border-left-color:var(--wait); }
832
1116
  .node[data-role="agent"][data-state="unknown"] { border-left-color:var(--warn); }
833
1117
  .node[data-role="agent"] .who { margin-top:0; width:100%; }
1118
+ /* A cell is 10.5rem wide, and every caption on it is one nowrap line: cut to that column, a
1119
+ replayed agent at 320px drew its project as "a…" while the word BACKGROUND beside it kept
1120
+ all of its own, and a waiting reason — the one caption this page calls why a node is not
1121
+ working — lost two thirds of itself. The cell has height to spare and no width to give, so
1122
+ they wrap DOWN it instead. Scoped to the flat grid: a strip docked in a berth is a band the
1123
+ width of its frame, and the prompt on it has no length limit — wrapping that one down its
1124
+ berth is the paragraph the ellipsis is there to prevent.
1125
+
1126
+ Two lines, then an ellipsis. Free to wrap as far as it likes, a caption is a card free to
1127
+ grow — and a card taller than the row floor takes its row and every row under it, which is
1128
+ the jump this grid exists to remove, re-entered through the fix for the clipping. A waiting
1129
+ reason has no length limit anywhere in this codebase: the source publishes what it likes
1130
+ and sessions.ts copies it through. */
1131
+ .map.flat .node .sub { white-space:normal; overflow-wrap:anywhere;
1132
+ display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-clamp:2; overflow:hidden; }
1133
+ .map.flat .node[data-role="agent"] .who { flex-wrap:wrap; }
1134
+ /* And the label that says an agent is not a terminal stays beside the name it qualifies. The
1135
+ margin that pushes it to the far end of a strip's one line drops it, alone and
1136
+ right-aligned, onto a second line the moment that line is allowed to wrap. */
1137
+ .map.flat .node[data-role="agent"] .kind { margin-left:0; }
834
1138
  /* A strip's project, which since the berths is the REPLAY's business alone: a live strip
835
1139
  prints none — the frame around it says the directory — and behind the scrubber there is no
836
1140
  frame, and the project is the only name the ring kept. The rule stayed when the markup
@@ -843,6 +1147,16 @@ ${HISTORY_CSS}
843
1147
  want the day it grows one, and unprefixed they would take it. */
844
1148
  .node .kind { margin-left:auto; font-size:.6rem; font-weight:700; text-transform:uppercase;
845
1149
  letter-spacing:.08em; color:var(--dim); }
1150
+ /* The reading on a strip, drawn. The bar is the table's own — same track, same fill, same
1151
+ refusal to be coloured by a state or a threshold — and the number beside it is what is
1152
+ authoritative, in the face every other number on this page wears. The word stays: a bar
1153
+ says a magnitude and never which one. */
1154
+ .node .ctx { display:inline-flex; align-items:center; gap:.3rem; white-space:nowrap; }
1155
+ .node .bar { display:inline-block; width:2.75rem; height:.3rem; border-radius:var(--r-pill);
1156
+ background:var(--line); vertical-align:middle; margin-right:0; }
1157
+ .node .bar > i { display:block; height:100%; border-radius:var(--r-pill); background:var(--dim); }
1158
+ .node .ctx-pct { font-family:var(--mono); font-variant-numeric:tabular-nums; font-weight:600;
1159
+ font-size:.8rem; color:var(--fg); }
846
1160
  /* The prompt a background session was named after — the strip's own line, now that the berth
847
1161
  around it carries the directory. One line, clipped: it is a sentence somebody typed, and
848
1162
  it is the only thing on the strip that has no length limit. */
@@ -851,24 +1165,29 @@ ${HISTORY_CSS}
851
1165
  /* Said, not shown: the four glyphs differ in silhouette, so a reader who cannot separate
852
1166
  two hues still has the state — but a screen reader is handed a bullet and nothing else. */
853
1167
  .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
854
- .dial { position:relative; width:5.5rem; height:5.5rem; }
1168
+ /* 6rem. On a post the ring IS the product, and at 5.5 with a 5px stroke it was a progress
1169
+ graphic. Ships WITH the 14rem row floor above — the taller card is what makes that floor
1170
+ necessary. */
1171
+ .dial { position:relative; width:6rem; height:6rem; }
855
1172
  .dial svg { width:100%; height:100%; display:block; overflow:visible; }
856
- .track { fill:none; stroke:var(--line); stroke-width:5; }
1173
+ /* Thicker, and the ring becomes a dial rather than a progress graphic. Purely CSS: the
1174
+ percentage is carried by stroke-dasharray off DIAL_R, which no stroke width touches. */
1175
+ .track { fill:none; stroke:var(--line); stroke-width:6.5; }
857
1176
  /* Butt caps, not round: a rounded cap adds half a stroke width at each end, which draws a
858
1177
  1% reading at three times its extent. The prettier cap overstates every small number. */
859
- .arc { fill:none; stroke:var(--dim); stroke-width:5; }
1178
+ .arc { fill:none; stroke:var(--dim); stroke-width:6.5; }
860
1179
  .node[data-state="busy"] .arc { stroke:var(--busy); }
861
1180
  /* A reading past the freshness threshold is drawn as what it is: thin, faded, and in the
862
1181
  warning hue — never the solid arc of a live one. Its EXTENT stays true, because the
863
1182
  number is still the truth of an earlier moment; and a dash pattern here would overwrite
864
1183
  stroke-dasharray, which is what carries the percentage. */
865
1184
  .node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
866
- stroke:var(--warn); stroke-width:2.5; opacity:.7; }
1185
+ stroke:var(--warn); stroke-width:3.5; opacity:.7; }
867
1186
  /* A replayed reading. Its EXTENT is what the record vouches for; its age is the one thing
868
1187
  the ring never kept, so it may not wear the solid arc that means "as current as a reading
869
1188
  gets" — nor the warning hue of a stale one, which would claim the opposite. Between the
870
1189
  two: full colour, a shade lighter, and no date underneath it. */
871
- .node[data-reading="undatable"] .arc { stroke-width:4; opacity:.85; }
1190
+ .node[data-reading="undatable"] .arc { stroke-width:5.5; opacity:.85; }
872
1191
  /* Nothing was measured. Keyed on the measurement and never on the age of the file: a
873
1192
  solid empty ring is what a session measured at 0% wears, and the two must not match. */
874
1193
  .track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
@@ -894,8 +1213,11 @@ ${HISTORY_CSS}
894
1213
  @media (prefers-reduced-motion: reduce) { .halo { animation:none; opacity:.35; transform:scale(1.18); } }
895
1214
  .val { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
896
1215
  font-variant-numeric:tabular-nums; }
897
- .pct { font-size:1.25rem; font-weight:650; letter-spacing:-.01em; }
898
- .pct i { font-style:normal; font-size:.7em; font-weight:500; color:var(--dim); }
1216
+ /* 650 was a weight the sans had and the system mono faces do not — asked for it, they round
1217
+ back to 400 and the number came out lighter than before. 600 is a weight they carry. The
1218
+ negative tracking goes with the sans it was measured on. */
1219
+ .pct { font-size:1.55rem; font-weight:600; }
1220
+ .pct i { font-style:normal; font-size:.62em; font-weight:500; color:var(--dim); }
899
1221
  .why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
900
1222
  .why b { display:block; font-size:1.25rem; font-weight:400; }
901
1223
  .who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
@@ -915,6 +1237,9 @@ ${HISTORY_CSS}
915
1237
  .sub.waiting-for { color:var(--wait); font-weight:600; }
916
1238
  .asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
917
1239
  .asof.stale { color:var(--warn); font-weight:600; }
1240
+ /* The columns narrow here; the row floor does not follow them. A card at 320px is taller than
1241
+ one at 1280, not shorter — its captions have less width and more lines — so a floor cut to
1242
+ the narrow column would sit under every card on the page and hold nothing up. */
918
1243
  @media (max-width: 30rem) { .map.flat { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); } .map { gap:.6rem; } }
919
1244
 
920
1245
  /* Below this the table stops being a table. What replaces it is the strip described down at
@@ -923,7 +1248,38 @@ ${HISTORY_CSS}
923
1248
  as nothing at all — and the labels that go are the ones whose value says what it is on its
924
1249
  own, with the exceptions named where they are given a word back. */
925
1250
  @media (max-width: 46rem) {
926
- body { padding:1.25rem .75rem; }
1251
+ body { padding:.75rem .75rem 1.25rem; }
1252
+ /* ── the header, in two lines ──────────────────────────────────────────────────────
1253
+ Six objects wrapping as they pleased put five rows of chrome — a name, a badge, tabs,
1254
+ two gauges and the age of the reading — above the first session on a 390px screen: the
1255
+ fleet started a third of the way down a page that is about the fleet.
1256
+
1257
+ Three now, and the first two are DECLARED rather than left to the wrap: the name and the
1258
+ tabs, then the account. The break is a real element in the markup between those two
1259
+ groups, zero-height and full-width — NOT "order" on the items around it, which would be
1260
+ this sheet moving two readings past each other to get a line break. Nothing here is
1261
+ reordered: what the page shows is the order the markup is in.
1262
+
1263
+ Three and not two, and the missing one is arithmetic rather than taste. The account's own
1264
+ row is 255px of "5h 62% resets in 59m 7d 43% resets in 4d 11h" and the age of the
1265
+ reading is 92 more, against 346 of usable width: the pair is 7px over, and the reset
1266
+ countdowns are the only words on this page that appear nowhere else — a phone that
1267
+ dropped them would be a phone that cannot tell you when your window opens. So the age
1268
+ takes the third row whole rather than a fact being spent on the second.
1269
+
1270
+ A demo badge takes the width it takes and pushes the tabs down, which is a fourth row on
1271
+ a demo and none on a serve: it is the one element here whose job is to be in the way. */
1272
+ header { gap:.3rem .5rem; padding:.5rem .6rem; margin-bottom:.75rem; }
1273
+ .hdr-break { display:block; flex-basis:100%; height:0; }
1274
+ /* The rule between the two groups is what the break already says at this width. */
1275
+ header .limits:not([hidden]) { gap:.8rem; padding-left:0; border-left:0; }
1276
+ .freshness { font-size:.72rem; }
1277
+ .gauge { font-size:.72rem; }
1278
+ /* The rail goes where the row bar goes, three rules below, and for the same reason: the
1279
+ number beside it is the authority, the bar is a second telling of it, and a phone has no
1280
+ width for the second telling. What it says in words — "62%", "resets in 1h" — is every
1281
+ word it said before. */
1282
+ .gauge .rail { display:none; }
927
1283
  /* The summary's ISO stamp, spent. It is the widest thing on that line and the header two
928
1284
  lines above already says the same fact in the words a reader uses — "updated 3s ago",
929
1285
  counted by the shell whether or not a poll ever lands. Hidden rather than dropped: the
@@ -938,17 +1294,21 @@ ${HISTORY_CSS}
938
1294
  them. The negative margin gives it the page's own gutters back, so the bar reaches the
939
1295
  edges of the phone and the rule above it reads as an edge rather than a floating line.
940
1296
 
941
- The sentence under the handle stays. Hiding it for the length of a replay was the obvious
942
- way to keep the bar short, and it silently undid a fix this file argues for forty lines
943
- into coversText: two of its three parts are standing properties of the RECORD, not the
944
- range — nothing replayed here is dated, and the past is drawn ungrouped — and they were
945
- put in the reader's view precisely because they had lived "nowhere the reader can see it"
946
- and an ungrouped map reads as a rendering that broke. A phone replaying is exactly when a
947
- reader is staring at one. The bar is taller for it. */
1297
+ The line under the handle stays. Hiding it for the length of a replay was the obvious way
1298
+ to keep the bar short, and it silently undid the fix coversRange and KEEPS argue for
1299
+ below: two of the things that line says are standing properties of the RECORD, not of the
1300
+ range — nothing replayed here is dated, and the past is drawn ungrouped — and they are in
1301
+ the reader's view precisely because they had lived "nowhere the reader can see it", where
1302
+ an ungrouped map reads as a rendering that broke. A phone replaying is exactly when a
1303
+ reader is staring at one, and a phone has no hover to reach a title with: the two words
1304
+ are on the line itself for that reason, and only their paragraph is behind it. */
948
1305
  body.replaying .replay:not([hidden]) { position:sticky; bottom:0; z-index:3;
949
1306
  background:var(--bg); border-top:1px solid var(--line);
950
1307
  padding:.55rem .75rem .8rem; margin:1rem -.75rem 0; }
951
- .wrap { overflow-x:visible; }
1308
+ /* The panel goes with the table. Below this width the rows ARE cards, and a panel around
1309
+ a column of cards is a frame drawn round a frame — the thing the two planes were
1310
+ introduced to stop. */
1311
+ .wrap { overflow-x:visible; background:transparent; border:0; border-radius:0; box-shadow:none; }
952
1312
  table, tbody { display:block; }
953
1313
  table { min-width:0; }
954
1314
  thead { display:none; }
@@ -965,8 +1325,12 @@ ${HISTORY_CSS}
965
1325
  The cell steps out of the layout entirely, with display:contents, so the row is the flex
966
1326
  container and every VALUE is one of its items. Anything else puts a box between the row
967
1327
  and the thing being placed, and the order below would have nothing to order. */
1328
+ /* And the strip becomes the card the desktop panel used to hold: filled, on the floor, with
1329
+ the same hairline and the same shadow as a node on the map. Without the fill it would be
1330
+ the one surface on the page still transparent over grey. */
968
1331
  tr { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.4rem; row-gap:.05rem;
969
- border:1px solid var(--line); border-left-width:3px; border-radius:8px;
1332
+ background:var(--surface); box-shadow:var(--shadow-1), var(--edge);
1333
+ border:1px solid var(--line); border-left-width:3px; border-radius:var(--r-md);
970
1334
  padding:.5rem .75rem .55rem; margin-bottom:.55rem; }
971
1335
  /* white-space on the CELL, and not on the row: the desktop rule being undone is
972
1336
  td { white-space:nowrap }, and an explicit declaration on the cell beats anything the row
@@ -1002,11 +1366,6 @@ ${HISTORY_CSS}
1002
1366
  background session named after its prompt, a name with no length limit. Not the exotic
1003
1367
  case: when nothing in the fleet calls itself interactive, every row is drawn as a card. */
1004
1368
  .berth-cards .node { flex:1 1 8.5rem; width:auto; min-width:0; }
1005
- /* A strip sharing a phone's width with a card is an ellipsis where the prompt was — the
1006
- one line saying what this agent was told to do is the first thing a narrow column takes
1007
- away. It spans the row instead, like the cells below it. The berth docks its own strips
1008
- full width at every size, so what this rule is left covering is the REPLAY's flat grid. */
1009
- .node[data-role="agent"] { grid-column:1 / -1; }
1010
1369
  /* Line one: who, and in what state. The project leads and carries the weight; the session
1011
1370
  name travels beside it in the page's grey. That order is deliberate and it is a red line
1012
1371
  — a background session is NAMED AFTER ITS PROMPT, and a prompt set as the heading of a
@@ -1087,6 +1446,10 @@ ${HISTORY_CSS}
1087
1446
  td[data-label="Model"] .v:has(.dim)::before { content:'· model '; }
1088
1447
  td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
1089
1448
  td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
1449
+ /* The table's strip only. The map's bar survives this on specificity — ".node .bar" above
1450
+ is 0,2,0 against this rule's 0,1,0, and a media query adds none — so an agent's reading
1451
+ is still drawn on the one screen it was reported missing from. No repeat of it here: a
1452
+ rule that changes nothing is a rule the next reader has to prove harmless. */
1090
1453
  .bar { display:none; }
1091
1454
  ${HISTORY_PHONE_CSS} }
1092
1455
  </style>
@@ -1108,6 +1471,11 @@ ${HISTORY_PHONE_CSS} }
1108
1471
  <a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
1109
1472
  <a href="/history"${view === 'history' ? ' aria-current="page"' : ''}>History</a>
1110
1473
  </nav>
1474
+ <!-- Where the header folds on a phone. An element and not a pseudo, because the alternative
1475
+ is "order" on the two groups around it — this page's sheet may move a control and never
1476
+ a reading, and both of those are readings. Empty, so there is nothing in it for anyone
1477
+ to be read. -->
1478
+ <span class="hdr-break" aria-hidden="true"></span>
1111
1479
  <!-- The account's two windows, page-level because that is what they are: a limit belongs to
1112
1480
  the account every session below is spending from, not to any one of them. Their VALUES
1113
1481
  come up from the fragment on every poll (the script's limits-src copy), so the header
@@ -1123,6 +1491,22 @@ ${HISTORY_PHONE_CSS} }
1123
1491
  <strong>&#9888; refresh failing</strong> — nothing on this page has moved since the time in the header.
1124
1492
  <span id="why"></span>
1125
1493
  </div>
1494
+ <!-- Which of the two fleets is on screen, said in the place the banner below will stand. The
1495
+ banner used to be inserted into the flow the moment a reader took hold of the handle and
1496
+ removed again when they let go, so the page dropped a line on the way in and rose one on
1497
+ the way out — at the exact moment a reader is comparing two minutes of it. The space is
1498
+ spent either way now, and a page that only speaks up when it is showing the past is a page
1499
+ that says nothing on the way back. Up with the scrubber and for the same reason: with no
1500
+ record there is no second state to be telling this one apart from. -->
1501
+ <!-- Served up, unlike the controls below it. It is not one of them: it says the page is showing
1502
+ the fleet now, which is true of a served page before any script runs and true of one where
1503
+ none ever will, and the way BACK from a replay is a button that lives in the banner. Shipped
1504
+ hidden it was the fault this pair exists to remove — the map paints, the record lands a
1505
+ moment later, and a line appears and pushes the whole fleet down. -->
1506
+ <div class="live-state" id="live-state">
1507
+ <strong>&#9679; live</strong>
1508
+ <span>&mdash; the fleet as the header dates it.</span>
1509
+ </div>
1126
1510
  <!-- The one claim on this page that could be a lie, so it is the loudest element on it and it
1127
1511
  carries the minute it is showing. Hidden until a script raises it: with no script there
1128
1512
  is no replay, and a banner about one would be a warning about nothing. -->
@@ -1154,9 +1538,15 @@ ${HISTORY_PHONE_CSS} }
1154
1538
  <!-- "Replay", and nothing about how much of the day it holds: the range is the record's to
1155
1539
  state, in the sentence below, which is built around never calling ten minutes a day. -->
1156
1540
  <span class="replay-name">Replay</span>
1157
- <button type="button" id="play">Play</button>
1541
+ <!-- The word says what the next press does; the attribute says what is happening now, which
1542
+ is the half a stylesheet can paint and a glance can catch. -->
1543
+ <button type="button" id="play" data-playing="false">Play</button>
1158
1544
  <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
1159
1545
  <div class="covers" id="covers"></div>
1546
+ <!-- What the line above could not fit, for a reader who cannot hover a title: the standing
1547
+ properties of the record — nothing replayed is dated, and the past is drawn ungrouped.
1548
+ Its own element rather than a child of the line, which the script rewrites wholesale. -->
1549
+ <span class="sr" id="covers-note"></span>
1160
1550
  </div>
1161
1551
  ${view === 'history' ? renderHistoryView({ historyEnabled, demo }) : ''}
1162
1552
  <script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
@@ -1217,6 +1607,28 @@ function pageScript(view) {
1217
1607
  var off = document.getElementById('offline'), why = document.getElementById('why');
1218
1608
  var limits = document.getElementById('limits');
1219
1609
  var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
1610
+ // ── the footnotes, across a swap ────────────────────────────────────────────────────
1611
+ // The folds live in the fragment this script replaces every five seconds, so a reader who
1612
+ // opens one gets about two seconds of it before a new one is built shut — a note less
1613
+ // readable folded than it was as a paragraph, which is the opposite of the change. What was
1614
+ // opened is remembered by id and put back on the other side of every swap.
1615
+ //
1616
+ // Capture, because "toggle" does not bubble: it fires on the <details> and nowhere else, so
1617
+ // a listener on the container only hears it on the way down. Delegated rather than bound to
1618
+ // each note, because the notes are exactly what the swap destroys.
1619
+ var openNotes = {};
1620
+ live.addEventListener('toggle', function (ev) {
1621
+ var t = ev && ev.target;
1622
+ if (!t || !t.id || t.id.indexOf('note-') !== 0) return;
1623
+ if (t.open) openNotes[t.id] = 1; else delete openNotes[t.id];
1624
+ }, true);
1625
+ function reopenNotes() {
1626
+ for (var k in openNotes) {
1627
+ if (!Object.prototype.hasOwnProperty.call(openNotes, k)) continue;
1628
+ var d = document.getElementById(k);
1629
+ if (d) d.open = true;
1630
+ }
1631
+ }
1220
1632
  // How many polls in a row have come back with nothing usable, and when the last of them was.
1221
1633
  // On a phone the page is read on a radio, and one dropped request is a tunnel rather than an
1222
1634
  // outage — the banner frames the table off and says the fleet cannot be read, which is the
@@ -1303,6 +1715,7 @@ function pageScript(view) {
1303
1715
  if (body.trim() === '') throw new Error('The server answered with an empty page.');
1304
1716
  if (!mineStill()) return;
1305
1717
  live.innerHTML = body;
1718
+ reopenNotes();
1306
1719
  // The account's gauges, lifted out of the fragment and into the header where they
1307
1720
  // belong. Here rather than in the fragment's own place on the page because a limit is
1308
1721
  // the account's and not a session's; here rather than in the shell alone because the
@@ -1348,6 +1761,7 @@ function pageScript(view) {
1348
1761
 
1349
1762
  var replay = document.getElementById('replay'), scrub = document.getElementById('scrub');
1350
1763
  var playBtn = document.getElementById('play'), covers = document.getElementById('covers');
1764
+ var coversNote = document.getElementById('covers-note');
1351
1765
  var rview = document.getElementById('replay-view'), rmap = document.getElementById('replay-map');
1352
1766
  var rmeta = document.getElementById('replay-meta'), note = document.getElementById('replaying');
1353
1767
  var rlimits = document.getElementById('replay-limits');
@@ -1390,50 +1804,86 @@ function pageScript(view) {
1390
1804
  return hhmm(t) + (new Date(t).getUTCDate() === new Date(ref).getUTCDate() ? '' : ' yesterday');
1391
1805
  }
1392
1806
 
1393
- // What the range covers, in the record's own terms. Never "a day": that is the size of the
1807
+ // What the range covers, in the record's own terms and in ONE line: the span, how many
1808
+ // readings are in it, and how many minutes have none. Never "a day" — that is the size of the
1394
1809
  // ring, and a serve ten minutes old has seen ten minutes.
1395
- function coversText() {
1810
+ //
1811
+ // One line because this is the line under the hand of whoever is dragging the handle, and it
1812
+ // was three of technical prose. What came off it is below, in KEEPS, and it is MOVED rather
1813
+ // than dropped: the standing properties of the record are the honest part of this feature.
1814
+ function coversRange() {
1396
1815
  var n = record.samples.length;
1397
- if (n === 0) {
1398
- // A record empty because every reading FAILED is not a record that has just started, and
1399
- // this was the one branch that threw that away: ten hours of a collector that could not
1400
- // run read exactly like a serve thirty seconds old.
1401
- return record.missed
1402
- ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' UTC and '
1403
- + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
1404
- : 'Nothing recorded yet — this serve started at ' + hhmm(record.since) + ' UTC'
1405
- + ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
1406
- }
1407
1816
  var last = record.samples[n - 1].t;
1408
- return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last) + ' UTC'
1409
- // Not "when the page loaded": the record is asked for again when a tab that has been
1410
- // away comes back, so the sentence names the last time this page asked rather than a
1411
- // moment it may be hours past.
1412
- + ', as this page last had it — ' + n + ' reading' + (n === 1 ? '' : 's')
1817
+ return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last) + ' UTC · '
1818
+ + n + ' reading' + (n === 1 ? '' : 's')
1413
1819
  // A gap that says it is a gap is not a gap. The handle steps through readings, not
1414
- // through minutes, and a record with holes in it is not a smooth walk.
1415
- + (record.missed ? ', ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
1416
- + '. The record keeps each reading, not how old that reading was, so nothing replayed here is dated.'
1417
- // The other thing the ring does not hold, said where the reader meets it: the argument
1418
- // for an ungrouped replay was written in the README, the manual, the changelog and a
1419
- // comment in this sheet, and nowhere the reader can see it. Shown less and told nothing,
1420
- // a reader reads it as a rendering that broke.
1421
- //
1422
- // A standing property of the record, never an event. This line sits in the scrubber's own
1423
- // block, outside the live fragment and outside the replay one, so it is on the page from
1424
- // the moment the record lands — and a sentence saying the grouping had gone would be
1425
- // printed under a live map with the grouping on it.
1426
- + ' It keeps a project name and never the directory a node was read in, so the past is'
1427
- + ' drawn ungrouped, in the order the sample carries.';
1820
+ // through minutes, and a record with holes in it is not a smooth walk — which is why this
1821
+ // one clause stayed on the visible line while the paragraph below it went.
1822
+ + (record.missed ? ' · ' + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' with no reading' : '')
1823
+ // The two words the paragraph below explains, kept where every reader meets them. A title
1824
+ // has no hover on a touchscreen and no keyboard, and a hidden span is for a screen reader:
1825
+ // moving the whole of it would have left the sighted phone reader the one this page
1826
+ // argues about, staring at an ungrouped map — with neither half, and an ungrouped map that
1827
+ // says nothing about being ungrouped reads as a rendering that broke.
1828
+ + ' · undated, ungrouped';
1829
+ }
1830
+
1831
+ // The two standing properties of the record, off the line and never out of reach: in the
1832
+ // title for a pointer, and in a span only a screen reader meets. Both are things this page
1833
+ // would otherwise be caught not saying the ring never kept how old a reading was, and it
1834
+ // never kept the directory a node was read in, so a replayed map is undated and ungrouped.
1835
+ // Shown less and told nothing, a reader reads an ungrouped map as a rendering that broke.
1836
+ var KEEPS = 'The record keeps each reading, not how old that reading was, so nothing replayed'
1837
+ + ' here is dated. It keeps a project name and never the directory a node was read in, so the'
1838
+ + ' past is drawn ungrouped, in the order the sample carries.'
1839
+ // The third of them, and the one that qualifies the range itself rather than what is drawn
1840
+ // from it: the record is asked for again only when a tab that has been away comes back, so
1841
+ // the minute this range ends on is the last one this page ASKED for. A map left open on a
1842
+ // desk all afternoon would otherwise read as a record that stops where the fleet did.
1843
+ + ' The range ends where this page last asked for the record, not at this minute.';
1844
+
1845
+ // The line, and what it could not fit. A message that IS the whole of what there is to say
1846
+ // carries no second copy of itself: a tooltip repeating the line it sits on is how a reader
1847
+ // learns to ignore the next one.
1848
+ function say(line, more) {
1849
+ covers.textContent = line;
1850
+ coversNote.textContent = more;
1851
+ if (more === '') covers.removeAttribute('title');
1852
+ else covers.setAttribute('title', more);
1428
1853
  }
1429
1854
 
1855
+ // What there is to say when there is no range: a message that is the whole of itself, and
1856
+ // carries none of KEEPS — there is nothing replayed for those properties to be true of.
1857
+ //
1858
+ // A record empty because every reading FAILED is not a record that has just started, and this
1859
+ // was the one branch that threw that away: ten hours of a collector that could not run read
1860
+ // exactly like a serve thirty seconds old.
1861
+ function emptyText() {
1862
+ return record.missed
1863
+ ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' UTC and '
1864
+ + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
1865
+ : 'Nothing recorded yet — this serve started at ' + hhmm(record.since) + ' UTC'
1866
+ + ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
1867
+ }
1868
+
1869
+ // Nothing here touches the state line, and that is the rule rather than an omission: it is
1870
+ // served up, and it says the page is showing the fleet as the header dates it — true of a
1871
+ // record with a day in it, of one a serve is too young to have taken, and of one that could
1872
+ // not be read at all. Hidden on the empty answer it removed a line the server had already
1873
+ // painted: a serve samples on an interval with no leading call, so every page opened on a
1874
+ // fresh one is answered with an empty sample list for a minute, and the fleet jumped up
1875
+ // 42px, 33ms in.
1430
1876
  function ready() {
1431
1877
  var n = record.samples.length;
1432
1878
  replay.hidden = false;
1433
- covers.textContent = coversText();
1879
+ say(n === 0 ? emptyText() : coversRange(), n === 0 ? '' : KEEPS);
1434
1880
  scrub.max = String(n === 0 ? 0 : n - 1);
1435
1881
  scrub.disabled = n === 0;
1436
1882
  playBtn.disabled = n === 0;
1883
+ // The record grows by a reading a minute, so the same index is a smaller share of it every
1884
+ // time this runs. Refilled here, or a handle nobody has touched since the last poll draws
1885
+ // the fraction of an hour ago.
1886
+ fill();
1437
1887
  }
1438
1888
 
1439
1889
  // Revealed, not hidden, when the record cannot be had: a scrubber that silently never
@@ -1442,7 +1892,7 @@ function pageScript(view) {
1442
1892
  replay.hidden = false;
1443
1893
  scrub.disabled = true;
1444
1894
  playBtn.disabled = true;
1445
- covers.textContent = said;
1895
+ say(said, '');
1446
1896
  }
1447
1897
 
1448
1898
  function load() {
@@ -1502,7 +1952,15 @@ function pageScript(view) {
1502
1952
  + '<span class="project">' + esc(x.project) + '</span>'
1503
1953
  + '<span class="kind">' + esc(x.kind) + '</span></div>'
1504
1954
  + (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
1505
- + (pct === null ? '' : '<div class="sub">ctx ' + pct + '%</div>')
1955
+ // The same fragment the server writes for a live strip, in the browser's copy of the
1956
+ // renderer: a bar for the magnitude, the number beside it, and the word that says which
1957
+ // quantity the bar is about. Clamped, because the track sits in a fixed box and a fill
1958
+ // wider than it paints over the text next to it.
1959
+ + (pct === null
1960
+ ? ''
1961
+ : '<div class="sub"><span class="ctx">ctx <span class="bar"><i style="width:'
1962
+ + Math.max(0, Math.min(100, pct)) + '%"></i></span>'
1963
+ + '<span class="ctx-pct">' + pct + '%</span></span></div>')
1506
1964
  + (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
1507
1965
  + '</article>';
1508
1966
  }
@@ -1632,12 +2090,32 @@ function pageScript(view) {
1632
2090
  : '$' + cost.toFixed(2) + (reporting < n ? ' (' + reporting + '/' + n + ' reporting cost)' : ''));
1633
2091
  }
1634
2092
 
2093
+ /**
2094
+ * How much of the record is behind the handle, as the track's own gradient reads it.
2095
+ *
2096
+ * Webkit gives a range input two shadow parts and no third one for the walked half, so the
2097
+ * fill is a stop in the track's background and this is where the stop is. Firefox has
2098
+ * ::-moz-range-progress and needs none of it, which is why the value is a token rather than a
2099
+ * background written from here: one number, two engines, and the sheet decides what each
2100
+ * does with it.
2101
+ *
2102
+ * A record of one reading has a max of 0 and nothing to divide by. Infinity and NaN are both
2103
+ * answers a browser throws the whole gradient away for, leaving a track that never fills for
2104
+ * anybody — so a span of nothing is nought per cent, which is where the handle is.
2105
+ */
2106
+ function fill() {
2107
+ var m = Number(scrub.max);
2108
+ var share = m > 0 ? (Number(scrub.value) / m) * 100 : 0;
2109
+ scrub.style.setProperty('--p', (share >= 0 && share <= 100 ? share : 0).toFixed(2) + '%');
2110
+ }
2111
+
1635
2112
  function draw(i) {
1636
2113
  var s = record && record.samples[i];
1637
2114
  if (!s) return;
1638
2115
  at = i;
1639
2116
  replaying = true;
1640
2117
  scrub.value = String(i);
2118
+ fill();
1641
2119
  // The handle's own value is an index, so a reader who cannot see the banner would be read
1642
2120
  // "3" while the fleet on screen is three hours old. The minute travels with the handle.
1643
2121
  var minute = hhmm(s.t) + ' UTC';
@@ -1658,6 +2136,10 @@ function pageScript(view) {
1658
2136
  function stopPlay() {
1659
2137
  if (playing) { clearInterval(playing); playing = null; }
1660
2138
  playBtn.textContent = 'Play';
2139
+ // Both halves, every time: the word for the press that comes next, the attribute for what
2140
+ // is happening now. Set here rather than only where a reader clicks, because the walk also
2141
+ // ends on its own at the last reading.
2142
+ playBtn.setAttribute('data-playing', 'false');
1661
2143
  }
1662
2144
 
1663
2145
  // Back to now, in one gesture, with nothing of the past left behind a hidden attribute.
@@ -1682,6 +2164,7 @@ function pageScript(view) {
1682
2164
  // has played nothing.
1683
2165
  draw(at < 0 || at >= record.samples.length - 1 ? 0 : at);
1684
2166
  playBtn.textContent = 'Pause';
2167
+ playBtn.setAttribute('data-playing', 'true');
1685
2168
  playing = setInterval(function () {
1686
2169
  // It stops at the end rather than looping back: a day that restarts on its own is a
1687
2170
  // day whose beginning and end are impossible to tell apart.
@@ -1866,10 +2349,25 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
1866
2349
  // around it and the table a column header over it, and a bare `61%` under a line of prompt
1867
2350
  // reads as how much of the prompt is done. Each part is dropped on its own field being
1868
2351
  // null — a snapshot with no turn behind it has a model in it and no percentage.
1869
- const published = [pct === null ? null : `ctx ${pct}%`, r.model, r.effort]
2352
+ //
2353
+ // The reading is DRAWN now, in the bar the table's Context column already speaks — a
2354
+ // magnitude at a glance beside the number that is the authority, track in --line and fill
2355
+ // in --dim, coloured by neither the state nor a threshold. What it is not is a small dial:
2356
+ // an arc at that size cannot be read (5% and 15% draw the same silhouette), and a ring on
2357
+ // an agent is the claim #170 removed. A strip stays a line of text, now with 44x5px of
2358
+ // graphic on it.
2359
+ //
2360
+ // Which is why the percentage leaves the escaped list: `published` puts `esc` over every
2361
+ // one of its members, and markup through it would come back as text.
2362
+ const ctx = pct === null
2363
+ ? ''
2364
+ : `<span class="ctx">ctx <span class="bar"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i></span>` +
2365
+ `<span class="ctx-pct">${pct}%</span></span>`;
2366
+ const meta = [r.model, r.effort]
1870
2367
  .filter((v) => v !== null && v !== '')
1871
2368
  .map(esc)
1872
2369
  .join(' · ');
2370
+ const published = ctx === '' ? meta : meta === '' ? ctx : `${ctx} · ${meta}`;
1873
2371
  return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
1874
2372
  <div class="who"><span class="shape" aria-hidden="true">${SHAPE[state]}</span><span class="sr">${esc(stateWord(state, r))}</span><span class="prompt">${esc(r.name)}</span><span class="kind">${esc(r.kind)}</span></div>
1875
2373
  ${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}