@beltype/cicero 0.1.9 → 0.1.11

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.
Files changed (42) hide show
  1. package/README.md +7 -6
  2. package/dist/seam/client.d.ts +12 -4
  3. package/dist/seam/client.js +57 -21
  4. package/dist/seam/facsimile.d.ts +13 -5
  5. package/dist/seam/facsimile.js +28 -12
  6. package/dist/seam/index.js +1 -1
  7. package/dist/seam/rewrite.js +7 -1
  8. package/dist/seam/rim.d.ts +6 -2
  9. package/dist/seam/rim.js +54 -11
  10. package/dist/src/capture.d.ts +28 -0
  11. package/dist/src/capture.js +121 -45
  12. package/dist/src/compose-dom.d.ts +8 -0
  13. package/dist/src/compose-dom.js +62 -8
  14. package/dist/src/compositor.d.ts +11 -2
  15. package/dist/src/compositor.js +47 -15
  16. package/dist/src/data-beltype.d.ts +11 -1
  17. package/dist/src/data-beltype.js +20 -1
  18. package/dist/src/document-state.d.ts +6 -2
  19. package/dist/src/document-state.js +87 -11
  20. package/dist/src/driver.d.ts +6 -0
  21. package/dist/src/driver.js +36 -46
  22. package/dist/src/forcing-memo.d.ts +10 -0
  23. package/dist/src/forcing-memo.js +22 -0
  24. package/dist/src/fragmentation.d.ts +3 -3
  25. package/dist/src/fragmentation.js +3 -3
  26. package/dist/src/fragments.d.ts +1 -1
  27. package/dist/src/fragments.js +47 -16
  28. package/dist/src/gauge.d.ts +0 -4
  29. package/dist/src/gauge.js +16 -30
  30. package/dist/src/gcpm-encoding.d.ts +2 -0
  31. package/dist/src/gcpm-encoding.js +2 -0
  32. package/dist/src/measure.d.ts +17 -12
  33. package/dist/src/measure.js +30 -16
  34. package/dist/src/positions.js +21 -9
  35. package/dist/src/reads.d.ts +4 -2
  36. package/dist/src/reads.js +10 -4
  37. package/dist/src/root.d.ts +18 -1
  38. package/dist/src/root.js +29 -3
  39. package/dist/src/stonehand.js +2 -6
  40. package/dist/src/word-space.d.ts +10 -8
  41. package/dist/src/word-space.js +44 -16
  42. package/package.json +5 -1
package/dist/seam/rim.js CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * THE RIM — the system's edge, where a fact the engine measured before it was true announces that it is (*late truth is an edit*, 2026-10-13; `.claude/decisions/cicero/late-truth-is-an-edit.md`, Decisions three and four; *a box announces its own size*, 2026-10-21; `.claude/decisions/cicero/a-box-announces-its-own-size.md`).
3
3
  *
4
- * It listens on the top-level boxes the intake took, and on nothing else: an `<img>` inside one (`load` and `error`, caught in the capture phase because they do not bubble — a picture still pending and a picture whose `src` changes later are the same event), a custom element whose name is not yet defined (`customElements.whenDefined`, then one animation frame for its `connectedCallback` to have run), and `document.fonts` (`loadingdone`, a fact about the conditions and so about no box).
4
+ * It listens on the top-level boxes the intake took, and on nothing else: an `<img>` inside one (`load` and `error`, caught in the capture phase because they do not bubble: a picture still pending at intake, which the first run no longer waits for, and a picture whose `src` changes later are the same event), a custom element whose name is not yet defined (`customElements.whenDefined`, then one animation frame for its `connectedCallback` to have run), and `document.fonts` (`loadingdone`, a fact about the conditions and so about no box).
5
5
  *
6
6
  * **And the one cause that needs no name: a box's own size.** One `ResizeObserver` over the same boxes reports each one's border box; the report is read only while the box is drawn as itself on a page (the root's `isPresented`, and a reading above zero, since a parked box and a box on a page the browser skips read zero), and it is news only where it stands more than a quarter pixel from the height the last run measured for that box (the driver's table, which the client hands in as `measured`). The ear never measures: the observation is a trigger, and the resume measures. It is never paused; what it hears while a run is called or under way is judged when the runs have ended, against the table they leave, which is what keeps the engine's own presentations silent with no flag on its writes.
7
7
  *
8
8
  * **And the pieces a built page draws a block through** (*a fragment announces its own size*, 2026-10-22; `.claude/decisions/cicero/a-fragment-announces-its-own-size.md`). A divided block's original is parked, and so is a composed block's and one holding a nested fixed box, so the root names to the rim, through the client, every piece it slots into a built page's flow and every node a presentation takes out (`pieces`), and the same observer watches each from the frame it is built in. **A piece's expected height is its first reading above zero**, the presentation's own; a later reading more than the quarter pixel from it, judged under the same gate as an original's, is a `size` signal naming the piece's source box, the page the piece stands on beside it. The engine's own rebuild of a page makes new pieces with new first readings, so its writes pass with no flag. **Its limits**: a change of the original's tree after the copy was taken reaches no piece and is heard by nothing; a box on a page far from the reader is heard when the page comes near (the hearing probe, P40e), and a piece on a page never yet near has read nothing, so a change made before then, like one landing in the presentation's own frame, is its first reading and passes; and a reflow that keeps a box's height and moves its cut passes unheard, since a cut is a word address and the gate a height.
9
9
  *
