clearotron 0.3.2-beta.4 → 0.3.2-beta.6

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.
@@ -265,6 +265,28 @@ const KO_CSS = `
265
265
  /* A wide counts table must scroll inside its own panel, never push the page sideways. */
266
266
  .ko-scroll{overflow-x:auto}
267
267
  @media(max-width:700px){.ko-row{grid-template-columns:1fr;gap:10px}}
268
+
269
+ /* The band ladder, the territories fold and the run's own caveats (the 2026-09-16 report redesign). Ported from
270
+ the design's stylesheet with its fixed colours replaced by this page's tokens, so the ladder reads
271
+ in both themes: the tick row was #a89a8a on white and the fold's summary and prose a brown pair,
272
+ all three of which go invisible on the dark ground this report also renders on. */
273
+ .kscale{position:relative;margin:10px 8px 34px}
274
+ .kbar{position:relative;height:8px;border-radius:4px}
275
+ .kmarker{position:absolute;top:-30px;text-align:center}
276
+ .kpill{display:inline-block;padding:5px 12px;border-radius:999px;color:#fff;
277
+ font:700 13px/1 'Satoshi','Helvetica Neue',Arial,sans-serif;white-space:nowrap}
278
+ .kneedle{width:3px;height:14px;margin:4px auto 0;border-radius:2px}
279
+ .kticks{position:relative;height:16px;margin-top:8px}
280
+ .kticks span{position:absolute;transform:translateX(-50%);color:var(--faint);white-space:nowrap;
281
+ font:700 10.5px/1 'Satoshi','Helvetica Neue',Arial,sans-serif;letter-spacing:.1em;text-transform:uppercase}
282
+ .kticks span.on{font-weight:800}
283
+ .terr{margin-top:4px}
284
+ .terr summary{cursor:pointer;color:var(--rose,var(--faint));font-size:12px;font-weight:600;list-style:none}
285
+ .terr summary::-webkit-details-marker{display:none}
286
+ .terr p{margin:4px 0 0;font-size:12px;line-height:1.6;color:var(--slate)}
287
+ .ko-caveats{padding:16px 24px;margin:14px 0 8px}
288
+ .ko-caveats p{margin:0 0 7px;font-size:12px;line-height:1.6;color:var(--faint)}
289
+ .ko-caveats p:last-child{margin-bottom:0}
268
290
  `;
269
291
 
270
292
  const REPORT_BASE = REPORT_ROOT + '\n' + readFileSync(join(HERE, 'templates', 'report.css'), 'utf8');
@@ -311,7 +333,7 @@ function glanceSection(marks, framework, registerCounts) {
311
333
  // No framework ⇒ NOTHING. A run this render was given no manifest for gets no attribution invented for
312
334
  // it, and the chips still carry their own words.
313
335
  const legend = framework?.title
314
- ? `<div class="ko-legend">Rated under <span class="mono">${esc(framework.title)}</span></div>`
336
+ ? '' /* The 2026-09-16 report redesign — the framework is named once, on the rating card */
315
337
  : '';
316
338
  return `<div class="panel ko-glance">${legend}${rows}</div>`;
317
339
  }
