clearotron 0.3.2-beta.8 → 0.3.2-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/.env.example +34 -3
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +6 -6
  4. package/SECURITY.md +3 -3
  5. package/bin/brandowner.mjs +3 -3
  6. package/bin/framework-preflight.mjs +1 -1
  7. package/bin/start.mjs +18 -4
  8. package/build-info.json +2 -2
  9. package/docs/DELIVERY.md +2 -1
  10. package/docs/INTAKE.md +1 -1
  11. package/docs/ONBOARDING.md +1 -1
  12. package/docs/architecture/03-run-lifecycle.md +6 -6
  13. package/docs/architecture/04-configuration-reference.md +6 -3
  14. package/docs/architecture/05-config-governance.md +6 -1
  15. package/docs/architecture/05-customer-profiles.md +2 -2
  16. package/docs/architecture/06-operations-runbook.md +3 -3
  17. package/docs/architecture/08-development-guide.md +6 -6
  18. package/docs/configuration.md +5 -5
  19. package/docs/decisions/0003-credential-model.md +1 -1
  20. package/docs/writing-standard.md +4 -0
  21. package/driver/CHANGELOG.md +48 -0
  22. package/driver/README.md +3 -3
  23. package/driver/binding-layers.mjs +1 -1
  24. package/driver/citation-census.json +3 -3
  25. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  26. package/driver/common-law-receipts.mjs +2 -2
  27. package/driver/company-bundle.mjs +3 -3
  28. package/driver/compose-read.mjs +8 -14
  29. package/driver/consumption-ledger.mjs +2 -2
  30. package/driver/contract-arm2-baseline.json +2 -3
  31. package/driver/contract-dictation-registry.mjs +19 -19
  32. package/driver/contract-e3-backlog.mjs +19 -19
  33. package/driver/contract-e3-baseline.json +14 -14
  34. package/driver/contract-vocabulary.mjs +24 -17
  35. package/driver/deliver-trigger.sh +16 -16
  36. package/driver/demo-container.mjs +3 -3
  37. package/driver/dev-portal.mjs +3 -3
  38. package/driver/disposition-call.mjs +1 -1
  39. package/driver/doubt-ledger.mjs +2 -2
  40. package/driver/drainer-identity.mjs +34 -8
  41. package/driver/driver.config.mjs +95 -45
  42. package/driver/engine/mcp/README.md +1 -1
  43. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  44. package/driver/engine/mcp/gather-config.mjs +9 -9
  45. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  46. package/driver/engine/mcp/recording-server.mjs +5 -5
  47. package/driver/enqueue-schema.mjs +6 -2
  48. package/driver/findings-model.mjs +5 -2
  49. package/driver/flag-snapshot.mjs +6 -3
  50. package/driver/form-neighbourhood.mjs +54 -7
  51. package/driver/framework.mjs +4 -4
  52. package/driver/gateway.mjs +12 -6
  53. package/driver/jx-lanes.mjs +2 -2
  54. package/driver/jx-units.mjs +1 -1
  55. package/driver/jx.mjs +30 -2
  56. package/driver/knockout-review-record.mjs +56 -4
  57. package/driver/known-conflicts.mjs +1 -1
  58. package/driver/named-band.mjs +1 -33
  59. package/driver/ordinary-words.mjs +51 -0
  60. package/driver/outbox-backoff.mjs +31 -16
  61. package/driver/package.json +1 -1
  62. package/driver/partial-payload-baseline.json +2 -2
  63. package/driver/phase0.mjs +3 -3
  64. package/driver/pipeline-knockout.mjs +5 -5
  65. package/driver/pipeline.mjs +206 -68
  66. package/driver/placement-form.mjs +77 -1
  67. package/driver/placement-model.mjs +1 -1
  68. package/driver/portal-report.mjs +92 -5
  69. package/driver/portal-service.mjs +34 -8
  70. package/driver/portal-upstream.mjs +1 -1
  71. package/driver/preserve-merge.mjs +3 -3
  72. package/driver/product-rows.mjs +2 -2
  73. package/driver/products.mjs +1 -1
  74. package/driver/profiles/README.md +3 -3
  75. package/driver/profiles/demo-brand-owner.json +2 -2
  76. package/driver/profiles.mjs +55 -17
  77. package/driver/progress.mjs +18 -8
  78. package/driver/provider-usage.mjs +8 -8
  79. package/driver/publish/index.mjs +110 -5
  80. package/driver/publish/knockout.mjs +29 -4
  81. package/driver/publish/pool-admin.mjs +1 -1
  82. package/driver/publish/publish-inputs.mjs +18 -2
  83. package/driver/publish/render-knockout.mjs +115 -24
  84. package/driver/publish/render.mjs +153 -34
  85. package/driver/publish/search-depth.mjs +133 -4
  86. package/driver/publish/templates/report.css +60 -3
  87. package/driver/publish/xlsx.mjs +8 -1
  88. package/driver/queue-order.mjs +2 -2
  89. package/driver/recording-agreement.mjs +1 -1
  90. package/driver/reference-score.mjs +1 -1
  91. package/driver/register-count.mjs +50 -5
  92. package/driver/register-coverage.mjs +67 -0
  93. package/driver/register-grant-vocabulary.mjs +1 -1
  94. package/driver/register-plan.mjs +19 -2
  95. package/driver/registry-fidelity.mjs +3 -3
  96. package/driver/repair-composers.mjs +1 -1
  97. package/driver/repair-contract.mjs +1 -1
  98. package/driver/replay-archive.mjs +6 -6
  99. package/driver/report-overview-record.mjs +2 -2
  100. package/driver/run-requirements.mjs +3 -3
  101. package/driver/runner.mjs +2 -2
  102. package/driver/scope-facts.mjs +20 -5
  103. package/driver/scope-ledger.mjs +5 -5
  104. package/driver/search-policy.mjs +22 -12
  105. package/driver/skills/README.md +15 -15
  106. package/driver/skills/blind-frame/SKILL.md +2 -2
  107. package/driver/skills/case-law-citation/SKILL.md +4 -4
  108. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  109. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  110. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  111. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  112. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  113. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  114. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  115. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  116. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  117. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  118. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  119. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  120. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  121. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  122. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  123. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  124. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  125. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  126. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  127. package/driver/skills/frame-diff/SKILL.md +1 -1
  128. package/driver/skills/knockout-assess/SKILL.md +10 -7
  129. package/driver/skills/matter-frame/SKILL.md +3 -3
  130. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  131. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  132. package/driver/stage-context.mjs +1 -1
  133. package/driver/stages-knockout.mjs +4 -4
  134. package/driver/stages.mjs +53 -53
  135. package/driver/status-snapshot.mjs +2 -2
  136. package/driver/suite-census.json +218 -92
  137. package/driver/surface-exit-verdict.mjs +58 -0
  138. package/driver/systemd/README.md +2 -2
  139. package/driver/systemd/clearotron-worker.service +1 -1
  140. package/driver/terminal-clamp.mjs +2 -0
  141. package/driver/usage-ledger.mjs +1 -1
  142. package/driver/variant-manifest-model.mjs +4 -4
  143. package/driver/verify-knockout.mjs +27 -0
  144. package/driver/verify.mjs +67 -6
  145. package/driver/whatif-queue.mjs +1 -1
  146. package/driver/wordlists/en.txt +63906 -0
  147. package/mcp-server/CHANGELOG.md +4 -0
  148. package/mcp-server/README.md +1 -1
  149. package/mcp-server/lib/README.md +1 -1
  150. package/mcp-server/lib/options.mjs +8 -7
  151. package/mcp-server/lib/plan.mjs +18 -2
  152. package/mcp-server/lib/runs.mjs +1 -1
  153. package/mcp-server/lib/usage.mjs +3 -3
  154. package/mcp-server/lib/whatif.mjs +1 -1
  155. package/mcp-server/package.json +1 -1
  156. package/mcp-server/server.mjs +3 -0
  157. package/package.json +12 -11
  158. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  159. package/portal-ui/dist/assets/{index-6jzO9HiX.js → index-EVaSo5-g.js} +1459 -482
  160. package/portal-ui/dist/index.html +2 -2
  161. package/portal-ui/package.json +1 -1
  162. package/providers/README.md +1 -1
  163. package/providers/_shared/enumerate.mjs +6 -6
  164. package/providers/_shared/execute-plan.mjs +3 -3
  165. package/providers/_shared/ledger.mjs +119 -5
  166. package/providers/_shared/provider-text.mjs +2 -2
  167. package/providers/_shared/screen.mjs +2 -2
  168. package/providers/_shared/script-form.mjs +3 -3
  169. package/providers/_shared/territory-codes.mjs +23 -3
  170. package/providers/clarivate/README.md +1 -1
  171. package/providers/clarivate/src/capabilities.js +12 -12
  172. package/providers/clarivate/src/core.js +37 -43
  173. package/providers/corsearch/README.md +1 -1
  174. package/providers/corsearch/src/capabilities.js +5 -5
  175. package/providers/corsearch/src/core.js +3 -3
  176. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  177. package/providers/oauth-mcp-bridge/package.json +1 -1
  178. package/providers/perplexity/src/core.js +1 -1
  179. package/providers/signa/README.md +1 -1
  180. package/providers/signa/src/capabilities.js +42 -49
  181. package/providers/signa/src/core.js +106 -29
  182. package/providers/uspto-local/src/sync.js +1 -1
  183. package/scripts/README.md +1 -0
  184. package/scripts/ask-ai-render-check.mjs +127 -1
  185. package/scripts/authority-boundary-probe.mjs +4 -4
  186. package/scripts/backfill-started-at.mjs +2 -2
  187. package/scripts/census-merge-driver.mjs +33 -2
  188. package/scripts/citation-anchor-report.mjs +181 -0
  189. package/scripts/dead-names.mjs +1 -1
  190. package/scripts/deprecate-below.mjs +66 -8
  191. package/scripts/drain-preflight.mjs +1 -1
  192. package/scripts/e2e.mjs +174 -0
  193. package/scripts/env-audit.mjs +27 -0
  194. package/scripts/env-classify.mjs +20 -2
  195. package/scripts/freeze-example-run.mjs +20 -10
  196. package/scripts/live-surface-check.mjs +124 -41
  197. package/scripts/markdown-link-check.mjs +1 -1
  198. package/scripts/merge-shape-check.mjs +242 -0
  199. package/scripts/mint-names-in-force.mjs +5 -5
  200. package/scripts/mint-offered-territories.mjs +72 -0
  201. package/scripts/mint-public-residue.mjs +2 -2
  202. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  203. package/scripts/mint-suite-census.mjs +75 -2
  204. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  205. package/scripts/purge-runs.mjs +7 -7
  206. package/scripts/reconcile-runs.mjs +2 -2
  207. package/scripts/release-approve-parked.mjs +20 -2
  208. package/scripts/release-await-cut.mjs +120 -1
  209. package/scripts/release-note-required.mjs +76 -8
  210. package/scripts/report-header-render-check.mjs +164 -0
  211. package/shared/brand.mjs +27 -0
  212. package/shared/connect-clients.mjs +39 -11
  213. package/shared/env-aliases.mjs +1 -1
  214. package/shared/identifier-scan.mjs +65 -9
  215. package/shared/identifier-sentinels.mjs +22 -0
  216. package/shared/names-in-force.mjs +3 -1
  217. package/shared/offered-territories.json +738 -0
  218. package/shared/pre-rename-spellings.mjs +53 -0
  219. package/shared/reference-guard-classes.mjs +40 -2
  220. package/shared/stdio-connect.mjs +39 -4
  221. package/shared/tree-commit.mjs +48 -0
  222. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  223. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  224. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  225. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  226. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  227. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  228. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  229. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  230. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  231. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  232. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  233. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  234. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  235. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  236. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  237. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  238. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
