clearotron 0.3.2-beta.3 → 0.3.2-beta.5

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.
@@ -25,6 +25,7 @@ import { fileURLToPath } from 'node:url';
25
25
  // its own split, which is how the two rules diverged. It calls stripTelemetry now, so the renderer holds
26
26
  // no copy of the RULE either, only a call to it.
27
27
  import { parseReport, stripInternal, stripTelemetry } from './parse.mjs';
28
+ import { clientConditions } from '../terminal-clamp.mjs'; // the reader's clause per condition, shared with the cover note
28
29
  import { COMMON_LAW, normRegion, regionName, REGION_NAMES } from './regions.mjs';
29
30
  import { parseFindingsJson, bindRecommendation, sentenceCaseLead, CLIENT_TIER_BY_COMPOSITE, bandOf, compareBlockingPower, inDispositionMode, reasonedNegativeGroups } from '../findings-model.mjs';
30
31
  import { REC, inPriorityWindow, ownerDisplayName } from '../registry-fidelity.mjs';
@@ -84,6 +85,12 @@ let NEGATIVES_GROUPED = false;
84
85
  // present the report speaks ITS band words: chips/one-liners read f.band, the gauge ticks show its
85
86
  // ladder, the footer names it. Absent ⇒ every legacy (composite) surface renders byte-identically.
86
87
  let FRAMEWORK = null;
88
+ // The 2026-09-16 report redesign — the depth rule, as one flag. The same sections render at every depth; what grows
89
+ // is what a finding's Full detail fold carries, and the goods as registered and the record's dates are
90
+ // the two blocks the design gives the full country alone. Module-level for the same reason FRAMEWORK is:
91
+ // fullDetail is reached through four call sites and threading a flag through all of them to reach one
92
+ // `if` is how the other three drift out of step.
93
+ let FULL_COUNTRY = false;
87
94
  const GAUGE_STOP_BY_TONE = { minimal: 1, low: 1, medium: 2, high: 3, severe: 4 };
88
95
  // Severity rank for sorting/worst-of: band index in the manifest (0 = most severe); legacy composite
89
96
  // negated so higher composite = lower rank. Unrated items sink to the bottom.
@@ -131,7 +138,86 @@ function frameworkTickIndex(findings) {
131
138
  }
132
139
  return null;
133
140
  }
