@beltype/cicero 0.1.7 → 0.1.9

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.
@@ -10,7 +10,7 @@
10
10
  * @packageDocumentation
11
11
  */
12
12
  import type { Decl } from './margin-boxes.ts';
13
- import type { counterStateAt } from './document-state.ts';
13
+ import { type counterStateAt, type PageCounterOps } from './document-state.ts';
14
14
  import type { Px } from './gauge.ts';
15
15
  export type Side = 'top' | 'right' | 'bottom' | 'left';
16
16
  /** One value per side of a box. */
@@ -40,6 +40,8 @@ export type PageGeometry = {
40
40
  /** Raw `content` values per margin box, transliterated per page by {@link transliterateContent}. */
41
41
  marginBoxes: Record<string, string>;
42
42
  pageStyle: PageStyle;
43
+ /** What the page context's rules do to the page counter, which `document-state.ts`'s `pageNumberAt` applies and the host never wears. */
44
+ pageCounter: PageCounterOps;
43
45
  };
44
46
  /** What a page is asked for by: the four conditions css-page-3 §4 selects `@page` rules on. */
45
47
  export type PageCondition = {
@@ -102,6 +104,8 @@ export declare function styleSheetsOf(doc: Document): CSSStyleSheet[];
102
104
  * A box outside a fragment's interval is kept hollow, an element with no children, and every structural selector matches it as it matched the deep clone it replaced — except a `:has()` whose argument reaches inside it. Since 2026-10-10 the host rewrites every `:has()` it can (`seam/rewrite.js`) into a union whose second arm reads a word the engine writes on the copy (`data-beltype.js`, `has-words.js`), so a hollow copy matches as its original does and the answer here is NO for a document whose `:has()` arguments are all rewritten. What is left is the `:has()` whose argument reads state a word computed once cannot follow — `:hover`, `:checked`, `:focus-within`, `:target` and their kin — and a sheet no host rewrote at all: for those the fragments are built deep, as before. Grouping rules, nested rules and imported sheets are walked, since a selector inside `@media` or `@layer` still matches; a sheet the engine cannot read answers yes, because it might. The reading is textual and counts: a rule whose selector holds more `:has(` than unions has one that nothing answers for.
103
105
  */
104
106
  export declare function sheetsUseHas(doc: Document): boolean;
107
+ /** Whether a rule sets a counter on a `::before` or an `::after`: only then does the counter walk ask every element's pseudo-elements for their counters (`document-state.ts`'s `documentOpsOf`), which costs two style reads an element. A sheet the engine cannot read answers yes. */
108
+ export declare function sheetsCountInPseudos(doc: Document): boolean;
105
109
  /** The arguments the host rewrote, read back from the registry rules (`:not(*) { --beltype-has-<h>: "<A>" }`, one per rewritten sheet): hash → the argument's canonical text. The capture reads the CSSOM and nothing else, so the arguments come from the rule the rewriter appended and no selector is ever parsed back. */
106
110
  export declare function hasArgumentsOf(doc: Document): Map<string, string>;
107
111
  /**
@@ -13,6 +13,7 @@ import { MARGIN_BOXES } from './margin-boxes.js';
13
13
  import { expandBox } from './box-shorthand.js';
14
14
  import { CUSTOM_PROPS, decodeContentFn, decodePageName, encodeStringProp } from './gcpm-encoding.js';
15
15
  import { HAS_PROPERTY_PREFIX, bareHasCount, decodeHasArgument, isHasRegistryRule } from './data-beltype.js';
16
+ import { pairsOf } from './document-state.js';
16
17
  const PX_PER = { px: 1, pt: 96 / 72, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6 };
17
18
  /**
18
19
  * Every sheet the document wears — `document.styleSheets` first, then `document.adoptedStyleSheets`.
@@ -32,6 +33,10 @@ export function styleSheetsOf(doc) {
32
33
  export function sheetsUseHas(doc) {
33
34
  return someRule(doc, (rule) => !!rule.selectorText && bareHasCount(rule.selectorText) > 0);
34
35
  }
36
+ /** Whether a rule sets a counter on a `::before` or an `::after`: only then does the counter walk ask every element's pseudo-elements for their counters (`document-state.ts`'s `documentOpsOf`), which costs two style reads an element. A sheet the engine cannot read answers yes. */
37
+ export function sheetsCountInPseudos(doc) {
38
+ return someRule(doc, (rule) => !!rule.selectorText && /::?(?:before|after)\b/i.test(rule.selectorText) && /\bcounter-(?:reset|increment|set)\s*:/.test(rule.style?.cssText ?? ''));
39
+ }
35
40
  /** The arguments the host rewrote, read back from the registry rules (`:not(*) { --beltype-has-<h>: "<A>" }`, one per rewritten sheet): hash → the argument's canonical text. The capture reads the CSSOM and nothing else, so the arguments come from the rule the rewriter appended and no selector is ever parsed back. */
36
41
  export function hasArgumentsOf(doc) {
37
42
  const found = new Map();
@@ -659,6 +664,36 @@ function declarationsOf(style, owned = ownedByCicero) {
659
664
  }
660
665
  return out;
661
666
  }
667
+ /** The three counter properties, each with the value an unnumbered name takes. */
668
+ const PAGE_COUNTER_PROPS = { 'counter-reset': 0, 'counter-increment': 1, 'counter-set': 0 };
669
+ /**
670
+ * A page rule's declarations with the page counter taken out of its `counter-reset`, `counter-increment` and `counter-set`: the page number is `pageNumberAt`'s, written on the host as `page N`, and an increment the host wore beside it was added to N (`@page { counter-increment: page 2 }` printed 3, 4, 5 where Chromium prints 2, 4, 6) while the host's inline reset hid every `@page` reset. Another counter's pair stays where it was.
671
+ */
672
+ function withoutPageCounter(decls) {
673
+ return decls.flatMap((d) => {
674
+ const fallback = PAGE_COUNTER_PROPS[d[0]];
675
+ if (fallback === undefined)
676
+ return [d];
677
+ const pairs = pairsOf(d[1], fallback);
678
+ if (!pairs.some(([name]) => name === 'page'))
679
+ return [d];
680
+ const rest = pairs.filter(([name]) => name !== 'page');
681
+ return rest.length ? [[d[0], rest.map(([name, value]) => `${name} ${value}`).join(' '), d[2]]] : [];
682
+ });
683
+ }
684
+ /** The page counter's value in each of a condition's three winning counter declarations, `null` where the winner names no `page`: the cascade over the applicable rules, in ascending specificity, `!important` beating normal. */
685
+ function pageCounterOf(rules) {
686
+ const winners = {};
687
+ for (const r of rules)
688
+ for (const d of r.style)
689
+ if (d[0] in PAGE_COUNTER_PROPS && (d[2] === 'important' || winners[d[0]]?.[2] !== 'important'))
690
+ winners[d[0]] = d;
691
+ const pageOf = (prop) => {
692
+ const pair = pairsOf(winners[prop]?.[1], PAGE_COUNTER_PROPS[prop]).findLast(([name]) => name === 'page');
693
+ return pair ? pair[1] : null;
694
+ };
695
+ return { reset: pageOf('counter-reset'), increment: pageOf('counter-increment'), set: pageOf('counter-set') };
696
+ }
662
697
  /**
663
698
  * The faces page furniture asks for, as `FontFaceSet.load()` arguments (the platform's font preloading) — read from the `@page` rules' own declarations, where the families are already in hand.
664
699
  *
@@ -1051,7 +1086,7 @@ export function readGeometryTable(doc) {
1051
1086
  try {
1052
1087
  for (const r of rules) {
1053
1088
  r.split = { host: [], cell: [] };
1054
- for (const decl of r.style)
1089
+ for (const decl of withoutPageCounter(r.style))
1055
1090
  (inheritance.reaches(decl) ? r.split.cell : r.split.host).push(decl);
1056
1091
  }
1057
1092
  }
@@ -1114,7 +1149,7 @@ export function readGeometryTable(doc) {
1114
1149
  const b = Object.fromEntries(SIDES.map((side) => [side, borderIsDrawn(borderStyles[side]) ? resolve(borderWidths[side] ?? 'medium', basePx(side), `${label} border-${side}-width`, 'borderTopWidth') : 0]));
1115
1150
  const area = pageArea({ pageW, pageH, margins: m, borders: b, paddings: p });
1116
1151
  assertPageArea(area, label);
1117
- return { pageW, pageH, margins: m, borders: b, paddings: p, ...area, marginBoxes, pageStyle };
1152
+ return { pageW, pageH, margins: m, borders: b, paddings: p, ...area, marginBoxes, pageStyle, pageCounter: pageCounterOf(applicable) };
1118
1153
  }
1119
1154
  /**
1120
1155
  * The intake sweep (*page lengths are measured*, decision 3). Every reachable condition is resolved once, here — where the document is live and the fonts are awaited — so an unresolvable length refuses at intake rather than throwing from inside the per-page loop, pages deep, after the measure context has been built. The set is finite and small: each declared page name plus the unnamed page, × `:first`, × parity. Afterwards the memo answers every per-page call, so `pageFor` stays synchronous and keeps its signature.
package/dist/src/chase.js CHANGED
@@ -312,9 +312,11 @@ export class BeltypePage extends HTMLElement {
312
312
  const keys = names.map((n, i) => `${this.#dressText} ${n} ${generated[i]} ${(this.#reads[n] ?? []).map((r) => `${r}=${this.#published.get(r) ?? ''}`).join(',')} ${cellKey(cells[i])}`);
313
313
  let pairs = keys.map((k) => INTRINSICS.get(k));
314
314
  if (pairs.some((p) => !p)) {
315
+ // A rect is drawn under every transform above the page, and a host that zooms with `transform: scale(s)` would hand §5.3.2 widths in its pixels, not the page's, and the cache would keep them for every scale after (assessment 2026-11-07). The band spans exactly `#bandWidth` of the page's own pixels, so its rect says the scale.
316
+ const scale = el.getBoundingClientRect().width / this.#bandWidth || 1;
315
317
  const read = (track) => {
316
318
  el.style.gridTemplateColumns = `${track} ${track} ${track}`;
317
- return cells.map((c) => c.getBoundingClientRect().width);
319
+ return cells.map((c) => c.getBoundingClientRect().width / scale);
318
320
  };
319
321
  const max = read('max-content');
320
322
  const min = read('min-content');
@@ -153,9 +153,9 @@ export declare const CUT_OFF: string;
153
153
  * The rules, as text. Each is a sentence about a word.
154
154
  *
155
155
  * - `away`: a box put away on this page is not displayed, and it stays an element so every structural selector reads as it does in the source (*every branch gets a place*, 2026-09-21).
156
- * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border and padding at that edge.
156
+ * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border, its padding and its corners' rounding at that edge: a slice is the box cut through, so the cut edge is square (Chromium's print, measured 2026-11-07).
157
157
  * - A resumed piece's counter operations already happened where the box began (`counter-reset`, `counter-increment` and `counter-set` are `none` on it): the page's start delivers the counters as they stood (`chase.js`'s `setCounters`), and a clone that fired them again would count the box twice.
158
- * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border and padding there.
158
+ * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border, padding and rounding there.
159
159
  * - `adjoin`: the box a bare-path cut resumes at, and the first-child chain through which its margin collapses, give up the margin that adjoins the break (css-break-3 §5.2) and nothing else — they are not divided.
160
160
  */
161
161
  export declare const WORDS_RULES: string;
@@ -292,18 +292,18 @@ const RESUMED_BOX = `${RESUMED}:not(${w(SHELL)})`;
292
292
  * The rules, as text. Each is a sentence about a word.
293
293
  *
294
294
  * - `away`: a box put away on this page is not displayed, and it stays an element so every structural selector reads as it does in the source (*every branch gets a place*, 2026-09-21).
295
- * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border and padding at that edge.
295
+ * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border, its padding and its corners' rounding at that edge: a slice is the box cut through, so the cut edge is square (Chromium's print, measured 2026-11-07).
296
296
  * - A resumed piece's counter operations already happened where the box began (`counter-reset`, `counter-increment` and `counter-set` are `none` on it): the page's start delivers the counters as they stood (`chase.js`'s `setCounters`), and a clone that fired them again would count the box twice.
297
- * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border and padding there.
297
+ * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border, padding and rounding there.
298
298
  * - `adjoin`: the box a bare-path cut resumes at, and the first-child chain through which its margin collapses, give up the margin that adjoins the break (css-break-3 §5.2) and nothing else — they are not divided.
299
299
  */
300
300
  export const WORDS_RULES = `
301
301
  @layer ${LAYER} {
302
302
  ${w(AWAY)} { display: none !important; }
303
303
  ${RESUMED_BOX} { margin-top: 0 !important; text-indent: 0 !important; counter-reset: none !important; counter-increment: none !important; counter-set: none !important; }
304
- ${RESUMED_BOX}:not(${w(WRAPPED)}) { border-top-width: 0 !important; padding-top: 0 !important; }
304
+ ${RESUMED_BOX}:not(${w(WRAPPED)}) { border-top-width: 0 !important; padding-top: 0 !important; border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; }
305
305
  ${CUT_OFF} { margin-bottom: 0 !important; }
306
- ${CUT_OFF}:not(${w(WRAPPED)}) { border-bottom-width: 0 !important; padding-bottom: 0 !important; }
306
+ ${CUT_OFF}:not(${w(WRAPPED)}) { border-bottom-width: 0 !important; padding-bottom: 0 !important; border-bottom-left-radius: 0 !important; border-bottom-right-radius: 0 !important; }
307
307
  ${w(ADJOIN)} { margin-top: 0 !important; }
308
308
  }
309
309
  `;
@@ -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 counter operation on a `::before` or `::after`; a list item's implicit increment; 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. **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.** A list item's implicit increment, so a `counter(list-item)` an author's own content reads, a margin box's among them, reads low on a page a list continues onto; the list's markers are right since 2026-11-07, because each divided `<ol>`'s copy carries a `start` of HTML's own numbering (`fragments.js`'s `numberDividedLists`); 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.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -62,6 +62,8 @@ export type Op = {
62
62
  at: number;
63
63
  };
