1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
// illumination: the folio's own behaviour, inlined into every written file.
//
// This is the trusted app script (distinct from the dev-only live-reload
// snippet `serve` injects). Transcript content is always escaped, never run;
// this code is the one script the artifact carries deliberately. Keep it small
// and dependency-free so a folio stays a single portable file.
//
// The imperative shell: everything that reads the document, listens for the
// reader, or writes to storage. The core it stands on is
// `illumination.core.js`, inlined ahead of this and answering as `core`.
// Anything that can be worked out from values belongs there, where it is tested
// without a browser; what is left here is the wiring.
(() => {
"use strict";
// --- Theme: light / dark / system -------------------------------------
//
// Every colour is a light-dark() pair resolved by `color-scheme`. Forcing a
// side is just pinning color-scheme via [data-theme] on <html>; "system"
// clears the attribute and falls back to the OS preference. The choice
// persists in localStorage and is applied before first paint (this script
// sits in <head>) so a forced theme never flashes the system one.
const THEME_KEY = "scriptorium-theme";
// The theme above is the reader's, and holds across everything they open.
// What follows is about one folio and is stored under the session the markup
// names: which marginalia stand open, and whether the reader is following the
// end of the session. Following a session still being written says nothing
// about a folio finished months ago, and every folio a reader opens from disk
// shares the `file://` origin, as does every folio served through one viewer,
// so an unscoped store is one folio's state imposed on all of them.
const FOLDS = "scriptorium-folds";
const TAIL = "scriptorium-tail";
const MAP = "scriptorium-map";
const KEY = "scriptorium-key";
const perFolio = (store) => core.perFolio(store, document.body.dataset.folio);
// Storage is a privilege a folio can be opened without (a `file://` page under
// some settings has none at all), so every read of it answers with nothing
// rather than throwing.
const stored = (key) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
// Keys whose default action scrolls the page, so pressing one counts as the
// reader taking over from follow mode (unless focus is in a control).
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ",
]);
const readTheme = () => {
try {
return core.theme(localStorage.getItem(THEME_KEY));
} catch {
return "system";
}
};
const applyTheme = (theme) => {
const root = document.documentElement;
if (theme === "system") {
delete root.dataset.theme;
} else {
root.dataset.theme = theme;
}
};
applyTheme(readTheme());
// The lights are the control: pressing one asks to read by it, and the reset
// beside them hands the choice back to the system. Which light is *drawn* as
// burning is the stylesheet's, off the scheme in force; all this does is say
// which scheme that is, and record which button asked for it.
const wireThemeToggle = () => {
const toggle = document.querySelector(".luminaries");
if (!toggle) return;
const buttons = toggle.querySelectorAll("[data-theme-choice]");
const sync = (theme) => {
buttons.forEach((button) => {
const active = button.dataset.themeChoice === theme;
button.setAttribute("aria-pressed", String(active));
});
};
sync(readTheme());
buttons.forEach((button) => {
button.addEventListener("click", () => {
const theme = button.dataset.themeChoice;
applyTheme(theme);
try {
localStorage.setItem(THEME_KEY, theme);
} catch {}
sync(theme);
});
});
};
// --- Search: highlight every match, step through with next / prev -------
//
// Non-destructive to the layout: nothing is hidden. Each query marks all
// matches inside the conversation, keeps a running index, and scrolls the
// current one into view, opening any collapsed marginalia that holds it so
// the match is actually visible.
const HIT = "search__hit";
const CURRENT = "is-current";
const clearHits = (container) => {
container.querySelectorAll("mark." + HIT).forEach((mark) => {
mark.replaceWith(document.createTextNode(mark.textContent));
});
container.normalize();
};
// Which kind of message a text node belongs to, judged by the block it sits
// in rather than its panel's label: a tool call folded into an assistant
// panel is still "tool", and reasoning is "thinking", so scoping is precise.
// A gloss is judged by its panel's own kind and checked first, since its
// content sits in a marginalia and would otherwise scope as a tool call, and
// since each kind of note is its own chip.
const scopeOf = (node) => {
const el = node.parentElement;
if (!el) return "assistant";
const gloss = el.closest(".turn--gloss");
if (gloss) return gloss.dataset.kind;
if (el.closest(".marginalia")) return "tool";
if (el.closest(".block--thinking")) return "thinking";
const turn = el.closest(".turn");
if (turn && turn.classList.contains("turn--user")) return "user";
return "assistant";
};
const markHits = (container, query, scopes) => {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (!node.nodeValue.trim()) continue;
// Copy-button labels are chrome, not transcript; don't match them.
if (node.parentElement && node.parentElement.closest(".copy-button")) {
continue;
}
// Restrict to the kinds of message the reader left enabled.
if (scopes && !scopes.has(scopeOf(node))) {
continue;
}
nodes.push(node);
}
const hits = [];
for (const node of nodes) {
const text = node.nodeValue;
const spans = core.spans(text, query);
if (!spans.length) continue;
const frag = document.createDocumentFragment();
let pos = 0;
for (const { from, to } of spans) {
if (from > pos) {
frag.appendChild(document.createTextNode(text.slice(pos, from)));
}
const mark = document.createElement("mark");
mark.className = HIT;
mark.textContent = text.slice(from, to);
frag.appendChild(mark);
hits.push(mark);
pos = to;
}
if (pos < text.length) {
frag.appendChild(document.createTextNode(text.slice(pos)));
}
node.parentNode.replaceChild(frag, node);
}
return hits;
};
const revealHit = (hit) => {
for (let node = hit.parentElement; node; node = node.parentElement) {
if (node.tagName === "DETAILS") node.open = true;
}
};
// The folio's key: which kinds of panel are in play. It is deliberately not
// the search's own control. The search, the dock, and the minimap all read it,
// so a reader says once what they are looking through rather than once per
// control that looks. Read fresh each time rather than cached, so nothing has
// to be told when a chip is pressed.
const keyChips = () => document.querySelectorAll(".key__chip");
const enabledKinds = () => {
const kinds = new Set();
keyChips().forEach((chip) => {
if (chip.getAttribute("aria-pressed") === "true") {
kinds.add(chip.dataset.scope);
}
});
return kinds;
};
// The key owns the chips' state, and is the only thing that writes it: the
// search, the dock, and the minimap each read it fresh and repaint on their
// own listeners, which run after this one because it is wired first.
//
// What is stored is the kinds set *aside*, not the ones in play, so a kind
// added to a later folio arrives in play rather than silently missing from a
// reader's stored list.
const wireKey = () => {
const key = document.querySelector(".key");
if (!key) return;
const setAside = () =>
new Set(
Array.from(keyChips())
.filter((chip) => chip.getAttribute("aria-pressed") !== "true")
.map((chip) => chip.dataset.scope),
);
let aside;
try {
const held = JSON.parse(stored(perFolio(KEY)) || "[]");
aside = new Set(Array.isArray(held) ? held : []);
} catch {
aside = new Set();
}
keyChips().forEach((chip) => {
chip.setAttribute("aria-pressed", String(!aside.has(chip.dataset.scope)));
});
// Captured, not bubbled: the search and the minimap listen for the same
// click, and a listener on the chip itself runs before one on the key that
// waits for the click to reach it. They would then read the state this is
// about to flip, and paint the press before it happened.
key.addEventListener(
"click",
(event) => {
const chip = event.target.closest(".key__chip");
if (!chip) return;
const active = chip.getAttribute("aria-pressed") === "true";
chip.setAttribute("aria-pressed", String(!active));
try {
localStorage.setItem(perFolio(KEY), JSON.stringify([...setAside()]));
} catch {}
},
true,
);
};
const wireSearch = () => {
const search = document.querySelector(".search");
const container = document.querySelector("main.folio");
if (!search || !container) return;
const input = search.querySelector(".search__input");
const count = search.querySelector(".search__count");
const prev = search.querySelector('[data-search-nav="prev"]');
const next = search.querySelector('[data-search-nav="next"]');
const chips = keyChips();
let hits = [];
let index = -1;
const paint = () => {
hits.forEach((hit) => hit.classList.remove(CURRENT));
const nav = hits.length > 0;
prev.disabled = !nav;
next.disabled = !nav;
if (!input.value) {
count.textContent = "";
return;
}
if (!nav) {
count.textContent = "no matches";
return;
}
const hit = hits[index];
hit.classList.add(CURRENT);
revealHit(hit);
hit.scrollIntoView({ block: "center", behavior: "smooth" });
count.textContent = index + 1 + "/" + hits.length;
};
const run = () => {
clearHits(container);
const query = input.value;
hits = query ? markHits(container, query, enabledKinds()) : [];
index = hits.length ? 0 : -1;
paint();
};
const step = (delta) => {
if (!hits.length) return;
index = (index + delta + hits.length) % hits.length;
paint();
};
input.addEventListener("input", run);
input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
step(event.shiftKey ? -1 : 1);
});
prev.addEventListener("click", () => step(-1));
next.addEventListener("click", () => step(1));
// The chip's own state is the key's to flip (see `wireKey`); the search only
// looks again once it has.
chips.forEach((button) => button.addEventListener("click", run));
};
// --- The nib: a quill's scratch as a copy is taken ---------------------
//
// Synthesized rather than sampled, for the same reason everything else here
// is inlined: a folio carries every byte it needs, and a recording of a pen
// would cost tens of kilobytes where this costs a few lines. A nib doesn't
// glide over parchment, it catches and releases dozens of times a stroke,
// and that stick-slip is what an ear hears as a scratch rather than a hiss,
// so the noise is cut into grains that each bite their own amount, and then
// trimmed to the band a dry point on paper actually sounds in. What is
// written is a word rather than a mark: strokes of their own length, weight,
// and tone, scheduled one after another with the pen lifted between them.
let quill = null;
// Built on the first copy rather than at load: a context made with no
// gesture behind it starts suspended, and browsers count it against the page.
const nib = () => {
if (quill) return quill;
const Context = window.AudioContext || window.webkitAudioContext;
if (!Context) return null;
try {
quill = new Context();
} catch {
return null;
}
return quill;
};
// One pull of the nib: how long it is down, how hard it is pressed, how
// dark it sounds, how coarsely it catches, and how long the pen is off the
// page after. A word is a handful of these, and no two of them match: a
// stem is a flick where a bowl is a long pull, the hand leans in and eases
// off, and the nib's angle changes what each one sounds like. Skewing the
// length keeps most strokes short, so the occasional long one tells.
const strokesOfAWord = () => {
const strokes = [];
const letters = 5 + Math.floor(Math.random() * 4);
for (let n = 0; n < letters; n += 1) {
strokes.push({
down: 0.018 + Math.random() ** 1.6 * 0.1,
// No lift after the last: the word ends when the pen does.
lifted: n === letters - 1 ? 0 : 0.015 + Math.random() * 0.05,
press: 0.35 + Math.random() * 0.65,
floor: 600 + Math.random() * 800,
ceiling: 2400 + Math.random() * 2400,
catches: 0.0004 + Math.random() * 0.0035,
});
}
return strokes;
};
// One stroke, sounded at `when`: grains of noise under the pressure of a
// hand, cut at either end to the band this pull sounds in. Below is a rumble
// the pen doesn't have; above is the sizzle that makes the same grains read
// as static rather than as a nib.
const drawStroke = (pen, stroke, when) => {
const length = Math.floor(pen.sampleRate * stroke.down);
const buffer = pen.createBuffer(1, length, pen.sampleRate);
const samples = buffer.getChannelData(0);
let caught = 0;
let bite = 0;
for (let i = 0; i < length; i += 1) {
if (i >= caught) {
caught = i + pen.sampleRate * stroke.catches * (0.5 + Math.random());
bite = 0.25 + Math.random() * 0.75;
}
// The stroke's own pressure: on as the hand commits, off as it lifts, so
// every stroke ends at silence and the gap after it is a clean one.
const weight = Math.sin((Math.PI * i) / length) ** 0.7;
samples[i] = (Math.random() * 2 - 1) * bite * weight * stroke.press;
}
const source = pen.createBufferSource();
source.buffer = buffer;
const rumble = pen.createBiquadFilter();
rumble.type = "highpass";
rumble.frequency.value = stroke.floor;
const sizzle = pen.createBiquadFilter();
sizzle.type = "lowpass";
sizzle.frequency.value = stroke.ceiling;
sizzle.Q.value = 0.9;
const quiet = pen.createGain();
// Most strokes are pressed well under full, so the word wants a little
// more gain than one flat stroke would to land as loudly.
quiet.gain.value = 0.07;
source
.connect(rumble)
.connect(sizzle)
.connect(quiet)
.connect(pen.destination);
source.start(when);
};
const scratch = () => {
const pen = nib();
if (!pen) return;
// resume() is a promise, and it rejects where the browser won't let the
// context start. The word simply goes unheard, which is not worth an
// unhandled rejection surfacing on a page whose copy already worked.
if (pen.state === "suspended") pen.resume().catch(() => {});
// A lead on the first stroke, so scheduling the word can't run late into
// its own opening.
let when = pen.currentTime + 0.01;
for (const stroke of strokesOfAWord()) {
drawStroke(pen, stroke, when);
when += stroke.down + stroke.lifted;
}
};
// --- Copy: a button on every code block and every message --------------
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// file:// or a denied permission: fall back to a throwaway textarea.
}
const scratch = document.createElement("textarea");
scratch.value = text;
scratch.style.position = "fixed";
scratch.style.opacity = "0";
document.body.appendChild(scratch);
scratch.select();
try {
document.execCommand("copy");
} catch {}
scratch.remove();
};
const makeCopyButton = (getText) => {
const button = document.createElement("button");
button.type = "button";
button.className = "copy-button";
button.textContent = "copy";
button.setAttribute("aria-label", "copy to clipboard");
// A hover is the reader announcing the click a moment early, so the pen is
// readied then: building the context is the one costly step, and doing it
// here leaves the click with only the stroke to draw. It starts suspended,
// since a hover carries no user gesture to unblock audio, and the click
// that follows resumes it. Once, because the context outlives the hover.
button.addEventListener("pointerenter", nib, { once: true });
button.addEventListener("click", async (event) => {
event.stopPropagation();
// The copy goes first and the pen follows in the same click. The hover
// above usually spares this click the context, but a reader who reached
// the button by keyboard pays tens of milliseconds to build one here, and
// the clipboard is what they pressed the button for.
const copied = copyToClipboard(getText());
scratch();
await copied;
button.textContent = "copied";
button.classList.add("is-done");
setTimeout(() => {
button.textContent = "copy";
button.classList.remove("is-done");
}, 1200);
});
return button;
};
const wireCopy = () => {
const container = document.querySelector("main.folio");
if (!container) return;
// Every code / diff / JSON / output block copies its own text.
container.querySelectorAll("pre").forEach((pre) => {
const code = pre.querySelector("code") || pre;
pre.appendChild(makeCopyButton(() => code.textContent));
});
// A fold whose body is prose carries no `pre` to hang a button on, so it
// gets its own. This is the most load-bearing text in a folio to be able to
// lift out: a skill's whole instructions, a rule pulled into context, a
// plan, the prompt a subagent was sent. The text is taken before the button
// is seated, since afterwards it would be inside what it copies.
container.querySelectorAll(".tool--prose").forEach((prose) => {
const text = prose.textContent.trim();
prose.appendChild(makeCopyButton(() => text));
});
// Every turn copies its readable prose (text and thinking, not tool JSON).
container.querySelectorAll(".turn").forEach((turn) => {
const prose = turn.querySelectorAll(".block--text, .block--thinking");
if (!prose.length) return;
const meta = turn.querySelector(".turn__meta");
if (!meta) return;
const button = makeCopyButton(() =>
Array.from(prose)
.map((block) => block.textContent.trim())
.filter(Boolean)
.join("\n\n"),
);
button.classList.add("copy-button--meta");
meta.appendChild(button);
});
};
// --- Dock: jump between messages, fold every marginalia ---------------
// How far down the viewport a panel still counts as the one being read: enough
// to clear a turn's own scroll-margin, so the panel just navigated to is
// current rather than the one before it.
const THRESHOLD = 40;
const wireDock = () => {
const dock = document.querySelector(".dock");
const container = document.querySelector("main.folio");
if (!dock || !container) return;
// The unscoped stores these replaced imposed one folio's state on every
// other; drop them rather than leave them to sit unread forever.
try {
localStorage.removeItem(FOLDS);
localStorage.removeItem(TAIL);
} catch {}
// Step along the folio's own axis: what reached the model, and what it
// produced. `data-side` carries the classification the renderer already
// holds (see `PanelKind::side`), so this need not keep a list of kinds that
// would drift from it. The `aside` kinds are skipped by both arrows and by
// the unscoped middle pair: a plan boundary, a rule, and a passing note are
// context a reader reaches for rather than stops at, and stepping to every
// one of them would make the dock no faster than scrolling. Only visible
// panels, since one a reader has no way to see reports top 0 and would
// hijack "current".
// The key narrows this the same way it narrows the search: an arrow steps
// through the kinds a reader left in play on its own side, so turning off
// `tool` and `thinking` leaves the warm arrow walking replies alone. The
// `aside` kinds stay out of the dock however the key is set, since the
// arrows are the two sides and those kinds are on neither.
const messages = (side) => {
const kinds = enabledKinds();
return Array.from(
container.querySelectorAll(
side
? `.turn[data-side="${side}"]`
: '.turn[data-side="entered"], .turn[data-side="model"]',
),
).filter(
(turn) =>
turn.getClientRects().length > 0 && kinds.has(turn.dataset.kind),
);
};
// Land on a panel: name it in the URL, and scroll to its start. Every way of
// arriving at a panel goes through here, so a folio's URL always names where
// its reader is, whether they stepped with the dock, leapt to an end, or
// scrubbed the minimap.
//
// `replaceState` rather than assigning `location.hash`, so twenty steps
// don't cost twenty presses of Back; it performs no scroll of its own, hence
// the explicit one, which honours the turn's `scroll-margin-top`.
//
// The panel is marked as well as named, because `:target` answers to
// navigation and `replaceState` is not navigation: the URL changes and the
// browser's own idea of the target does not follow it. The gilt wash that
// says "you landed here" would otherwise appear only when a reader arrived
// by a link, and stay stuck on that panel through every step after. The
// stylesheet draws the mark and `:target` the same way, so a landing and an
// arrival read alike.
const marked = () => container.querySelectorAll("[data-landed]");
const name = (target) => {
marked().forEach((panel) => delete panel.dataset.landed);
target.dataset.landed = "";
try {
history.replaceState(null, "", `#${target.id}`);
} catch {
location.hash = `#${target.id}`;
}
};
const landOn = (target, marking = true) => {
releaseTail();
if (marking) name(target);
target.scrollIntoView({ behavior: "auto", block: "start" });
};
const jump = (direction, side) => {
const panels = messages(side);
const tops = panels.map((turn) => turn.getBoundingClientRect().top);
const target = panels[core.stepIndex(tops, direction, THRESHOLD)];
if (!target) return;
// Step by the turn's own permalink, and land at once rather than gliding.
// Both follow from the same thing: `serve` re-renders under the reader, so
// a smooth scroll still in flight is simply lost when it does, and the
// scroll position it was heading for is not recorded anywhere. Writing the
// hash makes the URL name where the reader is, which the deep-link handler
// below already restores on the next load, and an instant landing is what
// stepping through a folio wants in any case: an animation is a thing to
// wait out when the reader means to press the button again.
landOn(target);
};
const fold = (open) => {
container.querySelectorAll("details").forEach((details) => {
details.open = open;
});
};
// --- Follow (tail -f): keep the newest message's start pinned across the
// reloads a live session drives, until the reader scrolls away.
//
// Only a served folio can gain a message, so only a served folio carries
// the toggle, and its presence is what says following is possible here. A
// written one is a snapshot of a session that may have ended long ago:
// there is nothing to follow, so jumping to its end is just a jump.
const tailButton = dock.querySelector('[data-tail="toggle"]');
const canFollow = Boolean(tailButton);
const visible = () =>
Array.from(container.querySelectorAll(".turn")).filter(
(turn) => turn.getClientRects().length > 0,
);
const firstMessage = () => visible()[0] || null;
const lastMessage = () => {
const panels = visible();
return panels[panels.length - 1] || null;
};
// What following remembers is not a flag but the permalink it last wrote:
// the mode and where it had reached, in one value rather than two that could
// disagree. The pin is what tells a hash the folio wrote itself apart from
// one the reader arrived with, which a flag alone cannot do: a live session
// grows between loads, so by the time a followed folio is reloaded the hash
// it wrote no longer names the end.
let pinned = canFollow ? stored(perFolio(TAIL)) : null;
let tailing = Boolean(pinned);
const paintTail = () => {
if (tailButton) tailButton.setAttribute("aria-pressed", String(tailing));
};
// Following is a mode, not a jump: while it is on, the newest panel's
// permalink is the folio's to write, and it is rewritten every time the end
// moves. `serve` re-renders under the reader, so the end moves on every
// reload of a live session and again whenever the leaf reflows beneath them
// (a fold opening, the web fonts landing). The URL therefore keeps naming
// the turn the reader is actually on, so a reload resumes at the end and a
// link copied out of a followed folio names what was on the screen.
// Every landing the dock makes is instant, the leaps to either end
// included: a folio is megabytes tall, so a glide from one end to the other
// is an animation to sit through rather than a sense of where you went, and
// `serve` can re-render under a scroll still in flight and lose it with
// nothing recording where it was headed. (Stepping between search hits
// still glides, deliberately: those are short hops within a page the reader
// is already looking at, where the movement is what shows the next match is
// near.)
const scrollToEnd = () => {
const target = lastMessage();
if (!target) return;
name(target);
if (tailing) {
pinned = target.id;
try {
localStorage.setItem(perFolio(TAIL), pinned);
} catch {}
}
target.scrollIntoView({ behavior: "auto", block: "start" });
};
const scrollToTop = () => {
const target = firstMessage();
if (!target) return;
name(target);
target.scrollIntoView({ behavior: "auto", block: "start" });
};
// While following, the folio decides where a reload lands, so the browser
// must not: left on "auto" it restores the scroll position it recorded
// before the reload, asynchronously and after this script has run, quietly
// undoing the snap to the end. `serve` reloads the page every time the
// session grows, which is exactly when the two disagree.
const holdScroll = () => {
try {
history.scrollRestoration = tailing ? "manual" : "auto";
} catch {}
};
// Turning it on pins the end (`scrollToEnd` records where); turning it off
// forgets, so the absence of a pin is the absence of the mode.
const setTail = (on) => {
tailing = canFollow && on;
if (!tailing) {
pinned = null;
try {
localStorage.removeItem(perFolio(TAIL));
} catch {}
}
holdScroll();
paintTail();
if (on) scrollToEnd();
};
// A programmatic scrollIntoView never emits wheel/touch/keydown, so those
// are unambiguous reader input: any of them hands control back.
const releaseTail = () => {
if (tailing) setTail(false);
};
window.addEventListener("wheel", releaseTail, { passive: true });
window.addEventListener("touchmove", releaseTail, { passive: true });
window.addEventListener("keydown", (event) => {
const tag = event.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "BUTTON") return;
if (SCROLL_KEYS.has(event.key)) releaseTail();
});
// A deep link (#turn-N) names the panel the reader came for, so it releases
// follow like a wheel or arrow key does, rather than losing the landing to
// a snap to the end. Releasing (not just skipping this load's snap) because
// the hash survives the reloads a live session drives, and a suppression
// that didn't persist would fight the anchor on every one.
//
// Unless it is the hash following itself last wrote, which is the folio
// naming where the reader is rather than the reader naming where they want
// to be. That is what the pin is for: reading every hash as the reader's
// meant following survived exactly one reload, and none at all once a step
// of the dock had left a permalink in the URL.
//
// The hash need not be a valid selector either, hence getElementById, as
// querySelector throws on one that isn't.
const anchoredPanel = () => {
const anchored = location.hash
? document.getElementById(core.anchorId(location.hash))
: null;
return anchored && container.contains(anchored) ? anchored : null;
};
const named = anchoredPanel();
const deepLink =
named && core.readersHash(location.hash, pinned) ? named : null;
if (deepLink) releaseTail();
// Following a turn's own number is the reader naming where they are, so it
// hands control back the way a scroll does. The folio's own writes go
// through `replaceState`, which fires nothing here, and the fallback path
// records the pin before the event can run.
window.addEventListener("hashchange", () => {
// A real navigation, so the browser's own `:target` takes over the mark:
// leaving one behind would wash two panels at once.
marked().forEach((panel) => delete panel.dataset.landed);
if (core.readersHash(location.hash, pinned)) releaseTail();
});
// On load, if still following, snap to the newest message at once; a second
// pass after layout settles (web fonts shift it) lands it precisely. A
// deep-linked turn needs that second pass too, since the browser's own
// anchor scroll happens before the fonts land.
paintTail();
holdScroll();
// Keep the end pinned as the leaf changes height under the reader: the
// fonts land, an image decodes, a fold is opened. Each moves the newest
// panel's start, and following means being there rather than where it used
// to be. Watching whether or not this load began by following, since the
// reader can turn it on at any point after.
if (canFollow) {
new ResizeObserver(() => {
if (tailing) scrollToEnd();
}).observe(container);
}
if (tailing) {
scrollToEnd();
// Again on the next frame, and again once every resource is in: a folio
// is megabytes of markup and its layout is still settling long after this
// script runs, and each settling moves the end.
requestAnimationFrame(() => {
if (tailing) scrollToEnd();
});
window.addEventListener("load", () => {
if (tailing) scrollToEnd();
});
} else if (deepLink) {
requestAnimationFrame(() => {
deepLink.scrollIntoView({ behavior: "auto", block: "start" });
});
}
dock.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
const { nav, side, fold: foldTo, tail } = button.dataset;
if (nav === "prev") jump(-1, side);
else if (nav === "next") jump(1, side);
// Leaping to the top is the reader taking control, so it releases follow
// the way a wheel or arrow key does: otherwise the next reload of a live
// session would snap straight back to the end.
else if (nav === "top") {
releaseTail();
scrollToTop();
} else if (nav === "end") setTail(true);
else if (tail === "toggle") setTail(!tailing);
else if (foldTo === "expand") fold(true);
else if (foldTo === "collapse") fold(false);
});
// Remember each marginalia's open/closed state across reloads, keyed per
// message so a live session that grows keeps the folds a reader set: the
// raw stream is append-only, so a panel's turn number and a marginalia's
// index within it stay stable as new turns arrive. Only open keys are
// stored; the markup default is collapsed.
const foldKey = (details) => {
const turn = details.closest(".turn");
const marginalia = turn ? turn.querySelectorAll("details") : [details];
const index = Array.prototype.indexOf.call(marginalia, details);
return core.foldKey(turn && turn.dataset.turn, index);
};
const readOpenFolds = () => {
try {
const held = JSON.parse(stored(perFolio(FOLDS)) || "[]");
return new Set(Array.isArray(held) ? held : []);
} catch {
return new Set();
}
};
const open = readOpenFolds();
container.querySelectorAll("details").forEach((details) => {
if (open.has(foldKey(details))) details.open = true;
});
// `toggle` fires on both a reader's click and the fold-all buttons, and does
// not bubble, so listen in the capture phase.
container.addEventListener(
"toggle",
(event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement)) return;
const folds = readOpenFolds();
if (details.open) folds.add(foldKey(details));
else folds.delete(foldKey(details));
try {
localStorage.setItem(perFolio(FOLDS), JSON.stringify([...folds]));
} catch {}
},
true,
);
// Handed to the minimap, which is another way of arriving at a panel and so
// must arrive the same way: releasing follow, naming the turn, landing at
// its start. Passed rather than reached for, so the two agree by
// construction instead of by a second copy of this.
return { landOn };
};
// --- Minimap: the whole folio at a glance, and a place to scrub it -----
// A hairline under a band, so a one-line note is still somewhere to aim at in
// a folio whose tool output runs to thousands of lines, and a thicker one
// under the reader's own view, which a long folio otherwise draws as a pixel.
const BAND_FLOOR = 2;
const VIEW_FLOOR = 12;
// How fast the wheel zooms the map, and how far in it can go. The rate is per
// pixel of wheel delta and exponential, so a notch is the same proportion of a
// zoom wherever it is turned; the limit is generous because the folios that
// want zoom are the ones with a thousand panels in them.
const ZOOM_RATE = 0.002;
const MOST_ZOOM = 64;
const wireMinimap = (dock) => {
const minimap = document.querySelector(".minimap");
const container = document.querySelector("main.folio");
if (!minimap || !container || !dock) return;
const track = minimap.querySelector(".minimap__track");
const view = minimap.querySelector(".minimap__view");
const panels = Array.from(container.querySelectorAll(".turn"));
if (!track || !view || !panels.length) return;
// A band per panel, in the panel's own pigment. Drawn from the panels
// themselves rather than written into the markup: what a band states is the
// share of the document its panel takes, which only the browser knows and
// which changes every time a fold opens.
const bands = panels.map((panel) => {
const band = document.createElement("div");
band.className = "minimap__band";
band.dataset.kind = panel.dataset.kind || "";
band.dataset.turn = panel.dataset.turn || "";
if (panel.dataset.sidechain !== undefined) band.dataset.sidechain = "";
band.title = `#${panel.dataset.turn} ${panel.dataset.kind}`;
track.insertBefore(band, view);
return { band, panel };
});
// The whole scrollable page rather than the folio's own extent, so the
// reader's view sits on the map where it sits on the document.
const leaf = () => document.documentElement.scrollHeight || 1;
// The stretch of the leaf the track is showing. Zoomed in, the map stops
// being the whole folio and becomes a part of it, which is the point: a
// session of a thousand panels draws most of them two pixels tall, and two
// pixels is a mark rather than a target.
//
// How it was last framed is a fact about this folio, so it is remembered
// under this folio, and survives the reload a live session drives: a reader
// who has opened up the stretch they are working through keeps it.
const framed = core.framing(stored(perFolio(MAP)));
let lens = core.lens({
leaf: leaf(),
track: track.clientHeight,
zoom: framed.zoom,
});
lens = core.lens({ ...lens, origin: framed.at * lens.leaf });
const remember = () => {
// An unzoomed map is the whole folio, which is where every map starts:
// nothing to remember, so nothing is kept.
try {
if (lens.zoom <= 1) localStorage.removeItem(perFolio(MAP));
else {
localStorage.setItem(
perFolio(MAP),
JSON.stringify({ zoom: lens.zoom, at: lens.origin / lens.leaf }),
);
}
} catch {}
};
// Re-measured whenever the leaf or the track changes size. Following the
// reader is *not* part of that: zoom is the map's own, so looking into one
// stretch of a folio while reading another is exactly what it is for. Only
// the reader's own scrolling brings the map back to them.
const relens = (follow) => {
lens = core.lens({ ...lens, leaf: leaf(), track: track.clientHeight });
if (follow) lens = core.followed(lens, window.scrollY, window.innerHeight);
// Zoomed, the map stands for a part of the folio rather than the whole of
// it, and the stylesheet says so.
if (lens.zoom > 1) minimap.dataset.zoomed = "";
else delete minimap.dataset.zoomed;
};
const layout = () => {
bands.forEach(({ band, panel }) => {
const box = panel.getBoundingClientRect();
band.hidden = box.height === 0;
if (band.hidden) return;
const placed = core.bandBox(
box.top + window.scrollY,
box.height,
lens,
BAND_FLOOR,
);
band.style.top = `${placed.top}px`;
band.style.height = `${placed.height}px`;
});
};
const paintView = () => {
const placed = core.viewBox(
window.scrollY,
window.innerHeight,
lens,
VIEW_FLOOR,
);
view.style.top = `${placed.top}px`;
view.style.height = `${placed.height}px`;
};
// The key narrows the map as it narrows the search and the dock: a kind out
// of play is still drawn, since the map would otherwise misstate where
// everything else sits, but it goes faint and is no longer somewhere the
// scrub can land.
const paintKinds = () => {
const kinds = enabledKinds();
bands.forEach(({ band }) => {
band.dataset.inPlay = String(kinds.has(band.dataset.kind));
});
};
const nearest = (clientY) => {
const y = clientY - track.getBoundingClientRect().top;
const index = core.nearestIndex(
y,
bands.map(({ band }) => ({
top: band.offsetTop,
height: band.offsetHeight,
inPlay: !band.hidden && band.dataset.inPlay === "true",
})),
);
return index === -1 ? null : bands[index].panel;
};
let scrubbing = false;
let landed = null;
// The permalink is written when the scrub settles, not at every panel it
// passes over: a drag crosses dozens, and the browsers that throttle
// `replaceState` count them all.
const scrub = (event, settled) => {
const target = nearest(event.clientY);
if (!target || (target === landed && !settled)) return;
landed = target;
dock.landOn(target, settled);
};
track.addEventListener("pointerdown", (event) => {
scrubbing = true;
track.setPointerCapture(event.pointerId);
scrub(event, false);
event.preventDefault();
});
track.addEventListener("pointermove", (event) => {
if (scrubbing) scrub(event, false);
});
const settle = (event) => {
if (!scrubbing) return;
scrubbing = false;
landed = null;
scrub(event, true);
};
track.addEventListener("pointerup", settle);
track.addEventListener("pointercancel", settle);
// Everything the map draws is a frame's worth of work over every panel, and
// a wheel or a scroll arrives many times a frame, so a redraw is asked for
// rather than done.
let asked = false;
const redraw = (follow) => {
if (asked) return;
asked = true;
requestAnimationFrame(() => {
asked = false;
relens(follow);
layout();
paintView();
});
};
window.addEventListener("scroll", () => redraw(true), { passive: true });
// Zoom, on the map alone: the wheel over the track narrows what it shows
// instead of scrolling the leaf, so a reader can open up a stretch of a
// thousand-panel folio where every band is two pixels tall, and pick one
// out, without leaving the place they are reading. Held about the pointer,
// as a map zooms about the cursor.
//
// The wheel is stopped here rather than let through: it must not scroll the
// page under the reader, and it must not reach the window listener that
// takes following as released, since looking at the map is not the reader
// leaving the end of the session.
track.addEventListener(
"wheel",
(event) => {
event.preventDefault();
event.stopPropagation();
lens = core.zoomedAbout(
lens,
event.clientY - track.getBoundingClientRect().top,
Math.exp(-event.deltaY * ZOOM_RATE),
MOST_ZOOM,
);
relens(false);
layout();
paintView();
remember();
},
{ passive: false },
);
// Every height the map states can change without a scroll: a fold opening,
// the web fonts landing, the window resizing. Observing the folio catches
// all three, and fires once on its own to draw the map in the first place.
// None of them is the reader moving, so none of them brings the map back to
// where they are.
new ResizeObserver(() => redraw(false)).observe(container);
window.addEventListener("resize", () => redraw(false));
const key = document.querySelector(".key");
if (key) key.addEventListener("click", paintKinds);
paintKinds();
redraw(true);
};
const onReady = (fn) => {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", fn);
} else {
fn();
}
};
onReady(() => {
// The key first: it restores which kinds are in play, and everything below
// reads that as it wires itself.
wireKey();
wireThemeToggle();
wireSearch();
wireCopy();
wireMinimap(wireDock());
});
})();