relmio 0.2.11 → 0.2.13

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/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ checks the registry separately after publication.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.2.13] - 2026-08-04
11
+
12
+ ### Changed
13
+
14
+ - Compact the local setup wizard so its active step stays near the top, move
15
+ safety and status notices into dismissible toasts, and collapse the optional
16
+ AI Agent and HTTP Request recipes until users choose to open them.
17
+ - Complete the HTTP Request recipe with authorization, content type, and a
18
+ copyable sample Responses API JSON payload.
19
+
20
+ ### Fixed
21
+
22
+ - Copy credential values reliably in Opera GX on Windows by preserving the
23
+ synchronous user gesture for the selection-based clipboard path before
24
+ falling back to the modern Clipboard API.
25
+
26
+ ## [0.2.12] - 2026-08-04
27
+
28
+ ### Fixed
29
+
30
+ - Install the pinned `openai-oauth@2.0.0` helper with its exact compatible
31
+ `zod@4.1.8` peer even when inherited npm settings omit peer dependencies,
32
+ preventing the Windows sign-in helper from exiting before it prints a URL.
33
+ - Accept the same strictly validated authorization line from either helper
34
+ output stream, including Windows terminal framing and final drained output.
35
+ - Run npm package builds through the current Node.js runtime on Windows instead
36
+ of executing `npm.cmd` directly with `shell: false`.
37
+
10
38
  ## [0.2.11] - 2026-08-04
11
39
 