64
64
  };
65
+ /** `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. */
66
+ export declare function pairsOf(value: unknown, fallback: number): [string, number][];
65
67
  /**
66
68
  * A `<content-list>` into terms — GCPM §1.1.1's own grammar and no more of CSS than it needs.
67
69
  *
@@ -78,11 +80,13 @@ export declare const collapse: (text: unknown) => string;
78
80
  /** 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. */
79
81
  export declare function blockOpsOf(block: any, index: {
80
82
  wordsBefore: (el: any) => number;
83
+ wordCountIn?: (el: any) => number;
81
84
  }, reads: {
82
85
  styleOf: (el: any) => any;
83
86
  textOf: (el: any) => string;
84
87
  attrOf: (el: any, name: string) => string | null;
85
88
  marks: Set<object> | null;
89
+ pseudoStyleOf?: ((el: any, pseudo: '::before' | '::after') => any) | null;
86
90
  }): Op[];
87
91
  /**
88
92
  * The counter operations of a document, in document order, each with the flat word position it sits at and its depth in the tree.
@@ -90,6 +94,7 @@ export declare function blockOpsOf(block: any, index: {
90
94
  * @param blocks - the top-level flow blocks, in order — `driver.js`'s `topLevelBlocks`.
91
95
  * @param index - the position index (`positions.js`), which holds every element inside every block.
92
96
  * @param opts.styleOf - the computed style of an element — injected, so a plain object tree exercises this without a browser.
97
+ * @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`.
93
98
  * @param opts.textOf - an element's text; absent, its `textContent`.
94
99
  * @param opts.attrOf - an element's attribute; absent, its `getAttribute`.
95
100
  * @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.
@@ -100,11 +105,13 @@ export declare function blockOpsOf(block: any, index: {
100
105
  export declare function documentOpsOf(blocks: ArrayLike<Element>, index: {
101
106
  bases: number[];
102
107
  wordsBefore: (el: any) => number;
108
+ wordCountIn?: (el: any) => number;
103
109
  entryFor?: (el: any) => {
104
110
  block: number;
105
111
  } | null;
106
- }, { styleOf, textOf, attrOf, ancestors, marks, running, keep }: {
112
+ }, { styleOf, pseudoStyleOf, textOf, attrOf, ancestors, marks, running, keep }: {
107
113
  styleOf: (el: any) => object;
114
+ pseudoStyleOf?: ((el: any, pseudo: '::before' | '::after') => object) | null;
108
115
  textOf?: (el: any) => string;
109
116
  attrOf?: (el: any, name: string) => string | null;
110
117
  ancestors?: Element[];
@@ -128,8 +135,16 @@ export declare function counterStateAt(ops: ReturnType<typeof documentOpsOf>, po
128
135
  from: number;
129
136
  to: number;
130
137
  }>): Record<string, number[]>;
138
+ /** What the page context's own rules do to the page counter on one page: the `page` value of the winning `counter-reset`, `counter-increment` and `counter-set` of the `@page` rules that apply to it, `null` where the winning declaration names no `page` (`capture.ts`'s `pageFor`). */
139
+ export type PageCounterOps = {
140
+ reset: number | null;
141
+ increment: number | null;
142
+ set: number | null;
143
+ };
144
+ /** No page-context rule touches the page counter. */
145
+ export declare const NO_PAGE_COUNTER_OPS: PageCounterOps;
131
146
  /**
132
- * The page number of page `pageIndex`, which begins at `pos`: the run's own count, or, where an element reset the page counter, that element's value counted on from the page it begins on.
147
+ * The page number of page `pageIndex`, which begins at `pos`: the run's own count, with an element's reset of the page counter counted on from the page it begins on, and the page context's own `counter-reset`, `counter-increment` and `counter-set` applied as Chromium's print applies them.
133
148
  *
134
149
  * @remarks
135
150
  * css-page-3 names a reset of the page counter only in the page context, but an author writes `counter-reset: page 1` on a chapter or on each letter of a print run to number its pages from one, and once the engine applies it the pages after must count on from there rather than stay at the value (Codeberg issue 1). The page counter is the page's own, reset on the host as `page N` (`chase.js`'s `setCounters`), so the reset is folded into N here and never delivered as an author counter: delivered, it reset the flow and the margin cells to the same value on every later page in the reset's scope. `target-counter()` to a page reads the same N (`reads.js`'s `pageValues`), so a reference answers the value the counter has at its target (Codeberg issue 2).
@@ -138,16 +153,21 @@ export declare function counterStateAt(ops: ReturnType<typeof documentOpsOf>, po
138
153
  *
139
154
  * **A blank page the engine makes to bring the element to its side begins where the element does and stands before it** (Codeberg issue 13): the reset is not in force on it, so it continues the count it stands in, and it is not counted toward the pages after it, so the element's page bears the value. A blank page inside the scope, after the element began, counts as every page does.
140
155
  *
141
- * @param pages - each page before `pageIndex`, by index: its first word position and whether it is blank.
156
+ * **The page context's rules are the page box's, and each page box is its own scope.** Measured 2026-11-07 on Chromium's print (`test/browser/page-context-counters.browser.ts`): every page adds its `counter-increment` of `page`, or 1 where none is declared, to the count the pages carry; a `counter-set` sets that count, and the pages after count on from it (`@page :first { counter-set: page 7 }` prints 7, 8, 9); a `counter-reset` opens a counter of the page box's own, so the page prints the reset value plus that page's own declared increment and the pages after continue the count as though it had not been there (`@page :first { counter-reset: page 10 }` prints 10, 1, 2, and with `@page { counter-increment: page 2 }` beside it 12, 2, 4). An element's reset is applied to the count before the page's own rules, and the page it begins on takes no implicit increment. Until 2026-11-07 these declarations reached the page host as author style, where `counter-increment: page 2` added to the host's `page N` and the host's inline reset hid every `@page` reset: 3, 4, 5 for Chromium's 7, 2, 4.
157
+ *
158
+ * @param pages - each page before `pageIndex`, by index: its first word position, whether it is blank, and its page context's rules.
142
159
  * @param blank - whether page `pageIndex` is one the engine made blank.
160
+ * @param counter - page `pageIndex`'s page context's rules.
143
161
  * @param away - the remainders the page's opening cut carries in, as for {@link counterStateAt}.
144
162
  */