10
+ * **A picture is news only where its arrival changed its box** (amended 2026-11-08, on Egildo's ruling of that day). A `load` or an `error` is judged when the runs have ended, as a size is: where the box is drawn as itself and the observer's last reading of it stands within the quarter pixel of the height the last run measured, the picture moved nothing and is no signal, which is what an image that declares its `width` and `height` gives; where the observer has read the box as nothing (a page the browser skips), the box's own `offsetHeight` is read instead. A box not drawn as itself while some page is not built is judged again when the observer next reports, since it may stand on that page. Where the reading differs, or the box is not drawn as itself once every page is built (parked, drawn through pieces) or was never measured whole, the rim cannot compare, and the picture is an `image` signal as before. A reading taken before the arrival was laid out passes, and the observer's own `size` hears the box a frame later.
11
+ *
10
12
  * **Every signal is one cancelable `beltype:change` on the document**, `detail: { kind, boxes }` with `kind` one of `image`, `element`, `font`, `size` and `boxes` the top-level boxes in document order (empty for a font); a `size` adds `pages`, beside each box the page it was heard on through a piece, or `null` where it was heard as itself. The same name is the one the live-values store dispatches for its writes (`kind: 'store'`, stage 2): the engine learns one name for every late truth and the host cancels one event to own the pace. A host that calls `preventDefault()` has taken it; otherwise `onSignal` runs with the same detail, and the client answers with the resumable run. **The default action is the engine's for an event the rim did not dispatch as well** (the amendment of 2026-10-13): the store's write (`kind: 'store'`) is dispatched by the host's module, which writes its fields synchronously when the dispatch returns un-cancelled, so the rim waits one animation frame and then, if nobody took the pace, hands `{ kind, boxes }` to the same `onSignal`.
11
13
  *
12
14
  * This module knows no run: it hears, tells, and hands the default action back. The `originals` array is the client's own and is read live — `patch` replaces an entry in it — so a box that left it is never named.
@@ -71,10 +73,11 @@ export function bringsNewFace(event) {
71
73
  * @param p.pageHolding - the page a piece is slotted into (`root.js`' `pageHolding`), asked when a piece is judged changed.
72
74
  * @param p.measured - the box's border-box height as the last run measured it, or undefined where it did not.
73
75
  * @param p.idle - resolves when no run is under way and nothing the rim told is still being answered: a size is judged then, against the table the run leaves.
76
+ * @param p.unbuilt - whether some page of the presentation is not built yet: a picture's box that is not drawn as itself may stand on it, and is judged again when the observer next reports. Absent, every page is built.
74
77
  * @param p.busy - whether a run is under way or a signal is being answered now: asked again just before a size is judged, since a named signal may have come while the ear waited for the frame.
75
78
  * @returns `add` subscribes to a box, releasing what the box it replaced (a patched edit) and the box itself already held, so one load is one signal; `pieces` observes the pieces a page was just built with, each beside its source box, and lets go of the ones a presentation took out; `stop` releases everything.
76
79
  */
77
- export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, onSignal, presented = () => false, pageHolding = () => null, measured = () => undefined, idle = async () => { }, busy = () => false }) {
80
+ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, onSignal, presented = () => false, pageHolding = () => null, measured = () => undefined, idle = async () => { }, busy = () => false, unbuilt = () => false }) {
78
81
  const win = doc.defaultView;
79
82
  let stopped = false;
80
83
  /** What each box's listeners hand back. */
@@ -86,6 +89,26 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
86
89
  const heard = new Map();
87
90
  /** The pieces a built page draws its blocks through (*a fragment announces its own size*, 2026-10-22): each one's source box and its expected height, which is the first reading above zero the observer reports for it — the presentation's own, since the feed has no number for a constructed first fragment or a two-cut fragment and a second arithmetic for the others is the fork the account ruling forbids. Zero until that reading; an entry leaves with its piece. */
88
91
  const pieces = new Map();
92
+ /** Each original's last border-box height as the observer reported it, zero included: what a picture's arrival is judged by. */
93
+ const latest = new WeakMap();
94
+ /** The boxes a picture loaded or failed in since the last judgment. */
95
+ const loaded = new Set();
96
+ /**
97
+ * What a picture's arrival in a box is: `same` where the box is drawn as itself at the height the last run measured for it, `later` where it is not drawn as itself while some page is not built (it may stand on that page, and is judged again when the observer next reports), and `moved` otherwise, which is also every box the rim cannot compare.
98
+ *
99
+ * @remarks The height is the observer's last reading, within {@link TOLERANCE}. Where the observer has read nothing above zero (a box on a page the browser skips reads zero, and a box parked when it was observed is never reported until it is laid out), the box is asked its `offsetHeight`, which lays out that one box and rounds to the whole pixel, so the gate there is half a pixel wider.
100
+ */
101
+ const verdict = (box) => {
102
+ if (!presented(box))
103
+ return unbuilt() ? 'later' : 'moved';
104
+ const was = measured(box);
105
+ if (was === undefined)
106
+ return 'moved';
107
+ const now = latest.get(box);
108
+ if (now)
109
+ return Math.abs(now - was) <= TOLERANCE ? 'same' : 'moved';
110
+ return box instanceof win.HTMLElement && Math.abs(box.offsetHeight - was) <= 0.5 + TOLERANCE ? 'same' : 'moved';
111
+ };
89
112
  let judging = false;
90
113
  const observer = new win.ResizeObserver((entries) => {
91
114
  const t = performance.now();
@@ -102,6 +125,7 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
102
125
  piece.expected = size;
103
126
  continue;
104
127
  }
128
+ latest.set(e.target, size);
105
129
  // Zero is what a box no page renders reads: a parked one, and one slotted into a page whose content the browser skips (`content-visibility: auto` on a page not yet near the reader — measured 2026-10-21: a page shown by a run stopped at it reads zero for one frame). It is not news; a box that comes into view reads again.
106
130
  if (size > 0 && presented(e.target))
107
131
  heard.set(e.target, size);
@@ -110,7 +134,11 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
110
134
  }
111
135
  // The run keeps time (*the run keeps time*, 2026-09-25), and so does the ear: inert unless a host reads the measures.
112
136
  performance.measure('cicero:size-heard', { start: t, end: performance.now(), detail: { entries: entries.length, kept: heard.size } });
113
- if (judging || heard.size === 0)
137
+ judge();
138
+ });
139
+ /** Judge what was heard, sizes and pictures, once the runs have ended. */
140
+ function judge() {
141
+ if (judging || (heard.size === 0 && loaded.size === 0))
114
142
  return;
115
143
  judging = true;
116
144
  // A frame to gather the batch; the runs' end; then the frame after it and the task after that frame, so the observer has reported what the last presentation left before anything is judged — and again while a run or an answer began in the meantime (the battery's parallel suite once heard a size beside an element and a face that the named signals were about to answer, 2026-10-22).
@@ -145,11 +173,24 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
145
173
  moved.set(el, null);
146
174
  }
