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.
- package/.env.example +10 -0
- package/INSTALL.md +2 -0
- package/README.md +15 -10
- package/bin/onboard.mjs +4 -4
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +54 -54
- package/docs/DELIVERY.md +16 -15
- package/docs/E2E.md +8 -8
- package/docs/GLOSSARY.md +2 -2
- package/docs/INTAKE.md +11 -11
- package/docs/ONBOARDING.md +17 -16
- package/docs/PORTAL.md +18 -16
- package/docs/README.md +10 -10
- package/docs/SECURITY.md +20 -20
- package/docs/architecture/01-product-overview.md +17 -16
- package/docs/architecture/02-architecture.md +6 -6
- package/docs/architecture/03-run-lifecycle.md +7 -7
- package/docs/architecture/04-configuration-reference.md +14 -13
- package/docs/architecture/05-config-governance.md +30 -29
- package/docs/architecture/05-customer-profiles.md +35 -35
- package/docs/architecture/06-operations-runbook.md +20 -20
- package/docs/architecture/07-quality-and-audit.md +17 -17
- package/docs/architecture/08-development-guide.md +3 -3
- package/docs/architecture/09-security-and-data.md +27 -27
- package/docs/architecture/README.md +1 -1
- package/docs/branding.md +8 -3
- package/docs/configuration.md +18 -18
- package/docs/writing-standard.md +3 -3
- package/driver/CHANGELOG.md +27 -0
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +34 -4
- package/driver/suite-census.json +110 -44
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +6 -6
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-BsbasHjM.js → index-Bki5jT_N.js} +3376 -2971
- package/portal-ui/dist/assets/{index-DNQpLYZF.css → index-De2RFLbT.css} +972 -205
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +287 -105
- package/scripts/release-note-required.mjs +16 -2
- package/scripts/settings-render-check.mjs +575 -0
- package/shared/brand.mjs +29 -0
- package/shared/connect-clients.mjs +99 -47
- package/shared/names-in-force.mjs +1 -0
- package/shared/stdio-connect.mjs +16 -2
- 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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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.
|
|
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,
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
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
|
|
172
|
+
{ text: "Paste the address." },
|
|
158
173
|
{ text: "Set **Authentication** to **None**." },
|
|
159
|
-
{ text: "Add a request header: **Authorization** = `Bearer`, then the key
|
|
160
|
-
{ text: "Press **Add**.
|
|
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
|
|
163
|
-
//
|
|
164
|
-
{ text: "Copy the address.", copy: "address"
|
|
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:
|
|
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"
|
|
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:
|
|
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"
|
|
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
|
|
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"
|
|
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
|
-
/**
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* the distribution is
|
|
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
|
-
+ "
|
|
383
|
-
+ "
|
|
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
|
-
|
|
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
|
|
429
|
+
const r = remoteConnectFor(shape, { address });
|
|
401
430
|
if (!r) return null;
|
|
402
|
-
return r.secret
|
|
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
|
-
|
|
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
|
}
|
package/shared/stdio-connect.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
434
|
-
const
|
|
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)
|
|
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
|
}
|