@adrrr/tarmac 0.5.0 → 0.7.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
@@ -137,6 +137,12 @@ export function renderSettings(config, configFile) {
137
137
  ['port', String(config.port.value), config.port.source],
138
138
  ['snapshots', config.snapshotsDir.value, config.snapshotsDir.source],
139
139
  ];
140
+ // Only when there are any. An empty list is what every other run has, chosen by nobody —
141
+ // a `(default)` line saying "none" on every serve is noise, and this line has to read as
142
+ // what it is: the one setting that widened who may read this port.
143
+ if (config.trustHosts.value.length > 0) {
144
+ rows.push(['trusted', config.trustHosts.value.join(', '), config.trustHosts.source]);
145
+ }
140
146
  // Only the LABEL column is padded. Padding the values aligned the sources against the
141
147
  // snapshots path, which is absolute — pushing the one word that says where a value came
142
148
  // from past the edge of an 80-column terminal.
@@ -157,7 +163,7 @@ export function renderTable({ rows, health }) {
157
163
  r.effort ?? '—',
158
164
  r.costUsd === null ? '—' : `$${r.costUsd.toFixed(2)}`,
159
165
  r.uptimeMs === null ? '—' : `${Math.round(r.uptimeMs / 3600000)}h`,
160
- ]);
166
+ ].map(clip));
161
167
  const w = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
162
168
  const line = (cells) => cells.map((c, i) => c.padEnd(w[i])).join(' ').trimEnd();
163
169
  const warns = [];
@@ -192,6 +198,11 @@ export function renderTable({ rows, health }) {
192
198
  warns.push(`! ${skewed} reading(s) are dated in the future — ${SKEW}`);
193
199
  if (health.unknownStatus > 0)
194
200
  warns.push(`! ${health.unknownStatus} session(s) report an unknown status`);
201
+ const account = accountLimits(rows, health.generatedAt);
202
+ const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt);
203
+ const split = accountSplit(account, gauges);
204
+ if (split)
205
+ warns.push(`! ${split}`);
195
206
  // Last, and never instead of anything above: this one is a heads-up, not a fault.
196
207
  const schema = schemaNotice(health.schemaGuard);
197
208
  if (schema)
@@ -200,7 +211,86 @@ export function renderTable({ rows, health }) {
200
211
  return ([line(head), ...body.map(line)].join('\n') +
201
212
  '\n' +
202
213
  (warns.length ? '\n' + warns.join('\n') + '\n' : '') +
203
- `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n`);
214
+ `\n${health.sessions} sessions · ${health.busy} busy · ${total}\n${accountLine(gauges, account, health)}\n`);
215
+ }
216
+ /**
217
+ * The account's two windows, under the fleet rather than in a column.
218
+ *
219
+ * They are the one pair of numbers in this table that is not about a session: every row above
220
+ * spends from the same five-hour and seven-day allowance, so a column of them would be the
221
+ * same two numbers printed once per session. Under the totals, where the other fleet-wide
222
+ * facts are.
223
+ *
224
+ * Dated like every reading here, and always: the AS OF column exists because a percentage is
225
+ * as old as the frame that wrote it, and this one has no column to be dated by. The `!` is the
226
+ * same mark, past the same threshold, explained by the same warning above.
227
+ */
228
+ function accountLine(gauges, account, health) {
229
+ const windows = gauges
230
+ .map((g) => `${g.label} ${g.pct === null ? `— ${LIMIT_WHY[g.why]}` : `${g.pct}% ${resetWords(g.resetsInMs, '—')}`}`)
231
+ .join(' · ');
232
+ // A reading is dated; no reading is not. The two states read alike in the windows above —
233
+ // `— no reading` is what a payload with no rate limits and a fleet with no snapshot at all
234
+ // both come to — and the age is what tells them apart: a snapshot that said nothing carries
235
+ // the moment it said it, and a fleet nothing was read for has no such moment to print.
236
+ const as = account === null ? '' : ` · as of ${age(account.ageMs)}${account.ageMs > health.staleAfterMs ? ' !' : ''}`;
237
+ return `account ${windows}${as}`;
238
+ }
239
+ /**
240
+ * What to say when the readings behind that line are not all about the same windows, and
241
+ * `null` on the ordinary fleet, where they are.
242
+ *
243
+ * One warning for both surfaces to be written from: the account is the ONE number here picked
244
+ * out of several that could have been it, and a picked winner presented as the fleet's account
245
+ * is exactly what a fleet signed into two logins at once would look like. The count is what the
246
+ * reader needs in order to go and look; WHY two windows were open at the same time is published
247
+ * nowhere tarmac reads, so it is not guessed.
248
+ *
249
+ * Only windows that are drawn as a number, because this sentence qualifies one: a window the
250
+ * surface prints as `— schema drift` has nothing for "the freshest is shown" to be true of, and
251
+ * a warning derived from a field the line under it has just called unreadable is a warning about
252
+ * the wrong thing. When that leaves nothing to name, there is nothing to say.
253
+ */
254
+ function accountSplit(account, gauges) {
255
+ if (account === null || account.apart === 0)
256
+ return null;
257
+ const drawn = new Set(gauges.filter((g) => g.pct !== null).map((g) => g.key));
258
+ const labels = LIMIT_WINDOWS.filter((w) => account.apartWindows.includes(w.key) && drawn.has(w.key)).map((w) => w.label);
259
+ if (labels.length === 0)
260
+ return null;
261
+ const which = `the ${labels.join(' and ')} window${labels.length === 1 ? '' : 's'}`;
262
+ // Said the long way round on purpose: "1 of 4 readings names" and "2 of 4 readings name" are
263
+ // two sentences, and a count that has to agree with a verb is a count someone will get wrong.
264
+ return `${which} ${labels.length === 1 ? 'is' : 'are'} read differently by ${account.apart} of ${account.readings} readings — the freshest is shown`;
265
+ }
266
+ /**
267
+ * How wide a column may get, `null` for one nothing can stretch.
268
+ *
269
+ * Four of the eight carry a string this tool did not choose the length of — a directory
270
+ * basename, a status word tarmac does not know or the free text a `waiting` session gives, a
271
+ * model name, an effort — and one long value in any of them used to push every row of the
272
+ * table past 190 columns, on a terminal that wraps at 80. The caps are picked so that the
273
+ * worst fleet a source can hand this renderer stays within 120 CODE POINTS a row — display
274
+ * width is the wider, separate question (#80): a CJK glyph is one point and two columns, and
275
+ * the width math below counts `.length` like it always has. The page has CSS to wrap with, a
276
+ * terminal has nothing. The other four are a percentage, an age, a cost and an hour count;
277
+ * their own magnitude is what bounds them, and no cap here would ever bite.
278
+ */
279
+ // STATE is the widest of the four because it is two facts on one line: the state, and the
280
+ // reason a `waiting` session gives for being in it. `waiting · permission prompt` — the
281
+ // reason the fleet's own suite is written around — is 27 of these 28 columns.
282
+ const CAPS = [20, 28, null, null, 16, 8, null, null];
283
+ /**
284
+ * One cell, cut to its column. The ellipsis is spent out of the cap rather than added past
285
+ * it — a cap a cut cell can exceed is not a cap — and the cut is by code point, because half
286
+ * a surrogate pair is not a shorter name, it is a broken one.
287
+ */
288
+ function clip(cell, i) {
289
+ const cap = CAPS[i];
290
+ if (cap === null)
291
+ return cell;
292
+ const chars = Array.from(cell);
293
+ return chars.length <= cap ? cell : chars.slice(0, cap - 1).join('') + '…';
204
294
  }