147
175
  heard.clear();
148
- performance.measure('cicero:size-judged', { start: t, end: performance.now(), detail: { heard: n, moved: moved.size } });
176
+ const pictures = loaded.size;
177
+ const late = [];
178
+ for (const box of loaded) {
179
+ const v = verdict(box);
180
+ if (v !== 'later')
181
+ loaded.delete(box);
182
+ if (v === 'moved')
183
+ late.push(box);
184
+ }
185
+ performance.measure('cicero:size-judged', { start: t, end: performance.now(), detail: { heard: n, moved: moved.size, pictures, late: late.length, later: loaded.size } });
186
+ for (const box of late)
187
+ moved.delete(box);
188
+ if (late.length)
189
+ signal('image', late);
149
190
  if (moved.size)
150
191
  signal('size', moved.keys(), moved);
151
192
  });
152
- });
193
+ }
153
194
  /**
154
195
  * @param [pageOf] - a size's: the page each box was heard on through a piece, or null where it was heard as itself.
155
196
  */
@@ -176,12 +217,14 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
176
217
  held.delete(gone);
177
218
  }
178
219
  observer.observe(box, { box: 'border-box' });
179
- // `load` and `error` do not bubble, so one capturing listener per box hears every picture inside it — the ones pending now and the ones whose `src` changes later, which a listener on the pending alone would miss. The intake awaited the pictures it found, so an event here is a late one.
180
- const loaded = (e) => { if (e.target.localName === 'img')
181
- signal('image', [box]); };
182
- box.addEventListener('load', loaded, true);
183
- box.addEventListener('error', loaded, true);
184
- held.set(box, () => { box.removeEventListener('load', loaded, true); box.removeEventListener('error', loaded, true); observer.unobserve(box); });
220
+ // `load` and `error` do not bubble, so one capturing listener per box hears every picture inside it: the ones pending now, the intake waiting for none since 2026-11-08, and the ones whose `src` changes later, which a listener on the pending alone would miss. Heard, and judged with the sizes: only an arrival that changed the box is a signal.
221
+ const arrived = (e) => { if (!stopped && e.target.localName === 'img') {
222
+ loaded.add(box);
223
+ judge();
224
+ } };
225
+ box.addEventListener('load', arrived, true);
226
+ box.addEventListener('error', arrived, true);
227
+ held.set(box, () => { box.removeEventListener('load', arrived, true); box.removeEventListener('error', arrived, true); observer.unobserve(box); loaded.delete(box); });
185
228
  for (const el of [box, ...box.querySelectorAll('*')]) {
186
229
  const name = el.localName;
187
230
  if (!name.includes('-') || win.customElements.get(name))
@@ -108,12 +108,36 @@ export declare function sheetsUseHas(doc: Document): boolean;
108
108
  export declare function sheetsCountInPseudos(doc: Document): boolean;
109
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. */
110
110
  export declare function hasArgumentsOf(doc: Document): Map<string, string>;
111
+ /**
112
+ * The properties that paint and move no box, the one list the sheets' reach reads a rule's declarations against (ruled 2026-11-08): a style rule whose every declaration sets one of these is passed over by {@link sheetReach} and {@link topLevelReach} whatever its selector, because the reach asks whether an edit can move another box's facts, and a colour, a shadow or an opacity moves none.
113
+ *
114
+ * @remarks
115
+ * Closed and short on purpose. The CSSOM lists a rule's declarations as longhands, so `background` arrives as its `background-*` longhands and `border-color` as the four sides', and both families are taken by their prefix and suffix besides the names here. Anything else counts as layout, custom properties (`--x`) included, since one may feed a layout value through `var()`. Four were weighed and left out: `visibility`, whose `collapse` removes a table row or column from the layout; and `filter`, `backdrop-filter` and `will-change`, which make a containing block for a fixed or absolutely positioned descendant and so move it.
116
+ */
117
+ export declare const PAINT_ONLY: ReadonlySet<string>;
118
+ /** The rule that set a reach and the sheet it stands in, which the memo's console notice names (ruled 2026-11-08): `selector` is a style rule's `selectorText`, `null` for an `@container` or `@scope` rule or a sheet the engine cannot read; `sheet` the sheet's `href`, or `<style> n` with its place among the document's sheets, counted from 1. */
119
+ export type ReachCulprit = {
120
+ selector: string | null;
121
+ sheet: string;
122
+ kind: 'style' | 'container' | 'scope' | 'unreadable';
123
+ };
124
+ /** A reach with the first rule that set it and how many more rules reached as far, {@link sheetReachOf}'s and {@link topLevelReachOf}'s answer. */
125
+ export type Reach = {
126
+ reach: 0 | 1 | 2;
127
+ culprit: ReachCulprit | null;
128
+ count: number;
129
+ };
111
130
  /**
112
131
  * How far a box's attributes can reach through the sheets the document wears, read off the selectors (*the forced-break index is kept across runs*, 2026-10-09) — what the forcing index's memo needs to know to say which of its answers an edit made stale, when the unit of replacement is `patchBox`'s, a box at any depth ({@link topLevelReach} reads the same sheets for a whole top-level box).
113
132
  *
114
133
  * 0: nothing but the box and what is below it; a descendant combinator or a child combinator reads a box's ancestors, which an edit does not change. 1: a sibling — `+`, `~` or `:nth-child(… of …)`, by which a box's attributes decide the style of the boxes after it (or beside it) and what they hold. 2: an ancestor or anything at all — `:has(`, an `@container` rule (a size the edit moves), or a sheet the engine cannot read. Blink's invalidation sets draw the same line and take the same fallback: where a selector defeats the reasoning, widen. The reading is textual and errs only toward more reach — a `+` in a selector's attribute value is read as a combinator — which costs speed and never a stale answer.
134
+ *
135
+ * @remarks
136
+ * A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, and an `@container` rule every rule under which paints answers nothing of itself; its nested rules are read as rules (ruled 2026-11-08).
115
137
  */
116
138
  export declare function sheetReach(doc: Document): 0 | 1 | 2;
139
+ /** {@link sheetReach} with the first rule that set the reach and how many more reached as far, for the memo's console notice. */
140
+ export declare function sheetReachOf(doc: Document): Reach;
117
141
  /**
118
142
  * How far an edit's effect can travel through the sheets when the unit of replacement is a whole top-level box (*the forced-break index is kept across runs*, amended 2026-10-25): what {@link ForcingMemo.settle}, told these boxes, needs to say which top-level boxes' kept parts an edit made stale. Named for its unit, as `sheetReach` is the reading for `patchBox`'s, a box replaced at any depth.
119
143
  *
@@ -124,12 +148,16 @@ export declare function sheetReach(doc: Document): 0 | 1 | 2;
124
148
  *
125
149
  * A nested rule is read as it matches: its `&` is its parent's selector in `:is()`, as CSS Nesting defines it. Conservative by construction, and these are the cases it does not read, each answered 2: a `:is()`, `:where()`, `:not()` or other wrapper whose argument holds a combinator and also a sibling combinator, a `:has(` or a structural pseudo-class, which takes in a nested rule whose parent's selector holds them (a wrapper with a bare `*` among its arguments constrains nothing and is passed over, which is how the engine's own `:has()` union reads); a `&` in a rule nested in no style rule; a selector whose quotes or brackets do not close; a relative selector at the top of a rule. The pseudo-classes `sheetReach` passes over (`:target`, `:checked`, `:dir()`) are passed over here too.
126
150
  *
151
+ * A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, so a paint-only `p + p` or `body:has(.x) p` reaches nothing, and an `@container` or `@scope` rule every rule under which paints answers nothing of itself; the walk reads its nested rules as rules (ruled 2026-11-08).
152
+ *
127
153
  * The unit's promise, which the host keeps: a top-level box whose name or attributes change is a new element, and the element it replaced is among `before`.
128
154
  *
129
155
  * @param boxes - the top-level boxes as they stand: the element children of their one parent, in order.
130
156
  * @param before - the top-level boxes the memo was last settled with (`ForcingMemo`'s `boxes`), empty where it keeps nothing.
131
157
  */
132
158
  export declare function topLevelReach(doc: Document, boxes: ArrayLike<Element>, before?: ArrayLike<Element>): 0 | 1 | 2;
159
+ /** {@link topLevelReach} with the first rule that set the reach, its sheet, and how many more rules reached as far, for the memo's console notice (ruled 2026-11-08). The number alone stays `topLevelReach`'s answer, which a view adapter imports. */
160
+ export declare function topLevelReachOf(doc: Document, boxes: ArrayLike<Element>, before?: ArrayLike<Element>): Reach;
133
161
  /** What {@link topLevelReach} asks of the document while it reads a selector. */
134
162
  export type ReachContext = {
135
163
  /** Whether the selector up to a compound can match a top-level box, or the compound alone a box an edit took out. */
@@ -80,25 +80,106 @@ function someRule(doc, test) {
80
80
  };
81
81
  return styleSheetsOf(doc).some(inSheet);
82
82
  }
83
+ /**
84
+ * The properties that paint and move no box, the one list the sheets' reach reads a rule's declarations against (ruled 2026-11-08): a style rule whose every declaration sets one of these is passed over by {@link sheetReach} and {@link topLevelReach} whatever its selector, because the reach asks whether an edit can move another box's facts, and a colour, a shadow or an opacity moves none.
85
+ *
86
+ * @remarks
87
+ * Closed and short on purpose. The CSSOM lists a rule's declarations as longhands, so `background` arrives as its `background-*` longhands and `border-color` as the four sides', and both families are taken by their prefix and suffix besides the names here. Anything else counts as layout, custom properties (`--x`) included, since one may feed a layout value through `var()`. Four were weighed and left out: `visibility`, whose `collapse` removes a table row or column from the layout; and `filter`, `backdrop-filter` and `will-change`, which make a containing block for a fixed or absolutely positioned descendant and so move it.
88
+ */
89
+ export const PAINT_ONLY = new Set(['color', 'background', 'border-color', 'outline-color', 'text-decoration-color', 'text-emphasis-color', 'caret-color', 'accent-color', 'column-rule-color', 'opacity', 'mix-blend-mode', 'box-shadow', 'text-shadow', 'cursor', 'pointer-events', 'user-select', 'forced-color-adjust', 'color-scheme', 'print-color-adjust']);
90
+ const paints = (name) => PAINT_ONLY.has(name) || name.startsWith('background-') || (name.startsWith('border-') && name.endsWith('-color'));
91
+ /** Whether a rule's own declarations, read through `rule.style`, all paint: a rule with none does too. A rule with no `style` is not a style rule and answers no. */
92
+ function paintOnly(rule) {
93
+ const style = rule.style;
94
+ if (!style || typeof style.length !== 'number')
95
+ return false;
96
+ for (let i = 0; i < style.length; i++)
97
+ if (!paints(style.item(i)))
98
+ return false;
99
+ return true;
100
+ }
101
+ /** Whether every rule under a grouping rule paints, nested rules and grouping rules within it included; a rule that is neither a style rule nor a grouping rule answers no. */
102
+ function allPaint(rules) {
103
+ for (const rule of Array.from((rules ?? []))) {
104
+ if (rule.style ? !paintOnly(rule) : !rule.cssRules)
105
+ return false;
106
+ if (rule.cssRules && !allPaint(rule.cssRules))
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ /** Every rule of every sheet the document wears, with its sheet's name, walked as {@link someRule} walks them but to the end; a sheet the engine cannot read is handed to `unread`. */
112
+ function eachRule(doc, visit, unread) {
113
+ const inRules = (rules, sheet) => {
114
+ for (const rule of rules) {
115
+ visit(rule, sheet);
116
+ if (rule.cssRules)
117
+ inRules(rule.cssRules, sheet);
118
+ if (rule.styleSheet)
119
+ inSheet(rule.styleSheet, sheet);
120
+ }
121
+ };
122
+ const inSheet = (sheet, name) => {
123
+ const own = sheet.href ?? name;
124
+ let rules;
125
+ try {
126
+ rules = sheet.cssRules;
127
+ }
128
+ catch {
129
+ unread(own);
130
+ return;
131
+ }
132
+ inRules(rules, own);
133
+ };
134
+ styleSheetsOf(doc).forEach((sheet, i) => inSheet(sheet, `<style> ${i + 1}`));
135
+ }
136
+ /** A tally of what reached how far: the highest level, the first rule at it, and the rules after it at the same level. */
137
+ function reachTally() {
138
+ const found = { reach: 0, culprit: null, count: 0 };
139
+ const add = (reach, culprit) => {
140
+ if (reach > found.reach)
141
+ Object.assign(found, { reach, culprit, count: 0 });
142
+ else if (reach > 0 && reach === found.reach)
143
+ found.count++;
144
+ };
145
+ return { found, add };
146
+ }
147
+ /** A grouping rule's kind where it is one the reach answers 2 for unless every rule under it paints, else `null`. */
148
+ const queried = (rule) => {
149
+ const kind = rule.constructor?.name;
150
+ return kind === 'CSSContainerRule' ? 'container' : kind === 'CSSScopeRule' ? 'scope' : null;
151
+ };
83
152
  /**
84
153
  * How far a box's attributes can reach through the sheets the document wears, read off the selectors (*the forced-break index is kept across runs*, 2026-10-09) — what the forcing index's memo needs to know to say which of its answers an edit made stale, when the unit of replacement is `patchBox`'s, a box at any depth ({@link topLevelReach} reads the same sheets for a whole top-level box).
85
154
  *
86
155
  * 0: nothing but the box and what is below it; a descendant combinator or a child combinator reads a box's ancestors, which an edit does not change. 1: a sibling — `+`, `~` or `:nth-child(… of …)`, by which a box's attributes decide the style of the boxes after it (or beside it) and what they hold. 2: an ancestor or anything at all — `:has(`, an `@container` rule (a size the edit moves), or a sheet the engine cannot read. Blink's invalidation sets draw the same line and take the same fallback: where a selector defeats the reasoning, widen. The reading is textual and errs only toward more reach — a `+` in a selector's attribute value is read as a combinator — which costs speed and never a stale answer.
156
+ *
157
+ * @remarks
158
+ * A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, and an `@container` rule every rule under which paints answers nothing of itself; its nested rules are read as rules (ruled 2026-11-08).
87
159
  */
88
160
  export function sheetReach(doc) {
89
- if (someRule(doc, (rule) => rule.constructor?.name === 'CSSContainerRule' || !!rule.selectorText?.includes(':has(')))
90
- return 2;
91
- // Parenthesised arguments and attribute selectors out of the way first: `:nth-child(2n+1)` and `[lang~=en]` are not combinators, and `:nth-child(2 of .x)` is the one `nth` form that looks sideways.
92
- if (someRule(doc, (rule) => {
161
+ return sheetReachOf(doc).reach;
162
+ }
163
+ /** {@link sheetReach} with the first rule that set the reach and how many more reached as far, for the memo's console notice. */
164
+ export function sheetReachOf(doc) {
165
+ const { found, add } = reachTally();
166
+ eachRule(doc, (rule, sheet) => {
167
+ if (rule.constructor?.name === 'CSSContainerRule') {
168
+ if (!allPaint(rule.cssRules))
169
+ add(2, { selector: null, sheet, kind: 'container' });
170
+ return;
171
+ }
93
172
  const text = rule.selectorText;
94
- if (!text)
95
- return false;
96
- if (/:nth-[\w-]+\([^)]*\sof\s/.test(text))
97
- return true;
98
- return /[+~]/.test(text.replace(/\[[^\]]*\]/g, '').replace(/:nth-[\w-]+\([^)]*\)/g, ''));
99
- }))
100
- return 1;
101
- return 0;
173
+ if (!text || paintOnly(rule))
174
+ return;
175
+ const culprit = { selector: text, sheet, kind: 'style' };
176
+ if (text.includes(':has('))
177
+ return add(2, culprit);
178
+ // Parenthesised arguments and attribute selectors out of the way first: `:nth-child(2n+1)` and `[lang~=en]` are not combinators, and `:nth-child(2 of .x)` is the one `nth` form that looks sideways.
179
+ if (/:nth-[\w-]+\([^)]*\sof\s/.test(text) || /[+~]/.test(text.replace(/\[[^\]]*\]/g, '').replace(/:nth-[\w-]+\([^)]*\)/g, '')))
180
+ add(1, culprit);
181
+ }, (sheet) => add(2, { selector: null, sheet, kind: 'unreadable' }));
182
+ return found;
102
183
  }
