clearotron 0.3.2-beta.2 → 0.3.2-beta.3

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 (50) hide show
  1. package/.env.example +10 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +15 -10
  4. package/bin/onboard.mjs +4 -4
  5. package/build-info.json +2 -2
  6. package/docs/CLIENT-MCP.md +54 -54
  7. package/docs/DELIVERY.md +16 -15
  8. package/docs/E2E.md +8 -8
  9. package/docs/GLOSSARY.md +2 -2
  10. package/docs/INTAKE.md +11 -11
  11. package/docs/ONBOARDING.md +17 -16
  12. package/docs/PORTAL.md +18 -16
  13. package/docs/README.md +10 -10
  14. package/docs/SECURITY.md +20 -20
  15. package/docs/architecture/01-product-overview.md +17 -16
  16. package/docs/architecture/02-architecture.md +6 -6
  17. package/docs/architecture/03-run-lifecycle.md +7 -7
  18. package/docs/architecture/04-configuration-reference.md +14 -13
  19. package/docs/architecture/05-config-governance.md +30 -29
  20. package/docs/architecture/05-customer-profiles.md +35 -35
  21. package/docs/architecture/06-operations-runbook.md +20 -20
  22. package/docs/architecture/07-quality-and-audit.md +17 -17
  23. package/docs/architecture/08-development-guide.md +3 -3
  24. package/docs/architecture/09-security-and-data.md +27 -27
  25. package/docs/architecture/README.md +1 -1
  26. package/docs/branding.md +8 -3
  27. package/docs/configuration.md +18 -18
  28. package/docs/writing-standard.md +3 -3
  29. package/driver/CHANGELOG.md +27 -0
  30. package/driver/package.json +1 -1
  31. package/driver/portal-service.mjs +34 -4
  32. package/driver/suite-census.json +110 -44
  33. package/mcp-server/CHANGELOG.md +4 -0
  34. package/mcp-server/package.json +1 -1
  35. package/mcp-server/packs/client/CONNECT.md +6 -6
  36. package/package.json +1 -1
  37. package/portal-ui/dist/assets/{index-BsbasHjM.js → index-Bki5jT_N.js} +3376 -2971
  38. package/portal-ui/dist/assets/{index-DNQpLYZF.css → index-De2RFLbT.css} +972 -205
  39. package/portal-ui/dist/index.html +2 -2
  40. package/portal-ui/package.json +1 -1
  41. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  42. package/providers/oauth-mcp-bridge/package.json +1 -1
  43. package/scripts/ask-ai-render-check.mjs +287 -105
  44. package/scripts/release-note-required.mjs +16 -2
  45. package/scripts/settings-render-check.mjs +575 -0
  46. package/shared/brand.mjs +29 -0
  47. package/shared/connect-clients.mjs +99 -47
  48. package/shared/names-in-force.mjs +1 -0
  49. package/shared/stdio-connect.mjs +16 -2
  50. package/shared/writing-standard-classes.mjs +34 -3
@@ -81,6 +81,17 @@ const KEY_HINT = "The key is made for you when you press, and is not shown again
81
81
  const CHECK_HINT = `To check: \`claude mcp list\` shows \`${STDIO_SERVER_NAME} ✓ Connected\`.`;
82
82
  const BRIEF = "ask it to brief you on your clearances.";
83
83
 
84
+ // THE SIGN-IN, NAMING THE ACCOUNT TO USE — one spelling for every row whose door signs its reader in. The
85
+ // address is emphasised because it is what the reader picks in the browser's sign-in window, the way a
86
+ // control's name is what they look for in a dialog. With no address known it names the kind of account.
87
+ const signInAs = (operator) =>
88
+ `Sign in when the browser opens — use ${operator ? `**${operator}**` : "your work email"}.`;
89
+
90
+ // THE LAST STEP IS A QUESTION THAT PROVES THE CONNECTION. It reaches the connector's run-listing tool, so a
91
+ // reply listing clearances is the connection working, seen from the reader's own assistant.
92
+ const TRY_IT = "Try it: in your assistant, ask **“Show my recent Clearotron clearances.”** "
93
+ + "A reply listing them confirms the connection.";
94
+
84
95
  // ── WHICH DOOR THIS DEPLOYMENT HAS, AND WHY EVERY HOSTED ROW ASKS ───────────────────────────────────