205
295
  /**
206
296
  * The STATE column, out of the same verdict the page draws from.
@@ -330,7 +420,7 @@ export function renderLive(fleet) {
330
420
  </table></div></div>
331
421
  <div class="view view-map" role="group" aria-label="fleet map" aria-describedby="fleet-notes">${renderMap(fleet)}</div>`;
332
422
  return `<div id="limits-src" hidden>${renderLimits(fleet)}</div>
333
- <div class="meta">${health.sessions} session${health.sessions === 1 ? '' : 's'} · ${health.busy} busy · ${cost(health)} · ${esc(new Date(health.generatedAt).toISOString())}</div>
423
+ <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>
334
424
  ${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
335
425
  ${body}
336
426
  <div id="fleet-notes">${notes.map((n) => `<div class="note">${esc(n)}</div>`).join('')}</div>`;
@@ -349,10 +439,9 @@ ${body}
349
439
  * alone would be as old as the tab.
350
440
  */
351
441
  export function renderLimits({ rows, health }) {
352
- const account = accountLimits(rows);
353
- const gauges = readLimits(account === null ? null : account.rateLimits, health.generatedAt)
354
- .map(gauge)
355
- .join('');
442
+ const account = accountLimits(rows, health.generatedAt);
443
+ const read = readLimits(account === null ? null : account.rateLimits, health.generatedAt);
444
+ const gauges = read.map(gauge).join('');
356
445
  // Dated when the snapshot behind it is past the threshold, exactly as the table dates a stale
357
446
  // context. It matters more here than anywhere else on the page: the percentage is as old as
358
447
  // that snapshot, while the countdown beside it is recomputed on every five-second re-render —
@@ -363,7 +452,15 @@ export function renderLimits({ rows, health }) {
363
452
  // fact said twice is noise. The replay has no equivalent — the ring keeps each reading and
364
453
  // never how old it was, which is why nothing replayed on this page is dated.
365
454
  const stale = account !== null && account.ageMs > health.staleAfterMs;
366
- return gauges + (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '');
455
+ // The other thing that can be wrong with this pair, and the one the age cannot say: the
456
+ // number was picked out of several readings, and they were not all about the same window.
457
+ // Beside the number rather than in a box below the fleet, because what it qualifies is the
458
+ // number — and it says the whole sentence, since a mark whose reason is elsewhere is a mark
459
+ // the reader cannot argue with.
460
+ const split = accountSplit(account, read);
461
+ return (gauges +
462
+ (stale ? `<span class="stale">! ${esc(asOfAge(account.ageMs))} ago</span>` : '') +
463
+ (split === null ? '' : `<span class="mixed">! ${esc(split)}</span>`));
367
464
  }
368
465
  /**
369
466
  * One window. Four things in a line: which window it is, a bar for the glance, the number that
@@ -380,7 +477,7 @@ function gauge(g) {
380
477
  : `<span class="rail" aria-hidden="true"><i style="width:${g.pct}%"></i></span>`;
381
478
  return (`<div class="gauge"><span class="lbl" aria-hidden="true">${g.label}</span><span class="sr">${g.said}</span>` +
382
479
  `${rail}<span class="num">${g.pct === null ? dash() : `${g.pct}%`}</span>` +
383
- `<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs)}</span></div>`);
480
+ `<span class="reset">${g.pct === null ? LIMIT_WHY[g.why] : resetWords(g.resetsInMs, dash())}</span></div>`);
384
481
  }
385
482
  /** Which kind of missing a missing window is, in the two words both surfaces use. */
386
483
  const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
@@ -391,7 +488,7 @@ const LIMIT_WHY = { absent: 'no reading', drift: 'schema drift' };
391
488
  * after the reading that reported it, so the percentage beside these words belongs to a window
392
489
  * that no longer exists. Saying that is the whole point of showing a reset at all.
393
490
  */
394
- const resetWords = (ms) => ms === null ? `reset ${dash()}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
491
+ const resetWords = (ms, none) => ms === null ? `reset ${none}` : ms > 0 ? `resets in ${left(ms)}` : `reset was due ${left(-ms)} ago`;
395
492
  /**
396
493
  * How long, in the two units that matter at each scale. Deliberately finer than `duration()`
397
494
  * next door, which floors a session's uptime to whole hours: five hours is a window someone
@@ -494,7 +591,10 @@ export function renderPage(fleet, view = 'table') {
494
591
  reading, the payload shapes nobody has captured yet. It reads as chrome to someone
495
592
  scanning their sessions and as an answer to someone who came looking for it. */
496
593
  .note { color:var(--dim); font-size:.75rem; line-height:1.5; margin:.9rem 0 0; max-width:95ch; }
497
- .stale { color:var(--warn); font-weight:600; }
594
+ /* Two marks, one weight: a reading that has gone cold, and a reading picked out of several
595
+ that were not about the same window. Both say the number beside them may not be what the
596
+ reader takes it for, so neither may end up quieter than the other. */
597
+ .stale, .mixed { color:var(--warn); font-weight:600; }
498
598
  .wrap { overflow-x:auto; }
499
599
  table { border-collapse:collapse; width:100%; min-width:44rem; }
500
600
  th { text-align:left; font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em;
@@ -535,6 +635,28 @@ export function renderPage(fleet, view = 'table') {
535
635
  nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
536
636
  letter-spacing:.06em; padding:.15rem .55rem; border-radius:99px; border:1px solid transparent; }
537
637
  nav a[aria-current="page"] { color:var(--fg); border-color:var(--line); }
638
+ /* A finger is not a cursor. Every control on this page is a pill sized for a pointer that
639
+ lands on a single pixel — about 26px of box against the 44 a thumb is asked to hit — and
640
+ the fix cannot be more padding: that would redraw the page for everyone to solve a problem
641
+ only a touchscreen has. So the TAPPABLE box grows and the drawn one does not, through an
642
+ invisible overlay that exists only where the pointer is coarse.
643
+
644
+ Two rules rather than one: the inset is what is LEFT to reach 44 once the pill's own line
645
+ box and padding are counted, and the way out of a replay is set in smaller type than the
646
+ tabs. Sized as one number for all three, it came out at 41px. The border is NOT part of that
647
+ sum: the overlay's containing block is the control's padding box, so the border sits inside
648
+ the rectangle rather than adding to it. Counting it read 45.2px for a target Chrome laid out
649
+ at 43.2 — the whole feature short of the threshold it exists for, in both rules at once.
650
+
651
+ Vertical only. Every control here is already wider than 44px on its own text (the narrowest,
652
+ Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
653
+ the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
654
+ Map because Map's pseudo paints later. */
655
+ nav a, .replay button, .replaying-note button { position:relative; }
656
+ @media (pointer: coarse) {
657
+ nav a::after, .replay button::after { content:''; position:absolute; inset:-.7rem 0; }
658
+ .replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }
659
+ }
538
660
  body[data-view="table"] .view-map { display:none; }
539
661
  body[data-view="map"] .view-table { display:none; }
540
662
 
@@ -585,6 +707,12 @@ export function renderPage(fleet, view = 'table') {
585
707
  beats it: unguarded, this page came up announcing a replay nobody had asked for. */
586
708
  .replay:not([hidden]) { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-top:1rem;
587
709
  padding-top:.7rem; border-top:1px solid var(--line); }
710
+ /* The pair gets a name. A button reading "Play" and a slider under a map, with nothing
711
+ saying what they move, is a control nobody dares touch — which on a phone is most of what
712
+ is on screen. Its own line above them, because dropped into the row it would read as a
713
+ label for the button rather than for the pair, and take width from the slider to do it. */
714
+ .replay .replay-name { flex-basis:100%; font-size:.7rem; font-weight:700; letter-spacing:.07em;
715
+ text-transform:uppercase; color:var(--dim); }
588
716
  .replay button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent;
589
717
  border:1px solid var(--line); border-radius:99px; padding:.15rem .8rem; cursor:pointer; }
590
718
  .replay input[type="range"] { flex:1; min-width:10rem; accent-color:var(--dim); }
@@ -687,7 +815,7 @@ export function renderPage(fleet, view = 'table') {
687
815
  it is the only thing on the strip that has no length limit. */
688
816
  .node .prompt { flex:1; min-width:0; color:var(--dim); font-size:.76rem;
689
817
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
690
- /* Said, not shown: the three glyphs differ in silhouette, so a reader who cannot separate
818
+ /* Said, not shown: the four glyphs differ in silhouette, so a reader who cannot separate
691
819
  two hues still has the state — but a screen reader is handed a bullet and nothing else. */
692
820
  .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
693
821
  .dial { position:relative; width:5.5rem; height:5.5rem; }
@@ -756,17 +884,75 @@ export function renderPage(fleet, view = 'table') {
756
884
  .asof.stale { color:var(--warn); font-weight:600; }
757
885
  @media (max-width: 30rem) { .map.flat { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); } .map { gap:.6rem; } }
758
886
 
759
- /* Below this the table stops being a table: one card per session, every value keeping the
760
- name of the column it came from. Nothing is dropped a phone that hides the context
761
- column would be a phone that renders "not measured" as nothing at all. */
887
+ /* Below this the table stops being a table. What replaces it is the strip described down at
888
+ the tr rule: two lines per session rather than one card of eight labelled ones. Nothing is
889
+ dropped — a phone that hid the context column would be a phone that rendered "not measured"
890
+ as nothing at all — and the labels that go are the ones whose value says what it is on its
891
+ own, with the exceptions named where they are given a word back. */
762
892
  @media (max-width: 46rem) {
763
893
  body { padding:1.25rem .75rem; }
894
+ /* The summary's ISO stamp, spent. It is the widest thing on that line and the header two
895
+ lines above already says the same fact in the words a reader uses — "updated 3s ago",
896
+ counted by the shell whether or not a poll ever lands. Hidden rather than dropped: the
897
+ fragment still carries the exact second for anyone who goes looking for it. */
898
+ .meta .stamp { display:none; }
899
+ /* The handle, pinned under the thumb. The scrubber sits at the FOOT of the map and a map on
900
+ a phone is several screens tall: dragging it means the dials it moves are above the fold,
901
+ so the reader scrubs blind, lets go, scrolls up to see what changed and scrolls back. Held
902
+ at the bottom of the viewport, the hand and the thing it is changing are on screen at once.
903
+
904
+ Opaque and above what passes under it, or the dials scroll through the slider dragging
905
+ them. The negative margin gives it the page's own gutters back, so the bar reaches the
906
+ edges of the phone and the rule above it reads as an edge rather than a floating line.
907
+
908
+ The sentence under the handle stays. Hiding it for the length of a replay was the obvious
909
+ way to keep the bar short, and it silently undid a fix this file argues for forty lines
910
+ into coversText: two of its three parts are standing properties of the RECORD, not the
911
+ range — nothing replayed here is dated, and the past is drawn ungrouped — and they were
912
+ put in the reader's view precisely because they had lived "nowhere the reader can see it"
913
+ and an ungrouped map reads as a rendering that broke. A phone replaying is exactly when a
914
+ reader is staring at one. The bar is taller for it. */
915
+ body.replaying .replay:not([hidden]) { position:sticky; bottom:0; z-index:3;
916
+ background:var(--bg); border-top:1px solid var(--line);
917
+ padding:.55rem .75rem .8rem; margin:1rem -.75rem 0; }
764
918
  .wrap { overflow-x:visible; }
765
- table, tbody, tr, td { display:block; }
919
+ table, tbody { display:block; }
766
920
  table { min-width:0; }
767
921
  thead { display:none; }
768
- tr { border:1px solid var(--line); border-left-width:3px; border-radius:8px;
769
- padding:.35rem .7rem; margin-bottom:.6rem; }
922
+ /* The card stops being eight labelled lines and becomes the strip the map already speaks.
923
+ Eight lines is 234px of phone: two and a half sessions fill the screen, and "is anything
924
+ waiting on me" costs four screens of scrolling. Two lines instead — who and in what
925
+ state, then the numbers, "ctx 65% · Opus 5 · medium · $20.79 · up 15h", which is the
926
+ line a docked agent has always printed on the map next door.
927
+
928
+ Nothing is dropped. The labels that go are the ones whose values wear their own name: a
929
+ "$", "%", a model, a state that is a word. The two that do not get one back below, in
930
+ the strip's own words rather than as a column heading.
931
+
932
+ The cell steps out of the layout entirely, with display:contents, so the row is the flex
933
+ container and every VALUE is one of its items. Anything else puts a box between the row
934
+ and the thing being placed, and the order below would have nothing to order. */
935
+ tr { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.4rem; row-gap:.05rem;
936
+ border:1px solid var(--line); border-left-width:3px; border-radius:8px;
937
+ padding:.5rem .75rem .55rem; margin-bottom:.55rem; }
938
+ /* white-space on the CELL, and not on the row: the desktop rule being undone is
939
+ td { white-space:nowrap }, and an explicit declaration on the cell beats anything the row
940
+ passes down — display:contents takes the cell out of the layout, not out of the cascade.
941
+ Put on the row instead, it read correctly and left the project name 128px past the phone. */
942
+ td { display:contents; white-space:normal; }
943
+ /* The column names are gone from this width, and there is no pseudo-element hiding them for
944
+ a screen reader: both ways of trying were measured against Chrome's accessibility tree and
945
+ neither works. Out of flow (the .sr recipe) the eight labels are read as ONE block after
946
+ the whole table, detached from every value they name — worse than silence. In flow at zero
947
+ size they are pruned from the tree entirely, and they still move the strip. What a reader
948
+ hears is the strip itself: "beacon, beacon-8c, waiting · permission prompt, ctx 65%, Opus
949
+ 5, medium, $20.79, up 15h" — named for five of the eight, and unnamed for the project, the
950
+ session and the model. The desktop table names all eight in its thead, and so does every
951
+ JSON surface. Naming them here needs markup, and markup is not what this change is. */
952
+ /* The line break, as an item: zero-height, full-width, wedged between the state and the
953
+ first number. Without it the strip is a paragraph that reflows per session, and a column
954
+ of cards whose second line starts somewhere different each time cannot be scanned. */
955
+ tr::after { content:''; order:4; flex-basis:100%; height:0; }
770
956
  tr[data-state="busy"] { border-left-color:var(--busy); }
771
957
  tr[data-state="waiting"] { border-left-color:var(--wait); }
772
958
  tr[data-state="unknown"] { border-left-color:var(--warn); }
@@ -788,11 +974,86 @@ export function renderPage(fleet, view = 'table') {
788
974
  away. It spans the row instead, like the cells below it. The berth docks its own strips
789
975
  full width at every size, so what this rule is left covering is the REPLAY's flat grid. */
790
976
  .node[data-role="agent"] { grid-column:1 / -1; }
791
- td, td:first-child { border:0; padding:.2rem 0; white-space:normal;
792
- display:flex; justify-content:space-between; align-items:baseline; gap:1rem; }
793
- td::before { content:attr(data-label); color:var(--dim); font-size:.72rem; font-weight:600;
794
- text-transform:uppercase; letter-spacing:.06em; flex:none; }
795
- .v { text-align:right; }
977
+ /* Line one: who, and in what state. The project leads and carries the weight; the session
978
+ name travels beside it in the page's grey. That order is deliberate and it is a red line
979
+ a background session is NAMED AFTER ITS PROMPT, and a prompt set as the heading of a
980
+ card is a dashboard announcing what its agents were told to do, in the largest type on
981
+ the page. It also has no length limit, so it is the one value here allowed to wrap: the
982
+ page is content-box at this width and .wrap has given up its overflow-x, so a line
983
+ that refuses to break takes the whole document sideways.
984
+
985
+ The cell above hands back the white-space the desktop table takes; these two need the
986
+ other half of it, because both can arrive as ONE long token — a project is a directory's
987
+ basename, a background session's name is a prompt — and normal has nowhere to break a
988
+ word. min-width:0 for the same reason the berths carry it: a flex item's automatic
989
+ minimum is its min-content width, which without this is the whole unbroken string. */
990
+ td[data-label="Project"] .v { order:1; font-weight:600; min-width:0; overflow-wrap:anywhere; }
991
+ /* The basis is auto and not 0, which is what decides whether the name breaks INSIDE
992
+ itself. A basis of 0 puts it on the first line however little is left of one: at 390px
993
+ beside a 40-character project that is 44.6px of column, a session id cut across two
994
+ lines, the pill dropped alone underneath and a 63px strip standing at 105.7 — on
995
+ exactly the fleets whose checkout names are long. Its own width is the floor it wraps
996
+ at instead: beside the project where it fits, on the next line whole where it does not.
997
+ That floor is a trade, not a free fix: a name longer than one full line keeps a line of
998
+ its own at EVERY width, where a basis of 0 let it compress back beside the project as
999
+ the screen widened. The accepted side of #108: never cut a name mid-word, at the price
1000
+ of a taller strip for prompt-named agents.
1001
+ min-width:0 is what still lets it shrink once it is the widest thing on a line of its
1002
+ own, which is the wrap the rule above is written for. */
1003
+ td[data-label="Session"] .v { order:2; flex:1 1 auto; min-width:0; color:var(--dim);
1004
+ white-space:normal; overflow-wrap:anywhere; }
1005
+ /* The third value that can arrive as one unbroken token, and the one the two rules above
1006
+ missed: a waiting reason is FREE TEXT, so "permission prompt: /Users/…/foo.ts" is a path
1007
+ with no space to break at. Wrapping inside the pill (below) breaks a sentence and does
1008
+ nothing for a path — at 320px an 84-character token laid the document out at 483px, 455 of
1009
+ them this cell, which is the scroll bar the fix beside it had just closed. */
1010
+ td[data-label="State"] .v { order:3; min-width:0; overflow-wrap:anywhere; }
1011
+ /* The reason a session is waiting is free text: "permission prompt" fits on a phone and a
1012
+ sentence does not. Held nowrap, the pill is one unbreakable item on that first line,
1013
+ which is the same scroll bar by the other road. It wraps inside its own border instead,
1014
+ and the border stops being a capsule once it has three lines to go round: 99px on a box
1015
+ that tall is an ellipse whose curve crosses the words. Under half a single line's height,
1016
+ the radius is still clamped to a capsule on one line and merely rounded on three. */
1017
+ .pill { white-space:normal; border-radius:.9rem; }
1018
+ /* Line two: the numbers, each wearing a name of its own. "65%" alone under a line of prompt
1019
+ reads as how much of the prompt is done, which is the mistake the map's strip already had
1020
+ to fix; "$20.79" and "Opus 5" say what they are without help. */
1021
+ td[data-label="Context"] .v, td[data-label="Model"] .v, td[data-label="Effort"] .v,
1022
+ td[data-label="Cost"] .v, td[data-label="Uptime"] .v { font-size:.82rem; }
1023
+ td[data-label="Context"] .v { order:5; font-variant-numeric:tabular-nums; font-weight:600; }
1024
+ td[data-label="Context"] .v::before { content:'ctx '; color:var(--dim); font-weight:400; }
1025
+ /* The weight above is for a percentage. A session with no reading renders this same cell as
1026
+ "— not chained", and in the number's weight a missing measurement reads like a
1027
+ measurement — heavier here than the same words are on the desktop table. */
1028
+ td[data-label="Context"] .v .dim { font-weight:400; }
1029
+ /* Same two rules again, and the same argument: a model and an effort are not tarmac's own
1030
+ words. They are model.display_name and the effort out of a statusline payload, copied
1031
+ through verbatim and capped nowhere — a 120-character model laid the document out at
1032
+ 814px. The three numbers below stay off this list, and NOT because of where they come
1033
+ from: a cost is copied out of that same payload and guarded even less (1e999 is legal
1034
+ JSON and reaches the cell as $Infinity, where a percentage that shape is refused). It is
1035
+ what they are PRINTED through that bounds them — a percentage clamped to 0..100 and
1036
+ floored, a duration, and a toFixed(2) that goes exponential long before it goes long.
1037
+ None can be a token wider than a phone, and a guard for that is prose that lies. */
1038
+ td[data-label="Model"] .v { order:6; min-width:0; overflow-wrap:anywhere; }
1039
+ td[data-label="Effort"] .v { order:7; color:var(--dim); min-width:0; overflow-wrap:anywhere; }
1040
+ td[data-label="Cost"] .v { order:8; font-variant-numeric:tabular-nums; }
1041
+ td[data-label="Uptime"] .v { order:9; color:var(--dim); font-variant-numeric:tabular-nums; }
1042
+ td[data-label="Model"] .v::before, td[data-label="Effort"] .v::before,
1043
+ td[data-label="Cost"] .v::before { content:'· '; color:var(--dim); font-weight:400; }
1044
+ td[data-label="Uptime"] .v::before { content:'· up '; color:var(--dim); font-weight:400; }
1045
+ /* A dash is not a value that wears its own name, and a session with no snapshot behind it
1046
+ has four of them at once: the percentage, the model, the effort and the cost all come out
1047
+ of one statusline frame, so they go missing together. That is not the corner case — it is
1048
+ every session until the status line has been chained and each one has drawn a frame, the
1049
+ state the page prints a warning about. As a strip it read "ctx — not chained · — · — · —",
1050
+ three anonymous dashes in a row, and the same happens one at a time for a session that
1051
+ reports no cost. Those three get their column word back; the other two already have one.
1052
+ The hook is the markup's own — a missing value is a .dim inside the cell's .v, and a
1053
+ present one never puts one there. */
1054
+ td[data-label="Model"] .v:has(.dim)::before { content:'· model '; }
1055
+ td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
1056
+ td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
796
1057
  .bar { display:none; }
797
1058
  }
798
1059
  </style>
@@ -850,6 +1111,9 @@ export function renderPage(fleet, view = 'table') {
850
1111
  <!-- A dead handle is worse than no handle: this is revealed once the record is in hand, and
851
1112
  what it says it covers is whatever the record answered with. -->
852
1113
  <div class="replay" id="replay" hidden>
1114
+ <!-- "Replay", and nothing about how much of the day it holds: the range is the record's to
1115
+ state, in the sentence below, which is built around never calling ten minutes a day. -->
1116
+ <span class="replay-name">Replay</span>
853
1117
  <button type="button" id="play">Play</button>
854
1118
  <input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
855
1119
  <div class="covers" id="covers"></div>
@@ -877,7 +1141,7 @@ export function renderPage(fleet, view = 'table') {
877
1141
  * The replay below is the one exception, and it is one the issue asks for: scrubbing a day
878
1142
  * has to be a lookup in samples the page already holds, or every pixel of a drag would be a
879
1143
  * request and a `claude agents --json` behind it. So a second, smaller renderer lives in the
880
- * browser — fed the same three words, the same three glyphs and the same dial geometry as the
1144
+ * browser — fed the same three words, the same four glyphs and the same dial geometry as the
881
1145
  * server's, by interpolation rather than by copy, and executed by `test/replay-script`.
882
1146
  */
883
1147
  export const REFRESH_MS = 5000;
@@ -912,6 +1176,22 @@ function pageScript(view) {
912
1176
  var off = document.getElementById('offline'), why = document.getElementById('why');
913
1177
  var limits = document.getElementById('limits');
914
1178
  var last = Date.now(), failing = false, inFlight = false, since = 0, gen = 0;
1179
+ // How many polls in a row have come back with nothing usable, and when the last of them was.
1180
+ // On a phone the page is read on a radio, and one dropped request is a tunnel rather than an
1181
+ // outage — the banner frames the table off and says the fleet cannot be read, which is the
1182
+ // wrong thing to shout five seconds before the next answer lands. It waits for the second
1183
+ // consecutive miss; the age upstairs keeps counting meanwhile, so nothing on the page is
1184
+ // claiming to be fresher than it is.
1185
+ //
1186
+ // Consecutive means in a row IN TIME, which is why the stamp is here. A count cleared only by
1187
+ // a successful poll is not the same rule: a hidden tab issues no polls, so a miss from before
1188
+ // the reader locked their phone sat there for an hour, and the wake-up poll — the likeliest
1189
+ // miss of the session, fired while the radio is still reassociating — found it and raised the
1190
+ // banner over one dropped request. A miss further back than a few poll intervals starts the
1191
+ // count again. The window is bounded at both ends and neither end is arbitrary: below one
1192
+ // poll interval two real misses in a row would never meet, and above five a locked phone
1193
+ // comes back to a miss from minutes ago being called consecutive with this one.
1194
+ var misses = 0, missAt = 0, MISSES_BEFORE_BANNER = 2, MISS_WINDOW_MS = 3 * ${REFRESH_MS};
915
1195
 
916
1196
  function ago(ms) {
917
1197
  // A clock that steps backwards (an NTP correction, a laptop waking) must not produce
@@ -922,8 +1202,18 @@ function pageScript(view) {
922
1202
  return m < 60 ? m + 'm' : Math.round(m / 60) + 'h';
923
1203
  }
924
1204
 
1205
+ // Called for the one failure that is NOT a missed poll: a request the server accepted and
1206
+ // never answered. Twenty seconds of silence from a live connection is not a dropped packet,
1207
+ // so it says so at once, without the second miss the count is there to wait for. It does not
1208
+ // touch the count: a miss cannot take this banner back down, because a miss never assigns the
1209
+ // failing flag anything but true, and only an ANSWER puts it back to false.
925
1210
  function fail(why_) {
926
1211
  failing = true;
1212
+ // Retired, not merely dropped. Clearing the in-flight flag without moving the generation
1213
+ // left the abandoned request still ours, so the answer that arrived twenty seconds later was
1214
+ // swapped in and stamped "updated 0s ago" — the freshest label on the page over a fleet read
1215
+ // before the stall was declared. The manual has always said such an answer is discarded.
1216
+ gen += 1;
927
1217
  why.textContent = why_;
928
1218
  off.hidden = false;
929
1219
  document.body.classList.toggle('failing', true);
@@ -983,10 +1273,20 @@ function pageScript(view) {
983
1273
  if (src) limits.innerHTML = src.innerHTML;
984
1274
  last = Date.now();
985
1275
  failing = false;
1276
+ // Consecutive, not cumulative: two blips an hour apart are two blips, and a count that
1277
+ // never went back to zero would turn the second one into a permanent banner.
1278
+ misses = 0;
986
1279
  });
987
1280
  }).catch(function (e) {
988
1281
  if (!mineStill()) return;
989
- failing = true;
1282
+ if (Date.now() - missAt > MISS_WINDOW_MS) misses = 0;
1283
+ misses += 1;
1284
+ missAt = Date.now();
1285
+ // Raised here, never lowered here. Only an ANSWER says the server came back, so this
1286
+ // assigns true or nothing at all — derived both ways, the window that starts a fresh count
1287
+ // also cleared the alarm, and a reader who locked their phone for ten minutes while the
1288
+ // server was down unlocked onto a green page over a fleet nobody could read.
1289
+ if (misses >= MISSES_BEFORE_BANNER) failing = true;
990
1290
  why.textContent = String((e && e.message) || e).slice(0, 200);
991
1291
  }).then(function () {
992
1292
  // Not ours to unlock: a request we were given up on must not clear a flag that a newer
@@ -1014,7 +1314,7 @@ function pageScript(view) {
1014
1314
  var record = null, recordAt = 0, at = -1, replaying = false, playing = null, hgen = 0;
1015
1315
 
1016
1316
  // The vocabulary and the geometry, handed over rather than written twice: three words for
1017
- // the three kinds of missing, three glyphs for the three states, one dial radius.
1317
+ // the three kinds of missing, four glyphs for the four states, one dial radius.
1018
1318
  var WHY = ${JSON.stringify(CTX_WHY)}, SHAPE = ${JSON.stringify(SHAPE)};
1019
1319
  var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
1020
1320
  var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
@@ -1033,12 +1333,20 @@ function pageScript(view) {
1033
1333
  }
1034
1334
 
1035
1335
  function two(n) { return (n < 10 ? '0' : '') + n; }
1036
- function hhmm(t) { var d = new Date(t); return two(d.getHours()) + ':' + two(d.getMinutes()); }
1336
+ // One page, one clock, and it is the one the summary line already dates the fleet on: an ISO
1337
+ // instant in UTC. Spelled on the machine's own clock, these minutes and that stamp are the
1338
+ // same fleet apparently living in two time zones, the first time anything shows both — an
1339
+ // export, a log line, a screenshot with the header in it. Every minute below carries UTC
1340
+ // where a reader meets it, because an unlabelled one reads as the reader's own clock and is
1341
+ // the same lie facing the other way.
1342
+ function hhmm(t) { var d = new Date(t); return two(d.getUTCHours()) + ':' + two(d.getUTCMinutes()); }
1037
1343
 
1038
1344
  // A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
1039
- // backwards until the older edge says which day it is.
1345
+ // backwards until the older edge says which day it is. Which midnight is UTC's, like the
1346
+ // minutes it separates: counted otherwise, "yesterday" turns up between two minutes of one
1347
+ // UTC day and goes missing across the midnight it exists for.
1040
1348
  function edge(t, ref) {
1041
- return hhmm(t) + (new Date(t).getDate() === new Date(ref).getDate() ? '' : ' yesterday');
1349
+ return hhmm(t) + (new Date(t).getUTCDate() === new Date(ref).getUTCDate() ? '' : ' yesterday');
1042
1350
  }
1043
1351
 
1044
1352
  // What the range covers, in the record's own terms. Never "a day": that is the size of the
@@ -1050,13 +1358,13 @@ function pageScript(view) {
1050
1358
  // this was the one branch that threw that away: ten hours of a collector that could not
1051
1359
  // run read exactly like a serve thirty seconds old.
1052
1360
  return record.missed
1053
- ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
1361
+ ? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' UTC and '
1054
1362
  + record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
1055
- : 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
1363
+ : 'Nothing recorded yet — this serve started at ' + hhmm(record.since) + ' UTC'
1056
1364
  + ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
1057
1365
  }
1058
1366
  var last = record.samples[n - 1].t;
1059
- return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
1367
+ return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last) + ' UTC'
1060
1368
  // Not "when the page loaded": the record is asked for again when a tab that has been
1061
1369
  // away comes back, so the sentence names the last time this page asked rather than a
1062
1370
  // moment it may be hours past.
@@ -1291,8 +1599,9 @@ function pageScript(view) {
1291
1599
  scrub.value = String(i);
1292
1600
  // The handle's own value is an index, so a reader who cannot see the banner would be read
1293
1601
  // "3" while the fleet on screen is three hours old. The minute travels with the handle.
1294
- scrub.setAttribute('aria-valuetext', hhmm(s.t));
1295
- atEl.textContent = hhmm(s.t);
1602
+ var minute = hhmm(s.t) + ' UTC';
1603
+ scrub.setAttribute('aria-valuetext', minute);
1604
+ atEl.textContent = minute;
1296
1605
  rmeta.textContent = metaOf(s);
1297
1606
  rmap.innerHTML = nodesOf(s);
1298
1607
  // The account of that minute, in the place the live pair occupies — which the body class
@@ -1379,11 +1688,13 @@ function pageScript(view) {
1379
1688
  function renderRow(r) {
1380
1689
  const state = stateOf(r);
1381
1690
  const word = stateLabel(state, r);
1382
- // `data-label` is not decoration: below ~46rem the columns stack, the header row is gone,
1383
- // and a value whose column has no name is a bare "—" that could mean anything.
1384
- // Every cell holds exactly ONE element. Stacked on a phone the label sits left and the
1385
- // value right, and two sibling nodes in one cell get pushed to opposite ends of the card
1386
- // which is how "63%" once ended up stranded in the middle of a row, under the wrong label.
1691
+ // `data-label` is not decoration, and no longer only for the reason it was: the header row is
1692
+ // gone below ~46rem, and what the attribute does there is CARRY THE STRIP. The per-column
1693
+ // rules select `td[data-label="…"] .v` for their `order`, for their wrapping, and for the five
1694
+ // `::before` prefixes that are the only column words left at that width `ctx `, up `, and
1695
+ // model `/`· effort `/`· cost ` for a value that is a bare dash. One element per cell, since
1696
+ // `td` is `display:contents` there: the `.v` IS the row's flex item, and a second sibling in
1697
+ // one cell would be a second item placed on an `order` of its own.
1387
1698
  return `<tr data-state="${state}">
1388
1699
  <td data-label="Project" class="project"><span class="v">${esc(r.project)}</span></td>
1389
1700
  <td data-label="Session" class="dim"><span class="v">${esc(r.name)}</span></td>