@a-company/atelier 0.29.0 → 0.37.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 (76) hide show
  1. package/dist/chunk-5QQESXI6.js +4432 -0
  2. package/dist/chunk-5QQESXI6.js.map +1 -0
  3. package/dist/cli.cjs +2391 -530
  4. package/dist/cli.cjs.map +1 -1
  5. package/dist/cli.js +301 -429
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +2233 -38
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +584 -2
  10. package/dist/index.d.ts +584 -2
  11. package/dist/index.js +111 -3
  12. package/dist/mcp.cjs +1215 -365
  13. package/dist/mcp.cjs.map +1 -1
  14. package/dist/mcp.js +1209 -365
  15. package/dist/mcp.js.map +1 -1
  16. package/package.json +20 -9
  17. package/src/web/inline-app.ts +867 -0
  18. package/src/web/tsconfig.json +9 -0
  19. package/templates/welcome.atelier +67 -0
  20. package/university/content/notes/N-atel-001-first-render.md +114 -0
  21. package/university/content/notes/N-atel-001-install-and-launch.md +84 -0
  22. package/university/content/notes/N-atel-001-what-is-atelier.md +51 -0
  23. package/university/content/notes/N-atel-101-easings.md +97 -0
  24. package/university/content/notes/N-atel-101-layers.md +106 -0
  25. package/university/content/notes/N-atel-101-states-and-deltas.md +94 -0
  26. package/university/content/notes/N-atel-101-the-atelier-format.md +72 -0
  27. package/university/content/notes/N-atel-201-authoring-tools.md +141 -0
  28. package/university/content/notes/N-atel-201-mcp-overview.md +86 -0
  29. package/university/content/notes/N-atel-201-patterns.md +108 -0
  30. package/university/content/notes/N-atel-201-visual-and-effects.md +125 -0
  31. package/university/content/notes/N-atel-301-composition-and-overlays.md +141 -0
  32. package/university/content/notes/N-atel-301-effects.md +136 -0
  33. package/university/content/notes/N-atel-301-images-and-video.md +126 -0
  34. package/university/content/notes/N-atel-301-shapes-and-text.md +118 -0
  35. package/university/content/notes/N-atel-401-hierarchical-states.md +71 -0
  36. package/university/content/notes/N-atel-401-motion-deep-dive.md +106 -0
  37. package/university/content/notes/N-atel-401-presets-and-templates.md +98 -0
  38. package/university/content/notes/N-atel-401-transitions.md +94 -0
  39. package/university/content/notes/N-atel-501-detected-vs-user-edited.md +76 -0
  40. package/university/content/notes/N-atel-501-layer-tag-isolation.md +62 -0
  41. package/university/content/notes/N-atel-501-silence-trim.md +98 -0
  42. package/university/content/notes/N-atel-501-transcribe-and-captions.md +98 -0
  43. package/university/content/notes/N-atel-601-carousel.md +71 -0
  44. package/university/content/notes/N-atel-601-overlay-rules.md +96 -0
  45. package/university/content/notes/N-atel-601-recipe-tools-and-apply.md +84 -0
  46. package/university/content/notes/N-atel-601-studio-recipe.md +103 -0
  47. package/university/content/notes/N-atel-701-choosing-output.md +68 -0
  48. package/university/content/notes/N-atel-701-png-and-frames.md +84 -0
  49. package/university/content/notes/N-atel-701-vector.md +85 -0
  50. package/university/content/notes/N-atel-701-video.md +88 -0
  51. package/university/content/notes/N-atel-801-editing-surface.md +69 -0
  52. package/university/content/notes/N-atel-801-live-bridge.md +84 -0
  53. package/university/content/notes/N-atel-801-studio-app.md +72 -0
  54. package/university/content/notes/N-atel-801-symbiotic-loop.md +56 -0
  55. package/university/content/paths/LP-atel-001.yaml +21 -0
  56. package/university/content/paths/LP-atel-101.yaml +22 -0
  57. package/university/content/paths/LP-atel-201.yaml +23 -0
  58. package/university/content/paths/LP-atel-301.yaml +22 -0
  59. package/university/content/paths/LP-atel-401.yaml +22 -0
  60. package/university/content/paths/LP-atel-501.yaml +22 -0
  61. package/university/content/paths/LP-atel-601.yaml +22 -0
  62. package/university/content/paths/LP-atel-701.yaml +22 -0
  63. package/university/content/paths/LP-atel-801.yaml +22 -0
  64. package/university/content/quizzes/Q-atel-001-orientation.yaml +66 -0
  65. package/university/content/quizzes/Q-atel-101-document-model.yaml +66 -0
  66. package/university/content/quizzes/Q-atel-201-mcp-authoring.yaml +66 -0
  67. package/university/content/quizzes/Q-atel-301-visual-system.yaml +66 -0
  68. package/university/content/quizzes/Q-atel-401-state-machines.yaml +66 -0
  69. package/university/content/quizzes/Q-atel-501-video-pipeline.yaml +66 -0
  70. package/university/content/quizzes/Q-atel-601-recipes.yaml +66 -0
  71. package/university/content/quizzes/Q-atel-701-export.yaml +66 -0
  72. package/university/content/quizzes/Q-atel-801-studio-loop.yaml +66 -0
  73. package/university/index.yaml +720 -0
  74. package/university/pack.yaml +21 -0
  75. package/dist/chunk-JV7RGETS.js +0 -2292
  76. package/dist/chunk-JV7RGETS.js.map +0 -1
