clearotron 0.3.2-beta.7 → 0.3.2-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.env.example +58 -26
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +148 -81
  4. package/README.md +3 -3
  5. package/SECURITY.md +3 -3
  6. package/bin/brandowner.mjs +3 -3
  7. package/bin/framework-preflight.mjs +1 -1
  8. package/bin/onboard.mjs +637 -216
  9. package/bin/start.mjs +151 -27
  10. package/bin/update.mjs +58 -11
  11. package/build-info.json +2 -2
  12. package/docs/DELIVERY.md +2 -1
  13. package/docs/INTAKE.md +1 -1
  14. package/docs/ONBOARDING.md +1 -1
  15. package/docs/architecture/03-run-lifecycle.md +6 -6
  16. package/docs/architecture/04-configuration-reference.md +32 -14
  17. package/docs/architecture/05-config-governance.md +23 -8
  18. package/docs/architecture/05-customer-profiles.md +2 -2
  19. package/docs/architecture/06-operations-runbook.md +3 -3
  20. package/docs/architecture/08-development-guide.md +6 -6
  21. package/docs/configuration.md +5 -5
  22. package/docs/decisions/0003-credential-model.md +1 -1
  23. package/docs/writing-standard.md +4 -0
  24. package/driver/CHANGELOG.md +124 -0
  25. package/driver/README.md +3 -3
  26. package/driver/band-size.mjs +59 -0
  27. package/driver/binding-layers.mjs +1 -1
  28. package/driver/citation-census.json +3 -3
  29. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  30. package/driver/common-law-receipts.mjs +2 -2
  31. package/driver/company-bundle.mjs +3 -3
  32. package/driver/compose-read.mjs +8 -14
  33. package/driver/config-inventory.mjs +112 -9
  34. package/driver/consumption-ledger.mjs +2 -2
  35. package/driver/contract-arm2-baseline.json +2 -5
  36. package/driver/contract-dictation-registry.mjs +19 -19
  37. package/driver/contract-e3-backlog.mjs +43 -43
  38. package/driver/contract-e3-baseline.json +14 -14
  39. package/driver/contract-vocabulary.mjs +68 -27
  40. package/driver/deliver-trigger.sh +16 -16
  41. package/driver/demo-container.mjs +3 -3
  42. package/driver/dev-portal.mjs +3 -3
  43. package/driver/disposition-call.mjs +1 -1
  44. package/driver/door-gates.mjs +41 -7
  45. package/driver/doubt-ledger.mjs +2 -2
  46. package/driver/drainer-identity.mjs +34 -8
  47. package/driver/driver.config.mjs +367 -104
  48. package/driver/engine/CONTRACT.md +10 -3
  49. package/driver/engine/README.md +2 -2
  50. package/driver/engine/anthropic-agent.mjs +77 -21
  51. package/driver/engine/auth.mjs +129 -10
  52. package/driver/engine/jx-turn.mjs +7 -6
  53. package/driver/engine/mcp/README.md +1 -1
  54. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  55. package/driver/engine/mcp/gather-config.mjs +9 -9
  56. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  57. package/driver/engine/mcp/recording-server.mjs +18 -5
  58. package/driver/engine/openai-agent.mjs +4 -2
  59. package/driver/engine/probe.mjs +110 -23
  60. package/driver/enqueue-schema.mjs +6 -2
  61. package/driver/findings-model.mjs +6 -3
  62. package/driver/flag-snapshot.mjs +34 -8
  63. package/driver/form-neighbourhood.mjs +54 -7
  64. package/driver/framework.mjs +4 -4
  65. package/driver/gateway.mjs +36 -24
  66. package/driver/jx-lanes.mjs +23 -4
  67. package/driver/jx-units.mjs +7 -4
  68. package/driver/jx.mjs +34 -4
  69. package/driver/knockout-review-record.mjs +56 -4
  70. package/driver/known-conflicts.mjs +1 -1
  71. package/driver/matter-frame-record.mjs +90 -1
  72. package/driver/named-band.mjs +1 -1
  73. package/driver/ordinary-words.mjs +51 -0
  74. package/driver/outbox-backoff.mjs +31 -16
  75. package/driver/package.json +1 -1
  76. package/driver/partial-payload-baseline.json +2 -2
  77. package/driver/phase0.mjs +3 -3
  78. package/driver/pipeline-knockout.mjs +5 -5
  79. package/driver/pipeline.mjs +396 -81
  80. package/driver/placement-form.mjs +77 -1
  81. package/driver/placement-model.mjs +1 -1
  82. package/driver/portal-config-view.mjs +30 -1
  83. package/driver/portal-report.mjs +107 -6
  84. package/driver/portal-service.mjs +80 -14
  85. package/driver/portal-upstream.mjs +1 -1
  86. package/driver/predelivery-lint.mjs +12 -2
  87. package/driver/preserve-merge.mjs +3 -3
  88. package/driver/product-rows.mjs +2 -2
  89. package/driver/products.mjs +1 -1
  90. package/driver/profiles/README.md +3 -3
  91. package/driver/profiles/demo-brand-owner.json +2 -2
  92. package/driver/profiles.mjs +55 -17
  93. package/driver/progress.mjs +18 -8
  94. package/driver/provider-usage.mjs +8 -8
  95. package/driver/publish/index.mjs +154 -8
  96. package/driver/publish/knockout.mjs +39 -5
  97. package/driver/publish/pool-admin.mjs +1 -1
  98. package/driver/publish/publish-inputs.mjs +18 -2
  99. package/driver/publish/render-knockout.mjs +184 -31
  100. package/driver/publish/render.mjs +323 -93
  101. package/driver/publish/report-data.mjs +4 -1
  102. package/driver/publish/report-topbar.mjs +58 -0
  103. package/driver/publish/search-depth.mjs +133 -4
  104. package/driver/publish/templates/report.css +78 -4
  105. package/driver/publish/xlsx.mjs +20 -1
  106. package/driver/queue-order.mjs +2 -2
  107. package/driver/recording-agreement.mjs +1 -1
  108. package/driver/reference-score.mjs +1 -1
  109. package/driver/register-availability.mjs +2 -2
  110. package/driver/register-count.mjs +50 -5
  111. package/driver/register-coverage.mjs +161 -1
  112. package/driver/register-digest-record.mjs +236 -11
  113. package/driver/register-grant-vocabulary.mjs +1 -1
  114. package/driver/register-plan.mjs +189 -2
  115. package/driver/registry-fidelity.mjs +3 -3
  116. package/driver/repair-composers.mjs +1 -1
  117. package/driver/repair-contract.mjs +1 -1
  118. package/driver/replay-archive.mjs +6 -6
  119. package/driver/report-overview-record.mjs +2 -2
  120. package/driver/result-noun-fields.mjs +2 -2
  121. package/driver/run-economics.mjs +41 -10
  122. package/driver/run-requirements.mjs +173 -9
  123. package/driver/runner.mjs +5 -5
  124. package/driver/scope-facts.mjs +20 -5
  125. package/driver/scope-ledger.mjs +5 -5
  126. package/driver/search-policy.mjs +22 -12
  127. package/driver/skills/README.md +15 -15
  128. package/driver/skills/blind-frame/SKILL.md +2 -2
  129. package/driver/skills/case-law-citation/SKILL.md +4 -4
  130. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  131. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  132. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  133. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  134. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  135. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  136. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  137. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  138. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  139. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  140. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  141. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  142. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  143. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  144. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  145. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  146. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  147. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  148. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  149. package/driver/skills/frame-diff/SKILL.md +1 -1
  150. package/driver/skills/knockout-assess/SKILL.md +10 -7
  151. package/driver/skills/matter-frame/SKILL.md +3 -3
  152. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  153. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  154. package/driver/stage-context.mjs +1 -1
  155. package/driver/stages-knockout.mjs +4 -4
  156. package/driver/stages.mjs +65 -61
  157. package/driver/status-snapshot.mjs +2 -2
  158. package/driver/suite-census.json +340 -136
  159. package/driver/surface-exit-verdict.mjs +58 -0
  160. package/driver/systemd/README.md +9 -6
  161. package/driver/systemd/clearotron-worker.service +1 -1
  162. package/driver/terminal-clamp.mjs +109 -1
  163. package/driver/tokens.mjs +169 -3
  164. package/driver/unit-environment.mjs +42 -15
  165. package/driver/unit-inventory.mjs +19 -2
  166. package/driver/usage-ledger.mjs +1 -1
  167. package/driver/variant-manifest-model.mjs +4 -4
  168. package/driver/verify-knockout.mjs +27 -0
  169. package/driver/verify.mjs +94 -6
  170. package/driver/whatif-queue.mjs +1 -1
  171. package/driver/wordlists/en.txt +63906 -0
  172. package/mcp-server/CHANGELOG.md +8 -0
  173. package/mcp-server/README.md +1 -1
  174. package/mcp-server/lib/README.md +1 -1
  175. package/mcp-server/lib/options.mjs +8 -7
  176. package/mcp-server/lib/plan.mjs +18 -2
  177. package/mcp-server/lib/runs.mjs +1 -1
  178. package/mcp-server/lib/usage.mjs +3 -3
  179. package/mcp-server/lib/whatif.mjs +1 -1
  180. package/mcp-server/package.json +1 -1
  181. package/mcp-server/server.mjs +18 -1
  182. package/package.json +12 -11
  183. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  184. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-EVaSo5-g.js} +1580 -527
  185. package/portal-ui/dist/index.html +2 -2
  186. package/portal-ui/package.json +1 -1
  187. package/providers/README.md +1 -1
  188. package/providers/_shared/enumerate.mjs +6 -6
  189. package/providers/_shared/execute-plan.mjs +3 -3
  190. package/providers/_shared/ledger.mjs +119 -5
  191. package/providers/_shared/provider-text.mjs +2 -2
  192. package/providers/_shared/screen.mjs +2 -2
  193. package/providers/_shared/script-form.mjs +3 -3
  194. package/providers/_shared/territory-codes.mjs +23 -3
  195. package/providers/clarivate/README.md +1 -1
  196. package/providers/clarivate/src/capabilities.js +12 -12
  197. package/providers/clarivate/src/core.js +37 -43
  198. package/providers/corsearch/README.md +1 -1
  199. package/providers/corsearch/src/capabilities.js +5 -5
  200. package/providers/corsearch/src/core.js +3 -3
  201. package/providers/jx/README.md +2 -1
  202. package/providers/jx/src/turn-envelope.mjs +8 -3
  203. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  204. package/providers/oauth-mcp-bridge/package.json +1 -1
  205. package/providers/perplexity/src/core.js +1 -1
  206. package/providers/signa/README.md +1 -1
  207. package/providers/signa/src/capabilities.js +42 -49
  208. package/providers/signa/src/core.js +106 -29
  209. package/providers/uspto-local/README.md +1 -1
  210. package/providers/uspto-local/src/sync.js +1 -1
  211. package/scripts/README.md +1 -0
  212. package/scripts/ask-ai-render-check.mjs +127 -1
  213. package/scripts/authority-boundary-probe.mjs +8 -6
  214. package/scripts/backfill-started-at.mjs +2 -2
  215. package/scripts/census-merge-driver.mjs +33 -2
  216. package/scripts/citation-anchor-report.mjs +181 -0
  217. package/scripts/dead-names.mjs +1 -1
  218. package/scripts/deprecate-below.mjs +66 -8
  219. package/scripts/drain-preflight.mjs +1 -1
  220. package/scripts/e2e.mjs +174 -0
  221. package/scripts/env-audit.mjs +39 -6
  222. package/scripts/env-classify.mjs +20 -2
  223. package/scripts/freeze-example-run.mjs +61 -18
  224. package/scripts/generated-files-are-current.mjs +69 -4
  225. package/scripts/live-surface-check.mjs +124 -41
  226. package/scripts/markdown-link-check.mjs +1 -1
  227. package/scripts/merge-shape-check.mjs +242 -0
  228. package/scripts/mint-names-in-force.mjs +5 -5
  229. package/scripts/mint-offered-territories.mjs +72 -0
  230. package/scripts/mint-public-residue.mjs +2 -2
  231. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  232. package/scripts/mint-suite-census.mjs +75 -2
  233. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  234. package/scripts/purge-runs.mjs +7 -7
  235. package/scripts/reconcile-runs.mjs +2 -2
  236. package/scripts/release-approve-parked.mjs +20 -2
  237. package/scripts/release-await-cut.mjs +120 -1
  238. package/scripts/release-note-required.mjs +76 -8
  239. package/scripts/report-header-render-check.mjs +164 -0
  240. package/scripts/settings-render-check.mjs +75 -2
  241. package/scripts/test-full.mjs +96 -3
  242. package/scripts/test-run.mjs +10 -0
  243. package/shared/brand.mjs +27 -0
  244. package/shared/connect-clients.mjs +39 -11
  245. package/shared/deployment-box.mjs +7 -2
  246. package/shared/driver-dir.mjs +1 -1
  247. package/shared/env-aliases.mjs +1 -1
  248. package/shared/identifier-scan.mjs +65 -9
  249. package/shared/identifier-sentinels.mjs +22 -0
  250. package/shared/names-in-force.mjs +4 -2
  251. package/shared/offered-territories.json +738 -0
  252. package/shared/pre-rename-spellings.mjs +53 -0
  253. package/shared/reference-guard-classes.mjs +40 -2
  254. package/shared/stdio-connect.mjs +39 -4
  255. package/shared/tree-commit.mjs +48 -0
  256. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  257. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  258. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  259. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  260. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  261. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  262. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  263. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  264. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  265. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  266. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  267. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  268. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  269. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  270. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  271. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  272. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.9
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.8
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.7
4
12
 