12
40
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relmio",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Turn a supported ChatGPT/Codex OAuth sign-in into a private OpenAI-compatible endpoint, starting with self-hosted n8n.",
5
5
  "keywords": [
6
6
  "relmio",
@@ -10,15 +10,48 @@ import {
10
10
  rm,
11
11
  } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
- import { basename, join, resolve } from "node:path";
13
+ import { basename, dirname, join, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { promisify } from "node:util";
16
16
 
17
17
  const execFileAsync = promisify(execFile);
18
- const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
19
18
  const sourceRoot = fileURLToPath(new URL("..", import.meta.url));
20
19
  const defaultOutputDirectory = join(sourceRoot, "dist", "npm");
21
20
 
21
+ export function resolveNpmInvocation({
22
+ env = process.env,
23
+ execPath = process.execPath,
24
+ platform = process.platform,
25
+ } = {}) {
26
+ if (platform !== "win32") {
27
+ return { command: "npm", prefixArgs: [] };
28
+ }
29
+
30
+ const npmExecPathKey = Object.keys(env ?? {}).find(
31
+ (key) => key.toLowerCase() === "npm_execpath",
32
+ );
33
+ const npmExecPath =
34
+ npmExecPathKey === undefined ? undefined : env[npmExecPathKey];
35
+ let npmCliPath;
36
+
37
+ if (typeof npmExecPath === "string") {
38
+ if (/(?:^|[\\/])npm-cli\.js$/iu.test(npmExecPath)) {
39
+ npmCliPath = resolve(npmExecPath);
40
+ } else if (/(?:^|[\\/])npx-cli\.js$/iu.test(npmExecPath)) {
41
+ npmCliPath = resolve(dirname(npmExecPath), "npm-cli.js");
42
+ }
43
+ }
44
+
45
+ return {
46
+ command: execPath,
47
+ // Windows cannot execute npm.cmd directly with shell:false.
48
+ prefixArgs: [
49
+ npmCliPath ??
50
+ resolve(dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"),
51
+ ],
52
+ };
53
+ }
54
+
22
55
  async function copyPublishEntry({ entry, stagingDirectory }) {
23
56
  const source = join(sourceRoot, entry);
24
57
  const target = join(stagingDirectory, entry);
@@ -61,9 +94,11 @@ export async function buildNpmPackage({
61
94
  const expectedPath = join(outputDirectory, expectedFilename);
62
95
  await rm(expectedPath, { force: true });
63
96
 
97
+ const npmInvocation = resolveNpmInvocation();
64
98
  const { stdout } = await execFileAsync(
65
- npmCommand,
99
+ npmInvocation.command,
66
100
  [
101
+ ...npmInvocation.prefixArgs,
67
102
  "pack",
68
103
  stagingDirectory,
69
104
  "--json",
@@ -214,7 +214,12 @@ export async function startOAuthLogin({
214
214
  const args = [
215
215
  "--yes",
216
216
  "--ignore-scripts",
217
- "openai-oauth@2.0.0",
217
+ "--legacy-peer-deps=false",
218
+ "--include=peer",
219
+ "--package=openai-oauth@2.0.0",
220
+ "--package=zod@4.1.8",
221
+ "--",
222
+ "openai-oauth",
218
223
  "login",
219
224
  "--no-open",
220
225
  "--login-timeout-ms",
@@ -279,11 +284,8 @@ export async function startOAuthLogin({
279
284
  return;
280
285
  }
281
286
  loginOutput[stream] += output;
282
- if (stream !== "stdout") {
283
- return;
284
- }
285
287
  try {
286
- const authorizationUrl = extractAuthorizationUrl(loginOutput.stdout);
288
+ const authorizationUrl = extractAuthorizationUrl(loginOutput[stream]);
287
289
  if (authorizationUrl) {
288
290
  settleAuthorizationUrl(null, authorizationUrl);
289
291
  }
@@ -325,9 +327,13 @@ export async function startOAuthLogin({
325
327
  child.once("close", (code) => {
326
328
  if (!authorizationUrlSettled) {
327
329
  try {
328
- const authorizationUrl = extractAuthorizationUrl(loginOutput.stdout, {
329
- includeFinalLine: true,
330
- });
330
+ const authorizationUrl =
331
+ extractAuthorizationUrl(loginOutput.stdout, {
332
+ includeFinalLine: true,
333
+ }) ??
334
+ extractAuthorizationUrl(loginOutput.stderr, {
335
+ includeFinalLine: true,
336
+ });
331
337
  if (authorizationUrl) {
332
338
  settleAuthorizationUrl(null, authorizationUrl);
333
339
  } else {
package/src/ui/app.js CHANGED
@@ -13,8 +13,16 @@ const state = {
13
13
  };
14
14
 
15
15
  const element = (id) => document.getElementById(id);
16
- const message = element("global-message");
16
+ const messageToast = element("global-message");
17
+ const message = element("global-message-text");
17
18
  const errorBox = element("global-error");
19
+ const errorMessage = element("global-error-text");
20
+ const toastTimers = new WeakMap();
21
+
22
+ function dismissToast(toast) {
23
+ window.clearTimeout(toastTimers.get(toast));
24
+ toast.hidden = true;
25
+ }
18
26
 
19
27
  function resetFingerprint() {
20
28
  state.fingerprint = null;
@@ -26,18 +34,24 @@ function resetFingerprint() {
26
34
  }
27
35
 
28
36
  function setMessage(text) {
37
+ messageToast.hidden = false;
29
38
  message.textContent = text;
39
+ window.clearTimeout(toastTimers.get(messageToast));
40
+ toastTimers.set(
41
+ messageToast,
42
+ window.setTimeout(() => dismissToast(messageToast), 6_000),
43
+ );
30
44
  }
31
45
 
32
46
  function showError(error) {
33
- errorBox.textContent = error.message ?? "Something went wrong.";
47
+ errorMessage.textContent = error.message ?? "Something went wrong.";
34
48
  errorBox.hidden = false;
35
49
  errorBox.focus();
36
50
  }
37
51
 
38
52
  function clearError() {
39
53
  errorBox.hidden = true;
40
- errorBox.textContent = "";
54
+ errorMessage.textContent = "";
41
55
  }
42
56
 
43
57
  function setBusy(button, busy, busyText) {
@@ -68,32 +82,124 @@ function flashCopied(button) {
68
82
  }
69
83
 
70
84
  async function copyText(value) {
85
+ const previouslyFocused = document.activeElement;
86
+ const textarea = document.createElement("textarea");
87
+ textarea.value = value;
88
+ textarea.readOnly = true;
89
+ textarea.setAttribute("aria-hidden", "true");
90
+ textarea.style.position = "fixed";
91
+ textarea.style.opacity = "0";
92
+
93
+ let copied = false;
71
94
  try {
72
- await navigator.clipboard.writeText(value);
73
- return;
74
- } catch {
75
- const previouslyFocused = document.activeElement;
76
- const textarea = document.createElement("textarea");
77
- textarea.value = value;
78
- textarea.readOnly = true;
79
- textarea.setAttribute("aria-hidden", "true");
80
- textarea.style.position = "fixed";
81
- textarea.style.opacity = "0";
82
95
  document.body.append(textarea);
96
+ textarea.focus();
83
97
  textarea.select();
84
- let copied = false;
98
+ textarea.setSelectionRange?.(0, textarea.value.length);
99
+ copied = document.execCommand("copy");
100
+ } catch {
101
+ copied = false;
102
+ } finally {
103
+ textarea.remove();
104
+ previouslyFocused?.focus?.();
105
+ }
106
+
107
+ if (copied) {
108
+ return;
109
+ }
110
+
111
+ if (navigator.clipboard?.writeText) {
85
112
  try {
86
- copied = document.execCommand("copy");
87
- } finally {
88
- textarea.remove();
89
- previouslyFocused?.focus?.();
90
- }
91
- if (!copied) {
92
- throw new Error("The browser refused clipboard access.");
113
+ await navigator.clipboard.writeText(value);
114
+ return;
115
+ } catch {
116
+ // The generic error below avoids exposing copied configuration values.
93
117
  }
94
118
  }
119
+
120
+ throw new Error("The browser refused clipboard access.");
121
+ }
122
+
123
+ function renderHttpRequestBody(model) {
124
+ element("result-http-body").textContent = JSON.stringify(
125
+ {
126
+ model,
127
+ input: "Reply with exactly: bridge works",
128
+ },
129
+ null,
130
+ 2,
131
+ );
132
+ }
133
+
134
+ function copyCredentialSettings() {
135
+ return [
136
+ `Base URL: ${element("result-url").textContent}`,
137
+ `API Key: ${element("result-key").textContent}`,
138
+ "Organization ID: leave empty",
139
+ "Add Custom Header: off",
140
+ ].join("\n");
141
+ }
142
+
143
+ function copyHttpRecipe() {
144
+ return [
145
+ "Method: POST",
146
+ `URL: ${element("result-http-url").textContent}`,
147
+ "Authorization: Bearer local-only",
148
+ "Content-Type: application/json",
149
+ "Authentication: None",
150
+ "JSON body:",
151
+ element("result-http-body").textContent,
152
+ ].join("\n");
153
+ }
154
+
155
+ function copyValueFor(button) {
156
+ if (button.dataset.copyTarget) {
157
+ return element(button.dataset.copyTarget).textContent;
158
+ }
159
+ if (button.dataset.copyGroup === "credential") {
160
+ return copyCredentialSettings();
161
+ }
162
+ if (button.dataset.copyGroup === "http") {
163
+ return copyHttpRecipe();
164
+ }
165
+ return "";
166
+ }
167
+
168
+ function createCopyClickHandler({
169
+ copyValueFor,
170
+ clearError,
171
+ copyText,
172
+ flashCopied,
173
+ setMessage,
174
+ showError,
175
+ }) {
176
+ return async function handleCopyClick(event) {
177
+ const button = event.currentTarget;
178
+ const label = button.dataset.copyLabel;
179
+ const value = copyValueFor(button);
180
+ clearError();
181
+ try {
182
+ if (!value) {
183
+ throw new Error("No displayed value is available to copy.");
184
+ }
185
+ await copyText(value);
186
+ flashCopied(button);
187
+ setMessage(`${label} copied.`);
188
+ } catch {
189
+ showError(new Error(`Copy failed. Select the ${label} manually.`));
190
+ }
191
+ };
95
192
  }
96
193
 
194
+ const handleCopyClick = createCopyClickHandler({
195
+ copyValueFor,
196
+ clearError,
197
+ copyText,
198
+ flashCopied,
199
+ setMessage,
200
+ showError,
201
+ });
202
+
97
203
  const delay = (milliseconds) =>
98
204
  new Promise((resolve) => window.setTimeout(resolve, milliseconds));
99
205
 
@@ -126,6 +232,11 @@ async function waitForOAuthCompletion() {
126
232
 
127
233
  function showStep(step) {
128
234
  state.step = step;
235
+ document.body.dataset.currentStep = String(step);
236
+ if (step === 5) {
237
+ dismissToast(element("global-safety"));
238
+ dismissToast(element("global-backup"));
239
+ }
129
240
  for (const panel of document.querySelectorAll("[data-step]")) {
130
241
  const active = Number(panel.dataset.step) === step;
131
242
  panel.hidden = !active;
@@ -483,10 +594,12 @@ element("install-button").addEventListener("click", async (event) => {
483
594
  });
484
595
  element("result-url").textContent = result.baseUrl;
485
596
  element("result-key").textContent = result.apiKeyPlaceholder;
486
- element("result-model").textContent = result.models[0] ?? "";
597
+ const firstModel = result.models[0] ?? "Not detected";
598
+ element("result-model").textContent = firstModel;
487
599
  element("result-models").textContent = result.models.join(", ");
488
600
  element("result-http-url").textContent =
489
601
  `${result.baseUrl.replace(/\/$/u, "")}/responses`;
602
+ renderHttpRequestBody(firstModel);
490
603
  showStep(5);
491
604
  setMessage(
492
605
  result.deploymentMode === "updated"
@@ -500,36 +613,15 @@ element("install-button").addEventListener("click", async (event) => {
500
613
  }
501
614
  });
502
615
 
503
- element("copy-settings").addEventListener("click", async (event) => {
504
- const settings = [
505
- `Base URL: ${element("result-url").textContent}`,
506
- `API Key: ${element("result-key").textContent}`,
507
- "Organization ID: leave empty",
508
- "Add Custom Header: off",
509
- ].join("\n");
510
- clearError();
511
- try {
512
- await copyText(settings);
513
- flashCopied(event.currentTarget);
514
- setMessage("OpenAI credential settings copied.");
515
- } catch {
516
- showError(new Error("Copy failed. Select the values manually."));
517
- }
518
- });
616
+ for (const button of document.querySelectorAll(
617
+ "[data-copy-target], [data-copy-group]",
618
+ )) {
619
+ button.addEventListener("click", handleCopyClick);
620
+ }
519
621
 
520
- for (const button of document.querySelectorAll("[data-copy-target]")) {
521
- button.addEventListener("click", async (event) => {
522
- const target = element(event.currentTarget.dataset.copyTarget);
523
- const label = event.currentTarget.dataset.copyLabel;
524
- const value = target.textContent;
525
- clearError();
526
- try {
527
- await copyText(value);
528
- flashCopied(event.currentTarget);
529
- setMessage(`${label} copied.`);
530
- } catch {
531
- showError(new Error(`Copy failed. Select the ${label} manually.`));
532
- }
622
+ for (const button of document.querySelectorAll("[data-dismiss-toast]")) {
623
+ button.addEventListener("click", (event) => {
624
+ dismissToast(element(event.currentTarget.dataset.dismissToast));
533
625
  });
534
626
  }
535
627
 
@@ -545,4 +637,5 @@ for (const button of document.querySelectorAll(".back-button")) {
545
637
  });
546
638
  }
547
639
 
640
+ renderHttpRequestBody(element("result-model").textContent);
548
641
  refreshAuthStatus().catch(showError);
package/src/ui/index.html CHANGED
@@ -16,7 +16,7 @@
16
16
  <script src="/theme.js" type="module"></script>
17
17
  <script src="/app.js" type="module"></script>
18
18
  </head>
19
- <body>
19
+ <body data-current-step="1">
20
20
  <a class="skip-link" href="#main-content">Skip to setup</a>
21
21
 
22
22
  <header class="site-header">
@@ -55,32 +55,93 @@
55
55
  </div>
56
56
  </header>
57
57
 
58
- <main id="main-content" class="shell">
59
- <section class="intro" aria-labelledby="page-title">
60
- <p class="eyebrow">Guided VPS setup</p>
61
- <h1 id="page-title">Connect n8n without changing n8n</h1>
62
- <p class="lede">
63
- This wizard installs one separate Docker sidecar. Your existing n8n
64
- image, Compose file, container, and workflows stay untouched.
65
- </p>
66
- </section>
67
-
68
- <aside class="safety-note" aria-label="Safety guarantee">
58
+ <div class="toast-stack" aria-label="Wizard notifications">
59
+ <aside
60
+ id="global-safety"
61
+ class="toast safety-note"
62
+ aria-label="Safety guarantee"
63
+ >
69
64
  <strong>Sidecar-only guarantee</strong>
70
65
  <span>
71
66
  No n8n restart. No n8n rebuild. No public port. You approve the exact
72
67
  plan before anything is written.
73
68
  </span>
69
+ <button
70
+ class="toast-close"
71
+ type="button"
72
+ data-dismiss-toast="global-safety"
73
+ aria-label="Dismiss safety guarantee"
74
+ >
75
+ <span aria-hidden="true">×</span>
76
+ </button>
74
77
  </aside>
75
78
 
76
- <aside class="safety-note backup-note" aria-label="Backup reminder">
79
+ <aside
80
+ id="global-backup"
81
+ class="toast safety-note backup-note"
82
+ aria-label="Backup reminder"
83
+ >
77
84
  <strong>Back up first</strong>
78
85
  <span>
79
86
  Export your n8n workflows before connecting. This wizard uses
80
87
  sidecar-only commands, but it still writes to your VPS.
81
88
  </span>
89
+ <button
90
+ class="toast-close"
91
+ type="button"
92
+ data-dismiss-toast="global-backup"
93
+ aria-label="Dismiss backup reminder"
94
+ >
95
+ <span aria-hidden="true">×</span>
96
+ </button>
82
97
  </aside>
83
98
 
99
+ <div
100
+ id="global-message"
101
+ class="toast message"
102
+ role="status"
103
+ aria-live="polite"
104
+ >
105
+ <span id="global-message-text">Checking your local sign-in…</span>
106
+ <button
107
+ class="toast-close"
108
+ type="button"
109
+ data-dismiss-toast="global-message"
110
+ aria-label="Dismiss status message"
111
+ >
112
+ <span aria-hidden="true">×</span>
113
+ </button>
114
+ </div>
115
+
116
+ <div
117
+ id="global-error"
118
+ class="toast error"
119
+ role="alert"
120
+ tabindex="-1"
121
+ hidden
122
+ >
123
+ <span id="global-error-text"></span>
124
+ <button
125
+ class="toast-close"
126
+ type="button"
127
+ data-dismiss-toast="global-error"
128
+ aria-label="Dismiss error"
129
+ >
130
+ <span aria-hidden="true">×</span>
131
+ </button>
132
+ </div>
133
+ </div>
134
+
135
+ <main id="main-content" class="shell">
136
+ <section class="intro" aria-labelledby="page-title">
137
+ <p class="eyebrow">Guided VPS setup</p>
138
+ <h1 id="page-title">Connect n8n without changing n8n</h1>
139
+ <p class="lede">
140
+ This wizard installs one separate Docker sidecar. Your existing n8n
141
+ image, Compose file, container, and workflows stay untouched.
142
+ </p>
143
+ </section>
144
+
84
145
  <nav class="steps" aria-label="Setup progress">
85
146
  <ol>
86
147
  <li data-step-marker="1" aria-current="step">
@@ -93,11 +154,6 @@
93
154
  </ol>
94
155
  </nav>
95
156
 
96
- <div id="global-message" class="message" role="status" aria-live="polite">
97
- Checking your local sign-in…
98
- </div>
99
- <div id="global-error" class="error" role="alert" tabindex="-1" hidden></div>
100
-
101
157
  <section class="panel" data-step="1" aria-labelledby="signin-title">
102
158
  <div class="panel-heading">
103
159
  <span class="step-kicker">Step 1 of 5</span>
@@ -377,70 +433,112 @@
377
433
  </div>
378
434
  </dl>
379
435
 
380
- <button id="copy-settings" class="button secondary" type="button">
436
+ <button
437
+ id="copy-settings"
438
+ class="button secondary"
439
+ type="button"
440
+ data-copy-group="credential"
441
+ data-copy-label="OpenAI credential settings"
442
+ >
381
443
  Copy credential settings
382
444
  </button>
383
445
 
384
- <div class="model-box">
385
- <h3>2. OpenAI Chat Model for AI Agent or Basic LLM Chain</h3>
386
- <dl class="result-list">
387
- <div>
388
- <dt>Model ID</dt>
389
- <dd class="result-value">
390
- <code id="result-model">Not detected</code>
391
- <button
392
- class="button secondary copy-value"
393
- type="button"
394
- data-copy-target="result-model"
395
- data-copy-label="model ID"
396
- aria-label="Copy model ID"
397
- >
398
- Copy
399
- </button>
400
- </dd>
401
- </div>
402
- <div>
403
- <dt>Responses API</dt>
404
- <dd><strong>On</strong> for Chat Model node version 1.3</dd>
405
- </div>
406
- </dl>
407
- <p>All models detected: <span id="result-models">Not detected</span></p>
408
- <p>
409
- If the Responses API switch is absent, keep the node's default
410
- Chat Completions behavior; the bridge supports that route too.
411
- </p>
412
- </div>
413
-
414
- <div class="model-box">
415
- <h3>3. HTTP Request node</h3>
416
- <dl class="result-list">
417
- <div>
418
- <dt>Method</dt>
419
- <dd><code>POST</code></dd>
420
- </div>
421
- <div>
422
- <dt>URL</dt>
423
- <dd class="result-value">
424
- <code id="result-http-url">
425
- http://n8n-openai-oauth:10531/v1/responses
426
- </code>
427
- <button
428
- class="button secondary copy-value"
429
- type="button"
430
- data-copy-target="result-http-url"
431
- data-copy-label="HTTP endpoint"
432
- aria-label="Copy HTTP endpoint"
433
- >
434
- Copy
435
- </button>
436
- </dd>
437
- </div>
438
- <div>
439
- <dt>Authentication</dt>
440
- <dd>None</dd>
441
- </div>
442
- </dl>
443
- </div>
446
+ <details class="recipe-disclosure">
447
+ <summary>
448
+ <span>2. OpenAI Chat Model for AI Agent or Basic LLM Chain</span>
449
+ </summary>
450
+ <div class="model-box recipe-content">
451
+ <dl class="result-list">
452
+ <div>
453
+ <dt>Model ID</dt>
454
+ <dd class="result-value">
455
+ <code id="result-model">Not detected</code>
456
+ <button
457
+ class="button secondary copy-value"
458
+ type="button"
459
+ data-copy-target="result-model"
460
+ data-copy-label="model ID"
461
+ aria-label="Copy model ID"
462
+ >
463
+ Copy
464
+ </button>
465
+ </dd>
466
+ </div>
467
+ <div>
468
+ <dt>Responses API</dt>
469
+ <dd><strong>On</strong> for Chat Model node version 1.3</dd>
470
+ </div>
471
+ </dl>
472
+ <p>All models detected: <span id="result-models">Not detected</span></p>
473
+ <p>
474
+ If the Responses API switch is absent, keep the node's default
475
+ Chat Completions behavior; the bridge supports that route too.
476
+ </p>
477
+ </div>
478
+ </details>
479
+
480
+ <details class="recipe-disclosure">
481
+ <summary><span>3. HTTP Request node</span></summary>
482
+ <div class="model-box recipe-content">
483
+ <dl class="result-list">
484
+ <div>
485
+ <dt>Method</dt>
486
+ <dd><code>POST</code></dd>
487
+ </div>
488
+ <div>
489
+ <dt>URL</dt>
490
+ <dd class="result-value">
491
+ <code id="result-http-url">http://n8n-openai-oauth:10531/v1/responses</code>
492
+ <button
493
+ class="button secondary copy-value"
494
+ type="button"
495
+ data-copy-target="result-http-url"
496
+ data-copy-label="HTTP endpoint"
497
+ aria-label="Copy HTTP endpoint"
498
+ >
499
+ Copy
500
+ </button>
501
+ </dd>
502
+ </div>
503
+ <div>
504
+ <dt>Authorization</dt>
505
+ <dd><code>Bearer local-only</code></dd>
506
+ </div>
507
+ <div>
508
+ <dt>Content-Type</dt>
509
+ <dd><code>application/json</code></dd>
510
+ </div>
511
+ <div>
512
+ <dt>Authentication</dt>
513
+ <dd>None</dd>
514
+ </div>
515
+ <div class="sample-body-row">
516
+ <dt>JSON body</dt>
517
+ <dd class="result-value">
518
+ <pre class="sample-json"><code id="result-http-body"></code></pre>
519
+ <button
520
+ class="button secondary copy-value"
521
+ type="button"
522
+ data-copy-target="result-http-body"
523
+ data-copy-label="HTTP JSON body"
524
+ aria-label="Copy HTTP JSON body"
525
+ >
526
+ Copy
527
+ </button>
528
+ </dd>
529
+ </div>
530
+ </dl>
531
+ <button
532
+ id="copy-http-recipe"
533
+ class="button secondary"
534
+ type="button"
535
+ data-copy-group="http"
536
+ data-copy-label="HTTP request recipe"
537
+ >
538
+ Copy HTTP request recipe
539
+ </button>
540
+ </div>
541
+ </details>
444
542
 
445
543
  <p class="final-note">
446
544
  The README includes complete AI Agent, Basic LLM Chain, JSON, and
package/src/ui/styles.css CHANGED
@@ -29,6 +29,7 @@
29
29
  --radius-sm: 0.5rem;
30
30
  --radius: 0.875rem;
31
31
  --radius-lg: 1.25rem;
32
+ --header-height: 3.75rem;
32
33
  }
33
34
 
34
35
  :root[data-theme="dark"] {
@@ -160,8 +161,8 @@ a:focus-visible {
160
161
  display: flex;
161
162
  align-items: center;
162
163
  justify-content: space-between;
163
- min-height: 4.25rem;
164
- padding: 0.75rem clamp(1rem, 4vw, 3rem);
164
+ min-height: var(--header-height);
165
+ padding: 0.5rem clamp(1rem, 4vw, 3rem);
165
166
  border-bottom: 1px solid rgb(221 227 223 / 75%);
166
167
  background: color-mix(in srgb, var(--background) 82%, transparent);
167
168
  backdrop-filter: blur(14px) saturate(1.4);
@@ -179,8 +180,8 @@ a:focus-visible {
179
180
 
180
181
  .brand-mark {
181
182
  display: block;
182
- width: 2rem;
183
- height: 2rem;
183
+ width: 1.75rem;
184
+ height: 1.75rem;
184
185
  flex: none;
185
186
  border-radius: 0.4375rem;
186
187
  transition:
@@ -302,15 +303,23 @@ a:focus-visible {
302
303
  /* ---------- Shell & intro ---------- */
303
304
 
304
305
  .shell {
305
- width: min(100% - 2rem, 54rem);
306
+ width: min(100% - 2rem, 62rem);
306
307
  margin: 0 auto;
307
- padding: 3.5rem 0 5rem;
308
+ padding: 1.75rem 0 3.5rem;
308
309
  }
309
310
 
310
311
  .intro {
311
312
  max-width: 44rem;
312
313
  }
313
314
 
315
+ body:not([data-current-step="1"]) .intro {
316
+ display: none;
317
+ }
318
+
319
+ body:not([data-current-step="1"]) .shell {
320
+ padding-top: 1rem;
321
+ }
322
+
314
323
  .eyebrow,
315
324
  .step-kicker {
316
325
  margin: 0 0 0.5rem;
@@ -330,8 +339,8 @@ p {
330
339
 
331
340
  h1 {
332
341
  max-width: 42rem;
333
- margin-bottom: 1rem;
334
- font-size: clamp(2.1rem, 6.5vw, 3.4rem);
342
+ margin-bottom: 0.75rem;
343
+ font-size: clamp(1.9rem, 5vw, 3rem);
335
344
  line-height: 1.04;
336
345
  letter-spacing: -0.045em;
337
346
  }
@@ -355,19 +364,37 @@ h3 {
355
364
 
356
365
  .lede {
357
366
  max-width: 42rem;
358
- font-size: 1.0625rem;
367
+ margin-bottom: 0;
368
+ font-size: 1rem;
369
+ }
370
+
371
+ /* ---------- Toasts ---------- */
372
+
373
+ .toast-stack {
374
+ position: fixed;
375
+ top: calc(var(--header-height) + 0.5rem);
376
+ right: clamp(0.75rem, 3vw, 2rem);
377
+ z-index: 15;
378
+ display: grid;
379
+ gap: 0.5rem;
380
+ width: min(25rem, calc(100vw - 1.5rem));
381
+ max-height: calc(100svh - var(--header-height) - 1rem);
382
+ overflow-y: auto;
383
+ pointer-events: none;
359
384
  }
360
385
 
361
- /* ---------- Safety notes ---------- */
386
+ .toast {
387
+ pointer-events: auto;
388
+ }
362
389
 
363
390
  .safety-note {
364
391
  position: relative;
365
392
  display: grid;
366
- grid-template-columns: 2.25rem 1fr;
393
+ grid-template-columns: 2rem minmax(0, 1fr) auto;
367
394
  gap: 0.25rem 0.875rem;
368
395
  align-items: start;
369
- margin: 1.25rem 0 0;
370
- padding: 1rem 1.125rem;
396
+ margin: 0;
397
+ padding: 0.875rem 0.875rem 0.875rem 1rem;
371
398
  border: 1px solid var(--accent-border);
372
399
  border-radius: var(--radius);
373
400
  background: var(--accent-soft);
@@ -376,8 +403,8 @@ h3 {
376
403
 
377
404
  .safety-note::before {
378
405
  display: grid;
379
- width: 2.25rem;
380
- height: 2.25rem;
406
+ width: 2rem;
407
+ height: 2rem;
381
408
  grid-row: span 2;
382
409
  place-items: center;
383
410
  border-radius: 0.625rem;
@@ -394,6 +421,7 @@ h3 {
394
421
 
395
422
  .safety-note span {
396
423
  color: var(--accent-copy);
424
+ font-size: 0.875rem;
397
425
  }
398
426
 
399
427
  .backup-note {
@@ -411,10 +439,41 @@ h3 {
411
439
  color: var(--warning);
412
440
  }
413
441
 
442
+ .toast-close {
443
+ display: inline-grid;
444
+ width: 1.75rem;
445
+ min-height: 1.75rem;
446
+ padding: 0;
447
+ place-items: center;
448
+ border: 0;
449
+ border-radius: 50%;
450
+ color: var(--text-muted);
451
+ background: transparent;
452
+ box-shadow: none;
453
+ cursor: pointer;
454
+ font-size: 1.25rem;
455
+ line-height: 1;
456
+ }
457
+
458
+ .toast-close:hover {
459
+ color: var(--text);
460
+ background: color-mix(in srgb, var(--text) 9%, transparent);
461
+ }
462
+
463
+ .toast-close:focus-visible {
464
+ outline: 3px solid #f2a94a;
465
+ outline-offset: 1px;
466
+ }
467
+
468
+ .safety-note .toast-close {
469
+ grid-column: 3;
470
+ grid-row: 1;
471
+ }
472
+
414
473
  /* ---------- Stepper ---------- */
415
474
 
416
475
  .steps {
417
- margin: 2.5rem 0 1.25rem;
476
+ margin: 1.5rem 0 0.75rem;
418
477
  }
419
478
 
420
479
  .steps ol {
@@ -509,12 +568,13 @@ h3 {
509
568
 
510
569
  .message,
511
570
  .error {
512
- display: flex;
513
- align-items: center;
571
+ display: grid;
572
+ grid-template-columns: auto minmax(0, 1fr) auto;
573
+ align-items: start;
514
574
  gap: 0.625rem;
515
- min-height: 2.875rem;
516
- margin: 1rem 0;
517
- padding: 0.75rem 1rem;
575
+ min-height: 3rem;
576
+ margin: 0;
577
+ padding: 0.75rem;
518
578
  border-radius: var(--radius-sm);
519
579
  font-size: 0.9375rem;
520
580
  }
@@ -527,6 +587,7 @@ h3 {
527
587
  }
528
588
 
529
589
  .message::before {
590
+ align-self: center;
530
591
  flex: 0 0 auto;
531
592
  width: 0.4375rem;
532
593
  height: 0.4375rem;
@@ -536,6 +597,7 @@ h3 {
536
597
  }
537
598
 
538
599
  .error {
600
+ grid-template-columns: minmax(0, 1fr) auto;
539
601
  border: 1px solid var(--danger-border);
540
602
  color: var(--danger);
541
603
  background: var(--danger-soft);
@@ -546,7 +608,7 @@ h3 {
546
608
  /* ---------- Panels ---------- */
547
609
 
548
610
  .panel {
549
- padding: clamp(1.5rem, 4vw, 2.25rem);
611
+ padding: clamp(1.25rem, 3vw, 1.75rem);
550
612
  border: 1px solid var(--border);
551
613
  border-radius: var(--radius-lg);
552
614
  background: var(--surface);
@@ -555,7 +617,7 @@ h3 {
555
617
  }
556
618
 
557
619
  .panel-heading {
558
- margin-bottom: 1.5rem;
620
+ margin-bottom: 1.125rem;
559
621
  }
560
622
 
561
623
  .panel-heading p {
@@ -955,7 +1017,7 @@ code {
955
1017
 
956
1018
  .model-box,
957
1019
  .final-note {
958
- margin-top: 1.5rem;
1020
+ margin-top: 1rem;
959
1021
  padding: 1.125rem;
960
1022
  border: 1px solid var(--border);
961
1023
  border-radius: var(--radius);
@@ -971,6 +1033,71 @@ code {
971
1033
  color: var(--text-muted);
972
1034
  }
973
1035
 
1036
+ .recipe-disclosure {
1037
+ margin-top: 1rem;
1038
+ border: 1px solid var(--border);
1039
+ border-radius: var(--radius);
1040
+ background: var(--surface-muted);
1041
+ }
1042
+
1043
+ .recipe-disclosure summary {
1044
+ display: flex;
1045
+ align-items: center;
1046
+ justify-content: space-between;
1047
+ gap: 0.75rem;
1048
+ padding: 0.875rem 1rem;
1049
+ cursor: pointer;
1050
+ font-weight: 750;
1051
+ list-style: none;
1052
+ }
1053
+
1054
+ .recipe-disclosure summary::-webkit-details-marker {
1055
+ display: none;
1056
+ }
1057
+
1058
+ .recipe-disclosure summary::after {
1059
+ flex: none;
1060
+ color: var(--accent);
1061
+ content: "+";
1062
+ font-size: 1.25rem;
1063
+ line-height: 1;
1064
+ }
1065
+
1066
+ .recipe-disclosure[open] summary::after {
1067
+ content: "−";
1068
+ }
1069
+
1070
+ .recipe-disclosure summary:focus-visible {
1071
+ outline: 3px solid #f2a94a;
1072
+ outline-offset: -3px;
1073
+ }
1074
+
1075
+ .recipe-content {
1076
+ margin: 0;
1077
+ border: 0;
1078
+ border-top: 1px solid var(--border);
1079
+ border-radius: 0;
1080
+ }
1081
+
1082
+ .sample-body-row {
1083
+ align-items: start !important;
1084
+ }
1085
+
1086
+ .sample-json {
1087
+ max-width: 100%;
1088
+ margin: 0;
1089
+ overflow-x: auto;
1090
+ white-space: pre-wrap;
1091
+ }
1092
+
1093
+ .sample-json code {
1094
+ display: block;
1095
+ padding: 0.75rem;
1096
+ border: 1px solid var(--border);
1097
+ background: var(--surface);
1098
+ line-height: 1.45;
1099
+ }
1100
+
974
1101
  footer {
975
1102
  padding: 1.5rem 1rem;
976
1103
  border-top: 1px solid var(--border);
@@ -1072,8 +1199,15 @@ footer p {
1072
1199
  }
1073
1200
 
1074
1201
  .shell {
1075
- width: min(100% - 1rem, 54rem);
1076
- padding-top: 2rem;
1202
+ width: min(100% - 1rem, 62rem);
1203
+ padding: 1.25rem 0 2.5rem;
1204
+ }
1205
+
1206
+ .toast-stack {
1207
+ top: calc(var(--header-height) + 0.375rem);
1208
+ right: 0.5rem;
1209
+ width: calc(100vw - 1rem);
1210
+ gap: 0.375rem;
1077
1211
  }
1078
1212
 
1079
1213
  .safety-note,
@@ -1086,6 +1220,19 @@ footer p {
1086
1220
  grid-row: auto;
1087
1221
  }
1088
1222
 
1223
+ .toast-stack .safety-note {
1224
+ grid-template-columns: 2rem minmax(0, 1fr) auto;
1225
+ align-items: center;
1226
+ }
1227
+
1228
+ .toast-stack .safety-note::before {
1229
+ grid-row: auto;
1230
+ }
1231
+
1232
+ .toast-stack .safety-note > span {
1233
+ display: none;
1234
+ }
1235
+
1089
1236
  .steps li em {
1090
1237
  font-size: 0.6875rem;
1091
1238
  }
@@ -1108,6 +1255,10 @@ footer p {
1108
1255
  grid-template-columns: 1fr;
1109
1256
  gap: 0.25rem;
1110
1257
  }
1258
+
1259
+ .sample-body-row dd.result-value {
1260
+ align-items: stretch;
1261
+ }
1111
1262
  }
1112
1263
 
1113
1264
  @media (prefers-reduced-motion: reduce) {