@adrrr/tarmac 0.8.1 → 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/README.md +27 -9
- package/dist/args.js +26 -8
- package/dist/cli.js +65 -11
- package/dist/collect.js +2 -1
- package/dist/demo-history.js +244 -0
- package/dist/demo.js +392 -0
- package/dist/fleet.js +14 -4
- package/dist/history-range.js +56 -9
- package/dist/history-store.js +12 -2
- package/dist/history-view.js +317 -63
- package/dist/map.js +6 -22
- package/dist/render.js +633 -114
- package/dist/schema.js +12 -3
- package/dist/server.js +83 -22
- package/dist/sessions.js +33 -2
- package/dist/snapshots.js +27 -11
- package/dist/wrapper.js +27 -10
- package/package.json +1 -1
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,
|
|
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
|
/**
|
|
@@ -192,20 +193,28 @@ export function renderTable({ rows, health }) {
|
|
|
192
193
|
warns.push(`! ${health.snapshotsUnreadable} snapshot file(s) present but unreadable — schema may have moved, check for a newer tarmac`);
|
|
193
194
|
if ((health.snapshotsDuplicates ?? 0) > 0)
|
|
194
195
|
warns.push(`! ${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest was kept`);
|
|
196
|
+
// Not folded into the line above: a name that is not a file is a directory somebody put
|
|
197
|
+
// something in, and sending them to look for a newer tarmac would be advice for the schema.
|
|
198
|
+
if ((health.snapshotsNotFiles ?? 0) > 0)
|
|
199
|
+
warns.push(`! ${health.snapshotsNotFiles} name(s) in the snapshot directory are not regular files — stepped over unread, never opened`);
|
|
195
200
|
// Covers both "not allowed to look" and "there is nothing there to look at", so the words
|
|
196
201
|
// have to fit an errno as well as a path that points nowhere.
|
|
197
202
|
if (health.snapshotsError)
|
|
198
203
|
warns.push(`! snapshots unavailable — ${health.snapshotsError}`);
|
|
199
204
|
else if (health.schemaBroken)
|
|
200
205
|
warns.push('! every snapshot drifted — the statusline payload schema moved');
|
|
201
|
-
else if (health.covered < health.
|
|
202
|
-
warns.push(
|
|
206
|
+
else if (health.covered < health.chainable) {
|
|
203
207
|
// The count travels, for the same reason `unreadable` does one line up: without it
|
|
204
208
|
// this line reads as "run install", and for a session id the wrapper declines to file
|
|
205
|
-
// that is advice already taken which can never work.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|
|
209
218
|
if (health.stale > 0)
|
|
210
219
|
warns.push(`! ${health.stale} reading(s) marked "!" are older than ${formatDuration(health.staleAfterMs)} (--stale-after)`);
|
|
211
220
|
const skewed = rows.filter(ahead).length;
|
|
@@ -365,6 +374,9 @@ export function renderLive(fleet) {
|
|
|
365
374
|
if ((health.snapshotsDuplicates ?? 0) > 0) {
|
|
366
375
|
warnings.push(`${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest reading was kept and the other ignored. Two wrappers may be writing into the same directory.`);
|
|
367
376
|
}
|
|
377
|
+
if ((health.snapshotsNotFiles ?? 0) > 0) {
|
|
378
|
+
warnings.push(`${health.snapshotsNotFiles} name(s) in the snapshot directory are not regular files — a directory, a link or a named pipe wearing a snapshot's name is stepped over rather than opened, because reading one can wait for ever. Whatever else is there was read as usual.`);
|
|
379
|
+
}
|
|
368
380
|
if (health.snapshotsError) {
|
|
369
381
|
// A permission error is ours to report, not the user's to be blamed for.
|
|
370
382
|
warnings.push(`The snapshot directory could not be used — ${health.snapshotsError}. Context readings are unavailable, and this is not an install problem.`);
|
|
@@ -372,13 +384,18 @@ export function renderLive(fleet) {
|
|
|
372
384
|
else if (health.schemaBroken) {
|
|
373
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.`);
|
|
374
386
|
}
|
|
375
|
-
else if (health.covered < health.
|
|
376
|
-
const blind = health.
|
|
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)` : ''}`;
|
|
377
394
|
warnings.push(health.unfilable === 0
|
|
378
|
-
? `Statusline chained on ${
|
|
395
|
+
? `Statusline chained on ${counted} — the rest report no context. Run \`tarmac install\` and give them one TUI frame.`
|
|
379
396
|
: health.unfilable >= blind
|
|
380
|
-
? `Statusline chained on ${
|
|
381
|
-
: `Statusline chained on ${
|
|
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.`);
|
|
382
399
|
}
|
|
383
400
|
if (health.unknownStatus > 0) {
|
|
384
401
|
warnings.push(`${health.unknownStatus} session(s) report a status tarmac does not know — treated as unknown, not idle.`);
|
|
@@ -403,14 +420,25 @@ export function renderLive(fleet) {
|
|
|
403
420
|
// banner above could not take the number with it. The second is a maintainer's line: it
|
|
404
421
|
// stands for every user of a released tarmac until the next release ships the fixture, so
|
|
405
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.
|
|
406
426
|
const notes = [];
|
|
407
427
|
// Not under the stall banner, which names the same threshold two lines up: the pair reads as
|
|
408
428
|
// the alarm followed by its own excuse, and the excuse is the reading the alarm exists to
|
|
409
429
|
// tell you not to accept.
|
|
410
430
|
if (health.stale > 0 && stalled === 0) {
|
|
411
|
-
notes.push(
|
|
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
|
+
});
|
|
412
440
|
}
|
|
413
|
-
const schema =
|
|
441
|
+
const schema = schemaNoteParts(health.schemaGuard);
|
|
414
442
|
if (schema)
|
|
415
443
|
notes.push(schema);
|
|
416
444
|
// Both views, every time, out of the one reading the page just asked for. The tabs are
|
|
@@ -438,7 +466,24 @@ export function renderLive(fleet) {
|
|
|
438
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>
|
|
439
467
|
${warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('')}
|
|
440
468
|
${body}
|
|
441
|
-
<div id="fleet-notes">${notes.map(
|
|
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>`;
|
|
442
487
|
}
|
|
443
488
|
/**
|
|
444
489
|
* The account's two windows, for the page's header.
|
|
@@ -576,7 +621,7 @@ function ago(ms) {
|
|
|
576
621
|
const m = Math.round(s / 60);
|
|
577
622
|
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
|
578
623
|
}
|
|
579
|
-
export function renderPage(fleet, view = 'table', { historyEnabled = false } = {}) {
|
|
624
|
+
export function renderPage(fleet, view = 'table', { historyEnabled = false, demo = false } = {}) {
|
|
580
625
|
// The header's copy. `renderLive` below renders its own, out of this same fleet and through
|
|
581
626
|
// this same function — two calls of one pure renderer over one reading, which is what keeps
|
|
582
627
|
// the pair the reader sees and the pair the script will copy up from being two accounts.
|
|
@@ -589,36 +634,169 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {
|
|
|
589
634
|
<style>
|
|
590
635
|
/* --wait is a fourth hue rather than the warning one: a session blocked on a human is not
|
|
591
636
|
a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
|
|
592
|
-
|
|
593
|
-
|
|
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; } }
|
|
594
675
|
/* Eight categorical hues for the history view, kept apart from the four above: those four
|
|
595
676
|
say what a session is DOING, and a chart that borrowed one would be colouring a project
|
|
596
677
|
with the word for busy. */${HISTORY_PALETTE}
|
|
597
|
-
|
|
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);
|
|
598
686
|
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
|
|
599
|
-
header
|
|
600
|
-
|
|
601
|
-
|
|
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; }
|
|
698
|
+
.meta { color:var(--dim); font-size:.85rem; }${demo
|
|
699
|
+
? `
|
|
700
|
+
/* The demo marker. It borrows the warning's hues rather than the dim chrome the tabs use:
|
|
701
|
+
what it says is not chrome, and a badge a reader's eye files with the furniture is a badge
|
|
702
|
+
that is not in the screenshot as far as anybody looking at the screenshot is concerned.
|
|
703
|
+
Shipped only on a demo, so a plain serve is byte-for-byte the page it always was. */
|
|
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;
|
|
708
|
+
padding:.05rem .55rem; font-size:.75rem; font-weight:600; letter-spacing:.02em; white-space:nowrap; }`
|
|
709
|
+
: ''}
|
|
602
710
|
/* Honest, and out of the way of the fleet: three of these stacked at full padding pushed
|
|
603
711
|
the table below the fold on a laptop, which is its own kind of hidden. */
|
|
604
|
-
.
|
|
605
|
-
|
|
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); }
|
|
606
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; }
|
|
607
745
|
/* The footnote: same words, none of the weight. Dim, small, below the fleet and with no box
|
|
608
746
|
around it, because what it carries is true rather than urgent — the threshold that dated a
|
|
609
747
|
reading, the payload shapes nobody has captured yet. It reads as chrome to someone
|
|
610
748
|
scanning their sessions and as an answer to someone who came looking for it. */
|
|
611
|
-
|
|
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; }
|
|
612
770
|
/* Two marks, one weight: a reading that has gone cold, and a reading picked out of several
|
|
613
771
|
that were not about the same window. Both say the number beside them may not be what the
|
|
614
772
|
reader takes it for, so neither may end up quieter than the other. */
|
|
615
773
|
.stale, .mixed { color:var(--warn); font-weight:600; }
|
|
616
|
-
.
|
|
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); }
|
|
617
778
|
table { border-collapse:collapse; width:100%; min-width:44rem; }
|
|
618
|
-
|
|
619
|
-
|
|
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; }
|
|
620
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); } }
|
|
621
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; }
|
|
622
800
|
.dim { color:var(--dim); }
|
|
623
801
|
/* Shape + word + border, so the state survives a reader who cannot tell our two hues
|
|
624
802
|
apart, and a print. */
|
|
@@ -642,6 +820,10 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {
|
|
|
642
820
|
.bar > i { display:block; height:100%; border-radius:99px; background:var(--dim); }
|
|
643
821
|
.empty { color:var(--dim); }
|
|
644
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; }
|
|
645
827
|
.pulse { display:inline-block; width:.4rem; height:.4rem; border-radius:99px; background:var(--busy); margin-right:.4rem; vertical-align:middle; }
|
|
646
828
|
/* The failing state is carried by the banner's words; the dashed frame only repeats it. */
|
|
647
829
|
body.failing .pulse { background:var(--warn); }
|
|
@@ -649,10 +831,15 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {
|
|
|
649
831
|
.offline strong { white-space:nowrap; }
|
|
650
832
|
/* The tabs, and what they hide. The shell owns the choice — not the fragment — so a poll
|
|
651
833
|
that swaps the fleet underneath cannot put the reader back on a view they left. */
|
|
652
|
-
|
|
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; }
|
|
653
840
|
nav a { color:var(--dim); text-decoration:none; font-size:.8rem; font-weight:600; text-transform:uppercase;
|
|
654
|
-
letter-spacing:.06em; padding:.
|
|
655
|
-
nav a[aria-current="page"] { color:var(--fg);
|
|
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); }
|
|
656
843
|
/* A finger is not a cursor. Every control on this page is a pill sized for a pointer that
|
|
657
844
|
lands on a single pixel — about 26px of box against the 44 a thumb is asked to hit — and
|
|
658
845
|
the fix cannot be more padding: that would redraw the page for everyone to solve a problem
|
|
@@ -670,9 +857,15 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {
|
|
|
670
857
|
Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
|
|
671
858
|
the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
|
|
672
859
|
Map because Map's pseudo paints later. */
|
|
673
|
-
nav a, .replay button, .replaying-note button, .hist-range button, .to-now, .key
|
|
860
|
+
nav a, .replay button, .replaying-note button, .hist-range button, .to-now, .key,
|
|
861
|
+
.note summary { position:relative; }
|
|
674
862
|
@media (pointer: coarse) {
|
|
675
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; }
|
|
676
869
|
.replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }${HISTORY_TOUCH_CSS} }
|
|
677
870
|
body[data-view="table"] .view-map { display:none; }
|
|
678
871
|
body[data-view="map"] .view-table { display:none; }
|
|
@@ -683,12 +876,17 @@ ${HISTORY_CSS}
|
|
|
683
876
|
the fleet is what the page is about, and these two numbers are the weather it flies in.
|
|
684
877
|
Laid out with flex behind the same :not([hidden]) guard the replay containers carry —
|
|
685
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. */
|
|
686
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); }
|
|
687
885
|
.gauge { display:flex; align-items:baseline; gap:.35rem; font-size:.8rem; }
|
|
688
886
|
/* Not upper-cased, alone among the small labels on this page: "5H" is not an hour, and a
|
|
689
887
|
unit that has been shouted reads as a different unit. */
|
|
690
888
|
.gauge .lbl { color:var(--dim); font-weight:600; letter-spacing:.04em; }
|
|
691
|
-
.gauge .num { font-variant-numeric:tabular-nums; font-weight:
|
|
889
|
+
.gauge .num { font-variant-numeric:tabular-nums; font-weight:600; }
|
|
692
890
|
.gauge .reset { color:var(--dim); }
|
|
693
891
|
/* Same bargain as the row bars: a glance at a magnitude, in the quiet ink of a secondary
|
|
694
892
|
fact, beside the number that is the authority. Its own class rather than .bar — that one
|
|
@@ -731,21 +929,79 @@ ${HISTORY_CSS}
|
|
|
731
929
|
label for the button rather than for the pair, and take width from the slider to do it. */
|
|
732
930
|
.replay .replay-name { flex-basis:100%; font-size:.7rem; font-weight:700; letter-spacing:.07em;
|
|
733
931
|
text-transform:uppercase; color:var(--dim); }
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
.replay
|
|
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; }
|
|
738
983
|
/* The two things the reader has to be able to read while dragging: the minute under the
|
|
739
984
|
handle, and what the whole range covers. Tabular, so neither jitters as it counts. */
|
|
740
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. */
|
|
741
990
|
.replay .covers { flex-basis:100%; color:var(--dim); font-size:.75rem; }
|
|
991
|
+
.replay .covers[title] { cursor:help; }
|
|
742
992
|
/* The banner wears the warning style on purpose: a page showing a past minute as though it were the
|
|
743
993
|
fleet is the worst thing this dashboard could do, so it wears the loudest thing it has. */
|
|
744
994
|
/* Sticky, because the handle is at the bottom of a map that can be taller than the
|
|
745
995
|
viewport: a reader dragging with the "this is the past" banner scrolled off the top is
|
|
746
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. */
|
|
747
1001
|
.replaying-note:not([hidden]) { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap;
|
|
748
|
-
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); }
|
|
749
1005
|
.replaying-note button { font:inherit; font-size:.75rem; font-weight:600; color:inherit;
|
|
750
1006
|
background:transparent; border:1px solid currentColor; border-radius:99px;
|
|
751
1007
|
padding:.05rem .7rem; cursor:pointer; }
|
|
@@ -764,7 +1020,28 @@ ${HISTORY_CSS}
|
|
|
764
1020
|
flat grid this view was before, which is the honest drawing of what it holds. */
|
|
765
1021
|
.map { gap:.9rem; }
|
|
766
1022
|
.map.berths { display:flex; flex-wrap:wrap; align-items:flex-start; }
|
|
767
|
-
|
|
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; }
|
|
768
1045
|
/* The berth: a frame around the nodes read in one directory, and the label is the whole of
|
|
769
1046
|
what it claims. Quiet on purpose — a hairline and a caption in the grey the rest of the
|
|
770
1047
|
page uses for a heading, because the loud thing on this view is a session's state, and a
|
|
@@ -781,7 +1058,13 @@ ${HISTORY_CSS}
|
|
|
781
1058
|
letter-spacing:.06em; color:var(--dim); }
|
|
782
1059
|
/* The cards side by side at their own width, wrapping inside the frame when the directory
|
|
783
1060
|
holds more of them than the row can take. */
|
|
784
|
-
.
|
|
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; }
|
|
785
1068
|
.berth-cards .node { width:10.5rem; }
|
|
786
1069
|
/* And the strips docked underneath, full width of the frame, one under the other: a strip is
|
|
787
1070
|
a line of text, and a line of text in a column half a card wide is an ellipsis where the
|
|
@@ -789,33 +1072,69 @@ ${HISTORY_CSS}
|
|
|
789
1072
|
directory's background work, under the terminals someone is sitting at — and NOT because
|
|
790
1073
|
one of those terminals dispatched it, which nothing here knows. */
|
|
791
1074
|
.berth-strips { display:flex; flex-direction:column; gap:.4rem; margin-top:.6rem; }
|
|
792
|
-
|
|
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;
|
|
793
1081
|
display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
794
|
-
|
|
795
|
-
|
|
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)); }
|
|
796
1089
|
/* An agent is not a smaller session — it is a strip. It was a card at three quarters scale,
|
|
797
1090
|
which put a dial on a session that has no terminal to draw a statusline frame with: a ring
|
|
798
1091
|
that can never fill, captioned with the words of a fault someone could go and repair. The
|
|
799
1092
|
honest form is the one the table already speaks in — text on a line, left-aligned, its
|
|
800
1093
|
state in the same glyph and in a three-pixel accent down the left edge. */
|
|
801
|
-
/*
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
+
row — and 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);
|
|
811
1104
|
/* The box goes back to the neutral line the tinted rule above gave it: the accent is
|
|
812
1105
|
the channel that carries state here, and a strip outlined in its hue as well was
|
|
813
1106
|
the same fact said twice, in two weights, on a shape half the size of a card. */
|
|
814
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; }
|
|
815
1114
|
.node[data-role="agent"][data-state="busy"] { border-left-color:var(--busy); }
|
|
816
1115
|
.node[data-role="agent"][data-state="waiting"] { border-left-color:var(--wait); }
|
|
817
1116
|
.node[data-role="agent"][data-state="unknown"] { border-left-color:var(--warn); }
|
|
818
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; }
|
|
819
1138
|
/* A strip's project, which since the berths is the REPLAY's business alone: a live strip
|
|
820
1139
|
prints none — the frame around it says the directory — and behind the scrubber there is no
|
|
821
1140
|
frame, and the project is the only name the ring kept. The rule stayed when the markup
|
|
@@ -828,6 +1147,16 @@ ${HISTORY_CSS}
|
|
|
828
1147
|
want the day it grows one, and unprefixed they would take it. */
|
|
829
1148
|
.node .kind { margin-left:auto; font-size:.6rem; font-weight:700; text-transform:uppercase;
|
|
830
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); }
|
|
831
1160
|
/* The prompt a background session was named after — the strip's own line, now that the berth
|
|
832
1161
|
around it carries the directory. One line, clipped: it is a sentence somebody typed, and
|
|
833
1162
|
it is the only thing on the strip that has no length limit. */
|
|
@@ -836,24 +1165,29 @@ ${HISTORY_CSS}
|
|
|
836
1165
|
/* Said, not shown: the four glyphs differ in silhouette, so a reader who cannot separate
|
|
837
1166
|
two hues still has the state — but a screen reader is handed a bullet and nothing else. */
|
|
838
1167
|
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
|
839
|
-
.
|
|
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; }
|
|
840
1172
|
.dial svg { width:100%; height:100%; display:block; overflow:visible; }
|
|
841
|
-
|
|
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; }
|
|
842
1176
|
/* Butt caps, not round: a rounded cap adds half a stroke width at each end, which draws a
|
|
843
1177
|
1% reading at three times its extent. The prettier cap overstates every small number. */
|
|
844
|
-
.arc { fill:none; stroke:var(--dim); stroke-width:5; }
|
|
1178
|
+
.arc { fill:none; stroke:var(--dim); stroke-width:6.5; }
|
|
845
1179
|
.node[data-state="busy"] .arc { stroke:var(--busy); }
|
|
846
1180
|
/* A reading past the freshness threshold is drawn as what it is: thin, faded, and in the
|
|
847
1181
|
warning hue — never the solid arc of a live one. Its EXTENT stays true, because the
|
|
848
1182
|
number is still the truth of an earlier moment; and a dash pattern here would overwrite
|
|
849
1183
|
stroke-dasharray, which is what carries the percentage. */
|
|
850
1184
|
.node[data-reading="stale"] .arc, .node[data-reading="undated"] .arc {
|
|
851
|
-
stroke:var(--warn); stroke-width:
|
|
1185
|
+
stroke:var(--warn); stroke-width:3.5; opacity:.7; }
|
|
852
1186
|
/* A replayed reading. Its EXTENT is what the record vouches for; its age is the one thing
|
|
853
1187
|
the ring never kept, so it may not wear the solid arc that means "as current as a reading
|
|
854
1188
|
gets" — nor the warning hue of a stale one, which would claim the opposite. Between the
|
|
855
1189
|
two: full colour, a shade lighter, and no date underneath it. */
|
|
856
|
-
.node[data-reading="undatable"] .arc { stroke-width:
|
|
1190
|
+
.node[data-reading="undatable"] .arc { stroke-width:5.5; opacity:.85; }
|
|
857
1191
|
/* Nothing was measured. Keyed on the measurement and never on the age of the file: a
|
|
858
1192
|
solid empty ring is what a session measured at 0% wears, and the two must not match. */
|
|
859
1193
|
.track.unmeasured { stroke-dasharray:2 6; stroke-linecap:round; }
|
|
@@ -879,8 +1213,11 @@ ${HISTORY_CSS}
|
|
|
879
1213
|
@media (prefers-reduced-motion: reduce) { .halo { animation:none; opacity:.35; transform:scale(1.18); } }
|
|
880
1214
|
.val { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
|
881
1215
|
font-variant-numeric:tabular-nums; }
|
|
882
|
-
|
|
883
|
-
|
|
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); }
|
|
884
1221
|
.why { font-size:.68rem; color:var(--dim); line-height:1.2; max-width:4.4rem; }
|
|
885
1222
|
.why b { display:block; font-size:1.25rem; font-weight:400; }
|
|
886
1223
|
.who { margin-top:.5rem; display:flex; align-items:baseline; gap:.3rem; max-width:100%; }
|
|
@@ -900,6 +1237,9 @@ ${HISTORY_CSS}
|
|
|
900
1237
|
.sub.waiting-for { color:var(--wait); font-weight:600; }
|
|
901
1238
|
.asof { font-size:.72rem; color:var(--dim); font-variant-numeric:tabular-nums; margin-top:.15rem; }
|
|
902
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. */
|
|
903
1243
|
@media (max-width: 30rem) { .map.flat { grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr)); } .map { gap:.6rem; } }
|
|
904
1244
|
|
|
905
1245
|
/* Below this the table stops being a table. What replaces it is the strip described down at
|
|
@@ -908,7 +1248,38 @@ ${HISTORY_CSS}
|
|
|
908
1248
|
as nothing at all — and the labels that go are the ones whose value says what it is on its
|
|
909
1249
|
own, with the exceptions named where they are given a word back. */
|
|
910
1250
|
@media (max-width: 46rem) {
|
|
911
|
-
body { padding
|
|
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; }
|
|
912
1283
|
/* The summary's ISO stamp, spent. It is the widest thing on that line and the header two
|
|
913
1284
|
lines above already says the same fact in the words a reader uses — "updated 3s ago",
|
|
914
1285
|
counted by the shell whether or not a poll ever lands. Hidden rather than dropped: the
|
|
@@ -923,17 +1294,21 @@ ${HISTORY_CSS}
|
|
|
923
1294
|
them. The negative margin gives it the page's own gutters back, so the bar reaches the
|
|
924
1295
|
edges of the phone and the rule above it reads as an edge rather than a floating line.
|
|
925
1296
|
|
|
926
|
-
The
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
range — nothing replayed here is dated, and the past is drawn ungrouped — and they
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
reader is staring at one
|
|
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. */
|
|
933
1305
|
body.replaying .replay:not([hidden]) { position:sticky; bottom:0; z-index:3;
|
|
934
1306
|
background:var(--bg); border-top:1px solid var(--line);
|
|
935
1307
|
padding:.55rem .75rem .8rem; margin:1rem -.75rem 0; }
|
|
936
|
-
.
|
|
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; }
|
|
937
1312
|
table, tbody { display:block; }
|
|
938
1313
|
table { min-width:0; }
|
|
939
1314
|
thead { display:none; }
|
|
@@ -950,8 +1325,12 @@ ${HISTORY_CSS}
|
|
|
950
1325
|
The cell steps out of the layout entirely, with display:contents, so the row is the flex
|
|
951
1326
|
container and every VALUE is one of its items. Anything else puts a box between the row
|
|
952
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. */
|
|
953
1331
|
tr { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.4rem; row-gap:.05rem;
|
|
954
|
-
|
|
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);
|
|
955
1334
|
padding:.5rem .75rem .55rem; margin-bottom:.55rem; }
|
|
956
1335
|
/* white-space on the CELL, and not on the row: the desktop rule being undone is
|
|
957
1336
|
td { white-space:nowrap }, and an explicit declaration on the cell beats anything the row
|
|
@@ -987,11 +1366,6 @@ ${HISTORY_CSS}
|
|
|
987
1366
|
background session named after its prompt, a name with no length limit. Not the exotic
|
|
988
1367
|
case: when nothing in the fleet calls itself interactive, every row is drawn as a card. */
|
|
989
1368
|
.berth-cards .node { flex:1 1 8.5rem; width:auto; min-width:0; }
|
|
990
|
-
/* A strip sharing a phone's width with a card is an ellipsis where the prompt was — the
|
|
991
|
-
one line saying what this agent was told to do is the first thing a narrow column takes
|
|
992
|
-
away. It spans the row instead, like the cells below it. The berth docks its own strips
|
|
993
|
-
full width at every size, so what this rule is left covering is the REPLAY's flat grid. */
|
|
994
|
-
.node[data-role="agent"] { grid-column:1 / -1; }
|
|
995
1369
|
/* Line one: who, and in what state. The project leads and carries the weight; the session
|
|
996
1370
|
name travels beside it in the page's grey. That order is deliberate and it is a red line
|
|
997
1371
|
— a background session is NAMED AFTER ITS PROMPT, and a prompt set as the heading of a
|
|
@@ -1072,12 +1446,22 @@ ${HISTORY_CSS}
|
|
|
1072
1446
|
td[data-label="Model"] .v:has(.dim)::before { content:'· model '; }
|
|
1073
1447
|
td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
|
|
1074
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. */
|
|
1075
1453
|
.bar { display:none; }
|
|
1076
1454
|
${HISTORY_PHONE_CSS} }
|
|
1077
1455
|
</style>
|
|
1078
1456
|
</head><body data-view="${view}">
|
|
1079
1457
|
<header>
|
|
1080
|
-
<h1>tarmac</h1
|
|
1458
|
+
<h1>tarmac</h1>${demo
|
|
1459
|
+
? `
|
|
1460
|
+
<!-- Beside the title, in the shell, and never quiet. The one thing a screenshot of this page
|
|
1461
|
+
must not be able to do is pass for a real fleet, and the reader who can be misled is not
|
|
1462
|
+
the one running the serve — it is whoever is sent the picture afterwards. -->
|
|
1463
|
+
<span class="demo-tag" role="status">demo data — an invented fleet</span>`
|
|
1464
|
+
: ''}
|
|
1081
1465
|
<!-- Links, not buttons: the view survives a reload, a bookmark and a browser with
|
|
1082
1466
|
JavaScript off — the state of a page whose own noscript banner promises it is still
|
|
1083
1467
|
readable. Both views are in the fragment below either way, so switching costs the
|
|
@@ -1087,6 +1471,11 @@ ${HISTORY_PHONE_CSS} }
|
|
|
1087
1471
|
<a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
|
|
1088
1472
|
<a href="/history"${view === 'history' ? ' aria-current="page"' : ''}>History</a>
|
|
1089
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>
|
|
1090
1479
|
<!-- The account's two windows, page-level because that is what they are: a limit belongs to
|
|
1091
1480
|
the account every session below is spending from, not to any one of them. Their VALUES
|
|
1092
1481
|
come up from the fragment on every poll (the script's limits-src copy), so the header
|
|
@@ -1102,6 +1491,22 @@ ${HISTORY_PHONE_CSS} }
|
|
|
1102
1491
|
<strong>⚠ refresh failing</strong> — nothing on this page has moved since the time in the header.
|
|
1103
1492
|
<span id="why"></span>
|
|
1104
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>● live</strong>
|
|
1508
|
+
<span>— the fleet as the header dates it.</span>
|
|
1509
|
+
</div>
|
|
1105
1510
|
<!-- The one claim on this page that could be a lie, so it is the loudest element on it and it
|
|
1106
1511
|
carries the minute it is showing. Hidden until a script raises it: with no script there
|
|
1107
1512
|
is no replay, and a banner about one would be a warning about nothing. -->
|
|
@@ -1133,11 +1538,17 @@ ${HISTORY_PHONE_CSS} }
|
|
|
1133
1538
|
<!-- "Replay", and nothing about how much of the day it holds: the range is the record's to
|
|
1134
1539
|
state, in the sentence below, which is built around never calling ten minutes a day. -->
|
|
1135
1540
|
<span class="replay-name">Replay</span>
|
|
1136
|
-
|
|
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>
|
|
1137
1544
|
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
1138
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>
|
|
1139
1550
|
</div>
|
|
1140
|
-
${view === 'history' ? renderHistoryView({ historyEnabled }) : ''}
|
|
1551
|
+
${view === 'history' ? renderHistoryView({ historyEnabled, demo }) : ''}
|
|
1141
1552
|
<script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
|
|
1142
1553
|
</body></html>
|
|
1143
1554
|
`;
|
|
@@ -1196,6 +1607,28 @@ function pageScript(view) {
|
|
|
1196
1607
|
var off = document.getElementById('offline'), why = document.getElementById('why');
|
|
1197
1608
|
var limits = document.getElementById('limits');
|
|
1198
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
|
+
}
|
|
1199
1632
|
// How many polls in a row have come back with nothing usable, and when the last of them was.
|
|
1200
1633
|
// On a phone the page is read on a radio, and one dropped request is a tunnel rather than an
|
|
1201
1634
|
// outage — the banner frames the table off and says the fleet cannot be read, which is the
|
|
@@ -1282,6 +1715,7 @@ function pageScript(view) {
|
|
|
1282
1715
|
if (body.trim() === '') throw new Error('The server answered with an empty page.');
|
|
1283
1716
|
if (!mineStill()) return;
|
|
1284
1717
|
live.innerHTML = body;
|
|
1718
|
+
reopenNotes();
|
|
1285
1719
|
// The account's gauges, lifted out of the fragment and into the header where they
|
|
1286
1720
|
// belong. Here rather than in the fragment's own place on the page because a limit is
|
|
1287
1721
|
// the account's and not a session's; here rather than in the shell alone because the
|
|
@@ -1327,6 +1761,7 @@ function pageScript(view) {
|
|
|
1327
1761
|
|
|
1328
1762
|
var replay = document.getElementById('replay'), scrub = document.getElementById('scrub');
|
|
1329
1763
|
var playBtn = document.getElementById('play'), covers = document.getElementById('covers');
|
|
1764
|
+
var coversNote = document.getElementById('covers-note');
|
|
1330
1765
|
var rview = document.getElementById('replay-view'), rmap = document.getElementById('replay-map');
|
|
1331
1766
|
var rmeta = document.getElementById('replay-meta'), note = document.getElementById('replaying');
|
|
1332
1767
|
var rlimits = document.getElementById('replay-limits');
|
|
@@ -1369,50 +1804,86 @@ function pageScript(view) {
|
|
|
1369
1804
|
return hhmm(t) + (new Date(t).getUTCDate() === new Date(ref).getUTCDate() ? '' : ' yesterday');
|
|
1370
1805
|
}
|
|
1371
1806
|
|
|
1372
|
-
// What the range covers, in the record's own terms
|
|
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
|
|
1373
1809
|
// ring, and a serve ten minutes old has seen ten minutes.
|
|
1374
|
-
|
|
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() {
|
|
1375
1815
|
var n = record.samples.length;
|
|
1376
|
-
if (n === 0) {
|
|
1377
|
-
// A record empty because every reading FAILED is not a record that has just started, and
|
|
1378
|
-
// this was the one branch that threw that away: ten hours of a collector that could not
|
|
1379
|
-
// run read exactly like a serve thirty seconds old.
|
|
1380
|
-
return record.missed
|
|
1381
|
-
? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' UTC and '
|
|
1382
|
-
+ record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
|
|
1383
|
-
: 'Nothing recorded yet — this serve started at ' + hhmm(record.since) + ' UTC'
|
|
1384
|
-
+ ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
|
|
1385
|
-
}
|
|
1386
1816
|
var last = record.samples[n - 1].t;
|
|
1387
|
-
return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last) + ' UTC'
|
|
1388
|
-
|
|
1389
|
-
// away comes back, so the sentence names the last time this page asked rather than a
|
|
1390
|
-
// moment it may be hours past.
|
|
1391
|
-
+ ', 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')
|
|
1392
1819
|
// A gap that says it is a gap is not a gap. The handle steps through readings, not
|
|
1393
|
-
// through minutes, and a record with holes in it is not a smooth walk
|
|
1394
|
-
|
|
1395
|
-
+
|
|
1396
|
-
// The
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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);
|
|
1407
1853
|
}
|
|
1408
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.
|
|
1409
1876
|
function ready() {
|
|
1410
1877
|
var n = record.samples.length;
|
|
1411
1878
|
replay.hidden = false;
|
|
1412
|
-
|
|
1879
|
+
say(n === 0 ? emptyText() : coversRange(), n === 0 ? '' : KEEPS);
|
|
1413
1880
|
scrub.max = String(n === 0 ? 0 : n - 1);
|
|
1414
1881
|
scrub.disabled = n === 0;
|
|
1415
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();
|
|
1416
1887
|
}
|
|
1417
1888
|
|
|
1418
1889
|
// Revealed, not hidden, when the record cannot be had: a scrubber that silently never
|
|
@@ -1421,7 +1892,7 @@ function pageScript(view) {
|
|
|
1421
1892
|
replay.hidden = false;
|
|
1422
1893
|
scrub.disabled = true;
|
|
1423
1894
|
playBtn.disabled = true;
|
|
1424
|
-
|
|
1895
|
+
say(said, '');
|
|
1425
1896
|
}
|
|
1426
1897
|
|
|
1427
1898
|
function load() {
|
|
@@ -1481,7 +1952,15 @@ function pageScript(view) {
|
|
|
1481
1952
|
+ '<span class="project">' + esc(x.project) + '</span>'
|
|
1482
1953
|
+ '<span class="kind">' + esc(x.kind) + '</span></div>'
|
|
1483
1954
|
+ (state === 'waiting' && x.waitingFor ? '<div class="sub waiting-for">' + esc(x.waitingFor) + '</div>' : '')
|
|
1484
|
-
|
|
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>')
|
|
1485
1964
|
+ (typeof x.costUsd === 'number' ? '<div class="sub">$' + x.costUsd.toFixed(2) + '</div>' : '')
|
|
1486
1965
|
+ '</article>';
|
|
1487
1966
|
}
|
|
@@ -1611,12 +2090,32 @@ function pageScript(view) {
|
|
|
1611
2090
|
: '$' + cost.toFixed(2) + (reporting < n ? ' (' + reporting + '/' + n + ' reporting cost)' : ''));
|
|
1612
2091
|
}
|
|
1613
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
|
+
|
|
1614
2112
|
function draw(i) {
|
|
1615
2113
|
var s = record && record.samples[i];
|
|
1616
2114
|
if (!s) return;
|
|
1617
2115
|
at = i;
|
|
1618
2116
|
replaying = true;
|
|
1619
2117
|
scrub.value = String(i);
|
|
2118
|
+
fill();
|
|
1620
2119
|
// The handle's own value is an index, so a reader who cannot see the banner would be read
|
|
1621
2120
|
// "3" while the fleet on screen is three hours old. The minute travels with the handle.
|
|
1622
2121
|
var minute = hhmm(s.t) + ' UTC';
|
|
@@ -1637,6 +2136,10 @@ function pageScript(view) {
|
|
|
1637
2136
|
function stopPlay() {
|
|
1638
2137
|
if (playing) { clearInterval(playing); playing = null; }
|
|
1639
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');
|
|
1640
2143
|
}
|
|
1641
2144
|
|
|
1642
2145
|
// Back to now, in one gesture, with nothing of the past left behind a hidden attribute.
|
|
@@ -1661,6 +2164,7 @@ function pageScript(view) {
|
|
|
1661
2164
|
// has played nothing.
|
|
1662
2165
|
draw(at < 0 || at >= record.samples.length - 1 ? 0 : at);
|
|
1663
2166
|
playBtn.textContent = 'Pause';
|
|
2167
|
+
playBtn.setAttribute('data-playing', 'true');
|
|
1664
2168
|
playing = setInterval(function () {
|
|
1665
2169
|
// It stops at the end rather than looping back: a day that restarts on its own is a
|
|
1666
2170
|
// day whose beginning and end are impossible to tell apart.
|
|
@@ -1845,10 +2349,25 @@ function renderNode({ row: r, role, state, reading, measured, pulse }) {
|
|
|
1845
2349
|
// around it and the table a column header over it, and a bare `61%` under a line of prompt
|
|
1846
2350
|
// reads as how much of the prompt is done. Each part is dropped on its own field being
|
|
1847
2351
|
// null — a snapshot with no turn behind it has a model in it and no percentage.
|
|
1848
|
-
|
|
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]
|
|
1849
2367
|
.filter((v) => v !== null && v !== '')
|
|
1850
2368
|
.map(esc)
|
|
1851
2369
|
.join(' · ');
|
|
2370
|
+
const published = ctx === '' ? meta : meta === '' ? ctx : `${ctx} · ${meta}`;
|
|
1852
2371
|
return `<article class="node" data-role="${role}" data-state="${state}" data-reading="${reading}">
|
|
1853
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>
|
|
1854
2373
|
${state === 'waiting' && r.waitingFor ? `<div class="sub waiting-for">${esc(r.waitingFor)}</div>` : ''}
|