cleans up loading, progress bar doesn't work, but great improvement
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Thu, 27 Aug 2026 10:05:45 +0000 (10:05 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Thu, 27 Aug 2026 10:05:45 +0000 (10:05 +0000)
developer/authored/Manuscript.copy/Core/stage_manager.js
developer/authored/Manuscript.copy/Core/theme_make.js
developer/authored/Manuscript.copy/Layout/article_tech_ref.js
developer/authored/Manuscript.copy/patch/CHANGES.md [deleted file]
developer/authored/Manuscript.copy/patch/RT-Style.patch [deleted file]

index 3fdcb95..2009bbf 100644 (file)
   // =========================================================
 
   /* ---------------------------------------------------------------
-     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);
 
 })();
index 127dc82..f991aaf 100644 (file)
         }
 
         apply_and_validate_theme(RT.theme_library[theme_name] ,fallback);
+
+        /* The screen colour ,put to work immediately.
+
+           This runs at parse time — the author calls RT.theme_preference in
+           the head — so the canvas is coloured before the first paint rather
+           than when the layout configuration is compiled ,which does not
+           happen until the whole document has parsed. On a book carrying
+           MathJax that difference is the several seconds of white the reader
+           was being shown. Nothing here computes the colour: it is read from
+           the dictionary that has just been filled. */
+        if(RT.screen_color_apply){
+          RT.screen_color_apply(dictionary.surface.screen || dictionary.surface[0]);
+        }
         
         // --- CSS Fallback for Pseudo-Elements and External Libraries ---
         let style_el = document.getElementById('RT·theme-custom-css');
index ac5e73f..d8d3c99 100644 (file)
     
     // 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);
+    /* The same colour the theme already applied at parse time ,by a longer
+       road. Repeated because a theme changed at run time arrives here and not
+       through the theme loader ,and because it costs one rule rewrite. */
+    if(window.RT.screen_color_apply) window.RT.screen_color_apply(screen_bg);
+
+    document.documentElement.style.backgroundColor = screen_bg;
 
     document.body.style.backgroundColor = screen_bg;
     document.body.style.margin = "0"; // Prevent default browser margin bleeding
diff --git a/developer/authored/Manuscript.copy/patch/CHANGES.md b/developer/authored/Manuscript.copy/patch/CHANGES.md
deleted file mode 100644 (file)
index 16c994c..0000000
+++ /dev/null
@@ -1,274 +0,0 @@
-# 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.
diff --git a/developer/authored/Manuscript.copy/patch/RT-Style.patch b/developer/authored/Manuscript.copy/patch/RT-Style.patch
deleted file mode 100644 (file)
index 4637f68..0000000
+++ /dev/null
@@ -1,972 +0,0 @@
-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');