@beltype/cicero 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,8 @@ Cicero is a paged-media engine for the live browser. It cuts a document into pag
9
9
  | Import | What |
10
10
  | --- | --- |
11
11
  | `@beltype/cicero` | The host face: `ciceroEngine` (BelType's engine seam) and `rewriteDocumentSheets`. |
12
+ | `@beltype/cicero/engine` | `ciceroEngine` alone, without the sheet rewriter and its css-tree: a host that only serves a page (BelType's CLI) loads this. |
13
+ | `@beltype/cicero/flags` | `ciceroFlags` alone, the run-wide flags in common's shape. |
12
14
  | `@beltype/cicero/rewrite` | `rewriteDocumentSheets` alone, for a page: the host face also carries `ciceroEngine`, which imports node's `url` and so cannot load in a browser. |
13
15
  | `@beltype/cicero/client` | The page side: loading it sets `window.__beltypeCicero`, typed in `seam/handle.d.ts`. |
14
16
  | `@beltype/cicero/facsimile` | The embedded PDF page (`<object type="application/pdf">`). |
@@ -110,6 +110,15 @@ export declare function run(opts?: {
110
110
  laid: number;
111
111
  } | null;
112
112
  }>;
113
+ /**
114
+ * Let go of everything this client registered in the page, so a document that replaces it (`document.open()` keeps the `Window`, its `FontFaceSet` and its listeners) is not answered by this one's.
115
+ *
116
+ * It removes the `loadingdone` listener, stops the rim, drops the batch of late truth waiting to be answered and deletes the handle from the window, all before the first `await`. A run stopped at a page is let go and its pages stay on screen. A run under way with a pace (`until`) rejects with the let-go error at its next page, and a first run that has shown nothing puts the document back as it found it; one without a pace finishes and presents. When nothing is under way, the promise resolves, after the root's idle build, observer and window listeners and the facsimile's observer have been let go. A `run()` made afterwards is rejected by name. Calling it again is a no-op.
117
+ *
118
+ * @remarks **Host obligations.** The custom-element registry belongs to the window and is not released: the next document's `<beltype-root>`, `<beltype-page>` and PDF element are built from the classes this module defined, which keep no state about the document they were defined for. The handle is gone, and the next client module runs only under a URL the window has not imported (measured 2026-11-03: the same URL after `document.open()` does not run again, a keyed one does, and a second `customElements.define` of a name throws, which the three definitions guard against). The host keys the URL.
119
+ * @see SPEC.md Article XII.7
120
+ */
121
+ export declare function release(): Promise<void>;
113
122
  /** THE HANDLE — `window.__beltypeCicero`, the host's one door onto the engine in the page. The calls are set at load; the doors onto the presentation are seated by every presentation ({@link seatDoors}), so they are absent before the first; `last` and `ledger` are the last run's. */
114
123
  export type Handle = {
115
124
  run: typeof run;
@@ -135,5 +144,6 @@ export type Handle = {
135
144
  addressOf?: (el: Element, word?: number) => Address | null;
136
145
  box?: (i: number) => Element | null;
137
146
  patch?: typeof patchBox;
147
+ release: typeof release;
138
148
  };
139
149
  export {};
@@ -65,8 +65,11 @@ let fontEpoch = 0;
65
65
  /** The document's face set, kept: it is the same object for the life of the document, and a test that watches how often the engine asks the document for it (progressive.test.js) counts the intake's and the driver's asks and no others. */
66
66
  const fonts = document.fonts;
67
67
  knowLoadedFaces(fonts);
68
- fonts.addEventListener('loadingdone', (event) => { if (bringsNewFace(event))
69
- fontEpoch++; });
68
+ const countFaces = (event) => { if (bringsNewFace(event))
69
+ fontEpoch++; };
70
+ fonts.addEventListener('loadingdone', countFaces);
71
+ /** Set by {@link release}: this module instance is finished with its document, and answers no more. A flag of this instance, never of the classes it defined, which the next document's module scripts inherit from the window. */
72
+ let released = false;
70
73
  /**
71
74
  * The run's conditions, as one string (*a break decision is valid only under render-identical conditions* (I5), applied to the memo — Decision one of *the run resumes from an entry*): the measuring realm's media environment — the viewport, the device pixel ratio, the colour scheme — the text of the stylesheets the document wears, as the rewrite and the presentation left them (the document's own: a sheet inside an `<svg>` or inside a copy the engine made is a box's, and what it changes is that box's extent, which the rim hears — *the conditions are the sheets of the document*, 2026-10-22), and the font epoch the run began under, and how many presenters are registered (*a copy may be presented*: a presenter is a condition of every copy, and a registration after a run is seen here as the change it is). The delivered read values are the driver's to compare, not this string's. Taken after a presentation and again before the next run, so the two readings see the same engine litter (the press `<style>` is left out) and a difference between them is the host's own.
72
75
  * @param epoch - {@link fontEpoch} as the run began, which is not the count at the moment of asking: a face that arrived during the run must show at the next one.
@@ -152,15 +155,16 @@ function patchBox(index, path, el) {
152
155
  boxes[index] = el;
153
156
  }
154
157
  rim?.add(boxes[index], top); // the replacement may hold a picture still loading, or an element not yet defined
155
- const address = window.__beltypeCicero.addressOf(el);
158
+ const address = handle.addressOf(el);
156
159
  if (address)
157
160
  edited.push(address);
158
161
  return address;
159
162
  }
160
163
  /** THE RIM, from the intake until the frame lets the run go (`rim.js`, *late truth is an edit*): the subscription to the facts that arrive after a measure. `null` before the intake. */
161
164
  let rim = null;
165
+ function stopRim() { rim?.stop(); rim = null; }
162
166
  /**
163
- * The rim's default action (*late truth is an edit*, Decision four, 2026-10-13): what a signal the host did not cancel does. Signals are coalesced over one animation frame into one batch; a batch that holds a font is one whole run (a face is a condition of every box), and any other — an image, an element, a store's write, a box's size — is one `run({ resumeAt })` per box, the earliest in the document first and one at a time, since convergence is believed past one edited box. A signal that arrives while a run is under way waits for it; a run stopped at a page is let go and the answer stays within the pages it had cut, as an edit's does. The runs go through the host's own door, `window.__beltypeCicero.run`, so a host that wraps it sees the rim's runs as it sees its own.
167
+ * The rim's default action (*late truth is an edit*, Decision four, 2026-10-13): what a signal the host did not cancel does. Signals are coalesced over one animation frame into one batch; a batch that holds a font is one whole run (a face is a condition of every box), and any other — an image, an element, a store's write, a box's size — is one `run({ resumeAt })` per box, the earliest in the document first and one at a time, since convergence is believed past one edited box. A signal that arrives while a run is under way waits for it; a run stopped at a page is let go and the answer stays within the pages it had cut, as an edit's does. The runs go through the host's own door, the `run` property of the handle object this module put on the window, read at each call, so a host that replaces that property (the essay's init scripts and tests do) sees the rim's runs as it sees its own. A host that replaces the whole object `window.__beltypeCicero` does not: the rim keeps calling this module's own handle (so a client that was released, or one a newer client replaced, never reaches into the next document's), and nothing in the repositories searched does that.
164
168
  *
165
169
  * A box heard through a piece a page draws it with (*a fragment announces its own size*, 2026-10-22) carries the page it was heard on, and its resume is at the address that page begins at, the earliest such page; every other box carries `null` and is resumed at its own address.
166
170
  */
@@ -200,13 +204,13 @@ async function lateTruth() {
200
204
  while (batch) {
201
205
  const { font, boxes } = batch;
202
206
  batch = null;
203
- const h = window.__beltypeCicero;
207
+ const h = handle;
204
208
  try {
205
209
  /** One step, after any run under way has ended; `null` where the run before left nothing to answer. */
206
210
  const ready = async () => {
207
211
  while (live && !live.gate)
208
212
  await live.finished;
209
- return previous?.composed ?? null;
213
+ return released ? null : previous?.composed ?? null;
210
214
  };
211
215
  if (font) {
212
216
  const composed = await ready();
@@ -269,6 +273,8 @@ function reached(session) {
269
273
  /** The driver's `pace`: called after every page of the first layout. It stops the run where the host asked it to stop — by returning a promise the next call releases — and otherwise lets it go on without a pause. */
270
274
  function pacer(session, progress) {
271
275
  session.progress = progress;
276
+ if (session.cancelled)
277
+ return Promise.reject(LET_GO); // a run released while it measures stops at its next page
272
278
  if (!reached(session))
273
279
  return undefined;
274
280
  return new Promise((resolve, reject) => {
@@ -324,8 +330,7 @@ async function takeIntake(relaxDpr) {
324
330
  for (const [el, parent, next] of moved.toReversed())
325
331
  parent?.insertBefore(el, next);
326
332
  root.remove();
327
- rim?.stop();
328
- rim = null;
333
+ stopRim();
329
334
  presented = null;
330
335
  }
331
336
  };
@@ -354,7 +359,7 @@ async function cutTo(el) {
354
359
  if (!session)
355
360
  return;
356
361
  if (session.gate)
357
- await window.__beltypeCicero.run({ composed: session.composed, until: el });
362
+ await handle.run({ composed: session.composed, until: el });
358
363
  else
359
364
  await session.finished;
360
365
  }
@@ -407,7 +412,7 @@ function show(session, { account, index, total, from, to, resume }) {
407
412
  /** The host's doors onto the presentation, seated on the handle after every presentation (*the engine answers the page*, 2026-09-04; beltype#19, beltype#20). A verb, never the index: the tokenization stays in the realm it is checked in, and a verb survives the parked horizon of the engine leaving this realm where an exposed index holding live DOM references would not. */
408
413
  function seatDoors(blocks) {
409
414
  const { root } = presented;
410
- const h = window.__beltypeCicero;
415
+ const h = handle;
411
416
  h.pageOf = (el, charOffset = 0) => root.pageOf(el, charOffset);
412
417
  // The windowing's doors (*only the pages near the reader are built*, 2026-10-12): the pages near the reader are built with the presentation and the rest as the reader nears them or the browser is idle, so a host about to read a page asks for it first. `reveal` builds the page that begins a source element and scrolls its drawn copy into view; `buildPage` builds one page; `buildAll` builds every page and is awaited by whoever reads them all (the printer, a gate); `built` says how many pages have their content.
413
418
  h.reveal = (el, options) => root.reveal(el, options);
@@ -452,6 +457,8 @@ function seatDoors(blocks) {
452
457
  * @returns what the console line reports; the presented pages stay live in the DOM. `pages` is how many are cut; `complete` whether that is the document's end. `resumed` names the pages cut again, from 1: `from` through `to`, with `shift` the pages the run added (or, negative, removed) and `kept` the prior pages after `to` left standing. `why` is a plain sentence, set exactly when `resumed` is `null`: the first run on a frame, no `resumeAt`, another mode, conditions that moved (and which part), a block count that moved, or a reference whose page number sent the run round again. `composition` is the composed mode's count (`null` in the default): `misses` the paragraphs composed, `hits` the ones found already composed, `blocks` their sum, and `laid` the top-level blocks cloned and measured to read their widths (the rest were answered by the cache whole).
453
458
  */
454
459
  export function run(opts) {
460
+ if (released)
461
+ return Promise.reject(new Error('BelType/Cicero: this client was released; the next document loads its own'));
455
462
  const call = runOnce(opts);
456
463
  calls.add(call);
457
464
  const answered = () => calls.delete(call);
@@ -543,12 +550,14 @@ async function runOnce({ composed = false, resumeAt = null, until = null, layer
543
550
  ...(target != null ? { pace: (progress) => pacer(session, progress) } : {})
544
551
  });
545
552
  live = session;
553
+ if (released)
554
+ session.cancelled = true; // called before the release, so the first page it cuts is its last
546
555
  return settle(session, t0);
547
556
  }
548
557
  async function carryOn(session, until, t0) {
549
558
  session.target = until;
550
559
  if (reached(session))
551
- return window.__beltypeCicero.last; // already cut as far as it is asked
560
+ return handle.last; // already cut as far as it is asked
552
561
  dress(false);
553
562
  presented.root.hold();
554
563
  const settled = settle(session, t0);
@@ -569,6 +578,8 @@ async function settle(session, t0) {
569
578
  catch (error) {
570
579
  if (live === session)
571
580
  live = null;
581
+ if (error === LET_GO && released && !session.partial && session.fresh)
582
+ presented?.undo(); // a first run released before it showed a page
572
583
  if (error !== LET_GO) {
573
584
  forcingMemo.clear(); // a run that threw filled it with reads no one vouched for
574
585
  if (!session.partial && session.fresh)
@@ -596,11 +607,11 @@ async function settle(session, t0) {
596
607
  edited.splice(0, session.taken);
597
608
  session.taken = 0;
598
609
  compositionCache.conditions = conditions;
599
- window.__beltypeCicero.last = { ...answerOf(session, t0, progress, resumed), complete: false };
600
- window.__beltypeCicero.ledger = progress.account;
610
+ handle.last = { ...answerOf(session, t0, progress, resumed), complete: false };
611
+ handle.ledger = progress.account;
601
612
  console.info(`BelType/Cicero: ${k} pages cut so far, in ${Math.round(performance.now() - t0)} ms — the run is stopped at a page and goes on when asked`);
602
613
  await reloadKnownFaces(); // the presentation re-created a layered frame's faces, and the next slice measures in them
603
- return window.__beltypeCicero.last;
614
+ return handle.last;
604
615
  }
605
616
  // Complete: the run reached the document's end.
606
617
  const result0 = outcome.result;
@@ -633,9 +644,9 @@ async function settle(session, t0) {
633
644
  session.taken = 0;
634
645
  compositionCache.conditions = conditions; // the reading after the presentation, which is the one the next call sees (the first call's was taken before there was any litter to leave out)
635
646
  // A host embedding this document (the editing tools' preview iframe, same-origin) reads its own render condition here rather than the devtools console nobody opens — set before returning, on the same handle the bootstrap already holds.
636
- window.__beltypeCicero.last = result;
647
+ handle.last = result;
637
648
  // The run's Ledger itself, beside the summary and not inside it: `last` is read across frames and serialised by hosts, and the account holds live `box` pointers that no host should be handed by accident. A venue test reads it in-page to compare two runs entry by entry (*the run resumes from an entry*).
638
- window.__beltypeCicero.ledger = account;
649
+ handle.ledger = account;
639
650
  if (live === session)
640
651
  live = null;
641
652
  // The presentation re-created a layered frame's faces, and a host reads the pages next: their loads are STARTED here and not awaited, so they are `loading` (and `document.fonts.ready` waits for them) by the time the call answers. Awaiting them put a task between the presentation and the answer, and a host that puts the caret back when the run answers (the studio experiment) lost a keystroke typed in that gap: the browser had no caret to type at, and fired no beforeinput.
@@ -654,5 +665,36 @@ function answerOf(session, t0, progress, resumed) {
654
665
  composition: session.composed ? { blocks: compositionCache.hits + compositionCache.misses, hits: compositionCache.hits, misses: compositionCache.misses, laid: compositionCache.laid } : null
655
666
  };
656
667
  }
657
- window.__beltypeCicero = { run, present,
668
+ // The shell's bootstrap reaches the engine through this handle. Setting it is the module's only load-time effect — rendering waits to be asked, because the bootstrap knows when the document has stopped changing and this module does not. `pageOf` is seated by `run()` rather than here: before a presentation there is no page to answer, and a stub that answered `null` for that reason would be indistinguishable from the refusals that mean something.
669
+ // `compositionCache` is the harness's door and the product's none: a venue test clears it to ask for a cold composition in a frame that has composed before, which is the only way a resumed run can be compared with a fresh one that did not borrow its records.
670
+ // `forcingMemo` and `forgetJudgedSheets` are the same kind of door, for the same comparison: a venue test empties them to ask what a run costs, and what it cuts, when it borrows nothing from the runs before it (*the forced-break index is kept across runs*).
671
+ // `present` is the host's say over what a copy of an element is (*a copy may be presented*, `src/presenters.js`): `present(selector, make)`, registered before the first run. One registered after a run changes the conditions key, so the next call is a whole run on a presented frame — nothing a presenter touched is reused — and a run stopped at a page goes on under the presenters it began with, its pages already built keeping the elements they were built with.
672
+ // `measured` is the same kind of door again (*a box announces its own size*): a venue test asks what height the last run measured for a top-level box, the number the `size` kind compares a presented box's observed size with.
673
+ // `table` is the geometry the intake captured from the author's `@page` rules, margin boxes and their `content` included, or null before an intake: the root clears every margin box's `content` from the CSSOM when it presents, so a second host in the same document, whose pages are not the root's, cannot read a table of its own from the sheets and takes this one (see CHANGELOG 2026-10-28).
674
+ /**
675
+ * Let go of everything this client registered in the page, so a document that replaces it (`document.open()` keeps the `Window`, its `FontFaceSet` and its listeners) is not answered by this one's.
676
+ *
677
+ * It removes the `loadingdone` listener, stops the rim, drops the batch of late truth waiting to be answered and deletes the handle from the window, all before the first `await`. A run stopped at a page is let go and its pages stay on screen. A run under way with a pace (`until`) rejects with the let-go error at its next page, and a first run that has shown nothing puts the document back as it found it; one without a pace finishes and presents. When nothing is under way, the promise resolves, after the root's idle build, observer and window listeners and the facsimile's observer have been let go. A `run()` made afterwards is rejected by name. Calling it again is a no-op.
678
+ *
679
+ * @remarks **Host obligations.** The custom-element registry belongs to the window and is not released: the next document's `<beltype-root>`, `<beltype-page>` and PDF element are built from the classes this module defined, which keep no state about the document they were defined for. The handle is gone, and the next client module runs only under a URL the window has not imported (measured 2026-11-03: the same URL after `document.open()` does not run again, a keyed one does, and a second `customElements.define` of a name throws, which the three definitions guard against). The host keys the URL.
680
+ * @see SPEC.md Article XII.7
681
+ */
682
+ export async function release() {
683
+ if (released)
684
+ return;
685
+ released = true;
686
+ fonts.removeEventListener('loadingdone', countFaces);
687
+ stopRim();
688
+ batch = null;
689
+ if (window.__beltypeCicero === handle)
690
+ delete window.__beltypeCicero;
691
+ await Promise.allSettled([...calls]); // a call made before the release has no session yet: it gets one, cancelled, in `runOnce`
692
+ if (live)
693
+ await letGo();
694
+ stopRim(); // the intake of a call that was under way made one after the first stop
695
+ presented?.root.dispose(); // last, so a presentation that finished above does not arm what it just let go
696
+ facsimile?.dispose();
697
+ }
698
+ const handle = { run, present, release,
658
699
  settled, compositionCache, forcingMemo, forgetJudgedSheets: () => { judgedSheets = new WeakMap(); }, measured: (el) => previous?.result.heights?.get(el), get table() { return presented?.table ?? null; } };
700
+ window.__beltypeCicero = handle;
@@ -38,6 +38,8 @@ export declare function locate(source: Element): {
38
38
  * @remarks The element has no child until {@link draw} gives it its canvas (*a facsimile's copy holds its box off screen*, CHANGELOG 2026-10-28). An `<object>` with a child and no `data` decides at its first layout to render the child, which is a reattach of its layout tree, and on a page the browser skips (`content-visibility: auto`, *a page is contained*) a read from script lays the subtree out for the length of the read, drops the reattach when it puts the subtree back, and leaves the copy with no box at all. Measured 2026-10-28: the same copy with no child keeps its box on every read.
39
39
  */
40
40
  export declare function make(source: Element): HTMLObjectElement;
41
+ /** Let go of the observer, which the next element to connect makes again: the element's class is the window's and outlives the client that defined it. */
42
+ export declare function dispose(): void;
41
43
  /**
42
44
  * Define the element, read the size of every page the document embeds and register the presenter — before the first run (the intake calls this first, and awaits it), because a presenter is a condition of every copy and a registration after a run is a whole run.
43
45
  *
@@ -165,6 +165,8 @@ const watcher = () => seen ??= new IntersectionObserver((entries) => {
165
165
  draw(entry.target);
166
166
  }
167
167
  }, { rootMargin: '300px' });
168
+ /** Let go of the observer, which the next element to connect makes again: the element's class is the window's and outlives the client that defined it. */
169
+ export function dispose() { seen?.disconnect(); seen = null; }
168
170
  /** The drawn page, at the box's width. The canvas takes the page's own ratio and the CSS says how it sits (`object-fit: contain`, in the box the author sized). */
169
171
  async function draw(el) {
170
172
  if (!el.isConnected || el.closest('#__beltype-measure'))
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Cicero's run-wide flags, in `@beltype/common`'s shape.
3
+ *
4
+ * @remarks
5
+ * One today: `composed`. The other options of a `run()` call — `until`, `resumeAt` and `layer` — belong to a single call, not to a run, and are not flags; nor is a constant someone may want to tune later, until someone asks.
6
+ *
7
+ * This is the node face's table. The client runs in a tab with no way to resolve `@beltype/common`, so `client.ts` keeps the literal `composed = false` as `run()`'s default, and `test/flags.test.ts` pins that literal to this table's default.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export declare const ciceroFlags: {
12
+ readonly owner: string;
13
+ readonly table: {
14
+ /** Default `false`: the browser sets the lines and Cicero reads what it produced. `true` gives them to the Compositor (*the composed mode reaches the operator*, 2026-09-10). */
15
+ composed: {
16
+ values: boolean[];
17
+ default: boolean;
18
+ };
19
+ };
20
+ readonly defaults: import("@beltype/common/flags").FlagValues<{
21
+ /** Default `false`: the browser sets the lines and Cicero reads what it produced. `true` gives them to the Compositor (*the composed mode reaches the operator*, 2026-09-10). */
22
+ composed: {
23
+ values: boolean[];
24
+ default: boolean;
25
+ };
26
+ }>;
27
+ resolve(given?: Partial<import("@beltype/common/flags").FlagValues<{
28
+ /** Default `false`: the browser sets the lines and Cicero reads what it produced. `true` gives them to the Compositor (*the composed mode reaches the operator*, 2026-09-10). */
29
+ composed: {
30
+ values: boolean[];
31
+ default: boolean;
32
+ };
33
+ }>> | undefined): import("@beltype/common/flags").FlagValues<{
34
+ /** Default `false`: the browser sets the lines and Cicero reads what it produced. `true` gives them to the Compositor (*the composed mode reaches the operator*, 2026-09-10). */
35
+ composed: {
36
+ values: boolean[];
37
+ default: boolean;
38
+ };
39
+ }>;
40
+ parse(name: string, raw: string): unknown;
41
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Cicero's run-wide flags, in `@beltype/common`'s shape.
3
+ *
4
+ * @remarks
5
+ * One today: `composed`. The other options of a `run()` call — `until`, `resumeAt` and `layer` — belong to a single call, not to a run, and are not flags; nor is a constant someone may want to tune later, until someone asks.
6
+ *
7
+ * This is the node face's table. The client runs in a tab with no way to resolve `@beltype/common`, so `client.ts` keeps the literal `composed = false` as `run()`'s default, and `test/flags.test.ts` pins that literal to this table's default.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { defineFlags } from '@beltype/common/flags';
12
+ export const ciceroFlags = defineFlags('cicero', {
13
+ /** Default `false`: the browser sets the lines and Cicero reads what it produced. `true` gives them to the Compositor (*the composed mode reaches the operator*, 2026-09-10). */
14
+ composed: { values: [false, true], default: false }
15
+ });
@@ -2,9 +2,10 @@
2
2
  * The host face, `@beltype/cicero`: what a host that is not BelType's CLI needs to paginate a document with Cicero.
3
3
  *
4
4
  * @remarks
5
- * `ciceroEngine` is the engine's side of BelType's engine seam (its head, its bootstrap, its vendored assets); `rewriteDocumentSheets` makes a document's sheets Cicero's (the `@page` world renamed, optionally under a layer the host names). The page-side API is the client handle, `window.__beltypeCicero`, typed in `seam/handle.d.ts`.
5
+ * `ciceroEngine` is the engine's side of BelType's engine seam (its head, its bootstrap, its vendored assets); `rewriteDocumentSheets` makes a document's sheets Cicero's (the `@page` world renamed, optionally under a layer the host names). A host that needs one of them alone imports the subpath (`@beltype/cicero/engine`, `@beltype/cicero/flags`, `@beltype/cicero/rewrite`), since this face loads all three and the sheet rewriter brings css-tree with it (about 24 ms in node). The page-side API is the client handle, `window.__beltypeCicero`, typed in `seam/handle.d.ts`.
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
9
  export { ciceroEngine } from './index.ts';
10
10
  export { rewriteDocumentSheets } from './rewrite-document.ts';
11
+ export { ciceroFlags } from './flags.ts';
package/dist/seam/host.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * The host face, `@beltype/cicero`: what a host that is not BelType's CLI needs to paginate a document with Cicero.
3
3
  *
4
4
  * @remarks
5
- * `ciceroEngine` is the engine's side of BelType's engine seam (its head, its bootstrap, its vendored assets); `rewriteDocumentSheets` makes a document's sheets Cicero's (the `@page` world renamed, optionally under a layer the host names). The page-side API is the client handle, `window.__beltypeCicero`, typed in `seam/handle.d.ts`.
5
+ * `ciceroEngine` is the engine's side of BelType's engine seam (its head, its bootstrap, its vendored assets); `rewriteDocumentSheets` makes a document's sheets Cicero's (the `@page` world renamed, optionally under a layer the host names). A host that needs one of them alone imports the subpath (`@beltype/cicero/engine`, `@beltype/cicero/flags`, `@beltype/cicero/rewrite`), since this face loads all three and the sheet rewriter brings css-tree with it (about 24 ms in node). The page-side API is the client handle, `window.__beltypeCicero`, typed in `seam/handle.d.ts`.
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
9
  export { ciceroEngine } from './index.js';
10
10
  export { rewriteDocumentSheets } from './rewrite-document.js';
11
+ export { ciceroFlags } from './flags.js';
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * **With a layer named, every sheet of the document goes into it** (*the author's sheets in a layer the host names*, 2026-10-17): a `<style>` holds its text wrapped (`wrapInLayer`), and every `<link>` becomes a `<style>` of its wrapped text at the same position. **The address is not kept, on a measurement**: `<style>@import url(<the href>) layer(<name>)</style>` would keep it, and Chromium fetches an imported sheet once for every frame that imports it, but it parses it again in each (measured 2026-10-17 in the studio experiment, an 11 MB sheet in four same-origin frames: +9 MB a further frame linked, +61–74 MB imported), so an import buys a host no shared copy a `<style>` does not, and it hides the sheet from every walk that reads `document.styleSheets`' top level (`@page`, `@font-face` for a printer, `position: fixed`). A host that shared a linked sheet's parsed copy across frames loses that sharing under a layer, and the loss is the host's to weigh. **And the engine's root line-height provision moves into the engine's own layer**, the first one: unlayered, it would outrank every rule of the author's now-layered sheets, the author's own root line-height among them, where it was written to lose every tie. **And the order of the two layers is stated first in the head**, `@layer <the engine's>, <the host's>;` in the engine's own layers element (made where it is not there yet): a layer's place is where it is first named, and a host that seats the author's sheets before the engine's head (the HTML lane, the studio, its print frame) would otherwise name the author's layer before the engine's, putting the engine's `!important` trimming below an author's and its provision above the author's normal rules. Without a layer nothing here changes.
11
11
  *
12
+ * **A replaced `<link>`'s relative addresses are written against its href** (`rebaseUrls`), because a `<style>` resolves them against the document.
13
+ *
12
14
  * **The `<link>` is replaced, not merely disabled.** A disabled link leaves the browser's own copy of the rules out of the CSSOM but leaves the element in the document, and the next thing to walk `document.styleSheets` would meet two sheets where the author wrote one.
13
15
  *
14
16
  * @packageDocumentation
@@ -9,11 +9,13 @@
9
9
  *
10
10
  * **With a layer named, every sheet of the document goes into it** (*the author's sheets in a layer the host names*, 2026-10-17): a `<style>` holds its text wrapped (`wrapInLayer`), and every `<link>` becomes a `<style>` of its wrapped text at the same position. **The address is not kept, on a measurement**: `<style>@import url(<the href>) layer(<name>)</style>` would keep it, and Chromium fetches an imported sheet once for every frame that imports it, but it parses it again in each (measured 2026-10-17 in the studio experiment, an 11 MB sheet in four same-origin frames: +9 MB a further frame linked, +61–74 MB imported), so an import buys a host no shared copy a `<style>` does not, and it hides the sheet from every walk that reads `document.styleSheets`' top level (`@page`, `@font-face` for a printer, `position: fixed`). A host that shared a linked sheet's parsed copy across frames loses that sharing under a layer, and the loss is the host's to weigh. **And the engine's root line-height provision moves into the engine's own layer**, the first one: unlayered, it would outrank every rule of the author's now-layered sheets, the author's own root line-height among them, where it was written to lose every tie. **And the order of the two layers is stated first in the head**, `@layer <the engine's>, <the host's>;` in the engine's own layers element (made where it is not there yet): a layer's place is where it is first named, and a host that seats the author's sheets before the engine's head (the HTML lane, the studio, its print frame) would otherwise name the author's layer before the engine's, putting the engine's `!important` trimming below an author's and its provision above the author's normal rules. Without a layer nothing here changes.
11
11
  *
12
+ * **A replaced `<link>`'s relative addresses are written against its href** (`rebaseUrls`), because a `<style>` resolves them against the document.
13
+ *
12
14
  * **The `<link>` is replaced, not merely disabled.** A disabled link leaves the browser's own copy of the rules out of the CSSOM but leaves the element in the document, and the next thing to walk `document.styleSheets` would meet two sheets where the author wrote one.
13
15
  *
14
16
  * @packageDocumentation
15
17
  */
16
- import { rewriteSheet, rewriteNote, isLayerName } from './rewrite.js';
18
+ import { rewriteSheet, rewriteNote, isLayerName, rebaseUrls } from './rewrite.js';
17
19
  import { CICERO_INTERFACE, CICERO_PROVISION, LAYER as ENGINE_LAYER, WORDS_RULES, hasWord } from '../src/data-beltype.js';
18
20
  import { LAYERS_ID } from '../src/dressing.js';
19
21
  /** Is this URL on the document's own origin? A relative href resolves against the document, so the constructor answers both cases at once. */
@@ -112,10 +114,12 @@ export async function rewriteDocumentSheets(doc, judged = new WeakMap(), { layer
112
114
  judged.set(node, out.text); // the text the next run will find there
113
115
  return;
114
116
  }
117
+ // A `<style>` resolves its addresses against the document, so the sheet's own are written out first.
118
+ const text = rebaseUrls(out.text, new URL(node.getAttribute('href'), doc.baseURI).href);
115
119
  const style = doc.createElement('style');
116
- style.textContent = out.text;
120
+ style.textContent = text;
117
121
  node.replaceWith(style); // in place, so the sheet keeps its seat in the cascade
118
- judged.set(style, out.text);
122
+ judged.set(style, text);
119
123
  });
120
124
  const note = rewriteNote(counts);
121
125
  if (note)
@@ -74,5 +74,15 @@ export declare function wrapInLayer(source: string, layer: string): {
74
74
  text: string;
75
75
  wrapped: boolean;
76
76
  };
77
+ /**
78
+ * A linked sheet's relative addresses, resolved against its own href, for the `<style>` that takes its place (`rewrite-document.js`).
79
+ *
80
+ * @remarks A `<style>` resolves `url()` against the document, a `<link>` against the sheet: once a sheet in `css/` is moved into a `<style>`, `url("./font.ttf")` asks for the document's `./font.ttf` (the tester's case, 2026-11-03: a font inside `@media print`, whose opening made the sheet a `<style>`). Paged.js resolves a sheet it inlines the same way. Every `url()` and every `@import "…"` string is written absolute, in place, so no line moves; a sheet css-tree cannot parse is returned as it is.
81
+ *
82
+ * ponytail: `url()` and the `@import` string only. A url in a custom property's value (kept raw) or a string in `image-set()` still resolves against the document; add them when a document needs them.
83
+ *
84
+ * @param base - the absolute href the sheet was fetched from.
85
+ */
86
+ export declare function rebaseUrls(source: string, base: string): string;
77
87
  /** The console line a host says once per document it rewrote — what moved and why a reader of the sheet will meet two strange forms. */
78
88
  export declare function rewriteNote(counts: Partial<Record<string, number>>): string | null;
@@ -522,6 +522,38 @@ function splitPageSelectorLists(source) {
522
522
  });
523
523
  return { text: applyEdits(source, edits), split: edits.length };
524
524
  }
525
+ /** An address the sheet's own href is the base of: not one with a scheme (`data:`, `https:`), and not a fragment, which names the document (`clip-path: url(#c)`). */
526
+ const isRelative = (url) => url !== '' && !url.startsWith('#') && !/^[a-z][a-z\d+.-]*:/i.test(url);
527
+ /**
528
+ * A linked sheet's relative addresses, resolved against its own href, for the `<style>` that takes its place (`rewrite-document.js`).
529
+ *
530
+ * @remarks A `<style>` resolves `url()` against the document, a `<link>` against the sheet: once a sheet in `css/` is moved into a `<style>`, `url("./font.ttf")` asks for the document's `./font.ttf` (the tester's case, 2026-11-03: a font inside `@media print`, whose opening made the sheet a `<style>`). Paged.js resolves a sheet it inlines the same way. Every `url()` and every `@import "…"` string is written absolute, in place, so no line moves; a sheet css-tree cannot parse is returned as it is.
531
+ *
532
+ * ponytail: `url()` and the `@import` string only. A url in a custom property's value (kept raw) or a string in `image-set()` still resolves against the document; add them when a document needs them.
533
+ *
534
+ * @param base - the absolute href the sheet was fetched from.
535
+ */
536
+ export function rebaseUrls(source, base) {
537
+ let ast;
538
+ try {
539
+ ast = parse(source, SHEET_OPTIONS);
540
+ }
541
+ catch {
542
+ return source;
543
+ }
544
+ const edits = [];
545
+ const absolute = (url) => `"${new URL(url, base).href}"`;
546
+ walkStyled(ast, source, SHEET_OPTIONS, {
547
+ enter(node) {
548
+ if (node.type === 'Url' && node.loc && isRelative(node.value))
549
+ edits.push({ start: node.loc.start.offset, end: node.loc.end.offset, text: `url(${absolute(node.value)})` });
550
+ const first = node.type === 'Atrule' && String(node.name).toLowerCase() === 'import' ? node.prelude?.children?.first : null;
551
+ if (first?.type === 'String' && first.loc && isRelative(first.value))
552
+ edits.push({ start: first.loc.start.offset, end: first.loc.end.offset, text: absolute(first.value) });
553
+ }
554
+ });
555
+ return applyEdits(source, edits);
556
+ }
525
557
  /** The console line a host says once per document it rewrote — what moved and why a reader of the sheet will meet two strange forms. */
526
558
  export function rewriteNote(counts) {
527
559
  const parts = [];
package/dist/src/chase.js CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * @packageDocumentation
17
17
  */
18
- import { MARGIN_CELLS, ALIGN_X, ALIGN_Y, HORIZONTAL_BANDS, bandWidths, translateAlignment } from './margin-boxes.js';
18
+ import { MARGIN_CELLS, ALIGN_X, ALIGN_Y, HORIZONTAL_BANDS, bandTracks, bandWidths, translateAlignment } from './margin-boxes.js';
19
19
  import { counterLevels } from './document-state.js';
20
20
  import { encodeStringProp, STRING_KEYWORDS } from './gcpm-encoding.js';
21
21
  import { cssString } from './reads.js';
@@ -186,9 +186,9 @@ export class BeltypePage extends HTMLElement {
186
186
  this.style.containIntrinsicSize = `${pageW}px ${pageH - 1}px`;
187
187
  this.#bandWidth = pageW - margins.left - margins.right;
188
188
  this.#pageH = pageH;
189
- const cw3 = this.#bandWidth / 3;
189
+ const [wa, wb, wc] = bandTracks(this.#bandWidth);
190
190
  const ch3 = (pageH - margins.top - margins.bottom) / 3;
191
- this.style.gridTemplateColumns = `${margins.left}px ${cw3}px ${cw3}px ${cw3}px ${margins.right}px`;
191
+ this.style.gridTemplateColumns = `${margins.left}px ${wa}px ${wb}px ${wc}px ${margins.right}px`;
192
192
  this.style.gridTemplateRows = `${margins.top}px ${ch3}px ${ch3}px ${ch3}px ${Math.max(0, margins.bottom - 1)}px`;
193
193
  }
194
194
  /**
@@ -35,6 +35,13 @@ export declare const MARGIN_BOXES: string[];
35
35
  */
36
36
  export declare const ALIGN_X: Record<string, string>;
37
37
  export declare const ALIGN_Y: Record<string, string>;
38
+ /**
39
+ * The three tracks the page grid gives the band between its margins, in CSS px.
40
+ *
41
+ * @remarks
42
+ * Chromium lays a length out in 1/64 px and drops the remainder of every track on its own, so three thirds of 423.307 px are three tracks of 141.09375 and sum to 423.28125 px, while the measure container, one box of that width, floors the whole to 423.296875. A line that fits the one and not the other is measured on one line and shown on two (specimen `trattato`, page 111). The tracks are therefore whole 1/64 px units that sum to the floor of the band, the last one taking the remainder: no third of a unit is left for the grid to drop, and the content area is as wide as the container it was measured in.
43
+ */
44
+ export declare function bandTracks(band: number): [number, number, number];
38
45
  /** The three boxes of each horizontal band, in band order — what `bandWidths` sizes, and the only membership test `chase.js` needs to know which cells leave the outer grid. */
39
46
  export declare const HORIZONTAL_BANDS: {
40
47
  top: string[];
@@ -41,6 +41,17 @@ export const MARGIN_BOXES = MARGIN_CELLS.map(([name]) => name);
41
41
  */
42
42
  export const ALIGN_X = { left: 'flex-start', start: 'flex-start', right: 'flex-end', end: 'flex-end', center: 'center', justify: 'flex-start' };
43
43
  export const ALIGN_Y = { top: 'flex-start', 'text-top': 'flex-start', middle: 'center', bottom: 'flex-end', 'text-bottom': 'flex-end' };
44
+ /**
45
+ * The three tracks the page grid gives the band between its margins, in CSS px.
46
+ *
47
+ * @remarks
48
+ * Chromium lays a length out in 1/64 px and drops the remainder of every track on its own, so three thirds of 423.307 px are three tracks of 141.09375 and sum to 423.28125 px, while the measure container, one box of that width, floors the whole to 423.296875. A line that fits the one and not the other is measured on one line and shown on two (specimen `trattato`, page 111). The tracks are therefore whole 1/64 px units that sum to the floor of the band, the last one taking the remainder: no third of a unit is left for the grid to drop, and the content area is as wide as the container it was measured in.
49
+ */
50
+ export function bandTracks(band) {
51
+ const units = Math.floor(band * 64);
52
+ const third = Math.floor(units / 3);
53
+ return [third / 64, third / 64, (units - 2 * third) / 64];
54
+ }
44
55
  /** The three boxes of each horizontal band, in band order — what `bandWidths` sizes, and the only membership test `chase.js` needs to know which cells leave the outer grid. */
45
56
  export const HORIZONTAL_BANDS = {
46
57
  top: ['top-left', 'top-center', 'top-right'],
@@ -149,6 +149,8 @@ export declare class BeltypeRoot extends HTMLElement {
149
149
  startOf(k: number): Address | null;
150
150
  /** Pause the idle build while a run measures, and let it go when the run presents (or throws). A page that comes near still builds. */
151
151
  hold(): void;
152
+ /** Let go of what this root registered outside itself: the idle build, the observer and the window's listeners. The root is finished with after it; the class is not, since the window keeps it. */
153
+ dispose(): void;
152
154
  release(): void;
153
155
  /**
154
156
  * Show a source element: the page that begins it is built, and the copy of it that page shows — the original where it stands whole, a fragment's counterpart where the page divides its block — is scrolled into view. The one door every jump goes through (a link to `#x`, the editing tools' follow, a hash on the address); `scrollIntoView` on the source itself would find a box only when the source happens to be the thing drawn.
package/dist/src/root.js CHANGED
@@ -93,6 +93,8 @@ export class BeltypeRoot extends HTMLElement {
93
93
  #idleIsRic = false;
94
94
  #held = false;
95
95
  #listening = false;
96
+ /** What undoes each window listener {@link dispose} must take off. */
97
+ #offs = [];
96
98
  #buildMs = 0;
97
99
  constructor() {
98
100
  super();
@@ -682,7 +684,7 @@ export class BeltypeRoot extends HTMLElement {
682
684
  this.#ensure(target);
683
685
  }, true);
684
686
  // And after it, the place is shown: a target that is no box of its own — a divided block, drawn on its pages through copies — is scrolled to where its copy is. An address typed into the bar while the document is still being cut asks for the cut the same way.
685
- view.addEventListener('hashchange', () => {
687
+ const onHash = () => {
686
688
  const target = targetOf(view.location.hash);
687
689
  if (!target)
688
690
  return;
@@ -690,8 +692,11 @@ export class BeltypeRoot extends HTMLElement {
690
692
  void this.cutTo(target).then(() => this.reveal(target));
691
693
  else
692
694
  this.reveal(target);
693
- });
694
- view.addEventListener('beforeprint', () => void this.buildAll());
695
+ };
696
+ const onPrint = () => void this.buildAll();
697
+ view.addEventListener('hashchange', onHash);
698
+ view.addEventListener('beforeprint', onPrint);
699
+ this.#offs.push(() => view.removeEventListener('hashchange', onHash), () => view.removeEventListener('beforeprint', onPrint));
695
700
  }
696
701
  /** Build the page that begins the element, when the engine can name it.@param [charOffset] @returns the page's index, or null. */
697
702
  #ensure(el, charOffset = 0) {
@@ -753,6 +758,16 @@ export class BeltypeRoot extends HTMLElement {
753
758
  this.#held = true;
754
759
  this.#unkick();
755
760
  }
761
+ /** Let go of what this root registered outside itself: the idle build, the observer and the window's listeners. The root is finished with after it; the class is not, since the window keeps it. */
762
+ dispose() {
763
+ this.#held = true;
764
+ this.#unkick();
765
+ this.#observer?.disconnect();
766
+ this.#observer = null;
767
+ for (const off of this.#offs.splice(0))
768
+ off();
769
+ this.#listening = false;
770
+ }
756
771
  release() {
757
772
  this.#held = false;
758
773
  this.#kick();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cicero",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Cicero, a paged-media engine for the live browser: it cuts a document into pages under CSS Paged Media and presents them as real frames. Beta.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,14 @@
20
20
  "types": "./dist/seam/host.d.ts",
21
21
  "import": "./dist/seam/host.js"
22
22
  },
23
+ "./engine": {
24
+ "types": "./dist/seam/index.d.ts",
25
+ "import": "./dist/seam/index.js"
26
+ },
27
+ "./flags": {
28
+ "types": "./dist/seam/flags.d.ts",
29
+ "import": "./dist/seam/flags.js"
30
+ },
23
31
  "./rewrite": {
24
32
  "types": "./dist/seam/rewrite-document.d.ts",
25
33
  "import": "./dist/seam/rewrite-document.js"
@@ -50,16 +58,18 @@
50
58
  "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.facsimile.json && node scripts/copy-assets.js",
51
59
  "check": "tsc -p tsconfig.test.json",
52
60
  "test": "node --test 'test/*.test.ts'",
61
+ "test:browser": "node --test --test-concurrency=1 'test/browser/*.browser.ts'",
53
62
  "prepare": "npm run build"
54
63
  },
55
64
  "dependencies": {
56
- "@beltype/common": "0.1.1",
65
+ "@beltype/common": "0.1.2",
57
66
  "css-tree": "^3.2.1",
58
67
  "pdfjs-dist": "6.2.108"
59
68
  },
60
69
  "devDependencies": {
61
70
  "@types/css-tree": "^3.2.0",
62
71
  "@types/node": "^22.20.4",
72
+ "playwright-core": "1.63.0",
63
73
  "typescript": "7.0.2"
64
74
  },
65
75
  "pnpm": {