@@ -40,10 +40,17 @@ import { join, dirname } from 'node:path';
40
40
  import { fileURLToPath } from 'node:url';
41
41
  import {
42
42
  REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, themeButton,
43
- THEME_BTN_CSS, CHROME_CSS, FAVICON_LINK, logoLockup, BRAND, confPosture,
43
+ THEME_BTN_CSS, CHROME_CSS, FAVICON_LINK, logoLockup, BRAND, confPosture, sectionStrip,
44
44
  } from '../../shared/brand.mjs';
45
45
  import { SUMMARY_BLOCK_LINE, parseSummaryBlocks } from '../../shared/summary-blocks.mjs';
46
- import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine } from '../register-count.mjs';
46
+
47
+ // THIS BOARD'S FOUR ENTRIES, in its order. Its third reads "Also considered" and resolves at the filings
48
+ // section, which is what that section is; the rule that filters them against the finished document lives
49
+ // in shared/brand.mjs, beside the clearance report's own five.
50
+ const STRIP = Object.freeze([
51
+ ['summary', 'Summary'], ['findings', 'Findings'], ['filings', 'Also considered'], ['next', 'Next steps'],
52
+ ]);
53
+ import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine, disclosedFloor, moreThan } from '../register-count.mjs';
47
54
  import { RECORD_BASIS, recordsForMark, recordsLine } from '../register-records.mjs';
48
55
  import { officeLinkSentences } from './office-record-links.mjs';
49
56
  import { knockoutFindingViews, splitKnockoutNotes } from '../findings-model.mjs';