103
184
  /**
104
185
  * How far an edit's effect can travel through the sheets when the unit of replacement is a whole top-level box (*the forced-break index is kept across runs*, amended 2026-10-25): what {@link ForcingMemo.settle}, told these boxes, needs to say which top-level boxes' kept parts an edit made stale. Named for its unit, as `sheetReach` is the reading for `patchBox`'s, a box replaced at any depth.
@@ -110,12 +191,18 @@ export function sheetReach(doc) {
110
191
  *
111
192
  * A nested rule is read as it matches: its `&` is its parent's selector in `:is()`, as CSS Nesting defines it. Conservative by construction, and these are the cases it does not read, each answered 2: a `:is()`, `:where()`, `:not()` or other wrapper whose argument holds a combinator and also a sibling combinator, a `:has(` or a structural pseudo-class, which takes in a nested rule whose parent's selector holds them (a wrapper with a bare `*` among its arguments constrains nothing and is passed over, which is how the engine's own `:has()` union reads); a `&` in a rule nested in no style rule; a selector whose quotes or brackets do not close; a relative selector at the top of a rule. The pseudo-classes `sheetReach` passes over (`:target`, `:checked`, `:dir()`) are passed over here too.
112
193
  *
194
+ * A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, so a paint-only `p + p` or `body:has(.x) p` reaches nothing, and an `@container` or `@scope` rule every rule under which paints answers nothing of itself; the walk reads its nested rules as rules (ruled 2026-11-08).
195
+ *
113
196
  * The unit's promise, which the host keeps: a top-level box whose name or attributes change is a new element, and the element it replaced is among `before`.
114
197
  *
115
198
  * @param boxes - the top-level boxes as they stand: the element children of their one parent, in order.
116
199
  * @param before - the top-level boxes the memo was last settled with (`ForcingMemo`'s `boxes`), empty where it keeps nothing.
117
200
  */
