clearotron 0.3.2-beta.4 → 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.
- package/INSTALL.md +1 -0
- package/bin/onboard.mjs +65 -0
- package/bin/start.mjs +14 -0
- package/build-info.json +2 -2
- package/demo/knockout-search/run/_driver/predelivery-lint.json +26 -2
- package/driver/CHANGELOG.md +6 -0
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +4 -2
- package/driver/publish/render-knockout.mjs +231 -98
- package/driver/publish/render.mjs +399 -152
- package/driver/publish/templates/report.css +103 -3
- package/driver/registry-fidelity.mjs +28 -0
- package/driver/suite-census.json +17 -17
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/audit.mjs +14 -2
- package/mcp-server/lib/http-handler.mjs +52 -13
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/shared/driver-dir.mjs +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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 & 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
|
-
|
|
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>
|
|
1340
|
-
: `<li><b>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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"><
|
|
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"><
|
|
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>
|
|
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
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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"><
|
|
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"><
|
|
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"><
|
|
2438
|
+
${secReg.length ? `<div class="sec"><h2>Secondary & watch</h2></div>
|
|
2199
2439
|
${secondaryRegions(secReg, cardFor, recordsByUri)}` : ''}${hasCL ? `
|
|
2200
2440
|
|
|
2201
|
-
${
|
|
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"><
|
|
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"><
|
|
2450
|
+
<div class="sec"><h2>Conflicts</h2></div>
|
|
2211
2451
|
${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}`
|
|
2212
2452
|
: '';
|
|
2213
|
-
|
|
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
|
-
//
|
|
2226
|
-
//
|
|
2227
|
-
//
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
-
|
|
2234
|
-
|
|
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"><
|
|
2487
|
+
tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
|
|
2243
2488
|
${parts.join('\n ')}`;
|
|
2244
2489
|
}
|
|
2245
|
-
|
|
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
|
-
${
|
|
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
|
|
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
|
-
${
|
|
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"><
|
|
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
|
-
<!--
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
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
|
-
? `
|
|
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>
|