@@ -164,6 +171,39 @@ function qualifierHtml(q) {
164
171
  // The layout this report adds on top of report.css. Additive and `ko-`prefixed: report.css is shared with
165
172
  // the frozen clearance renderer, and a selector that is inert there cannot regress it.
166
173
  const KO_CSS = `
174
+ /* THE BOARD'S SECTION STRIP AND ITS SECTION FURNITURE, lifted from the approved knockout board rather
175
+ than written here. Four entries; the third reads "Also considered" and resolves at the filings
176
+ section. Marked not to print, because the board marks it so: a strip that follows the reader down
177
+ the page is a control, and paper has nothing for it to follow.
178
+ NO BACKTICK BELOW, and none in this comment — one closes the template literal this sits inside, and
179
+ it has cost this file two silent breakages already.
180
+ The number span is drawn and then hidden, which is the board's own doing: it emits the span on every
181
+ section and sets it to none further down its own stylesheet. Carried so the delivered document holds
182
+ the same elements as the approved one; no pixel moves. */
183
+ /* THE BREADCRUMB IS A ROW OF THE HEADER, not a bar under it (owner, 2026-09-18).
184
+ It used to be a SIBLING of .rep-stickyhead, pinned on its own at top:var(--tb-h,52px) — and
185
+ --tb-h is set nowhere in this product, so the fallback was a guess at a bar that measures ~46px:
186
+ content showed through the slit between the two, and z-index:20 put the strip UNDER the header's
187
+ 100 whenever the guess was wrong. The strip is now emitted inside .rep-stickyhead, so the header
188
+ and the breadcrumb are one sticky surface that pins and unpins together and can never gap or
189
+ overlap, whatever the bar measures or how it wraps.
190
+ No background and no bottom hairline of its own: the wrapper carries the blurred surface and the
191
+ single edge, and the border-top here is the divider between the two rows. It is on .strip rather
192
+ than on any wrapper because sectionStrip() emits NOTHING when fewer than two of its sections are
193
+ live — a wrapper would leave a stray line on those reports.
194
+ The gutter matches the topbar's, so the first entry lines up under the back button. */
195
+ .strip{display:flex;gap:4px;align-items:center;padding:4px max(26px,calc((100% - 1120px)/2)) 6px;
196
+ border-top:1px solid var(--line);font:600 12px/1 'Satoshi','Helvetica Neue',Arial,sans-serif;
197
+ letter-spacing:.04em;overflow-x:auto;scrollbar-width:none}
198
+ .strip::-webkit-scrollbar{display:none}
199
+ .strip a{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;color:#6b5d50;text-decoration:none;white-space:nowrap}
200
+ .strip a i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;box-sizing:border-box}
201
+ .strip a.done{color:#4c7a4c}
202
+ .strip a.done i{background:#4c7a4c;border-color:#4c7a4c}
203
+ .strip a.now{color:#250902;background:rgba(0,0,0,.06)}
204
+ .strip a.now i{border-color:#860F09;border-width:3px}
205
+ .sec .num{display:none}
206
+ .sec .note{display:inline;margin-left:10px;font-size:12px;color:var(--faint)}
167
207
  .ko-glance{padding:6px 0 2px}
168
208
  /* — framework attribution, captioning the rows. Same gutter and hairline as.ko-row so it reads
169
209
  as the table's head, not a floating note. */
@@ -264,14 +304,55 @@ const KO_CSS = `
264
304
  .ko-scope{font-size:13.5px;color:var(--slate);line-height:1.65;margin:0;padding:18px 24px}
265
305
  .ko-scope b{color:var(--ink)}
266
306
  /* A wide counts table must scroll inside its own panel, never push the page sideways. */
307
+ /* AND IT SHOWS THAT IT SCROLLS. A panel that continues off its right edge and says nothing leaves a
308
+ reader who never drags it believing they have seen the whole table; the columns past the edge are
309
+ reachable and invisible at the same time. Ruled 2026-09-17: a scrollbar, always visible, and no text.
310
+ The -webkit-appearance:none is the part that does the work — without it the platform draws an OVERLAY
311
+ scrollbar that appears only once a scroll is already under way, which is the state this rule ends.
312
+ scrollbar-width and scrollbar-color are the same instruction for the engines that take the standard
313
+ properties. (NO BACKTICK IN THIS COMMENT: it sits inside the stylesheet's own template literal, and
314
+ one closes it. That is written above the export menu block too, and I still did it — so it is here
315
+ as well, beside the second rule anyone adds.) The colours are tokens, not fixed values, for the reason the ladder below this block
316
+ records: a fixed colour here reads on one ground and goes invisible on the other. */
267
317
  .ko-scroll{overflow-x:auto}
268
- @media(max-width:700px){.ko-row{grid-template-columns:1fr;gap:10px}}
318
+ .ko-scroll::-webkit-scrollbar{height:9px;-webkit-appearance:none}
319
+ .ko-scroll::-webkit-scrollbar-track{background:transparent}
320
+ .ko-scroll::-webkit-scrollbar-thumb{background:var(--faint);border-radius:5px}
321
+ /* THE TWO INSTRUCTIONS CANNOT SIT TOGETHER, and this was measured rather than reasoned. Setting the
322
+ standard scrollbar-width alongside the pseudo-elements makes the engine take the standard path and
323
+ ignore them, and on this one the standard path draws an overlay bar with no layout height at all.
324
+ Driven three ways on a delivered report at 390px, reading the height the bar takes out of the panel:
325
+ both together 0px, the pseudo-elements alone 9px, the standard properties alone 0px. So the standard
326
+ ones go behind a support query that the engines carrying the pseudo-elements never enter, which
327
+ leaves each engine exactly one instruction. */
328
+ @supports not selector(::-webkit-scrollbar){
329
+ .ko-scroll{scrollbar-width:thin;scrollbar-color:var(--faint) transparent}}
330
+ /* A finding's bullets carry bare source URLs, and a URL offers a line no place to break. On a phone
331
+ that unbreakable run becomes the minimum width of the grid item, so the single column floors at
332
+ 443px inside a 360px viewport and the whole page scrolls sideways -- 135px of it, measured
333
+ 2026-09-17. Only the value "anywhere" lowers that minimum: "break-word" breaks the line but
334
+ leaves the minimum where it was, which is why the blocks beside this one that already set
335
+ word-break were never the cause. Phone widths only: nothing at or above 700px changes.
336
+ NO BACKTICK IN THIS COMMENT -- it sits inside the stylesheet's own template literal. */
337
+ @media(max-width:700px){.ko-row{grid-template-columns:1fr;gap:10px}
338
+ .ko-bul{overflow-wrap:anywhere}}
269
339
 
270
340
  /* The band ladder, the territories fold and the run's own caveats (the 2026-09-16 report redesign). Ported from
271
341
  the design's stylesheet with its fixed colours replaced by this page's tokens, so the ladder reads
272
342
  in both themes: the tick row was #a89a8a on white and the fold's summary and prose a brown pair,
273
343
  all three of which go invisible on the dark ground this report also renders on. */
274
- .kscale{position:relative;margin:10px 8px 34px}
344
+ /* THE BAND PILL HANGS ABOVE THE BAR, so the space above the bar is what keeps it off the heading.
345
+ NO BACKTICK IN THIS COMMENT — it sits inside the stylesheet's own template literal.
346
+ .kmarker sits at top:-30px and the pill is ~23px tall (5+5 padding on a 13px line), so it starts
347
+ 30px above the bar and ends 7px above it. At the LEFTMOST band the pill is also drawn flush left
348
+ (translateX(-12px) at left:0%) — directly under the left-aligned "Overall risk" — so that is the
349
+ rung where the two touch, and the house ladder's leftmost rung is Low.
350
+ THE ROOM IS ONE MARGIN, NOT TWO, and that is what made the arithmetic wrong twice. This margin and
351
+ .gauge .label's 18px bottom margin are ADJACENT SIBLINGS, so they collapse to the larger of the two
352
+ rather than adding: 10px here gave 18px of room against a pill that needs 30, and the first attempt
353
+ at 24 still left 6. Measured in a browser at every rung (scripts/report-header-render-check.mjs),
354
+ not reasoned: 44 gives 14px of daylight under the heading at every band. */
355
+ .kscale{position:relative;margin:44px 8px 34px}
275
356
  .kbar{position:relative;height:8px;border-radius:4px}
276
357
  .kmarker{position:absolute;top:-30px;text-align:center}
277
358
  .kpill{display:inline-block;padding:5px 12px;border-radius:999px;color:#fff;
@@ -434,9 +515,9 @@ function territoriesLine(registerCounts) {
434
515
  return `Counted on ${regions.length} registers, listed on the workbook's Register Counts sheet, on ${provider}.`;
435
516
  }
436
517
 
437
- /** What the counts do and do not say — the one line that replaces the count-basis paragraph on the page. */
438
- const COUNTS_READER_LINE = 'Counts include live, pending and dead filings. '
439
- + 'A count is not a conflict; the cards above say which filings matter.';
518
+ // The reader line that used to sit under the counts table ("a count is not a conflict…") is off the page:
519
+ // the approved knockout board carries no caveat under the table, and the writing standard lists it among
520
+ // the caveat sentences a page does not print. The column headers say what each count is.
440
521
 
441
522
  function countsSection(marks, registerCounts, positions = '') {
442
523
  // ── THE DEFINITION MOVES INTO THE COLUMN HEADER ──────────────────────────────────────────────────
@@ -471,9 +552,10 @@ function countsSection(marks, registerCounts, positions = '') {
471
552
  const why = c?.unavailable ?? (c
472
553
  ? 'no count recorded'
473
554
  : 'not counted on this run — it predates this column, and an archived report re-renders as what it was');
474
- return Number.isFinite(c?.total)
475
- ? `<td class="num">${esc(String(c.total))}</td>`
476
- : `<td class="na" title="${escAttr(why)}">not available</td>`;
555
+ if (Number.isFinite(c?.total)) return `<td class="num">${esc(String(c.total))}</td>`;
556
+ // The register answered with its own floor: that is a figure, and it is printed as one.
557
+ if (disclosedFloor(c) !== null) return `<td class="num">${esc(moreThan(disclosedFloor(c)))}</td>`;
558
+ return `<td class="na" title="${escAttr(why)}">not available</td>`;
477
559
  }).join('');
478
560
  const scoped = e?.classScope !== 'all-classes' && (e?.classes?.length > 0);
479
561
  const scope = scoped
@@ -491,7 +573,7 @@ function countsSection(marks, registerCounts, positions = '') {
491
573
  // thing in three words each.
492
574
  return `<div class="panel">
493
575
  <div class="ko-counts ko-scroll"><table><thead><tr><th>Name</th>${head}</tr></thead><tbody>${rows}</tbody></table></div>
494
- <p class="ko-basis">${esc(COUNTS_READER_LINE)}<br>${esc(territoriesLine(registerCounts))}${
576
+ <p class="ko-basis">${esc(territoriesLine(registerCounts))}${
495
577
  positions ? `<br>${esc(positions)}` : ''}</p>
496
578
  </div>`;
497
579
  }
@@ -1157,16 +1239,16 @@ function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext =
1157
1239
  const cut = note.lastIndexOf(' \u2014 ');
1158
1240
  const stage = cut > 0 ? note.slice(0, cut).trim() : note;
1159
1241
  const context = String(productContext ?? '').trim();
1160
- // REGISTERS COUNTED IS A ROW, AND THE TERRITORY LIST HIDES BEHIND IT (the 2026-09-16 report redesign). A line of
1161
- // country names running through the middle of the panel is the thing a reader skips; the count and
1162
- // its source are what they read, and the list is one click away when they want it.
1242
+ // REGISTERS COUNTED IS A ROW (the 2026-09-16 report redesign). A line of country names running through
1243
+ // the middle of the panel is the thing a reader skips; the count and its source are what they read.
1163
1244
  const regions = (registerCounts?.scope?.regions ?? []).filter(Boolean);
1164
1245
  const provider = registerCounts?.providerLabel ?? registerCounts?.provider ?? '';
1165
- const named = regions.map(territoryName).filter(Boolean);
1246
+ // THE BOARD'S OWN ROW: "186 registers, on Clarivate Compumark." — registers, because that is what was
1247
+ // counted, and no fold: the board lists no territories here, and the workbook's counts sheet carries
1248
+ // every register a figure was taken over.
1166
1249
  const counted = regions.length
1167
- ? `${regions.length} ${regions.length === 1 ? 'territory' : 'territories'}${provider ? `, on ${provider}` : ''}`
1250
+ ? `${regions.length} ${regions.length === 1 ? 'register' : 'registers'}${provider ? `, on ${provider}` : ''}.`
1168
1251
  : (registerCounts ? `Counted worldwide${provider ? `, on ${provider}` : ''}` : '');
1169
- const territories = named.length ? listWords(named) : '';
1170
1252
  if (!asked && !where && !classLine && !stage && !context && !counted) return '';
1171
1253
  // ONE PANEL, THE SAME ON BOTH REPORTS (the 2026-09-16 report redesign). The rows carry what was asked for; the
1172
1254
  // counts row says what was counted and hides the territory list behind a fold rather than running a
@@ -1179,7 +1261,7 @@ function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext =
1179
1261
  ${row('Classes', classLine)}
1180
1262
  ${row('Where searched', where)}
1181
1263
  ${row('Context', context)}
1182
- ${row('Registers counted', counted, territories ? `<details class="terr"><summary>View territories</summary><p>${esc(territories)}</p></details>` : '')}
1264
+ ${row('Registers counted', counted)}
1183
1265
  ${row('Searched on', searched)}
1184
1266
  ${/* item 18 — a note for the reviewing lawyer does not reach the delivered page */''}
1185
1267
  </div>`;
@@ -1336,7 +1418,8 @@ function coverageClause(mark, registerCounts, probeRan) {
1336
1418
  ? 'Register: hit-counts are part of this search and none could be taken for this name — the reason is in the audit workbook.'
1337
1419
  : 'Register: not included in this product tier — no register was counted for this name.';
1338
1420
  }
1339
- const anyFigure = COUNT_PREDICATES.some((p) => Number.isFinite(entry.counts[p.key]?.total));
1421
+ // A floor the register disclosed is a figure it gave, so a name counted only that way was counted.
1422
+ const anyFigure = COUNT_PREDICATES.some((p) => Number.isFinite(entry.counts[p.key]?.total) || disclosedFloor(entry.counts[p.key]) !== null);
1340
1423
  if (!anyFigure) return 'Register: no count could be taken for this name — the reason is in the audit workbook.';
1341
1424
  // COVERAGE, NOT THE FIGURES. asks the renderer to own the register-tier caveat, and the numbers
1342
1425
  // are already on this page twice — in the glance line at the top and in the counts table above this
@@ -1758,11 +1841,11 @@ export function renderKnockoutHtml(findings, framework, {
1758
1841
  n + registerCardsOnPage(registerCardViews(m, framework, registerRecords).cards, m, framework).length, 0);
1759
1842
  const hasRecords = Boolean(registerRecords && (registerRecords.marks?.length || registerRecords.unavailable));
1760
1843
  const filings = hasRecords ? filingsSection(marks, registerRecords) : '';
1761
- const onFieldSec = `<div class="sec" id="findings"><h2>Conflicts</h2></div>
1844
+ const onFieldSec = `<div class="sec" id="findings"><span class="num"></span><h2>Conflicts</h2></div>
1762
1845
  ${counts}
1763
1846
  ${analysis}`;
1764
1847
  const filingsSec = filings
1765
- ? `<div class="sec" id="filings"><h2>Also considered</h2></div>
1848
+ ? `<div class="sec" id="filings"><span class="num"></span><h2>Also considered</h2><span class="note">the filings behind the counts</span></div>
1766
1849
  ${filings}`
1767
1850
  : '';
1768
1851
 
@@ -1774,7 +1857,7 @@ ${filings}`
1774
1857
  const cssInline = REPORT_BASE + '\n' + CHROME_CSS + '\n' + THEME_BTN_CSS + '\n' + KO_CSS;
1775
1858
  const chromeLinkTag = chromeHref ? `<link rel="stylesheet" href="${escAttr(chromeHref)}">` : '';
1776
1859
 
1777
- return `<!DOCTYPE html>
1860
+ const doc = `<!DOCTYPE html>
1778
1861
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1779
1862
  <title>${productName ? `${esc(productName)} — ` : ''}${esc(title)} · ${esc(BRAND.name)}</title>
1780
1863
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
@@ -1825,6 +1908,7 @@ ${filings}`
1825
1908
  `)}