85
96
  //
86
97
  // The hosted steps were fixed text. Claude's said: paste the address and a freshly minted key, set
@@ -97,19 +108,21 @@ const BRIEF = "ask it to brief you on your clearances.";
97
108
  // challenge the portal already probes for `doctor` and the reachability check, and handed to every row:
98
109
  //
99
110
  // "sign-in" the door answers a Bearer/OAuth challenge — address only, sign in when the browser opens
100
- // "key" the door takes an access key — the key steps, unchanged
111
+ // "key" the door takes an access key — the key steps
101
112
  // null it could not be read, and the page says so and shows both rather than guessing
102
113
  //
103
114
  // NEITHER ROUTE IS DELETED. A bare self-hosted install with no provider in front is the key door, and it
104
- // is the only route that works there.
115
+ // is the only route that works there. A hosted deployment may run a key door too, as a separate host
116
+ // (`keyAddress`), for assistants that cannot follow a browser sign-in: see `keySteps` below.
105
117
  export const DOOR_KINDS = Object.freeze(["sign-in", "key"]);
106
- const SIGNIN_HINT = "No key: this connector signs you in through your browser, and the sign-in is the "
107
- + "authentication your assistant is asking about.";
108
- // THE UNKNOWN DOOR IS THE PAGE'S SENTENCE NOW, NOT A STEP HINT. This hint said "both ways are shown"
109
- // while one set was drawn — a promise the page could not keep — and it said it in the words that screen
110
- // is not allowed to show a reader. The panel states it once, above both lists, and the lists make it
111
- // true. A hint under step one repeating it would be the same sentence twice, the second time in
112
- // vocabulary the reader did not ask for.
118
+ // NO HINT UNDER THE SIGN-IN DOOR'S FIRST STEP. There was one — "No key: this connector signs you in
119
+ // through your browser, and the sign-in is the authentication your assistant is asking about." — and the
120
+ // approved design (2026-09-16) removes it rather than rewording it: the sign-in step says the same thing
121
+ // where the reader does it, and a sentence under step one said it twice.
122
+ //
123
+ // THE UNKNOWN DOOR IS THE PAGE'S SENTENCE, NOT A STEP HINT. A hint once said "both ways are shown" while
124
+ // one set was drawn — a promise the page could not keep — in the words that screen is not allowed to show
125
+ // a reader. The panel states it once, above both lists, and the lists make it true.
113
126
 
