@beltype/cicero 0.1.8 → 0.1.10

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.
@@ -11,12 +11,14 @@
11
11
  *
12
12
  * **No key may end with another key** (`pos:` and `xpos:` cannot both exist), because "every element carrying a key" is selected by substring and a substring cannot anchor to the start of a word. {@link assertNoKeySuffix} checks it once, at load.
13
13
  *
14
- * **One rule, one registry.** The registry is a table of facts, each declared once — its name, its kind, what it is computed from, who writes it and, for a value, the custom property a generated sheet delivers it into — so that a later step adds entries and not a second mechanism. It holds the `has:` family, the line stamp `pos:`, `fragment`, the piece words `split`/`start`/`end`/`slice:N`, `fixed`, the engine-created elements' `shell`/`break`/`fill`, the three words the zeroing needs (`wrapped`, `adjoin`, `away`) and `artifact` (*the piece words*, 2026-10-11; see CHANGELOG 2026-10-11); `hollow` is a later step and is not written here.
14
+ * **One rule, one registry.** The registry is a table of facts, each declared once — its name, its kind, what it is computed from, who writes it and, for a value, the custom property a generated sheet delivers it into — so that a later step adds entries and not a second mechanism. It holds the `has:` family, the line stamp `pos:`, `fragment`, the piece words `split`/`start`/`end`/`slice:N`, `fixed`, the engine-created elements' `shell`/`break`/`fill`, the three words the zeroing needs (`wrapped`, `adjoin`, `away`), `artifact` (*the piece words*, 2026-10-11; see CHANGELOG 2026-10-11) and the one word a host writes on an element of its own, `host` (ruled 2026-11-08); `hollow` is a later step and is not written here.
15
15
  *
16
16
  * @packageDocumentation
17
17
  */
18
18
  /** The attribute's name. A single place for the literal; the grammar below is the only code that spells the value. */
19
19
  export const ATTRIBUTE = 'data-beltype';
20
+ /** The attribute a printer's preparation sets on every element it makes from generated content, valued by the pseudo it was made from (`before`, `after`, `marker`): its own attribute and not a word, because it is set by the host on the source, before the engine sees it. `content()` and `string-set` leave out what carries it, as css-gcpm-3's `content()` is the element's text without its pseudo-elements, and `content(before)` and `content(after)` read exactly that (ruled 2026-11-07). The name and its values are `GENERATED_ATTRIBUTE` and `GeneratedPseudo` in `@beltype/common/data-beltype`; `src/` imports nothing, so the literal is spelled here once, and is replaced by the import if the engine's layering ever admits common. */
21
+ export const GENERATED_ATTRIBUTE = 'data-beltype-generated';
20
22
  /** Every word and key, once. The table `docs/reference.md` documents, in the same order. */
