From: Thomas Walker Lynch Date: Thu, 27 Aug 2026 10:05:45 +0000 (+0000) Subject: cleans up loading, progress bar doesn't work, but great improvement X-Git-Url: https://git.reasoningtechnology.com/Realizable_Machine.svg?a=commitdiff_plain;h=38a0da1372f2fb7297c8d3b9b7f8879560ca802d;p=RT-Style cleans up loading, progress bar doesn't work, but great improvement --- diff --git a/developer/authored/Manuscript.copy/Core/stage_manager.js b/developer/authored/Manuscript.copy/Core/stage_manager.js index 3fdcb95..2009bbf 100644 --- a/developer/authored/Manuscript.copy/Core/stage_manager.js +++ b/developer/authored/Manuscript.copy/Core/stage_manager.js @@ -130,156 +130,229 @@ // ========================================================= /* --------------------------------------------------------------- - The white page. - - The canvas takes its colour from the root element ,and until the theme is - compiled the root has no colour ,so the browser paints its own default. - Firefox does this readily ,and a book that opens on a sheet of white - before turning black is worse than one that takes a moment longer to - open: the flash lands before the reader has focused on anything. - - Two measures ,because neither alone is enough. - - The colour last used is remembered and applied here ,at parse time ,ahead - of the first paint. A reader returning to a book — which is nearly every - opening after the first — never sees white at all. - - A transition is armed at the same moment ,for the opening where nothing is - remembered. There the colour arrives later ,when the theme compiles ,and - it arrives as a fade rather than as a cut. A fade from white is a change - of light; a cut from white is a flash ,and the eye reads the two quite - differently. - - The remembered colour is a hint and nothing more. If it is wrong ,which it - is when the reader has changed theme since ,the theme overwrites it within - the same second and the error shows as a fade. + The white page ,and the blank that follows it. + + Two faults ,and they were being treated as one. + + The first is that the canvas takes its colour from the root ,and until + something sets it the browser paints its own default. The colour was being + applied when the theme compiled ,in the element phase — after the whole + document had parsed. On a book carrying MathJax that is seconds of white. + But the colour is knowable far earlier than that: RT.theme_preference runs + in the head ,at parse time ,and RT.load's document.write ordering means + every theme is loaded before it. So the theme applies its screen colour + the moment it resolves ,and the canvas is never white at all. Remembering + the last colour was a workaround for a problem that did not need one; it + is kept only for the window before the theme call ,where it costs nothing. + + The second is what the reader is shown while the work is done. Hiding the + root hides the progress panel with it — visibility inherits — and leaves + the canvas background in doubt ,since a root with visibility hidden is a + poor thing to be relying on to paint. So the lock is a class on the root + that hides the contents of the body and excepts the panel. Backgrounds + paint normally ,because nothing about the root is hidden any more. + + Both live in one stylesheet ,written into the head at parse time. --------------------------------------------------------------- */ - // Namespaced as the theme preference beside it is ,and for the same store. + const boot_style_id = 'RT·boot-style'; const screen_color_key = 'RT-Manuscript·screen_color'; + const lock_class = 'RT·locked'; + + function boot_style(){ + let el = document.getElementById(boot_style_id); + if(el) return el; + el = document.createElement('style'); + el.id = boot_style_id; + el.textContent = boot_style_text(''); + (document.head || document.documentElement).appendChild(el); + return el; + } + + /* The panel is animated by CSS and not by script ,which is the whole of why + it works. A phase holds the main thread from beginning to end ,so anything + driven from script stops dead for the length of it — which is exactly the + interval the reader most needs to see movement in. Transform and opacity + animate off the main thread ,so they keep running while a phase blocks. + + The bar creeps rather than reports. It is not tied to the phases and does + not claim to be: it eases toward the end without arriving ,which is honest + about not knowing how long the work will take ,and it is always moving , + which is the one thing the reader needs to see. + + The panel fades in on a delay ,so a book that formats quickly never shows + it. That is done in the animation rather than in a timer for the same + reason as the bar: a timer would not fire. */ + function boot_style_text(screen_color){ + return (screen_color ? 'html ,body{ background-color:' + screen_color + '; }\n' : '') + + 'html.' + lock_class + ' body > *{ visibility:hidden; }\n' + + 'html.' + lock_class + ' #RT·progress{ visibility:visible; }\n' + + '#RT·progress{ position:fixed; top:0; left:0; right:0; bottom:0;' + + ' z-index:2147483647; display:flex; flex-direction:column;' + + ' align-items:center; justify-content:center; gap:1.1rem;' + + ' pointer-events:none; color:#8a8a8a; opacity:0;' + + " font:400 1rem/1.4 'Noto Sans JP' ,Arial ,sans-serif;" + + ' animation:RT·progress-appear 300ms ease-out 400ms forwards; }\n' + + '#RT·progress .RT·progress-label{ letter-spacing:0.08em; }\n' + + '#RT·progress .RT·progress-dot{ opacity:0.15;' + + ' animation:RT·progress-blink 1.4s ease-in-out infinite; }\n' + + '#RT·progress .RT·progress-dot:nth-child(2){ animation-delay:0.2s; }\n' + + '#RT·progress .RT·progress-dot:nth-child(3){ animation-delay:0.4s; }\n' + + '#RT·progress .RT·progress-track{ width:min(18rem ,60vw); height:2px;' + + ' background:currentColor; opacity:0.2; overflow:hidden; }\n' + + '#RT·progress .RT·progress-bar{ width:100%; height:100%;' + + ' background:currentColor; transform:scaleX(0);' + + ' transform-origin:left center;' + + ' animation:RT·progress-creep 40s cubic-bezier(0 ,0.7 ,0.15 ,1) forwards; }\n' + + '#RT·progress .RT·progress-time{ font-size:0.8rem; opacity:0.5;' + + ' font-variant-numeric:tabular-nums; }\n' + + '@keyframes RT·progress-appear{ to{ opacity:1; } }\n' + + '@keyframes RT·progress-blink{ 0% ,100%{ opacity:0.15; } 50%{ opacity:0.9; } }\n' + + '@keyframes RT·progress-creep{ to{ transform:scaleX(0.96); } }\n'; + } + + /* The screen colour ,written where it takes effect before the first paint. + Given to this from the theme the moment the theme resolves ,and again from + the layout configuration later ,which is the same colour by a longer road + and costs nothing to repeat. */ + window.RT.screen_color_apply = function(color){ + if(!color) return; + boot_style().textContent = boot_style_text(color); + try{ localStorage.setItem(screen_color_key ,color); }catch(e){} + }; - /* The two ends of the copy ,named for what they are. Both swallow their - faults: a store that refuses to answer is a reason to fall back on the - fade ,not a reason to stop opening the book. */ function screen_color_read(){ try{ return localStorage.getItem(screen_color_key); } catch(e){ return null; } } function prepaint_screen(){ - const root = document.documentElement; + boot_style(); const color = screen_color_read(); - - if(color) root.style.backgroundColor = color; - - /* Armed after the remembered colour is set ,so that colour lands - instantly and only a correction fades. */ - root.style.transition = 'background-color 400ms ease-out'; + if(color) window.RT.screen_color_apply(color); } - // Written by whoever resolves the theme ,read on the next opening. - window.RT.screen_color_write = function(color){ - if(!color) return; - try{ localStorage.setItem(screen_color_key ,color); }catch(e){} - }; - /* --------------------------------------------------------------- Telling the reader that the wait is work. - A blank that lasts four seconds and a blank that has hung are the same + A blank that lasts eight seconds and a blank that has hung are the same blank. The reader cannot tell them apart ,so they reload ,which starts the - four seconds again. - - An elapsed count answers it: a number that is moving is a machine that is - working. The phase is named alongside it ,which costs nothing and means a - slow book can be reported on precisely. - - The panel is only raised if the wait is long enough to be noticed. A short - book formats in less time than it takes to read the word 'formatting' ,and - raising a panel for that would be its own flicker. + eight seconds again. - Visibility is set explicitly. The root is hidden ,and visibility inherits , - so a descendant that does not overrule it is hidden with everything else. - The background is left clear so the screen colour shows through and the - panel appears to sit on the page rather than over it. + The panel is raised as early as there is a body to hang it on ,which is + during parsing and well before the pipeline begins. It is not raised on a + timer ,because a timer does not fire while a phase holds the thread ,and + the phases are the whole of the wait. --------------------------------------------------------------- */ - const progress = { - panel: null ,elapsed: null ,phase: null - ,start: 0 ,timer: 0 ,label: '' ,step: 0 ,raised: false - }; - - const progress_delay_ms = 500; - - function progress_raise(){ - if(progress.raised || !document.body) return; - progress.raised = true; + const progress = { panel: null ,time: null ,start: 0 ,timer: 0 }; + function progress_make(){ const panel = document.createElement('div'); panel.id = 'RT·progress'; - panel.style.cssText = - 'position:fixed; top:0; left:0; right:0; bottom:0; z-index:2147483647;' - + ' visibility:visible; pointer-events:none; background:transparent;' - + ' display:flex; flex-direction:column; align-items:center;' - + ' justify-content:center; gap:0.6rem; text-align:center;' - + " font:400 1rem/1.4 'Noto Sans JP', Arial, sans-serif; color:#8a8a8a;"; - const elapsed = document.createElement('div'); - elapsed.style.cssText = 'font-size:1.5rem; font-variant-numeric:tabular-nums;'; + const label = document.createElement('div'); + label.className = 'RT·progress-label'; + label.appendChild(document.createTextNode('Loading')); + for(let i = 0; i < 3; i++){ + const dot = document.createElement('span'); + dot.className = 'RT·progress-dot'; + dot.textContent = ' .'; + label.appendChild(dot); + } - const phase = document.createElement('div'); - phase.style.cssText = 'font-size:0.85rem; opacity:0.75;'; + const track = document.createElement('div'); + track.className = 'RT·progress-track'; + const bar = document.createElement('div'); + bar.className = 'RT·progress-bar'; + track.appendChild(bar); - panel.appendChild(elapsed); - panel.appendChild(phase); - document.body.appendChild(panel); + const time = document.createElement('div'); + time.className = 'RT·progress-time'; - progress.panel = panel; - progress.elapsed = elapsed; - progress.phase = phase; - progress_paint(); - } + panel.appendChild(label); + panel.appendChild(track); + panel.appendChild(time); - function progress_paint(){ - if(!progress.raised) return; - const seconds = (performance.now() - progress.start) / 1000; - progress.elapsed.textContent = 'Formatting ' + seconds.toFixed(1) + ' s'; - progress.phase.textContent = progress.label - ? progress.label + ' (' + progress.step + ' of ' + window.RT.Phase.length + ')' - : ''; + progress.panel = panel; + progress.time = time; + return panel; } - function progress_begin(){ + /* Raised on the first frame at which a body exists. Frames are served while + the document is still parsing ,so on a long head — a book carrying MathJax + has one — the panel is up before the pipeline has been reached. */ + function progress_raise(){ + if(progress.panel) return; progress.start = performance.now(); - /* The pipeline holds the main thread for the length of a phase ,so this - advances the count at phase boundaries and not within them. A phase that - runs long shows a still number under a moving phase name ,which is - honest about where the time is going. */ - progress.timer = setInterval(() => { - if( !progress.raised && performance.now() - progress.start > progress_delay_ms ){ - progress_raise(); + + const attempt = function(){ + if(!is_layout_locked) return; + if(document.body){ + document.body.appendChild(progress_make()); + return; } - progress_paint(); - } ,100); + requestAnimationFrame(attempt); + }; + requestAnimationFrame(attempt); } - function progress_report(phase_name ,step){ - progress.label = phase_name; - progress.step = step; - progress_paint(); + /* The count advances at phase boundaries and not within them ,since a phase + holds the thread. The bar carries the motion; this carries the magnitude. */ + function progress_report(){ + if(!progress.time) return; + const seconds = (performance.now() - progress.start) / 1000; + progress.time.textContent = seconds.toFixed(1) + ' s'; } function progress_end(){ - if(progress.timer) clearInterval(progress.timer); - progress.timer = 0; - if(progress.panel && progress.panel.parentNode) progress.panel.remove(); + const panel = progress.panel; progress.panel = null; - progress.raised = false; + progress.time = null; + if(progress.timer){ clearTimeout(progress.timer); progress.timer = 0; } + if(!panel || !panel.parentNode) return; + + /* Filled and faded rather than snatched away. An animation outranks an + inline declaration ,so each is stood down before its property is set. */ + const bar = panel.querySelector('.RT·progress-bar'); + if(bar){ + bar.style.animation = 'none'; + bar.style.transition = 'transform 160ms ease-out'; + bar.style.transform = 'scaleX(1)'; + } + panel.style.animation = 'none'; + panel.style.transition = 'opacity 220ms ease-out'; + panel.style.opacity = '0'; + progress.timer = setTimeout(() => panel.remove() ,260); } function lock_layout(){ is_layout_locked = true; - document.documentElement.style.visibility = "hidden"; + document.documentElement.classList.add(lock_class); + } + + /* The safety net must not fire while the pipeline is still working. + + It used to be harmless. The pipeline ran to completion inside the + DOMContentLoaded handler ,so by the time load fired there was nothing left + to protect and unlocking was a no-op. Yielding between phases broke that: + load now arrives in the middle of the run — usually within a frame or two + of DOMContentLoaded — and the net would lift the curtain on a document + that had been chunked into pages but not yet counted or resolved. + + That is precisely the fault of a title page appearing alone against a + black field ,with the rest of the book arriving some seconds later. It was + not slow rendering being glimpsed. It was the curtain going up early ,and + the pipeline then finishing in plain view. + + So the net catches only the case it was meant for: a pipeline that never + started. One that has started ends by unlocking itself ,whether its + phases succeed or fail ,since every task is already run inside a guard. */ + let pipeline_state = 'idle'; + + function unlock_on_load(){ + if(pipeline_state === 'running') return; + unlock_layout(); } function unlock_layout(){ @@ -287,9 +360,9 @@ is_layout_locked = false; progress_end(); - - document.documentElement.style.visibility = ""; - window.removeEventListener("load" ,unlock_layout); + + document.documentElement.classList.remove(lock_class); + window.removeEventListener("load" ,unlock_on_load); document.dispatchEvent(new Event("RT_layout_complete")); } @@ -422,19 +495,21 @@ pieces is a different order of change. */ function run_pipeline(){ - progress_begin(); + if(pipeline_state !== 'idle') return; + pipeline_state = 'running'; let index = 0; const step = function(){ if(index >= window.RT.Phase.length){ + pipeline_state = 'done'; progress_end(); if(is_layout_locked) resolve_scroll_target(); return; } const phase_name = window.RT.Phase[index++]; - progress_report(phase_name ,index); + progress_report(); next_frame(function(){ window.RT.Debug.log('stage' ,'phase: ' + phase_name); @@ -461,13 +536,21 @@ lock_layout(); prepaint_screen(); + progress_raise(); configure_history(); capture_scroll_target(); bind_window_events(); - document.addEventListener('DOMContentLoaded' ,run_pipeline); + /* A script that arrives after the document has been parsed never sees + DOMContentLoaded ,and would wait for an event that has already gone by. + The URL-only locator can land in exactly that position. */ + if(document.readyState === 'loading'){ + document.addEventListener('DOMContentLoaded' ,run_pipeline); + }else{ + run_pipeline(); + } - // Safety net: restore visibility on load if the layout engine hangs - window.addEventListener("load" ,unlock_layout); + // Safety net: restore visibility on load if the pipeline never started. + window.addEventListener("load" ,unlock_on_load); })(); diff --git a/developer/authored/Manuscript.copy/Core/theme_make.js b/developer/authored/Manuscript.copy/Core/theme_make.js index 127dc82..f991aaf 100644 --- a/developer/authored/Manuscript.copy/Core/theme_make.js +++ b/developer/authored/Manuscript.copy/Core/theme_make.js @@ -102,6 +102,19 @@ } apply_and_validate_theme(RT.theme_library[theme_name] ,fallback); + + /* The screen colour ,put to work immediately. + + This runs at parse time — the author calls RT.theme_preference in + the head — so the canvas is coloured before the first paint rather + than when the layout configuration is compiled ,which does not + happen until the whole document has parsed. On a book carrying + MathJax that difference is the several seconds of white the reader + was being shown. Nothing here computes the colour: it is read from + the dictionary that has just been filled. */ + if(RT.screen_color_apply){ + RT.screen_color_apply(dictionary.surface.screen || dictionary.surface[0]); + } // --- CSS Fallback for Pseudo-Elements and External Libraries --- let style_el = document.getElementById('RT·theme-custom-css'); diff --git a/developer/authored/Manuscript.copy/Layout/article_tech_ref.js b/developer/authored/Manuscript.copy/Layout/article_tech_ref.js index ac5e73f..d8d3c99 100644 --- a/developer/authored/Manuscript.copy/Layout/article_tech_ref.js +++ b/developer/authored/Manuscript.copy/Layout/article_tech_ref.js @@ -51,12 +51,13 @@ // Apply viewport screen boundary color, defaulting to surface_0 if undefined const screen_bg = conf.surface_screen || conf.surface_0 || '#000000'; - document.documentElement.style.backgroundColor = screen_bg; - /* Remembered for the next opening ,where it is applied at parse time , - ahead of the first paint. This is the only place the resolved screen - colour exists ,so it is the only place that can record it. */ - if(window.RT.screen_color_write) window.RT.screen_color_write(screen_bg); + /* The same colour the theme already applied at parse time ,by a longer + road. Repeated because a theme changed at run time arrives here and not + through the theme loader ,and because it costs one rule rewrite. */ + if(window.RT.screen_color_apply) window.RT.screen_color_apply(screen_bg); + + document.documentElement.style.backgroundColor = screen_bg; document.body.style.backgroundColor = screen_bg; document.body.style.margin = "0"; // Prevent default browser margin bleeding diff --git a/developer/authored/Manuscript.copy/patch/CHANGES.md b/developer/authored/Manuscript.copy/patch/CHANGES.md deleted file mode 100644 index 16c994c..0000000 --- a/developer/authored/Manuscript.copy/patch/CHANGES.md +++ /dev/null @@ -1,274 +0,0 @@ -# RT-Style — six amendments - -Eight files touched. About 490 lines added, 60 removed, and most of the -addition is commentary. Nothing was restructured. - -Drop `Manuscript.copy/` over `developer/authored/Manuscript.copy/` and build, -or apply `RT-Style.patch` from that directory. - - cd developer/authored/Manuscript.copy && patch -p1 < RT-Style.patch - ---- - -## 1. Orphan control and sections - -**Cause.** `paginate.js` asked whether an element was a heading by matching its -tag against `H[1-6]`. A section title stopped being an `