145
- export declare function pageNumberAt(ops: ReadonlyArray<Op>, pageIndex: number, pos: number, { pages, blank, away }: {
163
+ export declare function pageNumberAt(ops: ReadonlyArray<Op>, pageIndex: number, pos: number, { pages, blank, counter, away }: {
146
164
  pages: (i: number) => {
147
165
  start: number;
148
166
  blank: boolean;
167
+ counter?: PageCounterOps;
149
168
  };
150
169
  blank?: boolean;
170
+ counter?: PageCounterOps;
151
171
  away?: ReadonlyArray<{
152
172
  from: number;
153
173
  to: number;
@@ -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 counter operation on a `::before` or `::after`; a list item's implicit increment; 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. **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.** A list item's implicit increment, so a `counter(list-item)` an author's own content reads, a margin box's among them, reads low on a page a list continues onto; the list's markers are right since 2026-11-07, because each divided `<ol>`'s copy carries a `start` of HTML's own numbering (`fragments.js`'s `numberDividedLists`); 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.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -21,7 +21,7 @@ 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
23
  /** `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
- function pairsOf(value, fallback) {
24
+ export function pairsOf(value, fallback) {
25
25
  const out = [];
26
26
  const tokens = String(value ?? '').trim().split(/\s+/).filter(Boolean);
27
27
  if (!tokens.length || tokens[0] === 'none')
@@ -197,6 +197,11 @@ function opAt(el, pos, depth, { styleOf, textOf, attrOf, marks }) {
197
197
  const mark = marks?.has(el) ? el : null;
198
198
  return op || strings || mark ? { reset: [], increment: [], set: [], ...op, strings, pos, depth, ...(mark ? { mark } : {}) } : null;
199
199
  }
200
+ /** A pseudo-element's counter operations, or `null` where it has none or is not generated: a `::before` or an `::after` whose `content` is `none` or `normal` is no box, and its computed `counter-increment` counts nothing (measured 2026-11-07: Chromium answers `fig 1` for it and the counter stays at 0). */
201
+ function pseudoOpOf(el, pseudo, styleOf) {
202
+ const style = styleOf?.(el, pseudo);
203
+ return style && !/^(?:none|normal)?$/.test(String(style.content ?? '').trim()) ? opOf(style) : null;
204
+ }
200
205
  /** 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. */
201
206
  export function blockOpsOf(block, index, reads) {
202
207
  const ops = [];
@@ -207,11 +212,22 @@ export function blockOpsOf(block, index, reads) {
207
212
  // A `position: fixed` box has zero footprint at any depth (*a fixed box at any depth*, 2026-09-15), so it has no position in the word space this log is ordered by — `indexBlocks` skips it and so does this. A counter operation or a `string-set` inside such a box is therefore unread, which is exactly what was already true of a top-level fixed box and is recorded on the checklist beside it.
208
213
  if (style.position === 'fixed' || String(customOf(style, RUNNING_PROP)).trim())
209
214
  return;
210
- const op = opAt(el, index.wordsBefore(el), depth, reads);
211
- if (op)
215
+ const pos = index.wordsBefore(el);
216
+ const op = opAt(el, pos, depth, reads);
217
+ // 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
+ const before = pseudoOpOf(el, '::before', reads.pseudoStyleOf);
219
+ if (before) {
220
+ if (op && (op.reset.length || op.increment.length || op.set.length))
221
+ ops.push({ ...op, strings: null, mark: undefined });
222
+ ops.push({ ...before, strings: op?.strings ?? null, pos, depth: depth + 1, ...(op?.mark ? { mark: op.mark } : {}) });
223
+ }
224
+ else if (op)
212
225
  ops.push(op);
213
226
  for (const child of el.children ?? [])
214
227
  walk(child, depth + 1);
228
+ const after = index.wordCountIn ? pseudoOpOf(el, '::after', reads.pseudoStyleOf) : null;
229
+ if (after)
230
+ ops.push({ ...after, strings: null, pos: pos + index.wordCountIn(el), depth: depth + 1 });
215
231
  };
216
232
  walk(block, 0);
217
233
  return ops;
@@ -222,6 +238,7 @@ export function blockOpsOf(block, index, reads) {
222
238
  * @param blocks - the top-level flow blocks, in order — `driver.js`'s `topLevelBlocks`.
223
239
  * @param index - the position index (`positions.js`), which holds every element inside every block.
224
240
  * @param opts.styleOf - the computed style of an element — injected, so a plain object tree exercises this without a browser.
241
+ * @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`.
225
242
  * @param opts.textOf - an element's text; absent, its `textContent`.
226
243
  * @param opts.attrOf - an element's attribute; absent, its `getAttribute`.
227
244
  * @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.
@@ -229,8 +246,8 @@ export function blockOpsOf(block, index, reads) {
229
246
  * @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.
230
247
  * @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.
231
248
  */
232
- export function documentOpsOf(blocks, index, { styleOf, textOf = (el) => el.textContent, attrOf = (el, name) => el.getAttribute(name), ancestors = [], marks = null, running = [], keep = null }) {
233
- const reads = { styleOf, textOf, attrOf, marks };
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 }) {
250
+ const reads = { styleOf, textOf, attrOf, marks, pseudoStyleOf };
234
251
  const ops = [];
235
252
  ancestors.forEach((el, i) => {
236
253
  const op = opAt(el, -1, i, reads);
@@ -256,7 +273,7 @@ export function documentOpsOf(blocks, index, { styleOf, textOf = (el) => el.text
256
273
  const mine = marksIn?.get(b) ?? new Set();
257
274
  const read = () => ({ ops: blockOpsOf(blocks[b], index, reads), marks: mine });
258
275
  const part = keep
259
- ? keep(marks ? 'ops:marked' : 'ops', blocks[b], read, marks ? (p) => p.marks.size === mine.size && [...mine].every((m) => p.marks.has(m)) : undefined)
276
+ ? keep(`${marks ? 'ops:marked' : 'ops'}${pseudoStyleOf ? ':pseudo' : ''}`, blocks[b], read, marks ? (p) => p.marks.size === mine.size && [...mine].every((m) => p.marks.has(m)) : undefined)
260
277
  : read();
261
278
  for (const op of part.ops)
262
279
  ops.push({ ...op, pos: start + op.pos, depth: base + op.depth });
@@ -289,8 +306,10 @@ const runningOp = (r, at) => ({ reset: [], increment: [], set: [], strings: null
289
306
  export function counterStateAt(ops, pos, away = []) {
290
307
  return replay(ops, { until: pos, away });
291
308
  }
309
+ /** No page-context rule touches the page counter. */
310
+ export const NO_PAGE_COUNTER_OPS = Object.freeze({ reset: null, increment: null, set: null });
292
311
  /**
293
- * The page number of page `pageIndex`, which begins at `pos`: the run's own count, or, where an element reset the page counter, that element's value counted on from the page it begins on.
312
+ * The page number of page `pageIndex`, which begins at `pos`: the run's own count, with an element's reset of the page counter counted on from the page it begins on, and the page context's own `counter-reset`, `counter-increment` and `counter-set` applied as Chromium's print applies them.
294
313
  *
295
314
  * @remarks
296
315
  * css-page-3 names a reset of the page counter only in the page context, but an author writes `counter-reset: page 1` on a chapter or on each letter of a print run to number its pages from one, and once the engine applies it the pages after must count on from there rather than stay at the value (Codeberg issue 1). The page counter is the page's own, reset on the host as `page N` (`chase.js`'s `setCounters`), so the reset is folded into N here and never delivered as an author counter: delivered, it reset the flow and the margin cells to the same value on every later page in the reset's scope. `target-counter()` to a page reads the same N (`reads.js`'s `pageValues`), so a reference answers the value the counter has at its target (Codeberg issue 2).
@@ -299,33 +318,53 @@ export function counterStateAt(ops, pos, away = []) {
299
318
  *
300
319
  * **A blank page the engine makes to bring the element to its side begins where the element does and stands before it** (Codeberg issue 13): the reset is not in force on it, so it continues the count it stands in, and it is not counted toward the pages after it, so the element's page bears the value. A blank page inside the scope, after the element began, counts as every page does.
301
320
  *
302
- * @param pages - each page before `pageIndex`, by index: its first word position and whether it is blank.
321
+ * **The page context's rules are the page box's, and each page box is its own scope.** Measured 2026-11-07 on Chromium's print (`test/browser/page-context-counters.browser.ts`): every page adds its `counter-increment` of `page`, or 1 where none is declared, to the count the pages carry; a `counter-set` sets that count, and the pages after count on from it (`@page :first { counter-set: page 7 }` prints 7, 8, 9); a `counter-reset` opens a counter of the page box's own, so the page prints the reset value plus that page's own declared increment and the pages after continue the count as though it had not been there (`@page :first { counter-reset: page 10 }` prints 10, 1, 2, and with `@page { counter-increment: page 2 }` beside it 12, 2, 4). An element's reset is applied to the count before the page's own rules, and the page it begins on takes no implicit increment. Until 2026-11-07 these declarations reached the page host as author style, where `counter-increment: page 2` added to the host's `page N` and the host's inline reset hid every `@page` reset: 3, 4, 5 for Chromium's 7, 2, 4.
322
+ *
323
+ * @param pages - each page before `pageIndex`, by index: its first word position, whether it is blank, and its page context's rules.
303
324
  * @param blank - whether page `pageIndex` is one the engine made blank.
325
+ * @param counter - page `pageIndex`'s page context's rules.
304
326
  * @param away - the remainders the page's opening cut carries in, as for {@link counterStateAt}.
305
327
  */
306
- export function pageNumberAt(ops, pageIndex, pos, { pages, blank = false, away = [] }) {
328
+ export function pageNumberAt(ops, pageIndex, pos, { pages, blank = false, counter = NO_PAGE_COUNTER_OPS, away = [] }) {
307
329
  const inForce = (at, start, isBlank) => at < start || (at === start && !isBlank);
308
- let reset = null;
330
+ const resets = [];
309
331
  for (const op of ops) {
310
332
  if (op.pos > pos)
311
333
  break;
312
- if (!inForce(op.pos, pos, blank))
313
- continue;
314
- if (away.length && inRemainders(away, op.pos))
315
- continue;
316
334
  for (const [name, value] of [...op.reset, ...op.set])
317
335
  if (name === 'page')
318
- reset = { pos: op.pos, value };
336
+ resets.push({ pos: op.pos, value });
319
337
  }
320
- if (!reset)
321
- return pageIndex + 1;
322
- let since = 0;
323
- for (let i = 0; i < pageIndex; i++) {
324
- const page = pages(i);
325
- if (inForce(reset.pos, page.start, page.blank))
326
- since++;
338
+ /** The last element reset in force on a page; the current page alone knows the remainders it carries in. */
339
+ const resetOn = (start, isBlank, current) => {
340
+ let found = null;
341
+ for (const r of resets)
342
+ if (inForce(r.pos, start, isBlank) && !(current && away.length && inRemainders(away, r.pos)))
343
+ found = r;
344
+ return found;
345
+ };
346
+ let count = 0;
347
+ let shown = 0;
348
+ let before = null;
349
+ for (let i = 0; i <= pageIndex; i++) {
350
+ const page = i === pageIndex ? { start: pos, blank, counter } : pages(i);
351
+ const own = page.counter ?? NO_PAGE_COUNTER_OPS;
352
+ const reset = resets.length ? resetOn(page.start, page.blank, i === pageIndex) : null;
353
+ const elementReset = reset !== null && reset !== before;
354
+ before = reset;
355
+ if (elementReset)
356
+ count = reset.value;
357
+ if (own.reset !== null) {
358
+ // The page box's own counter: it shows here and the count goes on beneath it.
359
+ shown = own.set ?? own.reset + (own.increment ?? 0);
360
+ continue;
361
+ }
362
+ count += own.increment ?? (elementReset ? 0 : 1);
363
+ if (own.set !== null)
364
+ count = own.set;
365
+ shown = count;
327
366
  }
328
- return reset.value + since;
367
+ return shown;
329
368
  }
330
369
  /** The author's counters alone: the page's own two, `page` and `pages`, are the host's (`pageNumberAt`), and delivered as an author's they would reset the flow and the cells under the host's. */
331
370
  export function withoutPageCounters(state) {
@@ -25,7 +25,11 @@ export type Dressing = {
25
25
  /** whether the value words' properties are registered, which is done once a stylesheet the document wears names one. */
26
26
  registered: boolean;
27
27
  };
28
- /** Make sure the document has the words sheet's rules, once; later calls answer the same record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears. */
28
+ /**
29
+ * Make sure the document has the words sheet's rules, and answer its record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears.
30
+ *
31
+ * **The document is asked, not remembered** ([*the engine asks the document for its sheets*](../../../.claude/decisions/cicero/the-engine-asks-the-document-for-its-sheets.md)), as `ensureContinuationDressing` does: a host that re-seats a kept `Document` empties its adopted sheets, so a later call adopts the words sheet again, and names the layer again, where the document no longer carries them.
32
+ */
29
33
  export declare function ensureDressing(doc: Document): Dressing;
30
34
  /**
31
35
  * Make sure the document wears the continuation dressing, and answer its sheet: a piece that resumes a box shows none of the box's beginnings, `::before`, `::marker`, `::first-letter` and `::first-line`, and a piece the cut passes through shows no `::after`. The sheet is made once per document and adopted after whatever the document wears; a later call adopts it again only where the document no longer wears it.
@@ -36,25 +36,31 @@ function providesRules(doc) {
36
36
  const provision = doc.head.querySelector('style[data-beltype~="cicero-provision"]');
37
37
  return !!provision && (provision.textContent ?? '').includes(WORDS_RULES);
38
38
  }
39
- /** Make sure the document has the words sheet's rules, once; later calls answer the same record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears. */
39
+ /**
40
+ * Make sure the document has the words sheet's rules, and answer its record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears.
41
+ *
42
+ * **The document is asked, not remembered** ([*the engine asks the document for its sheets*](../../../.claude/decisions/cicero/the-engine-asks-the-document-for-its-sheets.md)), as `ensureContinuationDressing` does: a host that re-seats a kept `Document` empties its adopted sheets, so a later call adopts the words sheet again, and names the layer again, where the document no longer carries them.
43
+ */
40
44
  export function ensureDressing(doc) {
41
- const known = DRESSINGS.get(doc);
42
- if (known)
43
- return known;
44
- const dressing = { words: null, values: null, delivered: new Set(), registered: false };
45
- DRESSINGS.set(doc, dressing);
46
- if (providesRules(doc))
45
+ let dressing = DRESSINGS.get(doc);
46
+ if (!dressing) {
47
+ dressing = { words: null, values: null, delivered: new Set(), registered: false };
48
+ DRESSINGS.set(doc, dressing);
49
+ if (providesRules(doc))
50
+ return dressing;
51
+ dressing.words = new (doc.defaultView.CSSStyleSheet)();
52
+ dressing.words.replaceSync(WORDS_RULES);
53
+ }
54
+ if (!dressing.words)
47
55
  return dressing;
48
- const Sheet = doc.defaultView.CSSStyleSheet;
49
56
  if (!doc.getElementById(LAYERS_ID)) {
50
57
  const layers = doc.createElement('style');
51
58
  layers.id = LAYERS_ID;
52
59
  layers.textContent = `@layer ${LAYER};`;
53
60
  doc.head.prepend(layers);
54
61
  }
55
- dressing.words = new Sheet();
56
- dressing.words.replaceSync(WORDS_RULES);
57
- doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, dressing.words];
62
+ if (!doc.adoptedStyleSheets.includes(dressing.words))
63
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, dressing.words];
58
64
  return dressing;
59
65
  }
60
66
  /** The continuation dressing each document wears. */
@@ -134,13 +140,14 @@ export function deliverValues(doc, words) {
134
140
  dressing.delivered.add(word);
135
141
  grew = true;
136
142
  }
137
- if (!grew)
143
+ if (!grew && !dressing.values)
138
144
  return;
139
- if (!dressing.values) {
140
- dressing.values = new (doc.defaultView.CSSStyleSheet)();
145
+ // The document is asked, not remembered: a re-seated document wears no values sheet even when no word is new.
146
+ dressing.values ??= new (doc.defaultView.CSSStyleSheet)();
147
+ if (!doc.adoptedStyleSheets.includes(dressing.values))
141
148
  doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, dressing.values];
142
- }
143
- dressing.values.replaceSync([...dressing.delivered].map((word) => `${selectorOf(word)} { ${PROPERTY_OF.get(word.slice(0, word.indexOf(':')))}: ${word.slice(word.indexOf(':') + 1)}; }`).join('\n'));
149
+ if (grew)
150
+ dressing.values.replaceSync([...dressing.delivered].map((word) => `${selectorOf(word)} { ${PROPERTY_OF.get(word.slice(0, word.indexOf(':')))}: ${word.slice(word.indexOf(':') + 1)}; }`).join('\n'));
144
151
  }
145
152
  /** Is this adopted sheet one of the engine's three sheets keyed on words — the words sheet a host without the provision gets, the values sheet, and the continuation dressing? A host reading the sheets the document wears (`seam/client.js`' conditions key) leaves them out, as it leaves out the root's own sheets and the reads' delivery sheet. */
146
153
  export function isWordsSheet(doc, sheet) {
@@ -19,7 +19,7 @@ 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 } from './capture.js';
22
+ import { readGeometryTable, effectivePageName, pageRulesOf, styleSheetsOf, registerGcpmProperties, sheetsUseHas, sheetReach, 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';
@@ -648,6 +648,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
648
648
  */
649
649
  const documentOps = documentOpsOf(blocks, index, {
650
650
  styleOf: (el) => win.getComputedStyle(el),
651
+ pseudoStyleOf: sheetsCountInPseudos(doc) ? (el, pseudo) => win.getComputedStyle(el, pseudo) : null,
651
652
  ancestors: [doc.documentElement, doc.body].filter(Boolean),
652
653
  running: running.map((r) => ({ name: r.name, pos: runningPosition(r, index) })),
653
654
  keep: memo?.keep
@@ -780,14 +781,23 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
780
781
  let compositionKey = null;
781
782
  // Entry N determines page N+1's conditions (*geometry changes only at page boundaries*, C8): name from the cursor's own address, parity from the page number, :first for page one. A width change re-creates the measure context and recomposes through the cache's width key — *cut-invariance* (C7) suspended locally, exactly as designed; the v3 constraint (widths change only at named-switch boundaries) keeps that suspension off mid-paragraph continuations.
782
783
  const conditionsFor = (pageIndex, { blank = false } = {}) => conditionsAt(pageIndex, cursor.cut, cursor.pos, { blank, carried: cursor.carried });
784
+ /** A kept page's `@page` counter rules, by its conditions: one geometry read per kind of page, not one per page per page. */
785
+ const pageCounters = new Map();
786
+ const pageCounterOf = (i, c) => {
787
+ const key = JSON.stringify([c.name, i === 0, c.parity, c.blank]);
788
+ let ops = pageCounters.get(key);
789
+ if (!ops)
790
+ pageCounters.set(key, ops = table.pageFor({ name: c.name, first: i === 0, parity: c.parity, blank: c.blank }).pageCounter);
791
+ return ops;
792
+ };
783
793
  /** The conditions of page `pageIndex`, which begins at `cut` (projected to `pos`) — {@link conditionsFor} with the cursor made a parameter, so a resumed run can write the conditions of a page it keeps from the address that page began at (*the run resumes from an entry*, build two, 2026-10-08). */
784
794
  const conditionsAt = (pageIndex, cut, pos, { blank = false, carried = null } = {}) => {
785
795
  const name = nameAt(cut);
786
796
  const away = remaindersOf(carried, index.endOf);
787
- // The run's count, or an author's reset of the page counter counted on from the page it begins on (`document-state.js`'s `pageNumberAt`). Entry `i` closes page `i`, so page `i` begins where entry `i - 1` ended.
788
- const pageNumber = pageNumberAt(documentOps, pageIndex, pos, { pages: (i) => ({ start: i === 0 ? 0 : account[i - 1].pos ?? 0, blank: account[i].blank === true }), blank, away });
789
797
  const parity = parityAt(pageIndex);
790
798
  const geo = table.pageFor({ name, first: pageIndex === 0, parity, blank });
799
+ // The run's count, an author's reset of the page counter counted on from the page it begins on, and each page's own `@page` counter rules (`document-state.js`'s `pageNumberAt`). Entry `i` closes page `i`, so page `i` begins where entry `i - 1` ended, under the conditions it closes.
800
+ const pageNumber = pageNumberAt(documentOps, pageIndex, pos, { pages: (i) => ({ start: i === 0 ? 0 : account[i - 1].pos ?? 0, blank: account[i].blank === true, counter: pageCounterOf(i, account[i].closes) }), blank, counter: geo.pageCounter, away });
791
801
  // The document's counters as they stand at this page's first word — strictly before it, which is §6.1's *spans the page break* in the flat position space (`document-state.js`) — less what the flows carried onto this page have still to show, which stands before that word and is on this page or after it (beltype#43). The page's own two are not among them: they are the host's, and `pageNumber` carries the reset.
792
802
  const counters = withoutPageCounters(counterStateAt(documentOps, pos, away));
793
803
  return Object.freeze({ ...geo, name, pageNumber, parity, blank, counters, dpr: win.devicePixelRatio });
@@ -1376,7 +1386,8 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1376
1386
  const cutY = v.y;
1377
1387
  const places = v.items.map(({ item, state, verdict }) => ({
1378
1388
  item,
1379
- at: state === 'above' ? { box: item, word: null, px: { remaining: 0, paint: null }, flows: [] } : state === 'below' || state === 'held' ? { box: item, word: null, px: null, flows: [] } : verdict ? verdictPlace(verdict) : placesOf(item, cutY)
1389
+ // An item that is `done` has shown all its content and continues as a shell of what its box has left; the Stonehand said so knowing where a divided cell's content stands (`cellLift`), which the geometry of the feed does not.
1390
+ at: state === 'above' ? { box: item, word: null, px: { remaining: 0, paint: null }, flows: [] } : state === 'done' ? { box: item, word: null, px: { remaining: item.rect.bottom - cutY, paint: null }, flows: [] } : state === 'below' || state === 'held' ? { box: item, word: null, px: null, flows: [] } : verdict ? verdictPlace(verdict) : placesOf(item, cutY)
1380
1391
  }));
1381
1392
  const last = places.pop();
1382
1393
  return { ...last.at, itemBox: last.item, flows: [...places.map(({ item, at }) => ({ ...at, itemBox: item, remainingPx: item.rect.top < cutY && item.rect.bottom > cutY ? item.rect.bottom - cutY : null })), ...last.at.flows] };
@@ -1607,6 +1618,14 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1607
1618
  visit(f);
1608
1619
  })(rowPlace);
1609
1620
  }
1621
+ else if (v.kind !== 'E') {
1622
+ // A table cell the main path's cut divides gets the same extension: Chromium's print aligns a divided cell's content within its first fragment, which reaches the foot, and the Stonehand judged the cut so (`cellLift`).
1623
+ const foot = pageStart + usableH;
1624
+ for (let p = v.kind === 'A' ? v.box.parent : v.box; p; p = p.parent) {
1625
+ if (win.getComputedStyle(p.el).display === 'table-cell' && p.rect.top < v.y - 0.25 && p.rect.bottom > v.y + 0.25)
1626
+ extents.push({ path: pathOf(sourceOf(p.el), blocks[cutB], cutB), px: foot - p.rect.top });
1627
+ }
1628
+ }
1610
1629
  const tf = performance.now();
1611
1630
  const flat = flattenResume({ pos, box: sourceOf(v.box.el), cut, flows });
1612
1631
  addr.flatten += performance.now() - tf;
@@ -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';
@@ -2,17 +2,17 @@
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.js';
10
- export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, addWord, hasWord, removeWord } from './data-beltype.js';
10
+ export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.js';
11
11
  export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.js';
12
12
  export { ensureContinuationDressing, ensureDressing } from './dressing.js';
13
13
  export { forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks } from './driver.js';
14
14
  export { ForcingMemo } from './forcing-memo.js';
15
- export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack } from './fragments.js';
15
+ export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack, truncateLeadingDecoration } from './fragments.js';
16
16
  export { takeReport } from './gauge.js';
17
17
  export { STRING_KEYWORDS, encodeStringProp } from './gcpm-encoding.js';
18
18
  export { assertRenderConditions, awaitCloneImages, awaitFonts, createMeasureContext } from './measure.js';
@@ -20,4 +20,4 @@ export { divides, elAt, extensionPx, indexBlocks, normalizePath, pageContaining,
20
20
  export { present, presentation } from './presenters.js';
21
21
  export { cssString, findPageSites, settleReads } from './reads.js';
22
22
  export { adjudicate, firstBelowTheEdge, fitsPage, isOutOfFlow, neverCutInside, processionBottoms, reachesBelowTheEdge } from './stonehand.js';
23
- export { BREAKING_SPACE, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.js';
23
+ export { BREAKING_SPACE, clonesDecoration, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.js';
@@ -141,6 +141,12 @@ export declare function mutateToContinuation(clone: Element, { cut, remainderPx,
141
141
  hollow?: boolean;
142
142
  forced?: boolean;
143
143
  }): Element;
144
+ /**
145
+ * The ordinal value of each item an `<ol>` owns, as HTML numbers them (§4.4.8) and css-lists-3 states it as the `list-item` counter: the list resets the counter one step before its `start`, which under `reversed` defaults to the number of items; each item adds its step, 1 or −1 under `reversed` unless a `counter-increment` names `list-item`, and a `counter-set` of `list-item`, or else a `value` attribute, sets it.
146
+ *
147
+ * @remarks A `counter-reset` of `list-item` on an item, which opens a list of its own inside the list, is not modelled.
148
+ */
149
+ export declare function listOrdinals(ol: Element): Map<Element, number>;
144
150
  /**
145
151
  * A clone of a top-level block for one interval, hollow outside it (*the clone is hollow outside the interval*, 2026-09-27) — the one construction behind the measure feed and every presented fragment, and the one place the clone-to-source map is filled.
146
152
  *
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { applyRecordsUnder, hyphenCharOf } from './compose-dom.js';
21
21
  import { addWord, ADJOIN, ARTIFACT, AWAY, END, ENGINE_MARKS, hasAnyWord, hasWord, removeWord, SHELL, SPLIT, START, WRAPPED } from './data-beltype.js';
22
+ import { pairsOf } from './document-state.js';
22
23
  import { divides, elAtIn, flowsOf } from './positions.js';
23
24
  import { BREAKING_SPACE, clonesDecoration, cutRanges, emptyText, leaveOut, replaceChars, isEngineText, isFixedBox, isInlineBox, isLineBreak, copyOf, shellOf, noteText, READS_ITS_CHILDREN, sourceOf, sourceStyle } from './word-space.js';
24
25
  /** The word space, clone side: words are maximal non-whitespace runs of the subtree's joined text — a run crossing a text-node or inline-element seam is one word, exactly as the Gauge reads, and exactly as the position index counts over sources (src/positions.js — the two tokenizations must never drift; counting per node instead shifts every offset by the seam-spanning words before it, which the test-document's inline markup caught). **A non-inline boundary ends the run** (word-space.js), which is where this stopped agreeing with the page for two months. This walk survives on the clone side only: consumption must find the resume word's actual text nodes in the mutated copy, which no source-side index can answer. */
@@ -790,8 +791,72 @@ export function mutateToContinuation(clone, { cut = null, remainderPx = null, so
790
791
  applyCarriedRemainder(clone, source, remainderPx);
791
792
  for (const m of mine)
792
793
  mutateToContinuation(m.itemClone, { cut: m.cut, remainderPx: m.e.remainingPx ?? null, source: m.itemSource, cloneOf, flows: m.nested, rootPath: m.e.item, hollow });
794
+ numberDividedLists(root, cut, source, cloneOf);
793
795
  return root;
794
796
  }
797
+ /** The list items an `<ol>` owns, in tree order: HTML's list owner is the nearest `ol`, `ul` or `menu` ancestor. An item that is not displayed as a list item has no ordinal, where a style can be read. */
798
+ function ownedItems(ol) {
799
+ const out = [];
800
+ (function walk(el) {
801
+ for (const c of el.children) {
802
+ if (/^(?:OL|UL|MENU)$/i.test(c.tagName))
803
+ continue;
804
+ if (/^LI$/i.test(c.tagName)) {
805
+ const display = sourceStyle(c)?.display;
806
+ if (display == null || display.includes('list-item'))
807
+ out.push(c);
808
+ }
809
+ walk(c);
810
+ }
811
+ })(ol);
812
+ return out;
813
+ }
814
+ /**
815
+ * The ordinal value of each item an `<ol>` owns, as HTML numbers them (§4.4.8) and css-lists-3 states it as the `list-item` counter: the list resets the counter one step before its `start`, which under `reversed` defaults to the number of items; each item adds its step, 1 or −1 under `reversed` unless a `counter-increment` names `list-item`, and a `counter-set` of `list-item`, or else a `value` attribute, sets it.
816
+ *
817
+ * @remarks A `counter-reset` of `list-item` on an item, which opens a list of its own inside the list, is not modelled.
818
+ */
819
+ export function listOrdinals(ol) {
820
+ const items = ownedItems(ol);
821
+ const reversed = ol.hasAttribute('reversed');
822
+ const dflt = reversed ? -1 : 1;
823
+ const start = parseInt(ol.getAttribute('start') ?? '', 10);
824
+ let n = (Number.isFinite(start) ? start : reversed ? items.length : 1) - dflt;
825
+ const out = new Map();
826
+ for (const li of items) {
827
+ const style = sourceStyle(li);
828
+ const named = (value, fallback) => pairsOf(value, fallback).find(([name]) => name === 'list-item')?.[1];
829
+ n += named(style?.counterIncrement, 1) ?? dflt;
830
+ const value = parseInt(li.getAttribute('value') ?? '', 10);
831
+ const set = named(style?.counterSet, 0) ?? (Number.isFinite(value) ? value : null);
832
+ if (set != null)
833
+ n = set;
834
+ out.set(li, n);
835
+ }
836
+ return out;
837
+ }
838
+ /**
839
+ * Every `<ol>` a cut divides is numbered from the first item its piece keeps: the piece's copy carries a `start` attribute of that item's ordinal, so the browser's own marker counts on from the source's numbering. Without it a continued list numbered from 1 again on every page, and the first piece of a `reversed` list counted down from the items it kept (measured 2026-11-07 against Chromium's print, `test/browser/list-continuation.browser.ts`). Read from the source, so it holds whichever cut ran first.
840
+ */
841
+ function numberDividedLists(root, cut, source, cloneOf) {
842
+ if (!divides(cut) || !source || !cloneOf)
843
+ return;
844
+ const target = elAtIn(source, cut.path);
845
+ for (let a = target?.parentElement ?? null; a; a = a === source ? null : a.parentElement) {
846
+ if (!/^OL$/i.test(a.tagName))
847
+ continue;
848
+ const copy = cloneOf.get(a);
849
+ if (!copy)
850
+ continue;
851
+ for (const [li, n] of listOrdinals(a)) {
852
+ const c = cloneOf.get(li);
853
+ if (!c || !root.contains(c) || hasWord(c, AWAY))
854
+ continue;
855
+ copy.setAttribute('start', String(n));
856
+ break;
857
+ }
858
+ }
859
+ }
795
860
  /** The shell branch of the start cut: the box the path names continues at exactly the extent it has left, blank or painted. */
796
861
  function shellAt(clone, target, px, keep = new Set(), hollow = false) {
797
862
  const painted = px.paint != null;
@@ -1004,6 +1069,7 @@ export function mutateToFirstFragment(clone, { cut = null, extPx = 0, source = n
1004
1069
  /** And the cut line stays justified. css-text-3 §7.2 says a line before a forced or fragmentation break is not the last line of its block, so `text-align: justify` binds it — but in a clone cut AT the break it is the last line and the browser would set it ragged. The alignment is restated rather than inherited, and it is read from the source through the word space's map, because a detached clone has no computed style to ask. The Compositor makes the same move for the same reason (`compose-dom.js`), which is what makes this the engine's one answer written twice for two different line providers rather than two answers. */
1005
1070
  if (sourceStyle(inner)?.textAlign === 'justify')
1006
1071
  inner.style.textAlignLast = 'justify';
1072
+ numberDividedLists(clone, cut, source, cloneOf);
1007
1073
  return clone;
1008
1074
  }
1009
1075
  /**
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * **A missing target shows nothing**: no declaration is delivered for it, the fallback `""` stands, the pseudo-element still has its box (the leader the author drew still draws), and the console names the link once per paginate.
9
9
  *
10
- * **The loop is a consumer's too, over a chain of its own** (*the engine knows no view*, `.claude/decisions/project/the-engine-knows-no-view.md`, ruling 3). `settleReads`'s `run` and `blocksOf` are the door: a view that holds its own pages settles through this loop, with a `run` that refills the dirty entries of its chain, and never through a copy of it. What the loop assumes of its host, read in its body and its helpers: the sites are found in `doc`'s sheets and matched among `originals` (the whole document where none are given), and a site or a target counts only where it stands inside `blocksOf()`'s blocks, since the position index places it; so `originals` are the boxes that hold the blocks, never a presented copy of them. A target is `doc.getElementById`'s answer, the first element in tree order, which holds where the source boxes precede every copy that keeps the id, as `<beltype-root>` keeps its originals before its copies, or where the copies carry no id. The blocks are any elements in document order: nothing asks that they share a parent or be `body`'s children, since the position index, the counter walk and `pathOf` each walk one block on its own. The counter walk's ancestors are `html` and `body`, the two the driver's own walk reads, so another ancestor of the blocks must carry no counter operation. A changed site is measured on a clone of its block in the measure container, at its page's content width, the ruler the run measures with. The run's result carries an `account` whose entries have the driver's shape (`pos`, `cut`, `carried`, `blank`, `trace.forced`, and `closes` with `pageNumber`, `contentW`, `contentH` and `strings`), every address in `blocksOf()`'s numbering, and the `index` it was handed; and, where its pages publish values of their own, `laidFacts`, the page values each page was laid out under (a flow site reads them), which the loop compares the truth with in place of the table it handed the run (a run that returns none was laid out under the table it was handed, as the driver's is; see CHANGELOG 2026-10-28); a layout sent round again is handed `resume.at`, the addresses of the moved boxes, which such a `run` maps to the entries they dirty. The values reach every element in `doc`'s own tree through one adopted sheet, and the page facts are the page hosts' (`chase.js`). One assumption was the root's alone and is a parameter: the sheet that hides a reservation scopes its rules to `beltype-root`, and `hide` names another scope.
10
+ * **The loop is a consumer's too, over a chain of its own** (*the engine knows no view*, `.claude/decisions/project/the-engine-knows-no-view.md`, ruling 3). `settleReads`'s `run` and `blocksOf` are the door: a view that holds its own pages settles through this loop, with a `run` that refills the dirty entries of its chain, and never through a copy of it. What the loop assumes of its host, read in its body and its helpers: the sites are found in `doc`'s sheets and matched among `originals` (the whole document where none are given), and a site or a target counts only where it stands inside `blocksOf()`'s blocks, since the position index places it; so `originals` are the boxes that hold the blocks, never a presented copy of them. A target is the first element in tree order carrying the id among `originals` (`doc.getElementById`'s answer where none are given), which holds where the source boxes precede every copy that keeps the id, as `<beltype-root>` keeps its originals before its copies, or where the copies carry no id; a host's own element that carries an author's id is outside the originals and is never a target, and a `target-text()` target outside `blocksOf()`'s blocks shows nothing, as a counter or page target there does. The blocks are any elements in document order: nothing asks that they share a parent or be `body`'s children, since the position index, the counter walk and `pathOf` each walk one block on its own. The counter walk's ancestors are `html` and `body`, the two the driver's own walk reads, so another ancestor of the blocks must carry no counter operation. A changed site is measured on a clone of its block in the measure container, at its page's content width, the ruler the run measures with. The run's result carries an `account` whose entries have the driver's shape (`pos`, `cut`, `carried`, `blank`, `trace.forced`, and `closes` with `pageNumber`, `contentW`, `contentH` and `strings`), every address in `blocksOf()`'s numbering, and the `index` it was handed; and, where its pages publish values of their own, `laidFacts`, the page values each page was laid out under (a flow site reads them), which the loop compares the truth with in place of the table it handed the run (a run that returns none was laid out under the table it was handed, as the driver's is; see CHANGELOG 2026-10-28); a layout sent round again is handed `resume.at`, the addresses of the moved boxes, which such a `run` maps to the entries they dirty. The values reach every element in `doc`'s own tree through one adopted sheet, and the page facts are the page hosts' (`chase.js`). One assumption was the root's alone and is a parameter: the sheet that hides a reservation scopes its rules to `beltype-root`, and `hide` names another scope.
11
11
  *
12
12
  * @packageDocumentation
13
13
  */
@@ -224,7 +224,7 @@ export declare function linesOfSites<S extends {
224
224
  *
225
225
  * **A layout sent round again resumes from the one before it** (*delivery resumes from the moved page*, 2026-10-21). A delivered value that moves a line is an edit at its site's page, so `run` is handed `resume`, the layout just checked and the addresses of the boxes whose lines moved, and the driver restarts one page before the first of them, re-cuts, and stops where it converges past the last, exactly as it resumes from a host's edit. The result's `resumed` and `why` stay the call's: two resumes compose into one from the prior the host handed (`resume.js`' `composeResumed`), a whole layout anywhere in the loop makes `resumed` null, and each resumed layout leaves a `cicero:reads-run` measure naming the pages it cut again.
226
226
  *
227
- * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22).
227
+ * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
228
228
  */
229
229
  export declare function settleReads<R extends {
230
230
  account: any[];
@@ -232,6 +232,7 @@ export declare function settleReads<R extends {
232
232
  resumed?: Resumed | null;
233
233
  why?: string | null;
234
234
  laidFacts?: PagePublish[] | null;
235
+ complete?: boolean;
235
236
  }>(doc: Document, { run, blocksOf, originals, warm, warmFacts, hide, keep }: {
236
237
  run: (laid: Laid) => Promise<R>;
237
238
  blocksOf: () => Element[];
package/dist/src/reads.js CHANGED
@@ -7,12 +7,12 @@
7
7
  *
8
8
  * **A missing target shows nothing**: no declaration is delivered for it, the fallback `""` stands, the pseudo-element still has its box (the leader the author drew still draws), and the console names the link once per paginate.
9
9
  *
10
- * **The loop is a consumer's too, over a chain of its own** (*the engine knows no view*, `.claude/decisions/project/the-engine-knows-no-view.md`, ruling 3). `settleReads`'s `run` and `blocksOf` are the door: a view that holds its own pages settles through this loop, with a `run` that refills the dirty entries of its chain, and never through a copy of it. What the loop assumes of its host, read in its body and its helpers: the sites are found in `doc`'s sheets and matched among `originals` (the whole document where none are given), and a site or a target counts only where it stands inside `blocksOf()`'s blocks, since the position index places it; so `originals` are the boxes that hold the blocks, never a presented copy of them. A target is `doc.getElementById`'s answer, the first element in tree order, which holds where the source boxes precede every copy that keeps the id, as `<beltype-root>` keeps its originals before its copies, or where the copies carry no id. The blocks are any elements in document order: nothing asks that they share a parent or be `body`'s children, since the position index, the counter walk and `pathOf` each walk one block on its own. The counter walk's ancestors are `html` and `body`, the two the driver's own walk reads, so another ancestor of the blocks must carry no counter operation. A changed site is measured on a clone of its block in the measure container, at its page's content width, the ruler the run measures with. The run's result carries an `account` whose entries have the driver's shape (`pos`, `cut`, `carried`, `blank`, `trace.forced`, and `closes` with `pageNumber`, `contentW`, `contentH` and `strings`), every address in `blocksOf()`'s numbering, and the `index` it was handed; and, where its pages publish values of their own, `laidFacts`, the page values each page was laid out under (a flow site reads them), which the loop compares the truth with in place of the table it handed the run (a run that returns none was laid out under the table it was handed, as the driver's is; see CHANGELOG 2026-10-28); a layout sent round again is handed `resume.at`, the addresses of the moved boxes, which such a `run` maps to the entries they dirty. The values reach every element in `doc`'s own tree through one adopted sheet, and the page facts are the page hosts' (`chase.js`). One assumption was the root's alone and is a parameter: the sheet that hides a reservation scopes its rules to `beltype-root`, and `hide` names another scope.
10
+ * **The loop is a consumer's too, over a chain of its own** (*the engine knows no view*, `.claude/decisions/project/the-engine-knows-no-view.md`, ruling 3). `settleReads`'s `run` and `blocksOf` are the door: a view that holds its own pages settles through this loop, with a `run` that refills the dirty entries of its chain, and never through a copy of it. What the loop assumes of its host, read in its body and its helpers: the sites are found in `doc`'s sheets and matched among `originals` (the whole document where none are given), and a site or a target counts only where it stands inside `blocksOf()`'s blocks, since the position index places it; so `originals` are the boxes that hold the blocks, never a presented copy of them. A target is the first element in tree order carrying the id among `originals` (`doc.getElementById`'s answer where none are given), which holds where the source boxes precede every copy that keeps the id, as `<beltype-root>` keeps its originals before its copies, or where the copies carry no id; a host's own element that carries an author's id is outside the originals and is never a target, and a `target-text()` target outside `blocksOf()`'s blocks shows nothing, as a counter or page target there does. The blocks are any elements in document order: nothing asks that they share a parent or be `body`'s children, since the position index, the counter walk and `pathOf` each walk one block on its own. The counter walk's ancestors are `html` and `body`, the two the driver's own walk reads, so another ancestor of the blocks must carry no counter operation. A changed site is measured on a clone of its block in the measure container, at its page's content width, the ruler the run measures with. The run's result carries an `account` whose entries have the driver's shape (`pos`, `cut`, `carried`, `blank`, `trace.forced`, and `closes` with `pageNumber`, `contentW`, `contentH` and `strings`), every address in `blocksOf()`'s numbering, and the `index` it was handed; and, where its pages publish values of their own, `laidFacts`, the page values each page was laid out under (a flow site reads them), which the loop compares the truth with in place of the table it handed the run (a run that returns none was laid out under the table it was handed, as the driver's is; see CHANGELOG 2026-10-28); a layout sent round again is handed `resume.at`, the addresses of the moved boxes, which such a `run` maps to the entries they dirty. The values reach every element in `doc`'s own tree through one adopted sheet, and the page facts are the page hosts' (`chase.js`). One assumption was the root's alone and is a parameter: the sheet that hides a reservation scopes its rules to `beltype-root`, and `hide` names another scope.
11
11
  *
12
12
  * @packageDocumentation
13
13
  */
14
14
  import { READ_PREFIX, STRING_KEYWORDS, STRING_PROP_PREFIX, decodeRead, encodeStringProp, splitTopLevel } from './gcpm-encoding.js';
15
- import { styleSheetsOf } from './capture.js';
15
+ import { sheetsCountInPseudos, styleSheetsOf } from './capture.js';
16
16
  import { format as formatCounter } from './counter-styles.js';
17
17
  import { collapse, counterStatesAfter, counterValue, counterValues, documentOpsOf } from './document-state.js';
18
18
  import { indexBlocks, pageContaining, pageIntervals, pathOf } from './positions.js';
@@ -285,7 +285,7 @@ export function findSites(doc, originals = null, keep = null) {
285
285
  read,
286
286
  link,
287
287
  key: read.attr != null ? (link == null ? null : attrSelector(read.attr, link)) : '',
288
- target: fragment == null ? null : doc.getElementById(fragment),
288
+ target: fragment == null ? null : targetOf(doc, fragment, originals),
289
289
  pseudos
290
290
  });
291
291
  }
@@ -293,6 +293,19 @@ export function findSites(doc, originals = null, keep = null) {
293
293
  }
294
294
  return sites.length ? sites : null;
295
295
  }
296
+ /** The element a fragment names: `doc.getElementById`'s answer, or, where the host hands its originals, the first element carrying the id among them, so a host's own element that shares an author's id is never a target. */
297
+ function targetOf(doc, fragment, originals) {
298
+ const first = doc.getElementById(fragment);
299
+ if (!originals || !first || originals.some((box) => box.contains(first)))
300
+ return first;
301
+ const selector = `[id="${fragment.replace(/["\\]/g, '\\$&').replace(/\n/g, '\\a ')}"]`;
302
+ for (const box of originals) {
303
+ const el = box.id === fragment ? box : box.querySelector(selector);
304
+ if (el)
305
+ return el;
306
+ }
307
+ return null;
308
+ }
296
309
  /** The engine's one delivery sheet per document, replaced at every delivery and withdrawn when a paginate finds nothing to deliver. */
297
310
  const SHEETS = new WeakMap();
298
311
  /** Deliver a table of values: one constructed sheet, adopted once, its text replaced. */
@@ -377,6 +390,7 @@ export function staticValues(sites, { doc, blocks, index = null, keep = null })
377
390
  const win = doc.defaultView;
378
391
  const ops = documentOpsOf(blocks, index ?? indexBlocks(blocks), {
379
392
  styleOf: (el) => win.getComputedStyle(el),
393
+ pseudoStyleOf: sheetsCountInPseudos(doc) ? (el, pseudo) => win.getComputedStyle(el, pseudo) : null,
380
394
  ancestors: [doc.documentElement, doc.body].filter(Boolean),
381
395
  marks: new Set(counted.map((s) => s.target)),
382
396
  keep
@@ -514,7 +528,7 @@ function boxAddress(site, index, blocks) {
514
528
  *
515
529
  * **A layout sent round again resumes from the one before it** (*delivery resumes from the moved page*, 2026-10-21). A delivered value that moves a line is an edit at its site's page, so `run` is handed `resume`, the layout just checked and the addresses of the boxes whose lines moved, and the driver restarts one page before the first of them, re-cuts, and stops where it converges past the last, exactly as it resumes from a host's edit. The result's `resumed` and `why` stay the call's: two resumes compose into one from the prior the host handed (`resume.js`' `composeResumed`), a whole layout anywhere in the loop makes `resumed` null, and each resumed layout leaves a `cicero:reads-run` measure naming the pages it cut again.
516
530
  *
517
- * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22).
531
+ * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
518
532
  */
519
533
  export async function settleReads(doc, { run, blocksOf, originals = null, warm = null, warmFacts = null, hide = false, keep = null }) {
520
534
  let t = performance.now(); // the site search is the reads' own cost and is inside their measure (*a resumed run keeps its reads*, 2026-10-22)
@@ -529,6 +543,10 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
529
543
  const name = (s) => ('read' in s ? `<${s.el.tagName.toLowerCase()}${s.link == null ? '' : ` ${s.read.attr ?? 'url'}="${s.link}"`}>` : `<${s.el.tagName.toLowerCase()}${s.el.id ? ` id="${s.el.id}"` : ''}>`);
530
544
  const named = (list) => [...new Set(list.map(name))].join(', ');
531
545
  const blocks = blocksOf();
546
+ // A text target counts only inside the blocks, as every other target does through the position index and the counter walk: an element outside them (a host's own) is no target, and the reference shows nothing.
547
+ for (const s of sites)
548
+ if (s.target && s.read.fn === 'text' && !blocks.some((b) => b.contains(s.target)))
549
+ s.target = null;
532
550
  // One walk of the words, shared with the run (*the run resumes from an entry*, build two: 60 ms of every manual run was this walk, done twice). The counter log is not shared, and the reason is worth a line: it is read here before the run registers the GCPM properties, and an unregistered `string-set` inherits, so a log taken now would assign every heading's string to its descendants too.
533
551
  const index = indexBlocks(blocks, undefined, undefined, keep);
534
552
  const fixed = staticValues(sites, { doc, blocks, index, keep });
@@ -573,6 +591,10 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
573
591
  }
574
592
  runs++;
575
593
  t = performance.now();
594
+ if (result.complete === false) { // a partial chain is not the document: its pages place only the targets cut so far, so the reservations stay hidden and the table it was laid out under stays the warm one
595
+ facts = laidFacts;
596
+ break;
597
+ }
576
598
  showReservations(doc); // the run is complete: what was reserved is delivered true on the next lines, or was true already
577
599
  const truth = pageValues(paged, result);
578
600
  unplaced = truth.unplaced;
@@ -283,6 +283,13 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
283
283
  }
284
284
  return px;
285
285
  };
286
+ /** The rise a cut grants inside a table cell it divides: the cell's own nearest, the box included and the root not (a row item's rise is its row's, `rowPlan`). */
287
+ const liftIn = (box) => {
288
+ for (let p = box; p && p !== root; p = p.parent)
289
+ if (styleOfBox(p).display === 'table-cell')
290
+ return cellLift(w, p);
291
+ return 0;
292
+ };
286
293
  /** A box's bottom as a place in the procession: a row ends where its tallest item does, not where the item that happens to stand last in the source does. */
287
294
  const bottomOf = (box) => {
288
295
  const row = rowOf(box);
@@ -313,17 +320,19 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
313
320
  else if (item.rect.top >= h - eps)
314
321
  plan.push({ item, state: 'below', verdict: null });
315
322
  else {
316
- const [begins, reachesBelow] = contentSpanOf(w, item, h, eps);
323
+ // A cell the height divides sets its content from its own top on this page (`cellLift`), so its content is judged that much lower.
324
+ const lift = cellLift(w, item);
325
+ const [begins, reachesBelow] = contentSpanOf(w, item, h + lift, eps);
317
326
  if (begins === null)
318
327
  plan.push({ item, state: 'divided', verdict: null }); // holds nothing to show: a stretched box, divided by the geometry alone
319
- else if (begins >= h - eps)
328
+ else if (begins >= h + lift - eps)
320
329
  plan.push({ item, state: 'below', verdict: null }); // its content begins at or below the height: the break falls in the gap above it (§4.1's Class C, whatever made the gap — padding, an alignment), and the item resumes whole
321
330
  else if (!reachesBelow)
322
331
  plan.push({ item, state: 'done', verdict: null }); // all it has to show stands above the height; only its box reaches below, stretched by the row
323
332
  else if (monolithic(item))
324
333
  plan.push({ item, state: 'held', verdict: null }); // a monolith the height crosses resumes whole: the stated limit, as a float's (the item is the graphic, and nothing inside it is a place)
325
334
  else {
326
- const v = flowVerdict(w, item, h, eps);
335
+ const v = flowVerdict(w, item, h + lift, eps);
327
336
  if (!v)
328
337
  return null; // content crosses the height and the item offers no place above it: it shows nothing here
329
338
  plan.push({ item, state: 'divided', verdict: v });
@@ -357,7 +366,7 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
357
366
  candidates.push({
358
367
  kind: 'A',
359
368
  cls: 'A',
360
- y: bottomOf(anchor),
369
+ y: bottomOf(anchor) - liftIn(box.parent),
361
370
  docIndex,
362
371
  box,
363
372
  cost: clonedBlockEnd(box.parent), // the box itself goes whole to the next page; what this boundary breaks is its container and up
@@ -430,7 +439,7 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
430
439
  stats.gapMs += performance.now() - tg;
431
440
  if (gap) {
432
441
  const y = Math.min(gap.bottom, limit);
433
- if (y - gap.base > eps) {
442
+ if (y - (gap.base - liftIn(box)) > eps) {
434
443
  candidates.push({
435
444
  kind: 'C',
436
445
  cls: 'C',
@@ -485,11 +494,12 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
485
494
  const orphans = parseInt(st.orphans, 10) || 1;
486
495
  const widows = parseInt(st.widows, 10) || 1;
487
496
  const window_ = classBWindow(T, orphans, widows);
497
+ const lift = liftIn(box);
488
498
  for (let k = 0; k < T - 1; k++) {
489
499
  out.push({
490
500
  kind: 'B',
491
501
  cls: 'B',
492
- y: lines[k].bottom,
502
+ y: lines[k].bottom - lift,
493
503
  docIndex,
494
504
  box,
495
505
  cost: clonedBlockEnd(box),
@@ -550,6 +560,18 @@ function subtreeOf(boxes, root) {
550
560
  }
551
561
  return out;
552
562
  }
563
+ /**
564
+ * How far a table cell's content rises when a cut divides the cell: the distance from its content box's top to its first line or monolith. A cell's `vertical-align` places its content in the cell's whole height; Chromium's print aligns a divided cell's first fragment within that fragment instead (`FinalizeTableCellLayout`: *the fragment will start at the block-start edge* on a resumption, and the free space is the fragment's own), so content the alignment set below the cut stands above it once the cell is divided. The presentation matches it by pinning the divided cell's height to the page's foot. Zero for anything else, and for a cell aligned at its top.
565
+ *
566
+ * @remarks Under collapsed borders the content box is read with the cell's whole border width where half of it is used, so the rise is under by half a border, never over.
567
+ */
568
+ function cellLift(w, cell) {
569
+ const st = w.styleOfBox(cell);
570
+ if (st.display !== 'table-cell')
571
+ return 0;
572
+ const [begins] = contentSpanOf(w, cell, -Infinity, 0);
573
+ return begins === null ? 0 : Math.max(0, begins - (cell.rect.top + (parseFloat(st.borderTopWidth) || 0) + (parseFloat(st.paddingTop) || 0)));
574
+ }
553
575
  /** Where a flow's content stands against a height: where it begins (the top of its first line or monolith, null where it holds none) and whether any of it stands below the height — asked of the leaves that reach below it alone, so a flow of a thousand paragraphs above the foot costs the leaves it needs and not its line stacks (a leaf whose box ends above the height has every line above it too). */
554
576
  function contentSpanOf(w, root, h, eps) {
555
577
  let begins = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cicero",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Cicero, a paged-media engine for the live browser: it cuts a document into pages under CSS Paged Media and presents them as real frames. Beta.",
5
5
  "type": "module",
6
6
  "license": "MIT",