prolog-notebook 0.1.2 → 0.3.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/src/notebook.js CHANGED
@@ -1,74 +1,969 @@
1
1
  // Browser wiring: turns marked-up cells in a page into a running notebook.
2
2
  //
3
3
  // Deliberately not a framework. A page declares its cells as ordinary elements and
4
- // calls mount(); the DOM is the notebook. A file-backed renderer (reading .ipynb or
5
- // markdown and generating these cells) is the next layer up, and is not written yet.
6
- import { createSession, formatSolution } from './browser.js';
4
+ // calls mount(); the DOM is the notebook. A file-backed renderer (reading markdown
5
+ // and generating these cells) is the next layer up.
6
+ //
7
+ // The engine runs in a worker (src/browser.js), so every call here is awaited and
8
+ // a goal that never terminates leaves the page usable. That is not a nicety: a
9
+ // Prolog chapter has to be able to demonstrate non-termination.
10
+ //
11
+ // WHY THERE IS SO MUCH STATE IN HERE. A reader is looking at three things that can
12
+ // disagree: the text on screen, what the engine is holding, and the answers below.
13
+ // Any two of them come apart in a single click. So every cell answers the same two
14
+ // questions at all times — is this still what the chapter published, and does the
15
+ // engine agree with what I can see — through a tick that names its state and a
16
+ // reset that undoes it. A blank tick beside a greyed button is indistinguishable
17
+ // from a broken page, which is how an earlier version of this file read.
18
+ import { createSession, formatSolution, prologVersion, readableInCell } from './browser.js';
19
+ import { declaredDynamic, definedPredicates, unknownProcedure } from './clauses.js';
20
+ import { download } from './export.js';
21
+ import { solutionSequence } from './format.js';
22
+ import { colophon } from './version.js';
7
23
 
8
24
  let serial = 0;
9
- let booted = false;
25
+ let panels = 0;
26
+
27
+ /** Absolute, never relative: "3 minutes ago" is wrong the moment it is written. */
28
+ function clock(date = new Date()) {
29
+ return date.toLocaleTimeString(undefined, { hour12: false });
30
+ }
31
+
32
+ /**
33
+ * One notebook's internal notifications.
34
+ *
35
+ * Cells have to hear about each other: answers stop being current when a program
36
+ * cell ABOVE them changes, and no query can notice that by itself. Created per
37
+ * mount() rather than per module, because a page may hold more than one notebook
38
+ * (an embedded chapter, v0.4) and one notebook's edits are not another's.
39
+ */
40
+ function createBus() {
41
+ const watchers = new Set();
42
+ // Auto re-runs go through here one at a time. A consult can make several cells
43
+ // want to refresh at once, and this notebook has ONE engine: opening four
44
+ // queries into it simultaneously interleaves four solution streams in a single
45
+ // WASM heap for no gain, since the worker answers them serially anyway.
46
+ let chain = Promise.resolve();
47
+ return {
48
+ on: (watcher) => watchers.add(watcher),
49
+ emit: (event) => { for (const watcher of watchers) watcher(event); },
50
+ // The cells with a solution sequence open right now. One engine allows one
51
+ // (869epzqpc), so this is 0 or 1 — but it is a set because what the page
52
+ // needs to know is "is the reader mid-enquiry anywhere", and counting is how
53
+ // a cell that is not this one gets to say so.
54
+ stepping: new Set(),
55
+ queue: (job) => {
56
+ // Both arms are the same job on purpose: a re-run that threw must not stop
57
+ // the next cell's from ever starting.
58
+ chain = chain.then(job, job);
59
+ return chain;
60
+ },
61
+ // Whether THIS notebook's engine has started. Per mount rather than per
62
+ // module: "5.9 MB, first time only" is a claim about a particular notebook's
63
+ // first Run, and a second chapter embedded in the same page (v0.4) is a
64
+ // second notebook, not a continuation of this one.
65
+ booted: false,
66
+ };
67
+ }
10
68
 