134
- function frameworkGauge(fm, findings) {
141
+ // ── THE RATING CARD'S BODY ─────────────────────────────────────────────────────────────────────────
142
+ // One card across every report type (the 2026-09-16 report redesign): the band on the company's own ladder, the
143
+ // verdict with EVERY condition, "Why <band>" as the four answers with the basis each rests on, the
144
+ // highest exposure, and what was searched to get there. Nothing here is composed — the conditions are
145
+ // the sidecar's own client-voice clauses, the answers are the engine's, and the coverage line is counts.
146
+ function ratingExtras(fm, findings, coverage, fourAnswers, opts, bandWord) {
147
+ const out = [];
148
+ const conds = clientConditions(VERDICT_INFO || {});
149
+ if (conds.length) out.push(`<div class="gconds-wrap"><span class="gk">Conditions</span><ul class="gconds">${
150
+ conds.map((c) => `<li>${esc(c)}</li>`).join('')}</ul></div>`);
151
+ const fa = fourAnswers && typeof fourAnswers === 'object' ? fourAnswers : null;
152
+ if (fa) {
153
+ const rows = FOUR_ANSWER_LABELS.map(([key, label], i) => {
154
+ const a = fa[key];
155
+ if (!a || typeof a.read !== 'string' || !a.read.trim()) return '';
156
+ const read = inline(a.read.trim()).trim();
157
+ // AN INTERNAL ANSWER DRAWS NO ROW. This block used to mark a wholly-internal read with `int-note`
158
+ // and render it; the class went with the redesign's removal of internal material from the page,
159
+ // and the row went on rendering — so staff-only prose reached a client's report with nothing
160
+ // marking it at all, which is worse than either state before it. Every other internal line in this
161
+ // file is DROPPED rather than marked (the legal and practical reads, the obstacle notes just
162
+ // below), and this now matches them. A row with no answer left is furniture: the defect the
163
+ // marking was introduced for was a label with its text cut away, and no row leaves no label.
164
+ if (isInternalLabelled(read)) return '';
165
+ if (!read) return '';
166
+ const tok = String(a.token ?? '').toLowerCase();
167
+ let tone = 'mid';
168
+ if (i < 2) tone = /weak|unlikely|none|low/.test(tok) ? 'good' : /strong|likely|high/.test(tok) ? 'bad' : 'mid';
169
+ else tone = /^(registrable|strong|clear)$/.test(tok) ? 'good' : /condition|moderate|medium|qualified|narrow/.test(tok) ? 'mid' : /not|weak|unlikely|blocked|obstructed/.test(tok) ? 'bad' : 'mid';
170
+ const ords = (Array.isArray(a.ordinals) && a.ordinals.length)
171
+ ? ` <span class="fa-ords">${a.ordinals.map((n) => `<a href="#c${n}">#${n}</a>`).join(' ')}</span>` : '';
172
+ const basis = a.basis ? inline(String(a.basis).trim()).trim() : '';
173
+ // THE PER-CLASS OBSTACLES RIDE WITH THE ANSWER THEY QUALIFY. An answer that reads "registrable with
174
+ // conditions" and does not say in which classes leaves the reader with the conclusion and none of
175
+ // the thing they act on. Internal-labelled rows drop, like every other internal line.
176
+ const obsRows = (key === 'registrability' && Array.isArray(a.obstacles) ? a.obstacles : [])
177
+ .map((o) => ({ cls: esc(String(o?.class ?? '')), note: inline(String(o?.note ?? '').trim()).trim() }))
178
+ .filter((o) => o.note && !isInternalLabelled(o.note));
179
+ const obstacles = obsRows.length
180
+ ? `<ul class="fa-obstacles">${obsRows.map((o) => `<li><b>Class ${o.cls}.</b> ${o.note}</li>`).join('')}</ul>` : '';
181
+ return `<div class="fa-row"><span class="fa-k">${label}</span><span class="fa-v">${
182
+ a.token ? `<span class="fa-token tone-${tone}">${esc(humanize(a.token))}</span> — ` : ''}${read}${ords}${
183
+ basis ? `<div class="fa-based"><span class="fa-bk">Based on</span> ${basis}</div>` : ''}${obstacles}</span></div>`;
184
+ }).filter(Boolean);
185
+ if (rows.length) out.push(`<div class="gwhy"><div class="label">Why ${esc(bandWord || '')}</div>${rows.join('')}</div>`);
186
+ }
187
+ const bestRank = findings.reduce((m, f) => Math.min(m, severityRank(f)), Number.MAX_SAFE_INTEGER);
188
+ const worstSet = bestRank === Number.MAX_SAFE_INTEGER ? [] : findings.filter((f) => severityRank(f) === bestRank);
189
+ const worst = worstSet.length
190
+ ? [...new Set(worstSet.map((f) => regionCode(f)))].map((c) => c === COMMON_LAW ? 'Common-law use' : esc(regionName(c) || c))
191
+ : [];
192
+ if (worst.length) out.push(`<div class="grow gexp"><span class="gk">Highest exposure</span><span class="gv">${worst.join(' \u00b7 ')}</span></div>`);
193
+ const sd = opts && opts.searchDepth;
194
+ if (sd && sd.counts) {
195
+ const parts = [];
196
+ const byC = sd.counts.recordsByCountry || {};
197
+ const real = Object.entries(byC).filter(([c]) => c !== 'WO');
198
+ const intl = byC.WO || 0;
199
+ const records = real.reduce((s, [, n]) => s + n, 0);
200
+ if (records) parts.push(real.length === 1 ? `${records.toLocaleString('en-GB')} records read in ${regionName(real[0][0]) || real[0][0]}` : `${records.toLocaleString('en-GB')} records read across ${real.length} countries`);
201
+ if (intl) parts.push(`${intl.toLocaleString('en-GB')} international registrations read`);
202
+ const cleared = ((sd.cleared && sd.cleared.register) || []).length;
203
+ if (cleared) parts.push(`${cleared.toLocaleString('en-GB')} near-names cleared`);
204
+ const rated = findings.filter((f) => f && f.band).length;
205
+ parts.push(`${rated} ${rated === 1 ? 'conflict' : 'conflicts'}`);
206
+ if (sd.counts.localScriptSearched) parts.push('the name searched in local script');
207
+ const cd = sd.counts.courtDecisions;
208
+ if (cd === 'found') parts.push('court decisions checked');
209
+ else if (cd === 'none-found') parts.push('court decisions checked, none touch the name');
210
+ else if (cd === 'not-checked') parts.push('court decisions could not be checked');
211
+ if (parts.length) out.push(`<div class="grow gconf"><span class="gk">Search coverage</span><span class="gv">${parts.map(esc).join(' \u00b7 ')}</span></div>`);
212
+ }
213
+ // ONE NAME FOR ONE FRAMEWORK. A manifest run already prints it in the card's own heading, beside the
214
+ // ladder whose words it governs; adding a second line here named it twice on the same card. The line
215
+ // is for the run that records a framework by name without carrying its manifest.
216
+ if (!FRAMEWORK && fm.rated_under) out.push(`<div class="gframe">Rated on the ${esc(fm.rated_under)}</div>`);
217
+ return out.join('');
218
+ }
219
+
220
+ function frameworkGauge(fm, findings, coverage = [], fourAnswers = null, opts = {}) {
135
221
  const ladder = [...FRAMEWORK.bands].reverse(); // manifest is most-severe-first → reverse for left→right
136
222
  const n = ladder.length;
137
223
  const pct = (k) => `${(((k + 0.5) / n) * 100).toFixed(1)}%`; // center of flex tick cell k — marker and ticks agree by construction
@@ -169,6 +255,7 @@ function frameworkGauge(fm, findings) {
169
255
  <div class="scale" style="background:${grad}">${marker}</div>
170
256
  <div class="ticks">${ticks}</div>
171
257
  <div class="gconc">${conc}</div>
258
+ ${ratingExtras(fm, findings, coverage, fourAnswers, opts, label)}
172
259
  </div>`;
173
260
  }
174
261
 
@@ -241,16 +328,27 @@ function inline(s) {
241
328
  // internal legal_position/practical_position/manageable.reason/obstacle note prints into the PDF
242
329
  // while the same bytes in prose do not.
243
330
  const isInternalLabelled = (s) => /^\s*\[internal\]/i.test(String(s ?? ''));
244
- const intNoteCls = (s) => isInternalLabelled(s) ? ' class="int-note"' : '';
245
331
 
246
332
  // Markdown prose (paragraphs + `- ` lists) → HTML. Internal notes are handled by the stripInternal
247
333
  // choke point (whole-internal bullets disappear on the client export; labelled internally).
248
334
  function renderProse(text) {
249
335
  const lines = stripInternal(text || '', { client: false }).split('\n');
250
336
  let html = '', list = [], para = [];
251
- const intCls = intNoteCls;
252
- const flushList = () => { if (list.length) { html += '<ul>' + list.map(li => `<li${intCls(li)}>${inline(li)}</li>`).join('') + '</ul>'; list = []; } };
253
- const flushPara = () => { if (para.length) { const txt = para.join(' '); html += `<p${intCls(txt)}>${inline(txt)}</p>`; para = []; } };
337
+ // Item 18 (owner, 2026-09-16): a line the engine labels internal does not reach the delivered page.
338
+ // It was classed and hidden in print only, so every client reading in the portal saw it. It stays in
339
+ // the findings record, the audit workbook and the assistant, which is where the audit belongs.
340
+ const flushList = () => {
341
+ if (!list.length) return;
342
+ const kept = list.filter((li) => !isInternalLabelled(li));
343
+ if (kept.length) html += '<ul>' + kept.map((li) => `<li>${inline(li)}</li>`).join('') + '</ul>';
344
+ list = [];
345
+ };
346
+ const flushPara = () => {
347
+ if (!para.length) return;
348
+ const txt = para.join(' ');
349
+ if (!isInternalLabelled(txt)) html += `<p>${inline(txt)}</p>`;
350
+ para = [];
351
+ };
254
352
  for (const raw of lines) {
255
353
  const line = raw.trim();
256
354
  if (!line) { flushPara(); flushList(); continue; }
@@ -445,7 +543,9 @@ function meter(name, entry) {
445
543
  return { label: 'Use', cls, cap, ev: USE_EVIDENCE_LABEL[e._status] || '' };
446
544
  }
447
545
  const cls = tok === 'high' ? 'l3' : tok === 'medium' ? 'l2' : tok === 'low' ? 'l1' : '';
448
- const label = name === 'mark_similarity' ? 'Similarity' : name === 'goods_proximity' ? 'Goods proximity' : 'Enforcer';
546
+ // The 2026-09-16 report redesign — "Enforcer" was a noun for the owner; the meter measures what the owner is
547
+ // likely to DO, which is what a reader is deciding about. The token under it is unchanged.
548
+ const label = name === 'mark_similarity' ? 'Similarity' : name === 'goods_proximity' ? 'Goods proximity' : 'Likely to enforce';
449
549
  // With a joined status the status REPLACES the raw basis word on the enforcer — "verified" with no
450
550
  // receipt behind it is exactly the self-attestation A4 exists to demote.
451
551
  return { label, cls, cap: humanize(tok), ev: st || (name === 'enforcer' ? basis : '') };
@@ -478,10 +578,10 @@ const cardBlock = (card, re) => (card?.blocks || []).filter(b => re.test(b.label
478
578
  // Conclusion card (left of the hero): the overall-risk dial + ticks, then the verdict beneath it —
479
579
  // Recommendation + Open conditions (moved here from the old facts card; the summary paragraph now lives once
480
580
  // under the headline, so the gauge's old caption <p> is dropped — §2.6 de-duplication).
481
- function gauge(fm, findings, coverage = []) {
581
+ function gauge(fm, findings, coverage = [], fourAnswers = null, opts = {}) {
482
582
  // doc-54 — framework runs get the manifest's own dynamic ladder; the legacy body below is untouched
483
583
  // so archived / sidecar-less runs render byte-identically.
484
- if (FRAMEWORK) return frameworkGauge(fm, findings);
584
+ if (FRAMEWORK) return frameworkGauge(fm, findings, coverage, fourAnswers, opts);
485
585
  const i = overallIndex(fm, findings);
486
586
  // wp50: with a verdict sidecar the pill speaks the client tier word (VERY HIGH, never SEVERE) —
487
587
  // the derived tier when present, else the positional word; legacy (no sidecar) is byte-identical.
@@ -513,6 +613,7 @@ function gauge(fm, findings, coverage = []) {
513
613
  <div class="scale"><div class="marker" style="left:${STOP_LEFT[i]}"><div class="pill" style="background:var(${STOP_VAR[i]})">${label}</div><div class="needle" style="background:var(${STOP_VAR[i]})"></div></div></div>
514
614
  <div class="ticks">${ticks}</div>
515
615
  <div class="gconc">${conc}</div>
616
+ ${ratingExtras(fm, findings, coverage, fourAnswers, opts, label)}
516
617
  </div>`;
517
618
  }
518
619
 
@@ -670,6 +771,40 @@ function headScope(fm, coverage = [], findings = []) {
670
771
  return `<div class="mark-scope" style="${style}">${parts.filter(Boolean).join(' · ')}</div>`;
671
772
  }
672
773
 
774
+ // ── ABOUT THIS REQUEST ─────────────────────────────────────────────────────────────────────────────
775
+ // The scope line, the facts card and the covers sentence were three places saying what was asked for.
776
+ // They are one labelled panel now, directly under the name, on every report type (the 2026-09-16 report redesign).
777
+ // Every row is a fact the run already carries: nothing here is composed.
778
+ function aboutPanel(fm, coverage = [], findings = [], opts = {}) {
779
+ const { order: codes, meta, worldwide } = jurisdictionCodes(fm, coverage, findings);
780
+ // A JURISDICTION SEARCHED WITH LIMITED COVERAGE SAYS SO HERE. The chips this row replaces carried that
781
+ // on the office's own chip; a plain list of country names would read as "all of these were searched"
782
+ // and quietly drop the one thing in the row a reader could act on. The worldwide sweep also always
783
+ // covers the international register, which is why WO joins the list on a worldwide run.
784
+ const shown = (worldwide && !codes.includes('WO')) ? [...codes, 'WO'] : codes;
785
+ const where = shown.map((c) => {
786
+ const name = regionName(c) || c;
787
+ return meta.get(c)?.limited ? `${name} (coverage-limited)` : name;
788
+ }).filter(Boolean).join(', ');
789
+ // The type of search is the bolded head of the depth note the driver already composes; the rest of
790
+ // that sentence is the product's covers line, which the owner ruled off the page.
791
+ const note = String(opts.depthNote ?? '').trim();
792
+ const cut = note.lastIndexOf(' — ');
793
+ const stage = cut > 0 ? note.slice(0, cut).trim() : note;
794
+ const rows = [
795
+ ['Type of search', stage],
796
+ ['Instructed use', fm.use || ''],
797
+ ['Classes', fm.classes || ''],
798
+ ['Where searched', worldwide ? (where ? `Worldwide, focus on ${where}` : 'Worldwide') : where],
799
+ ['Project', fm.run_under_project || ''],
800
+ ['Searched on', fm.searched || fm.run || ''],
801
+ ].filter(([, v]) => String(v || '').trim());
802
+ if (!rows.length) return '';
803
+ return `<div class="panel about"><div class="label">About this request</div>${
804
+ rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(v)}</span></div>`).join('')
805
+ }</div>`;
806
+ }
807
+
673
808
  function doFirst(secs) {
674
809
  const t = secs['Do this first'] || secs['Do This First'] || '';
675
810
  if (!t.trim()) return '';
@@ -804,6 +939,165 @@ export function actYouConditions(youBucket) {
804
939
  // doc-52 routing — concept/genre neighbours that share no word or sound with the mark are NOT conflicts
805
940
  // (e.g. a same-theme game under a different name). They render in a quiet, collapsed "also considered —
806
941
  // ruled out" list (owner · mark · why), never as conflict cards and never plotted in the landscape.
942
+ // ── ALSO CONSIDERED ────────────────────────────────────────────────────────────────────────────────
943
+ // The depth section (the 2026-09-16 report redesign). A deeper search reads more and finds no more, and until now the
944
+ // page could not say so: the names read and cleared existed only in the workbook. Three parts, in order —
945
+ // the names this run rated and ruled out, the register near-names it cleared, and the web and marketplace
946
+ // names it wrote up. Everything is folded and capped: nothing on this page is an uncapped list, and the
947
+ // workbook is where the full one lives (owner, 2026-09-16).
948
+ //
949
+ // TWO GROUPS, AND THAT IS THE MEASUREMENT RATHER THAN A SIMPLIFICATION. The group key comes from the
950
+ // register providers' closed screening vocabulary, and across 480 cleared names in three runs the value
951
+ // meaning "different goods" appeared zero times, because such records are dropped by the batch screen
952
+ // before they ever become a cleared near-name. Grouping on the engine's reasoning paragraph instead would
953
+ // produce four groups and assert a reason the record does not support. Owner ruling, 2026-09-16.
954
+ const CLEARED_GROUP_LABEL = { 'dead-filing': 'Dead filings', 'different-goods': 'Different goods', 'other': 'Read and cleared' };
955
+ const CLEARED_GROUP_ORDER = ['different-goods', 'dead-filing', 'other'];
956
+ const NAMES_CAP = 8;
957
+
958
+ function clearedGroupsHtml(searchDepth, auditFile) {
959
+ const reg = (searchDepth && searchDepth.cleared && searchDepth.cleared.register) || [];
960
+ if (!reg.length) return '';
961
+ const link = (n) => auditFile ? `<div class="cmore"><a class="wb" href="${escAttr(auditFile)}">All ${n.toLocaleString('en-GB')} in the audit workbook</a></div>` : '';
962
+ return CLEARED_GROUP_ORDER.map((g) => {
963
+ const items = reg.filter((c) => c.group === g);
964
+ if (!items.length) return '';
965
+ const shown = items.slice(0, NAMES_CAP);
966
+ const rows = shown.map((c) => `<div class="crow"><span class="cm-mark">${esc(c.mark || c.term || '')}</span><span class="cwho">${
967
+ esc([c.owner, regionName(c.country) || c.country, c.classes ? `Class ${c.classes}` : '', c.status].filter(Boolean).join(' \u00b7 '))}</span></div>`).join('');
968
+ return `<details class="cgroup"><summary><span class="gname">${esc(CLEARED_GROUP_LABEL[g] || g)}</span><span class="gcount">${
969
+ items.length.toLocaleString('en-GB')} ${items.length === 1 ? 'name' : 'names'}</span></summary><div class="gbody">${rows}${
970
+ items.length > shown.length ? link(items.length) : ''}</div></details>`;
971
+ }).join('');
972
+ }
973
+
974
+ function webNamesHtml(searchDepth) {
975
+ const web = (searchDepth && searchDepth.cleared && searchDepth.cleared.web) || [];
976
+ if (!web.length) return '';
977
+ const shown = web.slice(0, NAMES_CAP);
978
+ const rows = shown.map((w) => `<div class="crow"><span class="cm-mark">${esc(w.title || '')}</span>${
979
+ w.url ? `<a class="curl" href="${escAttr(w.url)}">${esc(String(w.url).replace(/^https?:\/\//, '').replace(/\/$/, ''))}</a>` : '<span class="cwho"></span>'}</div>`).join('');
980
+ return `<details class="cgroup"><summary><span class="gname">Web and marketplace names</span><span class="gcount">${
981
+ web.length.toLocaleString('en-GB')} ${web.length === 1 ? 'name' : 'names'}</span></summary><div class="gbody">${rows}</div></details>`;
982
+ }
983
+
984
+ // ── WHERE IT STANDS, COURT DECISIONS, WHAT WAS SEARCHED ────────────────────────────────────────────
985
+ // The three sections that let a deeper search show its depth (the 2026-09-16 report redesign). All three read the
986
+ // search-depth record (the 2026-09-16 report redesign) and the findings; none composes a sentence. Where the record
987
+ // is absent — an archived run published before it existed — each returns '' rather than drawing a zero.
988
+ const searchStage = (opts) => {
989
+ const note = String(opts && opts.depthNote || '').trim();
990
+ const cut = note.lastIndexOf(' \u2014 ');
991
+ return (cut > 0 ? note.slice(0, cut) : note).trim();
992
+ };
993
+ const isFullCountry = (opts) => /full country/i.test(searchStage(opts));
994
+
995
+ // Every country the run READ, with its outcome: the band where a finding sits there, and the clean ones
996
+ // named. A list built from the findings alone would name only countries with a conflict, which is the
997
+ // gap this closes — the reader cannot otherwise see that the rest came back clean.
998
+ function whereItStandsSection(findings, opts) {
999
+ const sd = opts && opts.searchDepth;
1000
+ if (!sd || !sd.counts || isFullCountry(opts)) return '';
1001
+ const byC = sd.counts.recordsByCountry || {};
1002
+ const codes = Object.keys(byC).filter((c) => c !== 'WO');
1003
+ if (!codes.length) return '';
1004
+ const bandBy = new Map();
1005
+ for (const f of findings) {
1006
+ const c = regionCode(f);
1007
+ if (!c || c === COMMON_LAW || !f.band) continue;
1008
+ if (!bandBy.has(c)) bandBy.set(c, f.band);
1009
+ }
1010
+ const alias = { EM: 'EU', GB: 'UK' };
1011
+ const withF = [], clean = [];
1012
+ for (const c of codes) {
1013
+ const key = alias[c] || c;
1014
+ (bandBy.has(c) || bandBy.has(key) ? withF : clean).push({ code: key, name: regionName(c) || c, band: bandBy.get(c) || bandBy.get(key) });
1015
+ }
1016
+ if (!withF.length && !clean.length) return '';
1017
+ const rows = withF.map((c) => `<div class="wrow"><span class="rcode">${esc(c.code)}</span><span class="wname">${esc(c.name)}</span><span class="kc">${esc(c.band)}</span></div>`).join('');
1018
+ const cleanHtml = clean.length
1019
+ ? `<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>`
1020
+ : '';
1021
+ return `<div class="sec" id="countries"><h2>Where it stands</h2></div>
1022
+ <div class="panel where">${rows}${cleanHtml}</div>`;
1023
+ }
1024
+
1025
+ // One short section on a full country search, and four states kept apart: none found is a RESULT and
1026
+ // could not be checked is a GAP. Collapsing them would let an unreachable source read as a clean negative.
1027
+ function courtDecisionsSection(opts) {
1028
+ const sd = opts && opts.searchDepth;
1029
+ if (!sd || !sd.counts || !isFullCountry(opts)) return '';
1030
+ const state = sd.counts.courtDecisions;
1031
+ const byC = sd.counts.recordsByCountry || {};
1032
+ const first = Object.keys(byC).filter((c) => c !== 'WO')[0];
1033
+ const where = first ? (regionName(first) || first) : '';
1034
+ let line = '';
1035
+ if (state === 'not-checked') line = `Case-law research could not be completed for ${where}.`;
1036
+ else if (state === 'none-found') line = `Court decisions: none found for ${where}.`;
1037
+ else if (state === 'found') line = `Court decisions were searched for ${where} and are cited against the findings above.`;
1038
+ if (!line) return '';
1039
+ return `<div class="sec" id="court"><h2>Court decisions</h2></div>
1040
+ <div class="panel courtp"><p>${esc(line)}</p></div>`;
1041
+ }
1042
+
1043
+ // Counts only, folded closed. A count of records read is engine activity rather than something a reader
1044
+ // acts on (owner, 2026-09-16), so it lives here and not beside the outcome per country.
1045
+ // THE OPEN ROWS STAY ON THE PAGE, and that is deliberate rather than a leftover of the old fold. The
1046
+ // scope fold and the checks-we-ran narrative go, because narrating faults and refusals at a client is
1047
+ // what the owner ruled out. A slice the run deliberately LEFT OPEN is not narration: it is the
1048
+ // disclosure the whole can't-close-then-disclose doctrine rests on, and it was measured reaching a
1049
+ // client short by two rows only hours before this redesign (the 2026-09-16 report redesign). It renders here, in the
1050
+ // reader's own words, with the counts — never the query text, the refusals or the provider faults.
1051
+ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri = new Map()) {
1052
+ const sd = opts && opts.searchDepth;
1053
+ const open = (Array.isArray(coverage) ? coverage : []).filter((c) => COV_STATE[c?.state]?.cls !== 'ok');
1054
+ const c = (sd && sd.counts) || {}, rows = [];
1055
+ const byC = c.recordsByCountry || {};
1056
+ const per = Object.entries(byC).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${regionName(k) || k} ${n.toLocaleString('en-GB')}`).join(' \u00b7 ');
1057
+ if (per) rows.push(['Register records read', per]);
1058
+ const sw = c.sweep || {};
1059
+ if (sw.spellings) rows.push(['Spellings searched', String(sw.spellings)]);
1060
+ if (sw.checks) rows.push(['Marketplace and web', `${sw.checks.toLocaleString('en-GB')} checks across ${sw.platforms} platforms`]);
1061
+ if (sw.reputation) rows.push(['Reputation and meaning', `${sw.reputation.toLocaleString('en-GB')} checks`]);
1062
+ rows.push(['Local-script spellings', c.localScriptSearched ? 'searched' : 'not searched']);
1063
+ const cd = { 'found': 'found', 'none-found': 'none found', 'not-checked': 'could not be checked', 'not-in-scope': 'not part of this search' }[c.courtDecisions];
1064
+ if (cd) rows.push(['Court decisions', cd]);
1065
+ const openHtml = open.length ? `<div class="openrows"><div class="rk">Left open</div>${coverageGrid(open)}</div>` : '';
1066
+ // THE LEGEND FOR WHAT IS ON THE CARDS, kept when the scope fold went. It is not the engine narrating
1067
+ // its own searching — it is what a linked registration number opens, why the rest are cited by number,
1068
+ // and what a register-index entry means. A reader cannot read a card without it.
1069
+ const hasRecordSet = recordsByUri && recordsByUri.size > 0;
1070
+ const hasCards = Array.isArray(findings) && findings.length > 0;
1071
+ const hasIndexEntry = (Array.isArray(findings) ? findings : []).some((f) => (f?.owner?.registrations ?? []).some((r) => r?.uri
1072
+ && (hasRecordSet || !(r.status || r.filed || r.expiry || (r.classes && r.classes.length)))));
1073
+ const prov = (hasRecordSet || hasCards)
1074
+ ? `<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>`
1075
+ : '';
1076
+ if (!rows.length && !openHtml && !prov) return '';
1077
+ return `<details class="searched"><summary><span class="gname">What was searched</span><span class="gcount">Counts for this search</span></summary><div class="gbody">${
1078
+ 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>`;
1079
+ }
1080
+
1081
+ function alsoConsideredSection(ruledOut, recordsByUri = new Map(), opts = {}) {
1082
+ const sd = opts.searchDepth || null;
1083
+ const ruledCards = ruledOut.map((f) => {
1084
+ const who = recordOwner(f, recordsByUri) || f.owner?.name || '';
1085
+ const why = f.ruled_out_reason ? esc(f.ruled_out_reason) : 'a different name in a related field — not a conflict with your mark';
1086
+ const legal = f.legal_position ? esc(String(f.legal_position)) : '';
1087
+ const practical = f.practical_position ? esc(String(f.practical_position)) : '';
1088
+ const fold = (legal || practical)
1089
+ ? `<details class="roff"><summary>Why it was ruled out</summary>${legal ? `<p><b>Legal position.</b> ${legal}</p>` : ''}${practical ? `<p><b>Practical position.</b> ${practical}</p>` : ''}</details>` : '';
1090
+ return `<div class="card ruled"><div class="top"><div class="rail"></div><div class="body"><div class="cardhead">${
1091
+ f.ordinal != null ? `<span class="fnum">${esc(String(f.ordinal))}</span>` : ''}<span class="who mark-first">${esc(f.mark || '')}</span>${
1092
+ who ? `<span class="cwho">${esc(who)}</span>` : ''}<span class="tier ruled">Ruled out</span><button class="ask-fi no-print">\u2726 Ask AI about this finding</button></div><div class="oneline">${why}</div>${fold}</div></div></div>`;
1093
+ }).join('');
1094
+ const groups = clearedGroupsHtml(sd, opts.auditFile);
1095
+ const web = webNamesHtml(sd);
1096
+ if (!ruledCards && !groups && !web) return '';
1097
+ return `<div class="sec" id="also-considered"><h2>Also considered</h2></div>
1098
+ <div class="panel alsocons">${ruledCards ? `<div class="ruled-cards">${ruledCards}</div>` : ''}${groups || web ? `<div class="cgroups">${groups}${web}</div>` : ''}</div>`;
1099
+ }
1100
+
807
1101
  function ruledOutSection(ruledOut, recordsByUri = new Map()) {
808
1102
  if (!ruledOut.length) return '';
809
1103
  const rows = ruledOut.map(f => {
@@ -846,82 +1140,17 @@ function ruledOutSection(ruledOut, recordsByUri = new Map()) {
846
1140
  // Calling it makes the two rules agree BY CONSTRUCTION rather than by inspection, which is what the
847
1141
  // issue asks for: a divergence that is merely absent grows back. renderProse then sees the lines the
848
1142
  // author wrote, so `- ` bullets are <li> again and a telemetry-only note still reduces to ''.
849
- function plainScopeNote(text) {
850
- const t = String(text || '').trim();
851
- if (!t) return '';
852
- return stripTelemetry(t).trim(); // trim: an all-telemetry note leaves only newlines, and '' is falsy
853
- }
854
- function scopeSection(ranBucket, coverage, coverageJudgment, methodologyText, contextNotes, fm = {}, hasRecordSet = false, hasCards = false, hasIndexEntry = false) {
855
- const parts = [];
856
- // B3 (spec 2026-07-30 §4) — record provenance, stated ONCE, here, instead of a hedge stamped on
857
- // every card. This is the single home of what "fetched", "register-index entry" and "inferred"
858
- // mean on the cards above. Presentation, not a gate.
859
- //
860
- // It renders whenever those labels CAN render, which is not the same thing as hasRecordSet. B3 first
861
- // gated it on the record set alone, and that inverted the fix on the one branch it most needed to
862
- // cover: with no record set the cards still emit "(register-index entry)" (the second disjunct of the
863
- // registration render) and "inferred — reputation/profile signal" (the enforcer basis line, ungated),
864
- // but the paragraph explaining both was suppressed — and the per-leg wording that used to explain
865
- // itself ("full record not pulled this run") had just been removed as the duplicate it was. The
866
- // predicate here is deliberately "are there cards at all" rather than a copy of the two label
867
- // conditions: a copy is a thing that drifts, and over-including costs one explanatory paragraph in a
868
- // collapsed section while under-including costs a reader an unexplained label. The fetched-records
869
- // sentence stays conditional on hasRecordSet, so a run WITH a record set is byte-identical to B3.
870
- // THREE SENTENCES EXPLAINING ONE WORD, and the middle one printed on every report whether or not the
871
- // page had a register-index entry on it — a definition of a label the reader could not see. It renders
872
- // now only where such an entry does, and the remaining two say what "inferred" means in the words a
873
- // reader would use for it rather than in the renderer's.
874
- if (hasRecordSet || hasCards) parts.push(`<p class="scoperead" style="margin:0 0 4px;font-weight:600">Record provenance</p><p class="provnote" style="margin:0 0 6px;font-size:13px">${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. ' : ''}“Inferred” beside an owner’s likelihood to object means we judged it from what the owner sells and holds; we had no enforcement history to read.</p>`);
875
- // — this is the one part of §4 that does NOT fold. Same markup, same heading, same marker; it is
876
- // emitted beside the <details> instead of inside it, wrapped in the panel the only-you section already
877
- // uses so it reads as a region of the page rather than a stray heading.
878
- const ran = ranBucket && ranBucket.body
879
- ? `<div class="panel actions scope-ran"><div class="actgrp act-ran"><h4 style="margin:0 0 4px"><span class="actic">✓</span> ${inline(ranBucket.label)}</h4>${renderProse(ranBucket.body)}</div></div>`
880
- : '';
881
- // — THE HEADING ALWAYS RENDERS. `if (coverage.length)` dropped the whole section on a zero-row
882
- // run: no heading, no marker, nothing. A reader who has seen this section on another report cannot
883
- // tell a run that measured nothing from one whose section was simply not reached, and the surface with
884
- // the least context lost the most. MEASURED over the pool: 29 clearance reports, 28 carry it, and on
885
- // the one that does not (`global-preliminary-search`, delivered, verdict Medium, 81,823 bytes) the
886
- // internal `Coverage read` line was gone too — so there was no fallback in practice either.
887
- //
888
- // WHAT THIS COSTS A REPUBLISH, checked before writing it: `doRepublish()` re-renders an archived run,
889
- // so a change here reaches documents already delivered. This one is a NO-OP for 28 of the 29 — the
890
- // populated branch is byte-identical — and on the 29th it adds a disclosure that should always have
891
- // been there. `rerender-all` is an explicit operator command, not something that runs on its own.
892
- //
893
- // Owner ruling 2026-08-24, which discharged the standing "only with a legitimate renderer change"
894
- // condition: build it. The freeze hash moves in this commit, as the freeze's own header
895
- // requires.
896
- parts.push(coverage.length
897
- ? `<p class="scoperead" style="margin:10px 0 4px;font-weight:600">What we covered — and what's open</p>${coverageGrid(coverage)}`
898
- : `<p class="scoperead" style="margin:10px 0 4px;font-weight:600">What we covered — and what's open</p>`
899
- + `<p class="covnone" style="margin:0 0 6px;font-size:13px">No coverage record was produced for this run. `
900
- + `This section normally lists what each search covered and what is still open; its absence here is a gap `
901
- + `in the record, not a finding that nothing is open. Ask us before relying on it.</p>`);
902
- // THE INTERNAL COVERAGE READ IS NOT RENDERED. It concatenated the engine's own search-unit names into
903
- // about a thousand characters of prose — and on the measured run it ended mid-word, because it is a
904
- // machine's working note and nothing was reading it as a sentence. Every fact in it is already in the
905
- // coverage cells directly above, in plain words. It stays in the run's artifacts and in the workbook,
906
- // where the reader is someone who wants it.
907
- const meth = plainScopeNote(methodologyText);
908
- if (meth) parts.push(`<p class="scoperead" style="margin:10px 0 4px;font-weight:600">How this search was run</p><div class="methnote" style="font-size:13px">${renderProse(meth)}</div>`);
909
- const cn = contextNotesBlock(contextNotes);
910
- if (cn) parts.push(cn);
911
- // spec 62 — CONFIGURATION PROVENANCE (INTERNAL only): when the run is under a project, show which layer
912
- // (project / customer / house) set each operational knob. Provenance, not process-exhaust — never on export.
913
- if (fm.origins_json) {
914
- let rows = [];
915
- try { rows = JSON.parse(fm.origins_json); } catch { rows = []; }
916
- if (Array.isArray(rows) && rows.length) {
917
- const body = rows.map((r) => `<tr><td style="padding:1px 12px 1px 0">${esc(r.field)}</td><td style="padding:1px 12px 1px 0">${esc(r.value)}</td><td class="mono" style="padding:1px 0">${esc(r.origin)}</td></tr>`).join('');
918
- parts.push(`<p class="scoperead" style="margin:10px 0 4px;font-weight:600">Configuration provenance (internal)${fm.run_under_project ? ` — run under project ${esc(fm.run_under_project)}` : ''}</p>`
919
- + `<table class="origins" style="font-size:13px;border-collapse:collapse"><thead><tr><th style="text-align:left;padding-right:12px">Setting</th><th style="text-align:left;padding-right:12px">Effective</th><th style="text-align:left">Set by</th></tr></thead><tbody>${body}</tbody></table>`);
920
- }
921
- }
922
- if (!parts.length) return ran;
923
- return `${ran}<details class="scope"><summary>Scope &amp; what we didn't search</summary><div class="drillbody">${parts.join('\n ')}</div></details>`;
924
- }
1143
+ // The 2026-09-16 report redesign — scopeSection and plainScopeNote are DELETED, not merely unreferenced.
1144
+ //
1145
+ // They rendered the scope fold: the checks-we-ran narrative, the Methodology note, the full coverage
1146
+ // grid and the provenance paragraph. The owner ruled the narrative off the client's page on
1147
+ // 2026-09-16, and what a reader still needs from that fold moved with it by hand — the rows a run
1148
+ // LEFT OPEN and the record provenance legend both render with the counts now, and each has its own arm.
1149
+ //
1150
+ // Left here as dead code they would have read as a surface the client still meets. The rule they
1151
+ // carried about telemetry prose — a lead-in is a paragraph and each bullet an item, welded into one
1152
+ // before it was fixed — lives on in stripTelemetry in parse.mjs, which is where the rule belongs and
1153
+ // where its own arms still drive it.
925
1154
 
926
1155
  // Quadrant: x = goods proximity (0 distant → 1 identical), y = mark similarity (0 distinct → 1 identical).
927
1156
  // SVG plot box is x∈[70,530], y∈[372,30] (y inverted: identical at top). Marker colour by classification.
@@ -1297,7 +1526,17 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1297
1526
  // "file exists on disk" inference. Absent receipt (archived runs) renders nothing.
1298
1527
  const receiptTail = rec._receipt?.fetched_at
1299
1528
  ? ` · <i class="receipt">verified — ${esc(PROVIDER_LABEL || 'register')} record fetched ${esc(String(rec._receipt.fetched_at).slice(0, 10))}</i>` : '';
1300
- return `<li><b>${officeRecordCell(r.uri, regUri, RECORD_CITATION ? NO_LINK_NOTE[RECORD_CITATION] : null)}</b>${cls ? ` · Cl.${esc(cls)}` : ''}${status ? ` · ${esc(status)}` : ''}${tail ? ` (${esc(tail)})` : ''}${jur ? ` · ${esc(jur)}${sys ? ` <i class="jsys">(${sys})</i>` : ''}` : ''}${desigHtml}${prio}${receiptTail}</li>`;
1529
+ // AS REGISTERED, AND THE DATES — the full country's two extra blocks (the 2026-09-16 report redesign). Both come
1530
+ // off the fetched record and neither is composed: the goods are the register's own wording in the
1531
+ // language it granted them in, and the dates are its own, to the day. The one-line summary above
1532
+ // carries YEARS, which cannot say whether a registration lapses this month.
1533
+ const goods = FULL_COUNTRY ? REC.goods(rec) : null;
1534
+ const recDates = FULL_COUNTRY ? REC.dates(rec) : null;
1535
+ const goodsHtml = !goods ? '' : `<div class="row"><span class="k">As registered</span><span class="v">${
1536
+ goods.map((g) => `${g.classes.length ? `${esc(g.classes.length === 1 ? 'Class' : 'Classes')} ${esc(g.classes.join(', '))}: ` : ''}${esc(g.description)}`).join('<br>')}</span></div>`;
1537
+ const datesHtml = !recDates ? '' : `<div class="row"><span class="k">Dates</span><span class="v">${
1538
+ esc(recDates.map(([k, v]) => `${k} ${v}`).join(' \u00b7 '))}</span></div>`;
1539
+ return `<li><b>${officeRecordCell(r.uri, regUri, RECORD_CITATION ? NO_LINK_NOTE[RECORD_CITATION] : null)}</b>${cls ? ` · Cl.${esc(cls)}` : ''}${status ? ` · ${esc(status)}` : ''}${tail ? ` (${esc(tail)})` : ''}${jur ? ` · ${esc(jur)}${sys ? ` <i class="jsys">(${sys})</i>` : ''}` : ''}${desigHtml}${prio}${receiptTail}${goodsHtml || datesHtml ? `<div class="record">${goodsHtml}${datesHtml}</div>` : ''}</li>`;
1301
1540
  }
1302
1541
  // (2)+(3): a cited record with NO fetched body. NEVER-INVENT when the run has a record set (this URI was
1303
1542
  // not fetched ⇒ the model's fields are unconfirmed); also when there are no findings.json facts to show.
@@ -1336,8 +1575,8 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1336
1575
  const enfSrc = enf && String(enf.source ?? '').trim();
1337
1576
  const enfBasis = enf
1338
1577
  ? (enfVerified
1339
- ? `<li><b>Enforcer.</b> ${esc(humanize(enf.token))} appetite — <b>verified</b> (${enfSrc ? `source: ${esc(enfSrc.slice(0, 60))}` : 'enforcement record on file'}).</li>`
1340
- : `<li><b>Enforcer.</b> ${esc(humanize(enf.token))} appetite <i>inferred</i> — reputation/profile signal.</li>`)
1578
+ ? `<li><b>Likely to enforce.</b> ${esc(humanize(enf.token))} appetite — <b>verified</b> (${enfSrc ? `source: ${esc(enfSrc.slice(0, 60))}` : 'enforcement record on file'}).</li>`
1579
+ : `<li><b>Likely to enforce.</b> ${esc(humanize(enf.token))} appetite <i>inferred</i> — reputation/profile signal.</li>`)
1341
1580
  : '';
1342
1581
  // Instance #5 — the use-check / own-rights cite is rendered FROM the JSON (no prose-regex), so it can never
1343
1582
  // diverge from the structured field. The source string may hold ONE URL, SEVERAL (comma/space-separated —
@@ -1468,12 +1707,26 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1468
1707
  const proseHasRead = READ_LEAD_RE.test(proseFull || '');
1469
1708
  const positions = `${proseHasRead ? '' : lpSplit(f)}${manageableLine(f)}`;
1470
1709
  const body = `${positions}${regs || seniorLine || fetchFailLine || enfLine || enfTelemetry || useChk || ownR || impactLine ? `<ul>${fetchFailLine}${seniorLine}${regs}${enfLine}${enfTelemetry}${useChk}${ownR}${impactLine}</ul>` : ''}${proseFullShown ? renderProse(proseFullShown) : ''}${caseLawStrand}${prov}`;
1471
- return `<details class="drill"><summary>Full detail &amp; provenance</summary><div class="drillbody">${body}</div></details>`;
1710
+ return `<details class="drill"><summary>Full detail</summary><div class="drillbody">${body}</div></details>`;
1472
1711
  }
1473
1712
 
1474
1713
  // The contentious MARK + the classes it matched in, as a header chip next to the holder — so a card shows
1475
1714
  // WHICH mark/class the conflict is, not only who owns it (mirrors the rights-holder panel). Empty only when
1476
1715
  // the holder IS the mark (owner unknown) AND no classes are known.
1716
+ // The card head leads with the MARK and its country and class, and the owner sits on the line below
1717
+ // (owner + outside reviewer, 2026-09-16). Emphasis only: nothing is removed, the same facts are read in
1718
+ // the order a reader asks for them — which name, where, whose.
1719
+ function cardTitleParts(f, who) {
1720
+ const classes = [...new Set((f.owner?.registrations || []).flatMap((r) => Array.isArray(r.classes) ? r.classes : []))]
1721
+ .map(Number).filter((x) => !Number.isNaN(x)).sort((a, b) => a - b).join('/');
1722
+ const code = regionCode(f);
1723
+ const where = code && code !== COMMON_LAW ? (regionName(code) || code) : '';
1724
+ const tail = [where, classes ? `Class ${classes}` : ''].filter(Boolean).join(' \u00b7 ');
1725
+ const title = `<span class="who mark-first">${esc(f.mark || who || '')}${tail ? ` <span class="where">\u2014 ${esc(tail)}</span>` : ''}</span>`;
1726
+ const ownerLine = who ? `<div class="owner-line"><span class="ok">Owner</span> ${esc(who)}</div>` : '';
1727
+ return { title, ownerLine };
1728
+ }
1729
+
1477
1730
  function markClassChip(f, who) {
1478
1731
  const nrm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
1479
1732
  const classes = [...new Set((f.owner?.registrations || []).flatMap((r) => Array.isArray(r.classes) ? r.classes : []))]
@@ -1534,7 +1787,10 @@ function oneFallback(f, who) {
1534
1787
  // free string on this page goes through inline() — the ONE choke point (stripInternal →
1535
1788
  // esc → link/markdown safety). A field that was ENTIRELY internal therefore renders as '' on the
1536
1789
  // client and disappears with its label, exactly as a wholly-internal bullet does in renderProse.
1537
- const lpPara = (label, html) => `<p class="lp${isInternalLabelled(html) ? ' int-note' : ''}"><b>${label}</b> ${html}</p>`;
1790
+ // Item 18 (owner, 2026-09-16): a line the engine labels internal does not reach the delivered page at
1791
+ // all. It stays in the findings record, the audit workbook and the assistant, which is where the
1792
+ // audit was always meant to live. Marking it and hiding it in print left it on screen for a client.
1793
+ const lpPara = (label, html) => isInternalLabelled(html) ? '' : `<p class="lp"><b>${label}</b> ${html}</p>`;
1538
1794
  function lpSplit(f) {
1539
1795
  const legal = inline(String(f.legal_position ?? '').trim()).trim();
1540
1796
  const practical = inline(String(f.practical_position ?? '').trim()).trim();
@@ -1598,9 +1854,11 @@ function findingCard(f, card, recordsByUri = new Map()) {
1598
1854
  <div class="body">
1599
1855
  <div class="cardhead">
1600
1856
  <label class="pick no-print"><input type="checkbox" class="pickbox" checked></label>
1601
- <span class="fnum">${f.ordinal}</span><span class="who">${esc(who)}</span>${markChip}${evidenceChips(f)}
1857
+ <span class="fnum">${f.ordinal}</span>${cardTitleParts(f, who).title}${evidenceChips(f)}
1602
1858
  <span class="tier ${tierCls}">${riskChip(f)}</span>
1859
+ <button class="ask-fi no-print">\u2726 Ask AI about this finding</button>
1603
1860
  </div>
1861
+ ${cardTitleParts(f, who).ownerLine}
1604
1862
  <div class="oneline">${inline(sentenceCaseLead(one))}</div>
1605
1863
  ${meters(f)}
1606
1864
  </div>
@@ -1624,7 +1882,7 @@ function compactCard(f, card, recordsByUri = new Map()) {
1624
1882
  const markChip = markClassChip(f, who);
1625
1883
  return `<div class="card compact" id="c${f.ordinal}">
1626
1884
  <div class="top"><div class="rail low"></div><div class="body">
1627
- <div class="cardhead"><label class="pick no-print"><input type="checkbox" class="pickbox" checked></label><span class="fnum">${f.ordinal}</span><span class="who">${esc(who)}</span>${markChip}${evidenceChips(f)}<span class="tier low">${riskChip(f)}</span></div>
1885
+ <div class="cardhead"><label class="pick no-print"><input type="checkbox" class="pickbox" checked></label><span class="fnum">${f.ordinal}</span>${cardTitleParts(f, who).title}${evidenceChips(f)}<span class="tier low">${riskChip(f)}</span><button class="ask-fi no-print">\u2726 Ask AI about this finding</button></div>${cardTitleParts(f, who).ownerLine}
1628
1886
  <div class="oneline">${inline(sentenceCaseLead(one))}</div>
1629
1887
  </div></div>
1630
1888
  ${fullDetail(f, card, recordsByUri)}
@@ -1822,7 +2080,7 @@ document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=doc
1822
2080
  function markAssessmentBlock(ma) {
1823
2081
  if (ma == null) return '';
1824
2082
  const structured = typeof ma.distinctiveness === 'object' || typeof ma.connotation === 'object';
1825
- const SEC = `<div class="sec"><span class="num">✦</span><h2>The mark itself</h2><span class="note">how strong the name is on its own</span></div>`;
2083
+ const SEC = `<div class="sec"><h2>The mark itself</h2></div>`;
1826
2084
  if (!structured) {
1827
2085
  const dist = String(ma?.distinctiveness ?? '').trim(), conn = String(ma?.connotation ?? '').trim();
1828
2086
  if (!dist && !conn) return '';
@@ -1879,26 +2137,6 @@ const FOUR_ANSWER_LABELS = [
1879
2137
  // The tokens and the class numbers are closed/typed values — esc() is right for those. A read that was
1880
2138
  // ENTIRELY a ::p:: staff aside leaves nothing to say, so its row (and a wholly-internal obstacle row)
1881
2139
  // disappears on the client rather than rendering an empty answer.
1882
- function fourAnswersPanel(fa) {
1883
- if (!fa || typeof fa !== 'object') return '';
1884
- const rows = FOUR_ANSWER_LABELS.map(([key, label]) => {
1885
- const a = fa[key];
1886
- if (!a || typeof a.read !== 'string' || !a.read.trim()) return '';
1887
- const read = inline(a.read.trim()).trim();
1888
- if (!read) return '';
1889
- const ords = (Array.isArray(a.ordinals) && a.ordinals.length)
1890
- ? ` <span class="fa-ords">${a.ordinals.map((n) => `<a href="#c${n}">#${n}</a>`).join(' ')}</span>` : '';
1891
- const basis = a.basis ? inline(String(a.basis).trim()).trim() : '';
1892
- const obsRows = (key === 'registrability' && Array.isArray(a.obstacles) ? a.obstacles : [])
1893
- .map((r) => ({ cls: esc(String(r?.class ?? '')), note: inline(String(r?.note ?? '').trim()).trim() }))
1894
- .filter((r) => r.note);
1895
- const obstacles = obsRows.length
1896
- ? `<ul class="fa-obstacles">${obsRows.map((r) => `<li${intNoteCls(r.note)}><b>Class ${r.cls}.</b> ${r.note}</li>`).join('')}</ul>` : '';
1897
- return `<div class="fa-row${isInternalLabelled(read) ? ' int-note' : ''}"><span class="fa-k">${label}</span><span class="fa-v">${a.token ? `<span class="fa-token">${esc(humanize(a.token))}</span> — ` : ''}${read}${ords}${basis ? ` <i class="fa-basis">(${basis})</i>` : ''}${obstacles}</span></div>`;
1898
- }).filter(Boolean);
1899
- if (!rows.length) return '';
1900
- return `<div class="panel fourans"><div class="label">The four answers</div>${rows.join('')}</div>`;
1901
- }
1902
2140
 
1903
2141
  function contextNotesList(notes = []) {
1904
2142
  const items = notes.map(n => {
@@ -1912,7 +2150,7 @@ function contextNotesList(notes = []) {
1912
2150
  // the findings-section assembly below.
1913
2151
  function contextNotesBlock(notes = []) {
1914
2152
  if (!notes.length) return '';
1915
- return `<div class="sec"><span class="num">✦</span><h2>Famous-mark neighbours noted</h2><span class="note">diligence — no register record; not scored, does not affect the risk read</span></div>
2153
+ return `<div class="sec"><h2>Famous-mark neighbours noted</h2></div>
1916
2154
  ${contextNotesList(notes)}`;
1917
2155
  }
1918
2156
 
@@ -1978,7 +2216,7 @@ function heroCaptionHtml(text) {
1978
2216
  // `sub-lead` marks the FOLDED first sentence so the stylesheet can tighten its bottom margin without a
1979
2217
  // positional selector: `.sub:first-of-type` would have caught an UNFOLDED one-sentence caption too and
1980
2218
  // changed the spacing of every archived report that has no fold.
1981
- return `<p class="sub sub-lead">${inline(first)}</p><details class="sub-more"><summary>More</summary><p class="sub">${inline(rest)}</p></details>`;
2219
+ return `<p class="sub sub-lead">${inline(first)}</p><details class="sub-more"><summary>Show the chart and rights-holders</summary><p class="sub">${inline(rest)}</p></details>`;
1982
2220
  }
1983
2221
 
1984
2222
  // The hero's confidentiality line: the delivery posture and the PRODUCT, joined, and nothing else.
@@ -2050,6 +2288,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2050
2288
  AS_OF = opts.asOf ?? null; // C2
2051
2289
  VERDICT_INFO = (opts.verdictInfo && opts.verdictInfo.tier != null) ? opts.verdictInfo : null; // T2 — only an enriched sidecar is an authority
2052
2290
  FRAMEWORK = opts.framework ?? null; // doc 50 — the frozen manifest; null on archived/legacy runs
2291
+ FULL_COUNTRY = isFullCountry(opts); // The 2026-09-16 report redesign — the depth rule for the Full detail fold
2053
2292
  SEARCHED_JUR = Array.isArray(opts.searchedJurisdictions) && opts.searchedJurisdictions.length ? opts.searchedJurisdictions : null; // T6 (D4)
2054
2293
  SCOPE_WORLDWIDE = opts.scopeBasis === 'worldwide' ? true : null; // the plan's scope_basis; null ⇒ fall back to the ledger-prose sniff
2055
2294
  CASE_LAW_BY_ORD = opts.caseLawByOrdinal instanceof Map ? opts.caseLawByOrdinal : new Map(); // T7 (E5)
@@ -2184,33 +2423,38 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2184
2423
  const clNotice = (clNoticeText && CASE_LAW_BY_ORD.size)
2185
2424
  ? `<div class="panel" style="padding:12px 16px;margin:0 0 12px"><p style="margin:0 0 4px;font-weight:700;font-size:13px">Session-wide notice</p><div style="font-size:13px">${renderProse(clNoticeText)}</div></div>`
2186
2425
  : '';
2187
- const CL_SEC = (n) => `<div class="sec" id="common-law"><span class="num">${n}</span><h2>Common-law &amp; marketplace</h2><span class="note">who is using similar names, registered or not</span></div>
2188
- ${clNotice}${clBody}`;
2189
- const hasCL = Boolean(clBody || clNotice);
2426
+ // The 2026-09-16 report redesign — the section heading and the session-wide notice are off the page: the heading
2427
+ // named a layer rather than a thing a reader looks for, and the notice was operational narration. The
2428
+ // CARDS are not: a rated common-law finding is a finding, and it renders in place with the others.
2429
+ const hasCL = Boolean(clBody);
2190
2430
  let findingsSections, covNum;
2191
2431
  if (!DISPOSITION_MODE) {
2192
- findingsSections = `${onField.length ? `<div class="sec"><span class="num">01</span><h2>The conflict landscape</h2><span class="note">tap any point to jump to its finding</span></div>
2432
+ findingsSections = `${onField.length ? `<div class="sec"><h2>The conflict landscape</h2></div>
2193
2433
  <div class="landwrap">${quadrant(sorted)}${keyPanel(sorted, recordsByUri)}</div>
2194
2434
 
2195
- <div class="sec"><span class="num">02</span><h2>On-field conflicts</h2><span class="note">these drive the risk read</span></div>
2435
+ <div class="sec"><h2>Conflicts</h2></div>
2196
2436
  ${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}` : ''}
2197
2437
 
2198
- ${secReg.length ? `<div class="sec"><span class="num">03</span><h2>Secondary &amp; watch</h2><span class="note">listed for completeness</span></div>
2438
+ ${secReg.length ? `<div class="sec"><h2>Secondary &amp; watch</h2></div>
2199
2439
  ${secondaryRegions(secReg, cardFor, recordsByUri)}` : ''}${hasCL ? `
2200
2440
 
2201
- ${CL_SEC('04')}` : ''}`;
2441
+ ${clBody}` : ''}`;
2202
2442
  covNum = hasCL ? '05' : '04';
2203
2443
  } else {
2204
2444
  let secNum = 0;
2205
2445
  const num = () => String(++secNum).padStart(2, '0');
2206
2446
  const landscape = onField.length
2207
- ? `<div class="sec"><span class="num">${num()}</span><h2>The conflict landscape</h2><span class="note">tap any point to jump to its finding</span></div>
2447
+ ? `<div class="sec"><h2>The conflict landscape</h2></div>
2208
2448
  <div class="landwrap">${quadrant(sorted)}${keyPanel(sorted, recordsByUri)}</div>
2209
2449
 
2210
- <div class="sec"><span class="num">${num()}</span><h2>On-field conflicts</h2><span class="note">these drive the risk read</span></div>
2450
+ <div class="sec"><h2>Conflicts</h2></div>
2211
2451
  ${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}`
2212
2452
  : '';
2213
- let tail = '';
2453
+ // THE COMMON-LAW CARDS GO WITH THE CONFLICTS, NOT UNDER THE NEXT HEADING. Appended at the end they
2454
+ // landed beneath "Notable but manageable", which says something about a finding that is not true of
2455
+ // them: an off-field common-law card is not an absorbed register negative. With the section heading
2456
+ // gone they carry no heading of their own, so placement is the only thing that attributes them.
2457
+ let tail = hasCL ? `\n\n ${clBody}` : '';
2214
2458
  const band2r = band2.filter(f => !isCL(f)), band3r = band3.filter(f => !isCL(f));
2215
2459
  // spec 2026-07-30 §3 — ONE "we looked and cleared it" section: 03 Notable but manageable ABSORBS
2216
2460
  // 04 Commercial awareness (band 3) and the famous-mark diligence notes. Both keep their words as
@@ -2222,16 +2466,17 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2222
2466
  // grouping keys on. The gate is fail-CLOSED — an absent or unreadable version is legacy.
2223
2467
  const negatives = NEGATIVES_GROUPED ? reasonedNegativeGroups([...band2r, ...band3r]) : null;
2224
2468
  if (negatives) {
2225
- // ZERO IS NOT ABSENCE. A group is built from its members, so an empty heading cannot be produced;
2226
- // and a run that grouped and found none SAYS so, rather than leaving the reader to guess whether
2227
- // there were no negatives or the grouping never ran.
2228
- const body = negatives.total
2229
- ? reasonedNegatives(negatives.groups, cardFor, recordsByUri)
2230
- : `<p class="fold-lead"><b>No reasoned negatives.</b> Every retrieved close match on this run is an on-field conflict above — nothing was cleared into this section.</p>`;
2469
+ // THE SECTION APPEARS ONLY WHEN IT HOLDS SOMETHING (owner, 2026-09-16). The reasoning it replaces
2470
+ // was "zero is not absence": a run that grouped and found none should SAY so rather than leave the
2471
+ // reader guessing. That is right for a working record and wrong for a client's page, where a
2472
+ // heading followed by a sentence explaining that there is nothing under it is the "nothing here"
2473
+ // line the owner ruled out. Whether the grouping ran is answered by the counts, not by a paragraph.
2231
2474
  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>
2232
2475
  ${contextNotesList(contextNotes)}` : '';
2233
- tail += `\n\n <div class="sec"><span class="num">${num()}</span><h2>Notable but manageable</h2><span class="note">documented coexistence or clear distinction on the record</span></div>
2234
- ${body}${notes}`;
2476
+ if (negatives.total || notes) {
2477
+ tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
2478
+ ${negatives.total ? reasonedNegatives(negatives.groups, cardFor, recordsByUri) : ''}${notes}`;
2479
+ }
2235
2480
  } else if (band2r.length || band3r.length || contextNotes.length) {
2236
2481
  const parts = [];
2237
2482
  if (band2r.length) parts.push(secondaryRegions(band2r, cardFor, recordsByUri));
@@ -2239,10 +2484,12 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2239
2484
  ${secondaryRegions(band3r, cardFor, recordsByUri)}`);
2240
2485
  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>
2241
2486
  ${contextNotesList(contextNotes)}`);
2242
- tail += `\n\n <div class="sec"><span class="num">${num()}</span><h2>Notable but manageable</h2><span class="note">documented coexistence or clear distinction on the record</span></div>
2487
+ tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
2243
2488
  ${parts.join('\n ')}`;
2244
2489
  }
2245
- if (hasCL) tail += `\n\n ${CL_SEC(num())}`;
2490
+ // The 2026-09-16 report redesign — the section heading and its routing notice go; the CARDS stay, or a
2491
+ // finding the run rated would appear on no page at all.
2492
+
2246
2493
  findingsSections = landscape + tail;
2247
2494
  covNum = num();
2248
2495
  }
@@ -2275,13 +2522,16 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2275
2522
  <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&display=swap" rel="stylesheet">${FAVICON_LINK}${themeInitScript}
2276
2523
  <style>${cssInline}${darkCss}${PRINT_LIGHT}</style>${chromeLinkTag}</head><body class="has-glow">
2277
2524
  <div class="rep-stickyhead no-print">
2278
- ${opts.nav || ''}
2279
2525
  <div class="topbar no-print">
2526
+ ${/* ONE BAR (the 2026-09-16 report redesign). The site bar is not rendered on a report, so the brand belongs here:
2527
+ a reader met two stacked headers, and the lower one carried the only brand. */''}
2528
+ ${logoLockup({ mark: 16, cls: 'tb-lockup' })}
2280
2529
  ${opts.homeHref ? `<a class="homebtn tb-back no-print" href="${escAttr(opts.homeHref)}" title="All reports"><span aria-hidden="true">←</span> <span class="tb-back-lbl">All reports</span></a>` : ''}
2281
2530
  <span class="sp"></span>
2282
2531
  <span class="tb-risk" style="background:var(${STOP_VAR[i]})">${riskLabel}</span>
2283
2532
  <span class="mono tb-matter" style="font-size:11px;color:var(--faint)">${esc(fm.matter || opts.runId || '')}${fm.title ? ' / ' + esc(fm.title) : ''}</span>
2284
- ${opts.issued ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued ${esc(opts.issued)}</span>` : ''}
2533
+ ${opts.issued ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(opts.issued)}</span>` : ''}
2534
+ <button type="button" class="tbbtn tb-ask no-print">✦ <span class="tb-lbl">Ask AI</span></button>
2285
2535
  <div class="tb-menu">
2286
2536
  <button type="button" class="tbbtn primary tb-exp-toggle" aria-haspopup="true" aria-expanded="false">⬇ <span class="tb-lbl">Export</span> ▾</button>
2287
2537
  <div class="tb-pop tb-exp-pop" hidden>
@@ -2303,11 +2553,10 @@ ${opts.nav || ''}
2303
2553
  <header class="hero">
2304
2554
  ${demoBannerHtml(opts.demoData === true)}
2305
2555
  ${confLineHtml(opts.delivery, productName)}
2306
- ${depthStripHtml(opts.depthNote)}
2307
2556
  <h1 class="mark">${esc(fm.title || '')}</h1>
2308
- ${headScope(fm, coverage, findings)}
2557
+ ${aboutPanel(fm, coverage, findings, opts)}
2309
2558
  ${heroCaptionHtml(secs['Summary'] ? secs['Summary'].trim() : (fm.overall_caption || ''))}
2310
- <div class="heroGrid">${gauge(fm, findings, coverage)}${facts(fm, findings, coverage)}</div>${fourAnswersPanel(fourAnswers)}
2559
+ <div class="heroGrid">${gauge(fm, findings, coverage, fourAnswers, opts)}</div>
2311
2560
  ${doFirst(secs)}
2312
2561
  ${actionGroup(buckets.ans, 'ans')}
2313
2562
  </header>
@@ -2320,10 +2569,14 @@ ${opts.nav || ''}
2320
2569
  <!-- doc-52 §2 THE CONFLICTS — findings ordered by who can block first; each expands to full detail. -->
2321
2570
  ${findingsSections}
2322
2571
 
2323
- ${ruledOutSection(ruledOut, recordsByUri)}
2572
+ ${whereItStandsSection(findings, opts)}
2573
+
2574
+ ${courtDecisionsSection(opts)}
2575
+
2576
+ ${alsoConsideredSection(ruledOut, recordsByUri, opts)}
2324
2577
 
2325
2578
  <!-- doc-52 §3 WHAT ONLY YOU CAN CLOSE — forward decisions, plain English, after the findings. -->
2326
- ${buckets.you ? `<div class="sec" id="only-you"><span class="num">✔</span><h2>What only you can close</h2><span class="note">decisions that need you</span></div>
2579
+ ${buckets.you ? `<div class="sec" id="only-you"><h2>What only you can close</h2></div>
2327
2580
  <div class="panel actions"><div class="actgrp act-you">${renderProse(buckets.you.body)
2328
2581
  .replace(/\[Time-critical\]\s*/gi, '<span class="src cl" style="margin-right:6px">Time-critical</span> ')
2329
2582
  .replace(/\[Open question\]\s*/gi, '<span class="src" style="margin-right:6px">Open question</span> ')
@@ -2339,15 +2592,10 @@ ${opts.nav || ''}
2339
2592
  // and this replace is inert on it.
2340
2593
  .replace(/\[Before you can rely\]\s*/gi, '<span class="src cl" style="margin-right:6px">Before you can rely</span> ')}</div></div>` : ''}
2341
2594
 
2342
- <!-- doc-52 §4 SCOPE & WHAT WE DIDN'T SEARCH — one collapsible section, last; replaces "Checks we ran"
2343
- + "Methodology" + the coverage grid. Nothing here leads. -->
2344
- ${scopeSection(buckets.ran, coverage, opts.coverageJudgment, secs['Methodology'], DISPOSITION_MODE ? [] : contextNotes, fm, recordsByUri.size > 0, findings.length > 0,
2345
- // WHETHER A REGISTER-INDEX ENTRY IS ACTUALLY ON THIS PAGE, mirroring the registration render's own
2346
- // second disjunct rather than restating it loosely: a cited registration with no fetched body, which
2347
- // is the state that draws the "(register-index entry)" label. The provenance paragraph explains that
2348
- // label, so it renders where the label can and stays off every page where it cannot.
2349
- findings.some((f) => (f?.owner?.registrations ?? []).some((r) => r?.uri
2350
- && (recordsByUri.size > 0 || !(r.status || r.filed || r.expiry || (r.classes && r.classes.length))))))}
2595
+ <!-- The 2026-09-16 report redesign — WHAT WAS SEARCHED. The scope fold and the checks-we-ran narrative are gone
2596
+ (owner, 2026-09-16: no coverage narrative on the page). What is left is counts, folded closed:
2597
+ completed work appears as numbers and cleared names, never as the engine's account of itself. -->
2598
+ ${whatWasSearchedSection(opts, coverage, findings, recordsByUri)}
2351
2599
 
2352
2600
  <footer>
2353
2601
  <span>${productName ? `${esc(productName)}. ` : ''}${FRAMEWORK
@@ -2363,8 +2611,7 @@ ${opts.nav || ''}
2363
2611
  // describing its own status inaccurately to the person paying for it. Raised in review because
2364
2612
  // the first version of this comment argued only the band note and left the reader to infer that
2365
2613
  // the status line had gone along for the ride.
2366
- ? `Risk bands: <span class="mono">${esc(FRAMEWORK.bands.map(b => b.label).join(' / '))}</span>. Purple notes are for the reviewing lawyer and are removed on export.`
2367
- : 'Risk bands: <span class="mono">LOW / MANAGEABLE / MEDIUM / HIGH / VERY HIGH</span>. Purple notes are for the reviewing lawyer and are removed on export.'}<br>Matter ${esc(fm.matter || '')}${fm.run ? ` · ${esc(fm.run)}` : ''}.${fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : ''}${fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : ''}</span>
2614
+ ? '' : ''}<br>Matter ${esc(fm.matter || '')}${fm.run ? ` · ${esc(fm.run)}` : ''}.${fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : ''}${fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : ''}</span>
2368
2615
  ${logoLockup({ mark: 16 })}
2369
2616
  </footer>
2370
2617
  </div>