fixes widowed headings, still loads is funny and slow
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Thu, 27 Aug 2026 09:42:47 +0000 (09:42 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Thu, 27 Aug 2026 09:42:47 +0000 (09:42 +0000)
13 files changed:
developer/authored/Manuscript.copy/Core/stage_manager.js
developer/authored/Manuscript.copy/Core/utility.js
developer/authored/Manuscript.copy/Element/TOC.js
developer/authored/Manuscript.copy/Element/grid.js
developer/authored/Manuscript.copy/Element/section.js
developer/authored/Manuscript.copy/Layout/article_tech_ref.js
developer/authored/Manuscript.copy/Layout/counter.js
developer/authored/Manuscript.copy/Layout/paginate.js
developer/authored/Manuscript.copy/patch/CHANGES.md [new file with mode: 0644]
developer/authored/Manuscript.copy/patch/RT-Style.patch [new file with mode: 0644]
developer/document/RT-code-format.html
developer/document/debug_vars.txt
developer/document/todo.txt

index e09d272..3fdcb95 100644 (file)
     ,'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] = []; });
 
   // 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";
   function unlock_layout(){
     if(!is_layout_locked) return;
     is_layout_locked = false;
+
+    progress_end();
     
     document.documentElement.style.visibility = "";
     window.removeEventListener("load" ,unlock_layout);
     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();
   }
 
   // =========================================================
   // =========================================================
 
   lock_layout();
+  prepaint_screen();
   configure_history();
   capture_scroll_target();
   bind_window_events();
index 905e920..165caf0 100644 (file)
@@ -215,6 +215,50 @@ window.RT.Utility = {
 
   // 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 @@ window.RT.Utility = {
          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);
index ee6c107..b125842 100644 (file)
         }
       }
 