118
201
  export function topLevelReach(doc, boxes, before = []) {
202
+ return topLevelReachOf(doc, boxes, before).reach;
203
+ }
204
+ /** {@link topLevelReach} with the first rule that set the reach, its sheet, and how many more rules reached as far, for the memo's console notice (ruled 2026-11-08). The number alone stays `topLevelReach`'s answer, which a view adapter imports. */
205
+ export function topLevelReachOf(doc, boxes, before = []) {
119
206
  const now = Array.from(boxes);
120
207
  const was = Array.from(before);
121
208
  const here = new Set(now);
@@ -143,22 +230,24 @@ export function topLevelReach(doc, boxes, before = []) {
143
230
  shaped,
144
231
  changed
145
232
  };
146
- let reach = 0;
147
- const unread = someRule(doc, (rule) => {
148
- const kind = rule.constructor?.name;
149
- if (kind === 'CSSContainerRule' || kind === 'CSSScopeRule')
150
- return true;
151
- if (!rule.selectorText || kind === 'CSSPageRule')
152
- return false;
233
+ const { found, add } = reachTally();
234
+ eachRule(doc, (rule, sheet) => {
235
+ const kind = queried(rule);
236
+ if (kind) {
237
+ if (!allPaint(rule.cssRules))
238
+ add(2, { selector: null, sheet, kind });
239
+ return;
240
+ }
241
+ if (!rule.selectorText || rule.constructor?.name === 'CSSPageRule' || paintOnly(rule))
242
+ return;
153
243
  const text = resolvedSelector(rule);
154
244
  if (text != null && !CROSSING.test(text))
155
- return false;
245
+ return;
156
246
  const r = text == null ? 2 : selectorListReach(text, ctx);
157
- if (r > reach)
158
- reach = r;
159
- return reach === 2;
160
- });
161
- return unread ? 2 : reach;
247
+ if (r)
248
+ add(r, { selector: rule.selectorText, sheet, kind: 'style' });
249
+ }, (sheet) => add(2, { selector: null, sheet, kind: 'unreadable' }));
250
+ return found;
162
251
  }
