@beltype/cicero 0.1.5 → 0.1.7

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.
@@ -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'))
@@ -26,11 +26,13 @@ import { WORDS_RULES } from '../src/data-beltype.js';
26
26
  *
27
27
  * The value is the addendum's own 1.65 rather than an invention, so on the one surface where it can actually cascade — the HTML lane, which ships no addendum — the engine's provision and BelType's default agree. Below `body` it is inert: the addendum's own `body` rule wins by proximity.
28
28
  *
29
+ * It is a floor, not an override, so it is written at zero specificity: `:where(:root)` still beats the browser's `normal`, which no stylesheet sets, and loses to any author rule on the root, a plain `html { font: 10pt/13.4pt serif }` among them. As `:root` (0,1,0) it beat that rule's (0,0,1) silently, and five of the six specimens printed at 1.65 instead of their declared leading (trattato: 220 pages for 189; Codeberg issue 4, 2026-11-07).
30
+ *
29
31
  * It is a literal here, and not a line in the linked sheet beside it, because a conformance provision that can 404 is not a provision. A missing sheet — a table entry lost in a refactor, a server restarted mid-edit — would arrive as `I5 (DPR ruling): explicit line-height required`, a law refusing on a typographic claim when the real fault is a missing file, sending the reader to their own stylesheet. Cicero's refusals are load-bearing because they are true; one that names the wrong thing spends that.
30
32
  *
31
33
  * Recorded as a beta limitation, because the check the law performs is at the root and this satisfies it there: a document whose deeper typography still relies on `normal` is outside what *render-identical conditions* (I5) can see.
32
34
  */
33
- const I5_PROVISION = ':root { line-height: 1.65; }';
35
+ const I5_PROVISION = ':where(:root) { line-height: 1.65; }';
34
36
  /**
35
37
  * What the document says about a PDF `<object>` before the engine has met it (*the embedded PDF*, 2026-10-14; `seam/facsimile.js`).
36
38
  *
@@ -137,7 +137,14 @@ export type ReachContext = {
137
137
  /** Whether any top-level box is not the one that stood in its place before. */
138
138
  changed: boolean;
139
139
  };
140
- /** A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base and anything relative refuses by name (css-page-3 §7.1). */
140
+ /** Is this `size` an orientation keyword and nothing else? Then it turns the sheet the cascade already gave ({@link sizeToPx}). */
141
+ export declare const isOrientationOnly: (tokens: string[]) => boolean;
142
+ /**
143
+ * A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base, and a percentage refuses by name (css-page-3 §7.1).
144
+ *
145
+ * @remarks
146
+ * An orientation keyword turns whatever sheet stands beside it: `landscape` puts the longer side across, `portrait` down, for a named size and for a pair of lengths alike. §7.1 says a keyword alone means the UA's default sheet turned that way; this engine pins geometry (I5) and has no default sheet, so `pageFor` hands the keyword the sheet the cascade's less specific rule gave (the WPT print reftests' `@page :first { size: portrait }` on their 5 in by 3 in default), and a keyword with no such sheet refuses by name (Codeberg issue 10; until 2026-11-07 it refused as the length `undefined`).
147
+ */
141
148
  export declare function sizeToPx(tokens: string[], resolve: Resolve): [Px, Px];