1826
1909
  </div>
1827
1910
  </div>
1911
+ <!--SECTION-STRIP-->
1828
1912
  </div>
1829
1913
  <div class="fab-stack">${themeButton()}</div>
1830
1914
  <script>
@@ -1849,7 +1933,7 @@ window.addEventListener('beforeprint',o);})();
1849
1933
  the clearance template carries, for the same markup; they move together when the top bar is shared. */
1850
1934
  ${EXPORT_MENU_JS}</script>
1851
1935
  <div class="wrap">
1852
- <header class="hero">
1936
+ <header class="hero" id="summary">
1853
1937
  ${demoBannerHtml(demoData === true)}
1854
1938
  ${confLineHtml(delivery, productName)}
1855
1939
  <h1 class="mark">${esc(title)}</h1>
@@ -1866,7 +1950,7 @@ ${EXPORT_MENU_JS}</script>
1866
1950
  if (!outs.length) return '';
1867
1951
  const label = outs.length > 1;
1868
1952
  const body = outs.map((o) => (label && o.name ? `<h3>${esc(o.name)}</h3>` : '') + mdParagraphs(o.text)).join('');
1869
- return `<div class="sec" id="next"><h2>What happens next</h2></div>
1953
+ return `<div class="sec" id="next"><span class="num"></span><h2>What happens next</h2></div>
1870
1954
  <div class="panel actions"><div class="actgrp act-you">${body}</div></div>`;
1871
1955
  })()}
1872
1956
 
@@ -1903,6 +1987,7 @@ ${EXPORT_MENU_JS}</script>
1903
1987
  </footer>
1904
1988
  </div>
1905
1989
  </body></html>`;