@@ -415,7 +437,7 @@ function territoriesLine(registerCounts) {
415
437
  const COUNTS_READER_LINE = 'Counts include live, pending and dead filings. '
416
438
  + 'A count is not a conflict; the cards above say which filings matter.';
417
439
 
418
- function countsSection(marks, registerCounts) {
440
+ function countsSection(marks, registerCounts, positions = '') {
419
441
  // ── THE DEFINITION MOVES INTO THE COLUMN HEADER ──────────────────────────────────────────────────
420
442
  //
421
443
  // The 70-word count-basis paragraph existed because three one-word headers — Identical, Containing,
@@ -468,7 +490,8 @@ function countsSection(marks, registerCounts) {
468
490
  // thing in three words each.
469
491
  return `<div class="panel">
470
492
  <div class="ko-counts ko-scroll"><table><thead><tr><th>Name</th>${head}</tr></thead><tbody>${rows}</tbody></table></div>
471
- <p class="ko-basis">${esc(COUNTS_READER_LINE)}<br>${esc(territoriesLine(registerCounts))}</p>
493
+ <p class="ko-basis">${esc(COUNTS_READER_LINE)}<br>${esc(territoriesLine(registerCounts))}${
494
+ positions ? `<br>${esc(positions)}` : ''}</p>
472
495
  </div>`;
473
496
  }
474
497
 
@@ -730,11 +753,15 @@ function findingBlock(v, framework, markIndex) {
730
753
  <div class="cardhead">
731
754
  <span class="fnum">${esc(v.ref)}</span><span class="who">${esc(v.name ?? 'Unnamed finding')}</span>${
732
755
  v.band ? `<span class="ko-findband" style="background:var(${stop})">${esc(v.band)}</span>` : ''}${sourceChips(v)}
756
+ <button class="ask-fi no-print">\u2726 Ask AI about this finding</button>
733
757
  </div>
734
758
  ${meta ? `<p class="ko-findmeta">${meta}</p>` : ''}
735
759
  ${v.lead ? `<p class="ko-findnet">${inlineMd(v.lead)}</p>` : ''}
736
- ${whyBandFold(v.detail ? `<p class="ko-findbasis">${inlineMd(v.detail)}</p>` : '')}
737
- ${ev ? `<p class="ko-findev">Evidence: ${ev}</p>` : ''}
760
+ ${/* The 2026-09-16 report redesign — head, meta and the one-line read stay visible; the rest folds, as on
761
+ a clearance card, so a screen of many names reads as a screen. */''}
762
+ ${(v.detail || ev) ? `<details class="drill"><summary>Full detail</summary><div class="drillbody">${
763
+ whyBandFold(v.detail ? `<p class="ko-findbasis">${inlineMd(v.detail)}</p>` : '')}${
764
+ ev ? `<p class="ko-findev">Evidence: ${ev}</p>` : ''}</div></details>` : ''}
738
765
  </div>
739
766
  </div>
740
767
  </div>`;
@@ -1116,33 +1143,53 @@ function ownerCheckFor(ownerChecks, recordId) {
1116
1143
  * They keep the purple convention here exactly as they wear it under the cards, which is also what keeps
1117
1144
  * them off the export: the strip is one rule on .internal and it reaches both places.
1118
1145
  */
1119
- function aboutRequestBlock(scope, requestNotes) {
1146
+ function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext = '', searched = '', registerCounts = null) {
1120
1147
  const goods = String(scope?.goods ?? '').trim();
1121
1148
  const classes = (Array.isArray(scope?.classes) ? scope.classes : []).filter((c) => c || c === 0);
1122
1149
  const jx = (Array.isArray(scope?.jurisdictions) ? scope.jurisdictions : []).map((t) => territoryName(t)).filter(Boolean);
1123
- const asked = [
1124
- classes.length ? `Class${classes.length === 1 ? '' : 'es'} ${classes.join(', ')}` : '',
1125
- goods,
1126
- ].filter(Boolean).join(' — ');
1127
- const where = jx.length ? `Searched in ${listWords(jx)}.` : '';
1128
- const flags = (requestNotes ?? []).filter(Boolean);
1129
- if (!asked && !where && !flags.length) return '';
1130
- return `<div class="ko-req">
1131
- <span class="ko-lbl2">About this request</span>
1132
- ${asked ? `<p>${esc(asked)}</p>` : ''}
1133
- ${where ? `<p>${esc(where)}</p>` : ''}
1134
- ${flags.length ? `<div class="internal ko-reqflag">${
1135
- flags.map((n) => `<p class="ko-bul">${inlineMd(n)}</p>`).join('')}</div>` : ''}
1150
+ const asked = goods;
1151
+ const classLine = classes.length ? classes.join(', ') : '';
1152
+ const where = jx.length ? listWords(jx) : '';
1153
+ // The type of search is the bolded head of the depth note; the rest of that sentence is the product's
1154
+ // covers line, which the owner ruled off the page.
1155
+ const note = String(depthNote ?? '').trim();
1156
+ const cut = note.lastIndexOf(' \u2014 ');
1157
+ const stage = cut > 0 ? note.slice(0, cut).trim() : note;
1158
+ const context = String(productContext ?? '').trim();
1159
+ // REGISTERS COUNTED IS A ROW, AND THE TERRITORY LIST HIDES BEHIND IT (the 2026-09-16 report redesign). A line of
1160
+ // country names running through the middle of the panel is the thing a reader skips; the count and
1161
+ // its source are what they read, and the list is one click away when they want it.
1162
+ const regions = (registerCounts?.scope?.regions ?? []).filter(Boolean);
1163
+ const provider = registerCounts?.providerLabel ?? registerCounts?.provider ?? '';
1164
+ const named = regions.map(territoryName).filter(Boolean);
1165
+ const counted = regions.length
1166
+ ? `${regions.length} ${regions.length === 1 ? 'territory' : 'territories'}${provider ? `, on ${provider}` : ''}`
1167
+ : (registerCounts ? `Counted worldwide${provider ? `, on ${provider}` : ''}` : '');
1168
+ const territories = named.length ? listWords(named) : '';
1169
+ if (!asked && !where && !classLine && !stage && !context && !counted) return '';
1170
+ // ONE PANEL, THE SAME ON BOTH REPORTS (the 2026-09-16 report redesign). The rows carry what was asked for; the
1171
+ // counts row says what was counted and hides the territory list behind a fold rather than running a
1172
+ // line of country codes through the middle of the panel.
1173
+ const row = (k, v, extra = '') => v ? `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(v)}${extra}</span></div>` : '';
1174
+ return `<div class="panel about">
1175
+ <div class="label">About this request</div>
1176
+ ${row('Type of search', stage)}
1177
+ ${row('Instructed use', asked)}
1178
+ ${row('Classes', classLine)}
1179
+ ${row('Where searched', where)}
1180
+ ${row('Context', context)}
1181
+ ${row('Registers counted', counted, territories ? `<details class="terr"><summary>View territories</summary><p>${esc(territories)}</p></details>` : '')}
1182
+ ${row('Searched on', searched)}
1183
+ ${/* item 18 — a note for the reviewing lawyer does not reach the delivered page */''}
1136
1184
  </div>`;
1137
1185
  }
1138
1186
 
1139
1187
  function reviewerNotesBlock(m) {
1140
1188
  const notes = splitKnockoutNotes(m).name;
1141
1189
  if (!notes.length) return '';
1142
- return `<div class="internal">
1143
- <span class="tag">For the reviewing lawyer</span>
1144
- ${notes.map((n) => `<p class="ko-bul">${inlineMd(n)}</p>`).join('')}
1145
- </div>`;
1190
+ // Item 18 (owner, 2026-09-16): the reviewing lawyer's notes stay in the working record, the audit
1191
+ // workbook and the assistant. They were styled as a visible block on the page a client opens.
1192
+ return '';
1146
1193
  }
1147
1194
 
1148
1195
  function registerFindingBlock(v, markIndex, reads = null, framework = null, ownerChecks = []) {
@@ -1181,12 +1228,18 @@ function registerFindingBlock(v, markIndex, reads = null, framework = null, owne
1181
1228
  <div class="cardhead">
1182
1229
  <span class="fnum">${esc(v.ref)}</span><span class="who">${esc(r.mark ?? 'Unnamed filing')}</span>${
1183
1230
  band ? `<span class="ko-findband" style="background:var(${stop})">${esc(band)}</span>` : ''}<span class="src reg">Register</span>
1231
+ <button class="ask-fi no-print">\u2726 Ask AI about this finding</button>
1184
1232
  </div>
1185
1233
  <p class="ko-findmeta">${meta}</p>
1186
1234
  <p class="ko-findnet">${esc(v.statement)}</p>
1235
+ ${/* The 2026-09-16 report redesign — the head, the meta line and the one-line read stay visible; the rest
1236
+ sits behind a fold, as it does on a clearance card, so a screen of many names reads as a
1237
+ screen rather than as a wall. */''}
1238
+ <details class="drill"><summary>Full detail</summary><div class="drillbody">
1187
1239
  ${basisLine(band, read)}
1188
1240
  ${useCheck ? `<p class="ko-findev">${esc(USE_CHECK_LABEL)} ${isHttpUrl(useCheckSource) ? linkOrText(useCheckSource) : esc(useCheckSource)}</p>` : ''}
1189
1241
  <p class="ko-findev">Register record: ${receipt}</p>
1242
+ </div></details>
1190
1243
  </div>
1191
1244
  </div>
1192
1245
  </div>`;
@@ -1242,7 +1295,11 @@ function registerLine(mark, registerCounts, probeRan, registerRecords = null, ca
1242
1295
  * This is the "none exists" arm, and it is only reachable when every
1243
1296
  * search actually answered.
1244
1297
  */
1245
- function registerPositionClause(mark, registerCounts, registerRecords, cards = []) {
1298
+ // `where` names where the cards this clause points at actually sit relative to it. It said "above"
1299
+ // unconditionally, which was true while the clause was drawn under the cards. It is drawn under the
1300
+ // COUNTS now — the numbers it qualifies — and the cards are below it, so a fixed word would send a
1301
+ // reader the wrong way up the page. The caller knows the order; this function does not guess it.
1302
+ function registerPositionClause(mark, registerCounts, registerRecords, cards = [], where = 'above') {
1246
1303
  if (!registerRecords) {
1247
1304
  return registerCounts ? 'The filings behind those counts were not listed on this run.' : '';
1248
1305
  }
@@ -1255,11 +1312,11 @@ function registerPositionClause(mark, registerCounts, registerRecords, cards = [
1255
1312
  if (cards.length) {
1256
1313
  const classes = [...new Set(cards.flatMap((c) => (c.record.classes ?? []).map(Number).filter(Number.isFinite)))]
1257
1314
  .sort((a, b) => a - b);
1258
- const where = classes.length ? ` in class${classes.length === 1 ? '' : 'es'} ${classes.join(', ')}` : '';
1315
+ const classWhere = classes.length ? ` in class${classes.length === 1 ? '' : 'es'} ${classes.join(', ')}` : '';
1259
1316
  const one = cards.length === 1;
1260
1317
  const refs = cards.map((c) => c.ref).join(', ');
1261
1318
  return `${one ? 'A filing' : `${cards.length} filings`} of this name or a close variation of it `
1262
- + `stand${one ? 's' : ''} on ${provider}${where} — ${refs} above.`;
1319
+ + `stand${one ? 's' : ''} on ${provider}${classWhere} — ${refs} ${where}.`;
1263
1320
  }
1264
1321
  const records = entry.records ?? [];
1265
1322
  if (!records.length) {
@@ -1312,21 +1369,20 @@ function coverageClause(mark, registerCounts, probeRan) {
1312
1369
  * the first one and asked "what holds what?" — so the fix is not a synonym in the same style, it is a
1313
1370
  * label that carries the band's own word and answers the question by itself.
1314
1371
  *
1315
- * factors → "Why High" (the mark's own band)
1316
- * counterFactors → "Why not Very High" (the rung ABOVE it on the run's own ladder)
1317
- * mitigation → "What would lower the risk"
1372
+ * factors → "Why High" (the mark's own band, on the rating card)
1373
+ * counterFactors → "Remaining uncertainties"
1374
+ * mitigation → "What would change this"
1318
1375
  *
1319
- * The ladder runs worst-first, so the rung above is the PREVIOUS entry. At the top rung there is no
1320
- * higher band to name and the honest label is "What keeps it here" — the same question, asked where the
1321
- * comparative form has no answer. A band this build cannot find on the ladder gets that label too,
1322
- * rather than a comparative naming a rung that may not exist.
1376
+ * The counter-factor label was "Why not <the rung above>", read off the run's own ladder. It asked the
1377
+ * reader to hold a ladder the page had not given them; at the top rung there was no higher band to name,
1378
+ * so it asked a different question there; and a band the build could not find on the ladder fell to that
1379
+ * same special case. the 2026-09-16 report redesign names what is under the heading instead, at every rung.
1323
1380
  */
1324
- function counterLabel(framework, band) {
1325
- const ladder = Array.isArray(framework?.bands) ? framework.bands : [];
1326
- const i = ladder.findIndex((b) => String(b?.label ?? '').trim().toLowerCase() === String(band ?? '').trim().toLowerCase());
1327
- const up = i > 0 ? String(ladder[i - 1]?.label ?? '').trim() : '';
1328
- return up ? `Why not ${up}` : 'What keeps it here';
1329
- }
1381
+ // The 2026-09-16 report redesign — the heading names what is UNDER it rather than a band the reader was not shown.
1382
+ // It read "Why not <the rung above>", which asked the reader to hold a ladder they had not been given
1383
+ // and, at the top rung, had no band to name and asked a different question instead. The engine's
1384
+ // bullets are unchanged; only the label is, and it no longer varies with the band.
1385
+ const COUNTER_LABEL = 'Remaining uncertainties';
1330
1386
 
1331
1387
  const ASSESSMENT_FOLD_LABEL = 'Read the full assessment';
1332
1388
 
@@ -1343,21 +1399,82 @@ const SCOPE_BLOCK_TEXT = 'What this is. A fast screen for obvious blockers to us
1343
1399
  // The stopword set and the idempotent stem travel with it; the reference text below stays here, because
1344
1400
  // it is this page's own words and nothing else's.
1345
1401
 
1402
+ // ── THE RATING CARD, ONE DESIGN WITH THE CLEARANCE REPORT (the 2026-09-16 report redesign) ──────────────────────
1403
+ // The company's own ladder drawn as the coloured scale with the band lit, the basis sentence, the
1404
+ // engine's own "why" bullets under a heading that names the band, and the framework named once. The
1405
+ // ticks clamp at the ends so the first and last words do not hang off the bar. Nothing is composed:
1406
+ // the bullets and the basis are the engine's, and the band words are the framework's own.
1407
+ function koScale(framework, band) {
1408
+ const ladder = (Array.isArray(framework?.bands) ? framework.bands : []).map((b) => String(b?.label ?? '').trim()).filter(Boolean).reverse();
1409
+ if (!ladder.length) return '';
1410
+ const n = ladder.length;
1411
+ const idx = Math.max(0, ladder.findIndex((b) => b.toLowerCase() === String(band ?? '').trim().toLowerCase()));
1412
+ const pct = (k) => (n > 1 ? (k / (n - 1)) * 100 : 50);
1413
+ const TONE = ['var(--clear)', 'var(--low)', 'var(--med)', 'var(--high)', 'var(--severe)'];
1414
+ const tone = (k) => TONE[Math.round((k / Math.max(1, n - 1)) * (TONE.length - 1))];
1415
+ const shift = (k) => (k === 0 ? '0' : k === n - 1 ? '-100%' : '-50%');
1416
+ return `<div class="kscale"><div class="kbar" style="background:linear-gradient(90deg,${
1417
+ ladder.map((b, k) => `${tone(k)} ${pct(k)}%`).join(',')})"><div class="kmarker" style="left:${pct(idx)}%;transform:translateX(${
1418
+ idx === 0 ? '-12px' : idx === n - 1 ? 'calc(-100% + 12px)' : '-50%'})"><div class="kpill" style="background:${tone(idx)}">${
1419
+ esc(ladder[idx] || band || '')}</div><div class="kneedle" style="background:${tone(idx)}"></div></div></div><div class="kticks">${
1420
+ ladder.map((b, k) => `<span class="${k === idx ? 'on' : ''}" style="left:${pct(k)}%;transform:translateX(${shift(k)})${
1421
+ k === idx ? `;color:${tone(k)}` : ''}">${esc(b)}</span>`).join('')}</div></div>`;
1422
+ }
1423
+
1424
+ // WHAT HAPPENS NEXT, AS ITS OWN SECTION (the 2026-09-16 report redesign). The engine writes this paragraph part-way
1425
+ // down each mark's long assessment, under a heading of its own, where it rendered only inside a fold
1426
+ // that opens at the bottom of the screen. It is the one paragraph a reader acts on, so it closes the
1427
+ // page under its own heading.
1428
+ //
1429
+ // It MOVES, it is not copied: `splitOutcome` returns the assessment without it, and the fold renders
1430
+ // that remainder, so the words appear once.
1431
+ //
1432
+ // THE HEADING IS THE MODEL'S OWN, so this hoists only the LAST block of the assessment. The assessing
1433
+ // skill fixes no heading vocabulary — it asks for sub-headers "where the content divides" and for the
1434
+ // read to close on what to do with the name, and the wording is written fresh each run. Matching a list
1435
+ // of phrases anywhere in the document would let a mark whose read happens to argue under "Recommendation"
1436
+ // in the MIDDLE have that middle lifted to the bottom, handing the client the engine's argument in an
1437
+ // order the engine did not write, with nothing going red. A trailing block is the one position where a
1438
+ // move cannot reorder what is left. So the phrase list only decides whether the last block is the
1439
+ // outcome; anything earlier stays where it was written, and the page renders the assessment whole.
1440
+ const OUTCOME_HEADING_RE = /^#{1,6}\s*(?:what to do with it|what happens next|next steps?|recommendations?)\b\s*$/i;
1441
+ export function splitOutcome(assessment) {
1442
+ const whole = String(assessment ?? '').trim();
1443
+ const lines = whole.split('\n');
1444
+ const heads = lines.map((l, i) => [l.trim(), i]).filter(([l]) => /^#{1,6}\s/.test(l));
1445
+ const last = heads[heads.length - 1];
1446
+ if (!last || !OUTCOME_HEADING_RE.test(last[0])) return { body: whole, outcome: '' };
1447
+ const outcome = lines.slice(last[1] + 1).join('\n').trim();
1448
+ if (!outcome) return { body: whole, outcome: '' };
1449
+ return { body: lines.slice(0, last[1]).join('\n').trim(), outcome };
1450
+ }
1451
+ function markOutcomes(marks) {
1452
+ return (marks ?? [])
1453
+ .map((m) => ({ name: String(m?.name ?? '').trim(), text: splitOutcome(m?.assessment).outcome }))
1454
+ .filter((o) => o.text);
1455
+ }
1456
+
1346
1457
  function readBlock(m, framework) {
1347
1458
  const factors = (m.factors ?? []).filter((s) => typeof s === 'string' && s.trim());
1348
1459
  const counter = (m.counterFactors ?? []).filter((s) => typeof s === 'string' && s.trim());
1349
1460
  const structured = Boolean(m.basis || factors.length || counter.length || m.mitigation);
1350
1461
  const bullets = (m.bullets ?? []).map((b) => `<li>${inlineMd(b)}</li>`).join('');
1351
1462
  if (!structured) return bullets ? `<ul class="ko-bul">${bullets}</ul>` : '';
1352
- const assessment = String(m.assessment ?? '').trim();
1463
+ const assessment = splitOutcome(m.assessment).body;
1353
1464
  const band = String(m.rating ?? '').trim();
1354
- return [
1465
+ const card = [
1466
+ `<div class="label">Overall risk</div>`,
1467
+ koScale(framework, band),
1355
1468
  m.basis ? `<p class="ko-basisline">${inlineMd(m.basis)}</p>` : '',
1356
- factors.length ? `<div class="ko-why-band">${band ? `<span class="ko-lbl2">Why ${esc(band)}</span>` : ''}<ul class="ko-bul">${
1469
+ factors.length ? `<div class="gwhy">${band ? `<div class="label">Why ${esc(band)}</div>` : ''}<ul class="ko-bul">${
1357
1470
  factors.map((f) => `<li>${inlineMd(f)}</li>`).join('')}</ul></div>` : '',
1358
- counter.length ? `<div class="ko-counter"><span class="ko-lbl2">${esc(counterLabel(framework, band))}</span><ul class="ko-bul">${
1471
+ framework?.title ? `<div class="gframe">Rated on the ${esc(framework.title)}</div>` : '',
1472
+ ].filter(Boolean).join('');
1473
+ return [
1474
+ card ? `<div class="panel gauge ko-gauge">${card}</div>` : '',
1475
+ counter.length ? `<div class="ko-counter"><span class="ko-lbl2">${esc(COUNTER_LABEL)}</span><ul class="ko-bul">${
1359
1476
  counter.map((f) => `<li>${inlineMd(f)}</li>`).join('')}</ul></div>` : '',
1360
- m.mitigation ? `<div class="ko-mitig"><span class="ko-lbl2">What would lower the risk</span><p>${inlineMd(m.mitigation)}</p></div>` : '',
1477
+ m.mitigation ? `<div class="ko-mitig"><span class="ko-lbl2">What would change this</span><p>${inlineMd(m.mitigation)}</p></div>` : '',
1361
1478
  // ── THE ASSESSMENT REPLACES THE "FULL NARRATIVE" FOLD ────────────────────────────────────────────
1362
1479
  //
1363
1480
  // The model writes both. `bullets` (five) say in other words what `factors` (four) already say
@@ -1407,6 +1524,18 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
1407
1524
  // The guard is deliberately TIGHT: a listed filing that is dead or out of the counted classes does
1408
1525
  // not suppress it, because for that mark the sentence is true and the filings section says so.
1409
1526
  const body = [bullets, finds, regBlocks, overflow].filter(Boolean).join('');
1527
+ // A BAND WITH NO LADDER NAMED IS A NUMBER WITH NO SCALE. The framework's name rides the rating card,
1528
+ // and that card is only built for a mark with a structured read — an archived run whose marks carry
1529
+ // bullets and nothing else draws no card, and its band chips were then shown with no statement
1530
+ // anywhere of which ladder they belong to. It was named in the glance panel, which rendered either
1531
+ // way; that line went because the card names it, and this case went with it unnoticed.
1532
+ //
1533
+ // IT SITS OUTSIDE `body`, and that is not tidiness. `body` being empty is what decides whether this
1534
+ // mark says "no adverse signals recorded" — a mark with nothing against it — so anything added to it
1535
+ // silently retires that sentence. Named ONCE: the read above carries it where it drew a card, and
1536
+ // the test for that reads the markup this same function just produced.
1537
+ const frameworkLine = !body.includes('class="gframe"') && framework?.title && String(m.rating ?? '').trim()
1538
+ ? `<div class="gframe">Rated on the ${esc(framework.title)}</div>` : '';
1410
1539
  return `<div class="ko-row">
1411
1540
  <div>
1412
1541
  <p class="ko-name">${esc(m.name)}</p>
@@ -1416,9 +1545,10 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
1416
1545
  </div>
1417
1546
  <div>
1418
1547
  ${body || '<p class="ko-bul">No adverse signals recorded for this name on this screen.</p>'}
1548
+ ${frameworkLine}
1419
1549
  ${m.degraded ? `<p class="ko-degraded">${esc(DEGRADED_NOTE)}</p>` : ''}
1420
1550
  ${reviewerNotesBlock(m)}
1421
- <p class="ko-reg">${esc(registerLine(m, registerCounts, probeRan, registerRecords, reg.cards))}</p>
1551
+ ${/* The 2026-09-16 report redesign — the register line restated the counts table above it */''}
1422
1552
  </div>
1423
1553
  </div>`;
1424
1554
  }).join('');
@@ -1483,6 +1613,10 @@ function depthStrip(note) {
1483
1613
  // complete renderer-owned paragraphs (methodLine above, the audit-workbook sentence below). This is the
1484
1614
  // lead-in they had and it did not: it is renderer-owned, so it is there whatever the model wrote, and it
1485
1615
  // says what the lines under it are. It is not a filter and it rewrites no caveat.
1616
+ // IT CAME BACK WITH THE BLOCK (2026-09-16). The caveats were rendered nowhere for a while after the
1617
+ // section above them was removed, and the first restoration drew them as bare paragraphs — dropping this
1618
+ // sentence, which is the whole of what the ruling asked for. One paragraph, the lead-in first, the
1619
+ // caveats verbatim after it; only where it sits on the page has changed.
1486
1620
  const CAVEAT_LEAD = 'This screen also carries the following limits:';
1487
1621
 
1488
1622
 
@@ -1557,9 +1691,29 @@ export function renderKnockoutHtml(findings, framework, {
1557
1691
  // nothing. It renders in the place the numbers would have occupied, at the top of the section that
1558
1692
  // owns them, because that is where a reader looks for them and fails to find them.
1559
1693
  const tierLine = tierAbsenceLine(registerCounts, probeRan);
1694
+ // WHERE THE NAME STANDS, WHICH THE NUMBERS DO NOT SAY. A table of counts answers how many filings were
1695
+ // seen; the question a reader holds is whether anything STANDS on the register for this name, and two
1696
+ // of that question's six states are absences — nothing was listed, and the listing could not be made
1697
+ // at all. The clause said it from the line under the conflict cards, and that line was removed for
1698
+ // restating the counts table. Only its COVERAGE half restated the table; the position half is a
1699
+ // separate answer, and losing it left an absence as a silence over a table of numbers, which is the
1700
+ // one thing the comment over it says must never happen.
1701
+ //
1702
+ // IT RIDES BOTH BRANCHES. Attaching it to the counts table alone would have dropped it on every run
1703
+ // that has records and no counts — a product tier that lists filings without counting them — and that
1704
+ // is the same silent loss again, one branch further down. The refs come from the same view function
1705
+ // the cards are drawn from: a second derivation here would be a second answer to which filings were
1706
+ // promoted, and the clause would name refs no card carries.
1707
+ const cardsByMark = new Map(marks.map((m) => [m?.name, registerCardViews(m, framework, registerRecords).cards]));
1708
+ const positions = marks.map((m) => {
1709
+ const clause = registerPositionClause(m, registerCounts, registerRecords, cardsByMark.get(m?.name) ?? [], 'below');
1710
+ if (!clause) return '';
1711
+ return marks.length > 1 && m?.name ? `${m.name}: ${clause}` : clause;
1712
+ }).filter(Boolean).join(' ');
1560
1713
  const counts = hasCounts
1561
- ? countsSection(marks, registerCounts)
1562
- : `<div class="panel"><p class="ko-tier">${esc(tierLine)}</p></div>`;
1714
+ ? countsSection(marks, registerCounts, positions)
1715
+ : `<div class="panel"><p class="ko-tier">${esc(tierLine)}</p>${
1716
+ positions ? `<p class="ko-basis">${esc(positions)}</p>` : ''}</div>`;
1563
1717
  // ── A CAVEAT THAT SAYS NOTHING THE SCOPE BLOCK HAS NOT SAID IS NOT RENDERED ────────────────────────
1564
1718
  //
1565
1719
  // The four model-written caveats on the measured run overlap the fixed text completely — 331 item 9,
@@ -1577,7 +1731,7 @@ export function renderKnockoutHtml(findings, framework, {
1577
1731
  // They are about the asking, not about a name, so a batch repeating them per mark would be the same
1578
1732
  // sentence three times. reviewerNotesBlock renders the rest, under that mark's own cards.
1579
1733
  const requestNotes = marks.flatMap((m) => splitKnockoutNotes(m).request);
1580
- const aboutRequest = aboutRequestBlock(instructedScope, requestNotes);
1734
+ const aboutRequest = aboutRequestBlock(instructedScope, requestNotes, depthNote, productContext, issued, registerCounts);
1581
1735
  const provider = hasCounts ? (registerCounts.providerLabel ?? registerCounts.provider ?? 'the register') : null;
1582
1736
  // The filings appendix renders only when the run produced a listing artifact — never on its absence,
1583
1737
  // and never as an empty table. A knockout with no sidecar publishes exactly the counts-only document.
@@ -1594,11 +1748,11 @@ export function renderKnockoutHtml(findings, framework, {
1594
1748
  n + registerCardsOnPage(registerCardViews(m, framework, registerRecords).cards, m, framework).length, 0);
1595
1749
  const hasRecords = Boolean(registerRecords && (registerRecords.marks?.length || registerRecords.unavailable));
1596
1750
  const filings = hasRecords ? filingsSection(marks, registerRecords) : '';
1597
- const onFieldSec = `<div class="sec"><span class="num">${num()}</span><h2>On-field conflicts</h2><span class="note">${hasCounts ? `register hit-counts (${esc(provider)}) and the read, per name` : 'what we found, per name'}</span></div>
1751
+ const onFieldSec = `<div class="sec" id="findings"><h2>Conflicts</h2></div>
1598
1752
  ${counts}
1599
1753
  ${analysis}`;
1600
1754
  const filingsSec = filings
1601
- ? `<div class="sec"><span class="num">${num()}</span><h2>The filings behind the counts</h2><span class="note">the name itself and its close variations — the ${APPENDIX_ROWS} most material per name, full list in the workbook</span></div>
1755
+ ? `<div class="sec" id="filings"><h2>Also considered</h2></div>
1602
1756
  ${filings}`
1603
1757
  : '';
1604
1758
 
@@ -1623,7 +1777,8 @@ ${filings}`
1623
1777
  ${logoLockup({ mark: 20, tag: '' })}<span class="sp"></span>
1624
1778
  <span class="tb-risk" style="background:var(${overallStop})">${esc(overall ?? '—')}</span>
1625
1779
  <span class="mono tb-matter" style="font-size:11px;color:var(--faint)">${esc(matter || runId || '')}</span>
1626
- ${issued ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued ${esc(issued)}</span>` : ''}
1780
+ ${issued ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(issued)}</span>` : ''}
1781
+ <button type="button" class="tbbtn tb-ask no-print">\u2726 <span class="tb-lbl">Ask AI</span></button>
1627
1782
  </div>
1628
1783
  </div>
1629
1784
  <div class="fab-stack">${themeButton()}</div>
@@ -1649,9 +1804,7 @@ window.addEventListener('beforeprint',o);})();</script>
1649
1804
  <header class="hero">
1650
1805
  ${demoBannerHtml(demoData === true)}
1651
1806
  ${confLineHtml(delivery, productName)}
1652
- ${depthStrip(depthNote)}
1653
1807
  <h1 class="mark">${esc(title)}</h1>
1654
- ${productContext ? `<p class="ko-classes" style="font-size:13.5px;margin:0 0 16px">${inlineMd(productContext)}</p>` : ''}
1655
1808
  ${aboutRequest}
1656
1809
  ${summary ? `<div class="sub">${mdParagraphs(summary)}</div>` : ''}
1657
1810
  </header>
@@ -1660,53 +1813,33 @@ window.addEventListener('beforeprint',o);})();</script>
1660
1813
  ${onFieldSec}
