@1agh/maude 0.43.0 → 0.45.0

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 (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -0,0 +1,233 @@
1
+ // BrandUploadPanel — T12 (DDR-173). The in-app "Bring my existing brand"
2
+ // panel: upload a logo file → DDR-167 sanitize (`POST /_api/import-asset`)
3
+ // → DDR-173 typed-cue extraction (`POST /_api/import-brand`) → show the
4
+ // palette/fonts/logo the user can carry into `/design:setup-ds --from-brand`.
5
+ //
6
+ // Client never sends a filesystem PATH to either route — only bytes (native:
7
+ // via the existing `pick_media_file` Tauri command, which reads the file in
8
+ // Rust and returns bytes over IPC, mirroring app.jsx's `openFilePickerNative`;
9
+ // browser: via a plain `<input type=file>` + FileReader). This mirrors
10
+ // DDR-167's own entry-point trust table: "no server-side path resolution at
11
+ // all — the client never sends a path string, only bytes over loopback HTTP."
12
+
13
+ import { useRef, useState } from 'react';
14
+ import { isNativeApp, pickMediaFile } from '../github.js';
15
+
16
+ async function readFileAsArrayBuffer(file) {
17
+ return new Promise((resolvePromise, reject) => {
18
+ const reader = new FileReader();
19
+ reader.onload = () => resolvePromise(reader.result);
20
+ reader.onerror = () => reject(reader.error || new Error('file read failed'));
21
+ reader.readAsArrayBuffer(file);
22
+ });
23
+ }
24
+
25
+ /** Upload + extract. Returns `{ palette, fonts, logoRef, logoRasterRef, hadWordmarkText }` or throws. */
26
+ async function ingestBrandFile(bytes) {
27
+ const importAssetRes = await fetch('/_api/import-asset', {
28
+ method: 'POST',
29
+ headers: { 'X-Import-Kind': 'svg' },
30
+ body: bytes,
31
+ });
32
+ const importAssetBody = await importAssetRes.json().catch(() => ({}));
33
+ if (!importAssetRes.ok || !importAssetBody.ok) {
34
+ throw new Error(importAssetBody.error || `import failed (HTTP ${importAssetRes.status})`);
35
+ }
36
+
37
+ const importBrandRes = await fetch('/_api/import-brand', {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ assetPath: importAssetBody.path }),
41
+ });
42
+ const importBrandBody = await importBrandRes.json().catch(() => ({}));
43
+ if (!importBrandRes.ok || !importBrandBody.ok) {
44
+ throw new Error(importBrandBody.error || `extraction failed (HTTP ${importBrandRes.status})`);
45
+ }
46
+ return importBrandBody;
47
+ }
48
+
49
+ function PaletteSwatches({ palette }) {
50
+ if (!palette?.length) {
51
+ return <p className="brand-up-empty">No color values could be extracted from this file.</p>;
52
+ }
53
+ return (
54
+ <ul className="brand-up-swatches" data-testid="brand-up-palette">
55
+ {palette.map((color) => (
56
+ <li key={color} className="brand-up-swatch" title={color}>
57
+ <span className="brand-up-swatch-chip" style={{ background: color }} aria-hidden="true" />
58
+ <span className="brand-up-swatch-value">{color}</span>
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ );
63
+ }
64
+
65
+ function FontList({ fonts }) {
66
+ if (!fonts?.length) {
67
+ return (
68
+ <p className="brand-up-empty">
69
+ No recognized font names found — this is normal (font names inside the logo's own text are
70
+ stripped for safety before extraction; the design system's typography will still be
71
+ researched from your brief).
72
+ </p>
73
+ );
74
+ }
75
+ return (
76
+ <ul className="brand-up-fonts" data-testid="brand-up-fonts">
77
+ {fonts.map((f) => (
78
+ <li key={f} className="brand-up-font-chip">
79
+ {f}
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ );
84
+ }
85
+
86
+ export default function BrandUploadPanel({ open, onClose }) {
87
+ const [busy, setBusy] = useState(false);
88
+ const [err, setErr] = useState(null);
89
+ const [result, setResult] = useState(null); // ingestBrandFile() response
90
+ const fileInputRef = useRef(null);
91
+
92
+ const runIngest = async (bytes) => {
93
+ setBusy(true);
94
+ setErr(null);
95
+ setResult(null);
96
+ try {
97
+ const r = await ingestBrandFile(bytes);
98
+ setResult(r);
99
+ } catch (e) {
100
+ setErr(e?.message || 'upload failed');
101
+ } finally {
102
+ setBusy(false);
103
+ }
104
+ };
105
+
106
+ const pickNative = async () => {
107
+ setBusy(true);
108
+ setErr(null);
109
+ try {
110
+ const picked = await pickMediaFile();
111
+ if (!picked?.bytes) {
112
+ setBusy(false);
113
+ return; // user cancelled
114
+ }
115
+ await runIngest(new Blob([new Uint8Array(picked.bytes)]));
116
+ } catch (e) {
117
+ setErr(e?.message || 'open failed');
118
+ setBusy(false);
119
+ }
120
+ };
121
+
122
+ const pickBrowser = () => fileInputRef.current?.click();
123
+
124
+ const onBrowserFileChosen = async (e) => {
125
+ const file = e.target.files?.[0];
126
+ e.target.value = ''; // allow re-picking the same file
127
+ if (!file) return;
128
+ const buf = await readFileAsArrayBuffer(file);
129
+ await runIngest(new Blob([buf]));
130
+ };
131
+
132
+ if (!open) return null;
133
+
134
+ return (
135
+ <div
136
+ className="help-modal-backdrop"
137
+ role="presentation"
138
+ onMouseDown={(e) => {
139
+ if (e.target === e.currentTarget) onClose();
140
+ }}
141
+ >
142
+ <div
143
+ className="help-modal brand-up-modal"
144
+ role="dialog"
145
+ aria-modal="true"
146
+ aria-labelledby="brand-up-modal-title"
147
+ >
148
+ <header className="help-modal-hd">
149
+ <span className="title" id="brand-up-modal-title">
150
+ Bring my existing brand
151
+ </span>
152
+ <button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>
153
+ ×
154
+ </button>
155
+ </header>
156
+ <div className="help-modal-body">
157
+ <p className="brand-up-note">
158
+ Upload your logo (SVG) and Maude will pull out its color palette to seed your design
159
+ system — nothing here is final, you'll confirm every choice during setup.
160
+ </p>
161
+
162
+ {!result && (
163
+ <div className="brand-up-pick">
164
+ <button
165
+ type="button"
166
+ className="btn btn--primary btn--sm"
167
+ data-testid="brand-up-pick-file"
168
+ disabled={busy}
169
+ onClick={isNativeApp() ? pickNative : pickBrowser}
170
+ >
171
+ {busy ? 'Working…' : 'Choose logo file…'}
172
+ </button>
173
+ <input
174
+ ref={fileInputRef}
175
+ type="file"
176
+ accept="image/svg+xml,.svg"
177
+ style={{ display: 'none' }}
178
+ onChange={onBrowserFileChosen}
179
+ />
180
+ </div>
181
+ )}
182
+
183
+ {err && (
184
+ <p className="brand-up-error" data-testid="brand-up-error" role="alert">
185
+ {err}
186
+ </p>
187
+ )}
188
+
189
+ {result && (
190
+ <div className="brand-up-result" data-testid="brand-up-result">
191
+ <img
192
+ className="brand-up-preview"
193
+ src={`/${result.logoRasterRef || result.logoRef}`}
194
+ alt="Uploaded logo preview"
195
+ />
196
+ <h4 className="brand-up-sechd">Palette</h4>
197
+ <PaletteSwatches palette={result.palette} />
198
+ <h4 className="brand-up-sechd">Fonts</h4>
199
+ <FontList fonts={result.fonts} />
200
+ <p className="brand-up-nextstep">
201
+ Next: run <code>/design:setup-ds --from-brand {result.logoRef}</code> in the AI chat
202
+ (or your terminal) to build a design system seeded from this upload.
203
+ </p>
204
+ <div className="brand-up-actions">
205
+ <button
206
+ type="button"
207
+ className="btn btn--primary btn--sm"
208
+ data-testid="brand-up-copy-command"
209
+ onClick={() =>
210
+ navigator.clipboard?.writeText(`/design:setup-ds --from-brand ${result.logoRef}`)
211
+ }
212
+ >
213
+ Copy command
214
+ </button>
215
+ <button
216
+ type="button"
217
+ className="btn btn--ghost btn--sm"
218
+ data-testid="brand-up-again"
219
+ onClick={() => {
220
+ setResult(null);
221
+ setErr(null);
222
+ }}
223
+ >
224
+ Upload a different file
225
+ </button>
226
+ </div>
227
+ </div>
228
+ )}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
@@ -870,7 +870,7 @@ function Composer({
870
870
  );
871
871
 
872
872
  return (
873
- <div className="chat-composer">
873
+ <div className="chat-composer" data-testid="chat-composer">
874
874
  <ThreadPrimitive.If running={false}>
875
875
  {/* Context attachment chip (feature-acp-context-hardening) — the visible
876
876
  reveal of the fenced <maude-context> block the send will prepend
@@ -986,37 +986,32 @@ function Composer({
986
986
  );
987
987
  }
988
988
 
989
- function NotConnected({ reason, claudeMissing, readiness, readinessLoading, onRecheck }) {
989
+ // DDR-166 T0f the guided, button-driven cold start. Every actionable step
990
+ // (install command, Sign in) now lives in ReadinessList itself; this shell no
991
+ // longer carries its own terminal-instruction copy, so there's nothing here to
992
+ // go stale relative to what the rows actually offer.
993
+ function NotConnected({ reason, readiness, readinessLoading, onRecheck }) {
990
994
  return (
991
- <div className="chat-disabled">
995
+ <div className="chat-disabled" data-testid="acp-not-connected">
992
996
  <span className="chat-disabled-mark">
993
997
  <Spark size={28} />
994
998
  </span>
995
999
  <div className="chat-disabled-title">AI editing isn't ready yet</div>
996
1000
  <div className="chat-disabled-sub">
997
1001
  {reason ? <p>{reason}</p> : null}
998
- {readiness ? (
999
- <p>AI editing pairs with a Claude Code you have installed. Here's what it still needs:</p>
1000
- ) : claudeMissing ? (
1001
- <p>
1002
- Install it with <code>npm i -g @anthropic-ai/claude-code</code>, then run{' '}
1003
- <code>claude</code> and <code>/login</code> in a terminal.
1004
- </p>
1005
- ) : (
1006
- <p>
1007
- Open a terminal, run <code>claude</code> and <code>/login</code>, then reopen this panel.
1008
- </p>
1009
- )}
1002
+ <p>AI editing pairs with a Claude Code you have installed. Here's what it still needs:</p>
1010
1003
  </div>
1011
1004
  {readiness ? (
1012
1005
  <ReadinessList report={readiness} loading={readinessLoading} refresh={onRecheck} />
1013
- ) : null}
1006
+ ) : (
1007
+ <p className="chat-disabled-sub">Checking what's needed…</p>
1008
+ )}
1014
1009
  <div className="chat-trust">
1015
1010
  <div className="chat-trust-row">
1016
1011
  <Check /> Runs on your Pro/Max subscription
1017
1012
  </div>
1018
1013
  <div className="chat-trust-row">
1019
- <Check /> No login inside Maude
1014
+ <Check /> Signs in via Claude Code's own login Maude never sees your credentials
1020
1015
  </div>
1021
1016
  <div className="chat-trust-row">
1022
1017
  <Check /> Never metered API billing
@@ -1200,25 +1195,13 @@ export default function ChatPanel({
1200
1195
  }, [effort]);
1201
1196
 
1202
1197
  // Availability is global (is claude installed) — a single probe, no connection.
1203
- const [status, setStatus] = useState({ available: null, reason: undefined, claudeMissing: false });
1198
+ const [status, setStatus] = useState({ available: null, reason: undefined });
1204
1199
  const probeStatus = useCallback(
1205
1200
  () =>
1206
1201
  fetch('/_api/acp/status')
1207
1202
  .then((r) => r.json())
1208
- .then((d) =>
1209
- setStatus({
1210
- available: d.available,
1211
- reason: d.reason,
1212
- claudeMissing: !!d.adapterEntry && !d.claudePath,
1213
- })
1214
- )
1215
- .catch(() =>
1216
- setStatus({
1217
- available: false,
1218
- reason: 'Could not reach the Claude bridge.',
1219
- claudeMissing: false,
1220
- })
1221
- ),
1203
+ .then((d) => setStatus({ available: d.available, reason: d.reason }))
1204
+ .catch(() => setStatus({ available: false, reason: 'Could not reach the Claude bridge.' })),
1222
1205
  []
1223
1206
  );
1224
1207
  useEffect(() => {
@@ -1509,7 +1492,6 @@ export default function ChatPanel({
1509
1492
  {status.available === false ? (
1510
1493
  <NotConnected
1511
1494
  reason={status.reason}
1512
- claudeMissing={status.claudeMissing}
1513
1495
  readiness={readiness}
1514
1496
  readinessLoading={readinessLoading}
1515
1497
  onRecheck={recheck}
@@ -0,0 +1,44 @@
1
+ // The "Watch the intro" player (DDR-166 Phase 1 / T2). Plays the bundled
2
+ // showreel — served from Maude's OWN /_media/ route (paths.ts MEDIA_DIR,
3
+ // DDR-045), never the served project's .design/assets/ — so it's available
4
+ // identically whether a project is open or not. Reuses the shared help-modal
5
+ // chrome (backdrop + header + body), same as ReadinessDialog / What's New.
6
+ import { useEffect } from 'react';
7
+
8
+ export default function IntroVideoDialog({ open, onClose }) {
9
+ useEffect(() => {
10
+ if (!open) return undefined;
11
+ const onKey = (e) => e.key === 'Escape' && onClose();
12
+ window.addEventListener('keydown', onKey);
13
+ return () => window.removeEventListener('keydown', onKey);
14
+ }, [open, onClose]);
15
+ if (!open) return null;
16
+ return (
17
+ <div
18
+ className="help-modal-backdrop"
19
+ role="presentation"
20
+ onMouseDown={(e) => {
21
+ if (e.target === e.currentTarget) onClose();
22
+ }}
23
+ >
24
+ <div
25
+ className="help-modal help-modal--wide"
26
+ role="dialog"
27
+ aria-modal="true"
28
+ aria-labelledby="intro-video-title"
29
+ >
30
+ <header className="help-modal-hd">
31
+ <span className="title" id="intro-video-title">
32
+ Watch the intro
33
+ </span>
34
+ <button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>
35
+ ×
36
+ </button>
37
+ </header>
38
+ <div className="help-modal-body intro-video-body">
39
+ <video className="intro-video-el" src="/_media/intro.mp4" controls autoPlay playsInline />
40
+ </div>
41
+ </div>
42
+ </div>
43
+ );
44
+ }
@@ -13,6 +13,7 @@
13
13
  import { useEffect, useRef, useState } from 'react';
14
14
 
15
15
  import ReadinessList, { useReadiness } from './ReadinessList.jsx';
16
+ import IntroVideoDialog from './IntroVideoDialog.jsx';
16
17
  import {
17
18
  cloneRepo,
18
19
  createProject,
@@ -51,6 +52,7 @@ function Icon({ name, size = 16, className }) {
51
52
  external: (<><path d="M6 3.5H3.2A.7.7 0 0 0 2.5 4.2v8.6a.7.7 0 0 0 .7.7h8.6a.7.7 0 0 0 .7-.7V10" /><line x1="8" y1="8" x2="13" y2="3" /><polyline points="9.5 3 13 3 13 6.5" /></>),
52
53
  back: <polyline points="10 3.5 5.5 8 10 12.5" />,
53
54
  spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
55
+ play: <polygon points="5 3.2 12.8 8 5 12.8" fill="currentColor" stroke="none" />,
54
56
  }[name];
55
57
  return (
56
58
  <svg className={className} width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -179,13 +181,23 @@ function AiReadiness() {
179
181
 
180
182
  // ── A · Welcome (door picker) ────────────────────────────────────────────────
181
183
  function Welcome({ onGithub, onLocal, onHub, signing, signedIn, identity }) {
184
+ const [introOpen, setIntroOpen] = useState(false);
182
185
  return (
183
186
  <main className="ob-main">
184
187
  <header className="ob-head">
185
188
  <span className="ob-eyebrow">Welcome</span>
186
189
  <h1>How would you like to start?</h1>
187
190
  <p>{signedIn ? `You're signed in as @${identity?.login || 'GitHub'}. Open a project, or start a new one.` : "Most people sign in with GitHub — it's the simplest way to work with a team."}</p>
191
+ <button
192
+ type="button"
193
+ data-testid="onboarding-watch-intro"
194
+ className="ob-watch-intro"
195
+ onClick={() => setIntroOpen(true)}
196
+ >
197
+ <Icon name="play" size={13} /> Watch the intro
198
+ </button>
188
199
  </header>
200
+ <IntroVideoDialog open={introOpen} onClose={() => setIntroOpen(false)} />
189
201
  <div className="ob-doors">
190
202
  <button type="button" data-testid="ob-door-github" className="ob-door ob-door--primary" aria-label="Continue with GitHub — recommended" onClick={onGithub} disabled={signing}>
191
203
  <span className="ob-door-icon ob-door-icon--gh"><GitHubMark size={26} /></span>