142
149
  /**
143
150
  * A declared border width takes room only where the same side's style draws a border (*a page box has a border and a padding*, 2026-09-07).
@@ -180,6 +187,15 @@ export declare function assertPageArea({ contentW, contentH }: {
180
187
  contentW: Px;
181
188
  contentH: Px;
182
189
  }, where: string): void;
190
+ /**
191
+ * Every `@page` rule the document wears, wherever it stands: at a sheet's top, or inside an `@layer`, a `@supports` or an `@media` that holds, or an imported sheet. `layered` loses to `unlayered`, as the cascade has it, whatever their order.
192
+ *
193
+ * @remarks ponytail: the layers keep their document order, not their declared order; a page rule that must win over another layer is unlayered, as BelType's own is.
194
+ */
195
+ export declare function pageRulesOf(doc: Document): {
196
+ layered: CSSPageRule[];
197
+ unlayered: CSSPageRule[];
198
+ };
183
199
  /**
184
200
  * Clear every author @page margin-box's own `content` declaration in the live CSSOM — never its `size`/`margin`, never anything outside a margin-at-rule. Called once the geometry table above has already read what it needs from these rules (the second channel a named page cannot close).
185
201
  *
@@ -531,11 +531,12 @@ function createLengthResolver(doc) {
531
531
  const fast = absoluteToPx(raw);
532
532
  if (fast !== null)
533
533
  return fast;
534
- if (basePx === null)
535
- throw refuse(raw, where, 'a `size` descriptor is resolved before either page dimension exists, so only absolute lengths and the named sizes can be answered there (css-page-3 §7.1 gives `size` no percentage values at all)');
534
+ // A `size` is resolved before either page dimension exists, so it has no percentage base; css-page-3 §7.1 gives `size` no percentage values at all. Every other length the browser resolves is measured as a margin's is: an `em` resolves against the root's font, which the probe's wrapper wears (Codeberg issue 10; until 2026-11-07 every relative unit refused here).
535
+ if (basePx === null && raw.includes('%'))
536
+ throw refuse(raw, where, 'a `size` descriptor is resolved before either page dimension exists, and css-page-3 §7.1 gives `size` no percentage values at all');
536
537
  if (raw === 'auto')
537
538
  throw refuse(raw, where, '`auto` on a page margin means UA-determined, and the probe would read it as a plain 0 — a number this engine would then lay out against as though the author had written one');
538
- const px = measured(raw, basePx, prop);
539
+ const px = measured(raw, basePx ?? 0, prop);
539
540
  if (px === null)
540
541
  throw refuse(raw, where, 'the browser resolves it to nothing — an unknown unit, a malformed value, or a custom property that is never declared');
541
542
  return px;
@@ -560,11 +561,20 @@ const SIZE_NAMES = {
560
561
  legal: [215.9, 355.6], // css-page-3 legal 8.5 × 14in; Chromium 215.90 × 355.60mm
561
562
  ledger: [279.4, 431.8] // css-page-3 ledger 11 × 17in; Chromium 279.40 × 431.80mm
562
563
  };
563
- /** A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base and anything relative refuses by name (css-page-3 §7.1). */
564
+ /** css-page-3 §7.1's two orientation keywords. */
565
+ const ORIENTATIONS = new Set(['portrait', 'landscape']);
566
+ /** Is this `size` an orientation keyword and nothing else? Then it turns the sheet the cascade already gave ({@link sizeToPx}). */
567
+ export const isOrientationOnly = (tokens) => tokens.length === 1 && ORIENTATIONS.has(tokens[0].toLowerCase());
568
+ /**
569
+ * A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base, and a percentage refuses by name (css-page-3 §7.1).
570
+ *
571
+ * @remarks
572
+ * An orientation keyword turns whatever sheet stands beside it: `landscape` puts the longer side across, `portrait` down, for a named size and for a pair of lengths alike. §7.1 says a keyword alone means the UA's default sheet turned that way; this engine pins geometry (I5) and has no default sheet, so `pageFor` hands the keyword the sheet the cascade's less specific rule gave (the WPT print reftests' `@page :first { size: portrait }` on their 5 in by 3 in default), and a keyword with no such sheet refuses by name (Codeberg issue 10; until 2026-11-07 it refused as the length `undefined`).
573
+ */
564
574
  export function sizeToPx(tokens, resolve) {
565
- const t = tokens.map((s) => s.toLowerCase()).filter((s) => s !== 'portrait');
566
- const landscape = t.includes('landscape');
567
- const rest = t.filter((s) => s !== 'landscape');
575
+ const t = tokens.map((s) => s.toLowerCase());
576
+ const orientation = t.find((s) => ORIENTATIONS.has(s)) ?? null;
577
+ const rest = t.filter((s) => !ORIENTATIONS.has(s));
568
578
  const where = 'a `size` descriptor';
569
579
  let w;
570
580
  let h;
@@ -574,10 +584,33 @@ export function sizeToPx(tokens, resolve) {
574
584
  else if (rest.length === 1) {
575
585
  w = h = resolve(rest[0], null, where);
576
586
  }
587
+ else if (rest.length === 0) {
588
+ throw refuse(orientation ?? '', where, 'an orientation keyword alone means the UA\'s default sheet turned that way, and this engine has none: declare the sheet in a less specific @page rule, or beside the keyword');
589
+ }
577
590
  else {
578
591
  [w, h] = [resolve(rest[0], null, where), resolve(rest[1], null, where)];
579
592
  }
580
- return landscape ? [h, w] : [w, h];
593
+ if (orientation === 'landscape')
594
+ return [Math.max(w, h), Math.min(w, h)];
595
+ if (orientation === 'portrait')
596
+ return [Math.min(w, h), Math.max(w, h)];
597
+ return [w, h];
598
+ }
599
+ /**
600
+ * The root's custom properties substituted into a page-context value (css-page-3 §4.1: the page context inherits from the root element, so a `var()` there reads the root's value). The probe already inherits them for a longhand it seats, but a shorthand goes through `expandBox` before any probe, and `--margin: 20px 40px` must be split before it is seated (Codeberg issue 10, `page-context-inherits-from-root-custom-property`). A property the root does not declare takes the `var()`'s fallback, or stays as written for the probe to refuse by name.
601
+ */
602
+ function substituteRootVars(doc, text) {
603
+ if (!text.includes('var('))
604
+ return text;
605
+ const root = doc.defaultView.getComputedStyle(doc.documentElement);
606
+ let out = text;
607
+ for (let i = 0; i < 8 && out.includes('var('); i++) {
608
+ const next = out.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (whole, name, fallback) => root.getPropertyValue(name).trim() || fallback?.trim() || whole);
609
+ if (next === out)
610
+ break;
611
+ out = next;
612
+ }
613
+ return out;
581
614
  }