114
127
  /**
115
128
  * Every app we can speak to, and the steps for each route. Adding one is a row.
@@ -143,28 +156,31 @@ export const CONNECT_CLIENTS = Object.freeze([
143
156
  },
144
157
  "public-http": {
145
158
  // DRIVEN, NOT RECALLED: the owner connected on 2026-09-04 by pasting the address, setting
146
- // Authentication to None, and adding an `Authorization: Bearer <key>` request header. The warning
147
- // travels with the steps and is not optional — Claude probes, infers sign-in, and shows an
148
- // authentication warning even when None is right; a reader who is not told to ignore it will
149
- // assume they have done it wrong.
159
+ // Authentication to None, and adding an `Authorization: Bearer <key>` request header.
150
160
  verifiedOn: "2026-09-04", by: "owner",
151
161
  steps: ({ door, operator }) => (door === "key" ? [
152
- // THE KEY DOOR, UNCHANGED. Driven by the owner on 2026-09-04 against a door that took a key:
153
- // the warning travels with these steps and is not optional, because Claude probes, infers a
154
- // sign-in and warns even where None is right.
155
- { text: "Copy your address and key.", copy: "address-and-key", hint: KEY_HINT },
162
+ // THE KEY DOOR, the driven steps with the address and the key as two presses, each at the step
163
+ // that uses it — so the key is minted when the reader reaches the header it goes in.
164
+ //
165
+ // NO STEP MENTIONS AN AUTHENTICATION WARNING. The last step used to add "If Claude shows an
166
+ // authentication warning, ignore it." Nothing here records that warning's text, so the step
167
+ // told a reader to ignore a message it could not quote, and on a door behind an identity
168
+ // provider that warning IS the sign-in. The approved design (2026-09-16) drops the sentence
169
+ // rather than rewording it.
170
+ { text: "Copy the address.", copy: "address" },
156
171
  { text: "In Claude, open **Settings → Connectors → Add custom connector**." },
157
- { text: "Paste the address — the first line." },
172
+ { text: "Paste the address." },
158
173
  { text: "Set **Authentication** to **None**." },
159
- { text: "Add a request header: **Authorization** = `Bearer`, then the key — the second line." },
160
- { text: "Press **Add**. If Claude shows an authentication warning, ignore it." },
174
+ { text: "Add a request header: **Authorization** = `Bearer`, then the key.", copy: "key", hint: KEY_HINT },
175
+ { text: "Press **Add**." },
161
176
  ] : [
162
- // THE SIGN-IN DOOR. No key is minted and no header is set: the warning the old steps told the
163
- // reader to ignore IS the sign-in, and following it is the whole of the connection.
164
- { text: "Copy the address.", copy: "address", hint: door === null ? undefined : SIGNIN_HINT },
177
+ // THE SIGN-IN DOOR. No key is minted and no header is set: the sign-in in step four is the whole
178
+ // of the authentication, and step five is how the reader sees it worked.
179
+ { text: "Copy the address.", copy: "address" },
165
180
  { text: "In Claude, open **Settings → Connectors → Add custom connector**." },
166
181
  { text: "Paste the address and press **Add**." },
167
- { text: `Sign in when the browser opens — use ${operator ?? "your work email"}.` },
182
+ { text: signInAs(operator) },
183
+ { text: TRY_IT },
168
184
  ]),
169
185
  },
170
186
  },
@@ -187,7 +203,7 @@ export const CONNECT_CLIENTS = Object.freeze([
187
203
  { text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
188
204
  ] : [
189
205
  // The command carries no header, because a door that signs its reader in never honours one.
190
- { text: "Copy this command.", copy: "claude-cli-http-signin", hint: door === null ? undefined : SIGNIN_HINT },
206
+ { text: "Copy this command.", copy: "claude-cli-http-signin" },
191
207
  { text: "Paste it into a terminal and press Enter." },
192
208
  { text: "Sign in when the browser opens." },
193
209
  { text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
@@ -226,7 +242,7 @@ export const CONNECT_CLIENTS = Object.freeze([
226
242
  { text: "In ChatGPT on the web, turn on **Settings → Security and login → Developer mode**.",
227
243
  hint: "Needs a Plus, Pro, Business, Enterprise or Edu plan. On a company plan, your admin may have to allow it." },
228
244
  { text: "Add a custom connector and paste the address." },
229
- { text: `Sign in when the browser opens — use ${operator ?? "your work email"}.` },
245
+ { text: signInAs(operator) },
230
246
  ]),
231
247
  },
232
248
  },
@@ -254,7 +270,7 @@ export const CONNECT_CLIENTS = Object.freeze([
254
270
  hint: "Codex reads the key from there, so it never sits in the settings file." },
255
271
  { text: `Restart Codex and ${BRIEF}` },
256
272
  ] : [
257
- { text: "Copy this.", copy: "codex-toml-http-signin", hint: door === null ? undefined : SIGNIN_HINT },
273
+ { text: "Copy this.", copy: "codex-toml-http-signin" },
258
274
  { text: "Open `~/.codex/config.toml` and paste it at the end." },
259
275
  // ITS OWN STEP, not a hint on the one before it. The sign-in IS the connection here, and a
260
276
  // reader skimming numbered steps does not read the small print under one of them.
@@ -268,7 +284,7 @@ export const CONNECT_CLIENTS = Object.freeze([
268
284
  // ANYTHING ELSE. We do not know what the app is, so the steps name what any of them takes. The sub
269
285
  // line names two it covers, because a reader scanning for their app's name should find somewhere to
270
286
  // land; Perplexity had a row of its own with steps nobody had driven, and is folded in here.
271
- id: "other", name: "Another agent", sub: "Perplexity, OpenClaw and others", lead: "disk",
287
+ id: "other", name: "Another AI app", sub: "Perplexity, OpenClaw and others", lead: "disk",
272
288
  aliases: { perplexity: "public-http" },
273
289
  routes: {
274
290
  disk: {
@@ -284,7 +300,7 @@ export const CONNECT_CLIENTS = Object.freeze([
284
300
  { text: "Paste the address and the key wherever your app adds a custom MCP server.",
285
301
  hint: "It may call them “server URL” and “bearer token”." },
286
302
  ] : [
287
- { text: "Copy the address.", copy: "address", hint: door === null ? undefined : SIGNIN_HINT },
303
+ { text: "Copy the address.", copy: "address" },
288
304
  { text: "Paste it wherever your app adds a custom MCP server, and sign in when the browser opens.",
289
305
  hint: "It may call the address the “server URL”. There is no token to give it." },
290
306
  ]),
@@ -320,17 +336,21 @@ export const offersForWire = (offers) =>
320
336
  // floor, so the page had nothing to branch on and drew one set of steps as though the door had been
321
337
  // read. Both are stated after the spread so a row cannot pass its own raw shape through.
322
338
  ...(Array.isArray(rest.altSteps) ? { altSteps: stepsForWire(rest.altSteps) } : {}),
339
+ ...(Array.isArray(rest.keySteps) ? { keySteps: stepsForWire(rest.keySteps) } : {}),
323
340
  ...(Object.hasOwn(rest, "door") ? { door: rest.door ?? null } : {}),
324
341
  }));
325
342
 
326
- /** One route's steps, in the shape the browser reads. The alternative set gets the same mapping. */
343
+ /**
344
+ * One route's steps, in the shape the browser reads. The alternative sets get the same mapping.
345
+ * A block's `label` rides only on a bare value, and it says the button is all the page draws.
346
+ */
327
347
  const stepsForWire = (steps) =>