11
- export function mount(root = document) {
69
+ export function mount(root = document, options = {}) {
12
70
  // A page can carry a #boot-warning element saying "this notebook is not running".
13
71
  // It is removed only once mount() has actually run, so any failure that prevents
14
72
  // this module from loading — opening the page over file://, a bad path, a syntax
15
73
  // error — leaves the warning on screen instead of silently inert buttons.
16
74
  document.getElementById('boot-warning')?.remove();
17
75
 
18
- root.querySelectorAll('.cell.program').forEach(mountProgram);
19
- root.querySelectorAll('.cell.query').forEach(mountQuery);
76
+ const bus = createBus();
77
+
78
+ // Document order matters, and it is the only thing that does. A query is run
79
+ // against the program cells ABOVE it, and a predicate it cannot find may be
80
+ // defined in one BELOW it — which is worth saying rather than leaving as
81
+ // "Unknown procedure".
82
+ const cells = [...root.querySelectorAll('.cell')];
83
+ const programs = [];
84
+ cells.forEach((cell, index) => {
85
+ if (cell.classList.contains('program')) programs.push({ index, ...mountProgram(cell, options, bus) });
86
+ });
87
+ // A cell held until a prediction is answered names that prediction by POSITION:
88
+ // the nearest one above it. That is not the position-inferred spoiler rule the
89
+ // format refuses — the AUTHOR opted in, per cell, in the file (format §5). This
90
+ // is only how the cell finds the box it was told to wait for.
91
+ const predictions = [...root.querySelectorAll('.predict textarea')];
92
+ const predictionAbove = (el) => predictions
93
+ .filter((box) => box.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
94
+ .pop() ?? null;
95
+
96
+ const queries = [];
97
+ cells.forEach((cell, index) => {
98
+ if (!cell.classList.contains('query')) return;
99
+ queries.push(mountQuery(cell, options, bus, {
100
+ above: programs.filter((p) => p.index < index),
101
+ below: programs.filter((p) => p.index > index),
102
+ prediction: predictionAbove(cell),
103
+ }));
104
+ });
105
+
106
+ if (programs.length) mountPageBar(root, options, bus, programs, queries);
107
+
108
+ // Returned so a shell that HAS the parsed model — page.js, today — can ask the
109
+ // cells what they now say and hand the reader a file (869ejgbxf). notebook.js
110
+ // still knows nothing about markdown, and does not gain a parser to do it.
111
+ //
112
+ // `on` goes with them because page-level chrome has to hear about cells that
113
+ // change WITHOUT anyone clicking: an automatic re-run (format §5) is started by
114
+ // a consult in another cell, and a panel that only listens to its own clicks
115
+ // reports the state before it.
116
+ return { programs, queries, on: bus.on };
20
117
  }
21
118
 
22
- /** Boot the engine, reporting the first (slow, 5.9 MB) load through `status`. */
23
- async function boot(status) {
24
- if (!booted && status) {
119
+ /**
120
+ * The controls that belong to the page rather than to any one cell: what the
121
+ * engine is holding, whether the chapter is showing its answers, and the one
122
+ * button that acts on all of the first.
123
+ *
124
+ * CHROME, NOT CONTENT: built here at runtime rather than emitted into the
125
+ * document, so a built page, an EPUB or the GitHub view never carries a button
126
+ * that cannot work.
127
+ *
128
+ * Sticky, which is a fix rather than a flourish: this is where the answer to
129
+ * "what is the engine holding now" lives, and a reader who has to scroll to the
130
+ * end of the chapter to see it will read every cell above as unexplained.
131
+ *
132
+ * But a chapter is for reading, and a full-width bar pinned across the foot of
133
+ * every page is a tool insisting on itself. So it is a lozenge in the corner
134
+ * carrying the state at a glance — a dot and a word — and clicking it RAISES A
135
+ * CARD directly above it, right edges aligned, one row per thing the page
136
+ * controls. The thing the reader pressed is visibly the thing that opened.
137
+ *
138
+ * It rises rather than widening, and that is a correctness decision as much as a
139
+ * visual one: a widening pill has to be measured from the DOM every time its
140
+ * words change, and a measured animation is cancelled by anything that re-renders
141
+ * while it runs (869enmuy9). A row also costs vertical space, which nobody is
142
+ * short of — the widening pill had reached the edge of the viewport with three
143
+ * units in it, and there are more coming.
144
+ *
145
+ * On a click, not on hover: hover opens a panel nobody asked for, does not exist
146
+ * on a touch screen, and cannot be reached from a keyboard, so one gesture that
147
+ * works everywhere beats three that do not.
148
+ *
149
+ * IT OPENS ITSELF ONLY TO REPORT A FAILURE. An earlier version also opened when
150
+ * the engine started, on the grounds that its words were worth reading at that
151
+ * moment — but the reader had pressed Run in a cell, their attention was on that
152
+ * cell's output, and a second thing moving in the corner competed with the
153
+ * answers they had actually asked for. It was also redundant: the light says the
154
+ * engine came on, which is the whole reason a light is there. The panel is the
155
+ * detail, and detail is fetched, not pushed.
156
+ *
157
+ * A failure is the exception, because a message nobody sees is not a message.
158
+ *
159
+ * Otherwise it opens on a click and closes on Escape, on a click elsewhere, or on
160
+ * a second click of the lozenge.
161
+ */
162
+ /**
163
+ * The small line icons the page controls use.
164
+ *
165
+ * THE RULE, because "should this icon show the state or the action?" has a
166
+ * different obvious answer every time it is asked, and answering it per control
167
+ * is how a vocabulary rots:
168
+ *
169
+ * A LIGHT SAYS WHAT IS TRUE. AN ICON SAYS WHAT WILL HAPPEN.
170
+ *
171
+ * So every glyph in a button depicts that button's verb — the eye with a stroke
172
+ * through it means "hide these", not "these are hidden" — and the one piece of
173
+ * pure status, the engine's dot, is deliberately not an icon at all. It is a
174
+ * light: grey for no engine, amber while one is arriving, green once it is
175
+ * running. Nothing has to be read to see it.
176
+ *
177
+ * The chevron is the lozenge's own verb (it opens the panel), which is why the
178
+ * lozenge can carry a status word without becoming a button that lies: the light
179
+ * and the word are the state, the chevron is the action, and they are visibly
180
+ * different things.
181
+ *
182
+ * Inline SVG rather than a font or a file: this is chrome built at runtime, and a
183
+ * control that depends on a network fetch to say what it does is a control that
184
+ * sometimes does not. They inherit currentColor, so dark mode needs nothing.
185
+ */
186
+ const ICONS = {
187
+ chevron: '<path d="M15 5.5 8.5 12l6.5 6.5"/>',
188
+ power: '<path d="M12 3.2v8.2"/><path d="M6.6 6.7a7.6 7.6 0 1 0 10.8 0"/>',
189
+ restart: '<path d="M20.4 12a8.4 8.4 0 1 1-2.9-6.4"/><path d="M20.4 4.2v5.4h-5.2"/>',
190
+ download: '<path d="M12 3.6v10.6"/><path d="m7.6 10.2 4.4 4.4 4.4-4.4"/><path d="M4.6 19.4h14.8"/>',
191
+ hide: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.7"/><path d="M4 4l16 16"/>',
192
+ show: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.7"/>',
193
+ };
194
+
195
+ function icon(name) {
196
+ return `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor"`
197
+ + ` stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`;
198
+ }
199
+
200
+ /** Set a button's icon and words without disturbing the other. */
201
+ function label(button, name, text) {
202
+ button.querySelector('.icon').innerHTML = icon(name);
203
+ button.querySelector('.label').textContent = text;
204
+ }
205
+
206
+ function mountPageBar(root, options, bus, programs, queries) {
207
+ const host = root === document ? document.querySelector('main') ?? document.body : root;
208
+ const bar = document.createElement('div');
209
+ bar.className = 'page-controls';
210
+ bar.dataset.open = 'false';
211
+ // Its own counter, not the cell one: a page-control id is not a cell name, and
212
+ // sharing the counter would leave gaps in cell ids for no reason.
213
+ const panelId = `page-controls-${++panels}`;
214
+ // The handle comes FIRST in the markup and sits below the panel on screen, which
215
+ // is the order both want: the button that opens the panel is reached before it
216
+ // in the tab order, and the card rises from the thing that was pressed.
217
+ bar.innerHTML = `<button class="handle" data-act="handle" aria-expanded="false" aria-controls="${panelId}">`
218
+ + `<span class="dot"></span><span class="count"></span><span class="chev">${icon('chevron')}</span></button>`
219
+ + `<div class="panel" id="${panelId}">`
220
+ + '<div class="unit answers"><span class="state answers-state"></span></div>'
221
+ + '<div class="unit"><span class="state engine-state"></span>'
222
+ + `<button data-act="restart"><span class="icon">${icon('power')}</span>`
223
+ + '<span class="label">Start engine</span></button></div>'
224
+ // WHAT THIS IS, rather than what it is doing. Every row above is a state and
225
+ // the button that changes it; this changes nothing and has no verb, so it is
226
+ // not a row — it is the panel's footer, quieter than everything above it.
227
+ //
228
+ // The same words as `prolog-notebook --version`, from the same module, so the
229
+ // page and the command cannot describe one release differently. The engine's
230
+ // own version arrives on the second line once it has started, because until
231
+ // then nothing here knows it: it lives inside the WebAssembly, and asking is
232
+ // what starting is for.
233
+ + `<p class="about"><span class="running">${colophon().running}`
234
+ + '<span class="engine-version"></span></span>'
235
+ + `<span class="legal">${colophon().legal}</span></p>`
236
+ + '</div>';
237
+ const state = bar.querySelector('.engine-state');
238
+ // One button, whose label is always the thing it will do. "restart engine" over
239
+ // an engine that has never started names a state the reader cannot act on and
240
+ // leaves them asking how to switch it on — which is a fair question to ask of a
241
+ // control that says "off".
242
+ const restart = bar.querySelector('[data-act="restart"]');
243
+ let live = false;
244
+ const handle = bar.querySelector('.handle');
245
+ const count = bar.querySelector('.count');
246
+
247
+ // Open because the reader asked, or open because something went wrong. Kept
248
+ // apart so a flash cannot close a pill the reader deliberately pinned.
249
+ let pinned = false;
250
+ let flash = null;
251
+
252
+ /**
253
+ * Open or shut, and that is the whole of it.
254
+ *
255
+ * NOTHING IS MEASURED HERE, which is the point. The first version of this
256
+ * control was one pill that widened, so its open width had to be measured from
257
+ * the DOM on every state change — CSS cannot transition to `auto`. That made
258
+ * the animation cancellable by anything that re-rendered while it ran, and
259
+ * export's "has anything changed?" listener re-rendered on the frame after
260
+ * every click, including the click that opened it. The pill stopped animating
261
+ * and nobody could see why from the CSS, because the CSS was fine.
262
+ *
263
+ * A card that rises needs one attribute and a transform. It cannot be cancelled
264
+ * by a re-render, it costs no reflow, and a unit added tomorrow costs a row.
265
+ */
266
+ const render = () => {
267
+ const open = pinned || flash !== null;
268
+ bar.dataset.open = String(open);
269
+ handle.setAttribute('aria-expanded', String(open));
270
+ };
271
+ const show = (ms) => {
272
+ clearTimeout(flash);
273
+ // Long enough to read the message, short enough not to become furniture.
274
+ flash = setTimeout(() => { flash = null; render(); }, ms);
275
+ render();
276
+ };
277
+
278
+ const close = () => {
279
+ pinned = false;
280
+ clearTimeout(flash);
281
+ flash = null;
282
+ render();
283
+ };
284
+
285
+ handle.addEventListener('click', () => {
286
+ if (pinned || flash !== null) return close();
287
+ pinned = true;
288
+ render();
289
+ });
290
+
291
+ // The two ways out of any panel a reader expects to work. Both check that it is
292
+ // open first, so this adds no listener behaviour to a page that is only reading.
293
+ document.addEventListener('keydown', (e) => {
294
+ if (e.key === 'Escape' && bar.dataset.open === 'true') {
295
+ close();
296
+ handle.focus();
297
+ }
298
+ });
299
+ document.addEventListener('click', (e) => {
300
+ if (bar.dataset.open === 'true' && !bar.contains(e.target)) close();
301
+ });
302
+
303
+ const say = (text, title) => {
304
+ state.textContent = text;
305
+ if (title) state.title = title;
306
+ else state.removeAttribute('title');
307
+ // The pill's own label, for when the words are tucked away: the state at a
308
+ // glance, which is all a reader wants until they want the buttons.
309
+ handle.title = text;
310
+ };
311
+
312
+ // Counted in cells rather than bytes, because cells are what the reader can act
313
+ // on — and because "0 of 2 loaded" is the fact that makes a per-cell reset
314
+ // visibly do something.
315
+ const loaded = () => {
316
+ const n = programs.filter((p) => p.isLoaded()).length;
317
+ return `${n} of ${programs.length} program cell${programs.length === 1 ? '' : 's'} loaded`;
318
+ };
319
+
320
+ // Visible proof of the property the chapter is built on: nothing has been
321
+ // downloaded, and the answers above are still there to read.
322
+ //
323
+ // Says how it starts rather than only that it has not: pressing the button is
324
+ // the second way and the slower question to answer, so the sentence names the
325
+ // first. It does not repeat "engine off" either — the light two inches to its
326
+ // right already says that, and a panel that restates its own summary is one
327
+ // nobody finishes reading.
328
+ say('starts on your first Run',
329
+ 'the chapter is showing its saved answers; 5.9 MB of WebAssembly arrives when you press Run');
330
+ // "off" on its own says nothing about what is off. The word anchors what the
331
+ // pill is for, which is most of what makes a two-inch control discoverable.
332
+ count.textContent = 'Engine off';
333
+ render();
334
+
335
+ // How long this engine has been the engine. Kept rather than announced once,
336
+ // because "restarted 13:53:10" stops being visible the moment anything else
337
+ // happens — and it is precisely then that the reader wants it.
338
+ let age = null;
339
+
340
+ bus.on((event) => {
341
+ // A cell's own hide control moved something this panel is reporting on.
342
+ if (event.kind === 'answers') return refreshAnswers();
343
+ if (event.kind === 'booting') {
344
+ // Lit from wherever the engine was asked for — a Run halfway up the chapter
345
+ // starts it just as this button does, and the light should not care which.
346
+ bar.classList.add('busy');
347
+ count.textContent = 'Starting…';
348
+ return;
349
+ }
350
+ bar.classList.remove('busy');
351
+ if (event.kind === 'started') {
352
+ live = true;
353
+ label(restart, 'restart', 'Restart engine');
354
+ restart.title = 'throw this engine away and load your cells into a fresh one';
355
+ age = `started ${event.at}`;
356
+ } else if (event.kind === 'restarted') {
357
+ age = `restarted ${event.at}`;
358
+ } else if (event.kind === 'engine-version') {
359
+ // Joins the line that says what is running, because that is what it is.
360
+ // A rebuilt engine is the same build, so this is set once and left.
361
+ bar.querySelector('.engine-version').textContent = ` · SWI-Prolog ${event.version}`;
362
+ return;
363
+ }
364
+ if (!age) return;
365
+ say(`${loaded()} · ${age}`, event.kind === 'restarted'
366
+ ? 'assert/retract state is gone; the clauses in your cells were loaded again'
367
+ : 'SWI-Prolog is running in a Web Worker');
368
+ bar.classList.add('live');
369
+ // On or off, not a count. A count is only readable with the pill open, and by
370
+ // then the words beside it say the same thing at greater length.
371
+ count.textContent = 'Engine on';
372
+ });
373
+
374
+ // Work the whole chapter cold. Per-cell hiding is right there in each cell, but a
375
+ // reader who wants to do the exercises should not have to click every one of them
376
+ // first — and pressing Run on any cell brings that cell's answers back anyway.
377
+ //
378
+ // A STATE AND A VERB, like the engine beside it. A button labelled with its
379
+ // action always implies the opposite of what is true — "Hide saved answers" can
380
+ // only appear while they are visible — so a control with no state phrase leaves
381
+ // its own label as the only clue, and that clue has to be read backwards. That
382
+ // was the asymmetry: the engine had both, this had only the verb.
383
+ // Cells that HAVE saved answers in the file — but what this unit reports on is
384
+ // the ones SHOWING them. A cell displaying the reader's own run is not a spoiler:
385
+ // its saved answers are behind reset, and it refuses to hide precisely because
386
+ // they are no longer what is on screen.
387
+ //
388
+ // Counting the file rather than the screen is what jammed this control. The run
389
+ // cell could never be hidden, so "is anything still showing?" was permanently
390
+ // true, the set never reached all-hidden, the label never flipped to Show, and
391
+ // every click after the first re-hid the same cells and did nothing visible.
392
+ const spoilers = queries.filter((q) => q.hasSaved);
393
+ const showing = () => spoilers.filter((q) => q.showsChapter());
394
+ const answersUnit = bar.querySelector('.unit.answers');
395
+ const answersState = bar.querySelector('.answers-state');
396
+ let refreshAnswers = () => {};
397
+ if (!spoilers.length) {
398
+ answersUnit.remove();
399
+ } else {
400
+ const peek = document.createElement('button');
401
+ peek.dataset.act = 'peek-all';
402
+ peek.innerHTML = '<span class="icon"></span><span class="label"></span>';
403
+ peek.title = 'put every saved answer in this chapter out of sight, to work through it cold';
404
+ answersUnit.appendChild(peek);
405
+
406
+ refreshAnswers = () => {
407
+ // Counted rather than remembered, so hiding one output by hand leaves this
408
+ // telling the truth instead of contradicting the page.
409
+ const on = showing();
410
+ const hidden = on.filter((q) => q.isHidden()).length;
411
+ const all = on.length > 0 && hidden === on.length;
412
+ // Every cell is showing the reader's own answers: there is nothing here of
413
+ // the chapter's to put away. Saying so and going quiet beats offering a
414
+ // button that cannot do anything — a control that does nothing reads as a
415
+ // broken page, which is how this one was reported.
416
+ peek.disabled = on.length === 0;
417
+ answersState.textContent = on.length === 0 ? 'No saved answers on screen'
418
+ : hidden === 0 ? 'Answers shown'
419
+ : all ? 'Answers hidden'
420
+ : `${hidden} of ${on.length} hidden`;
421
+ label(peek, all ? 'show' : 'hide', all ? 'Show saved answers' : 'Hide saved answers');
422
+ render();
423
+ };
424
+
425
+ peek.addEventListener('click', () => {
426
+ // Anything still showing means the useful move is to hide the rest.
427
+ const on = showing();
428
+ const away = on.some((q) => !q.isHidden());
429
+ for (const q of on) q.setHidden(away);
430
+ refreshAnswers();
431
+ });
432
+ refreshAnswers();
433
+ }
434
+
435
+ restart.title = 'download SWI-Prolog and have it ready, so your first Run is not the slow one';
436
+
437
+ restart.addEventListener('click', async () => {
438
+ const starting = !live;
439
+ restart.disabled = true;
440
+ bar.classList.add('busy');
441
+ count.textContent = starting ? 'Starting…' : 'Restarting…';
442
+ say(starting ? 'starting SWI-Prolog (5.9 MB, first time only)…' : 'restarting…');
443
+ try {
444
+ if (starting) {
445
+ // Started empty, deliberately: consulting the chapter here would load
446
+ // cells the reader has not asked for, and Run loads what it needs anyway.
447
+ await boot(options, bus);
448
+ } else {
449
+ const session = await sessionFactory(options)(options);
450
+ await session.restart();
451
+ bus.emit({ kind: 'restarted', at: clock(), cells: [...session.log].length });
452
+ }
453
+ } catch (e) {
454
+ bar.classList.remove('busy');
455
+ count.textContent = live ? 'Engine on' : 'Engine off';
456
+ say(`${starting ? 'start' : 'restart'} failed: ${e.message}`);
457
+ show(10000);
458
+ } finally {
459
+ restart.disabled = false;
460
+ }
461
+ });
462
+
463
+ host.appendChild(bar);
464
+ render();
465
+ }
466
+
467
+ /**
468
+ * Add "download this notebook" to the page's controls.
469
+ *
470
+ * Called by whoever HAS the parsed model — page.js, because a markdown cell has
471
+ * been rendered to HTML by the time it reaches this file and cannot be read back
472
+ * out of the DOM. So notebook.js offers the affordance and someone else supplies
473
+ * the bytes; this module still knows nothing about markdown.
474
+ *
475
+ * A state and a verb, like everything else in that pill: whether the notebook on
476
+ * screen is still the chapter's, and the button that hands you a copy of it.
477
+ *
478
+ * @param {Element|Document} root
479
+ * @param {() => {filename: string, text: string}} produce
480
+ */
481
+ export function offerDownload(root, options = {}) {
482
+ const { produce, published = null, isEdited = null, on = null } = options;
483
+ const scope = root && root.querySelector ? root : document;
484
+ const bar = scope.querySelector('.page-controls') ?? document.querySelector('.page-controls');
485
+ if (!bar) return;
486
+
487
+ const unit = document.createElement('div');
488
+ unit.className = 'unit notebook';
489
+ // THE STATE IS THE CHOICE, once there is one to make.
490
+ //
491
+ // Every other row in this card says what is true and offers the verb that
492
+ // changes it. This row is the one place where the reader's version and the
493
+ // chapter's both exist at once, and what the button should hand them is
494
+ // genuinely a question — so the phrase that was reporting which of the two is
495
+ // on screen becomes the control that picks between them. One statement, one
496
+ // button, and no second download to be taken by mistake.
497
+ //
498
+ // A PHRASE UNTIL THERE ARE TWO. Before the reader has run or edited anything
499
+ // their version IS the chapter, and a menu with one item is a control
500
+ // pretending to offer something.
501
+ unit.innerHTML = '<span class="state notebook-state"><span class="only"></span>'
502
+ + '<span class="picker" hidden><select aria-label="which version to download">'
503
+ + '<option value="mine">Your version</option>'
504
+ + '<option value="published">As published</option>'
505
+ + `</select>${icon('chevron')}</span></span>`
506
+ + `<button data-act="download"><span class="icon">${icon('download')}</span>`
507
+ + '<span class="label">Download .prolog.md</span></button>';
508
+ bar.querySelector('.panel').prepend(unit);
509
+
510
+ const only = unit.querySelector('.only');
511
+ const picker = unit.querySelector('.picker');
512
+ const select = unit.querySelector('select');
513
+
514
+ const say = () => {
515
+ // ASKED, not sniffed. `data-edited` is written as a side effect of a cell's
516
+ // own reset button refreshing, so it is missing on a cell that has no reset
517
+ // and stale between an action and that cell's next refresh. The cells
518
+ // themselves are the authority on whether anything here is the reader's.
519
+ const edited = isEdited
520
+ ? isEdited()
521
+ : [...scope.querySelectorAll('.cell')].some((cell) => cell.dataset.edited === 'true');
522
+ // Offered only when both exist AND someone can produce the published copy.
523
+ const choose = edited && Boolean(published);
524
+ picker.hidden = !choose;
525
+ only.hidden = choose;
526
+ only.textContent = edited ? 'Your version' : 'As published';
527
+ // Not reset when the reader edits again: they chose, and a control that
528
+ // silently returns to its default hands them a file they did not pick.
529
+ if (!choose) select.value = 'mine';
530
+ unit.querySelector('button').title = choose && select.value === 'published'
531
+ ? 'the chapter exactly as published, without your edits — they stay on the page'
532
+ : 'this notebook as it now stands, with your edits and your answers in it';
533
+ };
534
+
535
+ unit.querySelector('button').addEventListener('click', () => {
536
+ const from = !picker.hidden && select.value === 'published' ? published : produce;
537
+ const { filename, text } = from();
538
+ download(filename, text);
539
+ });
540
+ select.addEventListener('change', say);
541
+
542
+ // Most of what changes this is a click or a keystroke, and both bubble.
543
+ scope.addEventListener('input', say);
544
+ scope.addEventListener('click', () => requestAnimationFrame(say));
545
+ // But not all of it. A cell can produce answers with nobody touching it — an
546
+ // automatic re-run is started by a consult somewhere else, and finishes after
547
+ // that click has been and gone — so the notebook's own events are heard too.
548
+ // Without this the row reports the state as it was before the run, until the
549
+ // reader happens to click again.
550
+ on?.(() => say());
551
+ say();
552
+ }
553
+
554
+ function sessionFactory(options) {
555
+ return options.createSession ?? createSession;
556
+ }
557
+
558
+ async function boot(options, bus, status) {
559
+ if (!bus.booted && status) {
25
560
  status.textContent = 'starting SWI-Prolog (5.9 MB, first time only)…';
26
561
  status.className = 'status busy';
27
562
  }
28
- const session = await createSession();
29
- booted = true;
563
+ const wasBooted = bus.booted;
564
+ if (!wasBooted) bus.emit({ kind: 'booting', at: clock() });
565
+ const session = await sessionFactory(options)(options);
566
+ bus.booted = true;
567
+ if (!wasBooted) {
568
+ bus.emit({ kind: 'started', at: clock() });
569
+ // Asked for, not waited for. The light going green is the answer to "did it
570
+ // start", and holding that back for a round trip about a version number
571
+ // would be the page reporting the less interesting fact first.
572
+ prologVersion(session)
573
+ .then((version) => version && bus.emit({ kind: 'engine-version', version }))
574
+ .catch(() => {});
575
+ }
30
576
  return session;
31
577
  }
32
578
 
33
- function mountProgram(cell) {
579
+ function mountProgram(cell, options, bus) {
34
580
  const source = cell.querySelector('textarea');
35
- const button = cell.querySelector('button');
581
+ const button = cell.querySelector('[data-act="consult"]') ?? cell.querySelector('button');
582
+ const resetBtn = cell.querySelector('[data-act="reset"]');
36
583
  const status = cell.querySelector('.status');
37
- const name = `cell${serial++}`;
584
+
585
+ /**
586
+ * The one cell whose state a re-consult does not undo (format §8).
587
+ *
588
+ * Chrome, not content: whether a cell is stateful is a fact about what happens
589
+ * when you run it, and a printed page has no engine for it to be true of. It is
590
+ * also derived from the text on every keystroke rather than at mount, so typing
591
+ * `:- dynamic` makes the badge appear — which is the moment the reader most
592
+ * wants to be told, rather than after they have asserted something and found it
593
+ * survived an edit.
594
+ */
595
+ const stateful = document.createElement('span');
596
+ stateful.className = 'badge stateful';
597
+ stateful.textContent = 'stateful';
598
+ stateful.hidden = true;
599
+ status.parentNode.insertBefore(stateful, status);
600
+
601
+ // One cell, one virtual file. A generated cell carries its notebook id, so SWI
602
+ // says "/p-family.pl" when this cell redefines another's clauses — a warning
603
+ // that names a cell the reader can actually find in the source.
604
+ const name = cell.dataset.cell || `cell-${serial++}`;
605
+
606
+ // The chapter's own version of this cell, for the way back. Correct today,
607
+ // because mount() runs against markup generated straight from the file. It
608
+ // becomes WRONG the moment a scratchpad restores the reader's edits before
609
+ // mount (869ectt5d) — at that point this must come from the parsed model.
610
+ const published = source.value;
611
+
612
+ // What the engine is actually holding for this cell, and when it took it.
613
+ let loaded = null;
614
+ let failure = null;
38
615
 
39
616
  autosize(source);
40
617
 
41
- button.addEventListener('click', async () => {
42
- const label = button.textContent;
43
- button.disabled = true;
44
- button.textContent = 'Working…';
618
+ const say = (text, cls, title) => {
619
+ status.textContent = text;
620
+ status.className = `status ${cls}`;
621
+ if (title) status.title = title;
622
+ else status.removeAttribute('title');
623
+ };
624
+
625
+ /**
626
+ * Say what is true, which is not always what the reader last pressed.
627
+ *
628
+ * A tick that still says "consulted" over text the reader has since edited is
629
+ * reassuring and wrong — and it became easy to hit the moment Run started
630
+ * consulting cells by itself (869ejgyaa).
631
+ *
632
+ * Every state is named, including the ones that used to be blank: a cell that
633
+ * says nothing looks like a cell whose buttons do nothing.
634
+ */
635
+ const refresh = () => {
636
+ const dynamic = declaredDynamic(source.value);
637
+ stateful.hidden = dynamic.size === 0;
638
+ if (!stateful.hidden) {
639
+ stateful.title = `this cell declares :- dynamic ${[...dynamic].join(', ')}.`
640
+ + ' Whatever a goal asserts into it lives in no file, so re-consulting the cell will'
641
+ + ' not undo it and neither will reset — restart the engine to clear it.';
642
+ }
643
+ if (resetBtn) {
644
+ // Enabled whenever this cell is not as the chapter published it — which
645
+ // includes being LOADED, because a published chapter has no engine at all.
646
+ // Reset staying grey after a consult is what made this read as broken: the
647
+ // reader had just changed the world and was offered no way back.
648
+ const mine = source.value !== published || loaded !== null;
649
+ // On the element, so the page-level control can read "is any of this the
650
+ // reader's?" without holding a reference to every cell.
651
+ cell.dataset.edited = String(source.value !== published);
652
+ resetBtn.disabled = !mine;
653
+ resetBtn.title = mine
654
+ ? 'undo this cell: the chapter’s program back, and out of the engine'
655
+ : 'this cell is exactly as the chapter published it';
656
+ }
657
+ if (failure) return say(failure, 'err');
658
+ if (!loaded) {
659
+ return say('not consulted', '',
660
+ 'the engine does not have this cell; Run on a query below loads it');
661
+ }
662
+ if (source.value !== loaded.text) {
663
+ return say('edited since consulted', 'warn',
664
+ `consulted ${loaded.at}; press Consult to load your changes`);
665
+ }
666
+ if (loaded.warning) return say(loaded.warning, 'warn', `consulted ${loaded.at}`);
667
+ return say(`✓ consulted ${loaded.at}`, 'ok', 'the engine is holding exactly this text');
668
+ };
669
+
670
+ /**
671
+ * @param {object} session
672
+ * @param {'consult'|'run'|'auto'} cause who asked for this — see the `consulted`
673
+ * event below. It is threaded rather than inferred because the difference
674
+ * between "the reader said this is what I mean now" and "the page reloaded
675
+ * its own state" is invisible from in here.
676
+ */
677
+ const consult = async (session, cause = 'consult') => {
678
+ const text = source.value;
679
+ const r = await session.consult(text, name);
680
+ // A warning here usually means this cell has just destroyed another
681
+ // cell's clauses, which the reader has no other way of finding out.
682
+ const warning = r.messages && r.messages.find((m) => m.kind === 'warning');
683
+ // Said the way this cell would say it: one cell is one virtual file, so SWI's
684
+ // line numbers are already the cell's own, and the path in front of them is a
685
+ // filename the reader never chose and cannot open.
686
+ failure = r.ok ? null : readableInCell(r.error, name);
687
+ loaded = r.ok
688
+ ? { text, at: clock(), warning: warning ? readableInCell(warning.text, name) : null }
689
+ : null;
690
+ refresh();
691
+ // Any answer below this cell was produced against whatever it held before.
692
+ // The cause travels with it: a cell that re-runs itself on a consult must not
693
+ // then react to the consults its own re-run performed (format §5).
694
+ bus.emit({ kind: 'consulted', name, at: clock(), cause });
695
+ return r;
696
+ };
697
+
698
+ source.addEventListener('input', () => {
699
+ refresh();
700
+ bus.emit({ kind: 'edited', name });
701
+ });
702
+
703
+ /** Any button that talks to the engine: busy while it does, honest afterwards. */
704
+ const working = async (btn, job) => {
705
+ const label = btn.textContent;
706
+ btn.disabled = true;
707
+ btn.textContent = 'Working…';
45
708
  try {
46
- const session = await boot(status);
47
- const r = session.consult(source.value, name);
48
- // A warning here usually means this cell has just destroyed another
49
- // cell's clauses, which the reader has no other way of finding out.
50
- const warning = r.messages && r.messages.find((m) => m.kind === 'warning');
51
- status.textContent = r.ok ? warning ? warning.text : '✓ consulted' : r.error;
52
- status.className = `status ${r.ok ? (warning ? 'warn' : 'ok') : 'err'}`;
709
+ await job();
53
710
  } catch (e) {
54
- status.textContent = e.message;
55
- status.className = 'status err';
711
+ failure = e.message;
56
712
  } finally {
57
- button.disabled = false;
58
- button.textContent = label;
713
+ btn.textContent = label;
714
+ // refresh() re-decides `disabled` from the state, so a button never comes
715
+ // back enabled just because it was the one that was pressed.
716
+ btn.disabled = false;
717
+ refresh();
718
+ }
719
+ };
720
+
721
+ button.addEventListener('click', () =>
722
+ working(button, async () => consult(await boot(options, bus, status))));
723
+
724
+ /**
725
+ * Undo this cell entirely: the chapter's program back, and out of the engine.
726
+ *
727
+ * Taking it out matters as much as putting the text back. A reader who presses
728
+ * reset has said "pretend I never touched this", and a page that restores the
729
+ * text while quietly leaving the clauses loaded has agreed with them in words
730
+ * and disagreed in fact.
731
+ *
732
+ * The cell is deliberately NOT re-consulted here, and nothing cascades to the
733
+ * cells that used it: Run on any query below consults the cells above it
734
+ * (869ejgyaa), so the chapter heals itself on the next click, and until then the
735
+ * tick says "not consulted" because that is what is true.
736
+ */
737
+ resetBtn?.addEventListener('click', () => working(resetBtn, async () => {
738
+ source.value = published;
739
+ autosizeNow(source);
740
+ if (loaded) {
741
+ const session = await boot(options, bus, status);
742
+ await session.unconsult(name);
743
+ loaded = null;
744
+ failure = null;
745
+ bus.emit({ kind: 'unconsulted', name, at: clock() });
746
+ }
747
+ bus.emit({ kind: 'edited', name });
748
+ }));
749
+
750
+ // A rebuilt engine consulted this cell again, at a new time. Saying the old one
751
+ // is a small lie that costs nothing to avoid and is invisible right up until the
752
+ // reader is trying to work out what the engine is holding — the one thing this
753
+ // tick exists to tell them.
754
+ bus.on((event) => {
755
+ if (event.kind === 'restarted' && loaded) {
756
+ loaded = { ...loaded, at: event.at };
757
+ refresh();
59
758
  }
60
759
  });
760
+
761
+ refresh();
762
+
763
+ return {
764
+ name,
765
+ /** The exact text the engine would be given, for a query deciding if it is stale. */
766
+ text: () => source.value,
767
+ isEdited: () => source.value !== published,
768
+ isLoaded: () => loaded !== null,
769
+ /**
770
+ * Load this cell unless it is already loaded at exactly this text.
771
+ *
772
+ * Cheap enough to do on every Run: the second Run of a chapter consults
773
+ * nothing at all, and an edited cell invalidates itself and nothing else.
774
+ */
775
+ ensure: async (session, cause = 'run') =>
776
+ (session.log.isCurrent(name, source.value) ? { ok: true } : consult(session, cause)),
777
+ /** What this cell defines, used only to explain an error, never to run one. */
778
+ defines: () => definedPredicates(source.value),
779
+ };
61
780
  }
62
781
 
63
- function mountQuery(cell) {
782
+ function mountQuery(cell, options, bus, { above = [], below = [], prediction = null } = {}) {
64
783
  const input = cell.querySelector('input');
65
784
  const runBtn = cell.querySelector('[data-act="run"]');
66
785
  const nextBtn = cell.querySelector('[data-act="next"]');
67
786
  const allBtn = cell.querySelector('[data-act="all"]');
787
+ const stopBtn = cell.querySelector('[data-act="stop"]');
788
+ const resetBtn = cell.querySelector('[data-act="reset"]');
789
+ const status = cell.querySelector('.status');
68
790
  const out = cell.querySelector('.out');
69
791
 
792
+ // The chapter's own query and the chapter's own answers, kept together because
793
+ // they only mean anything together (docs/modes.md §3). Same caveat as a program
794
+ // cell: correct until a scratchpad restores the reader's version before mount.
795
+ const published = { goal: input.value, out: out.innerHTML };
796
+
70
797
  let query = null;
798
+ let session = null;
71
799
  let count = 0;
800
+ let running = false;
801
+ let aborting = false;
802
+ // The reader's own run: when, against what — and what has happened since.
803
+ let ran = null;
804
+ // Kept as SWI rendered each solution, so an export carries the answers the
805
+ // reader actually saw rather than a reconstruction of them from the screen.
806
+ let produced = [];
807
+ let failed = null;
808
+ // Whether the SEARCH ended, which is not the same as whether the RUN ended. A
809
+ // reader who takes three of six and stops leaves a query that was never
810
+ // exhausted, and only the engine saying `done` can tell us otherwise — the
811
+ // buttons look identical either way (format §6).
812
+ let exhausted = false;
813
+ // Whose answers are on screen. A flag rather than a diff of out.innerHTML: the
814
+ // reader's own controls live in there too, and chrome must never read as a change
815
+ // to the chapter.
816
+ let mine = false;
817
+ let hidden = false;
818
+ // The author's own spoiler mark (format §5). It is a starting state rather than
819
+ // a lock: the reader can always press show, because withholding the answer from
820
+ // someone who has decided they want it is theatre, not teaching.
821
+ const hold = cell.dataset.hold ?? null;
822
+ let held = hold !== null && published.out !== '';
823
+ // Who decides when these answers are refreshed (format §5). `manual` is the
824
+ // default and is the whole of the behaviour this file had until now.
825
+ const auto = cell.dataset.rerun === 'auto';
826
+ // The renderer's verdict on the answers this cell was PUBLISHED with: their
827
+ // input-hash did not match the program above them, so the file itself shipped
828
+ // stale. Read once, because a run replaces the answers it is a claim about.
829
+ const savedStale = cell.dataset.stale === 'saved';
830
+ // Latched, unlike everything else here, because it is the one change that
831
+ // leaves no trace in the text: a rebuilt engine looks exactly like the old one.
832
+ let engineChanged = false;
833
+
834
+ /**
835
+ * Everything these answers depended on.
836
+ *
837
+ * Only the cells ABOVE, because those are the only ones Run loads. Compared as
838
+ * text rather than as a hash because the strings are already in hand and there
839
+ * are five of them, not five thousand.
840
+ */
841
+ const context = () => above.map((p) => `${p.name} ${p.text()}`).join('\n');
842
+
843
+ /**
844
+ * Why the answers on screen are no longer the answers this page would produce,
845
+ * or null.
846
+ *
847
+ * The question a reader cannot answer by looking, and the one that quietly makes
848
+ * a notebook untrustworthy when nobody answers it: they edit a program cell, the
849
+ * answers below sit there unchanged, and nothing says those answers came from a
850
+ * program that no longer exists. This is the live twin of the input-hash check
851
+ * the renderer does for SAVED answers (render.js) — the same question, asked of
852
+ * a run that happened a minute ago rather than at publish time.
853
+ *
854
+ * DERIVED, not remembered. A reader who edits a program cell and then undoes the
855
+ * edit is back where they started, and a warning that stays put through that
856
+ * teaches them to ignore warnings — which is worse than never having shown one.
857
+ */
858
+ const staleReason = () => {
859
+ if (!ran) return null;
860
+ // A restart replays the same clauses, so answers only really change for a
861
+ // query that depended on assert/retract state — but that is exactly the case
862
+ // format §8 says restart exists for, and the reader is owed the flag.
863
+ if (engineChanged) return 'engine restarted since this ran';
864
+ // Comparing the whole context ignores cells BELOW without this having to know
865
+ // which cell is which: they were never part of it.
866
+ if (ran.context !== context()) return 'program changed since this ran';
867
+ if (input.value.trim().replace(/\.$/, '') !== ran.goal) return 'query edited since this ran';
868
+ return null;
869
+ };
870
+
871
+ const refresh = () => {
872
+ if (resetBtn) {
873
+ const changed = mine || input.value !== published.goal;
874
+ cell.dataset.edited = String(changed);
875
+ resetBtn.disabled = !changed;
876
+ resetBtn.title = changed
877
+ ? 'put the chapter’s query and its saved answers back'
878
+ : 'this query and these answers are exactly as the chapter published them';
879
+ }
880
+ if (!status) return;
881
+ if (!ran) {
882
+ // Deliberately silent: the output's own first line already says whose
883
+ // answers those are, and a second label saying it again is noise.
884
+ status.textContent = '';
885
+ status.className = 'status';
886
+ status.removeAttribute('title');
887
+ return;
888
+ }
889
+ const stale = staleReason();
890
+ status.textContent = stale ?? `✓ ran ${ran.at}`;
891
+ status.className = `status ${stale ? 'warn' : 'ok'}`;
892
+ status.title = stale
893
+ ? `ran ${ran.at}; press Run to see what the program does now`
894
+ : 'these answers came from the cells above, exactly as they are now';
895
+ };
896
+
897
+ bus.on((event) => {
898
+ if (event.kind === 'restarted' && ran) engineChanged = true;
899
+ refresh();
900
+ });
901
+
902
+ /**
903
+ * Put the chapter's answers away, so the reader can work the question cold.
904
+ *
905
+ * A chapter shows its answers, always — that is the property the whole project
906
+ * is for (docs/modes.md §2), and it is why this is opt-in rather than the
907
+ * default. But prose that says "press Run for the first answer, then ; next to
908
+ * walk through the rest" is arguing with a page that has already printed all
909
+ * six, and the reader loses either the exercise or the trust.
910
+ *
911
+ * The answers are hidden, never discarded: they are the chapter's, and one click
912
+ * brings them back. That is also why this is not what reset does — there is
913
+ * nothing here to undo.
914
+ *
915
+ * The author can declare the same thing in the file — `hold="until-run"` on a
916
+ * query cell (format §5) — and it arrives here as the same mechanism, because a
917
+ * held answer and a hidden one differ only in who asked for it and what ends the
918
+ * wait. The reader's half needed no format change, which is why it came first.
919
+ */
920
+ /** What a held cell is waiting for, in the words of the thing that ends it. */
921
+ const heldNote = () => (hold === 'until-answered'
922
+ ? ' · held until you write your prediction above'
923
+ : ' · held until you run it');
924
+
925
+ const setHidden = (value) => {
926
+ const was = hidden;
927
+ hidden = value && !mine;
928
+ // Showing an answer ENDS THE AUTHOR'S WAIT for it, by whatever route it was
929
+ // shown — this cell's control, the page's, a prediction answered, a run, a
930
+ // reset afterwards. The reader has seen it, and a cell that goes back to
931
+ // saying "held until you run it" is arguing with them. Withholding it twice
932
+ // is theatre; the first time is the teaching.
933
+ if (!hidden) held = false;
934
+ // The page's own control reports on every cell, so it has to hear about one.
935
+ if (hidden !== was) bus.emit({ kind: 'answers' });
936
+ out.classList.toggle('answers-hidden', hidden);
937
+ const toggle = out.querySelector('[data-act="peek"]');
938
+ // Same word and same icon as the whole-chapter control in the page pill: one
939
+ // vocabulary, learned once.
940
+ if (toggle) label(toggle, hidden ? 'show' : 'hide', hidden ? 'show' : 'hide');
941
+ const note = out.querySelector('.peek-note');
942
+ // A held cell says WHY it is empty and what ends the wait. "hidden" alone
943
+ // reads as something the reader did and forgot doing.
944
+ if (note) note.textContent = hidden ? (held ? heldNote() : ' · hidden') : '';
945
+ };
946
+
947
+
948
+ /**
949
+ * CHROME, NOT CONTENT, and injected rather than rendered for the usual reason: a
950
+ * built page, an EPUB or the GitHub view must not carry a hide button that
951
+ * cannot work — and a printed chapter has no way to unhide.
952
+ */
953
+ const decorateSaved = () => {
954
+ const from = out.querySelector('.line.from');
955
+ if (mine || !from || from.querySelector('[data-act="peek"]')) return;
956
+ const note = document.createElement('span');
957
+ note.className = 'peek-note';
958
+ const toggle = document.createElement('button');
959
+ toggle.className = 'peek';
960
+ toggle.dataset.act = 'peek';
961
+ toggle.innerHTML = '<span class="icon"></span><span class="label"></span>';
962
+ toggle.title = 'the chapter’s answers stay here either way; this only puts them out of sight';
963
+ toggle.addEventListener('click', () => setHidden(!hidden));
964
+ from.append(note, toggle);
965
+ setHidden(hidden);
966
+ };
72
967
 
73
968
  const write = (text, cls) => {
74
969
  const line = document.createElement('div');
@@ -80,55 +975,315 @@ function mountQuery(cell) {
80
975
 
81
976
  const finish = () => {
82
977
  query = null;
978
+ running = false;
979
+ bus.stepping.delete(cell);
980
+ // Run must come back, whatever ended the query. Leaving it disabled after a
981
+ // Stop turns a rescued page into a dead one, which is worse than the freeze
982
+ // this whole mechanism exists to prevent.
983
+ runBtn.disabled = false;
83
984
  nextBtn.disabled = true;
84
985
  allBtn.disabled = true;
986
+ if (stopBtn) stopBtn.disabled = true;
987
+ refresh();
85
988
  };
86
989
 
87
- const step = () => {
88
- if (!query) return;
89
- const r = query.next();
90
- // r.text is rendered by SWI itself, so operators and quoting are right.
91
- if (r.solution) write(`${++count}. ${r.text ?? formatSolution(r.solution)}`, 'sol');
92
- if (r.error) {
93
- write(r.error, 'err');
94
- finish();
95
- return;
990
+ /** While a goal is in flight the only useful button is Stop. */
991
+ const setRunning = (state) => {
992
+ running = state;
993
+ runBtn.disabled = state;
994
+ nextBtn.disabled = state || !query;
995
+ allBtn.disabled = state || !query;
996
+ if (stopBtn) stopBtn.disabled = !state;
997
+ refresh();
998
+ };
999
+
1000
+ /**
1001
+ * Load every program cell above this query, in document order.
1002
+ *
1003
+ * Consult order cannot affect correctness — Prolog has no load-time name
1004
+ * binding, so `q(X) :- p(X)` merely mentions p/1 and the lookup happens when
1005
+ * it is called. So there is no dependency graph to compute, and at ~3.5 ms a
1006
+ * cell there is nothing to gain by computing one.
1007
+ */
1008
+ const loadPrograms = async (session, cause = 'run') => {
1009
+ for (const program of above) {
1010
+ const r = await program.ensure(session, cause);
1011
+ // Running a query against a chapter that failed to load would answer a
1012
+ // question the reader did not ask.
1013
+ if (!r.ok) return { ok: false, name: program.name, error: r.error };
96
1014
  }
97
- if (r.done) {
98
- write(count === 0 ? 'false.' : 'no more solutions.', 'done');
1015
+ return { ok: true };
1016
+ };
1017
+
1018
+ /**
1019
+ * "Unknown procedure: son_a/1" is true and unhelpful when the cell defining
1020
+ * son_a/1 is two inches further down the page.
1021
+ */
1022
+ const locate = (message) => {
1023
+ const indicator = unknownProcedure(message);
1024
+ if (!indicator) return null;
1025
+ const cell = below.find((program) => program.defines().has(indicator));
1026
+ return cell
1027
+ ? `${indicator} is defined below this query, in cell ${cell.name}. Press Consult there, or move that cell above the query.`
1028
+ : null;
1029
+ };
1030
+
1031
+ const step = async () => {
1032
+ if (!query || running) return false;
1033
+ setRunning(true);
1034
+ try {
1035
+ const r = await query.next();
1036
+ // r.text is rendered by SWI itself, so operators and quoting are right.
1037
+ if (r.solution) {
1038
+ const text = r.text ?? formatSolution(r.solution);
1039
+ produced.push(text);
1040
+ write(`${++count}. ${text}`, 'sol');
1041
+ }
1042
+ if (r.error) {
1043
+ failed = r.error;
1044
+ write(r.error, 'err');
1045
+ const hint = locate(r.error);
1046
+ if (hint) write(hint, 'done');
1047
+ finish();
1048
+ return false;
1049
+ }
1050
+ if (r.done) {
1051
+ exhausted = true;
1052
+ write(count === 0 ? 'false.' : 'no more solutions.', 'done');
1053
+ finish();
1054
+ return false;
1055
+ }
1056
+ setRunning(false);
1057
+ return true;
1058
+ } catch (e) {
1059
+ // An abort rejects whatever was in flight. When the reader asked for that,
1060
+ // it is not an error and saying "aborted" in red suggests something broke.
1061
+ if (!aborting) write(e.message, 'err');
99
1062
  finish();
1063
+ return false;
100
1064
  }
101
1065
  };
102
1066
 
103
- runBtn.addEventListener('click', async () => {
1067
+ /**
1068
+ * Run this cell's goal.
1069
+ *
1070
+ * The reader's own Run takes the first solution and waits, which is the whole
1071
+ * point of `; next`. An automatic one takes the sequence to the end — see
1072
+ * drain(), below, for why that is a correctness matter and not a preference.
1073
+ *
1074
+ * @param {{cause?: 'reader'|'auto'}} [options] `auto` when the page started this
1075
+ * rather than the reader — it changes what the output says about itself, and
1076
+ * it marks the consults this run performs so they cannot start it again.
1077
+ */
1078
+ const run = async ({ cause = 'reader' } = {}) => {
1079
+ // The chapter's saved answers are on screen until this moment. Replacing them
1080
+ // with the reader's own is fine; replacing them SILENTLY is not, so the run is
1081
+ // labelled and the way back is stated (docs/modes.md §3) — and the way back is
1082
+ // now a button on this cell rather than a page reload.
1083
+ const hadSaved = !mine && out.querySelector('.line.from') !== null;
104
1084
  out.innerHTML = '';
1085
+ mine = true;
1086
+ // This cell has just stopped showing the chapter's answers, which changes what
1087
+ // the page's control is counting. setHidden only speaks up when the hidden
1088
+ // flag itself moves, so a cell that was already visible would leave the count
1089
+ // reporting on a spoiler that is no longer on screen.
1090
+ bus.emit({ kind: 'answers' });
1091
+ produced = [];
1092
+ failed = null;
1093
+ exhausted = false;
1094
+ setHidden(false);
105
1095
  count = 0;
106
1096
  const goal = input.value.trim().replace(/\.$/, '');
107
1097
  if (!goal) return;
1098
+ const at = clock();
1099
+ // WHOSE ANSWERS THESE ARE is the one claim an output has always made, and an
1100
+ // automatic re-run is the first thing on this page that produces answers
1101
+ // nobody pressed a button for. Saying "your run" over those would be the page
1102
+ // attributing its own work to the reader.
1103
+ const whose = cause === 'auto' ? `re-run automatically · ${at}` : `your run · ${at}`;
1104
+ write(hadSaved
1105
+ ? `${whose} · press reset for the chapter’s saved answers`
1106
+ : whose, 'from');
108
1107
  write(`?- ${goal}.`, 'echo');
109
- runBtn.disabled = true;
1108
+ setRunning(true);
110
1109
  try {
111
- if (!booted) write('starting SWI-Prolog (5.9 MB, first time only)…', 'done');
112
- const session = await boot();
1110
+ if (!bus.booted) write('starting SWI-Prolog (5.9 MB, first time only)…', 'done');
1111
+ session = await boot(options, bus);
1112
+ const ok = await loadPrograms(session, cause === 'auto' ? 'auto' : 'run');
1113
+ // Recorded whatever happened, and recorded AFTER the consults: these answers
1114
+ // are the reader's either way, and the context is the one the goal actually
1115
+ // ran against rather than the one it was about to.
1116
+ ran = { at, goal, context: context() };
1117
+ engineChanged = false;
1118
+ if (!ok.ok) {
1119
+ failed = `cell ${ok.name} did not load: ${ok.error}`;
1120
+ write(failed, 'err');
1121
+ finish();
1122
+ return;
1123
+ }
113
1124
  query = session.query(goal);
114
- nextBtn.disabled = false;
115
- allBtn.disabled = false;
116
- step();
1125
+ bus.stepping.add(cell);
1126
+ // A sequence ends when another one starts — one engine, one open query
1127
+ // (869epzqpc). The reader hears it here, in the cell it happened to, with
1128
+ // the solutions they did take still under it and Run still lit. Silence was
1129
+ // the old behaviour and it was worse than an interruption: the cell looked
1130
+ // fine until they came back to it and got SWI's own words for a stack they
1131
+ // never knew existed.
1132
+ query.onSuperseded = () => {
1133
+ write('sequence closed — another query was run. Press Run to start this one again.', 'done');
1134
+ finish();
1135
+ };
1136
+ setRunning(false);
1137
+ // A re-run the reader did not ask for must not leave the page in a state
1138
+ // they did not ask for either: it takes the whole sequence, exactly as the
1139
+ // saved answers it replaced showed the whole sequence, and finishing is
1140
+ // what closes the query. A cell left mid-sequence would be a frame nobody
1141
+ // will ever step, and the next cell to run would be trapped underneath it.
1142
+ if (cause === 'auto') await drain();
1143
+ else await step();
117
1144
  } catch (e) {
118
1145
  write(e.message, 'err');
119
1146
  finish();
120
- } finally {
121
- runBtn.disabled = false;
122
1147
  }
123
- });
1148
+ };
1149
+
1150
+ runBtn.addEventListener('click', () => run());
1151
+
1152
+ /**
1153
+ * Would these answers be different if the page produced them now?
1154
+ *
1155
+ * The whole of what `rerun="auto"` acts on. It is deliberately not "did
1156
+ * something happen" — a Consult of a cell the reader never edited changes
1157
+ * nothing, and re-running four cells to print the same four answers is a page
1158
+ * being busy at the reader.
1159
+ */
1160
+ const outOfDate = () => {
1161
+ // Their own run: exactly the question the tick already answers out loud.
1162
+ if (ran) return staleReason() !== null;
1163
+ // Never run, and nothing on screen to be wrong: the author asked for this
1164
+ // cell to be live, and an empty box is not what the page would produce.
1165
+ if (published.out === '') return true;
1166
+ // Never run, showing the chapter's answers. Those were produced against the
1167
+ // chapter's own program, so they stop being true when a cell above is not the
1168
+ // one that was published — or when the file already shipped saying so.
1169
+ return savedStale || above.some((p) => p.isEdited());
1170
+ };
1171
+
1172
+ /**
1173
+ * `rerun="auto"`: the answers follow the program, without the reader
1174
+ * re-pressing Run in every cell below the one they just changed (869eddzgq).
1175
+ *
1176
+ * ON CONSULT, NEVER ON EDIT. Consult is the reader saying "this is what I mean
1177
+ * now"; a keystroke is not, and a page that re-ran mid-word would be handing
1178
+ * the engine half a clause and the reader a syntax error for something they
1179
+ * were still typing.
1180
+ *
1181
+ * AND NEVER WORK NOBODY ASKED FOR. Three things are deliberately not triggers:
1182
+ * page load, where a stale saved answer stays MARKED rather than pulling 5.9 MB
1183
+ * down a reader's connection; the consults an abort replays, which restore the
1184
+ * engine to what it already was and mean nothing changed; and this cell's own
1185
+ * re-runs, which is what the cause on the event is for. Auto follows the
1186
+ * reader's edits closely — it does not decide to work on its own.
1187
+ */
1188
+ if (auto) {
1189
+ bus.on((event) => {
1190
+ if (event.kind !== 'consulted' || event.cause === 'auto') return;
1191
+ // Only the cells this query actually runs against. A consult below it was
1192
+ // never part of these answers, and re-running for it would be a cell
1193
+ // reacting to something it cannot see.
1194
+ if (!above.some((program) => program.name === event.name)) return;
1195
+ // A held cell is forced to manual until its wait ends (format §5). Answering
1196
+ // a question the reader is still being asked is not a refresh, and a chapter
1197
+ // that quizzes the reader and then answers itself is worse than one that
1198
+ // never asked.
1199
+ if (held) return;
1200
+ if (running || query) return;
1201
+ if (!outOfDate()) return;
1202
+ bus.queue(() => {
1203
+ // MID-SEQUENCE ANYWHERE ON THE PAGE, not just in this cell. One engine
1204
+ // allows one open query, so an automatic re-run would close whatever the
1205
+ // reader is walking with `; next` (869epzqpc) — the page interrupting an
1206
+ // enquiry nobody asked it to interrupt. It stays as it is instead, and
1207
+ // the tick says the program has changed since, exactly as a manual cell
1208
+ // would; the next consult after they finish picks it up.
1209
+ //
1210
+ // Checked here rather than where the event arrived, because this is
1211
+ // queued behind the other cells' re-runs and the reader may have started
1212
+ // stepping in between.
1213
+ if (bus.stepping.size || running || query) return undefined;
1214
+ return run({ cause: 'auto' });
1215
+ });
1216
+ });
1217
+ }
124
1218
 
125
1219
  nextBtn.addEventListener('click', step);
126
- allBtn.addEventListener('click', () => {
1220
+
1221
+ /**
1222
+ * Take every solution, and leave no query open behind.
1223
+ *
1224
+ * THE SECOND HALF IS NOT A DETAIL. SWI's query frames are a stack: a query
1225
+ * opened while another is still open must be finished first, and stepping the
1226
+ * outer one afterwards fails with "Attempt to access not innermost query". An
1227
+ * abandoned sequence is not closed by anything — the worker forgets the id, the
1228
+ * frame stays — so the only thing that actually releases it is running it to
1229
+ * `done`. That is what this does, and it is why an automatic re-run uses it.
1230
+ */
1231
+ const drain = async () => {
127
1232
  let guard = 0;
128
- while (query && guard++ < 500) step();
1233
+ while (query && guard++ < 500) {
1234
+ if (!(await step())) break;
1235
+ }
129
1236
  if (guard >= 500) write('stopped after 500 solutions.', 'done');
1237
+ };
1238
+
1239
+ allBtn.addEventListener('click', drain);
1240
+
1241
+ stopBtn?.addEventListener('click', async () => {
1242
+ if (!session) return;
1243
+ aborting = true;
1244
+ stopBtn.disabled = true;
1245
+ write('stopping…', 'done');
1246
+ // Terminating the worker is the only thing that reaches a goal already
1247
+ // spinning inside WASM. The consults are replayed into the new engine, so the
1248
+ // clause store is back exactly as it was; only assert/retract state is lost,
1249
+ // which format §8 already says needs a restart anyway.
1250
+ await session.abort();
1251
+ write('stopped. the engine was restarted and every cell re-consulted.', 'done');
1252
+ finish();
1253
+ aborting = false;
1254
+ // Announced as a restart, because that is what it was: every other query that
1255
+ // had run is now showing answers from an engine that no longer exists.
1256
+ bus.emit({ kind: 'restarted', at: clock(), cells: [...session.log].length });
1257
+ });
1258
+
1259
+ /**
1260
+ * Put the chapter's query and its answers back.
1261
+ *
1262
+ * No CLAUSES change, and none need to: the chapter's saved answers make no
1263
+ * claim about what the engine is holding. They say whose they are, which is the
1264
+ * only claim they have ever made (docs/modes.md §3).
1265
+ *
1266
+ * The one piece of engine state it does give back is this cell's own open
1267
+ * query. A reader pressing reset has said "pretend I never ran this", and a
1268
+ * page that leaves a frame open in their name has agreed with them in words and
1269
+ * disagreed in fact — the same argument that makes a program cell's reset
1270
+ * un-consult.
1271
+ */
1272
+ resetBtn?.addEventListener('click', () => {
1273
+ query?.close();
1274
+ input.value = published.goal;
1275
+ out.innerHTML = published.out;
1276
+ mine = false;
1277
+ ran = null;
1278
+ engineChanged = false;
1279
+ decorateSaved();
1280
+ // Back in the set the page's control acts on, for the same reason.
1281
+ bus.emit({ kind: 'answers' });
1282
+ finish();
130
1283
  });
131
1284
 
1285
+ input.addEventListener('input', refresh);
1286
+
132
1287
  input.addEventListener('keydown', (e) => {
133
1288
  if (e.key === 'Enter') runBtn.click();
134
1289
  if (e.key === ';') {
@@ -136,13 +1291,59 @@ function mountQuery(cell) {
136
1291
  if (!nextBtn.disabled) step();
137
1292
  }
138
1293
  });
1294
+
1295
+ if (held) {
1296
+ hidden = true;
1297
+ // `change` rather than `input`: it fires when they leave the box, so the
1298
+ // answers do not appear under a reader who is still mid-sentence. An empty
1299
+ // box is not a prediction, so it does not end the wait.
1300
+ prediction?.addEventListener('change', () => {
1301
+ if (held && prediction.value.trim() !== '') setHidden(false);
1302
+ });
1303
+ }
1304
+
1305
+ decorateSaved();
1306
+ refresh();
1307
+
1308
+ return {
1309
+ id: cell.dataset.cell,
1310
+ hasSaved: published.out !== '',
1311
+ /**
1312
+ * Is the chapter's own output what this cell is showing right now?
1313
+ *
1314
+ * Not the same question as `hasSaved`, which is about the FILE. The page's
1315
+ * control acts on what is on screen, and after a run the chapter's answers
1316
+ * are behind reset rather than in front of the reader.
1317
+ */
1318
+ showsChapter: () => !mine && published.out !== '',
1319
+ setHidden,
1320
+ isHidden: () => hidden,
1321
+ isEdited: () => mine || input.value !== published.goal,
1322
+ goal: () => input.value,
1323
+ /**
1324
+ * This cell's answers for an export, in the format's own spelling (§6).
1325
+ *
1326
+ * `undefined` is a fourth answer that only this side has: the chapter's own
1327
+ * answers are on screen, so the file's output and its hash are left exactly
1328
+ * where they are. Everything else is the shared spelling — including the
1329
+ * empty terminator, which is what a reader who took two of six and moved on
1330
+ * has actually produced.
1331
+ */
1332
+ output: () => {
1333
+ if (!mine) return undefined;
1334
+ // Stopping and finishing look the same from outside — both leave no open
1335
+ // query — so `exhausted` is the only thing that distinguishes them.
1336
+ return solutionSequence({ solutions: produced, exhausted, error: failed });
1337
+ },
1338
+ };
1339
+ }
1340
+
1341
+ function autosizeNow(ta) {
1342
+ ta.style.height = 'auto';
1343
+ ta.style.height = `${ta.scrollHeight}px`;
139
1344
  }
140
1345
 
141
1346
  function autosize(ta) {
142
- const fit = () => {
143
- ta.style.height = 'auto';
144
- ta.style.height = `${ta.scrollHeight}px`;
145
- };
146
- ta.addEventListener('input', fit);
147
- requestAnimationFrame(fit);
1347
+ ta.addEventListener('input', () => autosizeNow(ta));
1348
+ requestAnimationFrame(() => autosizeNow(ta));
148
1349
  }