5
13
  No changes in this release.
@@ -73,7 +73,7 @@ whether the stage makes **billed** external calls, **what downstream is NOT reco
73
73
  report downstream are **not** recomputed (a finished-report answer would need a full cascade, which is out of
74
74
  scope for v1 — see Future).
75
75
 
76
- `what_if_run` takes the `confirmationToken` and re-runs the one stage in `_experiments/…` under a `prelim-exp-…`
76
+ `what_if_run` takes the `confirmationToken` and re-runs the one stage in `_experiments/…` under a `clearance-exp-…`
77
77
  session key (canonical bytes untouched, its billed calls keyed off-run), then diffs it against canonical.
78
78
  **Live (undelivered) runs only.** Express the change with `instructions` (e.g. *"treat ACME's mark as
79
79
  expired"*) and/or `model`.
@@ -20,7 +20,7 @@ Change behaviour in these files, never in a tool handler.
20
20
  - `ops.mjs` — the write verbs: `start_run`, `stop_run`, `feed_context`, plus the delivery seam
21
21
  (`list_outbox_events`, `get_delivery_packet`, `ack_event`, `mark_sent`). Writes in an agent's own queue and run
22
22
  dirs *and* clears `.pending` markers in the one shared outbox (`CLEAROTRON_OUTBOX_DIR`, default
23
- `<workspaceRoot>/prelim-outbox`), which is why `ack_event` is account-gated; shells nothing. **`start_run`
23
+ `<workspaceRoot>/clearance-outbox`), which is why `ack_event` is account-gated; shells nothing. **`start_run`
24
24
  enqueues a job that spends real money.**