328
348
  (Array.isArray(steps) ? steps : []).map((s) => ({
329
349
  text: s.text,
330
350
  ...(s.hint ? { hint: s.hint } : {}),
331
351
  ...(s.copy ? { copy: s.copy.kind === "secret"
332
352
  ? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
333
- : { kind: "block", text: s.copy.text } } : {}),
353
+ : { kind: "block", text: s.copy.text, ...(s.copy.label ? { label: s.copy.label } : {}) } } : {}),
334
354
  }));
335
355
 
336
356
  const ALIAS_ROUTE = new Map(CONNECT_CLIENTS.flatMap((c) =>
@@ -362,7 +382,7 @@ export const leadRouteFor = (id) => {
362
382
  * `served: false` always carries `reason` and `fix`. An absence with no reason reads as breakage.
363
383
  *
364
384
  * @param {object} client a row of CONNECT_CLIENTS
365
- * @param {{ stdioRoutes?: object, publicAddress?: string|null, operator?: string|null }} have
385
+ * @param {{ stdioRoutes?: object, publicAddress?: string|null, keyAddress?: string|null, operator?: string|null, door?: "sign-in"|"key"|null }} have
366
386
  * @param {"disk"|"public-http"} [route] defaults to the row's `lead`
367
387
  */
368
388
  /**
@@ -370,37 +390,49 @@ export const leadRouteFor = (id) => {
370
390
  *
371
391
  * IT USED TO SEND THE READER AWAY, and for a person whose product runs in WSL the assistant on Windows
372
392
  * is the normal one — so "an assistant on Windows cannot start it from there" left them with no working
373
- * row at all. The row now starts the server INSIDE the distribution through `wsl.exe`, so the sentence
374
- * says what the command does rather than where the reader may not be: paste it where the assistant
375
- * lives, on either side, and it crosses the boundary for them.
393
+ * row at all. The row starts the server INSIDE the distribution through `wsl.exe`, which is what an
394
+ * assistant on the Windows side needs.
395
+ *
396
+ * AND IT SAYS SO, because the sentence that replaced it promised more than the command can do. It read
397
+ * "paste it where your assistant lives, on Windows or in the WSL terminal, whichever it is" — an
398
+ * invitation to paste it inside WSL, where it does not work. Somebody took the invitation from Claude
399
+ * Code inside a distribution and got CONNECTION_CLOSED (measured 2026-09-16 on 0.3.2-beta.1).
376
400
  *
377
- * The Windows-side caveat stays as the second half, because a row that a host rewrites, or an assistant
378
- * that resolves `wsl.exe` differently, still fails on the same boundary — and then the terminal inside
379
- * the distribution is the answer.
401
+ * There is exactly ONE launcher per host shape today, and under WSL it is the Windows-side one, so this
402
+ * step names the side it is for rather than offering both. Building the second launcher — the plain
403
+ * `node` line for an assistant running inside the distribution — is its own piece of work; until it
404
+ * exists, saying which side this one is for is the whole of what can honestly be said.
380
405
  */
381
- export const WSL_STEP = "This install runs inside WSL, and the command below starts the server in there for you — "
382
- + "paste it where your assistant lives, on Windows or in the WSL terminal, whichever it is. "
383
- + "If your assistant rewrites the command or cannot find wsl.exe, run it from the WSL terminal instead.";
406
+ export const WSL_STEP = "This install runs inside WSL, and the command below starts the server in there for you. "
407
+ + "It is for an assistant running on the Windows side — Claude Desktop, or Claude Code in PowerShell. "
408
+ + "An assistant running inside this WSL terminal cannot use it: start the server from the WSL terminal "
409
+ + "yourself instead.";
384
410
 
385
411
  export function whatItNeeds(client, have = {}, route = client?.lead) {
386
412
  if (!client) return null;
387
413
  const author = client.routes?.[route];
388
414
  if (!author) return null;
389
- const { stdioRoutes = {}, publicAddress = null, operator = null, door = null } = have;
415
+ const { stdioRoutes = {}, publicAddress = null, keyAddress = null, operator = null, door = null } = have;
390
416
 
391
417
  // EACH COPY RESOLVES TO ITS OWN SHAPE, never to another's. Handing a Codex user `claude mcp add` is a
392
418
  // command their machine does not have, delivered with confidence — so a shape this deployment cannot
393
419
  // produce is an unserved route, not a fallback to one it can.
394
- const resolve = route === "disk"
420
+ //
421
+ // AND AT THE ADDRESS OF THE DOOR THE STEPS ARE FOR. The key door beside a sign-in door is its own host,
422
+ // so its steps resolve there and never at the host that refuses a key.
423
+ const resolveAt = (address) => route === "disk"
395
424
  ? (shape) => {
396
425
  const r = Object.hasOwn(stdioRoutes, shape ?? "") ? stdioRoutes[shape] : null;
397
426
  return r ? { kind: "block", text: r.text, stdio: r } : null;
398
427
  }
399
428
  : (shape) => {
400
- const r = remoteConnectFor(shape, { address: publicAddress });
429
+ const r = remoteConnectFor(shape, { address });
401
430
  if (!r) return null;
402
- return r.secret ? { kind: "secret", label: r.label, template: r.text, slot: KEY_SLOT } : { kind: "block", text: r.text };
431
+ return r.secret
432
+ ? { kind: "secret", label: r.label, template: r.text, slot: KEY_SLOT }
433
+ : { kind: "block", text: r.text, ...(r.label ? { label: r.label } : {}) };
403
434
  };
435
+ const resolve = resolveAt(publicAddress);
404
436
 
405
437
  // WHAT THE DOOR ANSWERS, handed to every row rather than decided per row: one reading, so two apps
406
438
  // on one page cannot describe one deployment two ways — which is the defect this carries.
@@ -431,7 +463,27 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
431
463
  // set follows two lines up. Half an alternative is a reader following steps that stop.
432
464
  const altResolved = !altAsked || altSteps.every((s, i) => !altAsked[i].copy || s.copy);
433
465
  const alt = altResolved && altSteps?.length ? { door: null, altSteps } : { ...(route === "public-http" ? { door } : {}) };
434
- const evidence = { ...(author.verifiedOn ? { verifiedOn: author.verifiedOn } : {}), ...(author.by ? { by: author.by } : {}) };
466
+
467
+ // ── A SIGN-IN DOOR WITH A KEY DOOR BESIDE IT: THE KEY STEPS RIDE ALONG, FOR A FOLD ───────────────
468
+ //
469
+ // Some assistants cannot follow a browser sign-in — a fixed "API key" box, a headless agent — and a
470
+ // hosted deployment can run a second door for them, on its own host, that takes a key. The page offers
471
+ // those steps closed, under a heading, beneath the sign-in steps.
472
+ //
473
+ // ONLY WHERE THAT DOOR EXISTS. The sign-in door never honours a key, so key steps pointed at it are
474
+ // instructions that cannot work and a credential minted for nothing — the defect reading `door` ended.
475
+ // No key door, no fold. Composed the way the unknown door's alternative is — the same author asked for
476
+ // the other answer — and resolved at the key door's address. `door` stays "sign-in", because it was
477
+ // read, and `altSteps` keeps meaning "we could not tell".
478
+ const keyAsked = route === "public-http" && door === "sign-in" && keyAddress
479
+ ? author.steps({ operator, door: "key" })
480
+ : null;
481
+ const keySteps = keyAsked
482
+ ? keyAsked.map((s) => (s.copy ? { ...s, copy: resolveAt(keyAddress)(s.copy) } : { ...s }))
483
+ : null;
484
+ const keyResolved = !keyAsked || keySteps.every((s, i) => !keyAsked[i].copy || s.copy);
485
+ const folded = keyResolved && keySteps?.length ? { keySteps } : {};
486
+ const evidence ={ ...(author.verifiedOn ? { verifiedOn: author.verifiedOn } : {}), ...(author.by ? { by: author.by } : {}) };
435
487
 
436
488
  if (route === "disk") {
437
489
  if (!resolved) {
@@ -467,7 +519,7 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
467
519
  fix: "whoever installed it can put it online — it takes about a minute and needs no account",
468
520
  operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" };
469
521
  }
470
- return { client, served: true, route, steps, ...alt, launch: client.launch ?? null, enables: null, ...evidence,
522
+ return { client, served: true, route, steps, ...alt, ...folded, launch: client.launch ?? null, enables: null, ...evidence,
471
523
  command: null, stdio: null, address: publicAddress, key: "issued",
472
524
  note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." };
473
525
  }
@@ -15,6 +15,7 @@
15
15
  export const NAMES_IN_FORCE = Object.freeze([
16
16
  "CLEAROTRON_ACCESS_DOMAIN",
17
17
  "CLEAROTRON_ACCESS_FILE",
18
+ "CLEAROTRON_ADMINISTRATOR_CONTACT",
18
19
  "CLEAROTRON_ADMISSION_BUDGET_MS",
19
20
  "CLEAROTRON_AGENTS",
20
21
  "CLEAROTRON_AGENT_MCP_URL",
@@ -241,10 +241,23 @@ export const REMOTE_SHAPES = Object.freeze({
241
241
  render: ({ address }) => `${address}\n${KEY_SLOT}`,
242
242
  },
243
243
  // A host that signs its reader in through the browser needs the address and nothing else.
244
+ //
245
+ // BARE: ONE VALUE, HANDED OVER BY ITS BUTTON ALONE. The reader pastes it and never reads it back, so a
246
+ // surface draws "Copy address" and not the text — a block above the button is a second thing to look
247
+ // at for the same press. Only a shape whose whole text is one value may say so; a command or a
248
+ // settings block is read before it is pasted, and stays a block.
244
249
  "address": {
245
- label: null,
250
+ label: "Copy address",
251
+ bare: true,
246
252
  render: ({ address }) => address,
247
253
  },
254
+ // THE KEY ALONE, for steps that take the address and the key as two presses: the address where the
255
+ // reader pastes it, the key at the header it goes in. One press handing over both is a credential
256
+ // minted before the step that uses it, and a reader then pasting two lines into a box that takes one.
257
+ "key": {
258
+ label: "Copy key",
259
+ render: () => KEY_SLOT,
260
+ },
248
261
  // THE SAME TWO HOSTS WITH NO KEY IN THEM, for a door that signs its reader in. A door behind an
249
262
  // identity provider never honours a key, so a command carrying a header is a command that cannot
250
263
  // work — and the key it names was minted for nothing.
@@ -290,7 +303,8 @@ export function remoteConnectFor(shape, { address = null } = {}) {
290
303
  if (!spec || !address) return null;
291
304
  const text = spec.render({ address });
292
305
  const secret = text.includes(KEY_SLOT);
293
- return { shape, secret, label: secret ? spec.label : null, text, name: STDIO_SERVER_NAME };
306
+ // A LABEL ON A BLOCK MEANS THE BUTTON IS ALL A SURFACE DRAWS, so only a bare shape carries one out.
307
+ return { shape, secret, label: secret || spec.bare ? spec.label : null, text, name: STDIO_SERVER_NAME };
294
308
  }
295
309
 
296
310
  /**
@@ -429,11 +429,42 @@ export function eyebrowOverHeading(src) {
429
429
  * heading it wrote itself — a heading inside a page, for an empty state or a run's own mark, is sized for
430
430
  * where it sits and is not the page naming itself.
431
431
  */
432
+ const H1_BLOCK = /<h1\b[^>]*>([\s\S]*?)<\/h1>/g
433
+
434
+ /**
435
+ * Is this heading the page's SUBJECT rather than its name?
436
+ *
437
+ * The carve-out this file's prose has always claimed and its code did not make: "a heading inside a page,
438
+ * for an empty state or a run's own mark, is sized for where it sits and is not the page naming itself."
439
+ * A page's NAME is a literal a reader could find in the rail — About, Profile, Give access. A page's
440
+ * SUBJECT is whatever this run is about, and it arrives interpolated.
441
+ *
442
+ * So strip the JSX expressions and the tags, and ask whether any words are left. Nothing left means the
443
+ * heading was entirely its subject.
444
+ */
445
+ const isSubjectHeading = (inner) => {
446
+ const bare = String(inner)
447
+ .replace(/\{(?:[^{}]|\{[^{}]*\})*\}/g, '') // JSX expressions, one level of nesting
448
+ .replace(/<[^>]*>/g, '') // nested tags
449
+ return !/[A-Za-z]{2}/.test(bare)
450
+ }
451
+
432
452
  export function writesItsOwnHeader(src) {
433
- const header = String(src).indexOf('<PageHeader')
434
- const h1 = String(src).indexOf('<h1')
453
+ const text = String(src)
454
+ const header = text.indexOf('<PageHeader')
455
+ const h1 = text.indexOf('<h1')
435
456
  if (header < 0 && h1 < 0) return null
436
- if (header < 0) return 'writes a heading and never the shared component'
457
+ if (header < 0) {
458
+ // A screen whose only headings are its subject names nothing, so there is nothing for the shared
459
+ // component to carry. UNKNOWN FIRES: if no `<h1>…</h1>` block can be read at all while the tag is
460
+ // present — an unclosed tag, a generated one — the class stands. A narrowing that cannot see its
461
+ // subject must refuse, or it goes quiet on exactly the file it cannot parse.
462
+ const blocks = [...text.matchAll(H1_BLOCK)]
463
+ if (!blocks.length) return 'writes a heading and never the shared component'
464
+ return blocks.every((m) => isSubjectHeading(m[1]))
465
+ ? null
466
+ : 'writes a heading and never the shared component'
467
+ }
437
468
  if (h1 >= 0 && h1 < header) return 'opens with a heading of its own, before the shared component'
438
469
  return null
439
470
  }