// =========================================================
/* ---------------------------------------------------------------
- The white page.
-
- The canvas takes its colour from the root element ,and until the theme is
- compiled the root has no colour ,so the browser paints its own default.
- Firefox does this readily ,and a book that opens on a sheet of white
- before turning black is worse than one that takes a moment longer to
- open: the flash lands before the reader has focused on anything.
-
- Two measures ,because neither alone is enough.
-
- The colour last used is remembered and applied here ,at parse time ,ahead
- of the first paint. A reader returning to a book — which is nearly every
- opening after the first — never sees white at all.
-
- A transition is armed at the same moment ,for the opening where nothing is
- remembered. There the colour arrives later ,when the theme compiles ,and
- it arrives as a fade rather than as a cut. A fade from white is a change
- of light; a cut from white is a flash ,and the eye reads the two quite
- differently.
-
- The remembered colour is a hint and nothing more. If it is wrong ,which it
- is when the reader has changed theme since ,the theme overwrites it within
- the same second and the error shows as a fade.
+ The white page ,and the blank that follows it.
+
+ Two faults ,and they were being treated as one.
+
+ The first is that the canvas takes its colour from the root ,and until
+ something sets it the browser paints its own default. The colour was being
+ applied when the theme compiled ,in the element phase — after the whole
+ document had parsed. On a book carrying MathJax that is seconds of white.
+ But the colour is knowable far earlier than that: RT.theme_preference runs
+ in the head ,at parse time ,and RT.load's document.write ordering means
+ every theme is loaded before it. So the theme applies its screen colour
+ the moment it resolves ,and the canvas is never white at all. Remembering
+ the last colour was a workaround for a problem that did not need one; it
+ is kept only for the window before the theme call ,where it costs nothing.
+
+ The second is what the reader is shown while the work is done. Hiding the
+ root hides the progress panel with it — visibility inherits — and leaves
+ the canvas background in doubt ,since a root with visibility hidden is a
+ poor thing to be relying on to paint. So the lock is a class on the root
+ that hides the contents of the body and excepts the panel. Backgrounds
+ paint normally ,because nothing about the root is hidden any more.
+
+ Both live in one stylesheet ,written into the head at parse time.
--------------------------------------------------------------- */
- // Namespaced as the theme preference beside it is ,and for the same store.
+ const boot_style_id = 'RT·boot-style';
const screen_color_key = 'RT-Manuscript·screen_color';
+ const lock_class = 'RT·locked';
+
+ function boot_style(){
+ let el = document.getElementById(boot_style_id);
+ if(el) return el;
+ el = document.createElement('style');
+ el.id = boot_style_id;
+ el.textContent = boot_style_text('');
+ (document.head || document.documentElement).appendChild(el);
+ return el;
+ }
+
+ /* The panel is animated by CSS and not by script ,which is the whole of why
+ it works. A phase holds the main thread from beginning to end ,so anything
+ driven from script stops dead for the length of it — which is exactly the
+ interval the reader most needs to see movement in. Transform and opacity
+ animate off the main thread ,so they keep running while a phase blocks.
+
+ The bar creeps rather than reports. It is not tied to the phases and does
+ not claim to be: it eases toward the end without arriving ,which is honest
+ about not knowing how long the work will take ,and it is always moving ,
+ which is the one thing the reader needs to see.
+
+ The panel fades in on a delay ,so a book that formats quickly never shows
+ it. That is done in the animation rather than in a timer for the same
+ reason as the bar: a timer would not fire. */
+ function boot_style_text(screen_color){
+ return (screen_color ? 'html ,body{ background-color:' + screen_color + '; }\n' : '')
+ + 'html.' + lock_class + ' body > *{ visibility:hidden; }\n'
+ + 'html.' + lock_class + ' #RT·progress{ visibility:visible; }\n'
+ + '#RT·progress{ position:fixed; top:0; left:0; right:0; bottom:0;'
+ + ' z-index:2147483647; display:flex; flex-direction:column;'
+ + ' align-items:center; justify-content:center; gap:1.1rem;'
+ + ' pointer-events:none; color:#8a8a8a; opacity:0;'
+ + " font:400 1rem/1.4 'Noto Sans JP' ,Arial ,sans-serif;"
+ + ' animation:RT·progress-appear 300ms ease-out 400ms forwards; }\n'
+ + '#RT·progress .RT·progress-label{ letter-spacing:0.08em; }\n'
+ + '#RT·progress .RT·progress-dot{ opacity:0.15;'
+ + ' animation:RT·progress-blink 1.4s ease-in-out infinite; }\n'
+ + '#RT·progress .RT·progress-dot:nth-child(2){ animation-delay:0.2s; }\n'
+ + '#RT·progress .RT·progress-dot:nth-child(3){ animation-delay:0.4s; }\n'
+ + '#RT·progress .RT·progress-track{ width:min(18rem ,60vw); height:2px;'
+ + ' background:currentColor; opacity:0.2; overflow:hidden; }\n'
+ + '#RT·progress .RT·progress-bar{ width:100%; height:100%;'
+ + ' background:currentColor; transform:scaleX(0);'
+ + ' transform-origin:left center;'
+ + ' animation:RT·progress-creep 40s cubic-bezier(0 ,0.7 ,0.15 ,1) forwards; }\n'
+ + '#RT·progress .RT·progress-time{ font-size:0.8rem; opacity:0.5;'
+ + ' font-variant-numeric:tabular-nums; }\n'
+ + '@keyframes RT·progress-appear{ to{ opacity:1; } }\n'
+ + '@keyframes RT·progress-blink{ 0% ,100%{ opacity:0.15; } 50%{ opacity:0.9; } }\n'
+ + '@keyframes RT·progress-creep{ to{ transform:scaleX(0.96); } }\n';
+ }
+
+ /* The screen colour ,written where it takes effect before the first paint.
+ Given to this from the theme the moment the theme resolves ,and again from
+ the layout configuration later ,which is the same colour by a longer road
+ and costs nothing to repeat. */
+ window.RT.screen_color_apply = function(color){
+ if(!color) return;
+ boot_style().textContent = boot_style_text(color);
+ try{ localStorage.setItem(screen_color_key ,color); }catch(e){}
+ };
- /* The two ends of the copy ,named for what they are. Both swallow their
- faults: a store that refuses to answer is a reason to fall back on the
- fade ,not a reason to stop opening the book. */
function screen_color_read(){
try{ return localStorage.getItem(screen_color_key); }
catch(e){ return null; }
}
function prepaint_screen(){
- const root = document.documentElement;
+ boot_style();
const color = screen_color_read();
-
- if(color) root.style.backgroundColor = color;
-
- /* Armed after the remembered colour is set ,so that colour lands
- instantly and only a correction fades. */
- root.style.transition = 'background-color 400ms ease-out';
+ if(color) window.RT.screen_color_apply(color);
}
- // Written by whoever resolves the theme ,read on the next opening.
- window.RT.screen_color_write = function(color){
- if(!color) return;
- try{ localStorage.setItem(screen_color_key ,color); }catch(e){}
- };
-
/* ---------------------------------------------------------------
Telling the reader that the wait is work.
- A blank that lasts four seconds and a blank that has hung are the same
+ A blank that lasts eight seconds and a blank that has hung are the same
blank. The reader cannot tell them apart ,so they reload ,which starts the
- four seconds again.
-
- An elapsed count answers it: a number that is moving is a machine that is
- working. The phase is named alongside it ,which costs nothing and means a
- slow book can be reported on precisely.
-
- The panel is only raised if the wait is long enough to be noticed. A short
- book formats in less time than it takes to read the word 'formatting' ,and
- raising a panel for that would be its own flicker.
+ eight seconds again.
- Visibility is set explicitly. The root is hidden ,and visibility inherits ,
- so a descendant that does not overrule it is hidden with everything else.
- The background is left clear so the screen colour shows through and the
- panel appears to sit on the page rather than over it.
+ The panel is raised as early as there is a body to hang it on ,which is
+ during parsing and well before the pipeline begins. It is not raised on a
+ timer ,because a timer does not fire while a phase holds the thread ,and
+ the phases are the whole of the wait.
--------------------------------------------------------------- */
- const progress = {
- panel: null ,elapsed: null ,phase: null
- ,start: 0 ,timer: 0 ,label: '' ,step: 0 ,raised: false
- };
-
- const progress_delay_ms = 500;
-
- function progress_raise(){
- if(progress.raised || !document.body) return;
- progress.raised = true;
+ const progress = { panel: null ,time: null ,start: 0 ,timer: 0 };
+ function progress_make(){
const panel = document.createElement('div');
panel.id = 'RT·progress';
- panel.style.cssText =
- 'position:fixed; top:0; left:0; right:0; bottom:0; z-index:2147483647;'
- + ' visibility:visible; pointer-events:none; background:transparent;'
- + ' display:flex; flex-direction:column; align-items:center;'
- + ' justify-content:center; gap:0.6rem; text-align:center;'
- + " font:400 1rem/1.4 'Noto Sans JP', Arial, sans-serif; color:#8a8a8a;";
- const elapsed = document.createElement('div');
- elapsed.style.cssText = 'font-size:1.5rem; font-variant-numeric:tabular-nums;';
+ const label = document.createElement('div');
+ label.className = 'RT·progress-label';
+ label.appendChild(document.createTextNode('Loading'));
+ for(let i = 0; i < 3; i++){
+ const dot = document.createElement('span');
+ dot.className = 'RT·progress-dot';
+ dot.textContent = ' .';
+ label.appendChild(dot);
+ }
- const phase = document.createElement('div');
- phase.style.cssText = 'font-size:0.85rem; opacity:0.75;';
+ const track = document.createElement('div');
+ track.className = 'RT·progress-track';
+ const bar = document.createElement('div');
+ bar.className = 'RT·progress-bar';
+ track.appendChild(bar);
- panel.appendChild(elapsed);
- panel.appendChild(phase);
- document.body.appendChild(panel);
+ const time = document.createElement('div');
+ time.className = 'RT·progress-time';
- progress.panel = panel;
- progress.elapsed = elapsed;
- progress.phase = phase;
- progress_paint();
- }
+ panel.appendChild(label);
+ panel.appendChild(track);
+ panel.appendChild(time);
- function progress_paint(){
- if(!progress.raised) return;
- const seconds = (performance.now() - progress.start) / 1000;
- progress.elapsed.textContent = 'Formatting ' + seconds.toFixed(1) + ' s';
- progress.phase.textContent = progress.label
- ? progress.label + ' (' + progress.step + ' of ' + window.RT.Phase.length + ')'
- : '';
+ progress.panel = panel;
+ progress.time = time;
+ return panel;
}
- function progress_begin(){
+ /* Raised on the first frame at which a body exists. Frames are served while
+ the document is still parsing ,so on a long head — a book carrying MathJax
+ has one — the panel is up before the pipeline has been reached. */
+ function progress_raise(){
+ if(progress.panel) return;
progress.start = performance.now();
- /* The pipeline holds the main thread for the length of a phase ,so this
- advances the count at phase boundaries and not within them. A phase that
- runs long shows a still number under a moving phase name ,which is
- honest about where the time is going. */
- progress.timer = setInterval(() => {
- if( !progress.raised && performance.now() - progress.start > progress_delay_ms ){
- progress_raise();
+
+ const attempt = function(){
+ if(!is_layout_locked) return;
+ if(document.body){
+ document.body.appendChild(progress_make());
+ return;
}
- progress_paint();
- } ,100);
+ requestAnimationFrame(attempt);
+ };
+ requestAnimationFrame(attempt);
}
- function progress_report(phase_name ,step){
- progress.label = phase_name;
- progress.step = step;
- progress_paint();
+ /* The count advances at phase boundaries and not within them ,since a phase
+ holds the thread. The bar carries the motion; this carries the magnitude. */
+ function progress_report(){
+ if(!progress.time) return;
+ const seconds = (performance.now() - progress.start) / 1000;
+ progress.time.textContent = seconds.toFixed(1) + ' s';
}
function progress_end(){
- if(progress.timer) clearInterval(progress.timer);
- progress.timer = 0;
- if(progress.panel && progress.panel.parentNode) progress.panel.remove();
+ const panel = progress.panel;
progress.panel = null;
- progress.raised = false;
+ progress.time = null;
+ if(progress.timer){ clearTimeout(progress.timer); progress.timer = 0; }
+ if(!panel || !panel.parentNode) return;
+
+ /* Filled and faded rather than snatched away. An animation outranks an
+ inline declaration ,so each is stood down before its property is set. */
+ const bar = panel.querySelector('.RT·progress-bar');
+ if(bar){
+ bar.style.animation = 'none';
+ bar.style.transition = 'transform 160ms ease-out';
+ bar.style.transform = 'scaleX(1)';
+ }
+ panel.style.animation = 'none';
+ panel.style.transition = 'opacity 220ms ease-out';
+ panel.style.opacity = '0';
+ progress.timer = setTimeout(() => panel.remove() ,260);
}
function lock_layout(){
is_layout_locked = true;
- document.documentElement.style.visibility = "hidden";
+ document.documentElement.classList.add(lock_class);
+ }
+
+ /* The safety net must not fire while the pipeline is still working.
+
+ It used to be harmless. The pipeline ran to completion inside the
+ DOMContentLoaded handler ,so by the time load fired there was nothing left
+ to protect and unlocking was a no-op. Yielding between phases broke that:
+ load now arrives in the middle of the run — usually within a frame or two
+ of DOMContentLoaded — and the net would lift the curtain on a document
+ that had been chunked into pages but not yet counted or resolved.
+
+ That is precisely the fault of a title page appearing alone against a
+ black field ,with the rest of the book arriving some seconds later. It was
+ not slow rendering being glimpsed. It was the curtain going up early ,and
+ the pipeline then finishing in plain view.
+
+ So the net catches only the case it was meant for: a pipeline that never
+ started. One that has started ends by unlocking itself ,whether its
+ phases succeed or fail ,since every task is already run inside a guard. */
+ let pipeline_state = 'idle';
+
+ function unlock_on_load(){
+ if(pipeline_state === 'running') return;
+ unlock_layout();
}
function unlock_layout(){
is_layout_locked = false;
progress_end();
-
- document.documentElement.style.visibility = "";
- window.removeEventListener("load" ,unlock_layout);
+
+ document.documentElement.classList.remove(lock_class);
+ window.removeEventListener("load" ,unlock_on_load);
document.dispatchEvent(new Event("RT_layout_complete"));
}
pieces is a different order of change.
*/
function run_pipeline(){
- progress_begin();
+ if(pipeline_state !== 'idle') return;
+ pipeline_state = 'running';
let index = 0;
const step = function(){
if(index >= window.RT.Phase.length){
+ pipeline_state = 'done';
progress_end();
if(is_layout_locked) resolve_scroll_target();
return;
}
const phase_name = window.RT.Phase[index++];
- progress_report(phase_name ,index);
+ progress_report();
next_frame(function(){
window.RT.Debug.log('stage' ,'phase: ' + phase_name);
lock_layout();
prepaint_screen();
+ progress_raise();
configure_history();
capture_scroll_target();
bind_window_events();
- document.addEventListener('DOMContentLoaded' ,run_pipeline);
+ /* A script that arrives after the document has been parsed never sees
+ DOMContentLoaded ,and would wait for an event that has already gone by.
+ The URL-only locator can land in exactly that position. */
+ if(document.readyState === 'loading'){
+ document.addEventListener('DOMContentLoaded' ,run_pipeline);
+ }else{
+ run_pipeline();
+ }
- // Safety net: restore visibility on load if the layout engine hangs
- window.addEventListener("load" ,unlock_layout);
+ // Safety net: restore visibility on load if the pipeline never started.
+ window.addEventListener("load" ,unlock_on_load);
})();
+++ /dev/null
-# RT-Style — six amendments
-
-Eight files touched. About 490 lines added, 60 removed, and most of the
-addition is commentary. Nothing was restructured.
-
-Drop `Manuscript.copy/` over `developer/authored/Manuscript.copy/` and build,
-or apply `RT-Style.patch` from that directory.
-
- cd developer/authored/Manuscript.copy && patch -p1 < RT-Style.patch
-
----
-
-## 1. Orphan control and sections
-
-**Cause.** `paginate.js` asked whether an element was a heading by matching its
-tag against `H[1-6]`. A section title stopped being an `<h1>` when sections
-became scoped counted steps — it is now a `div.RT·section-title` holding
-counter reads — so the test has matched nothing since, and the widow control
-it guards has been inert. Not a new fault in the orphan logic so much as an
-old test left describing a document that no longer exists.
-
-**Change.**
-
-- `section.js` marks the title `data-rt-heading="true"` when it builds it.
-- `paginate.js` gains `is_heading()` (h1–h6, or the mark) and `is_ghost()`
- (snapshot, make, name, whitespace — nodes that occupy no space). Both
- backtrack loops now use `is_heading`.
-- `RT.Splitter['rt·counter·step']` gains widow control, which it never had.
- If a fragment's tail, ignoring ghosts, is a heading, the cut moves above it.
- If nothing but the heading fitted, no fragment is emitted and the whole
- scope moves on.
-
-The backtrack loops were never going to be enough on their own: sections are
-cut by the step splitter, and it was the splitter that produced the
-`[snapshot, title]` fragment.
-
-**Termination.** A null first fragment sends the caller down one of two paths.
-On a page with content it closes the page and retries against a full page. On
-an empty page it places the scope whole and grows, which is terminal. Neither
-can return with the same room twice.
-
-## 2. A counter per division
-
-`section.js` now holds an open series table. Each series names a counter, the
-styles its levels are set in, and the words that precede a number:
-
-| series | counter | style | prefix |
-|---|---|---|---|
-| `body` | `RT·Section·counter` | CountingNumber | Chapter, Section |
-| `front` | `RT·Section·counter·front` | roman | Front Matter, Front Matter Section |
-| `appendix` | `RT·Section·counter·appendix` | Alpha, CountingNumber | Appendix, Appendix Section |
-
-Written once at the top of a division:
-
- <RT·section series="appendix">
- <RT·name>Notation</RT·name>
- <RT·section><RT·name>Symbols</RT·name></RT·section>
- </RT·section>
-
-giving *Appendix A* and *Appendix Section A.1*. Nested sections inherit, so the
-attribute is not repeated. A series never referenced emits no make tag.
-
-The table is open. Before the element phase:
-
- RT.Element.Section.series.part =
- { counter: 'RT·Section·counter·part', style: 'Roman'
- ,prefix: 'Part', on_first_step: 'I' };
-
-**The prefix list** mirrors the counter nesting with the last entry repeating,
-as specified. `{Chapter, Section}` reads *Chapter 3* at the top and
-*Section 3.2.1* at every level below the first. The word is chosen from the
-same `active_list` the number is formatted from — factored out as
-`active_list_of` — so the two cannot disagree at a scope boundary, which is
-where a separately computed depth would have gone wrong.
-
-**A read takes a list.** Fields are whitespace separated and emitted in order:
-
- <RT·counter·read snapshot="s" key="prefix count"> → Appendix B
- <RT·counter·read snapshot="s" key="count prefix"> → B Appendix
-
-Empty fields are dropped rather than leaving a hanging space, so a counter
-with no prefix set still reads as a bare number. Every read written before
-this — `key="name"`, `key="list.short"`, no key at all — takes the same path
-and reads the same.
-
-**`TOC.js`** named `RT·Section·counter` in its query, which would have listed
-the chapters and dropped the front matter and the appendices — the two things
-a reader looks for in a contents list first. It now queries
-`[data-rt-section]` and counts depth against each step's own counter, so the
-divisions nest independently and the query needs no knowledge of what counters
-exist.
-
-## 3. Authored leaves
-
-Worth knowing before anything else: **authored `<RT·page>` elements were being
-discarded.** `paginate_0` filtered them out of the element list and then
-cleared the article. Anything on one was lost silently.
-
-They are now kept. An authored leaf closes whatever page is open and stands as
-one, carried through unmeasured — the author decided what is on it and the
-paginator has no business adding to it.
-
- <RT·page no-number>
- <RT·title title="…" author="…"></RT·title>
- </RT·page>
-
-`no-number` takes no counter step, so the counter does not advance across the
-leaf. Not counted, rather than counted and hidden: the reader's page one is
-the first page of text. Written plainly, the leaf takes its number in sequence
-like any other.
-
-## 4. Rendering time
-
-**Where it went.** Not in the shrink wrapping. In the length of the document
-behind it. Setting a width on a label in the flow dirties it, which changes
-its height, which moves everything below, and the browser lays out the rest of
-the book before it will answer. A shrink wrap asks about a dozen such
-questions per label; a hundred labels is a thousand full layouts. The cost
-scales with book length rather than table count, which is why it read as
-general slowness rather than as slow tables.
-
-**Change.** `RT.Utility.Dom.measure_host(context_el)` returns a positioned,
-contained host appended to the element's real parent at that parent's content
-width. Out of flow, so nothing below it moves. A child of the real parent, so
-font, size, weight and colour are inherited exactly, and the wrapping measured
-is the wrapping that will be rendered — measuring somewhere convenient instead
-would answer a question about a document that does not exist.
-
-`grid.js` gains `place_and_size`, which inverts the order: measure in the host,
-*then* `replaceWith`. Both results are explicit lengths — a frozen column
-template, a pixel width per label — so they survive the move unchanged. Where
-no host can be established the old order stands: measuring in place is slow,
-measuring at the wrong width is wrong, and slow is better.
-
-The first-line widening probe drops from 16 trials to 6. Widths that satisfy
-the test come in runs rather than as isolated points, since a range of widths
-keeps the same line breaks, so the coarser step finds nearly all of them. Where
-it steps over one the balanced width stands and the loss is a short first line.
-
-**On the rest of your thinking.** Revealing before cleanup is done — see 6.
-On rendering pages in blocks: I would leave it. Growth is already local and
-terminal by design, and block rendering reintroduces exactly the coupling that
-reasoning was built to avoid. Cross references are the smaller problem and
-they are already last.
-
-**Not done.** The two long phases, `element` and `paginate_0`, still hold the
-thread from beginning to end. Cutting a phase into resumable pieces is the
-larger prize and a different order of change.
-
-## 5. The white page
-
-The canvas takes its colour from the root, and until the theme compiles the
-root has no colour, so the browser paints its own default.
-
-Two measures, because neither alone is enough.
-
-The resolved screen colour is remembered in `localStorage` and applied in
-`stage_manager` at parse time, ahead of the first paint. Every opening after
-the first shows no white at all.
-
-A transition is armed at the same moment, for the first opening, where the
-colour arrives late — and now arrives as a fade rather than as a cut. A fade
-from white is a change of light; a cut from white is a flash, and the eye reads
-the two quite differently. If the remembered colour is wrong because the
-reader has changed theme since, the correction shows as a fade too.
-
-## 6. The timer, and letting the reader in
-
-`run_pipeline` now runs one phase per turn with two frames between them. No
-phase is split and the order is unchanged; what changes is that the thread is
-given back between them, which is the only moment the browser has to paint.
-
-A panel appears after 500ms — not sooner, or a short book gets its own flicker
-— showing elapsed seconds and the phase name against the phase count. It sets
-`visibility:visible` explicitly, since the root is hidden and visibility
-inherits, and leaves its background clear so the screen colour shows through.
-
-**Honest limitation.** The count advances at phase boundaries, not within them.
-A long `element` or `paginate_0` shows a still number under a moving phase
-name. That is at least honest about where the time is going, and it is enough
-to tell a working machine from a hung one.
-
-**Reveal.** `RT.Phase_reveal = 'note'`. The document is readable once the notes
-resolve; everything after only grows leaves that overflowed. Scroll is settled
-first, or the reader would be shown the top of the book and then moved. Set to
-`null` to hold the blank until every phase finishes — worth doing if a late
-phase ever gains the power to move content rather than only to grow pages.
-
----
-
-## Tests
-
- npm install jsdom
- node test/test_counter.js # 22 — series, prefixes, list reads
- node test/test_widow.js # 11 — widow control, and what it must not change
- node test/test_page.js # 10 — authored leaves, numbered and not
-
-43 passing. jsdom reports every height as zero, so `test_widow` and `test_page`
-declare heights on a `data-h` attribute and answer `getBoundingClientRect` from
-the tree. That is enough to drive every decision the splitter makes and it lets
-a fragment be posed exactly — a heading with two lines beneath it, a heading
-with none — which is awkward to arrange in a real document and is the whole of
-what is being tested.
-
-## What wants a browser
-
-I have no browser here, so these are reasoned rather than observed:
-
-1. **`place_and_size`** is the change I would check first. The risk is not
- correctness but inheritance — if a grid sits somewhere whose typography
- differs from its parent's in a way I have not anticipated, the frozen
- widths will be subtly wrong. Compare a rendered table before and after.
-2. **The 6-probe widening** is a visual judgement. If first lines look short,
- raise `probe_budget` in `utility.js`.
-3. **Revealing at `note`** — whether `paginate_1` growing pages under a reader
- is acceptable in practice, or merely acceptable in principle.
-4. **The remembered screen colour** on a first-ever opening, where the fade is
- the only defence.
-
----
-
-## Addendum — RT code format conformance
-
-Corrected after review against `developer/document/RT-code-format.html`.
-
-**Acronyms stay capitalized.** The four attributes introduced here were written
-`data-rt-*`, following the surrounding code rather than the rule:
-
-| was | now |
-|---|---|
-| `data-rt-heading` | `data-RT-heading` |
-| `data-RT-series` (was `data-rt-series`) | `data-RT-series` |
-| `data-rt-section` | `data-RT-section` |
-| `data-rt-measure-host` | `data-RT-measure-host` |
-
-This is a source legibility change and nothing else. HTML lowercases attribute
-names on `setAttribute`, and matches them case-insensitively on `getAttribute`,
-`hasAttribute` and in selectors — verified, not assumed. So the two spellings
-are the same attribute at runtime and the mixed state cannot break anything.
-
-**Containers take a type prefix**, not a plural and not a type suffix.
-
-- `ghost_tag_set` → `Set_ghost_tag`, matching the `Map_*` / `dict_*` forms.
-- `parts` → `list_part` in `process_read_node`.
-
-`CounterMachine.prefix` is left as it is, against the `list_*` rule, because
-`style` beside it is also a list and is not `list_style`. Local consistency
-looked like the stronger claim; say if it is not.
-
-**Read and write are the two ends of a copy.**
-
-- `screen_color_remember` → `screen_color_write`, and the matching
- `screen_color_read` is lifted out of `prepaint_screen` so both ends are named.
-
-**Factory functions are called make.**
-
-- `RT.Utility.Dom.measure_host` → `measure_host_make`, as `theme_make` and
- `RT·counter·make` are.
-
-**Namespacing.** The store key `RT·screen_color` → `RT-Manuscript·screen_color`,
-matching `RT-Manuscript·theme_preference`, which sits in the same store.
-
-**Punctuation.** Three prose commas in new comments written `word, word` rather
-than `word ,word`. Multi-level enclosures given one space of padding on the
-outermost only — `if( !(best_height > 0) ){`.
-
-### Not touched
-
-The pre-existing `data-rt-component`, `data-rt-row`, `data-rt-row-extent`,
-`data-rt-col`, `data-rt-columns-frozen`, `data-rt-continued` and
-`data-rt-wrapped` are left alone. The format document invites updating
-non-conforming code on contact, and this is a one-line change per site with no
-runtime effect, but it touches files this work was not otherwise opening. Say
-the word and it is a separate patch.
+++ /dev/null
-Only in work: .git
-diff -ru work.orig/Core/stage_manager.js work/Core/stage_manager.js
---- work.orig/Core/stage_manager.js 2026-08-27 07:22:53.082936286 +0000
-+++ work/Core/stage_manager.js 2026-08-27 09:16:57.680190009 +0000
-@@ -80,6 +80,21 @@
- ,'paginate_1'
- ];
-
-+ /* Where the reader is let in.
-+
-+ The document is readable once the notes are resolved. Everything after
-+ that point only grows pages that overflowed ,which is a change to the
-+ bottom of a few leaves and to nothing a reader is looking at in the first
-+ seconds. Holding the blank until the last phase finished made the reader
-+ wait on work that did not concern them.
-+
-+ So the curtain rises here and the remaining phases run behind it. Set to
-+ null to hold the blank until every phase has finished ,which is the older
-+ behaviour and is what to do if a late phase is ever given the power to
-+ move content rather than only to grow it.
-+ */
-+ window.RT.Phase_reveal = 'note';
-+
- window.RT.Task = {};
- window.RT.Phase.forEach(phase_name => { window.RT.Task[phase_name] = []; });
-
-@@ -114,6 +129,154 @@
- // SCROLL & LAYOUT LOCK UTILITIES
- // =========================================================
-
-+ /* ---------------------------------------------------------------
-+ The white page.
-+
-+ The canvas takes its colour from the root element ,and until the theme is
-+ compiled the root has no colour ,so the browser paints its own default.
-+ Firefox does this readily ,and a book that opens on a sheet of white
-+ before turning black is worse than one that takes a moment longer to
-+ open: the flash lands before the reader has focused on anything.
-+
-+ Two measures ,because neither alone is enough.
-+
-+ The colour last used is remembered and applied here ,at parse time ,ahead
-+ of the first paint. A reader returning to a book — which is nearly every
-+ opening after the first — never sees white at all.
-+
-+ A transition is armed at the same moment ,for the opening where nothing is
-+ remembered. There the colour arrives later ,when the theme compiles ,and
-+ it arrives as a fade rather than as a cut. A fade from white is a change
-+ of light; a cut from white is a flash ,and the eye reads the two quite
-+ differently.
-+
-+ The remembered colour is a hint and nothing more. If it is wrong ,which it
-+ is when the reader has changed theme since ,the theme overwrites it within
-+ the same second and the error shows as a fade.
-+ --------------------------------------------------------------- */
-+
-+ // Namespaced as the theme preference beside it is ,and for the same store.
-+ const screen_color_key = 'RT-Manuscript·screen_color';
-+
-+ /* The two ends of the copy ,named for what they are. Both swallow their
-+ faults: a store that refuses to answer is a reason to fall back on the
-+ fade ,not a reason to stop opening the book. */
-+ function screen_color_read(){
-+ try{ return localStorage.getItem(screen_color_key); }
-+ catch(e){ return null; }
-+ }
-+
-+ function prepaint_screen(){
-+ const root = document.documentElement;
-+ const color = screen_color_read();
-+
-+ if(color) root.style.backgroundColor = color;
-+
-+ /* Armed after the remembered colour is set ,so that colour lands
-+ instantly and only a correction fades. */
-+ root.style.transition = 'background-color 400ms ease-out';
-+ }
-+
-+ // Written by whoever resolves the theme ,read on the next opening.
-+ window.RT.screen_color_write = function(color){
-+ if(!color) return;
-+ try{ localStorage.setItem(screen_color_key ,color); }catch(e){}
-+ };
-+
-+ /* ---------------------------------------------------------------
-+ Telling the reader that the wait is work.
-+
-+ A blank that lasts four seconds and a blank that has hung are the same
-+ blank. The reader cannot tell them apart ,so they reload ,which starts the
-+ four seconds again.
-+
-+ An elapsed count answers it: a number that is moving is a machine that is
-+ working. The phase is named alongside it ,which costs nothing and means a
-+ slow book can be reported on precisely.
-+
-+ The panel is only raised if the wait is long enough to be noticed. A short
-+ book formats in less time than it takes to read the word 'formatting' ,and
-+ raising a panel for that would be its own flicker.
-+
-+ Visibility is set explicitly. The root is hidden ,and visibility inherits ,
-+ so a descendant that does not overrule it is hidden with everything else.
-+ The background is left clear so the screen colour shows through and the
-+ panel appears to sit on the page rather than over it.
-+ --------------------------------------------------------------- */
-+
-+ const progress = {
-+ panel: null ,elapsed: null ,phase: null
-+ ,start: 0 ,timer: 0 ,label: '' ,step: 0 ,raised: false
-+ };
-+
-+ const progress_delay_ms = 500;
-+
-+ function progress_raise(){
-+ if(progress.raised || !document.body) return;
-+ progress.raised = true;
-+
-+ const panel = document.createElement('div');
-+ panel.id = 'RT·progress';
-+ panel.style.cssText =
-+ 'position:fixed; top:0; left:0; right:0; bottom:0; z-index:2147483647;'
-+ + ' visibility:visible; pointer-events:none; background:transparent;'
-+ + ' display:flex; flex-direction:column; align-items:center;'
-+ + ' justify-content:center; gap:0.6rem; text-align:center;'
-+ + " font:400 1rem/1.4 'Noto Sans JP', Arial, sans-serif; color:#8a8a8a;";
-+
-+ const elapsed = document.createElement('div');
-+ elapsed.style.cssText = 'font-size:1.5rem; font-variant-numeric:tabular-nums;';
-+
-+ const phase = document.createElement('div');
-+ phase.style.cssText = 'font-size:0.85rem; opacity:0.75;';
-+
-+ panel.appendChild(elapsed);
-+ panel.appendChild(phase);
-+ document.body.appendChild(panel);
-+
-+ progress.panel = panel;
-+ progress.elapsed = elapsed;
-+ progress.phase = phase;
-+ progress_paint();
-+ }
-+
-+ function progress_paint(){
-+ if(!progress.raised) return;
-+ const seconds = (performance.now() - progress.start) / 1000;
-+ progress.elapsed.textContent = 'Formatting ' + seconds.toFixed(1) + ' s';
-+ progress.phase.textContent = progress.label
-+ ? progress.label + ' (' + progress.step + ' of ' + window.RT.Phase.length + ')'
-+ : '';
-+ }
-+
-+ function progress_begin(){
-+ progress.start = performance.now();
-+ /* The pipeline holds the main thread for the length of a phase ,so this
-+ advances the count at phase boundaries and not within them. A phase that
-+ runs long shows a still number under a moving phase name ,which is
-+ honest about where the time is going. */
-+ progress.timer = setInterval(() => {
-+ if( !progress.raised && performance.now() - progress.start > progress_delay_ms ){
-+ progress_raise();
-+ }
-+ progress_paint();
-+ } ,100);
-+ }
-+
-+ function progress_report(phase_name ,step){
-+ progress.label = phase_name;
-+ progress.step = step;
-+ progress_paint();
-+ }
-+
-+ function progress_end(){
-+ if(progress.timer) clearInterval(progress.timer);
-+ progress.timer = 0;
-+ if(progress.panel && progress.panel.parentNode) progress.panel.remove();
-+ progress.panel = null;
-+ progress.raised = false;
-+ }
-+
- function lock_layout(){
- is_layout_locked = true;
- document.documentElement.style.visibility = "hidden";
-@@ -122,6 +285,8 @@
- function unlock_layout(){
- if(!is_layout_locked) return;
- is_layout_locked = false;
-+
-+ progress_end();
-
- document.documentElement.style.visibility = "";
- window.removeEventListener("load" ,unlock_layout);
-@@ -232,12 +397,62 @@
- enforce_scroll(target_y ,use_hash ,0);
- }
-
-+ /* Two frames ,not one.
-+
-+ A single frame runs the callback in the same paint as the style change
-+ that preceded it ,so the panel's new text is written and the next phase
-+ seizes the thread before the reader sees it. The second frame lets the
-+ paint land first. The cost is a few milliseconds per phase against a
-+ pipeline measured in seconds. */
-+ function next_frame(fn){
-+ requestAnimationFrame(() => requestAnimationFrame(fn));
-+ }
-+
-+ /* The pipeline runs one phase per turn rather than all of them in one.
-+
-+ Nothing about the order changes ,and no phase is split: each still runs to
-+ completion before the next begins. What changes is that the thread is
-+ given back between them ,which is the only moment the browser has to paint
-+ the elapsed count ,and the only moment at which the curtain can be raised
-+ part way through.
-+
-+ Splitting a phase would be the larger prize — the two long ones ,element
-+ and paginate_0 ,hold the thread for most of the wait — but a phase is
-+ written as one pass over the document and cutting one into resumable
-+ pieces is a different order of change.
-+ */
- function run_pipeline(){
-- window.RT.Phase.forEach(phase_name => {
-- window.RT.Debug.log('stage' ,'phase: ' + phase_name);
-- run_phase(phase_name);
-- });
-- resolve_scroll_target();
-+ progress_begin();
-+
-+ let index = 0;
-+
-+ const step = function(){
-+ if(index >= window.RT.Phase.length){
-+ progress_end();
-+ if(is_layout_locked) resolve_scroll_target();
-+ return;
-+ }
-+
-+ const phase_name = window.RT.Phase[index++];
-+ progress_report(phase_name ,index);
-+
-+ next_frame(function(){
-+ window.RT.Debug.log('stage' ,'phase: ' + phase_name);
-+ run_phase(phase_name);
-+
-+ /* The reader is let in here ,and the remaining phases go on behind
-+ them. Scroll is settled first ,or the reader would be shown the top
-+ of the book and then moved. */
-+ if(phase_name === window.RT.Phase_reveal && is_layout_locked){
-+ progress_end();
-+ resolve_scroll_target();
-+ }
-+
-+ step();
-+ });
-+ };
-+
-+ step();
- }
-
- // =========================================================
-@@ -245,6 +460,7 @@
- // =========================================================
-
- lock_layout();
-+ prepaint_screen();
- configure_history();
- capture_scroll_target();
- bind_window_events();
-diff -ru work.orig/Core/utility.js work/Core/utility.js
---- work.orig/Core/utility.js 2026-08-27 07:22:53.083177529 +0000
-+++ work/Core/utility.js 2026-08-27 09:16:57.682003283 +0000
-@@ -215,6 +215,50 @@
-
- // DOM Structural Operations
- window.RT.Utility.Dom = window.RT.Utility.Dom || {};
-+
-+ /* A place to measure in ,out of the flow of the book.
-+
-+ Every question about how text wraps is one only the browser can answer ,
-+ and it will only answer it about an element that is attached and laid out.
-+ Attached in the flow ,though ,each answer is dear: setting a width dirties
-+ the element ,which changes its height ,which moves everything below it ,
-+ and the browser must lay the rest of the book out again before it can
-+ reply. A shrink wrap asks a dozen such questions per label. A hundred
-+ labels in a long manuscript is then a thousand full layouts ,and that is
-+ where the time went when shrink wrapping was added — the cost is not in
-+ the wrapping ,it is in the length of the document behind it.
-+
-+ The host is positioned ,so it is out of flow and its contents cannot
-+ change the height of anything in the flow. Nothing below it moves ,so
-+ there is nothing below it to lay out again. It is a child of the element
-+ the content would really sit in ,so font ,size ,weight and colour are
-+ inherited exactly as they will be in place ,and it is given that parent's
-+ content width ,so the wrapping measured is the wrapping that will be
-+ rendered. Measuring somewhere convenient instead would answer a question
-+ about a document that does not exist.
-+
-+ Returns null where a width cannot be established ,which is the caller's
-+ signal to measure in place as before rather than to measure wrongly.
-+ */
-+ window.RT.Utility.Dom.measure_host_make = function(context_el){
-+ const parent = context_el && context_el.parentElement;
-+ if(!parent) return null;
-+
-+ const ps = window.getComputedStyle(parent);
-+ const width = parent.clientWidth
-+ - parseFloat(ps.paddingLeft || 0)
-+ - parseFloat(ps.paddingRight || 0);
-+ if( !(width > 0) ) return null;
-+
-+ const host = document.createElement('div');
-+ host.setAttribute('data-RT-measure-host' ,'true');
-+ host.style.cssText =
-+ 'position:fixed; top:0; left:0; visibility:hidden; pointer-events:none;'
-+ + ' contain:layout style; z-index:-1; width:' + width + 'px;';
-+
-+ parent.appendChild(host);
-+ return host;
-+ };
-
-
- /* Shrink wrap an attached element to a well set block of text.
-@@ -333,10 +377,20 @@
- Where no such width exists within the bound the balanced width stands:
- <strong>a stranded word is worse than a short first line</strong> ,and
- given the choice we decline to create one.
-+
-+ The bound is a count of probes rather than a step size ,because a
-+ probe is the expensive thing here and sixteen of them per label ,on
-+ top of the ten the bisection costs ,was most of the cost of shrink
-+ wrapping a manuscript. Six probes over the same span find nearly every
-+ width the sixteen found — the widths that satisfy the test are not
-+ isolated points but runs ,since a range of widths keeps the same line
-+ breaks — and where a coarser step steps over one ,the balanced width
-+ stands and the loss is a short first line rather than a fault.
- */
-+ const probe_budget = 6;
- const ceiling = Math.ceil(m.widest) || max_width;
- const span = Math.max(0 ,ceiling - best);
-- const step = Math.max(2 ,Math.round(span / 16));
-+ const step = Math.max(2 ,Math.round(span / probe_budget));
- for(let w = best + step; w <= ceiling; w += step){
- el.style.width = w + 'px';
- const t = line_metrics(el);
-diff -ru work.orig/Element/TOC.js work/Element/TOC.js
---- work.orig/Element/TOC.js 2026-08-27 07:22:53.084256738 +0000
-+++ work/Element/TOC.js 2026-08-27 09:16:01.566939801 +0000
-@@ -115,16 +115,25 @@
- }
- }
-
-+ /* Every section ,whichever series it belongs to.
-+
-+ Naming the body counter here would have listed the chapters and left
-+ the front matter and the appendices out of the contents ,which is
-+ where a reader looks for them first. Steps mark themselves as sections
-+ when they are built ,so the query does not have to know what counters
-+ exist ,and depth is counted against the step's own counter so the
-+ divisions nest independently. */
- const sections = [];
-- const all_sections = document.querySelectorAll('RT·counter·step[counter="RT·Section·counter"]');
-+ const all_sections = document.querySelectorAll('RT·counter·step[data-RT-section]');
-
- all_sections.forEach(section => {
-+ const counter_name = section.getAttribute('counter');
- let depth = 0;
- let curr = section.parentElement;
-
- while(curr){
- const tag = (curr.tagName || '').toLowerCase();
-- if(tag === 'rt·counter·step' && curr.getAttribute('counter') === 'RT·Section·counter'){
-+ if(tag === 'rt·counter·step' && curr.getAttribute('counter') === counter_name){
- depth++;
- }
- curr = curr.parentElement;
-diff -ru work.orig/Element/grid.js work/Element/grid.js
---- work.orig/Element/grid.js 2026-08-27 07:22:53.084933456 +0000
-+++ work/Element/grid.js 2026-08-27 09:16:14.278509929 +0000
-@@ -197,10 +197,7 @@
- wrapper.appendChild(el);
- });
-
-- container_node.replaceWith(wrapper);
-- freeze_columns(wrapper);
-- shrink_labels(wrapper);
-- execute_two_pass_measurement(wrapper, options);
-+ place_and_size(container_node ,wrapper ,options);
- }
-
- function render_model_html_dictionary(container_node, grid_state, options, config) {
-@@ -252,10 +249,7 @@
- wrapper.appendChild(el);
- });
-
-- container_node.replaceWith(wrapper);
-- freeze_columns(wrapper);
-- shrink_labels(wrapper);
-- execute_two_pass_measurement(wrapper, options);
-+ place_and_size(container_node ,wrapper ,options);
- }
-
-
-@@ -405,6 +399,47 @@
- }
- }
-
-+ /* Size the grid ,then put it in the book.
-+
-+ The order matters and it used to be the other way round. Placing the
-+ wrapper in the flow first meant every probe that followed — the column
-+ freeze ,and a dozen width trials per label — was answered by laying out
-+ the whole of the rest of the manuscript ,because a label that changes
-+ width changes its row's height and moves everything below it. The cost
-+ scaled with the length of the book rather than with the size of the table ,
-+ which is why it appeared as general slowness rather than as slow tables.
-+
-+ Measured in the host the same probes cost nothing beyond the grid itself.
-+ The host is out of flow ,so nothing below it moves ,and it is a child of
-+ the parent the grid is bound for at that parent's content width ,so the
-+ wrapping measured is the wrapping that will be rendered.
-+
-+ Both results are explicit lengths — a frozen column template ,a pixel
-+ width per label — so they survive the move into the flow unchanged.
-+
-+ Where no host can be established the old order stands. Measuring in place
-+ is slow ,and measuring at the wrong width is wrong ,and slow is better.
-+ */
-+ function place_and_size(container_node ,wrapper ,options){
-+ const host = window.RT.Utility.Dom.measure_host_make
-+ ? window.RT.Utility.Dom.measure_host_make(container_node)
-+ : null;
-+
-+ if(host){
-+ host.appendChild(wrapper);
-+ freeze_columns(wrapper);
-+ shrink_labels(wrapper);
-+ container_node.replaceWith(wrapper);
-+ host.remove();
-+ }else{
-+ container_node.replaceWith(wrapper);
-+ freeze_columns(wrapper);
-+ shrink_labels(wrapper);
-+ }
-+
-+ execute_two_pass_measurement(wrapper ,options);
-+ }
-+
- function freeze_columns(wrapper){
- if(!wrapper || !wrapper.isConnected) return;
- const resolved = window.getComputedStyle(wrapper).gridTemplateColumns;
-diff -ru work.orig/Element/section.js work/Element/section.js
---- work.orig/Element/section.js 2026-08-27 07:22:53.085301826 +0000
-+++ work/Element/section.js 2026-08-27 09:16:57.682406493 +0000
-@@ -13,6 +13,75 @@
-
- ns.tags = ['RT·section'];
-
-+ /* Series: one counter per division of the book.
-+
-+ A single counter across the whole manuscript numbers the preface as
-+ chapter one and starts the appendices wherever the last chapter left off.
-+ The divisions of a book are not one sequence and never were ,so they do
-+ not share a counter.
-+
-+ A series names a counter ,the styles its levels are set in ,and the words
-+ that precede a number at each level. The word list mirrors the counter
-+ nesting and its last entry repeats ,so {Chapter ,Section} reads 'Chapter 3'
-+ at the top and 'Section 3.2.1' at every level below the first.
-+
-+ The table is open. An author wanting a numbered part ,or a second
-+ appendix sequence ,adds an entry before the element phase runs:
-+
-+ RT.Element.Section.series.part =
-+ { counter: 'RT·Section·counter·part'
-+ ,style: 'Roman' ,prefix: 'Part' ,on_first_step: 'I' };
-+
-+ and writes <RT·section series="part">. Sections nested inside a section
-+ inherit its series ,so the attribute is written once at the top of a
-+ division and not repeated.
-+ */
-+ ns.series = {
-+ body: {
-+ counter: 'RT·Section·counter'
-+ ,style: 'CountingNumber'
-+ ,prefix: 'Chapter,Section'
-+ ,on_first_step: '0'
-+ }
-+ ,front: {
-+ counter: 'RT·Section·counter·front'
-+ ,style: 'roman'
-+ ,prefix: 'Front Matter,Front Matter Section'
-+ ,on_first_step: 'i'
-+ }
-+ ,appendix: {
-+ counter: 'RT·Section·counter·appendix'
-+ ,style: 'Alpha,CountingNumber'
-+ ,prefix: 'Appendix,Appendix Section'
-+ ,on_first_step: 'A'
-+ }
-+ };
-+
-+ ns.series_default = 'body';
-+
-+ /* The series is written on the outermost section of a division. Read it from
-+ the nearest ancestor that has one.
-+
-+ Sections are expanded in document order ,so by the time a nested section is
-+ reached its ancestors are already steps carrying data-RT-series. Both forms
-+ are checked ,since an ancestor may be either. */
-+ const resolve_series = function(section){
-+ let curr = section;
-+ while(curr){
-+ const declared = curr.getAttribute && (curr.getAttribute('series')
-+ || curr.getAttribute('data-RT-series'));
-+ if(declared){
-+ if(ns.series[declared]) return declared;
-+ window.RT.Debug.error('section'
-+ ,"unknown section series '" + declared + "'. Known series: "
-+ + Object.keys(ns.series).join(' ,') + ". Using '" + ns.series_default + "'.");
-+ return ns.series_default;
-+ }
-+ curr = curr.parentElement;
-+ }
-+ return ns.series_default;
-+ };
-+
- const apply_style = function(title_node ,depth ,config){
- const base_size = 2.25;
- const size = Math.max(1.1 ,base_size - (depth * 0.35));
-@@ -48,26 +117,38 @@
- if(section_seq.length === 0) return;
-
- const article = document.querySelector('RT·article, RT·memo');
-- const counter_name = 'RT·Section·counter';
-
-- // Check the global dictionary for existence rather than traversing the DOM
-- if(article && !U.Registry.has(ns, counter_name)){
-- const make = document.createElement('RT·counter·make');
-- make.setAttribute('counter' ,counter_name);
-- make.setAttribute('style' ,'CountingNumber');
-- make.setAttribute('mode' ,'scoped');
-- make.setAttribute('on-first-step' ,'0');
-- article.insertBefore(make ,article.firstChild);
--
-- // Register the physical node and its attributes into the global namespace
-- U.Registry.register_make(ns, counter_name, make, ['splitable']);
-- }
-+ /* One make tag per series ,emitted the first time that series is used. A
-+ series never referenced costs nothing and leaves no counter behind. */
-+ const counter_of = function(series_name){
-+ const spec = ns.series[series_name];
-+ const counter_name = spec.counter;
-+
-+ if(article && !U.Registry.has(ns ,counter_name)){
-+ const make = document.createElement('RT·counter·make');
-+ make.setAttribute('counter' ,counter_name);
-+ make.setAttribute('style' ,spec.style || 'CountingNumber');
-+ make.setAttribute('mode' ,'scoped');
-+ make.setAttribute('on-first-step' ,spec.on_first_step !== undefined ? spec.on_first_step : '0');
-+ if(spec.prefix) make.setAttribute('prefix' ,spec.prefix);
-+ article.insertBefore(make ,article.firstChild);
-+
-+ // Register the physical node and its attributes into the global namespace
-+ U.Registry.register_make(ns ,counter_name ,make ,['splitable']);
-+ }
-+
-+ return counter_name;
-+ };
-
- let section_idx = 0;
-
- section_seq.forEach(section => {
-+ const series_name = resolve_series(section);
-+ const spec = ns.series[series_name];
-+ const counter_name = counter_of(series_name);
-+
- // Utilize the abstracted structural depth utility
-- let depth = U.Dom.get_structural_depth(section, counter_name);
-+ let depth = U.Dom.get_structural_depth(section ,counter_name);
-
- if(depth === 0){
- if(!section.previousElementSibling?.tagName?.toLowerCase().includes('page-break')){
-@@ -80,10 +161,16 @@
-
- const step = document.createElement('RT·counter·step');
- step.setAttribute('counter' ,counter_name);
-+
-+ /* The series travels with the step ,so nested sections can read it and
-+ so the contents list can gather every division without knowing which
-+ counters exist. */
-+ step.setAttribute('data-RT-series' ,series_name);
-+ step.setAttribute('data-RT-section' ,'true');
-
- // Query the global dictionary for the splitable flag
-- if(U.Registry.has(ns[counter_name], 'splitable')) {
-- step.setAttribute('splitable', 'true');
-+ if( U.Registry.has(ns[counter_name] ,'splitable') ){
-+ step.setAttribute('splitable' ,'true');
- }
-
- step.id = snap_id;
-@@ -96,8 +183,17 @@
- const title_node = document.createElement('div');
- title_node.className = 'RT·section-title';
-
-+ /* Marked as a heading ,for the paginator's widow control. A title is a
-+ composed division rather than an <h1> ,so nothing about its tag says
-+ what it is; the mark says it. */
-+ title_node.setAttribute('data-RT-heading' ,'true');
-+
- const read_count = document.createElement('RT·counter·read');
- read_count.setAttribute('snapshot' ,snap_id);
-+ /* Prefix then number ,read in one tag: 'Appendix B' ,'Section 2.4'. The
-+ word is chosen by the counter from its own nesting depth ,so a section
-+ moved to another level is relabelled without being rewritten. */
-+ if(spec.prefix) read_count.setAttribute('key' ,'prefix count');
-
- const title_content = document.createElement('span');
- title_content.style.marginLeft = '0.75rem';
-diff -ru work.orig/Layout/article_tech_ref.js work/Layout/article_tech_ref.js
---- work.orig/Layout/article_tech_ref.js 2026-08-27 07:22:53.085573019 +0000
-+++ work/Layout/article_tech_ref.js 2026-08-27 09:16:14.277699603 +0000
-@@ -52,6 +52,12 @@
- // Apply viewport screen boundary color, defaulting to surface_0 if undefined
- const screen_bg = conf.surface_screen || conf.surface_0 || '#000000';
- document.documentElement.style.backgroundColor = screen_bg;
-+
-+ /* Remembered for the next opening ,where it is applied at parse time ,
-+ ahead of the first paint. This is the only place the resolved screen
-+ colour exists ,so it is the only place that can record it. */
-+ if(window.RT.screen_color_write) window.RT.screen_color_write(screen_bg);
-+
- document.body.style.backgroundColor = screen_bg;
- document.body.style.margin = "0"; // Prevent default browser margin bleeding
-
-diff -ru work.orig/Layout/counter.js work/Layout/counter.js
---- work.orig/Layout/counter.js 2026-08-27 07:22:53.085702401 +0000
-+++ work/Layout/counter.js 2026-08-27 09:16:14.278959577 +0000
-@@ -173,6 +173,11 @@
- this.separator_placement = 'embedded';
- this.mode = 'scoped';
-
-+ /* The word that precedes the number ,one per nesting level ,last entry
-+ repeating. Empty by default: a counter says nothing about what it
-+ counts unless told. */
-+ this.prefix = [];
-+
- if(config) this.write(config);
- }
-
-@@ -190,13 +195,19 @@
- if(this.mode === 'scoped' && status === 'between') return this.count.read('name' ,'short');
- return this.count.read('name');
- }
-+
-+ if(path[0] === 'prefix') return this.prefix_for(this.count);
-
- return path.reduce((acc ,key) => (acc && acc[key] !== undefined) ? acc[key] : undefined ,this);
- }
-
- write(dict){
- for(const [key ,value] of Object.entries(dict)){
-- if(key === 'style'){
-+ if(key === 'prefix'){
-+ this.prefix = Array.isArray(value)
-+ ? value.map(s => String(s).trim()).filter(s => s !== '')
-+ : String(value || '').split(',').map(s => s.trim()).filter(s => s !== '');
-+ }else if(key === 'style'){
- let parsed = Array.isArray(value) ? value : [value];
- if(parsed.length === 1 && parsed[0] === 'outline'){
- parsed = ['Roman' ,'Alpha' ,'roman' ,'alpha' ,'CountingNumber'];
-@@ -243,6 +254,30 @@
- }
- }
-
-+ /* The levels currently in force. A scoped counter sitting between two of
-+ its own steps has already pushed the level it is about to number ,so the
-+ innermost entry is not yet part of the value. Both the number and the
-+ word that precedes it are taken from this same list ,or the two would
-+ disagree at a scope boundary. */
-+ active_list_of(count_obj){
-+ const c = count_obj || this.count;
-+ const status = c.read('status');
-+ if(status === 'empty') return null;
-+ return (this.mode === 'scoped' && status === 'between')
-+ ? c.read('list' ,'short')
-+ : c.read('list');
-+ }
-+
-+ /* 'Chapter' ,'Section' ,'Appendix'. Chosen by depth ,with the last entry
-+ repeating ,so a two word list covers a document nested to any depth. */
-+ prefix_for(count_obj){
-+ if(!this.prefix || this.prefix.length === 0) return '';
-+ const active_list = this.active_list_of(count_obj);
-+ if(!active_list || active_list.length === 0) return '';
-+ const depth = Math.min(active_list.length ,this.prefix.length) - 1;
-+ return this.prefix[depth] || '';
-+ }
-+
- to_string(count_obj){
- if(!count_obj) return '';
-
-@@ -252,12 +287,7 @@
- return '[Empty Counter]';
- }
-
-- let active_list;
-- if(this.mode === 'scoped' && status === 'between'){
-- active_list = count_obj.read('list' ,'short');
-- }else{
-- active_list = count_obj.read('list');
-- }
-+ const active_list = this.active_list_of(count_obj);
-
- if(!active_list || active_list.length === 0) return '';
-
-@@ -337,6 +367,7 @@
- copy.separator = this.separator;
- copy.separator_placement = this.separator_placement;
- copy.mode = this.mode;
-+ copy.prefix = [...this.prefix];
- return copy;
- }
- }
-@@ -382,6 +413,7 @@
- ,separator: node.getAttribute('separator') || '.'
- ,separator_placement: node.getAttribute('separator-placement') || 'embedded'
- ,mode: node.getAttribute('mode') || 'scoped'
-+ ,prefix: node.getAttribute('prefix') || ''
- });
-
- const on_first_step_str = node.getAttribute('on-first-step');
-@@ -474,28 +506,42 @@
- process_read_node(reads[i]);
- }
-
-+ /* One field of a read. 'count' is the formatted number ,'prefix' the word
-+ that belongs in front of it ,and anything else is a path into the
-+ machine ,written with dots as before. */
-+ function read_field(machine ,field){
-+ if(field === 'count'){
-+ return machine.to_string(machine.read('count'));
-+ }
-+ if(field === 'prefix'){
-+ return machine.prefix_for(machine.count);
-+ }
-+
-+ const value = machine.read(...field.split('.'));
-+ if(value === null) return 'null';
-+ if(value === undefined) return `[Missing key: ${field}]`;
-+ return Array.isArray(value) ? value.join(',') : value;
-+ }
-+
- function process_read_node(node){
- const snapshot_name = node.getAttribute('snapshot');
- const key = node.getAttribute('key') || 'count';
-
- if(snapshot_name && ns.dict_snapshot[snapshot_name]){
- const snapshot_machine = ns.dict_snapshot[snapshot_name];
--
-- if(key === 'count'){
-- const raw_state = snapshot_machine.read('count');
-- node.innerHTML = snapshot_machine.to_string(raw_state);
-- }else{
-- const keys = key.split('.');
-- const value = snapshot_machine.read(...keys);
--
-- if(value === null){
-- node.innerHTML = 'null';
-- }else if(value !== undefined){
-- node.innerHTML = Array.isArray(value) ? value.join(',') : value;
-- }else{
-- node.innerHTML = `[Missing key: ${key}]`;
-- }
-- }
-+
-+ /* A read may name several fields ,separated by whitespace ,and they
-+ are emitted in the order written: key="prefix count" gives
-+ 'Appendix B' from one tag rather than two tags and a literal space
-+ the author has to keep in step with them. A single field ,which is
-+ every read written before this ,takes the same path and reads the
-+ same. Empty fields are dropped rather than leaving a hanging space:
-+ a counter with no prefix set reads as a bare number. */
-+ const list_part = key.trim().split(/\s+/)
-+ .map(field => read_field(snapshot_machine ,field))
-+ .filter(text => text !== '' && text !== undefined && text !== null);
-+
-+ node.innerHTML = list_part.join(' ');
- }else{
- node.innerHTML = `[Unknown snapshot: ${snapshot_name}]`;
- console.error(`RT-Manuscript Layout Error: <RT·counter·read> failed. No snapshot named '${snapshot_name}' found.`);
-diff -ru work.orig/Layout/paginate.js work/Layout/paginate.js
---- work.orig/Layout/paginate.js 2026-08-27 07:22:53.086095676 +0000
-+++ work/Layout/paginate.js 2026-08-27 09:16:57.681519535 +0000
-@@ -76,6 +76,45 @@
- return tag + (bits.length ? ' [' + bits.join(' ') + ']' : '');
- }
-
-+ /* ---------------------------------------------------------------
-+ What counts as a heading ,and what counts as nothing.
-+
-+ A heading is not content. It announces the content beneath it ,and a page
-+ that ends on one leaves the announcement on one leaf and the thing
-+ announced on the next. The paginator therefore has to recognize a heading
-+ when it sees one.
-+
-+ Tag name alone no longer answers this. Before sections were scoped and
-+ counted ,a heading was an <h1>–<h6> and the test could be a regular
-+ expression over the tag. A section title is now a composed division
-+ carrying counter reads ,so that test matches nothing and the widow
-+ control it guards has been silently inert since the change. Section titles
-+ are marked at construction instead ,and the mark is what is read here:
-+ the paginator does not need to know how a title is built.
-+
-+ 'Ghost' names a node that occupies no space — a snapshot ,a make tag ,a
-+ name tag ,a run of whitespace. They are not content ,so a fragment ending
-+ in a heading followed by ghosts still ends in a heading. Deciding this by
-+ tag rather than by measurement keeps it free.
-+ --------------------------------------------------------------- */
-+
-+ function is_heading(el){
-+ if(!el || el.nodeType !== Node.ELEMENT_NODE) return false;
-+ if( /^H[1-6]$/i.test(el.tagName || '') ) return true;
-+ return el.hasAttribute && el.hasAttribute('data-RT-heading');
-+ }
-+
-+ const Set_ghost_tag = new Set([
-+ 'rt·counter·snapshot' ,'rt·counter·make' ,'rt·name' ,'rt·note·write'
-+ ]);
-+
-+ function is_ghost(node){
-+ if(!node) return true;
-+ if(node.nodeType === Node.TEXT_NODE) return !node.textContent.trim();
-+ if(node.nodeType !== Node.ELEMENT_NODE) return true;
-+ return Set_ghost_tag.has((node.tagName || '').toLowerCase());
-+ }
-+
- let measure_container = null;
-
- // 1. DOM Measurement Utilities
-@@ -417,6 +456,43 @@
- }
- }
-
-+ /* Widow control.
-+
-+ A section fragment must not end on its own title ,nor on the title of a
-+ subsection it has only just opened. The cut is moved back above the
-+ heading ,which travels to the next page with the text it introduces.
-+
-+ Only the tail is examined ,and only when this scope cut its own child
-+ list. Where a child was itself split ,that child's own splitter has
-+ already applied this rule to its tail ,and the fragment ends inside the
-+ child rather than on a heading.
-+
-+ If nothing but the heading fitted ,no fragment is emitted at all: the
-+ whole scope moves on. The caller reads a null first as 'cannot be broken
-+ here' and either closes the page and retries with a full page ,or ,on a
-+ page that is already empty ,places the scope whole and grows the page.
-+ Both terminate ,and neither can return here with the same room twice.
-+ */
-+ if(!split_child_result && best_count > 0){
-+ let tail = best_count;
-+ while( tail > 0 && is_ghost(children[tail - 1]) ) tail--;
-+
-+ if( tail > 0 && is_heading(children[tail - 1]) ){
-+ trace_v(' -> fragment ends on ' + el_id(children[tail - 1])
-+ + ' ,moving the cut above it');
-+ best_count = tail - 1;
-+
-+ const kept = el.cloneNode(false);
-+ for(let i = 0; i < best_count; i++) kept.appendChild(children[i].cloneNode(true));
-+ best_height = best_count > 0 ? measure_fn(kept) : 0;
-+
-+ if( !(best_height > 0) ){
-+ trace_v(' -> nothing but the heading fits; the whole scope moves on');
-+ return { first: null ,rest: el ,firstHeight: 0 };
-+ }
-+ }
-+ }
-+
- /* Decide whether a remainder exists BEFORE marking the fragment.
-
- A fragment marked 'continued' is soft closed: the counter walk suppresses
-@@ -515,8 +591,20 @@
- });
-
- function paginate_article(article){
-+ /* An <RT·page> written by the author is kept ,not filtered away.
-+
-+ Some leaves are composed rather than flowed. A title page ,a
-+ dedication ,a plate: the author has decided what is on it and the
-+ paginator has no business measuring it or adding to it. Dropping such
-+ pages ,which is what excluding them here used to do ,silently lost
-+ whatever the author had put on them.
-+
-+ Written with no-number the leaf is neither numbered nor counted ,so a
-+ title page does not consume the number that belongs to the first page
-+ of text. Written plainly it takes its number in sequence like any
-+ other. */
- const raw_element_seq = Array.from(article.children).filter(el =>
-- !['SCRIPT' ,'STYLE' ,'RT·PAGE' ,'RT·COUNTER·MAKE'].includes((el.tagName || '').toUpperCase())
-+ !['SCRIPT' ,'STYLE' ,'RT·COUNTER·MAKE'].includes((el.tagName || '').toUpperCase())
- );
-
- const global_makes = Array.from(article.children).filter(el => (el.tagName || '').toUpperCase() === 'RT·COUNTER·MAKE');
-@@ -533,6 +621,21 @@
-
- while(i < raw_element_seq.length){
- const el = raw_element_seq[i];
-+
-+ // A composed leaf. It closes whatever page is open and stands as one.
-+ if( (el.tagName || '').toLowerCase() === 'rt·page' ){
-+ trace(el_id(el) + ' -> AUTHORED PAGE ,carried through whole'
-+ + (el.hasAttribute('no-number') ? ' ,unnumbered' : ''));
-+ if(current_h > 0){
-+ page_seq.push(current_batch_seq);
-+ current_batch_seq = [];
-+ current_h = 0;
-+ }
-+ page_seq.push(el);
-+ i++;
-+ continue;
-+ }
-+
- const splitter = is_splittable(el);
-
- if(splitter){
-@@ -625,7 +728,7 @@
-
- while(current_batch_seq.length > 0){
- const last = current_batch_seq[current_batch_seq.length - 1];
-- if(!last.tagName || !/^H[1-6]$/i.test(last.tagName)) break;
-+ if(!is_heading(last)) break;
- const popped = current_batch_seq.pop();
- backtrack_seq.unshift(popped);
- backtrack_h += get_el_height(popped);
-@@ -679,7 +782,7 @@
-
- while(current_batch_seq.length > 0){
- const last = current_batch_seq[current_batch_seq.length - 1];
-- if(!last.tagName || !/^H[1-6]$/i.test(last.tagName)) break;
-+ if(!is_heading(last)) break;
- const popped = current_batch_seq.pop();
- backtrack_seq.unshift(popped);
- backtrack_h += get_el_height(popped);
-@@ -721,14 +824,25 @@
- let p = 0;
- while(p < page_seq.length){
- const batch = page_seq[p];
-- const page_el = document.createElement('RT·page');
-+ const is_authored = !Array.isArray(batch);
-+ const page_el = is_authored ? batch : document.createElement('RT·page');
-
- page_el.style.minHeight = page_height_limit + 'px';
- page_el.style.position = 'relative';
- page_el.style.paddingBottom = '5rem';
- page_el.style.boxSizing = 'border-box';
-
-- batch.forEach(item => page_el.appendChild(item));
-+ if(!is_authored) batch.forEach( item => page_el.appendChild(item) );
-+
-+ /* An unnumbered leaf takes no step ,so the counter does not advance
-+ across it and the leaf after it holds the number this one would have
-+ taken. Not counted rather than counted and hidden ,which is what a
-+ title page wants: the reader's page one is the first page of text. */
-+ if(is_authored && page_el.hasAttribute('no-number')){
-+ article.appendChild(page_el);
-+ p++;
-+ continue;
-+ }
-
- const page_step = document.createElement('RT·counter·step');
- page_step.setAttribute('counter' ,'RT_page_number');