582
615
  /**
583
616
  * What Cicero owns: the one criterion, and it is deliberately not a CSS opinion.
@@ -600,6 +633,12 @@ const AREA_PREFIXES = ['border', 'padding'];
600
633
  function ownedByCicero(prop) {
601
634
  return OWNED_EXACTLY.has(prop) || OWNED_PREFIXES.some((p) => prop.startsWith(p));
602
635
  }
636
+ /**
637
+ * What the Chase owns on a margin cell, which is less than on the page host: a cell's `margin-*`, `border-*` and `padding-*` are the box's own (css-page-3 §5.3.2 distributes the boxes' outer widths, padding and border included, and `chase.js`'s `layoutBands` measures them on the cell), where the page box's are the geometry the capture models and the content area wears. Until 2026-11-07 the host's filter was applied to the boxes too, and `@top-left { padding-bottom: 5mm }` reached no cell (Codeberg issue 3). The `width` family stays withheld: §5.3.2's answer for a box that declares one is not built, and the cell's grid seat is the Chase's.
638
+ */
639
+ function ownedOnCell(prop) {
640
+ return OWNED_EXACTLY.has(prop) || prop.startsWith('grid-template');
641
+ }
603
642
  /** One declaration block's `border-*` and `padding-*` triples, in the block's own order — the page box's border and padding, which the Chase writes on the content area. Order and `!important` carried for the same reason `declarationsOf` carries them: they are the cascade. */
604
643
  function areaDeclarationsOf(style) {
605
644
  const out = [];
@@ -610,11 +649,11 @@ function areaDeclarationsOf(style) {
610
649
  }
611
650
  return out;
612
651
  }