25
25
  - `whatif.mjs` — the approval-gated counterfactual. `whatIfPlan()` never spends and returns the cost prior, what
26
26
  downstream will *not* be recomputed, a completeness verdict and a `confirmationToken`; `whatIfRun(token)` re-runs
@@ -34,7 +34,7 @@
34
34
  // It is a DESCRIPTION and never an order: plan_run is still the free preview of a specific request, and
35
35
  // start_run is still the only thing that spends.
36
36
 
37
- import { ORDERABLE_PRODUCTS, PRODUCT_POLICIES, productAvailability, gateCause, UNAVAILABLE_NOTE, coverageDisclosure, loadRecipes } from "../../driver/search-policy.mjs";
37
+ import { ORDERABLE_PRODUCTS, PRODUCT_POLICIES, productAvailability, gateCause, UNAVAILABLE_NOTE, loadRecipes } from "../../driver/search-policy.mjs";
38
38
  import { productRow } from "../../driver/product-rows.mjs";
39
39
  import { PRODUCTS, productName } from "../../driver/products.mjs";
40
40
  import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor } from "../../driver/flag-snapshot.mjs";
@@ -152,12 +152,13 @@ function products(built, registerCanCount = null, registerTerritories = undefine
152
152
  // The CAUSE never travels; only its sentence does. This is the structural reason no CLEAROTRON_* name
153
153
  // can reach a client's assistant from here.
154
154
  unavailableNote: cause ? UNAVAILABLE_NOTE[cause] : null,
155
- // — the same disclosure the browser gets, on the same product, from the
156
- // same composer. A worldwide search is orderable on a partial register now; what that register
157
- // does not reach is a sentence beside a live product rather than the reason a dead one refuses.
158
- // Two doors, one answer: an assistant driving this must not be able to tell a client something
159
- // the screen does not say.
160
- coverageNote: coverageDisclosure(row.geography, registerTerritories)?.note ?? null,
155
+ // NO COVERAGE SENTENCE ON A PRODUCT ROW, AND THIS DOOR GOES WITH THE OTHER ONE. The rule that put
156
+ // a disclosure here was "two doors, one answer: an assistant driving this must not be able to tell
157
+ // a client something the screen does not say." That rule is why this line is now null rather than
158
+ // why it exists — the owner took the paragraph off the screen on 2026-09-18, and an assistant that
159
+ // kept reading it out would be the same defect through the other door. The reason it went is in
160
+ // portal-service.mjs beside its twin: it promises to defer a territory the engine now refuses.
161
+ coverageNote: null,
161
162
  };