1661
1814
  ${filingsSec}
1662
1815
 
1663
- <div class="sec"><span class="num">${num()}</span><h2>Scope &amp; what we didn't search</h2><span class="note">what this screen is</span></div>
1664
- <!-- COLLAPSED, like the clearance report's. Both documents already inline the
1665
- same report.css, and details.scope is its vocabulary — this section was the one place the two
1666
- lanes presented the same thing differently, with the knockout's open panel pushing the filings
1667
- table and the footer down the page on every read. What is inside is unchanged; a reader who
1668
- wants it opens it, exactly as they do on the bigger report.
1669
- NO BACKTICKS IN THIS COMMENT: it sits inside a template literal and one ends it, failing at
1670
- IMPORT time on a token nobody wrote. Second time in this file today. -->
1671
- <details class="scope"><summary>Scope &amp; what we didn't search</summary>
1672
- <div class="panel drillbody">
1673
- ${(() => {
1674
- // FROM THE POLICY WHERE THERE IS ONE. The fallback is not a tidy default: an
1675
- // archived run froze no policy, and re-rendering it must not invent a scope claim about a run
1676
- // nobody can now ask. It keeps the sentence it was delivered with.
1677
- // ── THE SCOPE BLOCK, WHOLE ────────────────────────────────────────────────────────────────────
1678
- //
1679
- // The composed lines said one thing five times in 362 words: "not a clearance" three times,
1680
- // "proceeds to clearance" twice, and the count-basis sentence a second time after the counts table
1681
- // had already carried it. This is the owner's replacement text, and its two halves are the two
1682
- // questions a reader of a screen actually has.
1683
- //
1684
- // "Every conflict above links to the material we found" STAYS CONDITIONAL and that is not a
1685
- // stylistic carry-over: said unconditionally it is an absence claim wider than what was examined,
1686
- // which this file refuses elsewhere in the same words. It prints only when every rendered conflict
1687
- // cites something.
1688
- const linked = citedFindings && !uncitedFindings
1689
- ? ' Every conflict above links to the material we found.'
1690
- : '';
1691
- const source = registerCounts
1692
- ? ` Register data: ${registerCounts.providerLabel ?? registerCounts.provider ?? 'the register'}.`
1693
- : '';
1694
- return `<p class="ko-scope"><b>What this is.</b> A fast screen for obvious blockers to using each `
1695
- + `name, from marketplace and web use plus a count of register filings.</p>`
1696
- + `<p class="ko-scope"><b>What it is not.</b> A clearance search. We drew no register conclusions `
1697
- + `and give no filing advice. A name that passes here is not clear; it goes on to clearance.</p>`
1698
- + `<p class="ko-scope">${linked.trim()}${linked ? ' ' : ''}The audit workbook holds every search run, `
1699
- + `every empty result and the working notes.${source}</p>`;
1700
- })()}
1701
- ${newCaveats.length ? `<p class="ko-scope" style="border-top:1px solid var(--line)">${CAVEAT_LEAD}<br>${newCaveats.map((c) => inlineMd(c)).join('<br>')}</p>` : ''}
1702
- ${auditFile ? `<p class="ko-scope" style="border-top:1px solid var(--line)"><a href="${escAttr(auditFile)}">Download the audit workbook (Excel)</a> — every search run, every negative result, and the working notes behind these ratings.${
1703
- citedFindings ? ` The reference beside each common-law conflict above (for example <span class="mono">${esc(firstRef(marks, framework))}</span>) is its row on the workbook's Findings sheet.${
1704
- registerCardCount ? ` A <span class="mono">REG</span> reference is a register filing rather than a common-law conflict — it has no Findings row, and its receipt is the register record the card links.` : ''}` : ''}</p>` : ''}
1705
- </div>
1706
- </details>
1816
+ ${(() => {
1817
+ const outs = markOutcomes(marks);
1818
+ if (!outs.length) return '';
1819
+ const label = outs.length > 1;
1820
+ const body = outs.map((o) => (label && o.name ? `<h3>${esc(o.name)}</h3>` : '') + mdParagraphs(o.text)).join('');
1821
+ return `<div class="sec" id="next"><h2>What happens next</h2></div>
1822
+ <div class="panel actions"><div class="actgrp act-you">${body}</div></div>`;
1823
+ })()}
1824
+
1825
+ ${/* The 2026-09-16 report redesign — the scope section and its fold are off the page: what a screen is and
1826
+ what it did not search is the narration the owner ruled out. */''}
1827
+
1828
+ ${/* THE MODEL'S CAVEATS OUTLIVE THE BLOCK THEY SAT UNDER. They were rendered after the scope
1829
+ section, and taking that section off the page left them computed and drawn nowhere — on the
1830
+ committed demo run, two caveats a client used to receive, one of them the only sentence on the
1831
+ page saying nothing above is a finding of availability. What the owner ruled out is the fixed
1832
+ boilerplate, not what the engine wrote for this run, so they close the page instead.
1833
+
1834
+ THE SUBSET FILTER STAYS, and so does the text it measures against. A caveat is dropped only
1835
+ when every content word in it is already said elsewhere on the page, which is what the About
1836
+ panel and the tier line now carry; a caveat making any new claim brings a new word with it and
1837
+ is kept. Both of the demo run's survive. */''}
1838
+ ${newCaveats.length ? `<div class="panel ko-caveats"><p class="ko-scope">${CAVEAT_LEAD}<br>${
1839
+ newCaveats.map((c) => inlineMd(c)).join('<br>')}</p></div>` : ''}
1707
1840
 
1708
1841
  <footer>
1709
- <span>${productName ? `${esc(productName)}. ` : ''}${framework?.title ? ` Rated under <span class="mono">${esc(framework.title)}</span>.` : ''}<br>Matter ${esc(matter || runId || '')}.${issued ? ` Issued ${esc(issued)}.` : ''}</span>
1842
+ <span>${productName ? `${esc(productName)}. ` : ''}<br>Matter ${esc(matter || runId || '')}.${issued ? ` Issued ${esc(issued)}.` : ''}</span>
1710
1843
  ${logoLockup({ mark: 16 })}
1711
1844
  </footer>
1712
1845
  </div>