` 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: - - - Notation - Symbols - - -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: - - → Appendix B - → 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 `` 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. - - - - - -`no-number` takes no counter step, so the counter does not advance across the -leaf. Not counted, rather than counted and hidden: the reader's page one is -the first page of text. Written plainly, the leaf takes its number in sequence -like any other. - -## 4. Rendering time - -**Where it went.** Not in the shrink wrapping. In the length of the document -behind it. Setting a width on a label in the flow dirties it, which changes -its height, which moves everything below, and the browser lays out the rest of -the book before it will answer. A shrink wrap asks about a dozen such -questions per label; a hundred labels is a thousand full layouts. The cost -scales with book length rather than table count, which is why it read as -general slowness rather than as slow tables. - -**Change.** `RT.Utility.Dom.measure_host(context_el)` returns a positioned, -contained host appended to the element's real parent at that parent's content -width. Out of flow, so nothing below it moves. A child of the real parent, so -font, size, weight and colour are inherited exactly, and the wrapping measured -is the wrapping that will be rendered — measuring somewhere convenient instead -would answer a question about a document that does not exist. - -`grid.js` gains `place_and_size`, which inverts the order: measure in the host, -*then* `replaceWith`. Both results are explicit lengths — a frozen column -template, a pixel width per label — so they survive the move unchanged. Where -no host can be established the old order stands: measuring in place is slow, -measuring at the wrong width is wrong, and slow is better. - -The first-line widening probe drops from 16 trials to 6. Widths that satisfy -the test come in runs rather than as isolated points, since a range of widths -keeps the same line breaks, so the coarser step finds nearly all of them. Where -it steps over one the balanced width stands and the loss is a short first line. - -**On the rest of your thinking.** Revealing before cleanup is done — see 6. -On rendering pages in blocks: I would leave it. Growth is already local and -terminal by design, and block rendering reintroduces exactly the coupling that -reasoning was built to avoid. Cross references are the smaller problem and -they are already last. - -**Not done.** The two long phases, `element` and `paginate_0`, still hold the -thread from beginning to end. Cutting a phase into resumable pieces is the -larger prize and a different order of change. - -## 5. The white page - -The canvas takes its colour from the root, and until the theme compiles the -root has no colour, so the browser paints its own default. - -Two measures, because neither alone is enough. - -The resolved screen colour is remembered in `localStorage` and applied in -`stage_manager` at parse time, ahead of the first paint. Every opening after -the first shows no white at all. - -A transition is armed at the same moment, for the first opening, where the -colour arrives late — and now arrives as a fade rather than as a cut. A fade -from white is a change of light; a cut from white is a flash, and the eye reads -the two quite differently. If the remembered colour is wrong because the -reader has changed theme since, the correction shows as a fade too. - -## 6. The timer, and letting the reader in - -`run_pipeline` now runs one phase per turn with two frames between them. No -phase is split and the order is unchanged; what changes is that the thread is -given back between them, which is the only moment the browser has to paint. - -A panel appears after 500ms — not sooner, or a short book gets its own flicker -— showing elapsed seconds and the phase name against the phase count. It sets -`visibility:visible` explicitly, since the root is hidden and visibility -inherits, and leaves its background clear so the screen colour shows through. - -**Honest limitation.** The count advances at phase boundaries, not within them. -A long `element` or `paginate_0` shows a still number under a moving phase -name. That is at least honest about where the time is going, and it is enough -to tell a working machine from a hung one. - -**Reveal.** `RT.Phase_reveal = 'note'`. The document is readable once the notes -resolve; everything after only grows leaves that overflowed. Scroll is settled -first, or the reader would be shown the top of the book and then moved. Set to -`null` to hold the blank until every phase finishes — worth doing if a late -phase ever gains the power to move content rather than only to grow pages. - ---- - -## Tests - - npm install jsdom - node test/test_counter.js # 22 — series, prefixes, list reads - node test/test_widow.js # 11 — widow control, and what it must not change - node test/test_page.js # 10 — authored leaves, numbered and not - -43 passing. jsdom reports every height as zero, so `test_widow` and `test_page` -declare heights on a `data-h` attribute and answer `getBoundingClientRect` from -the tree. That is enough to drive every decision the splitter makes and it lets -a fragment be posed exactly — a heading with two lines beneath it, a heading -with none — which is awkward to arrange in a real document and is the whole of -what is being tested. - -## What wants a browser - -I have no browser here, so these are reasoned rather than observed: - -1. **`place_and_size`** is the change I would check first. The risk is not - correctness but inheritance — if a grid sits somewhere whose typography - differs from its parent's in a way I have not anticipated, the frozen - widths will be subtly wrong. Compare a rendered table before and after. -2. **The 6-probe widening** is a visual judgement. If first lines look short, - raise `probe_budget` in `utility.js`. -3. **Revealing at `note`** — whether `paginate_1` growing pages under a reader - is acceptable in practice, or merely acceptable in principle. -4. **The remembered screen colour** on a first-ever opening, where the fade is - the only defence. - ---- - -## Addendum — RT code format conformance - -Corrected after review against `developer/document/RT-code-format.html`. - -**Acronyms stay capitalized.** The four attributes introduced here were written -`data-rt-*`, following the surrounding code rather than the rule: - -| was | now | -|---|---| -| `data-rt-heading` | `data-RT-heading` | -| `data-RT-series` (was `data-rt-series`) | `data-RT-series` | -| `data-rt-section` | `data-RT-section` | -| `data-rt-measure-host` | `data-RT-measure-host` | - -This is a source legibility change and nothing else. HTML lowercases attribute -names on `setAttribute`, and matches them case-insensitively on `getAttribute`, -`hasAttribute` and in selectors — verified, not assumed. So the two spellings -are the same attribute at runtime and the mixed state cannot break anything. - -**Containers take a type prefix**, not a plural and not a type suffix. - -- `ghost_tag_set` → `Set_ghost_tag`, matching the `Map_*` / `dict_*` forms. -- `parts` → `list_part` in `process_read_node`. - -`CounterMachine.prefix` is left as it is, against the `list_*` rule, because -`style` beside it is also a list and is not `list_style`. Local consistency -looked like the stronger claim; say if it is not. - -**Read and write are the two ends of a copy.** - -- `screen_color_remember` → `screen_color_write`, and the matching - `screen_color_read` is lifted out of `prepaint_screen` so both ends are named. - -**Factory functions are called make.** - -- `RT.Utility.Dom.measure_host` → `measure_host_make`, as `theme_make` and - `RT·counter·make` are. - -**Namespacing.** The store key `RT·screen_color` → `RT-Manuscript·screen_color`, -matching `RT-Manuscript·theme_preference`, which sits in the same store. - -**Punctuation.** Three prose commas in new comments written `word, word` rather -than `word ,word`. Multi-level enclosures given one space of padding on the -outermost only — `if( !(best_height > 0) ){`. - -### Not touched - -The pre-existing `data-rt-component`, `data-rt-row`, `data-rt-row-extent`, -`data-rt-col`, `data-rt-columns-frozen`, `data-rt-continued` and -`data-rt-wrapped` are left alone. The format document invites updating -non-conforming code on contact, and this is a one-line change per site with no -runtime effect, but it touches files this work was not otherwise opening. Say -the word and it is a separate patch. diff --git a/developer/authored/Manuscript.copy/patch/RT-Style.patch b/developer/authored/Manuscript.copy/patch/RT-Style.patch deleted file mode 100644 index 4637f68..0000000 --- a/developer/authored/Manuscript.copy/patch/RT-Style.patch +++ /dev/null @@ -1,972 +0,0 @@ -Only in work: .git -diff -ru work.orig/Core/stage_manager.js work/Core/stage_manager.js ---- work.orig/Core/stage_manager.js 2026-08-27 07:22:53.082936286 +0000 -+++ work/Core/stage_manager.js 2026-08-27 09:16:57.680190009 +0000 -@@ -80,6 +80,21 @@ - ,'paginate_1' - ]; - -+ /* Where the reader is let in. -+ -+ The document is readable once the notes are resolved. Everything after -+ that point only grows pages that overflowed ,which is a change to the -+ bottom of a few leaves and to nothing a reader is looking at in the first -+ seconds. Holding the blank until the last phase finished made the reader -+ wait on work that did not concern them. -+ -+ So the curtain rises here and the remaining phases run behind it. Set to -+ null to hold the blank until every phase has finished ,which is the older -+ behaviour and is what to do if a late phase is ever given the power to -+ move content rather than only to grow it. -+ */ -+ window.RT.Phase_reveal = 'note'; -+ - window.RT.Task = {}; - window.RT.Phase.forEach(phase_name => { window.RT.Task[phase_name] = []; }); - -@@ -114,6 +129,154 @@ - // SCROLL & LAYOUT LOCK UTILITIES - // ========================================================= - -+ /* --------------------------------------------------------------- -+ The white page. -+ -+ The canvas takes its colour from the root element ,and until the theme is -+ compiled the root has no colour ,so the browser paints its own default. -+ Firefox does this readily ,and a book that opens on a sheet of white -+ before turning black is worse than one that takes a moment longer to -+ open: the flash lands before the reader has focused on anything. -+ -+ Two measures ,because neither alone is enough. -+ -+ The colour last used is remembered and applied here ,at parse time ,ahead -+ of the first paint. A reader returning to a book — which is nearly every -+ opening after the first — never sees white at all. -+ -+ A transition is armed at the same moment ,for the opening where nothing is -+ remembered. There the colour arrives later ,when the theme compiles ,and -+ it arrives as a fade rather than as a cut. A fade from white is a change -+ of light; a cut from white is a flash ,and the eye reads the two quite -+ differently. -+ -+ The remembered colour is a hint and nothing more. If it is wrong ,which it -+ is when the reader has changed theme since ,the theme overwrites it within -+ the same second and the error shows as a fade. -+ --------------------------------------------------------------- */ -+ -+ // Namespaced as the theme preference beside it is ,and for the same store. -+ const screen_color_key = 'RT-Manuscript·screen_color'; -+ -+ /* The two ends of the copy ,named for what they are. Both swallow their -+ faults: a store that refuses to answer is a reason to fall back on the -+ fade ,not a reason to stop opening the book. */ -+ function screen_color_read(){ -+ try{ return localStorage.getItem(screen_color_key); } -+ catch(e){ return null; } -+ } -+ -+ function prepaint_screen(){ -+ const root = document.documentElement; -+ const color = screen_color_read(); -+ -+ if(color) root.style.backgroundColor = color; -+ -+ /* Armed after the remembered colour is set ,so that colour lands -+ instantly and only a correction fades. */ -+ root.style.transition = 'background-color 400ms ease-out'; -+ } -+ -+ // Written by whoever resolves the theme ,read on the next opening. -+ window.RT.screen_color_write = function(color){ -+ if(!color) return; -+ try{ localStorage.setItem(screen_color_key ,color); }catch(e){} -+ }; -+ -+ /* --------------------------------------------------------------- -+ Telling the reader that the wait is work. -+ -+ A blank that lasts four seconds and a blank that has hung are the same -+ blank. The reader cannot tell them apart ,so they reload ,which starts the -+ four seconds again. -+ -+ An elapsed count answers it: a number that is moving is a machine that is -+ working. The phase is named alongside it ,which costs nothing and means a -+ slow book can be reported on precisely. -+ -+ The panel is only raised if the wait is long enough to be noticed. A short -+ book formats in less time than it takes to read the word 'formatting' ,and -+ raising a panel for that would be its own flicker. -+ -+ Visibility is set explicitly. The root is hidden ,and visibility inherits , -+ so a descendant that does not overrule it is hidden with everything else. -+ The background is left clear so the screen colour shows through and the -+ panel appears to sit on the page rather than over it. -+ --------------------------------------------------------------- */ -+ -+ const progress = { -+ panel: null ,elapsed: null ,phase: null -+ ,start: 0 ,timer: 0 ,label: '' ,step: 0 ,raised: false -+ }; -+ -+ const progress_delay_ms = 500; -+ -+ function progress_raise(){ -+ if(progress.raised || !document.body) return; -+ progress.raised = true; -+ -+ const panel = document.createElement('div'); -+ panel.id = 'RT·progress'; -+ panel.style.cssText = -+ 'position:fixed; top:0; left:0; right:0; bottom:0; z-index:2147483647;' -+ + ' visibility:visible; pointer-events:none; background:transparent;' -+ + ' display:flex; flex-direction:column; align-items:center;' -+ + ' justify-content:center; gap:0.6rem; text-align:center;' -+ + " font:400 1rem/1.4 'Noto Sans JP', Arial, sans-serif; color:#8a8a8a;"; -+ -+ const elapsed = document.createElement('div'); -+ elapsed.style.cssText = 'font-size:1.5rem; font-variant-numeric:tabular-nums;'; -+ -+ const phase = document.createElement('div'); -+ phase.style.cssText = 'font-size:0.85rem; opacity:0.75;'; -+ -+ panel.appendChild(elapsed); -+ panel.appendChild(phase); -+ document.body.appendChild(panel); -+ -+ progress.panel = panel; -+ progress.elapsed = elapsed; -+ progress.phase = phase; -+ progress_paint(); -+ } -+ -+ function progress_paint(){ -+ if(!progress.raised) return; -+ const seconds = (performance.now() - progress.start) / 1000; -+ progress.elapsed.textContent = 'Formatting ' + seconds.toFixed(1) + ' s'; -+ progress.phase.textContent = progress.label -+ ? progress.label + ' (' + progress.step + ' of ' + window.RT.Phase.length + ')' -+ : ''; -+ } -+ -+ function progress_begin(){ -+ progress.start = performance.now(); -+ /* The pipeline holds the main thread for the length of a phase ,so this -+ advances the count at phase boundaries and not within them. A phase that -+ runs long shows a still number under a moving phase name ,which is -+ honest about where the time is going. */ -+ progress.timer = setInterval(() => { -+ if( !progress.raised && performance.now() - progress.start > progress_delay_ms ){ -+ progress_raise(); -+ } -+ progress_paint(); -+ } ,100); -+ } -+ -+ function progress_report(phase_name ,step){ -+ progress.label = phase_name; -+ progress.step = step; -+ progress_paint(); -+ } -+ -+ function progress_end(){ -+ if(progress.timer) clearInterval(progress.timer); -+ progress.timer = 0; -+ if(progress.panel && progress.panel.parentNode) progress.panel.remove(); -+ progress.panel = null; -+ progress.raised = false; -+ } -+ - function lock_layout(){ - is_layout_locked = true; - document.documentElement.style.visibility = "hidden"; -@@ -122,6 +285,8 @@ - function unlock_layout(){ - if(!is_layout_locked) return; - is_layout_locked = false; -+ -+ progress_end(); - - document.documentElement.style.visibility = ""; - window.removeEventListener("load" ,unlock_layout); -@@ -232,12 +397,62 @@ - enforce_scroll(target_y ,use_hash ,0); - } - -+ /* Two frames ,not one. -+ -+ A single frame runs the callback in the same paint as the style change -+ that preceded it ,so the panel's new text is written and the next phase -+ seizes the thread before the reader sees it. The second frame lets the -+ paint land first. The cost is a few milliseconds per phase against a -+ pipeline measured in seconds. */ -+ function next_frame(fn){ -+ requestAnimationFrame(() => requestAnimationFrame(fn)); -+ } -+ -+ /* The pipeline runs one phase per turn rather than all of them in one. -+ -+ Nothing about the order changes ,and no phase is split: each still runs to -+ completion before the next begins. What changes is that the thread is -+ given back between them ,which is the only moment the browser has to paint -+ the elapsed count ,and the only moment at which the curtain can be raised -+ part way through. -+ -+ Splitting a phase would be the larger prize — the two long ones ,element -+ and paginate_0 ,hold the thread for most of the wait — but a phase is -+ written as one pass over the document and cutting one into resumable -+ pieces is a different order of change. -+ */ - function run_pipeline(){ -- window.RT.Phase.forEach(phase_name => { -- window.RT.Debug.log('stage' ,'phase: ' + phase_name); -- run_phase(phase_name); -- }); -- resolve_scroll_target(); -+ progress_begin(); -+ -+ let index = 0; -+ -+ const step = function(){ -+ if(index >= window.RT.Phase.length){ -+ progress_end(); -+ if(is_layout_locked) resolve_scroll_target(); -+ return; -+ } -+ -+ const phase_name = window.RT.Phase[index++]; -+ progress_report(phase_name ,index); -+ -+ next_frame(function(){ -+ window.RT.Debug.log('stage' ,'phase: ' + phase_name); -+ run_phase(phase_name); -+ -+ /* The reader is let in here ,and the remaining phases go on behind -+ them. Scroll is settled first ,or the reader would be shown the top -+ of the book and then moved. */ -+ if(phase_name === window.RT.Phase_reveal && is_layout_locked){ -+ progress_end(); -+ resolve_scroll_target(); -+ } -+ -+ step(); -+ }); -+ }; -+ -+ step(); - } - - // ========================================================= -@@ -245,6 +460,7 @@ - // ========================================================= - - lock_layout(); -+ prepaint_screen(); - configure_history(); - capture_scroll_target(); - bind_window_events(); -diff -ru work.orig/Core/utility.js work/Core/utility.js ---- work.orig/Core/utility.js 2026-08-27 07:22:53.083177529 +0000 -+++ work/Core/utility.js 2026-08-27 09:16:57.682003283 +0000 -@@ -215,6 +215,50 @@ - - // DOM Structural Operations - window.RT.Utility.Dom = window.RT.Utility.Dom || {}; -+ -+ /* A place to measure in ,out of the flow of the book. -+ -+ Every question about how text wraps is one only the browser can answer , -+ and it will only answer it about an element that is attached and laid out. -+ Attached in the flow ,though ,each answer is dear: setting a width dirties -+ the element ,which changes its height ,which moves everything below it , -+ and the browser must lay the rest of the book out again before it can -+ reply. A shrink wrap asks a dozen such questions per label. A hundred -+ labels in a long manuscript is then a thousand full layouts ,and that is -+ where the time went when shrink wrapping was added — the cost is not in -+ the wrapping ,it is in the length of the document behind it. -+ -+ The host is positioned ,so it is out of flow and its contents cannot -+ change the height of anything in the flow. Nothing below it moves ,so -+ there is nothing below it to lay out again. It is a child of the element -+ the content would really sit in ,so font ,size ,weight and colour are -+ inherited exactly as they will be in place ,and it is given that parent's -+ content width ,so the wrapping measured is the wrapping that will be -+ rendered. Measuring somewhere convenient instead would answer a question -+ about a document that does not exist. -+ -+ Returns null where a width cannot be established ,which is the caller's -+ signal to measure in place as before rather than to measure wrongly. -+ */ -+ window.RT.Utility.Dom.measure_host_make = function(context_el){ -+ const parent = context_el && context_el.parentElement; -+ if(!parent) return null; -+ -+ const ps = window.getComputedStyle(parent); -+ const width = parent.clientWidth -+ - parseFloat(ps.paddingLeft || 0) -+ - parseFloat(ps.paddingRight || 0); -+ if( !(width > 0) ) return null; -+ -+ const host = document.createElement('div'); -+ host.setAttribute('data-RT-measure-host' ,'true'); -+ host.style.cssText = -+ 'position:fixed; top:0; left:0; visibility:hidden; pointer-events:none;' -+ + ' contain:layout style; z-index:-1; width:' + width + 'px;'; -+ -+ parent.appendChild(host); -+ return host; -+ }; - - - /* Shrink wrap an attached element to a well set block of text. -@@ -333,10 +377,20 @@ - Where no such width exists within the bound the balanced width stands: - a stranded word is worse than a short first line ,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 . 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

,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: 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

–

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 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');