21
23
  export const REGISTRY = Object.freeze([
22
24
  { name: 'transform', kind: 'flag', status: 'in use', writer: 'belt', from: 'source', facing: 'host', meaning: 'the site engine\'s JSON binding block (`<script type="application/json">`)' },
@@ -36,6 +38,7 @@ export const REGISTRY = Object.freeze([
36
38
  { name: 'wrapped', kind: 'flag', status: 'in use', writer: 'engine', from: 'original', facing: 'engine', meaning: 'the box wraps each fragment whole (`box-decoration-break: clone`), so border and padding stay at a cut; decided by the engine because a selector cannot test the property' },
37
39
  { name: 'adjoin', kind: 'flag', status: 'in use', writer: 'engine', from: 'cut', facing: 'engine', meaning: 'the first-child chain after a cut BETWEEN boxes: its block-start margin adjoins the cut and is truncated, and the box is not divided' },
38
40
  { name: 'away', kind: 'flag', status: 'in use', writer: 'engine', from: 'cut', facing: 'engine', meaning: 'a box put away on this page (`display: none`): kept as an element so structural selectors read as in the source' },
41
+ { name: 'host', kind: 'flag', status: 'in use', writer: 'host', from: 'host', facing: 'engine', meaning: 'the host\'s word on an element of its own machinery that stands among the document\'s boxes (a hidden `<iframe>`, a probe); the intake passes over it, so it gets no address and moves none (ruled 2026-11-08)' },
39
42
  { name: 'artifact', kind: 'flag', status: 'in use', writer: 'engine', from: 'cut', facing: 'host', meaning: 'a copy the engine repeats that is not the one a reader reads: a running element\'s copy in a margin box, a fixed box\'s copy on every page after the first, and a table\'s repeated header or footer group; PDF\'s own word for pagination furniture, and nothing else. Every artifact is also aria-hidden' }
40
43
  ].map((e) => Object.freeze(e)));
41
44
  /** The flags in use. */
@@ -46,6 +49,8 @@ export const KEYS = new Set(REGISTRY.filter((e) => e.kind === 'value').map((e) =
46
49
  export const TRANSFORM = 'transform';
47
50
  export const CICERO_PROVISION = 'cicero-provision';
48
51
  export const CICERO_INTERFACE = 'cicero-interface';
52
+ /** The word a host puts on an element of its own machinery that stands among the document's boxes, so the intake passes over it (`topLevelBoxes`, `driver.js`). A host that holds the document in an element of its own names that element with the `root` option first. */
53
+ export const HOST = 'host';
49
54
  /** The words step 2 writes, named once: the engine, the editing tools, Caster and the essay spell them through these and never as a literal of their own. */
50
55
  export const FRAGMENT = 'fragment';
51
56
  export const SPLIT = 'split';
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * **A position is the flat word index** (`positions.js`), and an operation applies when its position is strictly before the one asked about — and not inside a remainder a carried flow has still to show, which stands before the page's first word in document order and is on that page or after it (beltype#43; `positions.js`' `remaindersOf`). That is §6.1's *"the deepest element in the normal flow that spans the page break"* said in this engine's own space: an element whose first word is the page's first word begins on the new page and does not span the break, so its own operation is not yet in force, while an element that spans the break started before that word and is included. The ancestors above the top-level blocks sit at position **−1** — they open before the document's first word and span every break there is — which is what makes the first page's answer fall out of the same comparison rather than needing a case of its own.
15
15
  *
16
- * **What is not read, each a real gap and each showing as a counter reading low.** A list item's implicit increment, so an `<ol>` that runs onto a second page numbers its items from 1 there (measured 2026-11-07, `<ol reversed>` and a `counter-set` of `list-item` likewise); css-lists-3's `reversed()` counters; and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
16
+ * **What is not read, each a real gap and each showing as a counter reading low.** css-lists-3's `reversed()` counters (an author's `counter-reset: reversed(name)`), a top-level `position: fixed` box, which is not among the flow blocks the position index is built over; and a scope that ended before the position asked about with no operation logged between its end and the position, since the replay closes a scope only when an operation at or above its depth arrives: a `counter(list-item)` read on a page past a list whose last item holds a list reads the inner list's count (3 where Chromium prints 2, measured 2026-11-07), and an author's nested reset is read the same way. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A list's and a list item's implicit `list-item` operations are read since 2026-11-07 (`implicitListOps`), so a `counter(list-item)` read on a page a list continued onto or past it counts the items before it. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -48,6 +48,8 @@ export type Op = {
48
48
  name: string;
49
49
  terms: Term[];
50
50
  text: string;
51
+ before?: string;
52
+ after?: string;
51
53
  attrs: Record<string, string>;
52
54
  }[] | null;
53
55
  /** The flat word position the operation sits at. */
@@ -77,6 +79,8 @@ export declare function parseStringSet(value: unknown): {
77
79
  }[];
78
80
  /** The whitespace collapsing GCPM asks for: `content()` is *"the string value of the element, determined as if `white-space: normal` had been set"*. */
79
81
  export declare const collapse: (text: unknown) => string;
82
+ /** An element's text without the elements a printer made from its generated content ({@link GENERATED_ATTRIBUTE}): css-gcpm-3's `content()`, the element's text and not its pseudo-elements'. An element holding none answers its `textContent`, as before. */
83
+ export declare function contentTextOf(el: any): string;
80
84
  /** One top-level block's part of the log: its operations in document order, each with its position and depth relative to the block — the words of the block before the element, and its depth below the block (*a resumed run keeps its reads*, 2026-10-22). {@link documentOpsOf} lays the parts end to end, a part's positions offset by its block's base and its depths by the ancestors above the blocks. */
81
85
  export declare function blockOpsOf(block: any, index: {
82
86
  wordsBefore: (el: any) => number;
@@ -95,7 +99,7 @@ export declare function blockOpsOf(block: any, index: {
95
99
  * @param index - the position index (`positions.js`), which holds every element inside every block.
96
100
  * @param opts.styleOf - the computed style of an element — injected, so a plain object tree exercises this without a browser.
97
101
  * @param opts.pseudoStyleOf - the computed style of an element's `::before` or `::after`, whose counter operations are logged as the element's first and last child's; absent, a pseudo-element's counters are unread. The `::after` needs the index's `wordCountIn`.
98
- * @param opts.textOf - an element's text; absent, its `textContent`.
102
+ * @param opts.textOf - an element's text; absent, {@link contentTextOf}, its `textContent` without what a printer made from its generated content.
99
103
  * @param opts.attrOf - an element's attribute; absent, its `getAttribute`.
100
104
  * @param opts.ancestors - the elements above the blocks, outermost first (`html`, `body`). They open before the document's first word and are recorded at position −1.
101
105
  * @param opts.marks - elements whose counters are asked about after their own operations (`counterStatesAfter`): each is logged even where it does nothing, carrying itself as `mark`, so the replay reaches it in document order (*a pointer is a read*, 2026-10-03).
@@ -13,13 +13,14 @@
13
13
  *
14
14
  * **A position is the flat word index** (`positions.js`), and an operation applies when its position is strictly before the one asked about — and not inside a remainder a carried flow has still to show, which stands before the page's first word in document order and is on that page or after it (beltype#43; `positions.js`' `remaindersOf`). That is §6.1's *"the deepest element in the normal flow that spans the page break"* said in this engine's own space: an element whose first word is the page's first word begins on the new page and does not span the break, so its own operation is not yet in force, while an element that spans the break started before that word and is included. The ancestors above the top-level blocks sit at position **−1** — they open before the document's first word and span every break there is — which is what makes the first page's answer fall out of the same comparison rather than needing a case of its own.
15
15
  *
16
- * **What is not read, each a real gap and each showing as a counter reading low.** A list item's implicit increment, so an `<ol>` that runs onto a second page numbers its items from 1 there (measured 2026-11-07, `<ol reversed>` and a `counter-set` of `list-item` likewise); css-lists-3's `reversed()` counters; and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
16
+ * **What is not read, each a real gap and each showing as a counter reading low.** css-lists-3's `reversed()` counters (an author's `counter-reset: reversed(name)`), a top-level `position: fixed` box, which is not among the flow blocks the position index is built over; and a scope that ended before the position asked about with no operation logged between its end and the position, since the replay closes a scope only when an operation at or above its depth arrives: a `counter(list-item)` read on a page past a list whose last item holds a list reads the inner list's count (3 where Chromium prints 2, measured 2026-11-07), and an author's nested reset is read the same way. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A list's and a list item's implicit `list-item` operations are read since 2026-11-07 (`implicitListOps`), so a `counter(list-item)` read on a page a list continued onto or past it counts the items before it. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
20
20
  import { STRING_SET_PROP, RUNNING_PROP } from './gcpm-encoding.js';
21
21
  import { format as formatCounter } from './counter-styles.js';
22
22
  import { inRemainders } from './positions.js';
23
+ import { GENERATED_ATTRIBUTE } from './data-beltype.js';
23
24
  /** `counter-reset: a 2 b` → `[['a', 2], ['b', 0]]`. The computed value normalizes name/value pairs, but an absent number still defaults per property, so the default is a parameter. `none` and the empty string yield nothing. */
24
25
  export function pairsOf(value, fallback) {
25
26
  const out = [];
@@ -180,6 +181,24 @@ function splitArgs(raw) {
180
181
  const unquote = (s) => (/^(["']).*\1$/.test(s) ? s.slice(1, -1) : s);
181
182
  /** The whitespace collapsing GCPM asks for: `content()` is *"the string value of the element, determined as if `white-space: normal` had been set"*. */
182
183
  export const collapse = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
184
+ /** An element's text without the elements a printer made from its generated content ({@link GENERATED_ATTRIBUTE}): css-gcpm-3's `content()`, the element's text and not its pseudo-elements'. An element holding none answers its `textContent`, as before. */
185
+ export function contentTextOf(el) {
186
+ if (!el?.querySelector?.(`[${GENERATED_ATTRIBUTE}]`))
187
+ return el?.textContent ?? '';
188
+ let out = '';
189
+ for (const node of el.childNodes) {
190
+ if (node.nodeType === 3 || node.nodeType === 4)
191
+ out += node.data;
192
+ else if (node.nodeType === 1 && !node.hasAttribute(GENERATED_ATTRIBUTE))
193
+ out += contentTextOf(node);
194
+ }
195
+ return out;
196
+ }
197
+ /** The text of an element's own `::before` or `::after` where a printer made it real: its children carrying {@link GENERATED_ATTRIBUTE} with that value, or `null` where it has none. */
198
+ function generatedTextOf(el, which) {
199
+ const made = [...(el?.children ?? [])].filter((child) => child.getAttribute?.(GENERATED_ATTRIBUTE) === which);
200
+ return made.length ? made.map((child) => child.textContent).join('') : null;
201
+ }
183
202
  /** One element's entry in the log, or `null` where it does nothing and is not marked: its three counter declarations, its rewritten `string-set` with the text and attributes its terms name, and the mark, read off the one `styleOf` answer. */
184
203
  function opAt(el, pos, depth, { styleOf, textOf, attrOf, marks }) {
185
204
  const style = styleOf(el) ?? {};
@@ -191,6 +210,10 @@ function opAt(el, pos, depth, { styleOf, textOf, attrOf, marks }) {
191
210
  name,
192
211
  terms,
193
212
  text: collapse(textOf(el)),
213
+ ...Object.fromEntries(['before', 'after'].flatMap((which) => {
214
+ const made = terms.some((t) => t.kind === 'content' && t.which === which) ? generatedTextOf(el, which) : null;
215
+ return made === null ? [] : [[which, collapse(made)]];
216
+ })),
194
217
  attrs: Object.fromEntries(terms.filter((t) => t.kind === 'attr').map((t) => [t.name, attrOf(el, t.name) ?? '']))
195
218
  }))
196
219
  : null;
@@ -205,7 +228,7 @@ function pseudoOpOf(el, pseudo, styleOf) {
205
228
  /** One top-level block's part of the log: its operations in document order, each with its position and depth relative to the block — the words of the block before the element, and its depth below the block (*a resumed run keeps its reads*, 2026-10-22). {@link documentOpsOf} lays the parts end to end, a part's positions offset by its block's base and its depths by the ancestors above the blocks. */
206
229
  export function blockOpsOf(block, index, reads) {
207
230
  const ops = [];
208
- const walk = (el, depth) => {
231
+ const walk = (el, depth, list) => {
209
232
  const style = reads.styleOf(el) ?? {};
210
233
  if (style.display === 'none')
211
234
  return; // CSS 2.1 §12.4.3: an element with `display: none` does not increment or reset counters
@@ -213,7 +236,18 @@ export function blockOpsOf(block, index, reads) {
213
236
  if (style.position === 'fixed' || String(customOf(style, RUNNING_PROP)).trim())
214
237
  return;
215
238
  const pos = index.wordsBefore(el);
216
- const op = opAt(el, pos, depth, reads);
239
+ const implicit = implicitListOps(el, style, list, reads);
240
+ let op = opAt(el, pos, depth, reads);
241
+ if (implicit.reset || implicit.increment || implicit.set) {
242
+ op ??= { reset: [], increment: [], set: [], strings: null, pos, depth };
243
+ const names = (pairs) => pairs.some(([name]) => name === 'list-item');
244
+ if (implicit.reset && !names(op.reset))
245
+ op.reset = [...op.reset, implicit.reset];
246
+ if (implicit.increment && !names(op.increment))
247
+ op.increment = [...op.increment, implicit.increment];
248
+ if (implicit.set && !names(op.set))
249
+ op.set = [...op.set, implicit.set];
250
+ }
217
251
  // A `::before` is the element's first child and an `::after` its last (CSS 2.1 §12.1), so their counter operations are logged one level down, the one at the element's first word and the other at its end. The element's named strings and its mark are evaluated after its `::before`: the number a heading or a figure prints there is the one its `string-set` and a `target-counter()` to it read.
218
252
  const before = pseudoOpOf(el, '::before', reads.pseudoStyleOf);
219
253
  if (before) {
@@ -224,14 +258,56 @@ export function blockOpsOf(block, index, reads) {
224
258
  else if (op)
225
259
  ops.push(op);
226
260
  for (const child of el.children ?? [])
227
- walk(child, depth + 1);
261
+ walk(child, depth + 1, implicit.inner);
228
262
  const after = index.wordCountIn ? pseudoOpOf(el, '::after', reads.pseudoStyleOf) : null;
229
263
  if (after)
230
264
  ops.push({ ...after, strings: null, pos: pos + index.wordCountIn(el), depth: depth + 1 });
231
265
  };
232
- walk(block, 0);
266
+ walk(block, 0, OUTSIDE_LISTS);
233
267
  return ops;
234
268
  }
269
+ const OUTSIDE_LISTS = { step: 1, ol: false };
270
+ /** The list items a list owns, as HTML's list owner has them: the elements displayed as `list-item` below it, nested lists' own left out. */
271
+ function ownedItemCount(list, styleOf) {
272
+ let n = 0;
273
+ for (const c of list.children ?? []) {
274
+ if (/^(?:OL|UL|MENU)$/i.test(c.tagName ?? ''))
275
+ continue;
276
+ const display = String(styleOf(c)?.display ?? '');
277
+ if (display === 'none')
278
+ continue;
279
+ if (display.includes('list-item'))
280
+ n++;
281
+ n += ownedItemCount(c, styleOf);
282
+ }
283
+ return n;
284
+ }
285
+ /**
286
+ * The `list-item` operations an element has with no declaration of them: css-lists-3 §4.3 and HTML's list rendering.
287
+ *
288
+ * @remarks
289
+ * A list (`ol`, `ul`, `menu`) resets `list-item`: an `<ol>` to one step before its `start`, which under `reversed` defaults to the number of items it owns, and a list of another kind to 0. Each element displayed as `list-item` adds its list's step, 1 or −1 under `reversed`, and an `<li>` an `<ol>` owns is set to its `value`. Each is the element's only where its own `counter-reset`, `counter-increment` or `counter-set` does not name `list-item`, which the caller tests. The walk read none of it before 2026-11-07, so a `counter(list-item)` read on a page past a list, in a margin box or in the list's scope after it, read 0.
290
+ *
291
+ * Measured 2026-11-07, Chromium's own `counter(list-item)` differs from this in two places, both left as the spec has them because the marker Cicero numbers by (`fragments.js`' `listOrdinals`) is HTML's and agrees with it: under `reversed` without a `start` Chromium counts down from 0 (0, −1, −2 for HTML's 3, 2, 1), and it ignores `value` (an item after `value="20"` reads 3 where its marker prints 21).
292
+ */
293
+ function implicitListOps(el, style, list, reads) {
294
+ const tag = String(el.tagName ?? '').toUpperCase();
295
+ const item = String(style.display ?? '').includes('list-item');
296
+ const value = item && list.ol && tag === 'LI' ? parseInt(reads.attrOf(el, 'value') ?? '', 10) : NaN;
297
+ const out = { reset: null, increment: item ? ['list-item', list.step] : null, set: Number.isFinite(value) ? ['list-item', value] : null, inner: list };
298
+ if (tag === 'OL') {
299
+ const reversed = reads.attrOf(el, 'reversed') != null;
300
+ const start = parseInt(reads.attrOf(el, 'start') ?? '', 10);
301
+ const step = reversed ? -1 : 1;
302
+ out.reset = ['list-item', (Number.isFinite(start) ? start : reversed ? ownedItemCount(el, reads.styleOf) : 1) - step];
303
+ out.inner = { step, ol: true };
304
+ }
305
+ else if (tag === 'UL' || tag === 'MENU') {
306
+ out.reset = ['list-item', 0];
307
+ out.inner = OUTSIDE_LISTS;
308
+ }
309
+ return out;
310
+ }
235
311
  /**
236
312
  * The counter operations of a document, in document order, each with the flat word position it sits at and its depth in the tree.
237
313
  *
@@ -239,14 +315,14 @@ export function blockOpsOf(block, index, reads) {
239
315
  * @param index - the position index (`positions.js`), which holds every element inside every block.
240
316
  * @param opts.styleOf - the computed style of an element — injected, so a plain object tree exercises this without a browser.
241
317
  * @param opts.pseudoStyleOf - the computed style of an element's `::before` or `::after`, whose counter operations are logged as the element's first and last child's; absent, a pseudo-element's counters are unread. The `::after` needs the index's `wordCountIn`.
242
- * @param opts.textOf - an element's text; absent, its `textContent`.
318
+ * @param opts.textOf - an element's text; absent, {@link contentTextOf}, its `textContent` without what a printer made from its generated content.
243
319
  * @param opts.attrOf - an element's attribute; absent, its `getAttribute`.
244
320
  * @param opts.ancestors - the elements above the blocks, outermost first (`html`, `body`). They open before the document's first word and are recorded at position −1.
245
321
  * @param opts.marks - elements whose counters are asked about after their own operations (`counterStatesAfter`): each is logged even where it does nothing, carrying itself as `mark`, so the replay reaches it in document order (*a pointer is a read*, 2026-10-03).
246
322
  * @param opts.running - the document's running elements in document order (`driver.js`' `runningBoxes`), each with the flat position the flow stood at where it was written (`driver.js`' `runningPosition`): each is logged as an assignment of its name there, carrying its place in this list as `running.at` (*a running element is a named node*, 2026-10-21). A running element is out of the flow at any depth, so the walk does not enter one, and its own counters and strings are unread, as a fixed box's are.
247
323
  * @param opts.keep - the memo's door (`forcing-memo.js`): each block's {@link blockOpsOf} part is asked of it, and a part kept from an earlier run is laid in place of a walk (*a resumed run keeps its reads*, 2026-10-22). A part read under marks is kept apart from one read without, and answers only while the marks that fall in its block are the ones it was read under (the position index says which block a mark is in). Absent, every block is walked.
248
324
  */
249
- export function documentOpsOf(blocks, index, { styleOf, pseudoStyleOf = null, textOf = (el) => el.textContent, attrOf = (el, name) => el.getAttribute(name), ancestors = [], marks = null, running = [], keep = null }) {
325
+ export function documentOpsOf(blocks, index, { styleOf, pseudoStyleOf = null, textOf = contentTextOf, attrOf = (el, name) => el.getAttribute(name), ancestors = [], marks = null, running = [], keep = null }) {
250
326
  const reads = { styleOf, textOf, attrOf, marks, pseudoStyleOf };
251
327
  const ops = [];
252
328
  ancestors.forEach((el, i) => {
@@ -423,11 +499,11 @@ function replay(ops, { until = Infinity, onOp = null, away = [] } = {}) {
423
499
  return snapshot();
424
500
  }
425
501
  /** One `<content-list>` term against the element's own facts and the counter state in force at it. */
426
- function evaluateTerm(term, { text, attrs, counters }) {
502
+ function evaluateTerm(term, { text, before, after, attrs, counters }) {
427
503
  switch (term.kind) {
428
504
  case 'string': return term.value;
429
- // `before`, `after` and `first-letter` are read as `text`: a pseudo-element's own string is generated content this walk does not see, and the module header states the limit rather than pretending to four readings.
430
- case 'content': return text;
505
+ // `before` and `after` read the pseudo a printer made real, where it did; otherwise they and `first-letter` are read as `text`: a native pseudo-element's own string is generated content this walk does not see, and the module header states the limit rather than pretending to four readings.
506
+ case 'content': return (term.which === 'before' ? before : term.which === 'after' ? after : undefined) ?? text;
431
507
  case 'attr': return attrs?.[term.name] ?? '';
432
508
  case 'counter': return formatCounter(counterValue(counters, term.name), term.style);
433
509
  case 'counters': return counterValues(counters, term.name).map((v) => formatCounter(v, term.style)).join(term.separator);
@@ -444,7 +520,7 @@ export function stringAssignmentsOf(ops) {
444
520
  replay(ops, {
445
521
  onOp: (op, counters) => {
446
522
  for (const s of op.strings ?? [])
447
- out.push({ name: s.name, pos: op.pos, value: s.terms.map((t) => evaluateTerm(t, { text: s.text, attrs: s.attrs, counters })).join('') });
523
+ out.push({ name: s.name, pos: op.pos, value: s.terms.map((t) => evaluateTerm(t, { text: s.text, before: s.before, after: s.after, attrs: s.attrs, counters })).join('') });
448
524
  }
449
525
  });
450
526
  return out;
@@ -253,6 +253,12 @@ export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf,
253
253
  };
254
254
  } | null;
255
255
  };
256
+ /**
257
+ * The element children of `root` (the body, unless a host names another) minus what is not the document's: engine scaffolding (an `id` starting `__beltype`), the tags that never render ({@link NEVER_RENDERED}), and any element carrying the host word ({@link HOST}), a host's own machinery standing among the boxes (ruled 2026-11-08). Every top-level box the document declares, in or out of the flow. The two functions below divide it. **This is the one filter**: the client's intake (`seam/client.js`) calls it too, so a box this passes over has no address anywhere. A host that holds the document in an element of its own still names it with `root` first.
258
+ *
259
+ * @param root - the element the boxes stand in.
260
+ */
261
+ export declare function topLevelBoxes(doc: Document, root?: Element): Element[];
256
262
  /**
257
263
  * The document's top-level blocks — the flow, which is what the cuts divide. One definition, shared by the build loop and the differential's judge measure.
258
264
  *
@@ -14,19 +14,19 @@
14
14
  * @packageDocumentation
15
15
  */
16
16
  import { Ledger, createEntry } from './ledger.js';
17
- import { assertRenderConditions, awaitCloneImages, awaitFonts, awaitImages, createMeasureContext } from './measure.js';
17
+ import { assertRenderConditions, awaitCloneImages, awaitFonts, eagerImages, createMeasureContext } from './measure.js';
18
18
  import { holdsAnonymousBlocks, takeReport } from './gauge.js';
19
19
  import { adjudicate, adjudicateFlow, firstBelowTheEdge, isAbsolutelyPositioned, isOutOfFlow, isMonolith, neverCutInside, reachesBelowTheEdge, processionBottoms, fitsPage } from './stonehand.js';
20
20
  import { composeDocument, applyRecordsUnder } from './compose-dom.js';
21
21
  import { CompositionCache } from './composition-key.js';
22
- import { readGeometryTable, effectivePageName, pageRulesOf, styleSheetsOf, registerGcpmProperties, sheetsUseHas, sheetReach, sheetsCountInPseudos } from './capture.js';
22
+ import { readGeometryTable, effectivePageName, pageRulesOf, styleSheetsOf, registerGcpmProperties, sheetsUseHas, sheetReachOf, sheetsCountInPseudos } from './capture.js';
23
23
  import { snapshotOfStyle } from './forcing-memo.js';
24
24
  import { cloneFlowPiece, declineRepeats, mayRepeat, mutateToContinuation, truncateFrom } from './fragments.js';
25
25
  import { cutAfter, divides, elAtIn, flattenResume, indexBlocks, inFlowIndex, inRemainders, normalizePath, pathOf, placeAt, remaindersOf } from './positions.js';
26
26
  import { documentOpsOf, counterStateAt, pageNumberAt, withoutPageCounters, stringAssignmentsOf, runningAssignmentsOf, namedStringsForPage } from './document-state.js';
27
27
  import { RUNNING_PROP } from './gcpm-encoding.js';
28
- import { ARTIFACT, hasWord } from './data-beltype.js';
29
- import { blockChildrenOf, footerBeforeRows, headerAfterRows, isFixedBox, SIDE_BY_SIDE, sourceOf } from './word-space.js';
28
+ import { ARTIFACT, HOST, hasWord } from './data-beltype.js';
29
+ import { blockChildrenOf, footerBeforeRows, inDisplayOrder, isFixedBox, SIDE_BY_SIDE, sourceOf } from './word-space.js';
30
30
  import { changedPairs, settleReads } from './reads.js';
31
31
  import { converge, holderPage, namesNoPlace, priorKeys, restartPage } from './resume.js';
32
32
  import { answerHas, boxesWhoseWordsMoved } from './has-words.js';
@@ -38,31 +38,28 @@ function toPx(len) {
38
38
  return parseFloat(m[1]) * PX_PER[m[2]];
39
39
  }
40
40
  /**
41
- * The message a table shape refuses with (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the table, by its id or class or its first words, and the shape. Both shapes lost rows with no message, and a refusal by name is the detector until the address can say where §17.2 lays the group out.
41
+ * The message a table shape refuses with (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the table, by its id or class or its first words, and the shape. The one shape left is a footer group written before its rows and taller than the page area, refused by name as a box taller than its page is rather than lose rows with no message.
42
42
  *
43
- * @remarks The two shapes, both groups whose words and box disagree in order. A header group written after the rows is laid out above them (CSS 2.1 §17.2) while its words stand after them in the word space: one that repeats is walked first and shown by its copy on every page before its words, and one too tall to repeat, in a divided table, is refused, because which page shows it is the open ruling on §17.2's display order in the word space. A footer group written before the rows is walked where it is laid out, last and whole, and one taller than the page area could be placed nowhere, so the table ended on its first page.
44
- *
45
- * @param group - the source `thead` or `tfoot`.
43
+ * @param group - the source `tfoot`.
46
44
  * @param shape - the shape, in words.
47
45
  * @param remedy - what the author can write instead.
48
46
  */
49
- function refusedTable(group, shape, remedy = 'write the groups in HTML\'s order: thead, tbody, tfoot') {
47
+ function refusedTable(group, shape, remedy) {
50
48
  const table = group.closest('table');
51
49
  const words = (table?.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40);
52
50
  const name = table?.id ? `<table id="${table.id}">` : table?.className ? `<table class="${table.className}">` : `the table beginning "${words}"`;
53
51
  return `cicero: ${name} has ${shape}, which this engine cannot divide across pages yet; it refuses rather than lose rows. To print it, ${remedy}.`;
54
52
  }
55
- /** The nearest copy the engine repeats (`artifact`) standing ahead of this element among its siblings — the table's header, in front of its rows — or null. */
53
+ /** The nearest copy the engine repeats (`artifact`) standing ahead of this element among its siblings in display order (`inDisplayOrder`) — the table's header, in front of its rows, wherever it is written — or null. */
56
54
  const artifactBefore = (el) => {
55
+ const isCopy = (s) => typeof s.getAttribute === 'function' && hasWord(s, ARTIFACT);
56
+ const parent = el?.parentElement;
57
+ const written = parent ? [...parent.children] : [];
58
+ const shown = parent ? inDisplayOrder(parent, written) : written;
59
+ if (shown !== written)
60
+ return shown.slice(0, shown.indexOf(el)).reverse().find(isCopy) ?? null;
57
61
  for (let s = el?.previousElementSibling; s; s = s.previousElementSibling)
58
- if (typeof s.getAttribute === 'function' && hasWord(s, ARTIFACT))
59
- return s;
60
- return null;
61
- };
62
- /** The header group written after this element among its siblings, which §17.2 lays out ahead of it all the same (`headerAfterRows`), or null. It is a copy on the pages before its words and the original in a feed, which pours it whole, and it stands ahead of the rows either way. */
63
- const headerCopyAfter = (el) => {
64
- for (let s = el?.nextElementSibling; s; s = s.nextElementSibling)
65
- if (headerAfterRows(s))
62
+ if (isCopy(s))
66
63
  return s;
67
64
  return null;
68
65
  };
@@ -82,10 +79,9 @@ export function gapAbove(box, report, win) {
82
79
  for (let child = box, a = box.parent; a; child = a, a = a.parent) {
83
80
  const cs = win.getComputedStyle(a.el);
84
81
  // A copy the engine repeats ahead of the child (the table's header) is furniture with a footprint, not a spent lead (*the header repeats*): the level owes only what stands above the copy, which under separated borders is the table's `border-spacing` above its first row, and Chromium draws none above a repeated header (*a repeated header joins the rows as Chromium prints them*, 2026-10-22). Under collapsed borders the copy's top is the table's own and nothing is owed.
85
- const late = headerCopyAfter(child.el);
86
- if (late || standsAfterArtifact(child.el)) {
87
- // The header's copy, never a footer's: a footer written before its rows stands among the siblings too, and is laid out at the foot (*the footer before the rows*). A header written after its rows is laid out ahead of them, and its copy is the one.
88
- let copy = cs.borderCollapse === 'separate' ? late ?? artifactBefore(child.el) : null;
82
+ if (standsAfterArtifact(child.el)) {
83
+ // The header's copy, never a footer's: the siblings are in display order, where a footer stands after the rows wherever it is written (*the-word-space-follows-the-table-display-order*).
84
+ let copy = cs.borderCollapse === 'separate' ? artifactBefore(child.el) : null;
89
85
  while (copy && !/^THEAD$/i.test(copy.tagName))
90
86
  copy = artifactBefore(copy);
91
87
  const box = copy ? report.boxOf(copy) : null;
@@ -363,13 +359,15 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
363
359
  }
364
360
  };
365
361
  }
362
+ /** The tags that never render, so the intake passes over them wherever a host leaves one among the document's boxes: `TEMPLATE`, whose content is inert; `LINK`, `META`, `BASE`, `TITLE` and `HEAD`, metadata the HTML parser may leave in the body and the UA sheet hides; and `NOSCRIPT`, which renders only when scripts are off, and the engine is a script. A closed list, beside `STYLE` and `SCRIPT`, which were passed over before it (ruled 2026-11-08). */
363
+ const NEVER_RENDERED = new Set(['STYLE', 'SCRIPT', 'TEMPLATE', 'LINK', 'META', 'BASE', 'TITLE', 'HEAD', 'NOSCRIPT']);
366
364
  /**
367
- * The element children of `root` (the body, unless a host names another) minus engine scaffolding — every top-level box the document declares, in or out of the flow. The two functions below divide it.
365
+ * The element children of `root` (the body, unless a host names another) minus what is not the document's: engine scaffolding (an `id` starting `__beltype`), the tags that never render ({@link NEVER_RENDERED}), and any element carrying the host word ({@link HOST}), a host's own machinery standing among the boxes (ruled 2026-11-08). Every top-level box the document declares, in or out of the flow. The two functions below divide it. **This is the one filter**: the client's intake (`seam/client.js`) calls it too, so a box this passes over has no address anywhere. A host that holds the document in an element of its own still names it with `root` first.
368
366
  *
369
367
  * @param root - the element the boxes stand in.
370
368
  */
371
- function topLevelBoxes(doc, root = doc.body) {
372
- return [...root.children].filter((el) => !el.id.startsWith('__beltype') && !['STYLE', 'SCRIPT'].includes(el.tagName));
369
+ export function topLevelBoxes(doc, root = doc.body) {
370
+ return [...root.children].filter((el) => !el.id.startsWith('__beltype') && !NEVER_RENDERED.has(el.tagName) && !hasWord(el, HOST));
373
371
  }
374
372
  /**
375
373
  * The document's top-level blocks — the flow, which is what the cuts divide. One definition, shared by the build loop and the differential's judge measure.
@@ -538,7 +536,7 @@ export function paginate(doc, opts = {}) {
538
536
  /**
539
537
  * The memo this call reads through, settled once before any walk, or `null` (*the forced-break index is kept across runs*, 2026-10-09; *a resumed run keeps its reads*, 2026-10-22).
540
538
  *
541
- * The boxes a resumed call names as edited — `dirtyAt` and `edited`, resolved against the blocks as they stand now — are reported through the memo's one door, {@link ForcingMemo.replaced}, beside the boxes `patchBox` reported there: a host that edits a box in place, as the venue's pins do, replaces no element and would leave the walks' parts of its box standing. Then the memo is settled against the sheets' reach; a sheet by which a box could change an ancestor's style (`sheetReach` 2) empties it for this call, and the console says so once.
539
+ * The boxes a resumed call names as edited — `dirtyAt` and `edited`, resolved against the blocks as they stand now — are reported through the memo's one door, {@link ForcingMemo.replaced}, beside the boxes `patchBox` reported there: a host that edits a box in place, as the venue's pins do, replaces no element and would leave the walks' parts of its box standing. Then the memo is settled against the sheets' reach; a sheet by which a box could change an ancestor's style (`sheetReach` 2) empties it for this call and a sheet by which it could change a sibling's (1) narrows it, and the console says each once, naming the rule ({@link ForcingMemo.notice}).
542
540
  */
543
541
  function settledMemo(doc, { forcingMemo = null, prior, dirtyAt, edited = [], blocks }) {
544
542
  if (!forcingMemo)
@@ -551,13 +549,12 @@ function settledMemo(doc, { forcingMemo = null, prior, dirtyAt, edited = [], blo
551
549
  forcingMemo.replaced(elAtIn(block, at.path) ?? block);
552
550
  }
553
551
  }
554
- if (forcingMemo.settle(sheetReach(doc)))
555
- return forcingMemo;
556
- if (!forcingMemo.said) {
557
- forcingMemo.said = true;
558
- doc.defaultView?.console?.info('BelType/Cicero: a stylesheet uses :has(), an @container rule or a selector the engine cannot read, so the forced-break index and the run-level walks are read afresh on every run rather than kept. The pages are the same; an edit costs more.');
559
- }
560
- return null;
552
+ const read = sheetReachOf(doc);
553
+ const usable = forcingMemo.settle(read.reach);
554
+ const line = forcingMemo.notice(read);
555
+ if (line)
556
+ doc.defaultView?.console?.info(line);
557
+ return usable ? forcingMemo : null;
561
558
  }
562
559
  /**
563
560
  * One run: the document laid out once, from its first word to its last, or from the page an edit dirtied to the page where the run converges. A layout the reads send round again resumes from the layout before it, at the boxes whose lines the delivered values moved (*delivery resumes from the moved page*, 2026-10-21): `laid.resume` names them and that layout, and they stand in for `prior`, `dirtyAt` and `edited`.
@@ -569,13 +566,13 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
569
566
  assertRenderConditions(doc, { relaxDpr });
570
567
  // The two rewritten GCPM declarations, registered before anything reads a computed style: unregistered, a custom property inherits, and `string-set` on a heading would reach every descendant of it (*the host rewrites what the parser drops*, 2026-09-14).
571
568
  registerGcpmProperties(doc.defaultView);
572
- // Once each, before the first measure — late-arriving truth never enters adjudication. one call site covers every host, because the seam's bootstrap, the editing tools' `beltype:ready` call and the venue's own client all enter through here. The images are a stand-in for the first run, and `measure.js`'s `awaitImages` carries the whole reason (*images are awaited at intake*, beltype#39).
569
+ // Once each, before the first measure — late-arriving truth never enters adjudication. one call site covers every host, because the seam's bootstrap, the editing tools' `beltype:ready` call and the venue's own client all enter through here. The images are not awaited (ruled 2026-11-08): they are made eager and the run goes on, and an arrival that changes a box is the rim's (`measure.js`'s `eagerImages` carries the reason).
573
570
  // The run keeps time in the User Timing API (*the run keeps time*, 2026-09-25): one running timestamp, one `performance.measure` per phase, `detail` carrying the count a reader normalizes by. A measure is read by nothing here and decides nothing.
574
571
  let t = performance.now();
575
572
  await awaitFonts(doc);
576
573
  performance.measure('cicero:fonts', { start: t, end: (t = performance.now()) });
577
- const images = await awaitImages(originals ?? intakeBlocks ?? doc);
578
- performance.measure('cicero:images', { start: t, end: (t = performance.now()), detail: { images } });
574
+ const images = eagerImages(originals ?? intakeBlocks ?? doc);
575
+ performance.measure('cicero:images', { start: t, end: (t = performance.now()), detail: { images, awaited: false } }); // the eager pass alone: nothing is waited for since 2026-11-08
579
576
  const table = intakeTable ?? readGeometryTable(doc);
580
577
  await awaitFonts(doc, table.furnitureFaces); // the faces page furniture names, requested now and not when the first margin box is presented (the table is read after the first wait, so its lengths are measured as they always were)
581
578
  const win = doc.defaultView;
@@ -1231,7 +1228,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1231
1228
  performance.measure('cicero:feed', { start: t, end: (t = performance.now()), detail: { page: account.length, blocks: items.length, carried: (cursor.carried ?? []).length, bound: bounded ? words : null, reach: (bounded ? posOfCut(place.cut) : forcingCuts ? posOfCut(forcedEvent.cut) : index.total) - cursor.pos, pours } });
1232
1229
  container = ctx.measure(items.map((it) => it.clone), laid.facts?.[account.length] ?? null); // a page fact a flow site reads stands as the run before decided it, or at its reservation (*the page carries its conditions*)
1233
1230
  performance.measure('cicero:layout', { start: t, end: (t = performance.now()), detail: { page: account.length, blocks: items.length } });
1234
- // The clones' own images, settled before the report is taken: a clone of a loaded image is not always available in the turn it is mounted (`measure.js`'s `awaitCloneImages`), and a figure measured at no height moves every cut after it.
1231
+ // The clones' own images, settled before the report is taken: a clone of a loaded image is not always available in the turn it is mounted (`measure.js`'s `awaitCloneImages`), and a figure measured at no height moves every cut after it. Kept when the first run's awaits went (2026-11-08), until the Service Worker case is measured without it.
1235
1232
  const waited = await awaitCloneImages(container);
1236
1233
  performance.measure('cicero:clone-images', { start: t, end: (t = performance.now()), detail: { page: account.length, waited } });
1237
1234
  report = takeReport(container, { contentHeight: conditions.contentH });
@@ -1479,17 +1476,10 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1479
1476
  if (!fits && hasGroups) {
1480
1477
  const ct = container.getBoundingClientRect().top;
1481
1478
  const edge = pageStart + conditions.contentH;
1482
- // A header written after its rows that does not repeat, in a table the page's edge divides, is refused (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): its words are on the table's last fragment and its box above the first, and which page shows it is the open ruling on §17.2's display order in the word space. Undivided, the one fragment holds both.
1483
- for (const h of container.querySelectorAll('thead')) {
1484
- const src = sourceOf(h);
1485
- const r = h.closest('table')?.getBoundingClientRect();
1486
- if (src && r && declined.has(src) && headerAfterRows(src) && r.top - ct < edge && r.bottom - ct > edge)
1487
- throw new Error(refusedTable(src, 'a header group written after its rows, too tall to repeat'));
1488
- }
1489
1479
  for (const f of container.querySelectorAll('tfoot')) {
1490
1480
  const src = sourceOf(f);
1491
- // A footer written before its rows that does not repeat is still reserved where it is shown: its words stand before the rows, so it is on the table's first fragment and nowhere after (*the footer before the rows*, 2026-10-15; Chromium shows it on the last, which the address cannot say).
1492
- if (!f.getClientRects().length || !src || (declined.has(src) && !footerBeforeRows(src)) || !mayRepeat(src))
1481
+ // A footer that does not repeat is not reserved: its words follow the rows wherever it is written (*the-word-space-follows-the-table-display-order*), so it stands on the table's last fragment alone, where Chromium prints it.
1482
+ if (!f.getClientRects().length || !src || declined.has(src) || !mayRepeat(src))
1493
1483
  continue;
1494
1484
  const table = f.closest('table');
1495
1485
  if (!table)
@@ -13,9 +13,12 @@
13
13
  *
14
14
  * **Who must keep the promise.** The host: a box replaced any other way than `patchBox` (which calls {@link ForcingMemo.replaced}) must be reported the same way or the run must ask for a fresh memo. Under the top-level unit, a top-level box whose name or attributes change is a new element, and the box it replaced is reported too. The seam fills the memo only from runs on a presented frame, never from the first run after load, whose realm (the sources are children of `body`, not of `<beltype-root>`) can answer a `body > …` or `:first-child` selector otherwise than later runs do.
15
15
  *
16
+ * **A rule that only paints reaches nothing** (ruled 2026-11-08). Both readings pass over a style rule whose declarations all set a property on `capture.js`' `PAINT_ONLY` list, whatever its selector, and an `@container` or `@scope` rule every rule under which paints. Where a reach of 2 puts the memo out of use or a reach of 1 narrows it, {@link ForcingMemo.notice} gives the console one line per memo life at each level, naming the first rule that set the level and its sheet.
17
+ *
16
18
  * @see .claude/decisions/cicero/the-forced-break-index-is-kept-across-runs.md
17
19
  * @packageDocumentation
18
20
  */
21
+ import type { Reach } from './capture.ts';
19
22
  /** The computed style properties `forcingIndexOf` and the predicates it calls (`isAbsolutelyPositioned`, `isOutOfFlow`, `isMonolith`, `neverCutInside`, `notYetDivided`) read of a box, and so the whole of a snapshot. `test/forcing-memo.test.js` records the reads of a real walk against this list. */
20
23
  export declare const FORCING_STYLE_READS: readonly string[];
21
24
  /** The door a run-level walk asks a per-box part through (*a resumed run keeps its reads*, 2026-10-22): `kind` names the walk, `box` is the top-level box the part is of, `read` takes the part, and `still`, where given, says whether a kept part still answers this run's question. {@link ForcingMemo.keep} is the one implementation; a walk handed none reads every box. */
@@ -29,6 +32,8 @@ export declare class ForcingMemo {
29
32
  misses: number;
30
33
  /** Whether the console has said, for this memo's life, that a sheet's reach put it out of use. */
31
34
  said: boolean;
35
+ /** Whether the console has said, for this memo's life, that a sheet's reach of 1 narrowed what it keeps. */
36
+ saidNarrowed: boolean;
32
37
  /** Per walk, the boxes whose part was kept and the boxes read, since the last {@link ForcingMemo.settle}. */
33
38
  tally: Record<string, {
34
39
  kept: number;
@@ -36,6 +41,11 @@ export declare class ForcingMemo {
36
41
  }>;
37
42
  /** The top-level boxes the memo was last settled with under the top-level unit, in order, which `capture.js`' `topLevelReach` takes as `before`; empty where it keeps nothing. */
38
43
  get boxes(): Element[];
44
+ /**
45
+ * The console line for a reach that put this memo out of use (2) or narrowed it (1), once per memo life at each level, or `null` (ruled 2026-11-08): it names the first rule that set the level, its sheet, and how many more rules reached as far, so an author can find the rule that costs the edit.
46
+ * @param read - `capture.js`' `sheetReachOf` or `topLevelReachOf`.
47
+ */
48
+ notice(read: Reach): string | null;
39
49
  /** Forget everything: the conditions moved, the run was not a resume, or it threw. */
40
50
  clear(): void;
41
51
  /** Begin a run: the tally starts again. */
@@ -14,6 +14,8 @@
14
14
  *
15
15
  * **Who must keep the promise.** The host: a box replaced any other way than `patchBox` (which calls {@link ForcingMemo.replaced}) must be reported the same way or the run must ask for a fresh memo. Under the top-level unit, a top-level box whose name or attributes change is a new element, and the box it replaced is reported too. The seam fills the memo only from runs on a presented frame, never from the first run after load, whose realm (the sources are children of `body`, not of `<beltype-root>`) can answer a `body > …` or `:first-child` selector otherwise than later runs do.
16
16
  *
17
+ * **A rule that only paints reaches nothing** (ruled 2026-11-08). Both readings pass over a style rule whose declarations all set a property on `capture.js`' `PAINT_ONLY` list, whatever its selector, and an `@container` or `@scope` rule every rule under which paints. Where a reach of 2 puts the memo out of use or a reach of 1 narrows it, {@link ForcingMemo.notice} gives the console one line per memo life at each level, naming the first rule that set the level and its sheet.
18
+ *
17
19
  * @see .claude/decisions/cicero/the-forced-break-index-is-kept-across-runs.md
18
20
  * @packageDocumentation
19
21
  */
@@ -32,6 +34,8 @@ export class ForcingMemo {
32
34
  misses = 0;
33
35
  /** Whether the console has said, for this memo's life, that a sheet's reach put it out of use. */
34
36
  said = false;
37
+ /** Whether the console has said, for this memo's life, that a sheet's reach of 1 narrowed what it keeps. */
38
+ saidNarrowed = false;
35
39
  #facts = new WeakMap();
36
40
  #pageName = new WeakMap();
37
41
  #children = new WeakMap();
@@ -47,6 +51,24 @@ export class ForcingMemo {
47
51
  get boxes() {
48
52
  return this.#boxes;
49
53
  }
54
+ /**
55
+ * The console line for a reach that put this memo out of use (2) or narrowed it (1), once per memo life at each level, or `null` (ruled 2026-11-08): it names the first rule that set the level, its sheet, and how many more rules reached as far, so an author can find the rule that costs the edit.
56
+ * @param read - `capture.js`' `sheetReachOf` or `topLevelReachOf`.
57
+ */
58
+ notice(read) {
59
+ const { reach, culprit, count } = read;
60
+ if (reach === 0 || !culprit || (reach === 2 ? this.said : this.saidNarrowed))
61
+ return null;
62
+ if (reach === 2)
63
+ this.said = true;
64
+ else
65
+ this.saidNarrowed = true;
66
+ const what = culprit.kind === 'style' ? `the rule "${culprit.selector}" in ${culprit.sheet}` : culprit.kind === 'unreadable' ? `the sheet ${culprit.sheet}, which the engine cannot read,` : `an @${culprit.kind} rule in ${culprit.sheet}`;
67
+ const more = count ? `${culprit.kind === 'unreadable' ? '' : ','} and ${count} more ${count === 1 ? 'rule' : 'rules'},` : '';
68
+ return reach === 2
69
+ ? `BelType/Cicero: ${what}${more} can change the style of a box an edit did not touch, so the forced-break index and the run-level walks are read afresh on every run rather than kept; the pages are the same and an edit costs more.`
70
+ : `BelType/Cicero: ${what}${more} can make a box change the style of the boxes beside it, so the forced-break index and the run-level walks read the boxes beside an edited one again rather than keep them; the pages are the same and an edit costs more.`;
71
+ }
50
72
  /** Forget everything: the conditions moved, the run was not a resume, or it threw. */
51
73
  clear() {
52
74
  this.#facts = new WeakMap();
@@ -2,18 +2,18 @@
2
2
  * The fragmentation face: the engine's members a view adapter builds on, `@beltype/cicero/fragmentation`.
3
3
  *
4
4
  * @remarks
5
- * Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged; nothing is new and nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/internal/chase`.
5
+ * Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/internal/chase`.
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
9
  export { effectivePageName, readGeometryTable, registerGcpmProperties, topLevelReach, transliterateContent } from './capture.ts';
10
- export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, addWord, hasWord, removeWord } from './data-beltype.ts';
10
+ export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.ts';
11
11
  export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.ts';
12
12
  export { ensureContinuationDressing, ensureDressing } from './dressing.ts';
13
13
  export { forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks } from './driver.ts';
14
14
  export { ForcingMemo } from './forcing-memo.ts';
15
15
  export type { Keep } from './forcing-memo.ts';
16
- export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack } from './fragments.ts';
16
+ export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack, truncateLeadingDecoration } from './fragments.ts';
17
17
  export { takeReport } from './gauge.ts';
18
18
  export { STRING_KEYWORDS, encodeStringProp } from './gcpm-encoding.ts';
19
19
  export { assertRenderConditions, awaitCloneImages, awaitFonts, createMeasureContext } from './measure.ts';
@@ -21,4 +21,4 @@ export { divides, elAt, extensionPx, indexBlocks, normalizePath, pageContaining,
21
21
  export { present, presentation } from './presenters.ts';
22
22
  export { cssString, findPageSites, settleReads } from './reads.ts';
23
23
  export { adjudicate, firstBelowTheEdge, fitsPage, isOutOfFlow, neverCutInside, processionBottoms, reachesBelowTheEdge } from './stonehand.ts';
24
- export { BREAKING_SPACE, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.ts';
24
+ export { BREAKING_SPACE, clonesDecoration, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.ts';