1990
+ return doc.replace('<!--SECTION-STRIP-->', sectionStrip(doc, STRIP));
1906
1991
  }
1907
1992
 
1908
1993
  /**
@@ -2053,6 +2138,12 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
2053
2138
  classScope: counted.classScope ?? null,
2054
2139
  classes: counted.classes ?? [],
2055
2140
  ...Object.fromEntries(COUNT_PREDICATES.map((p) => [p.key, Number.isFinite(counted.counts[p.key]?.total) ? counted.counts[p.key].total : null])),
2141
+ // The figure stays null for a floor — a floor is never a count to be summed — and the floor
2142
+ // rides beside it, so a reader of this file has the register's own figure and not only a null.
2143
+ ...(COUNT_PREDICATES.some((p) => disclosedFloor(counted.counts[p.key]) !== null)
2144
+ ? { floors: Object.fromEntries(COUNT_PREDICATES.filter((p) => disclosedFloor(counted.counts[p.key]) !== null)
2145
+ .map((p) => [p.key, disclosedFloor(counted.counts[p.key])])) }
2146
+ : {}),
2056
2147
  // The close column is an aggregate, so the forms under it ride with it — a consumer that
2057
2148
  // got the number and not the forms could restate the figure but never explain it, and this
2058
2149
  // file is what the assistant drafts client mail from.
@@ -32,7 +32,7 @@ import { parseFindingsJson, bindRecommendation, sentenceCaseLead, CLIENT_TIER_BY
32
32
  import { REC, inPriorityWindow, ownerDisplayName } from '../registry-fidelity.mjs';
33
33
  import { registrationSystem } from '../jurisdiction-systems.mjs';
34
34
  import { READ_LEAD_RE } from '../report-card-record.mjs'; // D3 — the dedupe gate and the card's acceptance are ONE predicate
35
- import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture } from '../../shared/brand.mjs';
35
+ import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture, sectionStrip } from '../../shared/brand.mjs';
36
36
  import { NAV_CSS } from '../../shared/site-nav.mjs';
37
37
  import { isEntrypoint } from "../../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
38
38
 
@@ -1041,18 +1041,55 @@ function whereItStandsSection(findings, opts) {
1041
1041
  const sd = opts && opts.searchDepth;
1042
1042
  if (!sd || !sd.counts || isFullCountry(opts)) return '';
1043
1043
  const byC = sd.counts.recordsByCountry || {};
1044
- const codes = Object.keys(byC).filter((c) => c !== 'WO');
1045
- if (!codes.length) return '';
1046
1044
  const bandBy = new Map();
1047
1045
  for (const f of findings) {
1048
1046
  const c = regionCode(f);
1049
1047
  if (!c || c === COMMON_LAW || !f.band) continue;
1050
1048
  if (!bandBy.has(c)) bandBy.set(c, f.band);
1051
1049
  }
1050
+ // WHICH COUNTRIES THIS SECTION IS ABOUT COMES FROM THE SEARCH PLAN, NOT FROM THE RECORD ARCHIVE.
1051
+ //
1052
+ // The archive is the authority on what came back and was kept; the plan is the authority on what was
1053
+ // asked of the register. Read off the archive, a provider that keeps no records produced no countries,
1054
+ // no rows, no chips and no section at all — so a register nobody could archive read exactly like a
1055
+ // register nobody searched, on the page a client acts on. Every provider that keeps partial records
1056
+ // under-reported here for the same reason.
1057
+ //
1058
+ // `planTerritories` is three-valued and the third value is why this is not a one-line swap. `null`
1059
+ // means there is no plan to read, which is every archived and legacy run: those keep the archive as
1060
+ // their only authority and re-render exactly as they always have. A plan that named nothing is a
1061
+ // different answer from no plan at all and is allowed to leave this section empty.
1062
+ //
1063
+ // THE FINDINGS' OWN COUNTRIES ARE UNIONED IN, never filtered out. A finding sitting in a territory the
1064
+ // plan does not list — an international registration designating one, most often — had its row from
1065
+ // the archive before this and must keep it: the section's first duty is that a country carrying a
1066
+ // conflict is on the page.
1067
+ //
1068
+ // The plan's unreached territories are NOT drawn. This section has two slots in the approved board, a
1069
+ // row carrying a band and a "Nothing found" chip, and a territory the provider does not cover is
1070
+ // neither: filing it under "Nothing found" would state a clean result for a register nobody read,
1071
+ // which is the one fusion this file's court-decisions section exists to keep apart.
1072
+ const plan = opts && opts.planTerritories;
1073
+ const planned = plan && Array.isArray(plan.searched) ? plan.searched.map(String) : null;
1074
+ const codes = (planned
1075
+ ? [...new Set([...planned, ...bandBy.keys()])]
1076
+ : Object.keys(byC)).filter((c) => c !== 'WO');
1077
+ if (!codes.length) return '';
1052
1078
  const alias = { EM: 'EU', GB: 'UK' };
1053
- const withF = [], clean = [];
1079
+ // ONE ROW PER COUNTRY, DEDUPED AFTER THE ALIAS AND NOT BEFORE. The archive wrote each country once, so
1080
+ // this loop never had to ask; a plan carries both spellings — the register's EM and GB alongside the EU
1081
+ // and UK a reader knows — and aliasing them afterwards produced two EU rows and two UK rows on the
1082
+ // first worldwide run measured through it. The band is looked up across every raw code that folded
1083
+ // into the key, so a conflict recorded under EM still reaches the EU row.
1084
+ const rawByKey = new Map();
1054
1085
  for (const c of codes) {
1055
1086
  const key = alias[c] || c;
1087
+ if (!rawByKey.has(key)) rawByKey.set(key, []);
1088
+ rawByKey.get(key).push(c);
1089
+ }
1090
+ const withF = [], clean = [];
1091
+ for (const [key, raws] of rawByKey) {
1092
+ const c = raws.find((r) => bandBy.has(r)) ?? raws[0];
1056
1093
  // THE NAME IS LOOKED UP ON THE ALIASED KEY, NOT THE RAW CODE. The register writes the EUIPO and
1057
1094
  // ISO spellings — EM and GB — and `alias` maps those to the codes a reader knows, EU and UK. The
1058
1095
  // name was resolved from the RAW code, which has no entry under either spelling, so it fell back
@@ -1067,7 +1104,7 @@ function whereItStandsSection(findings, opts) {
1067
1104
  const cleanHtml = clean.length
1068
1105
  ? `<div class="wclean"><span class="wk">Nothing found</span>${clean.map((c) => `<span class="wchip" title="${escAttr(c.name)}">${esc(clean.length > 12 ? c.code : c.name)}</span>`).join('')}</div>`
1069
1106
  : '';
1070
- return `<div class="sec" id="countries"><h2>Where it stands</h2></div>
1107
+ return `<div class="sec" id="countries"><span class="num"></span><h2>Where it stands</h2></div>
1071
1108
  <div class="panel where">${rows}${cleanHtml}</div>`;
1072
1109
  }
1073
1110
 
@@ -1076,17 +1113,29 @@ function whereItStandsSection(findings, opts) {
1076
1113
  function courtDecisionsSection(opts) {
1077
1114
  const sd = opts && opts.searchDepth;
1078
1115
  if (!sd || !sd.counts || !isFullCountry(opts)) return '';
1079
- const state = sd.counts.courtDecisions;
1080
- const byC = sd.counts.recordsByCountry || {};
1116
+ const line = courtLineFor(opts, sd.counts.courtDecisions);
1117
+ if (!line) return '';
1118
+ return `<div class="sec" id="court"><span class="num"></span><h2>Court decisions</h2></div>
1119
+ <div class="panel courtp"><p>${esc(line)}</p></div>`;
1120
+ }
1121
+
1122
+ // The court section's sentence for a state, and the one the Court decisions row carries under its state
1123
+ // word when the research could not be completed — one sentence, said in both places in the same words.
1124
+ function courtLineFor(opts, state) {
1125
+ const sd = opts && opts.searchDepth;
1126
+ const byC = (sd && sd.counts && sd.counts.recordsByCountry) || {};
1081
1127
  const first = Object.keys(byC).filter((c) => c !== 'WO')[0];
1082
1128
  const where = first ? (regionName(first) || first) : '';
1129
+ // The country comes from the record listing, so a run whose provider archives nothing has no name to
1130
+ // put here — and every branch below embedded it mid-sentence. The page then read "Case-law research
1131
+ // could not be completed for ." Each clause is now attached to the name rather than assuming one:
1132
+ // same words when there is a country, one clause shorter when there is not.
1133
+ const forWhere = where ? ` for ${where}` : '';
1083
1134
  let line = '';
1084
- if (state === 'not-checked') line = `Case-law research could not be completed for ${where}.`;
1085
- else if (state === 'none-found') line = `Court decisions: none found for ${where}.`;
1086
- else if (state === 'found') line = `Court decisions were searched for ${where} and are cited against the findings above.`;
1087
- if (!line) return '';
1088
- return `<div class="sec" id="court"><h2>Court decisions</h2></div>
1089
- <div class="panel courtp"><p>${esc(line)}</p></div>`;
1135
+ if (state === 'not-checked') line = `Case-law research could not be completed${forWhere}.`;
1136
+ else if (state === 'none-found') line = `Court decisions: none found${forWhere}.`;
1137
+ else if (state === 'found') line = `Court decisions were searched${forWhere} and are cited against the findings above.`;
1138
+ return line;
1090
1139
  }
1091
1140
 
1092
1141
  // Counts only, folded closed. A count of records read is engine activity rather than something a reader
@@ -1101,16 +1150,72 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
1101
1150
  const sd = opts && opts.searchDepth;
1102
1151
  const open = (Array.isArray(coverage) ? coverage : []).filter((c) => COV_STATE[c?.state]?.cls !== 'ok');
1103
1152
  const c = (sd && sd.counts) || {}, rows = [];
1153
+ // THE REGISTER ROW, AS THE BOARDS DRAW IT: one line of totals — records read across the countries, plus
1154
+ // the international registrations apart from them — and the per-country counts as chips under it, code
1155
+ // first and the name on hover. One country is named in the line instead ("141 records read in Japan"),
1156
+ // and draws no chips: a single chip would repeat the line.
1104
1157
  const byC = c.recordsByCountry || {};
1105
- const per = Object.entries(byC).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${regionName(k) || k} ${n.toLocaleString('en-GB')}`).join(' \u00b7 ');
1106
- if (per) rows.push(['Register records read', per]);
1158
+ const intl = Number(byC.WO) || 0;
1159
+ const countries = Object.entries(byC).filter(([k, n]) => k !== 'WO' && Number(n) > 0).sort((a, b) => b[1] - a[1]);
1160
+ const read = countries.reduce((t, [, n]) => t + Number(n), 0);
1161
+ if (countries.length || intl) {
1162
+ const where = countries.length === 1 ? ` in ${regionName(countries[0][0]) || countries[0][0]}`
1163
+ : countries.length ? ` across ${countries.length.toLocaleString('en-GB')} countries` : '';
1164
+ const plus = intl ? `${countries.length ? ', plus ' : ''}${intl.toLocaleString('en-GB')} international registration${intl === 1 ? '' : 's'}` : '';
1165
+ const line = `${countries.length ? `${read.toLocaleString('en-GB')} record${read === 1 ? '' : 's'} read${where}` : ''}${plus}`;
1166
+ const chips = countries.length > 1
1167
+ ? `<div class="wchips">${countries.map(([k, n]) => `<span class="wchip" title="${escAttr(regionName(k) || k)}">${esc(k)} <b>${Number(n).toLocaleString('en-GB')}</b></span>`).join('')}</div>`
1168
+ : '';
1169
+ rows.push(['Register', { html: `${esc(line)}${chips}` }]);
1170
+ }
1107
1171
  const sw = c.sweep || {};
1108
1172
  if (sw.spellings) rows.push(['Spellings searched', String(sw.spellings)]);
1109
- if (sw.checks) rows.push(['Marketplace and web', `${sw.checks.toLocaleString('en-GB')} checks across ${sw.platforms} platforms`]);
1173
+ if (sw.checks) rows.push(['Marketplace and web', `${sw.checks.toLocaleString('en-GB')} checks on ${sw.platforms} platforms`]);
1110
1174
  if (sw.reputation) rows.push(['Reputation and meaning', `${sw.reputation.toLocaleString('en-GB')} checks`]);
1111
- rows.push(['Local-script spellings', c.localScriptSearched ? 'searched' : 'not searched']);
1112
- const cd = { 'found': 'found', 'none-found': 'none found', 'not-checked': 'could not be checked', 'not-in-scope': 'not part of this search' }[c.courtDecisions];
1113
- if (cd) rows.push(['Court decisions', cd]);
1175
+ rows.push(['Local-script spellings', c.localScriptSearched ? 'Searched' : 'Not searched']);
1176
+ // HOW DEEP THE LOCAL-LANGUAGE INVESTIGATION WENT, which is a different question from the row above it.
1177
+ // That one answers whether the spellings were searched; this one answers whether the investigation ran
1178
+ // at the depth the matter configured. The engine can run it shallower than the account asked for, and
1179
+ // it used to say so in exactly one place — a sentence a model wrote into the Methodology paragraph the
1180
+ // redesign replaced with named rows — so a run that went shallow said so on no page at all.
1181
+ //
1182
+ // EVERY ONE OF THE FOUR WORDS IS ALREADY ON THE PAGE. "Included" and "Not part of this search" are the
1183
+ // approved board's own values for this row; "Partially covered" and "Not run this run" are the coverage
1184
+ // vocabulary this file already renders, taken from COV_STATE rather than retyped so they cannot drift
1185
+ // apart from it. Nothing here composes a sentence.
1186
+ //
1187
+ // A state this table has no word for draws NO ROW, rather than the nearest word. The four are the whole
1188
+ // set the record can produce, so the fallthrough is unreachable today and is there for the fifth state
1189
+ // somebody adds: a row is a claim about a client's search, and the nearest word to an unknown state is
1190
+ // a claim nobody checked.
1191
+ const LL_WORD = {
1192
+ ran: 'Included',
1193
+ 'ran-shallow': COV_STATE['coverage-limited'].word,
1194
+ 'not-run': COV_STATE['not-searched'].word,
1195
+ 'not-in-scope': 'Not part of this search',
1196
+ };
1197
+ //
1198
+ // AND THE ROW NEEDS A LANE RECORD BEHIND IT, not just a state. The state folds to `not-in-scope` when
1199
+ // there is no lane record at all, which is right for a clearance that never asked for the
1200
+ // investigation and WRONG for a run that asked and whose record was never written. Measured on the
1201
+ // full country demo: its own coverage carries "Native-language investigation depth / ja — the
1202
+ // configured depth for this lane was full and this run delivered a depth this run cannot establish",
1203
+ // and the state beside it reads not-in-scope, so this row would have told that reader the
1204
+ // investigation was not part of their search. An empty `lanes` map is that absence, and an absence is
1205
+ // not a finding — so the row is drawn from a record and not from a default. A run whose record exists
1206
+ // draws it on every one of the four states.
1207
+ const llLanes = c.localLanguage?.lanes;
1208
+ const llRecorded = !!llLanes && typeof llLanes === 'object' && Object.keys(llLanes).length > 0;
1209
+ const ll = llRecorded ? LL_WORD[c.localLanguage?.state] : null;
1210
+ if (ll) rows.push(['Local-language investigation', ll]);
1211
+ // COURT DECISIONS, AS THE FULL COUNTRY BOARD DRAWS THE ROW: its state, and for a search that could not
1212
+ // be completed, the same sentence the court section under it prints. A search that does not include
1213
+ // court decisions draws no row, as the other boards draw none.
1214
+ const cdWord = { 'found': 'Found', 'none-found': 'None found', 'not-checked': 'Not searched' }[c.courtDecisions];
1215
+ if (cdWord && isFullCountry(opts)) {
1216
+ const why = c.courtDecisions === 'not-checked' ? courtLineFor(opts, 'not-checked') : '';
1217
+ rows.push(['Court decisions', { html: `<span class="wstate">${esc(cdWord)}</span>${why ? `<span class="wnote">${esc(why)}</span>` : ''}` }]);
1218
+ }
1114
1219
  const openHtml = open.length ? `<div class="openrows"><div class="rk">Left open</div>${coverageGrid(open)}</div>` : '';
1115
1220
  // THE LEGEND FOR WHAT IS ON THE CARDS, kept when the scope fold went. It is not the engine narrating
1116
1221
  // its own searching — it is what a linked registration number opens, why the rest are cited by number,
@@ -1122,9 +1227,14 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
1122
1227
  const prov = (hasRecordSet || hasCards)
1123
1228
  ? `<div class="provwrap"><div class="rk">Record provenance</div><p class="provnote">${hasRecordSet ? 'Registration numbers on the cards were read from the register records. ' : ''}${officeLinkNote()}${hasIndexEntry ? 'A registration shown as a register-index entry was seen in the register index; its full record was not pulled. ' : ''}\u201cInferred\u201d beside an owner\u2019s likelihood to object means we judged it from what the owner sells and holds; we had no enforcement history to read.</p></div>`
1124
1229
  : '';
1230
+ // THE BOARDS END THE SECTION WITH THE WORKBOOK: every search and its result are in the audit workbook,
1231
+ // which is where a reader who wants the record goes.
1232
+ const more = opts?.auditFile
1233
+ ? `<div class="cmore"><a class="wb" href="${escAttr(opts.auditFile)}">Every search and result, in the audit workbook</a></div>` : '';
1125
1234
  if (!rows.length && !openHtml && !prov) return '';
1126
- return `<details class="searched"><summary><span class="gname">What was searched</span><span class="gcount">Counts for this search</span></summary><div class="gbody">${
1127
- rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(v)}</span></div>`).join('')}${openHtml}${prov}</div></details>`;
1235
+ return `<div class="sec" id="searched"><span class="num"></span><h2>What was searched</h2></div>
1236
+ <details class="searched"><summary><span class="gname">Counts for this search</span></summary><div class="gbody">${
1237
+ rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${typeof v === 'object' ? v.html : esc(v)}</span></div>`).join('')}${openHtml}${prov}${more}</div></details>`;
1128
1238
  }
1129
1239
 
1130
1240
  function alsoConsideredSection(ruledOut, recordsByUri = new Map(), opts = {}) {
@@ -1152,7 +1262,7 @@ function alsoConsideredSection(ruledOut, recordsByUri = new Map(), opts = {}) {
1152
1262
  const groups = clearedGroupsHtml(sd, opts.auditFile);
1153
1263
  const web = webNamesHtml(sd);
1154
1264
  if (!ruledCards && !groups && !web) return '';
1155
- return `<div class="sec" id="also-considered"><h2>Also considered</h2></div>
1265
+ return `<div class="sec" id="also-considered"><span class="num"></span><h2>Also considered</h2></div>
1156
1266
  <div class="panel alsocons">${ruledCards ? `<div class="ruled-cards">${ruledCards}</div>` : ''}${groups || web ? `<div class="cgroups">${groups}${web}</div>` : ''}</div>`;
1157
1267
  }
1158
1268
 
@@ -2129,7 +2239,7 @@ ${EXPORT_MENU_JS}`;
2129
2239
  function markAssessmentBlock(ma) {
2130
2240
  if (ma == null) return '';
2131
2241
  const structured = typeof ma.distinctiveness === 'object' || typeof ma.connotation === 'object';
2132
- const SEC = `<div class="sec"><h2>The mark itself</h2></div>`;
2242
+ const SEC = `<div class="sec"><span class="num"></span><h2>The mark itself</h2></div>`;
2133
2243
  if (!structured) {
2134
2244
  const dist = String(ma?.distinctiveness ?? '').trim(), conn = String(ma?.connotation ?? '').trim();
2135
2245
  if (!dist && !conn) return '';
@@ -2199,7 +2309,7 @@ function contextNotesList(notes = []) {
2199
2309
  // the findings-section assembly below.
2200
2310
  function contextNotesBlock(notes = []) {
2201
2311
  if (!notes.length) return '';
2202
- return `<div class="sec"><h2>Famous-mark neighbours noted</h2></div>
2312
+ return `<div class="sec"><span class="num"></span><h2>Famous-mark neighbours noted</h2></div>
2203
2313
  ${contextNotesList(notes)}`;
2204
2314
  }
2205
2315
 
@@ -2332,6 +2442,13 @@ function depthStripHtml(note) {
2332
2442
  }
2333
2443
 
2334
2444
  // Signature: renderHtml(parsed, findings, coverage, opts). ONE render path — there is no client variant.
2445
+ // THE BOARD'S FIVE ENTRIES, in its order. The rule that filters them lives in shared/brand.mjs, because
2446
+ // the knockout renderer draws a strip from its own board and the filtering is the half worth having once.
2447
+ const STRIP = Object.freeze([
2448
+ ['summary', 'Summary'], ['findings', 'Findings'], ['also-considered', 'Also considered'],
2449
+ ['next', 'Next steps'], ['searched', 'What was searched'],
2450
+ ]);
2451
+
2335
2452
  export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2336
2453
  if (findings && !Array.isArray(findings)) { opts = findings; findings = []; coverage = []; } // tolerate legacy (parsed, opts)
2337
2454
  AS_OF = opts.asOf ?? null; // C2
@@ -2479,13 +2596,13 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2479
2596
  const hasCL = Boolean(clBody);
2480
2597
  let findingsSections, covNum;
2481
2598
  if (!DISPOSITION_MODE) {
2482
- findingsSections = `${onField.length ? `<div class="sec"><h2>The conflict landscape</h2></div>
2599
+ findingsSections = `${onField.length ? `<div class="sec" id="findings"><span class="num"></span><h2>The conflict landscape</h2></div>
2483
2600
  <div class="landwrap">${quadrant(sorted)}${keyPanel(sorted, recordsByUri)}</div>
2484
2601
 
2485
- <div class="sec"><h2>Conflicts</h2></div>
2602
+ <div class="sec"><span class="num"></span><h2>Conflicts</h2></div>
2486
2603
  ${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}` : ''}
2487
2604
 
2488
- ${secReg.length ? `<div class="sec"><h2>Secondary &amp; watch</h2></div>
2605
+ ${secReg.length ? `<div class="sec"><span class="num"></span><h2>Secondary &amp; watch</h2></div>
2489
2606
  ${secondaryRegions(secReg, cardFor, recordsByUri)}` : ''}${hasCL ? `
2490
2607
 
2491
2608
  ${clBody}` : ''}`;
@@ -2494,10 +2611,10 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2494
2611
  let secNum = 0;
2495
2612
  const num = () => String(++secNum).padStart(2, '0');
2496
2613
  const landscape = onField.length
2497
- ? `<div class="sec"><h2>The conflict landscape</h2></div>
2614
+ ? `<div class="sec" id="findings"><span class="num"></span><h2>The conflict landscape</h2></div>
2498
2615
  <div class="landwrap">${quadrant(sorted)}${keyPanel(sorted, recordsByUri)}</div>
2499
2616
 
2500
- <div class="sec"><h2>Conflicts</h2></div>
2617
+ <div class="sec"><span class="num"></span><h2>Conflicts</h2></div>
2501
2618
  ${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}`
2502
2619
  : '';
2503
2620
  // THE COMMON-LAW CARDS GO WITH THE CONFLICTS, NOT UNDER THE NEXT HEADING. Appended at the end they
@@ -2524,7 +2641,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2524
2641
  const notes = contextNotes.length ? `\n <p class="fold-lead"><b>Famous-mark neighbours.</b> Diligence — no register record; not scored, does not affect the risk read.</p>
2525
2642
  ${contextNotesList(contextNotes)}` : '';
2526
2643
  if (negatives.total || notes) {
2527
- tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
2644
+ tail += `\n\n <div class="sec"><span class="num"></span><h2>Notable but manageable</h2></div>
2528
2645
  ${negatives.total ? reasonedNegatives(negatives.groups, cardFor, recordsByUri) : ''}${notes}`;
2529
2646
  }
2530
2647
  } else if (band2r.length || band3r.length || contextNotes.length) {
@@ -2534,7 +2651,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2534
2651
  ${secondaryRegions(band3r, cardFor, recordsByUri)}`);
2535
2652
  if (contextNotes.length) parts.push(`<p class="fold-lead"><b>Famous-mark neighbours.</b> Diligence — no register record; not scored, does not affect the risk read.</p>
2536
2653
  ${contextNotesList(contextNotes)}`);
2537
- tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
2654
+ tail += `\n\n <div class="sec"><span class="num"></span><h2>Notable but manageable</h2></div>
2538
2655
  ${parts.join('\n ')}`;
2539
2656
  }
2540
2657
  // The 2026-09-16 report redesign — the section heading and its routing notice go; the CARDS stay, or a
@@ -2564,7 +2681,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2564
2681
  const linkChrome = !!opts.chromeHref;
2565
2682
  const cssInline = REPORT_BASE + '\n' + (linkChrome ? '' : NAV_SHEET) + '\n' + REP_OVERRIDE;
2566
2683
  const chromeLinkTag = linkChrome ? `<link rel="stylesheet" href="${escAttr(opts.chromeHref)}">` : '';
2567
- return `<!DOCTYPE html>
2684
+ const doc = `<!DOCTYPE html>
2568
2685
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
2569
2686
  <title>${productName ? `${esc(productName)} — ` : ''}${esc(fm.title || '')} · ${esc(BRAND.name)}</title>
2570
2687
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
@@ -2604,12 +2721,13 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2604
2721
  `)}
2605
2722
  </div>
2606
2723
  </div>
2724
+ <!--SECTION-STRIP-->
2607
2725
  </div>
2608
2726
 
2609
2727
  <div class="wrap">
2610
2728
  <!-- doc-52 §1 THE VERDICT — the answer leads: mark · band · one plain "subject to" line · the lawyer's
2611
2729
  questions answered · the time-critical alert. Process + coverage detail are below and collapsible. -->
2612
- <header class="hero">
2730
+ <header class="hero" id="summary">
2613
2731
  ${demoBannerHtml(opts.demoData === true)}
2614
2732
  ${confLineHtml(opts.delivery, productName)}
2615
2733
  <h1 class="mark">${esc(fm.title || '')}</h1>
@@ -2635,7 +2753,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2635
2753
  ${alsoConsideredSection(ruledOut, recordsByUri, opts)}
2636
2754
 
2637
2755
  <!-- doc-52 §3 WHAT ONLY YOU CAN CLOSE — forward decisions, plain English, after the findings. -->
2638
- ${buckets.you ? `<div class="sec" id="only-you"><h2>What only you can close</h2></div>
2756
+ ${buckets.you ? `<div class="sec" id="next"><span class="num"></span><h2>What happens next</h2></div>
2639
2757
  <div class="panel actions"><div class="actgrp act-you">${renderProse(buckets.you.body)
2640
2758
  .replace(/\[Time-critical\]\s*/gi, '<span class="src cl" style="margin-right:6px">Time-critical</span> ')
2641
2759
  .replace(/\[Open question\]\s*/gi, '<span class="src" style="margin-right:6px">Open question</span> ')
@@ -2707,6 +2825,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2707
2825
  </div>
2708
2826
  <script>${PAGE_JS}</script>
2709
2827
  </body></html>`;
2828
+ return doc.replace('<!--SECTION-STRIP-->', sectionStrip(doc, STRIP));
2710
2829
  }
2711
2830
 
2712
2831
  // CLI: node render.mjs <report.md> [findings.json] [outDir]