@@ -0,0 +1,867 @@
1
+ /**
2
+ * #studio-inline-app — the browser-side Atelier Studio client.
3
+ *
4
+ * This was previously a ~700-LOC template-literal STRING returned by
5
+ * `getInlineApp()` in `src/commands/studio.ts`: no typecheck, no lint, no
6
+ * tests. Two real bugs (the autosave save-race and the slider-commit flood)
7
+ * hid there precisely because the compiler never saw it.
8
+ *
9
+ * It now lives here as REAL TypeScript, typechecked against the DOM lib via
10
+ * `src/web/tsconfig.json` (the surrounding `src/` is a node project; mixing
11
+ * DOM + node globals in one tsconfig clashes, so `web/` is isolated — mirrors
12
+ * how the `@a-company/atelier-studio` package scopes DOM).
13
+ *
14
+ * The studio command writes a tiny entry shim into its temp dir that imports
15
+ * `bootStudioApp` from this file (by absolute path under the CLI package) and
16
+ * calls it with the injected `{ initialFile }`. Vite's dev server transpiles
17
+ * + serves this module directly from source at request time.
18
+ *
19
+ * BEHAVIOR IS A BYTE-FOR-BEHAVIOR EXTRACTION of the old string — no logic
20
+ * changes. The manual debounce autosave (NOT the `SaveCoordinator` class) is
21
+ * preserved verbatim, including the path/bytes capture + flush-on-switch that
22
+ * fixed the cross-file corruption bug.
23
+ *
24
+ * NOTE: do NOT `import` this file from any tsup entry (`src/index.ts`,
25
+ * `src/cli.ts`) — it must stay reachable only as a string path, or tsup will
26
+ * try to bundle DOM code into the node build. The CLI ships it as raw source
27
+ * via the package `files: ["src/web/**"]` glob.
28
+ */
29
+
30
+ import { AtelierStudio, exportDocument, ImageCache } from "@a-company/atelier-studio";
31
+ import "@a-company/atelier-studio/styles.css";
32
+ import { parseAtelier, serializeAtelier } from "@a-company/atelier-schema";
33
+
34
+ type AtelierDoc = import("@a-company/atelier-types").AtelierDocument;
35
+
36
+ // TYPE-SKEW / RUNTIME BUG SURFACED BY THIS EXTRACTION:
37
+ // The CLI resolves @a-company/atelier-studio via the registry range
38
+ // ">=0.25.1", which pnpm pinned to 0.26.1 — and 0.26.1 ships NEITHER the
39
+ // `applyMutation` method nor its type (it was added in workspace 0.27.0, see
40
+ // "Builder F's commit 0219d28"). The bridge's `llm:mutation` / `doc:loaded`
41
+ // handlers below CALL `studio.applyMutation(...)`, so they throw
42
+ // `TypeError: studio.applyMutation is not a function` at runtime whenever an
43
+ // LLM mutation actually arrives over the bridge. This was completely invisible
44
+ // while the client lived as an un-typechecked string.
45
+ //
46
+ // Per the extraction's scope (pure move, no behavior change) we do NOT fix the
47
+ // runtime gap here — that would mean bumping the cli's studio dep to a version
48
+ // that ships the method (or switching it to `workspace:*`), which is a real
49
+ // behavior change. We only augment the TYPE so the call typechecks; the runtime
50
+ // call is left exactly as it was so the surfaced bug is preserved, not masked.
51
+ declare module "@a-company/atelier-studio" {
52
+ interface AtelierStudio {
53
+ applyMutation(op: {
54
+ type: "doc:replace";
55
+ doc: AtelierDoc;
56
+ source: "human" | "llm" | "system";
57
+ }): void;
58
+ }
59
+ }
60
+
61
+ /** Injected by the entry shim the studio command writes into its temp dir. */
62
+ export interface StudioAppConfig {
63
+ /** Path of the file to open on boot, or null to open the first file found. */
64
+ initialFile: string | null;
65
+ }
66
+
67
+ // ── Types ──
68
+ interface FileEntry {
69
+ path: string;
70
+ name: string;
71
+ folder: string;
72
+ }
73
+
74
+ /**
75
+ * Boot the in-browser Atelier Studio client. Wires up the file sidebar, the
76
+ * AtelierStudio editor + autosave, image drag-drop, the WS bridge, and the
77
+ * export-all flow. Called once by the temp-dir entry shim.
78
+ */
79
+ export function bootStudioApp(config: StudioAppConfig): void {
80
+ // ── State ──
81
+ let studio: AtelierStudio | null = null;
82
+ let currentFile: string | null = null;
83
+ let files: FileEntry[] = [];
84
+ let saveTimeout: ReturnType<typeof setTimeout> | null = null;
85
+ // The path + bytes the pending saveTimeout was scheduled for. Captured so a
86
+ // fired timer (and the flush-on-switch below) writes to the file it was
87
+ // scheduled for — NOT whatever currentFile becomes when the user switches
88
+ // files mid-debounce. Without this, editing A then opening B within the 800ms
89
+ // window wrote A's bytes to B's path: real .atelier corruption.
90
+ let pendingSavePath: string | null = null;
91
+ let pendingSaveContent: string | null = null;
92
+ let flushPendingSave: (() => void) | null = null;
93
+ let bridgeSocket: WebSocket | null = null;
94
+ // One-tick suppression: when the bridge delivers an LLM mutation we apply it
95
+ // via studio.applyMutation (which sets suppressNotify internally) — the
96
+ // resulting onDocumentChange callback won't fire, so we don't echo back.
97
+ // This flag is a backstop in case future studio refactors change that path.
98
+ let suppressOutbound = false;
99
+
100
+ // ── API helpers ──
101
+ async function fetchFiles(): Promise<FileEntry[]> {
102
+ const res = await fetch("/api/files");
103
+ return res.json();
104
+ }
105
+
106
+ async function fetchFileContent(path: string): Promise<string> {
107
+ const res = await fetch("/api/file?path=" + encodeURIComponent(path));
108
+ return res.text();
109
+ }
110
+
111
+ async function saveFileContent(path: string, content: string): Promise<void> {
112
+ const res = await fetch("/api/file?path=" + encodeURIComponent(path), {
113
+ method: "POST",
114
+ headers: { "Content-Type": "text/plain" },
115
+ body: content,
116
+ });
117
+ if (!res.ok) {
118
+ const message = (await res.text()) || ("HTTP " + res.status);
119
+ throw new Error(message);
120
+ }
121
+ }
122
+
123
+ // Build a minimal-but-non-empty starter document for a brand-new file.
124
+ // One centered text layer so the canvas isn't a confusing void; 1080x1080
125
+ // to match the social-square default the welcome template uses.
126
+ function newStarterDoc(title: string) {
127
+ return {
128
+ version: "1.0",
129
+ name: title,
130
+ canvas: { width: 1080, height: 1080, fps: 30, background: "#0F1115" },
131
+ layers: [
132
+ {
133
+ id: "title",
134
+ visual: {
135
+ type: "text",
136
+ content: title,
137
+ style: {
138
+ fontFamily: "Inter, system-ui, sans-serif",
139
+ fontSize: 72,
140
+ fontWeight: 600,
141
+ color: "#F5F0EB",
142
+ textAlign: "center",
143
+ },
144
+ },
145
+ frame: { x: 540, y: 540 },
146
+ bounds: { width: 900, height: 120 },
147
+ anchorPoint: { x: 0.5, y: 0.5 },
148
+ },
149
+ ],
150
+ states: { default: { duration: 60, deltas: [] } },
151
+ };
152
+ }
153
+
154
+ // Create a new .atelier file in the project root, then select it.
155
+ // The server's POST /api/file writes arbitrary safe paths, so a "new file"
156
+ // is just a write of a fresh starter doc followed by a file-list refresh.
157
+ async function createNewFile() {
158
+ const raw = window.prompt("New file name", "untitled.atelier");
159
+ if (raw === null) return; // user cancelled
160
+ let name = raw.trim();
161
+ if (!name) return;
162
+ if (!name.endsWith(".atelier")) name += ".atelier";
163
+ if (files.some((f) => f.path === name || f.name === name)) {
164
+ window.alert('A file named "' + name + '" already exists.');
165
+ return;
166
+ }
167
+ const baseName = name.replace(/\.atelier$/, "");
168
+ // The starter-doc literal's discriminant fields (visual.type, etc.) widen
169
+ // to `string`, so it isn't assignable to AtelierDocument as a bare literal.
170
+ // The bytes are validated immediately below via parseAtelier, so the cast is
171
+ // safe; this was simply never typechecked in the old string form.
172
+ const content = serializeAtelier(newStarterDoc(baseName) as Parameters<typeof serializeAtelier>[0]);
173
+ // Sanity-check the serialized doc round-trips before writing it to disk.
174
+ const parsed = parseAtelier(content);
175
+ if (!parsed.success) {
176
+ window.alert("Could not create file (internal schema error).");
177
+ return;
178
+ }
179
+ try {
180
+ await saveFileContent(name, content);
181
+ } catch (e) {
182
+ window.alert("Failed to create file: " + (e instanceof Error ? e.message : String(e)));
183
+ return;
184
+ }
185
+ files = await fetchFiles();
186
+ renderFileList();
187
+ await loadFile(name);
188
+ }
189
+
190
+ async function saveExportBlob(path: string, blob: Blob): Promise<void> {
191
+ const buf = await blob.arrayBuffer();
192
+ await fetch("/api/export?path=" + encodeURIComponent(path), {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/octet-stream" },
195
+ body: buf,
196
+ });
197
+ }
198
+
199
+ async function exportAll(format: "gif" | "mp4" | "webm"): Promise<void> {
200
+ if (files.length === 0) return;
201
+
202
+ // Create progress overlay
203
+ const overlay = document.createElement("div");
204
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.75);display:flex;align-items:center;justify-content:center;z-index:10000";
205
+ const card = document.createElement("div");
206
+ card.style.cssText = "background:#333;border:1px solid #4A4A4A;border-radius:8px;padding:32px 40px;min-width:360px;color:#F5F0EB;font-family:'Cormorant Garamond',Georgia,serif";
207
+ overlay.appendChild(card);
208
+ document.body.appendChild(overlay);
209
+
210
+ const title = document.createElement("div");
211
+ title.style.cssText = "font-size:18px;margin-bottom:16px;font-weight:600";
212
+ title.textContent = "Exporting All Files…";
213
+ card.appendChild(title);
214
+
215
+ const fileLabel = document.createElement("div");
216
+ fileLabel.style.cssText = "font-size:13px;color:#A89F95;margin-bottom:8px;font-family:'SF Mono','Fira Code',monospace";
217
+ card.appendChild(fileLabel);
218
+
219
+ const progress = document.createElement("progress");
220
+ progress.style.cssText = "width:100%;height:6px;appearance:none;-webkit-appearance:none";
221
+ progress.max = files.length;
222
+ progress.value = 0;
223
+ card.appendChild(progress);
224
+
225
+ const statusText = document.createElement("div");
226
+ statusText.style.cssText = "font-size:12px;color:#A89F95;margin-top:8px";
227
+ card.appendChild(statusText);
228
+
229
+ let exported = 0;
230
+ let errors = 0;
231
+
232
+ for (const file of files) {
233
+ fileLabel.textContent = file.path;
234
+ statusText.textContent = (exported + errors + 1) + " / " + files.length;
235
+
236
+ try {
237
+ const content = await fetchFileContent(file.path);
238
+ const result = parseAtelier(content);
239
+ if (!result.success) {
240
+ errors++;
241
+ progress.value = exported + errors;
242
+ continue;
243
+ }
244
+
245
+ const doc = result.data;
246
+ const w = doc.canvas.width;
247
+ const h = doc.canvas.height;
248
+ const canvas = document.createElement("canvas");
249
+ canvas.width = w;
250
+ canvas.height = h;
251
+ const imageCache = new ImageCache();
252
+
253
+ // TYPE-SKEW: parseAtelier returns the WORKSPACE AtelierDocument, while
254
+ // exportDocument (from @a-company/atelier-studio@0.26.1) expects the
255
+ // AtelierDocument pinned via studio's own atelier-types dep. The two are
256
+ // structurally identical at runtime (the workspace type merely adds the
257
+ // "video" AssetType), so the cast is safe. Invisible in the old string.
258
+ const exportResult = await exportDocument(
259
+ doc as Parameters<typeof exportDocument>[0],
260
+ canvas,
261
+ imageCache,
262
+ {
263
+ format,
264
+ onProgress: ({ percent }) => {
265
+ statusText.textContent = (exported + errors + 1) + " / " + files.length + " — " + percent + "%";
266
+ },
267
+ },
268
+ );
269
+
270
+ // Save alongside the source file: e.g. "dir/my-anim.atelier" → "dir/my-anim.gif"
271
+ const outPath = file.path.replace(/\.atelier$/, "." + format);
272
+ await saveExportBlob(outPath, exportResult.blob);
273
+ exported++;
274
+ } catch (e) {
275
+ console.error("Export failed:", file.path, e);
276
+ errors++;
277
+ }
278
+ progress.value = exported + errors;
279
+ }
280
+
281
+ // Done
282
+ title.textContent = "Export Complete";
283
+ fileLabel.textContent = "";
284
+ statusText.textContent = exported + " exported" + (errors > 0 ? ", " + errors + " failed" : "");
285
+ if (errors > 0) console.warn("Export All finished with " + errors + " error(s). Check console for details.");
286
+
287
+ const closeBtn = document.createElement("button");
288
+ closeBtn.style.cssText = "margin-top:16px;padding:6px 20px;background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;cursor:pointer;font-family:inherit;font-size:13px";
289
+ closeBtn.textContent = "Close";
290
+ closeBtn.addEventListener("click", () => document.body.removeChild(overlay));
291
+ card.appendChild(closeBtn);
292
+ }
293
+
294
+ // ── Theme (matches branded theme from showcase) ──
295
+ const theme = {
296
+ bg: "#2C2C2C",
297
+ bgSecondary: "#333333",
298
+ bgTertiary: "#3D3D3D",
299
+ text: "#F5F0EB",
300
+ textMuted: "#A89F95",
301
+ textAccent: "#F5F0EB",
302
+ border: "#4A4A4A",
303
+ buttonBg: "#3D3D3D",
304
+ buttonHover: "#4A4A4A",
305
+ buttonActive: "#555555",
306
+ accent: "#C75B39",
307
+ accentHover: "#D4724E",
308
+ sliderTrack: "#4A4A4A",
309
+ sliderThumb: "#C75B39",
310
+ fontFamily: "'Cormorant Garamond', Georgia, serif",
311
+ fontMono: "'SF Mono', 'Fira Code', monospace",
312
+ canvasShadow: "0 4px 60px rgba(199, 91, 57, 0.12), 0 0 40px rgba(0,0,0,0.4)",
313
+ };
314
+
315
+ // ── Styles ──
316
+ const style = document.createElement("style");
317
+ style.textContent = `
318
+ * { margin: 0; padding: 0; box-sizing: border-box; }
319
+ html, body { height: 100%; overflow: hidden; background: #2C2C2C; color: #F5F0EB; }
320
+ body { font-family: 'Cormorant Garamond', Georgia, serif; }
321
+ #studio { display: flex; height: 100vh; width: 100vw; }
322
+
323
+ .sidebar {
324
+ width: 260px;
325
+ min-width: 260px;
326
+ background: #333333;
327
+ border-right: 1px solid #4A4A4A;
328
+ display: flex;
329
+ flex-direction: column;
330
+ overflow: hidden;
331
+ }
332
+ .sidebar__header {
333
+ padding: 16px 20px;
334
+ border-bottom: 1px solid #4A4A4A;
335
+ font-size: 11px;
336
+ font-weight: 600;
337
+ letter-spacing: 2px;
338
+ text-transform: uppercase;
339
+ color: #A89F95;
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 8px;
343
+ }
344
+ .sidebar__header span {
345
+ color: #C75B39;
346
+ font-size: 13px;
347
+ }
348
+ .sidebar__list {
349
+ flex: 1;
350
+ overflow-y: auto;
351
+ padding: 8px 0;
352
+ }
353
+ .sidebar__list::-webkit-scrollbar { width: 6px; }
354
+ .sidebar__list::-webkit-scrollbar-track { background: transparent; }
355
+ .sidebar__list::-webkit-scrollbar-thumb { background: #4A4A4A; border-radius: 3px; }
356
+
357
+ .sidebar__folder {
358
+ padding: 10px 20px 4px;
359
+ font-size: 10px;
360
+ font-weight: 600;
361
+ letter-spacing: 1.5px;
362
+ text-transform: uppercase;
363
+ color: #A89F95;
364
+ }
365
+ .sidebar__item {
366
+ padding: 8px 20px 8px 28px;
367
+ font-size: 13px;
368
+ cursor: pointer;
369
+ color: #A89F95;
370
+ transition: background 0.15s, color 0.15s;
371
+ white-space: nowrap;
372
+ overflow: hidden;
373
+ text-overflow: ellipsis;
374
+ font-family: 'SF Mono', 'Fira Code', monospace;
375
+ font-size: 11.5px;
376
+ }
377
+ .sidebar__item:hover { background: #363636; color: #F5F0EB; }
378
+ .sidebar__item--active {
379
+ background: rgba(199, 91, 57, 0.12) !important;
380
+ color: #C75B39 !important;
381
+ }
382
+
383
+ .main {
384
+ flex: 1;
385
+ display: flex;
386
+ flex-direction: column;
387
+ overflow: hidden;
388
+ }
389
+ .main__status {
390
+ height: 32px;
391
+ min-height: 32px;
392
+ display: flex;
393
+ align-items: center;
394
+ padding: 0 16px;
395
+ background: #333333;
396
+ border-bottom: 1px solid #4A4A4A;
397
+ font-size: 11px;
398
+ color: #A89F95;
399
+ font-family: 'SF Mono', 'Fira Code', monospace;
400
+ gap: 12px;
401
+ }
402
+ .main__status .save-indicator {
403
+ display: inline-flex;
404
+ align-items: center;
405
+ gap: 4px;
406
+ margin-left: auto;
407
+ transition: opacity 0.3s;
408
+ }
409
+ .main__status .save-indicator--saving { color: #C75B39; }
410
+ .main__status .save-indicator--saved { color: #6B8E6B; }
411
+ .main__status .save-indicator--error {
412
+ color: #E25C5C;
413
+ cursor: pointer;
414
+ text-decoration: underline dotted;
415
+ }
416
+ .main__status .save-indicator--error:hover { color: #FF7777; }
417
+ .main__editor {
418
+ flex: 1;
419
+ overflow: hidden;
420
+ }
421
+ .main__empty {
422
+ flex: 1;
423
+ display: flex;
424
+ align-items: center;
425
+ justify-content: center;
426
+ color: #A89F95;
427
+ font-size: 18px;
428
+ }
429
+
430
+ /* ── Bridge toast (LLM mutation attribution) ── */
431
+ .atel-toast-host {
432
+ position: fixed;
433
+ right: 24px;
434
+ bottom: 24px;
435
+ z-index: 9999;
436
+ display: flex;
437
+ flex-direction: column;
438
+ gap: 8px;
439
+ pointer-events: none;
440
+ }
441
+ .atel-toast {
442
+ background: #333333;
443
+ border: 1px solid #C75B39;
444
+ color: #F5F0EB;
445
+ padding: 10px 14px;
446
+ border-radius: 6px;
447
+ font-family: 'SF Mono', 'Fira Code', monospace;
448
+ font-size: 12px;
449
+ box-shadow: 0 4px 16px rgba(0,0,0,0.5);
450
+ opacity: 0;
451
+ transform: translateY(8px);
452
+ transition: opacity 200ms ease, transform 200ms ease;
453
+ pointer-events: auto;
454
+ max-width: 360px;
455
+ }
456
+ .atel-toast--visible { opacity: 1; transform: translateY(0); }
457
+ .atel-toast__hint { color: #A89F95; margin-left: 6px; font-size: 11px; }
458
+ `;
459
+ document.head.appendChild(style);
460
+
461
+ // ── Build UI ──
462
+ const root = document.getElementById("studio")!;
463
+ const sidebar = document.createElement("div");
464
+ sidebar.className = "sidebar";
465
+
466
+ const sidebarHeader = document.createElement("div");
467
+ sidebarHeader.className = "sidebar__header";
468
+ sidebarHeader.style.cssText = "display:flex;align-items:center;justify-content:space-between";
469
+ const sidebarTitle = document.createElement("span");
470
+ sidebarTitle.innerHTML = '<span>&#9670;</span> ATELIER STUDIO';
471
+ sidebarHeader.appendChild(sidebarTitle);
472
+ const newFileBtn = document.createElement("button");
473
+ newFileBtn.textContent = "+ New";
474
+ newFileBtn.title = "Create a new .atelier file";
475
+ newFileBtn.style.cssText = "background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;padding:3px 10px;font-size:11px;font-family:inherit;cursor:pointer";
476
+ newFileBtn.addEventListener("click", () => { createNewFile(); });
477
+ sidebarHeader.appendChild(newFileBtn);
478
+ sidebar.appendChild(sidebarHeader);
479
+
480
+ const sidebarList = document.createElement("div");
481
+ sidebarList.className = "sidebar__list";
482
+ sidebar.appendChild(sidebarList);
483
+
484
+ const sidebarFooter = document.createElement("div");
485
+ sidebarFooter.style.cssText = "padding:12px 16px;border-top:1px solid #4A4A4A;display:flex;gap:8px;align-items:center";
486
+ const exportAllSelect = document.createElement("select");
487
+ exportAllSelect.style.cssText = "flex:1;background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;padding:4px 8px;font-size:11px;font-family:'SF Mono','Fira Code',monospace;cursor:pointer";
488
+ for (const [val, label] of [["gif", "GIF"], ["mp4", "MP4"], ["webm", "WebM"]] as const) {
489
+ const o = document.createElement("option");
490
+ o.value = val;
491
+ o.textContent = label;
492
+ exportAllSelect.appendChild(o);
493
+ }
494
+ sidebarFooter.appendChild(exportAllSelect);
495
+ const exportAllBtn = document.createElement("button");
496
+ exportAllBtn.style.cssText = "background:#C75B39;color:#F5F0EB;border:none;border-radius:4px;padding:5px 12px;font-size:11px;font-family:inherit;cursor:pointer;white-space:nowrap";
497
+ exportAllBtn.textContent = "Export All";
498
+ exportAllBtn.addEventListener("click", () => {
499
+ exportAll(exportAllSelect.value as "gif" | "mp4" | "webm");
500
+ });
501
+ sidebarFooter.appendChild(exportAllBtn);
502
+ sidebar.appendChild(sidebarFooter);
503
+
504
+ const main = document.createElement("div");
505
+ main.className = "main";
506
+
507
+ const statusBar = document.createElement("div");
508
+ statusBar.className = "main__status";
509
+ main.appendChild(statusBar);
510
+
511
+ const editorContainer = document.createElement("div");
512
+ editorContainer.className = "main__editor";
513
+ main.appendChild(editorContainer);
514
+
515
+ root.appendChild(sidebar);
516
+ root.appendChild(main);
517
+
518
+ // ── File list rendering ──
519
+ function renderFileList(): void {
520
+ sidebarList.innerHTML = "";
521
+ let lastFolder = "";
522
+
523
+ for (const file of files) {
524
+ if (file.folder && file.folder !== lastFolder) {
525
+ lastFolder = file.folder;
526
+ const folder = document.createElement("div");
527
+ folder.className = "sidebar__folder";
528
+ folder.textContent = file.folder;
529
+ sidebarList.appendChild(folder);
530
+ }
531
+
532
+ const item = document.createElement("div");
533
+ item.className = "sidebar__item" + (file.path === currentFile ? " sidebar__item--active" : "");
534
+ item.textContent = file.name;
535
+ item.title = file.path;
536
+ item.addEventListener("click", () => loadFile(file.path));
537
+ sidebarList.appendChild(item);
538
+ }
539
+ }
540
+
541
+ // ── Load a file into the studio ──
542
+ async function loadFile(path: string): Promise<void> {
543
+ // Flush any pending autosave to its OWN file BEFORE switching. This both
544
+ // (a) avoids dropping the user's unsaved edit to the outgoing file and
545
+ // (b) prevents the debounce timer from later firing and writing the old
546
+ // file's bytes to the newly-loaded file's path (.atelier corruption).
547
+ if (flushPendingSave) flushPendingSave();
548
+ if (saveTimeout) {
549
+ clearTimeout(saveTimeout);
550
+ saveTimeout = null;
551
+ }
552
+ pendingSavePath = null;
553
+ pendingSaveContent = null;
554
+ flushPendingSave = null;
555
+
556
+ currentFile = path;
557
+ renderFileList();
558
+
559
+ const content = await fetchFileContent(path);
560
+ const result = parseAtelier(content);
561
+
562
+ if (!result.success) {
563
+ editorContainer.innerHTML = "";
564
+ const err = document.createElement("div");
565
+ err.className = "main__empty";
566
+ err.style.flexDirection = "column";
567
+ err.style.gap = "8px";
568
+ err.innerHTML = '<div style="color:#C75B39">Parse Error</div><div style="font-size:13px;font-family:monospace">' +
569
+ result.errors.map(e => e.path + ": " + e.message).join("<br>") + "</div>";
570
+ editorContainer.appendChild(err);
571
+ return;
572
+ }
573
+
574
+ statusBar.innerHTML = '<span>' + path + '</span><span class="save-indicator save-indicator--saved">&#10003; saved</span>';
575
+
576
+ if (studio) {
577
+ studio.destroy();
578
+ studio = null;
579
+ }
580
+
581
+ // Set filename for export downloads (strip path and .atelier extension)
582
+ const baseName = path.split("/").pop()?.replace(/\.atelier$/, "") || null;
583
+
584
+ // Closure state for save status + retry affordance.
585
+ // Tracks the most recently-attempted payload so a retry click re-sends
586
+ // the same bytes (the user may have kept editing in the meantime, but
587
+ // they're retrying THE failed save).
588
+ let lastPendingContent: string | null = null;
589
+ let lastPendingPath: string | null = null;
590
+
591
+ function setSavedIndicator(): void {
592
+ const ind = statusBar.querySelector(".save-indicator") as HTMLElement | null;
593
+ if (!ind) return;
594
+ ind.className = "save-indicator save-indicator--saved";
595
+ ind.innerHTML = "&#10003; saved";
596
+ ind.title = "";
597
+ ind.onclick = null;
598
+ }
599
+
600
+ function setSavingIndicator(): void {
601
+ const ind = statusBar.querySelector(".save-indicator") as HTMLElement | null;
602
+ if (!ind) return;
603
+ ind.className = "save-indicator save-indicator--saving";
604
+ ind.innerHTML = "&#9679; saving...";
605
+ ind.title = "";
606
+ ind.onclick = null;
607
+ }
608
+
609
+ function setErrorIndicator(message: string): void {
610
+ const ind = statusBar.querySelector(".save-indicator") as HTMLElement | null;
611
+ if (!ind) return;
612
+ ind.className = "save-indicator save-indicator--error";
613
+ ind.innerHTML = "&#9888; Save failed: " + escapeHtml(message) + " (click to retry)";
614
+ ind.title = "Click to retry save";
615
+ ind.onclick = () => { retrySave(); };
616
+ }
617
+
618
+ function escapeHtml(s: string): string {
619
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
620
+ }
621
+
622
+ async function performSave(path: string, content: string): Promise<void> {
623
+ lastPendingPath = path;
624
+ lastPendingContent = content;
625
+ setSavingIndicator();
626
+ try {
627
+ await saveFileContent(path, content);
628
+ // Only clear error state if this save corresponds to the latest attempt
629
+ if (lastPendingPath === path && lastPendingContent === content) {
630
+ lastPendingContent = null;
631
+ lastPendingPath = null;
632
+ }
633
+ setSavedIndicator();
634
+ } catch (err) {
635
+ const msg = err instanceof Error ? err.message : String(err);
636
+ console.error("Save failed:", msg);
637
+ setErrorIndicator(msg);
638
+ }
639
+ }
640
+
641
+ async function retrySave(): Promise<void> {
642
+ if (!lastPendingPath || lastPendingContent === null) return;
643
+ await performSave(lastPendingPath, lastPendingContent);
644
+ }
645
+
646
+ studio = new AtelierStudio(editorContainer, {
647
+ mode: "full",
648
+ initialTab: "yaml",
649
+ allowSave: true,
650
+ onDocumentChange: (doc) => {
651
+ // Auto-save with debounce — flip to "saving" immediately so the user
652
+ // knows their edit was registered even while the timeout is pending.
653
+ setSavingIndicator();
654
+
655
+ // Capture the target path + bytes NOW so the timer writes to the file
656
+ // this edit belongs to, regardless of what currentFile becomes later.
657
+ const targetPath = currentFile;
658
+ const yaml = serializeAtelier(doc);
659
+ pendingSavePath = targetPath;
660
+ pendingSaveContent = yaml;
661
+
662
+ // Register the synchronous-ish flush used by loadFile on a file switch:
663
+ // it fires the pending save to its OWN path (fetch is async, but the
664
+ // captured path guarantees correctness) and clears the timer.
665
+ flushPendingSave = () => {
666
+ if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null; }
667
+ if (pendingSavePath && pendingSaveContent !== null) {
668
+ void performSave(pendingSavePath, pendingSaveContent);
669
+ }
670
+ pendingSavePath = null;
671
+ pendingSaveContent = null;
672
+ };
673
+
674
+ if (saveTimeout) clearTimeout(saveTimeout);
675
+ saveTimeout = setTimeout(() => {
676
+ saveTimeout = null;
677
+ if (!targetPath) return;
678
+ pendingSavePath = null;
679
+ pendingSaveContent = null;
680
+ void performSave(targetPath, yaml);
681
+ }, 800);
682
+
683
+ // Bridge: send the human edit so any connected MCP client (or future
684
+ // bridge listeners) sees the doc as-edited. The server marks this as
685
+ // source:"human" so the bridge does NOT echo back.
686
+ if (!suppressOutbound && bridgeSocket && bridgeSocket.readyState === WebSocket.OPEN && currentFile) {
687
+ try {
688
+ bridgeSocket.send(JSON.stringify({
689
+ type: "doc:patch",
690
+ documentId: currentFile,
691
+ doc,
692
+ source: "human",
693
+ }));
694
+ } catch {
695
+ // Bridge transport failure is non-fatal — autosave still covers persistence.
696
+ }
697
+ }
698
+ },
699
+ });
700
+ studio.setTheme(theme);
701
+ studio.setFilename(baseName);
702
+ // TYPE-SKEW (see exportAll above): workspace vs studio-pinned AtelierDocument
703
+ // are structurally identical at runtime; the cast bridges the nominal gap.
704
+ studio.loadDocument(result.data as Parameters<AtelierStudio["loadDocument"]>[0]);
705
+ }
706
+
707
+ // ── Toast (LLM mutation attribution) ──
708
+ const toastHost = document.createElement("div");
709
+ toastHost.className = "atel-toast-host";
710
+ document.body.appendChild(toastHost);
711
+
712
+ function showToast(message: string, hint?: string): void {
713
+ const toast = document.createElement("div");
714
+ toast.className = "atel-toast";
715
+ toast.textContent = message;
716
+ if (hint) {
717
+ const span = document.createElement("span");
718
+ span.className = "atel-toast__hint";
719
+ span.textContent = hint;
720
+ toast.appendChild(span);
721
+ }
722
+ toastHost.appendChild(toast);
723
+ // Trigger transition next frame.
724
+ requestAnimationFrame(() => { toast.classList.add("atel-toast--visible"); });
725
+ setTimeout(() => {
726
+ toast.classList.remove("atel-toast--visible");
727
+ setTimeout(() => { toast.remove(); }, 250);
728
+ }, 3200);
729
+ }
730
+
731
+ // ── Bridge connection (LLM ↔ human live mutation loop) ──
732
+ //
733
+ // Manual test:
734
+ // 1. Run `atelier studio` in a directory with a .atelier file.
735
+ // 2. In another terminal, run an MCP client (e.g. Claude Desktop with the
736
+ // atelier-mcp server configured to connect to ws://127.0.0.1:<port>/mcp).
737
+ // 3. Ask the LLM to add a layer, change a color, etc.
738
+ // 4. Watch the studio canvas update in real time and a toast appear in
739
+ // the bottom-right reading "agent edited document (<tool>) — Cmd-Z to undo".
740
+ // 5. Hit Cmd-Z; the prior state returns. The history snapshot was made
741
+ // by studio.applyMutation() (see Builder F's commit 0219d28).
742
+ //
743
+ // Playwright e2e coverage is task #15.3 — deferred per the testing strategy.
744
+ function connectBridge(): void {
745
+ if (bridgeSocket && bridgeSocket.readyState !== WebSocket.CLOSED) return;
746
+ const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/bridge";
747
+ try {
748
+ bridgeSocket = new WebSocket(url);
749
+ } catch (err) {
750
+ console.warn("Bridge connect failed:", err);
751
+ return;
752
+ }
753
+
754
+ bridgeSocket.addEventListener("message", (ev) => {
755
+ let env: BridgeMessage;
756
+ try { env = JSON.parse(ev.data); } catch { return; }
757
+
758
+ if (env.type === "hello") {
759
+ // Logged for parity with the server's clientId — useful when debugging.
760
+ console.debug("[bridge] connected as", env.clientId, "protocol v" + env.protocolVersion);
761
+ return;
762
+ }
763
+
764
+ if (env.type === "doc:loaded") {
765
+ // The bridge sent us its current doc on connect. Only apply if we
766
+ // either have no doc loaded yet OR the bridge's doc id matches what
767
+ // we're showing — otherwise the user just switched files in the
768
+ // sidebar and the bridge will catch up via /api/file.
769
+ if (studio && currentFile === env.documentId) {
770
+ suppressOutbound = true;
771
+ try { studio.applyMutation({ type: "doc:replace", doc: env.doc, source: "system" }); }
772
+ finally { queueMicrotask(() => { suppressOutbound = false; }); }
773
+ }
774
+ return;
775
+ }
776
+
777
+ if (env.type === "llm:mutation") {
778
+ if (studio && currentFile === env.documentId) {
779
+ suppressOutbound = true;
780
+ try {
781
+ studio.applyMutation({ type: "doc:replace", doc: env.doc, source: env.source ?? "llm" });
782
+ } finally {
783
+ queueMicrotask(() => { suppressOutbound = false; });
784
+ }
785
+ const tool = env.toolName ? "(" + env.toolName + ")" : "(MCP tool)";
786
+ showToast("agent edited document " + tool, " — Cmd-Z to undo");
787
+ }
788
+ return;
789
+ }
790
+
791
+ if (env.type === "error") {
792
+ console.warn("[bridge] error:", env.code, env.message);
793
+ return;
794
+ }
795
+ });
796
+
797
+ bridgeSocket.addEventListener("close", () => {
798
+ // Auto-reconnect after a short delay so the studio survives transient
799
+ // dev-server reloads. Bounded retry would be nice in v1.1.
800
+ setTimeout(connectBridge, 1000);
801
+ });
802
+
803
+ bridgeSocket.addEventListener("error", () => {
804
+ // close handler will fire next and trigger the reconnect.
805
+ });
806
+ }
807
+
808
+ // ── Boot ──
809
+ async function boot(): Promise<void> {
810
+ files = await fetchFiles();
811
+
812
+ if (files.length === 0) {
813
+ editorContainer.innerHTML = "";
814
+ const empty = document.createElement("div");
815
+ empty.className = "main__empty";
816
+ empty.style.flexDirection = "column";
817
+ empty.style.gap = "10px";
818
+ empty.style.textAlign = "center";
819
+ empty.style.padding = "40px";
820
+ empty.innerHTML = '<div style="font-size:16px;color:#F5F0EB">No .atelier files yet</div>' +
821
+ '<div style="font-size:13px;color:#A89F95">Create a new document, or drop a .atelier file anywhere on this window.</div>';
822
+ const newBtn = document.createElement("button");
823
+ newBtn.textContent = "+ New document";
824
+ newBtn.style.cssText = "background:#C75B39;color:#F5F0EB;border:none;border-radius:4px;padding:8px 18px;font-size:13px;font-family:inherit;cursor:pointer;margin-top:6px";
825
+ newBtn.addEventListener("click", () => { createNewFile(); });
826
+ empty.appendChild(newBtn);
827
+ editorContainer.appendChild(empty);
828
+ statusBar.textContent = "No files";
829
+ renderFileList();
830
+ return;
831
+ }
832
+
833
+ renderFileList();
834
+
835
+ const initialFile = config.initialFile;
836
+ const target = initialFile
837
+ ? files.find(f => f.path === initialFile || f.path.endsWith(initialFile))
838
+ : files[0];
839
+
840
+ if (target) {
841
+ await loadFile(target.path);
842
+ }
843
+ }
844
+
845
+ boot();
846
+ connectBridge();
847
+ }
848
+
849
+ /**
850
+ * Shape of the bridge WS messages the client receives, as a discriminated
851
+ * union on `type`. Mirrors the server-side `BridgeEnvelope` union (declared in
852
+ * @a-company/atelier-mcp) for the subset the client handles. In the old string
853
+ * form this was `any` — exactly the kind of un-typed wire surface this
854
+ * extraction exists to eliminate. Each variant carries only the fields the
855
+ * matching handler reads.
856
+ */
857
+ type BridgeMessage =
858
+ | { type: "hello"; clientId?: string; protocolVersion?: number | string }
859
+ | { type: "doc:loaded"; documentId: string; doc: AtelierDoc }
860
+ | {
861
+ type: "llm:mutation";
862
+ documentId: string;
863
+ doc: AtelierDoc;
864
+ source?: "human" | "llm" | "system";
865
+ toolName?: string;
866
+ }
867
+ | { type: "error"; code?: string; message?: string };