163
252
  /** A style rule's selector as it matches: a rule nested in a style rule (through any grouping rules between them) has each `&` read as the parent's selector in `:is()`, and an item with no `&` the parent's as its ancestor, which is CSS Nesting's own definition, resolved up the chain; `null` for a `&` in a rule nested in no style rule, or text that does not close. */
164
253
  function resolvedSelector(rule) {
@@ -843,29 +932,16 @@ function compareSpecificity(a, b) {
843
932
  return a[i] - b[i];
844
933
  return 0;
845
934
  }
846
- /** Margin-box declarations out of a CSSPageRule — via child CSSMarginRule objects where the engine exposes them, else a cssText walk. Returns { 'bottom-center': rawContentValue, ... }. */
935
+ /** Margin-box declarations out of a CSSPageRule — via the child CSSMarginRule objects (the pinned Chromium has them; `margin-box-padding.browser.ts` asserts it). Returns { 'bottom-center': rawContentValue, ... }. */
847
936
  function marginBoxesOf(rule) {
848
937
  const out = {};
849
- if (rule.cssRules) {
850
- for (const child of rule.cssRules) {
851
- const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
852
- if (MARGIN_BOXES.includes(name)) {
853
- const content = child.style?.getPropertyValue('content');
854
- if (content)
855
- out[name] = content.trim();
856
- }
938
+ for (const child of (rule.cssRules ?? [])) {
939
+ const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
940
+ if (MARGIN_BOXES.includes(name)) {
941
+ const content = child.style?.getPropertyValue('content');
942
+ if (content)
943
+ out[name] = content.trim();
857
944
  }
858
- if (Object.keys(out).length)
859
- return out;
860
- }
861
- const re = /@([a-z-]+)\s*\{([^}]*)\}/g;
862
- let m;
863
- while ((m = re.exec(rule.cssText))) {
864
- if (!MARGIN_BOXES.includes(m[1]))
865
- continue;
866
- const c = /content\s*:\s*([^;}]+)/.exec(m[2]);
867
- if (c)
868
- out[m[1]] = c[1].trim();
869
945
  }
870
946
  return out;
871
947
  }
@@ -52,6 +52,10 @@ export type CompositionRecord = {
52
52
  * It used to ask only the children's display, so a `<pre>`, an SVG's `<text>` and an inline `<code>` were paragraphs. The children's display is asked through `word-space.js`' `isInlineBox`, the procession's one predicate (*the outer display decides*, 2026-09-29): it used to be a second copy of the `inline` prefix test, so a paragraph holding an inline formula, whose `<math>` computes `math`, was refused here while the procession, once fixed, divided it. Every refusal here is silent: a refused block keeps the browser's lines, which are the only lines it has.
53
53
  */
54
54
  export declare function isComposable(el: Element): boolean;
55
+ /**
56
+ * A sixth clause: a block in a multi-column container, or itself one, keeps the browser's lines. The Compositor has one measure per block and reads it from the block's own box, not from a column's, so a `columns: 3` box 210px wide was set at the container's measure, the longest line 356px against a column of 70. Refused like a block beside a float, with a console line (`refusedInColumns`); the real fix is a per-column measure and the fragmentation of a multi-column box, which is not this engine's yet.
57
+ */
58
+ export declare function inColumns(el: Element): boolean;
55
59
  /**
56
60
  * Words and author-break runs of an element, with DOM points. A word is a maximal non-whitespace run of text not crossing a <br>; nested inlines merge (parts). Returns { words: [{ text, parts: [{node, start, end}] }], runs: [{ start, end }] } where runs index into words, end exclusive.
57
61
  *
@@ -74,6 +78,10 @@ export declare function collectWords(el: Element): {
74
78
  export declare function hyphenCharOf(style: {
75
79
  hyphenateCharacter?: string;
76
80
  }): string;
81
+ /**
82
+ * css-text-4 §6.3.5's `hyphenate-limit-lines`, read from the custom property the host rewrote it into (Chromium drops the property itself): `no-limit` is `null`, an integer is the limit, and an absent or unreadable value is the engine's own {@link HYPHENATE_LIMIT_LINES}.
83
+ */
84
+ export declare function limitLinesOf(style: CSSStyleDeclaration): number | null;
77
85
  /** Two border-box rects overlapping in both axes, sub-pixel noise excluded. The geometry half of the beside-a-float refusal below, exported so it is checkable without a browser; what it cannot say is which boxes were handed to it, which is the case runner's job. */
78
86
  export declare function overlaps(a: {
79
87
  top: number;
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * @packageDocumentation
11
11
  */
12
- import { compose } from './compositor.js';
12
+ import { compose, HYPHENATE_LIMIT_LINES } from './compositor.js';
13
+ import { HYPHENATE_LIMIT_LINES_PROP } from './gcpm-encoding.js';
13
14
  import { addWord, AWAY, BREAK, FILL, hasWord } from './data-beltype.js';
14
15
  import { createMeasureContext } from './measure.js';
15
16
  import { indexBlocks, pageIntervals, presentationWidths } from './positions.js';
@@ -47,10 +48,24 @@ export function isComposable(el) {
47
48
  for (let a = el.parentElement; a; a = a.parentElement)
48
49
  if (isAtom(a))
49
50
  return false;
51
+ if (inColumns(el))
52
+ return false;
50
53
  if (holdsGeneratedText(el, win))
51
54
  return false;
52
55
  return collectWords(el).words.some((w) => !w.atom);
53
56
  }
57
+ /**
58
+ * A sixth clause: a block in a multi-column container, or itself one, keeps the browser's lines. The Compositor has one measure per block and reads it from the block's own box, not from a column's, so a `columns: 3` box 210px wide was set at the container's measure, the longest line 356px against a column of 70. Refused like a block beside a float, with a console line (`refusedInColumns`); the real fix is a per-column measure and the fragmentation of a multi-column box, which is not this engine's yet.
59
+ */
60
+ export function inColumns(el) {
61
+ const win = el.ownerDocument.defaultView;
62
+ for (let a = el; a; a = a.parentElement) {
63
+ const cs = win.getComputedStyle(a);
64
+ if (cs.columnCount !== 'auto' || cs.columnWidth !== 'auto')
65
+ return true;
66
+ }
67
+ return false;
68
+ }
54
69
  /**
55
70
  * Does this pseudo-element put a box in its element's lines? Its `content` must generate one — `none` and `normal` do not — and its outer display must be inline: a `display: block` `::after` is a block of its own after the lines, not text in them.
56
71
  *
@@ -176,6 +191,16 @@ export function hyphenCharOf(style) {
176
191
  return raw;
177
192
  return raw.slice(1, -1).replace(/\\(.)/g, '$1');
178
193
  }
194
+ /**
195
+ * css-text-4 §6.3.5's `hyphenate-limit-lines`, read from the custom property the host rewrote it into (Chromium drops the property itself): `no-limit` is `null`, an integer is the limit, and an absent or unreadable value is the engine's own {@link HYPHENATE_LIMIT_LINES}.
196
+ */
197
+ export function limitLinesOf(style) {
198
+ const raw = style.getPropertyValue(HYPHENATE_LIMIT_LINES_PROP).trim();
199
+ if (raw === 'no-limit')
200
+ return null;
201
+ const n = /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
202
+ return Number.isFinite(n) && n >= 1 ? n : HYPHENATE_LIMIT_LINES;
203
+ }
179
204
  /**
180
205
  * css-text-4 §6.3.4's `hyphenate-limit-chars` as three values, `null` for each `auto`.
181
206
  *
@@ -367,16 +392,27 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
367
392
  }
368
393
  // A float's own descendants are laid out in the float's own formatting context at the float's own width, which is exactly what their rect reports — nothing is refused there. What is refused is a block the float reaches into: a sibling it stands beside, or an ancestor whose line boxes it is nested in.
369
394
  const intrudedByFloat = (clone, rect) => floats.some((f) => !f.el.contains(clone) && overlaps(rect, f.rect));
395
+ /** The block's content box: a float wholly in the padding or the border (a hanging number) shortens none of its line boxes, so it is the content box the float's rect is compared with. */
396
+ const contentBoxOf = (cs, rect) => {
397
+ const left = rect.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft);
398
+ const top = rect.top + parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop);
399
+ return { left, top, right: rect.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight), bottom: rect.bottom - parseFloat(cs.borderBottomWidth) - parseFloat(cs.paddingBottom) };
400
+ };
370
401
  const refusedBesideFloat = [];
371
402
  /** A second refusal, of the same kind and for the same reason (*a line inside a word is a break point*, 2026-09-23). The Compositor's break opportunities are spaces, hyphenation points and css-text-4 §6.4's emergency break; a block whose text the browser may break anywhere else — `word-break: break-all`, `line-break: anywhere`, or a script written without spaces (`word-space.js`' `UNSPACED_SCRIPT`) — reaches it as words it cannot divide. Measured on `packages/essay/cases/63-the-line-inside-a-word`: a thirty-line `break-all` paragraph was one word to the DP, set on one nowrap line, and twenty-one of its thirty lines were on no page. So such a block keeps the browser's lines, and the console says so; the real fix is the Compositor learning UAX #14's opportunities, which is a later round. */
372
403
  const refusedUnspaced = [];
404
+ /** A third refusal: a block in a multi-column container (`inColumns`). */
405
+ const refusedInColumns = [];
373
406
  const jobs = [];
374
407
  for (const [clone, source] of cloneToSource) {
375
- if (!isComposable(clone))
408
+ if (!isComposable(clone)) {
409
+ if (inColumns(clone) && PARAGRAPH_DISPLAY.has(win.getComputedStyle(clone).display))
410
+ refusedInColumns.push(source);
376
411
  continue;
412
+ }
377
413
  const cs = win.getComputedStyle(clone);
378
414
  const rect = clone.getBoundingClientRect();
379
- if (floats.length && intrudedByFloat(clone, rect)) {
415
+ if (floats.length && intrudedByFloat(clone, contentBoxOf(cs, rect))) {
380
416
  refusedBesideFloat.push(source);
381
417
  continue;
382
418
  }
@@ -393,12 +429,13 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
393
429
  const indent = parseFloat(sourceStyle.textIndent) || 0;
394
430
  const hyphenChar = hyphenCharOf(sourceStyle);
395
431
  const limits = limitCharsOf(sourceStyle);
432
+ const limitLines = limitLinesOf(sourceStyle);
396
433
  const breakAnywhere = ['anywhere', 'break-word'].includes(sourceStyle.overflowWrap);
397
434
  const paragraph = paragraphKey({
398
435
  path: pathToSource.get(source),
399
436
  markup: shapeOf(source.outerHTML),
400
437
  width: key,
401
- inputs: [align, indent, hyphensMode, langAttr ?? null, hyphenChar, limits.total, limits.before, limits.after, breakAnywhere, sourceStyle.wordBreak, sourceStyle.lineBreak, ...WIDTH_INPUTS.map((name) => sourceStyle[name]), ancestryOf(source)]
438
+ inputs: [align, indent, hyphensMode, langAttr ?? null, hyphenChar, limits.total, limits.before, limits.after, limitLines, breakAnywhere, sourceStyle.wordBreak, sourceStyle.lineBreak, ...WIDTH_INPUTS.map((name) => sourceStyle[name]), ancestryOf(source)]
402
439
  });
403
440
  const hit = lookup(cache, paragraph);
404
441
  if (hit) {
@@ -420,6 +457,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
420
457
  // The three css-text-4 properties the line already had and this module was not reading (2026-09-07). All three are inherited, so the block's own computed style is the whole answer.
421
458
  hyphenChar,
422
459
  limits,
460
+ limitLines,
423
461
  breakAnywhere
424
462
  });
425
463
  }
@@ -432,8 +470,18 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
432
470
  c.style.textIndent = '0';
433
471
  return c;
434
472
  });