+      /* 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;
index ce18eb4..69b6dbc 100644 (file)
       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) {
       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);
   }
 
 
     }
   }
 
+  /* 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;
index 8a9c035..0184018 100644 (file)
 
   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));
     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(sectioncounter_name);
+      let depth = U.Dom.get_structural_depth(section ,counter_name);
 
       if(depth === 0){
         if(!section.previousElementSibling?.tagName?.toLowerCase().includes('page-break')){
       
       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; 
       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';
index e3e6e0d..ac5e73f 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);
+
     document.body.style.backgroundColor = screen_bg;
     document.body.style.margin = "0"; // Prevent default browser margin bleeding
     
index 36c4cfa..64bb5bb 100644 (file)
       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);
     }
 
         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'];
       }
     }
 
+    /* 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 '';
       
           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 '';
 
       copy.separator = this.separator;
       copy.separator_placement = this.separator_placement;
       copy.mode = this.mode;
+      copy.prefix = [...this.prefix];
       return copy;
     }
   }
               ,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');
       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.`);
index 34d9866..50cdff6 100644 (file)
     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
       }
     }
 
+    /* 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
     });
 
     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');
 
       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){
               
               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);
           
           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);
       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');
diff --git a/developer/authored/Manuscript.copy/patch/CHANGES.md b/developer/authored/Manuscript.copy/patch/CHANGES.md
new file mode 100644 (file)
index 0000000..16c994c
--- /dev/null
@@ -0,0 +1,274 @@
+# 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
new file mode 100644 (file)
index 0000000..4637f68
--- /dev/null
@@ -0,0 +1,972 @@
+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');
index 0bf33a5..e42ee2e 100644 (file)
@@ -44,7 +44,7 @@
       <h2>Case</h2>
       <ul>
         <li>Types, modules: <RT·code>PascalCase</RT·code></li>
-        <li>Functions, variables: <RT·code>snake-kebab_case</RT·code>. If a language does not allow the <RT·code>-</RT·code> character in identifiers, then <RT·code>snake-kebab_case</RT·code> reduces to RT·code>snake_case</RT·code>.</li>
+        <li>Functions, variables: <RT·code>snake-kebab_case</RT·code>. If a language does not allow the <RT·code>-</RT·code> character in identifiers, then <RT·code>snake-kebab_case</RT·code> reduces to <RT·code>snake_case</RT·code>.</li>
         <li>Globals: <RT·code>UPPER_SNAKE-KEBAB_CASE</RT·code>, that, reduces to <RT·code>UPPER_SNAKE_CASE</RT·code> when the language does not allow the <RT·code>-</RT·code> character in identifiers.</li>
       </ul>
 
index 4e6a6a1..7ea9e77 100644 (file)
@@ -1,54 +1,4 @@
 RT.Debug.enable('paginate')     // one line per element with its reason
 RT.Debug.enable('paginate_v')   // per-child measurement inside a split
 
-Pass 0 — survey, no changes. I read the manuscript and report: what tags it uses, where it diverges from current conventions, what's broken, how big it is, what it needs that the engine doesn't have yet. You get an inventory before committing to anything.
 
-Pass 1 — mechanical conformance. RT code format, tag spellings, heading capitalization, the may/just rule. Mostly scriptable, entirely verifiable, no judgement about content.
-
-Pass 2 — structure. Sections, counters, TOC, and whatever the book needs that we haven't built — figures and theorem numbering being the obvious candidates for a computation theory text. This is where the engine likely grows.
-
-Pass 3 — render and fix. The real test bed. Expect layout faults we haven't seen, since a book exercises depths and lengths the manuals don't.
-
-Pass 4 — copy editing, a chapter at a time. Slowest, most judgement-dependent, and best done once everything else is stable.
-
-
----
-
-No update tool
-
--> we will manually update too complex for a script
-
-README.md is stale — it advertises <RT-article>, <RT-title>, <RT-constraint> in hyphen form. RT-constraint doesn't exist in the engine under either spelling.
-
--> remind me later we will get back to this.
-
-
-<RT·book> is documented but unimplemented. The user manual lists it as a manuscript type alongside article and memo; nothing in the engine references it. For a 4-volume work this is probably the single most consequential gap.
-
--> ignore
-
-User manual omissions: RT·term-em and RT·neologism-em (both live in term.js, and the book uses term-em 5 times), RT·noop, RT·e, RT·label, RT·tuple/RT·tuple-meta, RT·vector/RT·vector-meta, and the splitable attribute.
-
--> I am surprised they are not in there, apparently they got dropped, remind me later we will get back to this.
-
-to_pdf.py is dead against current documents — it regex-rewrites <RT-term-em>, <RT-term>, etc. in hyphen form, which matches nothing in a v4/v5 file. to_pdf2.py has the same problem on 8 lines. Two PDF tools with unclear precedence is worth resolving.
-
--> it has been removed from the project.
-
-
-RT-code-format.html has a broken tag: RT·code>snake_case is missing its opening <. Renders as literal text.
-
-term.js violates its own migration note. The design manual's migration notes say to drop dual-spelling selectors in favor of the canonical form; term.js uses the all-lowercase form exclusively ('rt·term, rt·term-em, …'), as does footnote.js. Worth a consistency sweep.
-
-todo.txt triage. Still open and relevant to the book: paginate's ad-hoc page numbering vs. the counters; unifying footnote/endnote under Note with path keys; the Count/Note capitalization and space-vs-dot key path convention; justified text inserting spaces into inline RT blocks; table types. The theme-name/manifest mismatch item at the top appears already fixed — all three themes' meta.name match their manifest keys and filenames. Close it.
-
-footnote.js is a validation stub (processing lives in the paginator). Fine, but the user manual presents it as a peer of endnote.
-
-Q
-The title. The file is TM-2026.html, titled "Computational Naturalism", and there's a chapter called "Or, does it go the other way? Computational Naturalism". You described this as volume 1 of Tom's Turing Complete Computer Architecture. Is "Computational Naturalism" the volume-1 title, a working title, or a leftover from a reorganization?
-
-RT·chapter — implement or convert? Given <RT·book> is also unimplemented and you have three more volumes coming, I'd lean toward building RT·book and RT·chapter properly in the engine rather than flattening chapters into depth-0 sections. That's more work now and much less later. But it's your call, and the section route gets a readable book faster.
-
-Lists and blockquotes. 29 raw ones in the book. Add RT elements, or accept raw HTML as legitimate for these?
-
-Order of operations. Do you want me to start with the engine gaps (so the migration has a target), or with a mechanical first pass on the book (theme-selector, copyright, the comment, the <pre> blocks) to get it rendering before we touch structure?
index 5f50f7d..23e0d0b 100644 (file)
 2026-07-31 03:39:54 Z
   The line formatting is inserting spaces into inline RT blocks when ext is justified.
   The tables need work, perhaps a selection of table types. Both vertical formatting and justification.
+
+2026-08-08 09:12:25 Z
+  center the figure, table, list, code, captions. Math hmm. typical eq 3 rather than caption.
+
+   sometimes the screen stays blindingly white while the document comes up, I would prefer that it never even flashes.
+
+   boy things have gotten slow for loading the book, probably due to the shrink wrap before justification for table entries.
+