613
- /** One declaration block as `[property, value, priority]` triples, in the block's own order, minus what Cicero owns. Order and `!important` are carried because they are the cascade: the triples are re-emitted into one rule, where later wins and important beats normal exactly as the author wrote them. */
614
- function declarationsOf(style) {
652
+ /** One declaration block as `[property, value, priority]` triples, in the block's own order, minus what Cicero owns — on the page host by default, on a margin cell where `owned` is {@link ownedOnCell}. Order and `!important` are carried because they are the cascade: the triples are re-emitted into one rule, where later wins and important beats normal exactly as the author wrote them. */
653
+ function declarationsOf(style, owned = ownedByCicero) {
615
654
  const out = [];
616
655
  for (const prop of style ?? []) {
617
- if (ownedByCicero(prop))
656
+ if (owned(prop))
618
657
  continue;
619
658
  out.push([prop, style.getPropertyValue(prop), style.getPropertyPriority(prop)]);
620
659
  }
@@ -655,7 +694,7 @@ function marginBoxStylesOf(rule) {
655
694
  const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
656
695
  if (!MARGIN_BOXES.includes(name))
657
696
  continue;
658
- const decls = declarationsOf(child.style).filter(([prop]) => prop !== 'content');
697
+ const decls = declarationsOf(child.style, ownedOnCell).filter(([prop]) => prop !== 'content');
659
698
  if (decls.length)
660
699
  out[name] = decls;
661
700
  }
@@ -796,27 +835,57 @@ function marginBoxesOf(rule) {
796
835
  return out;
797
836
  }
798
837
  /**
799
- * Clear every author @page margin-box's own `content` declaration in the live CSSOM — never its `size`/`margin`, never anything outside a margin-at-rule. Called once the geometry table above has already read what it needs from these rules (the second channel a named page cannot close).
838
+ * Every `@page` rule the document wears, wherever it stands: at a sheet's top, or inside an `@layer`, a `@supports` or an `@media` that holds, or an imported sheet. `layered` loses to `unlayered`, as the cascade has it, whatever their order.
800
839
  *
801
- * Margin-box chrome is delivered exclusively as a shadow-DOM literal (chase.js's own note under *content is one document*, I6: "it does not live once, so it cannot live twice"), but the author's own @page rules stay linked in the document throughout — their non-page rules keep styling the light-DOM content, so the whole stylesheet can never simply be disabled. Once the press sheet (root.js) renames every `<beltype-page>` to a synthetic geometry name, no author rule with a page-name selector (`@page title`, `@page :left`, …) can be name-matched by the browser's own native cascade any more — only the bare, unnamed base rule still matches every page unconditionally, becoming a second, uncontrolled channel that paints the very same chrome the shadow DOM already delivers correctly. `cases/17-the-borrowed-dress` found this the hard way: a named page's `content: normal` override was honored inside the shadow DOM (confirmed by direct inspection) while the printed page still showed the un-overridden base string, painted by this exact channel. Clearing `content` closes it by construction, for every page, independent of the precise cascade mechanism that let it through.
840
+ * @remarks ponytail: the layers keep their document order, not their declared order; a page rule that must win over another layer is unlayered, as BelType's own is.
802
841
  */
803
- export function neutralizeMarginBoxes(doc) {
804
- for (const sheet of styleSheetsOf(doc)) {
805
- let rules;
842
+ export function pageRulesOf(doc) {
843
+ const win = doc.defaultView;
844
+ const found = { layered: [], unlayered: [] };
845
+ const rulesOf = (sheet) => {
806
846
  try {
807
- rules = sheet.cssRules;
847
+ return sheet.cssRules;
808
848
  }
809
849
  catch {
810
- continue;
811
- } // cross-origin sheet: not ours to touch
850
+ return [];
851
+ } // cross-origin sheet: not ours to read
852
+ };
853
+ const walk = (rules, inLayer) => {
812
854
  for (const rule of rules) {
813
- if (!(rule instanceof doc.defaultView.CSSPageRule) || !rule.cssRules)
814
- continue;
815
- for (const child of rule.cssRules) {
816
- const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
817
- if (MARGIN_BOXES.includes(name))
818
- child.style?.removeProperty('content');
855
+ if (rule instanceof win.CSSPageRule)
856
+ (inLayer ? found.layered : found.unlayered).push(rule);
857
+ else if (rule.constructor?.name === 'CSSLayerBlockRule')
858
+ walk(rule.cssRules, true);
859
+ else if (rule.constructor?.name === 'CSSSupportsRule') {
860
+ if (win.CSS?.supports?.(rule.conditionText))
861
+ walk(rule.cssRules, inLayer);
862
+ }
863
+ else if (rule.constructor?.name === 'CSSMediaRule') {
864
+ if (win.matchMedia(rule.media.mediaText).matches)
865
+ walk(rule.cssRules, inLayer);
819
866
  }
867
+ else if (rule.constructor?.name === 'CSSImportRule' && rule.styleSheet)
868
+ walk(rulesOf(rule.styleSheet), inLayer || rule.layerName != null);
869
+ }
870
+ };
871
+ for (const sheet of styleSheetsOf(doc))
872
+ walk(rulesOf(sheet), false);
873
+ return found;
874
+ }
875
+ /**
876
+ * Clear every author @page margin-box's own `content` declaration in the live CSSOM — never its `size`/`margin`, never anything outside a margin-at-rule. Called once the geometry table above has already read what it needs from these rules (the second channel a named page cannot close).
877
+ *
878
+ * Margin-box chrome is delivered exclusively as a shadow-DOM literal (chase.js's own note under *content is one document*, I6: "it does not live once, so it cannot live twice"), but the author's own @page rules stay linked in the document throughout — their non-page rules keep styling the light-DOM content, so the whole stylesheet can never simply be disabled. Once the press sheet (root.js) renames every `<beltype-page>` to a synthetic geometry name, no author rule with a page-name selector (`@page title`, `@page :left`, …) can be name-matched by the browser's own native cascade any more — only the bare, unnamed base rule still matches every page unconditionally, becoming a second, uncontrolled channel that paints the very same chrome the shadow DOM already delivers correctly. `cases/17-the-borrowed-dress` found this the hard way: a named page's `content: normal` override was honored inside the shadow DOM (confirmed by direct inspection) while the printed page still showed the un-overridden base string, painted by this exact channel. Clearing `content` closes it by construction, for every page, independent of the precise cascade mechanism that let it through.
879
+ */
880
+ export function neutralizeMarginBoxes(doc) {
881
+ const { layered, unlayered } = pageRulesOf(doc);
882
+ for (const rule of [...layered, ...unlayered]) {
883
+ if (!rule.cssRules)
884
+ continue;
885
+ for (const child of rule.cssRules) {
886
+ const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
887
+ if (MARGIN_BOXES.includes(name))
888
+ child.style?.removeProperty('content');
820
889
  }
821
890
  }
822
891
  }
@@ -943,47 +1012,37 @@ export function registerGcpmProperties(win) {
943
1012
  */
944
1013
  export function readGeometryTable(doc) {
945
1014
  const rules = [];
946
- for (const sheet of styleSheetsOf(doc)) {
947
- let list;
948
- try {
949
- list = sheet.cssRules;
950
- }
951
- catch {
952
- continue; // cross-origin sheet: not ours to capture
953
- }
954
- for (const rule of list) {
955
- if (!(rule instanceof doc.defaultView.CSSPageRule))
956
- continue;
957
- const sel = parseSelector(rule.selectorText);
958
- // The rule's own declaration block, never its serialized text: `rule.cssText` carries the nested margin boxes inside it, so a regex over it credits a folio's `margin-top` to the page and reads a running head's `font-size` as the page's `size` (a page rule reads its own declarations; the arithmetic is on the checklist, the case is `15-the-margin-box`). `size` is a descriptor rather than a property and is exposed here anyway — measured, not assumed.
959
- const declared = (prop) => rule.style?.getPropertyValue(prop).trim() || null;
960
- const size = declared('size')?.split(/\s+/) ?? null;
961
- // Longhands only: the CSSOM has already expanded whatever shorthand was written, and re-expanding it here would re-implement the browser's own work.
962
- //
963
- // Except where it has expanded nothing. A shorthand carrying a `var()` is held in pending substitution — the block answers `margin` with the author's text and every one of `margin-top`…`margin-left` with the empty string — so reading longhands alone reports no declaration at all, and the page lays out against a default. That is not a plausible failure: measured 2026-09-09, `@page { margin: var(--outer) }` with `--outer: 12mm` composed against BelType's own inlined default of 20mm and against 0 in a bare document, both silently, and the case is `33-the-shorthand-the-cssom-cannot-expand`. So the shorthand is consulted only when all four longhands are empty, which is exactly the pending-substitution state and nothing else — the browser expands every shorthand it can, so a non-empty longhand means there is nothing here to do. `expandBox` hands back the author's raw values, which the resolver then seats and measures per side exactly as it does a longhand's; nothing about lengths is decided here.
964
- //
965
- // Its reach is one level: `border: 5px var(--style)` leaves `border-width` empty too, and that stays unread. Naming it rather than widening the door — a shorthand of shorthands is a second expansion with a second grammar, and no document has asked for it.
966
- const perSide = (prefix, suffix = '') => {
967
- const sides = { top: declared(`${prefix}-top${suffix}`), right: declared(`${prefix}-right${suffix}`), bottom: declared(`${prefix}-bottom${suffix}`), left: declared(`${prefix}-left${suffix}`) };
968
- if (sides.top || sides.right || sides.bottom || sides.left)
969
- return sides;
970
- const shorthand = declared(`${prefix}${suffix}`);
971
- return (shorthand && expandBox(shorthand)) || sides;
972
- };
973
- rules.push({
974
- sel,
975
- size,
976
- style: declarationsOf(rule.style),
977
- area: areaDeclarationsOf(rule.style),
978
- boxStyles: marginBoxStylesOf(rule),
979
- margins: perSide('margin'),
980
- // A border width's used value is zero unless a style is set, so the style is read beside the width and the two are accumulated separately — a `@page { border: 5px }` declares a width and no style, and takes no room at all.
981
- borderWidths: perSide('border', '-width'),
982
- borderStyles: perSide('border', '-style'),
983
- paddings: perSide('padding'),
984
- marginBoxes: marginBoxesOf(rule)
985
- });
986
- }
1015
+ const { layered, unlayered } = pageRulesOf(doc);
1016
+ for (const rule of [...layered, ...unlayered]) {
1017
+ const sel = parseSelector(rule.selectorText);
1018
+ // The rule's own declaration block, never its serialized text: `rule.cssText` carries the nested margin boxes inside it, so a regex over it credits a folio's `margin-top` to the page and reads a running head's `font-size` as the page's `size` (a page rule reads its own declarations; the arithmetic is on the checklist, the case is `15-the-margin-box`). `size` is a descriptor rather than a property and is exposed here anyway — measured, not assumed.
1019
+ const declared = (prop) => rule.style?.getPropertyValue(prop).trim() || null;
1020
+ const size = declared('size')?.split(/\s+/) ?? null;
1021
+ // Longhands only: the CSSOM has already expanded whatever shorthand was written, and re-expanding it here would re-implement the browser's own work.
1022
+ //
1023
+ // Except where it has expanded nothing. A shorthand carrying a `var()` is held in pending substitution — the block answers `margin` with the author's text and every one of `margin-top`…`margin-left` with the empty string — so reading longhands alone reports no declaration at all, and the page lays out against a default. That is not a plausible failure: measured 2026-09-09, `@page { margin: var(--outer) }` with `--outer: 12mm` composed against BelType's own inlined default of 20mm and against 0 in a bare document, both silently, and the case is `33-the-shorthand-the-cssom-cannot-expand`. So the shorthand is consulted only when all four longhands are empty, which is exactly the pending-substitution state and nothing else — the browser expands every shorthand it can, so a non-empty longhand means there is nothing here to do. `expandBox` hands back the author's raw values, which the resolver then seats and measures per side exactly as it does a longhand's; nothing about lengths is decided here.
1024
+ //
1025
+ // Its reach is one level: `border: 5px var(--style)` leaves `border-width` empty too, and that stays unread. Naming it rather than widening the door — a shorthand of shorthands is a second expansion with a second grammar, and no document has asked for it.
1026
+ const perSide = (prefix, suffix = '') => {
1027
+ const sides = { top: declared(`${prefix}-top${suffix}`), right: declared(`${prefix}-right${suffix}`), bottom: declared(`${prefix}-bottom${suffix}`), left: declared(`${prefix}-left${suffix}`) };
1028
+ if (sides.top || sides.right || sides.bottom || sides.left)
1029
+ return sides;
1030
+ const shorthand = declared(`${prefix}${suffix}`);
1031
+ return (shorthand && expandBox(substituteRootVars(doc, shorthand))) || sides;
1032
+ };
1033
+ rules.push({
1034
+ sel,
1035
+ size,
1036
+ style: declarationsOf(rule.style),
1037
+ area: areaDeclarationsOf(rule.style),
1038
+ boxStyles: marginBoxStylesOf(rule),
1039
+ margins: perSide('margin'),
1040
+ // A border width's used value is zero unless a style is set, so the style is read beside the width and the two are accumulated separately — a `@page { border: 5px }` declares a width and no style, and takes no room at all.
1041
+ borderWidths: perSide('border', '-width'),
1042
+ borderStyles: perSide('border', '-style'),
1043
+ paddings: perSide('padding'),
1044
+ marginBoxes: marginBoxesOf(rule)
1045
+ });
987
1046
  }
988
1047
  if (!rules.length)
989
1048
  throw new Error('no @page rule found — the corpus and the venue always pin geometry (I5)');
@@ -1021,7 +1080,7 @@ export function readGeometryTable(doc) {
1021
1080
  const pageStyle = { host: [], cell: [], area: [], boxes: {} };
1022
1081
  for (const r of applicable) {
1023
1082
  if (r.size)
1024
- size = r.size;
1083
+ size = isOrientationOnly(r.size) && size ? [...size.filter((t) => !ORIENTATIONS.has(t.toLowerCase())), ...r.size] : r.size; // a keyword alone turns the sheet the cascade gave (`sizeToPx`)
1025
1084
  for (const side of SIDES) {
1026
1085
  if (r.margins[side] != null)
1027
1086
  margins[side] = r.margins[side];
package/dist/src/chase.js CHANGED
@@ -153,6 +153,7 @@ export class BeltypePage extends HTMLElement {
153
153
  for (const [band, row] of [['top', 1], ['bottom', 5]]) {
154
154
  const el = doc.createElement('div');
155
155
  el.className = 'band';
156
+ el.setAttribute('aria-hidden', 'true'); // pagination furniture is never read (*an artifact is never read*)
156
157
  el.dataset.band = band;
157
158
  el.style.gridArea = `${row} / 2 / ${row + 1} / 5`;
158
159
  this.#bands[band] = el;
@@ -161,6 +162,7 @@ export class BeltypePage extends HTMLElement {
161
162
  for (const [name, row, col] of MARGIN_CELLS) {
162
163
  const cell = doc.createElement('div');
163
164
  cell.className = 'margin-cell';
165
+ cell.setAttribute('aria-hidden', 'true');
164
166
  cell.dataset.box = name;
165
167
  const band = Object.keys(HORIZONTAL_BANDS).find((b) => HORIZONTAL_BANDS[b].includes(name));
166
168
  if (band) {