162
163
  });
163
164
  }
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { validateJob, wantsPortalRoute, PORTAL_ROUTE_UNAVAILABLE } from "../../driver/enqueue-schema.mjs";
28
28
  import { resolveEffectiveProfile, recipeProseGuard } from "../../driver/profiles.mjs";
29
+ import { demoRunAgreement } from "../../driver/demo-run-agreement.mjs"; // the claim's own demo decision, consulted not copied
29
30
  import { gateCause, UNAVAILABLE_NOTE, coverageDisclosure, policyFor, loadRecipes, countJobMarks } from "../../driver/search-policy.mjs";
30
31
  import { productRow, baseTurnaroundFor } from "../../driver/product-rows.mjs";
31
32
  import { resolveRequest } from "../../driver/resolve-request.mjs";
@@ -199,7 +200,14 @@ export function planRun(args = {}, { scope, now = Date.now() } = {}) {
199
200
  let profile = null, projectKey = null, origins = null;
200
201
  // Profile resolution reads the roster off disk; infra trouble must degrade the PREVIEW, never throw —
201
202
  // the same fail-open posture validateJob takes for the roster checks.
202
- try { ({ profile, projectKey, origins } = resolveEffectiveProfile(job)); } catch { /* previewed without it */ }
203
+ // …EXCEPT the one refusal the claim itself makes on this read. With a company file unreadable, an order that
204
+ // names no company cannot be told apart from an order for that company, and the claim refuses it rather
205
+ // than rate it under `generic`. A preview that swallowed that answered wouldRun:true about a job the queue
206
+ // will refuse, so it travels as a blocker in the claim's own words. Only an unkeyed order reaches it, and
207
+ // only a staff session sends one unkeyed: a session scoped to accounts has its key stamped above.
208
+ let rosterRefusal = null;
209
+ try { ({ profile, projectKey, origins } = resolveEffectiveProfile(job)); }
210
+ catch (e) { if (e?.code === "profile_roster_incomplete") rosterRefusal = String(e.message); /* anything else: previewed without it */ }
203
211
 
204
212
  let resolved = null, eff = null, unavailable = null, snapshotForCoverage = null;
205
213
  try {
@@ -315,6 +323,14 @@ export function planRun(args = {}, { scope, now = Date.now() } = {}) {
315
323
  // plan_run itself invites. Read off `job` (post-buildJob, post-validateJob) with the wall's own
316
324
  // predicate and the wall's own sentence, so the two doors cannot drift apart.
317
325
  const routeBlocker = wantsPortalRoute(job) ? PORTAL_ROUTE_UNAVAILABLE : null;
326
+ // THE DEMO-DATA AGREEMENT, asked as the claim asks it. A demo account with an ordinary order, or a real
327
+ // account with a demo order, is refused at the claim in `demoRunAgreement`'s own words; a preview that did
328
+ // not ask answered wouldRun:true about an order the queue refuses. Same function, same inputs, same
329
+ // precedence as the wall: only once the request resolves, never ahead of a clarify.
330
+ const demoAdmission = profile && !resolved?.clarify
331
+ ? demoRunAgreement({ demoRun: job?.demoRun === true, demoData: profile?.demoData === true, who: job?.account ?? job?.profileKey ?? "this account" })
332
+ : null;
333
+ const demoBlocker = demoAdmission && !demoAdmission.ok ? demoAdmission.reject : null;
318
334
  // DEDUPED, and this door is the only one that needs it. The other four SHORT-CIRCUIT — they refuse on
319
335
  // validateJob and never reach the resolved-product fold — while a preview's whole job is to list every
320
336
  // blocker at once, so it sees both. Some sentences are legitimately produced twice for one request: a
@@ -322,7 +338,7 @@ export function planRun(args = {}, { scope, now = Date.now() } = {}) {
322
338
  // the fold against what it RESOLVES TO, and when the two are the same product they are the same
323
339
  // sentence. Read aloud to a requester, "this request names 2 countries" twice is a defect; deduping
324
340
  // where they are joined keeps every source honest and says it once.
325
- const blocking = [...new Set([...(v.classify === "reject" || !v.ok ? v.errors : []), ...gates.errors, ...(unavailable ? [unavailable] : []), ...(quotaBlocker ? [quotaBlocker] : []), ...(routeBlocker ? [routeBlocker] : [])])];
341
+ const blocking = [...new Set([...(rosterRefusal ? [rosterRefusal] : []), ...(v.classify === "reject" || !v.ok ? v.errors : []), ...gates.errors, ...(unavailable ? [unavailable] : []), ...(quotaBlocker ? [quotaBlocker] : []), ...(routeBlocker ? [routeBlocker] : []), ...(demoBlocker ? [demoBlocker] : [])])];
326
342
 
327
343
  // ONE lookup, and the fields below come off it — the same shape portal-service's plan door was fixed
328
344
  // into. productRow computes a turnaround on every call; asking twice for one answer is how two answers
@@ -25,7 +25,7 @@ function findStatusFiles(root, depth, acc) {
25
25
  }
26
26
  }
27
27
 
28
- // Every workspace-<agent>/studio/prelim-search root under the live workspace root.
28
+ // Every workspace-<agent>/studio/clearance-search root under the live workspace root.
29
29
  export function studioRoots() {
30
30
  const out = [];
31
31
  let names = [];
@@ -10,9 +10,9 @@
10
10
 
11
11
  import { tallyRegisterCalls, DEFAULT_LEDGER_PATH } from "./driver.mjs";
12
12
 
13
- // The driver tags every billed call's gateway session-key `prelim-<slug>-<codename>-<stage>…`. Our server is
14
- // named "trademark-artifacts", but the RUNS were produced by the prelim-driver, so the prefix stays `prelim-`.
15
- export function runPrefix(run) { return `prelim-${run.slug}-${run.codename}-`; }
13
+ // The driver tags every billed call's gateway session-key `clearance-<slug>-<codename>-<stage>…`. Our server is
14
+ // named "trademark-artifacts", but the RUNS were produced by the prelim-driver, so the prefix stays `clearance-`.
15
+ export function runPrefix(run) { return `clearance-${run.slug}-${run.codename}-`; }
16
16
 
17
17
  // — the compared key set is DERIVED from the two tallies, not restated here.
18
18
  //
@@ -54,7 +54,7 @@ export const WHAT_IF_NOTE =
54
54
 
55
55
  // Stages that hit billed external APIs (so the cost gate is honest about more than tokens).
56
56
  const BILLED_EXTERNAL = new Set(["common-law", "register-unit", "case-law"]);
57
- const MAYBE_EXTERNAL = new Set(["prelim-variants"]); // may make a few Perplexity famous-mark calls
57
+ const MAYBE_EXTERNAL = new Set(["clearance-variants"]); // may make a few Perplexity famous-mark calls
58
58
 
59
59
  function encodeToken(op) { return Buffer.from(JSON.stringify(op)).toString("base64url"); }
60
60
  function decodeToken(token) { try { return JSON.parse(Buffer.from(String(token), "base64url").toString("utf8")); } catch { return null; } }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.7",
3
+ "version": "0.3.2-beta.9",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -31,7 +31,7 @@ import { join, basename } from "node:path";
31
31
  import { driverDir } from "../shared/driver-dir.mjs"; //
32
32
  import { fileURLToPath } from "node:url";
33
33
 
34
- import { enumerateRuns, resolveRun, runAccountKey, runOrganisation, runProfileFacts } from "./lib/runs.mjs";
34
+ import { enumerateRuns, resolveRun, runAccountKey, runOrganisation, runProfileFacts, productIdentityFor } from "./lib/runs.mjs";
35
35
  import { ORDERABLE_PRODUCTS } from "../driver/search-policy.mjs";
36
36
  import { PRODUCTS } from "../driver/products.mjs";
37
37
 
@@ -193,6 +193,20 @@ function runSummary(run) {
193
193
  ? { key: facts.account, name: facts.clientName }
194
194
  : { key: null, name: null, known: false, note: "this run's client could not be read from its own record" },
195
195
  project: facts.projectKey ? { key: facts.projectKey, name: facts.projectName ?? facts.projectKey } : null,
196
+ // WHAT KIND OF SEARCH IT WAS, on every row, and it is the same defect as the client above one turn
197
+ // later. A mark and a date do not name one run: on the bundled demo data alone, one mark and one day
198
+ // return nine rows across four products — a full country search, a multi-country focus search, a
199
+ // global preliminary search and two knockouts. A session bound to one report is safe whatever the
200
+ // row says, because the scope carries the runId; an account-scoped session has only these rows, and
201
+ // nothing in them told the two apart except the runId string, which is an internal slug a client has
202
+ // never seen and which this tool's own description warns is not theirs to read.
203
+ //
204
+ // RESOLVED AT READ TIME THROUGH THE REGISTRY, never a stored string, for the reason `runs.mjs` gives
205
+ // where this resolver lives: a run freezes its product ID and the name is today's name, so the row,
206
+ // the brief and the report masthead cannot disagree and a renamed product renames everywhere at
207
+ // once. null where the registry cannot name it — the row says nothing rather than guessing, because
208
+ // a hardcoded fallback is how a knockout once announced itself as a product it provably was not.
209
+ product: productIdentityFor(run),
196
210
  state: run.state, location: run.location, verdict: run.verdict, url: run.url,
197
211
  markName: run.markName, ref: run.ref, classes: run.classes,
198
212
  step: s.stepN ? `${s.stepN}/${s.stepTotal} ${s.stepLabel ?? ""}`.trim() : null,
@@ -302,6 +316,9 @@ const tools = {
302
316
  // Staff-only tool (TOOL_SCOPES), so the reason may name the file: no client door sees this reply.
303
317
  ...(storeError ? { storeUnreadable: `The company store could not be re-read (${storeError}). This is the list as `
304
318
  + "last read, so a company added or changed since may be missing until that file is fixed." } : {}),
319
+ // A company whose own file would not load is left off `clients` and named here with the reason, so a
320
+ // missing company reads as a fault to fix rather than as a company that does not exist.
321
+ ...(roster?.unreadable?.length ? { unreadable: roster.unreadable.map((u) => ({ key: u.key, reason: u.reason })) } : {}),
305
322
  };
306
323
  },
307
324
  async describe_options(args, extra) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.2-beta.7",
4
+ "version": "0.3.2-beta.9",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -107,21 +107,22 @@
107
107
  "!driver/profiles/projects/*/",
108
108
  "driver/profiles/projects/demo-brand-owner/",
109
109
  "!driver/recipes/*/",
110
- "!driver/skills/prelim-search/risk-framework-*",
111
- "driver/skills/prelim-search/risk-framework.md",
112
- "driver/skills/prelim-search/risk-framework.manifest.json",
113
- "driver/skills/prelim-search/risk-framework-demo.md",
114
- "driver/skills/prelim-search/risk-framework-demo.manifest.json",
115
- "driver/skills/prelim-search/risk-framework-triage.md",
116
- "driver/skills/prelim-search/risk-framework-triage.manifest.json",
117
- "!driver/skills/prelim-search/worked-examples-*",
118
- "driver/skills/prelim-search/worked-examples.md",
119
- "driver/skills/prelim-search/worked-examples-demo.md",
110
+ "!driver/skills/clearance-search/risk-framework-*",
111
+ "driver/skills/clearance-search/risk-framework.md",
112
+ "driver/skills/clearance-search/risk-framework.manifest.json",
113
+ "driver/skills/clearance-search/risk-framework-demo.md",
114
+ "driver/skills/clearance-search/risk-framework-demo.manifest.json",
115
+ "driver/skills/clearance-search/risk-framework-triage.md",
116
+ "driver/skills/clearance-search/risk-framework-triage.manifest.json",
117
+ "!driver/skills/clearance-search/worked-examples-*",
118
+ "driver/skills/clearance-search/worked-examples.md",
119
+ "driver/skills/clearance-search/worked-examples-demo.md",
120
120
  "!scripts/home-render-check.mjs",
121
121
  "!scripts/ai-page-render-check.mjs",
122
122
  "!scripts/composer-render-check.mjs",
123
123
  "!scripts/clearances-render-check.mjs",
124
124
  "!scripts/portal-lifecycle-check.mjs",
125
+ "!scripts/portal-strings.mjs",
125
126
  "!scripts/test-account-names.mjs",
126
127
  "!scripts/no-test-account-reaches-the-package.mjs",
127
128
  "!**/test/",
@@ -254,6 +254,32 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
254
254
  border-bottom: 1px solid var(--border-hairline);
255
255
  }
256
256
 
257
+ /* THE HEADER'S SECOND ROW: which read you are in, then where you are inside it.
258
+ The Reads strip used to sit BELOW this box and the report's own section breadcrumb inside the frame,
259
+ so neither stayed on screen — the frame is sized to its content, which leaves a `position:sticky` bar
260
+ in there pinned to nothing. Both are rows of the pinned header now (owner, 2026-09-18).
261
+ It scrolls sideways rather than wrapping to a third line: a header that grows taller with the number of
262
+ sections is a header that eats the document it is heading. */
263
+ .report-nav {
264
+ display: flex; align-items: center; gap: 10px; margin-top: 9px;
265
+ overflow-x: auto; scrollbar-width: none;
266
+ }
267
+ .report-nav::-webkit-scrollbar { display: none; }
268
+ .report-nav-sep { flex: none; width: 1px; align-self: stretch; background: var(--border-hairline); }
269
+ .report-sections { display: flex; align-items: center; gap: 2px; }
270
+ .report-section {
271
+ flex: none; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
272
+ font: inherit; font-size: 12.5px; font-weight: 600; letter-spacing: .01em;
273
+ padding: 5px 10px; border: 1px solid transparent; border-radius: 999px;
274
+ background: transparent; color: var(--text-muted); cursor: pointer;
275
+ }
276
+ .report-section:hover, .report-section:focus-visible { color: var(--text-strong); background: var(--surface-sunken); }
277
+ .report-section-dot {
278
+ width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; box-sizing: border-box;
279
+ }
280
+ .report-section[aria-current='true'] { color: var(--text-strong); background: var(--surface-sunken); }
281
+ .report-section[aria-current='true'] .report-section-dot { border-color: var(--accent); border-width: 3px; }
282
+
257
283
  /* The breadcrumb above it: one step back as a quiet button, then where the reader is. Compound selectors
258
284
  throughout this block, because the button and row rules they adjust are declared later in the file. */
259
285
  .report-crumbs { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -271,6 +297,36 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
271
297
  .btn-ghost.ask-ai-btn.is-quiet { border-color: transparent; color: var(--text-muted); }
272
298
  .btn-ghost.ask-ai-btn.is-quiet:hover { border-color: var(--border-hairline); color: var(--text-strong); }
273
299
  .ask-ai-float { position: absolute; right: 0; top: calc(100% + 6px); z-index: 50; }
300
+
301
+ /* THE PANEL BELONGS TO THE BUTTON THAT OPENED IT, and a reader has to be able to see that it does. It was
302
+ anchored already — the rule above hangs it off `.ask-ai`, which is positioned — but it is 360px wide and
303
+ right-aligned, so it reaches back under the header's other chrome and reads as something floating over
304
+ the page rather than as this button's panel (owner, on test, 2026-09-18). Two triangles rather than one:
305
+ the outer carries the panel's own hairline and the inner covers it in the panel's surface, so the caret
306
+ is the panel's edge continuing rather than a shape drawn near it. Both grounds, from the same tokens. */
307
+ .ask-ai-float::before,
308
+ .ask-ai-float::after {
309
+ content: '';
310
+ position: absolute;
311
+ right: 16px;
312
+ width: 0;
313
+ height: 0;
314
+ border-left: 7px solid transparent;
315
+ border-right: 7px solid transparent;
316
+ }
317
+ .ask-ai-float::before { top: -7px; border-bottom: 7px solid var(--border-strong); }
318
+ .ask-ai-float::after { top: -6px; border-bottom: 7px solid var(--surface-float); }
319
+
320
+ /* AND THE BUTTON SAYS IT IS THE OPEN ONE. `aria-expanded` is already on it for a reader using a screen
321
+ reader; this is the same fact for the reader looking at it, drawn the way every other open-or-current
322
+ control in this file is (accent wash, accent border). One class stronger than the quiet variant, whose
323
+ transparent border would otherwise win. */
324
+ .btn-ghost.ask-ai-btn[aria-expanded='true'],
325
+ .btn-ghost.ask-ai-btn.is-quiet[aria-expanded='true'] {
326
+ background: var(--accent-wash);
327
+ border-color: var(--accent);
328
+ color: var(--text-accent);
329
+ }
274
330
  .ask-ai-panel { width: 360px; max-width: calc(100vw - 32px); padding: 12px; display: grid; gap: 10px; text-align: left; }
275
331
  .ask-ai-head { font-size: 12.5px; line-height: 1.45; color: var(--text-muted); }
276
332
  .ask-ai-when { white-space: nowrap; }
@@ -313,6 +369,11 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
313
369
  padding: 0 10px;
314
370
  }
315
371
 
372
+ /* The board's own box for Connect your AI: between the list that scrolls and the foot. */
373
+ .sidebar-pinned {
374
+ padding: 0 10px 2px;
375
+ }
376
+
316
377
  .sidebar-foot {
317
378
  padding: 10px;
318
379
  border-top: 1px solid var(--border-hairline);
@@ -462,6 +523,65 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
462
523
  overflow-x: auto;
463
524
  }
464
525
 
526
+ /* A TABLE THAT CONTINUES PAST THIS WRAPPER'S RIGHT EDGE SHOWS THAT IT DOES. The note further down makes
527
+ half the argument — a table a reader drags sideways is readable, one printed through itself is not —
528
+ and this is the other half: a reader has to be able to see that it drags. Ruled 2026-09-17, a
529
+ scrollbar, always visible, no text.
530
+
531
+ THE TWO WAYS OF ASKING FOR ONE CANNOT SIT TOGETHER, and nothing about reading this file would tell
532
+ you. Putting the standard `scrollbar-width` on the rule above, beside these pseudo-elements, makes the
533
+ engine take the standard path and IGNORE them — and on the engine these pages render in, that path
534
+ draws an overlay bar with no layout height at all. Measured on a delivered report at 390px, reading
535
+ the height the bar takes out of the panel:
536
+
537
+ both together 0px
538
+ pseudo-elements alone 9px
539
+ standard alone 0px
540
+
541
+ So the standard properties sit behind a support query that the engines carrying the pseudo-elements
542
+ never enter, and each engine gets exactly one instruction. Merging these two blocks back together is
543
+ the tidy-up that deletes the scrollbar with every check still green, which is why this is written here
544
+ and pinned by an arm rather than left to be re-derived.
545
+
546
+ `--border-strong` is the line the table already sits inside, and it has a real value on both grounds. */
547
+ .table-wrap::-webkit-scrollbar { height: 9px; -webkit-appearance: none; }
548
+ .table-wrap::-webkit-scrollbar-track { background: transparent; }
549
+ .table-wrap::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 5px; }
550
+ @supports not selector(::-webkit-scrollbar) {
551
+ .table-wrap { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
552
+ }
553
+
554
+ /* THE BAR, PINNED to the bottom of the screen while its table is in view (components/PinnedScrollbar.tsx).
555
+ Drawn, not native: a phone's own bar is an overlay that takes no room and shows only while moving. The
556
+ track and thumb take the colours the desktop bar above uses, on the page's own surface so rows do not
557
+ show through, and sticky, so the bar settles under the table at the table's foot. */
558
+ .pinned-bar {
559
+ position: sticky;
560
+ bottom: 0;
561
+ z-index: 2;
562
+ padding: 4px 0;
563
+ background: var(--surface-page);
564
+ }
565
+ .pinned-track {
566
+ position: relative;
567
+ height: 9px;
568
+ border-radius: 5px;
569
+ touch-action: none;
570
+ }
571
+ .table-wrap.has-pinned-bar::-webkit-scrollbar { height: 0; }
572
+ @supports not selector(::-webkit-scrollbar) {
573
+ .table-wrap.has-pinned-bar { scrollbar-width: none; }
574
+ }
575
+ .pinned-thumb {
576
+ position: absolute;
577
+ top: 0;
578
+ height: 100%;
579
+ min-width: 24px;
580
+ border-radius: 5px;
581
+ background: var(--border-strong);
582
+ touch-action: none;
583
+ }
584
+
465
585
  table.data {
466
586
  width: 100%;
467
587
  min-width: 760px;
@@ -724,6 +844,29 @@ table.data td.nest-2 {
724
844
  panel opened from a row near the bottom of the list was cut off at the table's edge. */
725
845
  overflow: visible;
726
846
  }
847
+
848
+ /* ── AND BELOW THE BREAKPOINT IT SCROLLS AFTER ALL, AND CONTAINS WHAT IT SCROLLS ────────────────────
849
+ *
850
+ * The rule above is right at desktop widths and wrong below 560px, where `table.data.fixed` stops
851
+ * dividing and becomes an ordinary content-sized table with its 860px floor restored. The note on that
852
+ * breakpoint says "its wrapper, which has always had `overflow-x: auto`, does the scrolling" — true of
853
+ * `.table-wrap`, and NOT true of this wrapper, which turns it off two rules later for the Ask AI panel.
854
+ * Two rules describing the same box disagreed, the later one won, and the table pushed the page sideways
855
+ * instead. Measured at 400px: table 860px, page scrolling by 396px, every column still present.
856
+ *
857
+ * `position: relative` IS LOAD-BEARING and does not look it. Scrolling alone leaves the page scrolling,
858
+ * because the sort button's visually-hidden label (", sorted descending") is absolutely positioned and
859
+ * this wrapper was not a containing block — so it resolved against the body, landed at x=568 with the
860
+ * header cell it belongs to, and extended the document on its own. One pixel wide. Both halves were
861
+ * planted at 400, 500 and 560: remove either and the page scrolls again.
862
+ *
863
+ * WHAT THIS COSTS, said plainly: a scroll container clips both axes, so below 560px an Ask AI panel
864
+ * opened from a row near the bottom is clipped again — the thing the rule above exists to prevent. That
865
+ * is the trade the phone width forces, and the page scrolling sideways is the worse of the two for a
866
+ * client reading on a phone. Above 560px nothing changes and the panel is unclipped as before. */
867
+ @media (max-width: 560px) {
868
+ .names-wrap { overflow-x: auto; position: relative; }
869
+ }
727
870
  /* Eight pixels either side instead of twelve, on every column but the two narrow ones at the left: the
728
871
  actions column takes a quarter of the table, and the text columns keep their words on one line by
729
872
  giving up padding rather than width. The date and the actions give up a little more. */
@@ -750,6 +893,37 @@ table.data td.nest-2 {
750
893
  .status-count {
751
894
  white-space: nowrap;
752
895
  }
896
+ /* One line wherever a risk word is drawn — in the stylesheet rather than inline, so the table below can
897
+ let it wrap. */
898
+ .risk-dot {
899
+ white-space: nowrap;
900
+ }
901
+ /* A risk word wraps the same way, and for the same reason: "No rated conflicts" in a column of fixed
902
+ width ran on under the date beside it instead of taking a second line. */
903
+ .names-wrap table.data.fixed td .risk-dot {
904
+ white-space: normal;
905
+ align-items: flex-start;
906
+ }
907
+ .names-wrap table.data.fixed td .risk-dot .dot {
908
+ flex: none;
909
+ margin-top: 0.42em;
910
+ }
911
+ /* AND NEVER INSIDE A WORD. At 700px the column holds 84px and "Manageable" is 85: beside its dot it broke
912
+ into "Managea" over "ble". The cell gives up padding the way the date's does, the dot and the word may
913
+ part so a word too long to sit beside its dot takes the next line whole, and the word itself breaks only
914
+ when it cannot fit even on a line of its own — `break-word`, which unlike `anywhere` does not shrink the
915
+ word's minimum width, so the parting happens first. */
916
+ .names-wrap table.data.fixed td:has(.risk-dot) {
917
+ padding-left: 6px;
918
+ padding-right: 6px;
919
+ }
920
+ .names-wrap table.data.fixed td .risk-dot {
921
+ flex-wrap: wrap;
922
+ row-gap: 2px;
923
+ }
924
+ .names-wrap table.data.fixed td .risk-dot > span:last-child {
925
+ overflow-wrap: break-word;
926
+ }
753
927
  /* A date is one word to a reader. A line may break before it, never at its hyphens: "2026-09-" over "02"
754
928
  read as a different date on the line below. */
755
929
  .names-wrap table.data.fixed td .sub.report-line {
@@ -766,9 +940,15 @@ table.data td.nest-2 {
766
940
  and red in CI on the same commit. 13.5em is the local measurement plus about a fifth, which is
767
941
  more than the spread between the font stacks the two see. */
768
942
  --row-open-w: 13.5em;
769
- --row-ask-w: 6.5em;
943
+ /* Ask AI measured 89px at this size, so 6.5em (81px) ran the button into the menu beside it. */
944
+ --row-ask-w: 7.25em;
770
945
  display: grid;
771
- grid-template-columns: var(--row-open-w) auto;
946
+ /* MINMAX, BECAUSE THE TRACK IS A CEILING HERE AND NOT A FLOOR. A bare `var(--row-open-w)` is a fixed
947
+ 13.5em track that cannot shrink, so in a narrow container the row's controls were wider than the
948
+ column they were given: measured at 700px, the "more" button sat at x=697 with the wrapper's right
949
+ edge at 682, and the cell overflowed the table and then the page by 25px. The wide form below
950
+ restores the fixed track, where it fits and the one-width rule matters. */
951
+ grid-template-columns: minmax(0, var(--row-open-w)) auto;
772
952
  grid-template-areas: 'open more' 'ask more';
773
953
  justify-content: start;
774
954
  align-items: start;
@@ -789,7 +969,15 @@ table.data td.nest-2 {
789
969
  .row-actions-ask:empty {
790
970
  display: none;
791
971
  }
792
- @container (min-width: 1000px) {
972
+ /* SIDE BY SIDE ONLY WHERE THE CELL HOLDS IT. This asked the whole table's width, and the actions column is
973
+ a fifth of it: at 1440px the table was 1060px, the query said "wide", and three fixed tracks needing
974
+ about 300px were laid into a cell of about 200 — every row's menu past the table's edge, the page
975
+ scrolling sideways. The cell itself is the container now, so the wide form appears exactly when its
976
+ tracks fit and the stacked form, which shrinks, holds every other width. */
977
+ .row-actions-box {
978
+ container: rowactions / inline-size;
979
+ }
980
+ @container rowactions (min-width: 300px) {
793
981
  .row-actions {
794
982
  grid-template-columns: var(--row-open-w) var(--row-ask-w) auto;
795
983
  grid-template-areas: 'open ask more';
@@ -812,6 +1000,22 @@ button.row-open {
812
1000
  white-space: nowrap;
813
1001
  cursor: pointer;
814
1002
  }
1003
+ /* AND ITS LABEL WRAPS, in the narrow form only. Ruled 2026-09-18: the row controls collapse at this
1004
+ width.
1005
+ THIS LINE DOES NOT RED WHEN IT IS REMOVED, and it is kept anyway — said out loud because the next
1006
+ person to plant it will find the same thing. With `nowrap` and the shrunken track, the longest label
1007
+ measures 121px inside a 120px button on this box: it does not overflow here, so the check passes, and
1008
+ it is one pixel from clipping. The comment on `--row-open-w` above records this exact failure
1009
+ happening — green on a developer machine, red on the CI runner, whose default font is wider, on the
1010
+ same commit. Wrapping removes the dependency on which font measured it rather than betting a pixel
1011
+ on it. Nothing in this check can see the difference, which is why this note is here instead. */
1012
+ @container (max-width: 999px) {
1013
+ button.row-open {
1014
+ min-width: 0;
1015
+ white-space: normal;
1016
+ }
1017
+ }
1018
+
815
1019
  button.row-open:hover {
816
1020
  border-color: var(--text-muted);
817
1021
  }