473
+ // A nested paragraph is measured inside shallow copies of its ancestors up to its top-level block, or it loses what it inherits from them and what a descendant selector reads: a pull quote's `blockquote { font: italic 22px serif }` measured at the measure container's 16px set lines 22% too wide for the box (`composed-pull-quote-overrun`). ponytail: shallow copies carry attributes only, so a sibling selector (`p + p`, `:first-child`) still reads the lone copy; clone the earlier siblings too if one is ever measured wrong.
474
+ const measureRoots = jobs.map((j, i) => {
475
+ let top = nowrapClones[i];
476
+ for (let cur = j.source; !tops.has(cur) && cur.parentElement; cur = cur.parentElement) {
477
+ const wrap = cur.parentElement.cloneNode(false);
478
+ wrap.appendChild(top);
479
+ top = wrap;
480
+ }
481
+ return top;
482
+ });
435
483
  const probes = jobs.map((job, i) => seatHyphenProbe(nowrapClones[i], job.hyphenChar));
436
- ctx.measure(nowrapClones);
484
+ ctx.measure(measureRoots);
437
485
  // One pass reading, one pass removing: the removal is the last write, so the layout it forces is the last one the composition pays for. `collectWords` runs after it, or a probe's character would be a word.
438
486
  const hyphenWidths = probes.map(probeWidth);
439
487
  for (const probe of probes)
@@ -462,7 +510,8 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
462
510
  available: job.avail,
463
511
  indent: r === 0 ? job.indent : 0,
464
512
  align: job.align,
465
- breakAnywhere: job.breakAnywhere
513
+ breakAnywhere: job.breakAnywhere,
514
+ limitLines: job.limitLines
466
515
  });
467
516
  record.runs.push({ start: runs[r].start, ...result });
468
517
  record.lineCount += result.lineCount;
@@ -471,7 +520,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
471
520
  resolved.set(job.source, record);
472
521
  });
473
522
  }
474
- return { floaty: floats.length > 0, refusedBesideFloat, refusedUnspaced };
523
+ return { floaty: floats.length > 0, refusedBesideFloat, refusedUnspaced, refusedInColumns };
475
524
  };
476
525
  let todo = blocks.map((_, i) => i).filter((i) => !(trustMemo && memos?.[i]));
477
526
  let laid = lay(todo);
@@ -509,13 +558,18 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
509
558
  tally.blocks.set(memoKeys[i], { records, floaty: laid.floaty, refused: laid.refusedUnspaced.filter((src) => el.contains(src)).length });
510
559
  }
511
560
  }
512
- const { refusedBesideFloat, refusedUnspaced } = laid;
561
+ const { refusedBesideFloat, refusedUnspaced, refusedInColumns } = laid;
513
562
  // Never in silence — the render-conditions rule, applied to a decision the engine declines to take rather than to one it takes under a relaxation.
514
563
  if (refusedBesideFloat.length) {
515
564
  win.console?.warn(`BelType/Cicero: ${refusedBesideFloat.length} block(s) sit beside a float and are NOT composed by this engine — ` +
516
565
  'their lines are the browser\'s, because a float shortens line boxes and the Compositor has one measure per block, not one per line. ' +
517
566
  'The pages are honest; the line breaking in those blocks is not this engine\'s.');
518
567
  }
568
+ if (refusedInColumns.length) {
569
+ win.console?.warn(`BelType/Cicero: ${refusedInColumns.length} block(s) sit in a multi-column container and are NOT composed by this engine — ` +
570
+ 'their lines are the browser\'s, because the Compositor has one measure per block, not one per column. ' +
571
+ 'The pages are honest; the line breaking in those blocks is not this engine\'s.');
572
+ }
519
573
  const refusedCount = refusedUnspaced.length + refusedFromMemo;
520
574
  if (refusedCount) {
521
575
  win.console?.warn(`BelType/Cicero: ${refusedCount} block(s) may break lines inside a word — word-break: break-all, line-break: anywhere, or a script written without spaces — and are NOT composed by this engine; ` +