@a-company/atelier 0.36.0 → 0.38.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 (99) hide show
  1. package/dist/{chunk-JPZ4F4PW.js → chunk-3ARBOSWY.js} +64 -5
  2. package/dist/chunk-3ARBOSWY.js.map +1 -0
  3. package/dist/cli.js +11469 -413
  4. package/dist/cli.js.map +1 -1
  5. package/dist/{dist-M67UZGFQ.js → dist-3YQK6PI6.js} +2 -2
  6. package/dist/index.cjs +3193 -227
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +701 -8
  9. package/dist/index.d.ts +701 -8
  10. package/dist/index.js +7237 -72
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp.js +2898 -507
  13. package/dist/mcp.js.map +1 -1
  14. package/package.json +14 -9
  15. package/src/web/inline-app.ts +55 -4
  16. package/src/web/timeline-state-types.ts +28 -0
  17. package/src/web/timeline-view.test.ts +99 -0
  18. package/src/web/timeline-view.ts +339 -0
  19. package/src/web/workspace-app.ts +3146 -0
  20. package/templates/workspace/.claude/agents/atelier-iris.md +75 -0
  21. package/templates/workspace/.claude/agents/atelier-lux.md +67 -0
  22. package/templates/workspace/.claude/agents/atelier-quill.md +61 -0
  23. package/templates/workspace/.gitignore +30 -0
  24. package/templates/workspace/.paradigm/personas/_shared/cascade-merge.md +172 -0
  25. package/templates/workspace/CLAUDE.md +93 -0
  26. package/templates/workspace/README.md +75 -0
  27. package/templates/workspace/SETUP.md +127 -0
  28. package/templates/workspace/_brand/.atelier-brand.yaml +34 -0
  29. package/templates/workspace/_brand/DESIGN.md +56 -0
  30. package/templates/workspace/_brand/SCRIPT.md +41 -0
  31. package/templates/workspace/_brand/STORYBOARD.md +33 -0
  32. package/templates/workspace/_packs/README.md +54 -0
  33. package/templates/workspace/projects/README.md +49 -0
  34. package/templates/workspace/workspace.atelier +22 -0
  35. package/university/content/notes/N-atel-001-first-render.md +114 -0
  36. package/university/content/notes/N-atel-001-install-and-launch.md +84 -0
  37. package/university/content/notes/N-atel-001-what-is-atelier.md +51 -0
  38. package/university/content/notes/N-atel-101-easings.md +97 -0
  39. package/university/content/notes/N-atel-101-layers.md +106 -0
  40. package/university/content/notes/N-atel-101-states-and-deltas.md +94 -0
  41. package/university/content/notes/N-atel-101-the-atelier-format.md +72 -0
  42. package/university/content/notes/N-atel-201-authoring-tools.md +141 -0
  43. package/university/content/notes/N-atel-201-mcp-overview.md +86 -0
  44. package/university/content/notes/N-atel-201-patterns.md +108 -0
  45. package/university/content/notes/N-atel-201-visual-and-effects.md +125 -0
  46. package/university/content/notes/N-atel-301-composition-and-overlays.md +141 -0
  47. package/university/content/notes/N-atel-301-effects.md +136 -0
  48. package/university/content/notes/N-atel-301-images-and-video.md +126 -0
  49. package/university/content/notes/N-atel-301-shapes-and-text.md +118 -0
  50. package/university/content/notes/N-atel-401-hierarchical-states.md +71 -0
  51. package/university/content/notes/N-atel-401-motion-deep-dive.md +106 -0
  52. package/university/content/notes/N-atel-401-presets-and-templates.md +98 -0
  53. package/university/content/notes/N-atel-401-transitions.md +94 -0
  54. package/university/content/notes/N-atel-501-detected-vs-user-edited.md +76 -0
  55. package/university/content/notes/N-atel-501-layer-tag-isolation.md +62 -0
  56. package/university/content/notes/N-atel-501-silence-trim.md +98 -0
  57. package/university/content/notes/N-atel-501-transcribe-and-captions.md +98 -0
  58. package/university/content/notes/N-atel-601-carousel.md +71 -0
  59. package/university/content/notes/N-atel-601-overlay-rules.md +96 -0
  60. package/university/content/notes/N-atel-601-recipe-tools-and-apply.md +84 -0
  61. package/university/content/notes/N-atel-601-studio-recipe.md +103 -0
  62. package/university/content/notes/N-atel-701-choosing-output.md +68 -0
  63. package/university/content/notes/N-atel-701-png-and-frames.md +84 -0
  64. package/university/content/notes/N-atel-701-vector.md +85 -0
  65. package/university/content/notes/N-atel-701-video.md +88 -0
  66. package/university/content/notes/N-atel-801-editing-surface.md +69 -0
  67. package/university/content/notes/N-atel-801-live-bridge.md +84 -0
  68. package/university/content/notes/N-atel-801-studio-app.md +72 -0
  69. package/university/content/notes/N-atel-801-symbiotic-loop.md +56 -0
  70. package/university/content/paths/LP-atel-001.yaml +21 -0
  71. package/university/content/paths/LP-atel-101.yaml +22 -0
  72. package/university/content/paths/LP-atel-201.yaml +23 -0
  73. package/university/content/paths/LP-atel-301.yaml +22 -0
  74. package/university/content/paths/LP-atel-401.yaml +22 -0
  75. package/university/content/paths/LP-atel-501.yaml +22 -0
  76. package/university/content/paths/LP-atel-601.yaml +22 -0
  77. package/university/content/paths/LP-atel-701.yaml +22 -0
  78. package/university/content/paths/LP-atel-801.yaml +22 -0
  79. package/university/content/quizzes/Q-atel-001-orientation.yaml +66 -0
  80. package/university/content/quizzes/Q-atel-101-document-model.yaml +66 -0
  81. package/university/content/quizzes/Q-atel-201-mcp-authoring.yaml +66 -0
  82. package/university/content/quizzes/Q-atel-301-visual-system.yaml +66 -0
  83. package/university/content/quizzes/Q-atel-401-state-machines.yaml +66 -0
  84. package/university/content/quizzes/Q-atel-501-video-pipeline.yaml +66 -0
  85. package/university/content/quizzes/Q-atel-601-recipes.yaml +66 -0
  86. package/university/content/quizzes/Q-atel-701-export.yaml +66 -0
  87. package/university/content/quizzes/Q-atel-801-studio-loop.yaml +66 -0
  88. package/university/index.yaml +720 -0
  89. package/university/pack.yaml +21 -0
  90. package/dist/chunk-5QQESXI6.js +0 -4432
  91. package/dist/chunk-5QQESXI6.js.map +0 -1
  92. package/dist/chunk-JPZ4F4PW.js.map +0 -1
  93. package/dist/cli.cjs +0 -6313
  94. package/dist/cli.cjs.map +0 -1
  95. package/dist/cli.d.cts +0 -1
  96. package/dist/cli.d.ts +0 -1
  97. package/dist/mcp.cjs +0 -5462
  98. package/dist/mcp.cjs.map +0 -1
  99. /package/dist/{dist-M67UZGFQ.js.map → dist-3YQK6PI6.js.map} +0 -0
@@ -0,0 +1,3146 @@
1
+ /**
2
+ * #studio-workspace-app — the workspace-mode UI for the in-browser Atelier
3
+ * Studio. Runs alongside the legacy single-document app in `inline-app.ts`;
4
+ * `bootStudioApp` picks between them based on whether the server reports a
5
+ * workspace context.
6
+ *
7
+ * Architecture: vanilla TS + DOM (matches the existing studio panels).
8
+ * No React. No new framework. All UI elements are atel-prefixed.
9
+ *
10
+ * Panels live in this file because they're studio-session-only (the CLI's
11
+ * served session, not the published `@a-company/atelier-studio` package).
12
+ * Putting them inside the studio package would require a separate publish
13
+ * + bump cycle — keeping them here keeps the v1 UX bundle in one place.
14
+ *
15
+ * Panels:
16
+ * - WorkspaceSidebar — projects + artifacts + media + transcripts tree.
17
+ * - MarkdownView — read/edit DESIGN.md / SCRIPT.md / STORYBOARD.md.
18
+ * - MediaPreview — video / image / audio playback with metadata.
19
+ * - TranscriptViewer — formatted transcript with per-word timing.
20
+ * - DropZone — full-window drag-and-drop overlay for media ingestion.
21
+ */
22
+
23
+ import { AtelierStudio } from "@a-company/atelier-studio";
24
+ import "@a-company/atelier-studio/styles.css";
25
+ import { parseAtelier, serializeAtelier } from "@a-company/atelier-schema";
26
+
27
+ // ── v1.0 bottom timeline (Kit-B) ──
28
+ import { TimelineView } from "./timeline-view.js";
29
+ import type { TimelineSelection } from "./timeline-state-types.js";
30
+ // ── end v1.0 bottom timeline (Kit-B) ──
31
+
32
+ // ── v1.0 sidebar drag (Kit-D) ──
33
+ import { type MediaRefPayload, isValidMediaRefPath } from "../lib/ingest-bin-types.js";
34
+ // ── end v1.0 sidebar drag (Kit-D) ──
35
+
36
+ type AtelierDoc = import("@a-company/atelier-types").AtelierDocument;
37
+
38
+ // ─── Server-side type shapes (mirrors `workspace-project-info.ts`) ──
39
+ // Kept narrow — the studio reads the fields it uses; new fields are ignored.
40
+
41
+ interface WorkspaceInfo {
42
+ name: string;
43
+ workspaceDir: string;
44
+ projects: WorkspaceProjectEntry[];
45
+ }
46
+
47
+ interface WorkspaceProjectEntry {
48
+ name: string;
49
+ dir: string;
50
+ hasAtelierDoc: boolean;
51
+ hasOtherAtelierDocs: boolean;
52
+ hasDesign: boolean;
53
+ hasScript: boolean;
54
+ hasStoryboard: boolean;
55
+ }
56
+
57
+ interface ActiveProjectFile {
58
+ name: string;
59
+ set_at: string;
60
+ stale?: boolean;
61
+ }
62
+
63
+ interface ProjectMediaEntry {
64
+ name: string;
65
+ kind: "video" | "image" | "audio" | "font" | "other";
66
+ path: string;
67
+ sizeBytes: number;
68
+ hasTranscript: boolean;
69
+ /**
70
+ * Workspace-relative path of the transcript file backing this media entry,
71
+ * or null when none exists. Used by MediaPreview's split layout to fetch
72
+ * the transcript without re-deriving the filename from `name`.
73
+ */
74
+ transcriptPath: string | null;
75
+ hasNotes: boolean;
76
+ isRootSource: boolean;
77
+ }
78
+
79
+ interface ProjectTranscriptEntry {
80
+ path: string;
81
+ name: string;
82
+ segments: number | null;
83
+ totalDuration: number | null;
84
+ language: string | null;
85
+ }
86
+
87
+ interface ProjectArtifactsInfo {
88
+ design: ArtifactEntry;
89
+ script: ArtifactEntry;
90
+ storyboard: ArtifactEntry;
91
+ }
92
+
93
+ /**
94
+ * Mirrors `DocListEntry` in `lib/doc-management-types.ts`. Kept narrow — the
95
+ * sidebar reads only the fields it renders; new server fields stay optional.
96
+ *
97
+ * Post-pivot (Wave 1+): the role/slideIndex/slideLabel fields are gone —
98
+ * every doc is a flat root-level `.atelier` file; carousel structure lives
99
+ * inside the carousel doc's `slides[]` (handled by Kit-K's carousel toolbar,
100
+ * not the sidebar).
101
+ */
102
+ interface DocListEntry {
103
+ path: string;
104
+ kind?: "video" | "image" | "carousel";
105
+ }
106
+
107
+ /** Response body for DELETE /api/project/:name/docs?path=... */
108
+ interface DocDeleteCascadeReport {
109
+ docPath: string;
110
+ slideRefsRemoved: Array<{ project: string; slideIndex: number }>;
111
+ refLayersDangling: Array<{ project: string; docPath: string; layerId: string }>;
112
+ }
113
+
114
+ interface ArtifactEntry {
115
+ path: string;
116
+ exists: boolean;
117
+ mtime: string | null;
118
+ sizeBytes: number | null;
119
+ }
120
+
121
+ /** Branded theme — matches inline-app.ts for visual consistency. */
122
+ const theme = {
123
+ bg: "#2C2C2C",
124
+ bgSecondary: "#333333",
125
+ bgTertiary: "#3D3D3D",
126
+ text: "#F5F0EB",
127
+ textMuted: "#A89F95",
128
+ textAccent: "#F5F0EB",
129
+ border: "#4A4A4A",
130
+ buttonBg: "#3D3D3D",
131
+ buttonHover: "#4A4A4A",
132
+ buttonActive: "#555555",
133
+ accent: "#C75B39",
134
+ accentHover: "#D4724E",
135
+ sliderTrack: "#4A4A4A",
136
+ sliderThumb: "#C75B39",
137
+ // Inter — variable UI sans with high legibility at 12-15px sizes (replaces
138
+ // Cormorant Garamond, a beautiful editorial serif that lost too much detail
139
+ // in dense UI chrome per owner feedback 2026-05-28). Inter is loaded via
140
+ // Google Fonts in getInlineHTML().
141
+ fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
142
+ fontMono: "'SF Mono', 'Fira Code', monospace",
143
+ canvasShadow: "0 4px 60px rgba(199, 91, 57, 0.12), 0 0 40px rgba(0,0,0,0.4)",
144
+ };
145
+
146
+ // ─── API helpers ─────────────────────────────────────────────────────
147
+
148
+ async function fetchJson<T>(path: string): Promise<T> {
149
+ const res = await fetch(path);
150
+ if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
151
+ return res.json() as Promise<T>;
152
+ }
153
+
154
+ async function fetchText(path: string): Promise<string> {
155
+ const res = await fetch(path);
156
+ if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
157
+ return res.text();
158
+ }
159
+
160
+ async function postJson<T>(path: string, body: unknown): Promise<T> {
161
+ const res = await fetch(path, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify(body),
165
+ });
166
+ if (!res.ok) {
167
+ const msg = await res.text();
168
+ throw new Error(msg || `HTTP ${res.status}`);
169
+ }
170
+ return res.json() as Promise<T>;
171
+ }
172
+
173
+ async function saveText(path: string, content: string): Promise<void> {
174
+ const res = await fetch("/api/file?path=" + encodeURIComponent(path), {
175
+ method: "POST",
176
+ headers: { "Content-Type": "text/plain" },
177
+ body: content,
178
+ });
179
+ if (!res.ok) {
180
+ const msg = await res.text();
181
+ throw new Error(msg || `HTTP ${res.status}`);
182
+ }
183
+ }
184
+
185
+ /** DELETE helper — throws on non-2xx; returns parsed JSON body. */
186
+ async function deleteJson<T>(path: string): Promise<T> {
187
+ const res = await fetch(path, { method: "DELETE" });
188
+ if (!res.ok) {
189
+ const msg = await res.text();
190
+ throw new Error(msg || `HTTP ${res.status}`);
191
+ }
192
+ return res.json() as Promise<T>;
193
+ }
194
+
195
+ // ─── #studio-workspace-sidebar ───────────────────────────────────────
196
+
197
+ interface SidebarCallbacks {
198
+ onSelectProject: (name: string) => void;
199
+ onSelectArtifact: (projectName: string, kind: "design" | "script" | "storyboard") => void;
200
+ /**
201
+ * Select any .atelier doc (including `project.atelier`) inside the active
202
+ * project. Loads `<projectName>/<entry.path>` into the canvas.
203
+ */
204
+ onSelectDoc: (projectName: string, entry: DocListEntry) => void;
205
+ onSelectMedia: (projectName: string, entry: ProjectMediaEntry) => void;
206
+ onSelectTranscript: (projectName: string, entry: ProjectTranscriptEntry) => void;
207
+ onCreateProject: () => void;
208
+ /** "+ new doc" button on the DOCS header row. Opens the NewDocDialog. */
209
+ onCreateDoc: (projectName: string) => void;
210
+ /** Right-click → Duplicate on any doc row. */
211
+ onDuplicateDoc: (projectName: string, entry: DocListEntry) => void;
212
+ /** Right-click → Delete on any doc row. Cascade-preview confirm. */
213
+ onDeleteDoc: (projectName: string, entry: DocListEntry) => void;
214
+ /** Right-click → Delete on a project row. Triggers the type-to-confirm modal. */
215
+ onDeleteProject: (name: string) => void;
216
+ /** Right-click → Delete on a media row. Triggers the cascade-preview modal. */
217
+ onDeleteMedia: (projectName: string, entry: ProjectMediaEntry) => void;
218
+ /** Right-click → Delete on a transcript row. Standalone delete; media intact. */
219
+ onDeleteTranscript: (projectName: string, entry: ProjectTranscriptEntry) => void;
220
+ /**
221
+ * Right-click → Delete on an artifact row (DESIGN/SCRIPT/STORYBOARD).
222
+ * Single-file delete via /api/file — sidecar to nothing.
223
+ */
224
+ onDeleteArtifact: (projectName: string, kind: "design" | "script" | "storyboard", path: string) => void;
225
+ }
226
+
227
+ class WorkspaceSidebar {
228
+ readonly el: HTMLElement;
229
+ private listEl: HTMLElement;
230
+ private headerEl: HTMLElement;
231
+ private workspace: WorkspaceInfo | null = null;
232
+ private active: ActiveProjectFile | null = null;
233
+ /**
234
+ * Path of the currently-loaded doc within the active project (e.g.
235
+ * "project.atelier" or "intro-scene.atelier"). Drives the "← loaded" marker
236
+ * on the matching doc row. `null` when nothing's loaded.
237
+ */
238
+ private currentDocPath: string | null = null;
239
+ /** Per-project loaded detail (media/transcripts/artifacts/docs) — lazily filled. */
240
+ private detail = new Map<string, {
241
+ media: ProjectMediaEntry[];
242
+ transcripts: ProjectTranscriptEntry[];
243
+ artifacts: ProjectArtifactsInfo | null;
244
+ docs: DocListEntry[];
245
+ }>();
246
+
247
+ constructor(parent: HTMLElement, private cb: SidebarCallbacks) {
248
+ this.el = document.createElement("div");
249
+ this.el.className = "atel-workspace-sidebar";
250
+
251
+ this.headerEl = document.createElement("div");
252
+ this.headerEl.className = "atel-workspace-sidebar__header";
253
+ this.el.appendChild(this.headerEl);
254
+
255
+ this.listEl = document.createElement("div");
256
+ this.listEl.className = "atel-workspace-sidebar__list";
257
+ this.el.appendChild(this.listEl);
258
+
259
+ parent.appendChild(this.el);
260
+ }
261
+
262
+ async refresh(): Promise<void> {
263
+ try {
264
+ this.workspace = await fetchJson<WorkspaceInfo>("/api/workspace");
265
+ } catch {
266
+ this.renderError("Failed to load workspace.");
267
+ return;
268
+ }
269
+ try {
270
+ this.active = await fetchJson<ActiveProjectFile | null>("/api/workspace/active");
271
+ } catch {
272
+ this.active = null;
273
+ }
274
+
275
+ // Preload detail for the active project so the tree renders fully on
276
+ // first paint (avoids a flash of an empty active project).
277
+ if (this.active && this.workspace) {
278
+ const proj = this.workspace.projects.find((p) => p.name === this.active!.name);
279
+ if (proj) await this.loadDetail(proj.name);
280
+ }
281
+
282
+ this.render();
283
+ }
284
+
285
+ /** Trigger a refresh of one project's media / transcripts (post-ingest). */
286
+ async refreshProject(name: string): Promise<void> {
287
+ await this.loadDetail(name);
288
+ this.render();
289
+ }
290
+
291
+ private async loadDetail(name: string): Promise<void> {
292
+ try {
293
+ const [media, transcripts, artifacts, docs] = await Promise.all([
294
+ fetchJson<ProjectMediaEntry[]>(`/api/project/${encodeURIComponent(name)}/media`),
295
+ fetchJson<ProjectTranscriptEntry[]>(`/api/project/${encodeURIComponent(name)}/transcripts`),
296
+ fetchJson<ProjectArtifactsInfo>(`/api/project/${encodeURIComponent(name)}/artifacts`),
297
+ fetchJson<DocListEntry[]>(`/api/project/${encodeURIComponent(name)}/docs`),
298
+ ]);
299
+ this.detail.set(name, { media, transcripts, artifacts, docs });
300
+ } catch {
301
+ // Keep what we had on failure.
302
+ }
303
+ }
304
+
305
+ /** Public accessor for the currently-cached doc list (used for fallback logic). */
306
+ getDocs(name: string): DocListEntry[] {
307
+ return this.detail.get(name)?.docs ?? [];
308
+ }
309
+
310
+ /**
311
+ * Update the "← loaded" marker on the doc row matching `path`. Pass `null`
312
+ * to clear the marker (e.g. when a non-doc panel — media / transcript /
313
+ * markdown — is open in the main panel).
314
+ */
315
+ setCurrentDocPath(path: string | null): void {
316
+ if (this.currentDocPath === path) return;
317
+ this.currentDocPath = path;
318
+ this.render();
319
+ }
320
+
321
+ private renderError(msg: string): void {
322
+ this.listEl.innerHTML = "";
323
+ const err = document.createElement("div");
324
+ err.style.cssText = "padding:24px;color:#C75B39;font-size:13px";
325
+ err.textContent = msg;
326
+ this.listEl.appendChild(err);
327
+ }
328
+
329
+ private render(): void {
330
+ if (!this.workspace) return;
331
+
332
+ this.headerEl.innerHTML = "";
333
+ const title = document.createElement("div");
334
+ title.className = "atel-workspace-sidebar__title";
335
+ title.innerHTML = `<span style="color:#C75B39">&#9670;</span> WORKSPACE: ${escapeHtml(this.workspace.name)}`;
336
+ this.headerEl.appendChild(title);
337
+
338
+ const projectsLabel = document.createElement("div");
339
+ projectsLabel.className = "atel-workspace-sidebar__section";
340
+ projectsLabel.innerHTML = `<span>Projects</span>`;
341
+
342
+ const newBtn = document.createElement("button");
343
+ newBtn.className = "atel-workspace-sidebar__new-btn";
344
+ newBtn.textContent = "+ new";
345
+ newBtn.addEventListener("click", () => this.cb.onCreateProject());
346
+ projectsLabel.appendChild(newBtn);
347
+ this.headerEl.appendChild(projectsLabel);
348
+
349
+ // Project list
350
+ this.listEl.innerHTML = "";
351
+ if (this.workspace.projects.length === 0) {
352
+ const empty = document.createElement("div");
353
+ empty.style.cssText = "padding:16px 24px;color:#A89F95;font-size:12px";
354
+ empty.textContent = "No projects yet — click `+ new` to create one.";
355
+ this.listEl.appendChild(empty);
356
+ return;
357
+ }
358
+
359
+ for (const proj of this.workspace.projects) {
360
+ this.listEl.appendChild(this.renderProject(proj));
361
+ }
362
+ }
363
+
364
+ private renderProject(proj: WorkspaceProjectEntry): HTMLElement {
365
+ const isActive = this.active?.name === proj.name;
366
+ const wrapper = document.createElement("div");
367
+ wrapper.className = "atel-workspace-sidebar__project" +
368
+ (isActive ? " atel-workspace-sidebar__project--active" : "");
369
+
370
+ const header = document.createElement("div");
371
+ header.className = "atel-workspace-sidebar__project-header";
372
+ header.innerHTML = `<span class="atel-tree-arrow">${isActive ? "▾" : "▸"}</span> <span class="atel-project-name">${escapeHtml(proj.name)}</span>` +
373
+ (isActive ? '<span class="atel-active-marker"> ← active</span>' : "");
374
+ header.addEventListener("click", () => this.cb.onSelectProject(proj.name));
375
+ header.addEventListener("contextmenu", (ev) => {
376
+ ev.preventDefault();
377
+ showContextMenu(ev.clientX, ev.clientY, [
378
+ {
379
+ label: "Delete project…",
380
+ destructive: true,
381
+ onClick: () => this.cb.onDeleteProject(proj.name),
382
+ },
383
+ ]);
384
+ });
385
+ wrapper.appendChild(header);
386
+
387
+ // Only render children for the active project to keep the sidebar compact.
388
+ if (!isActive) return wrapper;
389
+
390
+ const detail = this.detail.get(proj.name);
391
+ const children = document.createElement("div");
392
+ children.className = "atel-workspace-sidebar__project-children";
393
+
394
+ // ── DOCS ──
395
+ // Single flat group, alphabetical by path (server-sorted via listDocs).
396
+ // Every doc — including project.atelier — is a regular row with a type
397
+ // icon, a click-to-load handler, and the Duplicate / Delete… context
398
+ // menu. Post-pivot, there's no "pinned manifest" distinction.
399
+ //
400
+ // Falls back to a synthesized manifest-only list when the live doc list
401
+ // hasn't landed yet (first paint, transient failure). Empty arrays render
402
+ // an empty-state message under the heading.
403
+ const docs: DocListEntry[] = detail?.docs ?? (
404
+ proj.hasAtelierDoc ? [{ path: "project.atelier", kind: undefined }] : []
405
+ );
406
+
407
+ {
408
+ const group = document.createElement("div");
409
+ group.className = "atel-workspace-sidebar__group";
410
+
411
+ const label = document.createElement("div");
412
+ label.className = "atel-workspace-sidebar__group-label atel-workspace-sidebar__group-label--with-action";
413
+ const labelText = document.createElement("span");
414
+ labelText.textContent = "Docs";
415
+ label.appendChild(labelText);
416
+ const newDocBtn = document.createElement("button");
417
+ newDocBtn.className = "atel-workspace-sidebar__new-btn atel-workspace-sidebar__new-btn--inline";
418
+ newDocBtn.textContent = "+ new doc";
419
+ newDocBtn.title = "Create a new doc in this project";
420
+ newDocBtn.addEventListener("click", (ev) => {
421
+ ev.stopPropagation();
422
+ this.cb.onCreateDoc(proj.name);
423
+ });
424
+ label.appendChild(newDocBtn);
425
+ group.appendChild(label);
426
+
427
+ if (docs.length === 0) {
428
+ const empty = document.createElement("div");
429
+ empty.className = "atel-workspace-sidebar__empty";
430
+ // ── v1.0 UI shell rework (Kit-C) ──
431
+ // When the project has media but no docs yet, nudge the creator
432
+ // toward the explicit "compose a doc" step (post-pivot, drops add
433
+ // to media/ rather than auto-creating a doc).
434
+ const mediaCount = detail?.media?.length ?? 0;
435
+ if (mediaCount > 0) {
436
+ empty.textContent =
437
+ `You have ${mediaCount} media file${mediaCount === 1 ? "" : "s"} in this project. ` +
438
+ `Click + new doc to compose.`;
439
+ } else {
440
+ empty.textContent = "No documents yet. Click + new doc to create one.";
441
+ }
442
+ // ── end Kit-C ──
443
+ group.appendChild(empty);
444
+ } else {
445
+ for (const d of docs) {
446
+ group.appendChild(this.renderDocRow(proj.name, d));
447
+ }
448
+ }
449
+ children.appendChild(group);
450
+ }
451
+
452
+ // Artifacts
453
+ if (detail?.artifacts) {
454
+ const group = document.createElement("div");
455
+ group.className = "atel-workspace-sidebar__group";
456
+ group.innerHTML = "<div class=\"atel-workspace-sidebar__group-label\">Artifacts</div>";
457
+ group.appendChild(this.renderArtifactRow(proj.name, "DESIGN.md", "design", detail.artifacts.design));
458
+ group.appendChild(this.renderArtifactRow(proj.name, "SCRIPT.md", "script", detail.artifacts.script));
459
+ group.appendChild(this.renderArtifactRow(proj.name, "STORYBOARD.md", "storyboard", detail.artifacts.storyboard));
460
+ children.appendChild(group);
461
+ }
462
+
463
+ // Media
464
+ if (detail?.media && detail.media.length > 0) {
465
+ const group = document.createElement("div");
466
+ group.className = "atel-workspace-sidebar__group";
467
+ group.innerHTML = "<div class=\"atel-workspace-sidebar__group-label\">Media</div>";
468
+ for (const m of detail.media) {
469
+ const item = document.createElement("div");
470
+ item.className = "atel-workspace-sidebar__item";
471
+ const transcriptBadge = m.hasTranscript ? '<span class="atel-badge atel-badge--ok">✓ transcript</span>' : "";
472
+ const notesBadge = m.hasNotes ? '<span class="atel-badge">notes</span>' : "";
473
+ item.innerHTML = `<span class="atel-item-icon">${kindIcon(m.kind)}</span> ${escapeHtml(m.name)} ${transcriptBadge} ${notesBadge}`;
474
+ item.addEventListener("click", () => this.cb.onSelectMedia(proj.name, m));
475
+ item.addEventListener("contextmenu", (ev) => {
476
+ ev.preventDefault();
477
+ showContextMenu(ev.clientX, ev.clientY, [
478
+ {
479
+ label: `Delete "${m.name}"…`,
480
+ destructive: true,
481
+ onClick: () => this.cb.onDeleteMedia(proj.name, m),
482
+ },
483
+ ]);
484
+ });
485
+ // ── v1.0 sidebar drag (Kit-D) ──
486
+ // Make the row draggable; on dragstart, stash a MediaRefPayload so the
487
+ // canvas / timeline drop targets can POST to the ?source=existing
488
+ // endpoint (Kit-A) without uploading. Only video/image/audio kinds are
489
+ // transferable into a doc (font/other are sidebar-only entries).
490
+ const kindForPayload: MediaRefPayload["kind"] | null =
491
+ m.kind === "video" || m.kind === "image" || m.kind === "audio" ? m.kind : null;
492
+ if (kindForPayload !== null) {
493
+ item.draggable = true;
494
+ item.addEventListener("dragstart", (e) => {
495
+ const payload: MediaRefPayload = {
496
+ path: `media/${m.name}`,
497
+ kind: kindForPayload,
498
+ };
499
+ e.dataTransfer?.setData("application/x-atelier-media-ref", JSON.stringify(payload));
500
+ // Fallback for browsers that strip custom MIME types from
501
+ // dataTransfer.types until the drop event fires (some Firefox
502
+ // builds). The canvas/timeline target prefers the typed payload.
503
+ e.dataTransfer?.setData("text/plain", payload.path);
504
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
505
+ item.classList.add("atel-media-row--dragging");
506
+ });
507
+ item.addEventListener("dragend", () => {
508
+ item.classList.remove("atel-media-row--dragging");
509
+ });
510
+ }
511
+ // ── end v1.0 sidebar drag (Kit-D) ──
512
+ group.appendChild(item);
513
+ }
514
+ children.appendChild(group);
515
+ }
516
+
517
+ // Transcripts
518
+ if (detail?.transcripts && detail.transcripts.length > 0) {
519
+ const group = document.createElement("div");
520
+ group.className = "atel-workspace-sidebar__group";
521
+ group.innerHTML = "<div class=\"atel-workspace-sidebar__group-label\">Transcripts</div>";
522
+ for (const t of detail.transcripts) {
523
+ const item = document.createElement("div");
524
+ item.className = "atel-workspace-sidebar__item";
525
+ const wordsLabel = t.segments !== null ? `${t.segments} seg` : "?";
526
+ const langLabel = t.language ?? "—";
527
+ const durLabel = t.totalDuration !== null ? `${t.totalDuration.toFixed(1)}s` : "?";
528
+ item.innerHTML = `<span class="atel-item-icon">📝</span> ${escapeHtml(t.name)} <span class="atel-item-meta">(${wordsLabel} · ${langLabel} · ${durLabel})</span>`;
529
+ item.addEventListener("click", () => this.cb.onSelectTranscript(proj.name, t));
530
+ item.addEventListener("contextmenu", (ev) => {
531
+ ev.preventDefault();
532
+ showContextMenu(ev.clientX, ev.clientY, [
533
+ {
534
+ label: `Delete "${t.name}"…`,
535
+ destructive: true,
536
+ onClick: () => this.cb.onDeleteTranscript(proj.name, t),
537
+ },
538
+ ]);
539
+ });
540
+ group.appendChild(item);
541
+ }
542
+ children.appendChild(group);
543
+ }
544
+
545
+ wrapper.appendChild(children);
546
+ return wrapper;
547
+ }
548
+
549
+ /**
550
+ * Render a single doc row (any kind, including the project manifest). Click
551
+ * loads the doc into the canvas; right-click opens the Duplicate / Delete…
552
+ * context menu. The currently-loaded doc gets a `← loaded` marker and an
553
+ * accent-colored treatment.
554
+ */
555
+ private renderDocRow(projectName: string, entry: DocListEntry): HTMLElement {
556
+ const item = document.createElement("div");
557
+ const isLoaded = entry.path === this.currentDocPath;
558
+ item.className = "atel-workspace-sidebar__item" +
559
+ (isLoaded ? " atel-workspace-sidebar__item--loaded" : "");
560
+ // Show just the basename for readability ("intro.atelier" not "intro.atelier").
561
+ const basename = entry.path.split("/").pop() ?? entry.path;
562
+ const loadedMarker = isLoaded
563
+ ? '<span class="atel-loaded-marker"> ← loaded</span>'
564
+ : "";
565
+ item.innerHTML = `<span class="atel-item-icon">${docKindIcon(entry.kind)}</span> ${escapeHtml(basename)}${loadedMarker}`;
566
+ item.addEventListener("click", () => this.cb.onSelectDoc(projectName, entry));
567
+ item.addEventListener("contextmenu", (ev) => {
568
+ ev.preventDefault();
569
+ showContextMenu(ev.clientX, ev.clientY, [
570
+ {
571
+ label: "Duplicate",
572
+ onClick: () => this.cb.onDuplicateDoc(projectName, entry),
573
+ },
574
+ {
575
+ label: "Delete…",
576
+ destructive: true,
577
+ onClick: () => this.cb.onDeleteDoc(projectName, entry),
578
+ },
579
+ ]);
580
+ });
581
+ return item;
582
+ }
583
+
584
+ private renderArtifactRow(
585
+ projectName: string,
586
+ label: string,
587
+ kind: "design" | "script" | "storyboard",
588
+ entry: ArtifactEntry,
589
+ ): HTMLElement {
590
+ const item = document.createElement("div");
591
+ item.className = "atel-workspace-sidebar__item";
592
+ const marker = entry.exists ? '<span style="color:#6B8E6B">✓</span>' : '<span style="color:#A89F95">○</span>';
593
+ const empty = entry.exists ? "" : '<span class="atel-item-meta"> (empty)</span>';
594
+ item.innerHTML = `<span class="atel-item-icon">📄</span> ${escapeHtml(label)} ${marker}${empty}`;
595
+ item.addEventListener("click", () => this.cb.onSelectArtifact(projectName, kind));
596
+ if (entry.exists) {
597
+ item.addEventListener("contextmenu", (ev) => {
598
+ ev.preventDefault();
599
+ showContextMenu(ev.clientX, ev.clientY, [
600
+ {
601
+ label: `Delete ${label}…`,
602
+ destructive: true,
603
+ onClick: () => this.cb.onDeleteArtifact(projectName, kind, entry.path),
604
+ },
605
+ ]);
606
+ });
607
+ }
608
+ return item;
609
+ }
610
+ }
611
+
612
+ // ─── #confirm-dialog (destructive-action modal) ──────────────────────
613
+ //
614
+ // Single reusable modal class for delete confirmations. Hand-rolled DOM,
615
+ // matching the existing studio panels' idiom (no new dep, no React).
616
+ //
617
+ // Three knobs:
618
+ // - title — destructive verb + target ("Delete IMG_7347.MOV?")
619
+ // - body — DOM fragment listing what the cascade affects (paths, counts)
620
+ // - requireTypeName — when set to a string, the Delete button stays
621
+ // disabled until the creator types the exact value into a confirm
622
+ // field. The gold-standard guard for irreversible destruction —
623
+ // #confirm-dialog-type-name-for-destructive-deletes.
624
+ //
625
+ // `prompt()` returns a Promise<boolean> the caller awaits. Escape / clicking
626
+ // the backdrop dismisses (returns false). The modal cleans up its own DOM
627
+ // after resolution.
628
+
629
+ interface ConfirmDialogOptions {
630
+ title: string;
631
+ /** Either preformatted HTML (escaped at the caller) or a DOM node. */
632
+ body: string | Node;
633
+ /** Button label. Defaults to "Delete". */
634
+ confirmLabel?: string;
635
+ /**
636
+ * When set, the Delete button stays disabled until the creator types
637
+ * exactly this string into the confirm field. Used for project deletion
638
+ * (the project name) — anything irreversible enough to deserve a
639
+ * second checkpoint.
640
+ */
641
+ requireTypeName?: string;
642
+ }
643
+
644
+ class ConfirmDialog {
645
+ /**
646
+ * Show a confirmation modal and return whether the creator confirmed.
647
+ * The modal mounts onto `document.body`, gates focus, and removes itself
648
+ * on dismiss / confirm.
649
+ */
650
+ static prompt(opts: ConfirmDialogOptions): Promise<boolean> {
651
+ return new Promise((resolveDone) => {
652
+ const overlay = document.createElement("div");
653
+ overlay.className = "atel-confirm-dialog";
654
+
655
+ const card = document.createElement("div");
656
+ card.className = "atel-confirm-dialog__card";
657
+
658
+ const title = document.createElement("div");
659
+ title.className = "atel-confirm-dialog__title";
660
+ title.textContent = opts.title;
661
+ card.appendChild(title);
662
+
663
+ const body = document.createElement("div");
664
+ body.className = "atel-confirm-dialog__body";
665
+ if (typeof opts.body === "string") {
666
+ body.innerHTML = opts.body;
667
+ } else {
668
+ body.appendChild(opts.body);
669
+ }
670
+ card.appendChild(body);
671
+
672
+ let confirmInput: HTMLInputElement | null = null;
673
+ if (opts.requireTypeName) {
674
+ const wrapper = document.createElement("div");
675
+ wrapper.className = "atel-confirm-dialog__type-to-confirm";
676
+ const label = document.createElement("label");
677
+ label.textContent = `Type "${opts.requireTypeName}" to confirm:`;
678
+ label.className = "atel-confirm-dialog__type-label";
679
+ wrapper.appendChild(label);
680
+
681
+ confirmInput = document.createElement("input");
682
+ confirmInput.type = "text";
683
+ confirmInput.className = "atel-confirm-dialog__type-input";
684
+ confirmInput.autocomplete = "off";
685
+ confirmInput.spellcheck = false;
686
+ wrapper.appendChild(confirmInput);
687
+
688
+ card.appendChild(wrapper);
689
+ }
690
+
691
+ const buttons = document.createElement("div");
692
+ buttons.className = "atel-confirm-dialog__buttons";
693
+
694
+ const cancelBtn = document.createElement("button");
695
+ cancelBtn.className = "atel-confirm-dialog__btn atel-confirm-dialog__btn--cancel";
696
+ cancelBtn.textContent = "Cancel";
697
+ buttons.appendChild(cancelBtn);
698
+
699
+ const confirmBtn = document.createElement("button");
700
+ confirmBtn.className = "atel-confirm-dialog__btn atel-confirm-dialog__btn--danger";
701
+ confirmBtn.textContent = opts.confirmLabel ?? "Delete";
702
+ // Type-to-confirm: start disabled, listen for input, enable on exact match.
703
+ if (opts.requireTypeName && confirmInput) {
704
+ confirmBtn.disabled = true;
705
+ confirmInput.addEventListener("input", () => {
706
+ confirmBtn.disabled = confirmInput!.value !== opts.requireTypeName;
707
+ });
708
+ // Allow Enter to confirm once enabled.
709
+ confirmInput.addEventListener("keydown", (ev) => {
710
+ if (ev.key === "Enter" && !confirmBtn.disabled) {
711
+ ev.preventDefault();
712
+ confirmBtn.click();
713
+ }
714
+ });
715
+ }
716
+ buttons.appendChild(confirmBtn);
717
+
718
+ card.appendChild(buttons);
719
+ overlay.appendChild(card);
720
+
721
+ const cleanup = (): void => {
722
+ document.removeEventListener("keydown", onKey);
723
+ overlay.remove();
724
+ };
725
+ const onKey = (ev: KeyboardEvent): void => {
726
+ if (ev.key === "Escape") {
727
+ cleanup();
728
+ resolveDone(false);
729
+ }
730
+ };
731
+
732
+ cancelBtn.addEventListener("click", () => {
733
+ cleanup();
734
+ resolveDone(false);
735
+ });
736
+ confirmBtn.addEventListener("click", () => {
737
+ if (confirmBtn.disabled) return;
738
+ cleanup();
739
+ resolveDone(true);
740
+ });
741
+ overlay.addEventListener("click", (ev) => {
742
+ if (ev.target === overlay) {
743
+ cleanup();
744
+ resolveDone(false);
745
+ }
746
+ });
747
+ document.addEventListener("keydown", onKey);
748
+
749
+ document.body.appendChild(overlay);
750
+
751
+ // Autofocus the type-to-confirm input for fast keyboard flow; otherwise
752
+ // focus Cancel (a creator who summoned the modal via context menu is
753
+ // most likely to escape with Cancel by default).
754
+ if (confirmInput) {
755
+ confirmInput.focus();
756
+ } else {
757
+ cancelBtn.focus();
758
+ }
759
+ });
760
+ }
761
+ }
762
+
763
+ // ─── #new-doc-dialog (create a fresh doc in the active project) ──────
764
+ //
765
+ // Three-tile modal (video / image / carousel — owner explicitly excluded
766
+ // "blank" from v1). Mirrors ConfirmDialog's idiom: same backdrop, same
767
+ // centered card. Returns the form selection (or null on cancel); the
768
+ // HTTP call lives at the caller so error rendering can stay local.
769
+ //
770
+ // Post-pivot: the dialog ONLY creates docs at project root. Slide creation
771
+ // (anchoring a doc into a carousel's slides[]) moves into the carousel-doc
772
+ // canvas toolbar (Kit-K's territory) — no slide toggle here.
773
+
774
+ interface NewDocSelection {
775
+ name: string;
776
+ kind: "video" | "image" | "carousel";
777
+ }
778
+
779
+ interface NewDocPromptOptions {
780
+ /** Override the dialog title — e.g. "Add slide" when adding a slide to a carousel. */
781
+ title?: string;
782
+ }
783
+
784
+ class NewDocDialog {
785
+ /**
786
+ * Show the create-doc modal and resolve with the creator's selection or
787
+ * null on cancel. Inline server errors are rendered via `showError`.
788
+ *
789
+ * `options.title` lets callers retitle the dialog (e.g. Kit-K's "+ add slide"
790
+ * carousel-toolbar entrypoint, which reuses this dialog for the
791
+ * "create new doc as slide" path).
792
+ */
793
+ static prompt(options: NewDocPromptOptions = {}): Promise<NewDocSelection | null> {
794
+ return new Promise((resolveDone) => {
795
+ const overlay = document.createElement("div");
796
+ overlay.className = "atel-confirm-dialog";
797
+
798
+ const card = document.createElement("div");
799
+ card.className = "atel-confirm-dialog__card atel-new-doc-dialog__card";
800
+
801
+ const title = document.createElement("div");
802
+ title.className = "atel-confirm-dialog__title";
803
+ title.textContent = options.title ?? "New doc";
804
+ card.appendChild(title);
805
+
806
+ // ── Kind tiles (3, not 4 — blank excluded by owner) ──
807
+ const tilesRow = document.createElement("div");
808
+ tilesRow.className = "atel-new-doc-dialog__tiles";
809
+
810
+ let selectedKind: "video" | "image" | "carousel" = "video";
811
+ const tileDefs: Array<{ kind: "video" | "image" | "carousel"; icon: string; label: string }> = [
812
+ { kind: "video", icon: "🎬", label: "Video" },
813
+ { kind: "image", icon: "🖼", label: "Image" },
814
+ { kind: "carousel", icon: "🎠", label: "Carousel" },
815
+ ];
816
+ const tileEls: HTMLElement[] = [];
817
+ for (const def of tileDefs) {
818
+ const tile = document.createElement("button");
819
+ tile.type = "button";
820
+ tile.className = "atel-new-doc-dialog__tile";
821
+ if (def.kind === selectedKind) tile.classList.add("atel-new-doc-dialog__tile--selected");
822
+ tile.innerHTML = `<div class="atel-new-doc-dialog__tile-icon">${def.icon}</div>
823
+ <div class="atel-new-doc-dialog__tile-label">${def.label}</div>`;
824
+ tile.addEventListener("click", () => {
825
+ selectedKind = def.kind;
826
+ for (const el of tileEls) el.classList.remove("atel-new-doc-dialog__tile--selected");
827
+ tile.classList.add("atel-new-doc-dialog__tile--selected");
828
+ });
829
+ tileEls.push(tile);
830
+ tilesRow.appendChild(tile);
831
+ }
832
+ card.appendChild(tilesRow);
833
+
834
+ // ── Name input + live slug preview ──
835
+ const nameWrapper = document.createElement("div");
836
+ nameWrapper.className = "atel-new-doc-dialog__field";
837
+ const nameLabel = document.createElement("label");
838
+ nameLabel.textContent = "Name";
839
+ nameLabel.className = "atel-confirm-dialog__type-label";
840
+ nameWrapper.appendChild(nameLabel);
841
+ const nameInput = document.createElement("input");
842
+ nameInput.type = "text";
843
+ nameInput.className = "atel-confirm-dialog__type-input";
844
+ nameInput.autocomplete = "off";
845
+ nameInput.spellcheck = false;
846
+ nameInput.placeholder = "e.g. Intro scene";
847
+ nameWrapper.appendChild(nameInput);
848
+ const slugPreview = document.createElement("div");
849
+ slugPreview.className = "atel-new-doc-dialog__slug-preview";
850
+ slugPreview.textContent = " ";
851
+ nameWrapper.appendChild(slugPreview);
852
+ card.appendChild(nameWrapper);
853
+
854
+ // ── Error row (inline; populated by showError below) ──
855
+ const errorRow = document.createElement("div");
856
+ errorRow.className = "atel-new-doc-dialog__error";
857
+ errorRow.style.display = "none";
858
+ card.appendChild(errorRow);
859
+
860
+ // ── Buttons ──
861
+ const buttons = document.createElement("div");
862
+ buttons.className = "atel-confirm-dialog__buttons";
863
+
864
+ const cancelBtn = document.createElement("button");
865
+ cancelBtn.className = "atel-confirm-dialog__btn atel-confirm-dialog__btn--cancel";
866
+ cancelBtn.textContent = "Cancel";
867
+ buttons.appendChild(cancelBtn);
868
+
869
+ const createBtn = document.createElement("button");
870
+ createBtn.className = "atel-confirm-dialog__btn atel-confirm-dialog__btn--primary";
871
+ createBtn.textContent = "Create";
872
+ createBtn.disabled = true;
873
+ buttons.appendChild(createBtn);
874
+
875
+ card.appendChild(buttons);
876
+ overlay.appendChild(card);
877
+
878
+ // Live slug preview + Create enable/disable.
879
+ nameInput.addEventListener("input", () => {
880
+ const raw = nameInput.value.trim();
881
+ if (raw.length === 0) {
882
+ slugPreview.textContent = " ";
883
+ createBtn.disabled = true;
884
+ } else {
885
+ slugPreview.textContent = `slug: ${previewSlug(raw)}.atelier`;
886
+ createBtn.disabled = false;
887
+ }
888
+ // Clear any prior inline error on edit.
889
+ errorRow.style.display = "none";
890
+ });
891
+ nameInput.addEventListener("keydown", (ev) => {
892
+ if (ev.key === "Enter" && !createBtn.disabled) {
893
+ ev.preventDefault();
894
+ createBtn.click();
895
+ }
896
+ });
897
+
898
+ // Cleanup + resolution wiring (mirrors ConfirmDialog).
899
+ const cleanup = (): void => {
900
+ document.removeEventListener("keydown", onKey);
901
+ overlay.remove();
902
+ };
903
+ const onKey = (ev: KeyboardEvent): void => {
904
+ if (ev.key === "Escape") {
905
+ cleanup();
906
+ resolveDone(null);
907
+ }
908
+ };
909
+ cancelBtn.addEventListener("click", () => {
910
+ cleanup();
911
+ resolveDone(null);
912
+ });
913
+ createBtn.addEventListener("click", () => {
914
+ if (createBtn.disabled) return;
915
+ const name = nameInput.value.trim();
916
+ if (name.length === 0) return;
917
+ cleanup();
918
+ resolveDone({
919
+ name,
920
+ kind: selectedKind,
921
+ });
922
+ });
923
+ overlay.addEventListener("click", (ev) => {
924
+ if (ev.target === overlay) {
925
+ cleanup();
926
+ resolveDone(null);
927
+ }
928
+ });
929
+ document.addEventListener("keydown", onKey);
930
+
931
+ document.body.appendChild(overlay);
932
+ nameInput.focus();
933
+ });
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Lightweight slug preview — matches the spec's "lowercase + space→hyphen"
939
+ * fallback. Exact slug is the server's job (slugifyBasename); this is purely
940
+ * a visual hint.
941
+ */
942
+ function previewSlug(raw: string): string {
943
+ return raw
944
+ .toLowerCase()
945
+ .replace(/[^a-z0-9\s-]+/g, "")
946
+ .trim()
947
+ .replace(/\s+/g, "-")
948
+ .replace(/-+/g, "-") || "doc";
949
+ }
950
+
951
+ // ─── #context-menu (sidebar right-click affordance) ──────────────────
952
+ //
953
+ // Lightweight one-shot context menu. Matches the existing studio panel
954
+ // idiom (vanilla DOM, no new dep). Single "Delete..." item by design —
955
+ // when we need more verbs the API grows to accept an item list.
956
+
957
+ interface ContextMenuItem {
958
+ label: string;
959
+ /** When set, the item is rendered as destructive (warm accent). */
960
+ destructive?: boolean;
961
+ onClick: () => void;
962
+ }
963
+
964
+ /** Show a context menu at (x, y) with the given items. Dismiss on outside click / escape. */
965
+ function showContextMenu(x: number, y: number, items: ContextMenuItem[]): void {
966
+ // Tear down any open menu first — only one at a time.
967
+ document.querySelectorAll(".atel-context-menu").forEach((n) => n.remove());
968
+
969
+ const menu = document.createElement("div");
970
+ menu.className = "atel-context-menu";
971
+ menu.style.left = `${x}px`;
972
+ menu.style.top = `${y}px`;
973
+
974
+ for (const item of items) {
975
+ const row = document.createElement("div");
976
+ row.className = "atel-context-menu__item" +
977
+ (item.destructive ? " atel-context-menu__item--danger" : "");
978
+ row.textContent = item.label;
979
+ row.addEventListener("click", () => {
980
+ menu.remove();
981
+ item.onClick();
982
+ });
983
+ menu.appendChild(row);
984
+ }
985
+
986
+ const cleanup = (): void => {
987
+ document.removeEventListener("click", onDocClick, true);
988
+ document.removeEventListener("keydown", onKey);
989
+ menu.remove();
990
+ };
991
+ const onDocClick = (ev: MouseEvent): void => {
992
+ if (!menu.contains(ev.target as Node)) cleanup();
993
+ };
994
+ const onKey = (ev: KeyboardEvent): void => {
995
+ if (ev.key === "Escape") cleanup();
996
+ };
997
+ // Defer registration so the click that opened us doesn't immediately close
998
+ // (the contextmenu event has already fired by this tick, but we register
999
+ // on capture phase so a same-tick stray click still cleans up cleanly).
1000
+ setTimeout(() => {
1001
+ document.addEventListener("click", onDocClick, true);
1002
+ document.addEventListener("keydown", onKey);
1003
+ }, 0);
1004
+
1005
+ document.body.appendChild(menu);
1006
+
1007
+ // Clamp inside viewport — a menu that opens near the bottom-right edge
1008
+ // gets nudged up/left.
1009
+ const rect = menu.getBoundingClientRect();
1010
+ const vw = window.innerWidth;
1011
+ const vh = window.innerHeight;
1012
+ if (rect.right > vw) menu.style.left = `${vw - rect.width - 4}px`;
1013
+ if (rect.bottom > vh) menu.style.top = `${vh - rect.height - 4}px`;
1014
+ }
1015
+
1016
+ // ─── #studio-markdown-view ───────────────────────────────────────────
1017
+
1018
+ class MarkdownView {
1019
+ readonly el: HTMLElement;
1020
+ private viewerEl: HTMLElement;
1021
+ private editorEl: HTMLTextAreaElement;
1022
+ private toggleBtn: HTMLButtonElement;
1023
+ private saveBtn: HTMLButtonElement;
1024
+ private headerEl: HTMLElement;
1025
+ private currentPath: string | null = null;
1026
+ private currentContent: string = "";
1027
+ private editing = false;
1028
+
1029
+ constructor(parent: HTMLElement) {
1030
+ this.el = document.createElement("div");
1031
+ this.el.className = "atel-markdown-view";
1032
+
1033
+ this.headerEl = document.createElement("div");
1034
+ this.headerEl.className = "atel-markdown-view__header";
1035
+ const title = document.createElement("div");
1036
+ title.className = "atel-markdown-view__title";
1037
+ this.headerEl.appendChild(title);
1038
+
1039
+ this.toggleBtn = document.createElement("button");
1040
+ this.toggleBtn.className = "atel-markdown-view__btn";
1041
+ this.toggleBtn.textContent = "Edit";
1042
+ this.toggleBtn.addEventListener("click", () => this.toggleEdit());
1043
+ this.headerEl.appendChild(this.toggleBtn);
1044
+
1045
+ this.saveBtn = document.createElement("button");
1046
+ this.saveBtn.className = "atel-markdown-view__btn";
1047
+ this.saveBtn.textContent = "Save";
1048
+ this.saveBtn.style.display = "none";
1049
+ this.saveBtn.addEventListener("click", () => this.save());
1050
+ this.headerEl.appendChild(this.saveBtn);
1051
+
1052
+ this.el.appendChild(this.headerEl);
1053
+
1054
+ this.viewerEl = document.createElement("div");
1055
+ this.viewerEl.className = "atel-markdown-view__body";
1056
+ this.el.appendChild(this.viewerEl);
1057
+
1058
+ this.editorEl = document.createElement("textarea");
1059
+ this.editorEl.className = "atel-markdown-view__editor";
1060
+ this.editorEl.style.display = "none";
1061
+ this.el.appendChild(this.editorEl);
1062
+
1063
+ parent.appendChild(this.el);
1064
+ }
1065
+
1066
+ async load(path: string): Promise<void> {
1067
+ this.currentPath = path;
1068
+ try {
1069
+ this.currentContent = await fetchText("/api/file?path=" + encodeURIComponent(path));
1070
+ } catch (e) {
1071
+ this.currentContent = "";
1072
+ this.viewerEl.innerHTML = `<div style="color:#E25C5C">Failed to load ${escapeHtml(path)}: ${escapeHtml(e instanceof Error ? e.message : String(e))}</div>`;
1073
+ return;
1074
+ }
1075
+ this.editing = false;
1076
+ this.saveBtn.style.display = "none";
1077
+ this.toggleBtn.textContent = "Edit";
1078
+ this.editorEl.style.display = "none";
1079
+ this.viewerEl.style.display = "";
1080
+ (this.headerEl.querySelector(".atel-markdown-view__title") as HTMLElement).textContent = path;
1081
+ this.renderViewer();
1082
+ }
1083
+
1084
+ private renderViewer(): void {
1085
+ const { frontmatter, body } = splitFrontmatter(this.currentContent);
1086
+ let html = "";
1087
+ if (frontmatter && Object.keys(frontmatter).length > 0) {
1088
+ html += `<table class="atel-markdown-view__frontmatter">`;
1089
+ for (const [k, v] of Object.entries(frontmatter)) {
1090
+ html += `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(String(v))}</td></tr>`;
1091
+ }
1092
+ html += `</table>`;
1093
+ }
1094
+ html += renderMarkdownBody(body);
1095
+ this.viewerEl.innerHTML = html;
1096
+ }
1097
+
1098
+ private toggleEdit(): void {
1099
+ if (!this.currentPath) return;
1100
+ this.editing = !this.editing;
1101
+ if (this.editing) {
1102
+ this.editorEl.value = this.currentContent;
1103
+ this.editorEl.style.display = "";
1104
+ this.viewerEl.style.display = "none";
1105
+ this.saveBtn.style.display = "";
1106
+ this.toggleBtn.textContent = "Cancel";
1107
+ } else {
1108
+ this.editorEl.style.display = "none";
1109
+ this.viewerEl.style.display = "";
1110
+ this.saveBtn.style.display = "none";
1111
+ this.toggleBtn.textContent = "Edit";
1112
+ }
1113
+ }
1114
+
1115
+ private async save(): Promise<void> {
1116
+ if (!this.currentPath) return;
1117
+ const next = this.editorEl.value;
1118
+ try {
1119
+ await saveText(this.currentPath, next);
1120
+ this.currentContent = next;
1121
+ this.toggleEdit(); // exit edit mode
1122
+ this.renderViewer();
1123
+ } catch (err) {
1124
+ alert("Save failed: " + (err instanceof Error ? err.message : String(err)));
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ /** Split frontmatter YAML (---...---) from body. Returns empty frontmatter when none. */
1130
+ function splitFrontmatter(raw: string): { frontmatter: Record<string, string>; body: string } {
1131
+ const lines = raw.split(/\r?\n/);
1132
+ if (lines[0]?.trim() !== "---") return { frontmatter: {}, body: raw };
1133
+ let endIdx = -1;
1134
+ for (let i = 1; i < lines.length; i++) {
1135
+ if (lines[i].trim() === "---") { endIdx = i; break; }
1136
+ }
1137
+ if (endIdx === -1) return { frontmatter: {}, body: raw };
1138
+ const frontmatter: Record<string, string> = {};
1139
+ for (let i = 1; i < endIdx; i++) {
1140
+ const m = lines[i].match(/^([^:]+):\s*(.*)$/);
1141
+ if (m) frontmatter[m[1].trim()] = m[2].trim();
1142
+ }
1143
+ return {
1144
+ frontmatter,
1145
+ body: lines.slice(endIdx + 1).join("\n").replace(/^\n+/, ""),
1146
+ };
1147
+ }
1148
+
1149
+ /**
1150
+ * Hand-rolled minimal markdown → HTML converter. Supports the subset that
1151
+ * DESIGN/SCRIPT/STORYBOARD artifacts actually use:
1152
+ * - # / ## / ### headings
1153
+ * - blockquotes (> )
1154
+ * - bulleted lists (- ) and numbered lists (1. )
1155
+ * - inline `code`, **bold**, *italic*
1156
+ * - fenced code blocks (```)
1157
+ * - paragraphs (blank-line separated)
1158
+ *
1159
+ * No new dep per the spec. Falls back to escaped plaintext for unknown input.
1160
+ */
1161
+ function renderMarkdownBody(md: string): string {
1162
+ const lines = md.split(/\r?\n/);
1163
+ const out: string[] = [];
1164
+ let i = 0;
1165
+ let inCode = false;
1166
+ let codeBuf: string[] = [];
1167
+
1168
+ const inlineFmt = (s: string): string => {
1169
+ let out = escapeHtml(s);
1170
+ // `code`
1171
+ out = out.replace(/`([^`]+)`/g, '<code class="atel-md-code">$1</code>');
1172
+ // **bold**
1173
+ out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1174
+ // *italic*
1175
+ out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
1176
+ return out;
1177
+ };
1178
+
1179
+ while (i < lines.length) {
1180
+ const line = lines[i];
1181
+
1182
+ // Fenced code blocks
1183
+ if (line.startsWith("```")) {
1184
+ if (!inCode) {
1185
+ inCode = true;
1186
+ codeBuf = [];
1187
+ } else {
1188
+ inCode = false;
1189
+ out.push(`<pre class="atel-md-pre"><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
1190
+ }
1191
+ i++;
1192
+ continue;
1193
+ }
1194
+ if (inCode) {
1195
+ codeBuf.push(line);
1196
+ i++;
1197
+ continue;
1198
+ }
1199
+
1200
+ // Headings
1201
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
1202
+ if (headingMatch) {
1203
+ const level = headingMatch[1].length;
1204
+ out.push(`<h${level} class="atel-md-h${level}">${inlineFmt(headingMatch[2])}</h${level}>`);
1205
+ i++;
1206
+ continue;
1207
+ }
1208
+
1209
+ // Blockquote
1210
+ if (line.startsWith("> ")) {
1211
+ const buf: string[] = [];
1212
+ while (i < lines.length && lines[i].startsWith("> ")) {
1213
+ buf.push(lines[i].slice(2));
1214
+ i++;
1215
+ }
1216
+ out.push(`<blockquote class="atel-md-quote">${inlineFmt(buf.join(" "))}</blockquote>`);
1217
+ continue;
1218
+ }
1219
+
1220
+ // Unordered list
1221
+ if (line.match(/^\s*-\s+/)) {
1222
+ const items: string[] = [];
1223
+ while (i < lines.length && lines[i].match(/^\s*-\s+/)) {
1224
+ items.push(`<li>${inlineFmt(lines[i].replace(/^\s*-\s+/, ""))}</li>`);
1225
+ i++;
1226
+ }
1227
+ out.push(`<ul class="atel-md-list">${items.join("")}</ul>`);
1228
+ continue;
1229
+ }
1230
+
1231
+ // Ordered list
1232
+ if (line.match(/^\s*\d+\.\s+/)) {
1233
+ const items: string[] = [];
1234
+ while (i < lines.length && lines[i].match(/^\s*\d+\.\s+/)) {
1235
+ items.push(`<li>${inlineFmt(lines[i].replace(/^\s*\d+\.\s+/, ""))}</li>`);
1236
+ i++;
1237
+ }
1238
+ out.push(`<ol class="atel-md-list">${items.join("")}</ol>`);
1239
+ continue;
1240
+ }
1241
+
1242
+ // Blank line
1243
+ if (line.trim().length === 0) { i++; continue; }
1244
+
1245
+ // Paragraph
1246
+ const paraBuf: string[] = [line];
1247
+ i++;
1248
+ while (i < lines.length && lines[i].trim().length > 0 && !lines[i].match(/^(#{1,6}|>|-|\d+\.)\s/)) {
1249
+ paraBuf.push(lines[i]);
1250
+ i++;
1251
+ }
1252
+ out.push(`<p>${inlineFmt(paraBuf.join(" "))}</p>`);
1253
+ }
1254
+
1255
+ return out.join("\n");
1256
+ }
1257
+
1258
+ // ─── #studio-media-preview ───────────────────────────────────────────
1259
+
1260
+ /**
1261
+ * Transcript word — narrowed shape used by the inline transcript pane. Fields
1262
+ * are a subset of `VideoTranscript`'s schema; we only consume what's needed
1263
+ * for rendering + click-to-seek.
1264
+ */
1265
+ interface TranscriptWord {
1266
+ text: string;
1267
+ start: number;
1268
+ end: number;
1269
+ }
1270
+
1271
+ interface TranscriptSegment {
1272
+ text: string;
1273
+ start: number;
1274
+ end: number;
1275
+ words: TranscriptWord[];
1276
+ }
1277
+
1278
+ interface TranscriptShape {
1279
+ language?: string;
1280
+ segments: TranscriptSegment[];
1281
+ }
1282
+
1283
+ /**
1284
+ * MediaPreview — single-column for image/font/other, audio-with-transcript,
1285
+ * and any media entry without a transcript. For video/audio entries that
1286
+ * have a transcript, render a two-column split: player on the left,
1287
+ * formatted transcript on the right with click-to-seek + timeupdate-driven
1288
+ * current-word highlight.
1289
+ */
1290
+ class MediaPreview {
1291
+ readonly el: HTMLElement;
1292
+ private headerEl: HTMLElement;
1293
+ private bodyEl: HTMLElement;
1294
+ private mediaEl: HTMLMediaElement | HTMLImageElement | null = null;
1295
+ private wordSpans: Array<{ start: number; end: number; el: HTMLElement }> = [];
1296
+ private currentWordEl: HTMLElement | null = null;
1297
+ private timeupdateHandler: (() => void) | null = null;
1298
+
1299
+ constructor(parent: HTMLElement) {
1300
+ this.el = document.createElement("div");
1301
+ this.el.className = "atel-media-preview";
1302
+
1303
+ this.headerEl = document.createElement("div");
1304
+ this.headerEl.className = "atel-media-preview__header";
1305
+ this.el.appendChild(this.headerEl);
1306
+
1307
+ this.bodyEl = document.createElement("div");
1308
+ this.bodyEl.className = "atel-media-preview__body";
1309
+ this.el.appendChild(this.bodyEl);
1310
+
1311
+ parent.appendChild(this.el);
1312
+ }
1313
+
1314
+ load(entry: ProjectMediaEntry): void {
1315
+ this.detachTimeupdate();
1316
+ this.wordSpans = [];
1317
+ this.currentWordEl = null;
1318
+
1319
+ this.headerEl.innerHTML = `<div class="atel-media-preview__title">${escapeHtml(entry.name)}</div>
1320
+ <div class="atel-media-preview__meta">${entry.kind} · ${formatBytes(entry.sizeBytes)}${entry.hasTranscript ? ' · ✓ transcript' : ''}${entry.hasNotes ? ' · notes' : ''}</div>`;
1321
+
1322
+ this.bodyEl.innerHTML = "";
1323
+ const src = `/api/file?path=${encodeURIComponent(entry.path)}`;
1324
+
1325
+ // Transcript split applies only to time-based media (video / audio).
1326
+ // Images, fonts, and "other" stay single-column even if a transcript
1327
+ // were somehow associated.
1328
+ const isTimeBased = entry.kind === "video" || entry.kind === "audio";
1329
+ const splitLayout = isTimeBased && entry.hasTranscript && entry.transcriptPath !== null;
1330
+
1331
+ if (splitLayout) {
1332
+ this.bodyEl.classList.add("atel-media-preview__body--split");
1333
+ } else {
1334
+ this.bodyEl.classList.remove("atel-media-preview__body--split");
1335
+ }
1336
+
1337
+ // Build the player column (left when split, the sole column otherwise).
1338
+ const playerCol = document.createElement("div");
1339
+ playerCol.className = "atel-media-preview__player";
1340
+
1341
+ if (entry.kind === "video") {
1342
+ const v = document.createElement("video");
1343
+ v.src = src;
1344
+ v.controls = true;
1345
+ v.className = "atel-media-preview__video";
1346
+ playerCol.appendChild(v);
1347
+ this.mediaEl = v;
1348
+ } else if (entry.kind === "image") {
1349
+ const img = document.createElement("img");
1350
+ img.src = src;
1351
+ img.className = "atel-media-preview__image";
1352
+ playerCol.appendChild(img);
1353
+ this.mediaEl = img;
1354
+ } else if (entry.kind === "audio") {
1355
+ const a = document.createElement("audio");
1356
+ a.src = src;
1357
+ a.controls = true;
1358
+ a.className = "atel-media-preview__audio";
1359
+ playerCol.appendChild(a);
1360
+ this.mediaEl = a;
1361
+ } else {
1362
+ playerCol.innerHTML = `<div class="atel-media-preview__noop">No preview available for ${escapeHtml(entry.kind)} files.</div>`;
1363
+ this.mediaEl = null;
1364
+ }
1365
+
1366
+ this.bodyEl.appendChild(playerCol);
1367
+
1368
+ if (splitLayout && entry.transcriptPath) {
1369
+ const transcriptCol = document.createElement("div");
1370
+ transcriptCol.className = "atel-media-preview__transcript";
1371
+ transcriptCol.innerHTML = `<div class="atel-media-preview__transcript-loading">Loading transcript…</div>`;
1372
+ this.bodyEl.appendChild(transcriptCol);
1373
+ void this.renderTranscript(transcriptCol, entry.transcriptPath);
1374
+ }
1375
+ }
1376
+
1377
+ /**
1378
+ * Fetch + render the per-media transcript into the right column. Wires:
1379
+ * - word-span click → seek the player to `word.start`
1380
+ * - player `timeupdate` → highlight the currently-playing word
1381
+ */
1382
+ private async renderTranscript(host: HTMLElement, transcriptPath: string): Promise<void> {
1383
+ try {
1384
+ const text = await fetchText("/api/file?path=" + encodeURIComponent(transcriptPath));
1385
+ const transcript = JSON.parse(text) as TranscriptShape;
1386
+
1387
+ host.innerHTML = "";
1388
+ this.wordSpans = [];
1389
+
1390
+ for (const seg of transcript.segments) {
1391
+ const segEl = document.createElement("div");
1392
+ segEl.className = "atel-media-preview__transcript-segment";
1393
+
1394
+ const timeEl = document.createElement("div");
1395
+ timeEl.className = "atel-media-preview__transcript-time";
1396
+ timeEl.textContent = `${formatTime(seg.start)} → ${formatTime(seg.end)}`;
1397
+ segEl.appendChild(timeEl);
1398
+
1399
+ const textEl = document.createElement("div");
1400
+ textEl.className = "atel-media-preview__transcript-text";
1401
+ for (const w of seg.words) {
1402
+ const wSpan = document.createElement("span");
1403
+ wSpan.className = "atel-transcript-word";
1404
+ wSpan.textContent = w.text + " ";
1405
+ wSpan.title = formatTime(w.start);
1406
+ wSpan.addEventListener("click", () => this.seekTo(w.start));
1407
+ textEl.appendChild(wSpan);
1408
+ this.wordSpans.push({ start: w.start, end: w.end, el: wSpan });
1409
+ }
1410
+ segEl.appendChild(textEl);
1411
+ host.appendChild(segEl);
1412
+ }
1413
+
1414
+ this.attachTimeupdate();
1415
+ } catch (err) {
1416
+ host.innerHTML = `<div style="color:#E25C5C;padding:12px">Failed to load transcript: ${escapeHtml(err instanceof Error ? err.message : String(err))}</div>`;
1417
+ }
1418
+ }
1419
+
1420
+ /** Set the player's currentTime to `t` (no-op if media element is image/none). */
1421
+ private seekTo(t: number): void {
1422
+ const m = this.mediaEl;
1423
+ if (!m || !("currentTime" in m)) return;
1424
+ try {
1425
+ (m as HTMLMediaElement).currentTime = Math.max(0, t);
1426
+ // Try to start playback for an immediate response — fail silently if
1427
+ // the browser blocks (no user-gesture etc.).
1428
+ void (m as HTMLMediaElement).play().catch(() => { /* ignore */ });
1429
+ } catch { /* ignore */ }
1430
+ }
1431
+
1432
+ /** Wire timeupdate → current-word highlight. Idempotent — detaches any prior listener. */
1433
+ private attachTimeupdate(): void {
1434
+ this.detachTimeupdate();
1435
+ const m = this.mediaEl;
1436
+ if (!m || !("currentTime" in m)) return;
1437
+ const media = m as HTMLMediaElement;
1438
+ const handler = (): void => {
1439
+ const t = media.currentTime;
1440
+ // Linear scan — word counts are small enough (transcript pages, not
1441
+ // novels) and most timeupdate events land near the previous match.
1442
+ let match: HTMLElement | null = null;
1443
+ for (const w of this.wordSpans) {
1444
+ if (t >= w.start && t < w.end) { match = w.el; break; }
1445
+ }
1446
+ if (match === this.currentWordEl) return;
1447
+ if (this.currentWordEl) this.currentWordEl.classList.remove("atel-transcript-word--current");
1448
+ if (match) match.classList.add("atel-transcript-word--current");
1449
+ this.currentWordEl = match;
1450
+ };
1451
+ media.addEventListener("timeupdate", handler);
1452
+ this.timeupdateHandler = handler;
1453
+ }
1454
+
1455
+ private detachTimeupdate(): void {
1456
+ if (this.timeupdateHandler && this.mediaEl && "removeEventListener" in this.mediaEl) {
1457
+ (this.mediaEl as HTMLMediaElement).removeEventListener("timeupdate", this.timeupdateHandler);
1458
+ }
1459
+ this.timeupdateHandler = null;
1460
+ }
1461
+
1462
+ destroy(): void {
1463
+ this.detachTimeupdate();
1464
+ if (this.mediaEl && "pause" in this.mediaEl) {
1465
+ try { (this.mediaEl as HTMLMediaElement).pause(); } catch { /* ignore */ }
1466
+ }
1467
+ }
1468
+ }
1469
+
1470
+ // ─── #studio-transcript-viewer ───────────────────────────────────────
1471
+
1472
+ class TranscriptViewer {
1473
+ readonly el: HTMLElement;
1474
+ private headerEl: HTMLElement;
1475
+ private bodyEl: HTMLElement;
1476
+
1477
+ constructor(parent: HTMLElement) {
1478
+ this.el = document.createElement("div");
1479
+ this.el.className = "atel-transcript-viewer";
1480
+
1481
+ this.headerEl = document.createElement("div");
1482
+ this.headerEl.className = "atel-transcript-viewer__header";
1483
+ this.el.appendChild(this.headerEl);
1484
+
1485
+ this.bodyEl = document.createElement("div");
1486
+ this.bodyEl.className = "atel-transcript-viewer__body";
1487
+ this.el.appendChild(this.bodyEl);
1488
+
1489
+ parent.appendChild(this.el);
1490
+ }
1491
+
1492
+ async load(path: string, meta: ProjectTranscriptEntry): Promise<void> {
1493
+ try {
1494
+ const text = await fetchText("/api/file?path=" + encodeURIComponent(path));
1495
+ const transcript = JSON.parse(text) as {
1496
+ language?: string;
1497
+ segments: Array<{
1498
+ text: string;
1499
+ start: number;
1500
+ end: number;
1501
+ words: Array<{ text: string; start: number; end: number; confidence?: number }>;
1502
+ }>;
1503
+ };
1504
+
1505
+ this.headerEl.innerHTML = `<div class="atel-transcript-viewer__title">${escapeHtml(meta.name)}</div>
1506
+ <div class="atel-transcript-viewer__meta">
1507
+ ${transcript.segments.length} segments · ${meta.language ?? "—"} · ${meta.totalDuration?.toFixed(1) ?? "?"}s
1508
+ </div>`;
1509
+
1510
+ this.bodyEl.innerHTML = "";
1511
+ for (const seg of transcript.segments) {
1512
+ const segEl = document.createElement("div");
1513
+ segEl.className = "atel-transcript-viewer__segment";
1514
+
1515
+ const timeEl = document.createElement("div");
1516
+ timeEl.className = "atel-transcript-viewer__time";
1517
+ timeEl.textContent = `${formatTime(seg.start)} → ${formatTime(seg.end)}`;
1518
+ segEl.appendChild(timeEl);
1519
+
1520
+ const textEl = document.createElement("div");
1521
+ textEl.className = "atel-transcript-viewer__text";
1522
+ for (const w of seg.words) {
1523
+ const wSpan = document.createElement("span");
1524
+ wSpan.className = "atel-transcript-viewer__word atel-transcript-word";
1525
+ wSpan.textContent = w.text + " ";
1526
+ wSpan.title = `${formatTime(w.start)} → ${formatTime(w.end)}`;
1527
+ textEl.appendChild(wSpan);
1528
+ }
1529
+ segEl.appendChild(textEl);
1530
+
1531
+ this.bodyEl.appendChild(segEl);
1532
+ }
1533
+ } catch (err) {
1534
+ this.bodyEl.innerHTML = `<div style="color:#E25C5C">Failed to load: ${escapeHtml(err instanceof Error ? err.message : String(err))}</div>`;
1535
+ }
1536
+ }
1537
+ }
1538
+
1539
+ // ─── #studio-drop-zone ──────────────────────────────────────────────
1540
+
1541
+ interface DropZoneCallbacks {
1542
+ getActiveProject: () => string | null;
1543
+ /**
1544
+ * Project-relative path of the .atelier doc currently loaded in the canvas,
1545
+ * or `null` when nothing is loaded. The drop zone uses this to decide
1546
+ * whether a canvas-area drop attaches to the loaded doc (`?targetDocPath=`)
1547
+ * or falls back to the legacy "scaffold a new <slug>.atelier" path.
1548
+ */
1549
+ getCurrentDocPath: () => string | null;
1550
+ /**
1551
+ * Bounding-rect refs for the two visual drop targets. The DropZone reads
1552
+ * these once per drag-enter to position the canvas vs sidebar highlight
1553
+ * panes and to decide which target absorbed the drop. Returning the
1554
+ * elements (instead of cached rects) keeps the position fresh across
1555
+ * sidebar resize / scroll without re-binding callbacks.
1556
+ */
1557
+ getSidebarElement: () => HTMLElement | null;
1558
+ getCanvasElement: () => HTMLElement | null;
1559
+ onIngestStart: (filename: string) => void;
1560
+ onIngestProgress: (phase: string, info?: { received?: number; total?: number | null; error?: string }) => void;
1561
+ onIngestDone: (filename: string) => void;
1562
+ }
1563
+
1564
+ /**
1565
+ * Full-window drag-and-drop overlay with TWO visual targets:
1566
+ *
1567
+ * 1. **Canvas pane** — when the user has a doc loaded, dropping here
1568
+ * POSTs `?targetDocPath=<currentDocPath>` so the media is appended to
1569
+ * the loaded doc (compose-video adds a clip; audio adds an AudioVisual
1570
+ * layer). Image-into-loaded-doc is rejected by the server (v1).
1571
+ * 2. **Sidebar pane** — drops here ALWAYS scaffold a new <slug>.atelier
1572
+ * (legacy / page-level behaviour). No `targetDocPath` is sent.
1573
+ *
1574
+ * When no doc is loaded, the canvas pane silently degrades to the
1575
+ * sidebar-style "new doc" behaviour so the page-level UX stays familiar.
1576
+ *
1577
+ * The split is driven by `e.clientX/clientY` against the sidebar's bounding
1578
+ * rect — cursor in the sidebar bounds at drop time → sidebar route, else
1579
+ * canvas route. We keep ONE global drag tracker so the overlay appears
1580
+ * reliably regardless of where the cursor lands first.
1581
+ */
1582
+ class DropZone {
1583
+ readonly el: HTMLElement;
1584
+ private hoverDepth = 0;
1585
+ private sidebarPane: HTMLElement;
1586
+ private canvasPane: HTMLElement;
1587
+
1588
+ constructor(private cb: DropZoneCallbacks) {
1589
+ this.el = document.createElement("div");
1590
+ this.el.className = "atel-drop-zone";
1591
+ this.el.innerHTML = `
1592
+ <div class="atel-drop-zone__pane atel-drop-zone__pane--sidebar">
1593
+ <div class="atel-drop-zone__inner">
1594
+ <div class="atel-drop-zone__icon">+</div>
1595
+ <!-- ── v1.0 UI shell rework (Kit-C) ── sidebar half: media-bin ingest -->
1596
+ <div class="atel-drop-zone__title">Add to media</div>
1597
+ <div class="atel-drop-zone__hint">Drop files into the project's media bin</div>
1598
+ <!-- ── end Kit-C ── -->
1599
+ </div>
1600
+ </div>
1601
+ <div class="atel-drop-zone__pane atel-drop-zone__pane--canvas">
1602
+ <div class="atel-drop-zone__inner">
1603
+ <div class="atel-drop-zone__icon">⤓</div>
1604
+ <div class="atel-drop-zone__title atel-drop-zone__title--canvas">…</div>
1605
+ <div class="atel-drop-zone__hint atel-drop-zone__hint--canvas">Drop video / audio to extend</div>
1606
+ </div>
1607
+ </div>
1608
+ `;
1609
+ document.body.appendChild(this.el);
1610
+ this.sidebarPane = this.el.querySelector(".atel-drop-zone__pane--sidebar") as HTMLElement;
1611
+ this.canvasPane = this.el.querySelector(".atel-drop-zone__pane--canvas") as HTMLElement;
1612
+
1613
+ window.addEventListener("dragenter", (e) => this.onDragEnter(e));
1614
+ window.addEventListener("dragover", (e) => this.onDragOver(e));
1615
+ window.addEventListener("dragleave", (e) => this.onDragLeave(e));
1616
+ window.addEventListener("drop", (e) => { void this.onDrop(e); });
1617
+ }
1618
+
1619
+ /** Read the current sidebar / canvas rects and position the highlight panes. */
1620
+ private layoutPanes(): void {
1621
+ const sidebarEl = this.cb.getSidebarElement();
1622
+ const canvasEl = this.cb.getCanvasElement();
1623
+ if (sidebarEl) {
1624
+ const r = sidebarEl.getBoundingClientRect();
1625
+ Object.assign(this.sidebarPane.style, {
1626
+ left: `${r.left}px`, top: `${r.top}px`,
1627
+ width: `${r.width}px`, height: `${r.height}px`,
1628
+ });
1629
+ this.sidebarPane.style.display = "flex";
1630
+ } else {
1631
+ this.sidebarPane.style.display = "none";
1632
+ }
1633
+ if (canvasEl) {
1634
+ const r = canvasEl.getBoundingClientRect();
1635
+ Object.assign(this.canvasPane.style, {
1636
+ left: `${r.left}px`, top: `${r.top}px`,
1637
+ width: `${r.width}px`, height: `${r.height}px`,
1638
+ });
1639
+ this.canvasPane.style.display = "flex";
1640
+ } else {
1641
+ this.canvasPane.style.display = "none";
1642
+ }
1643
+
1644
+ // Canvas pane label reflects the loaded-doc state — "Add to <doc>" when a
1645
+ // doc is loaded; "New doc here" otherwise (degraded mode).
1646
+ const docPath = this.cb.getCurrentDocPath();
1647
+ const titleEl = this.canvasPane.querySelector(".atel-drop-zone__title--canvas") as HTMLElement;
1648
+ const hintEl = this.canvasPane.querySelector(".atel-drop-zone__hint--canvas") as HTMLElement;
1649
+ if (docPath) {
1650
+ titleEl.textContent = `Add to ${docPath}`;
1651
+ hintEl.textContent = "Video clip → appended · audio → music layer";
1652
+ } else {
1653
+ titleEl.textContent = "New doc";
1654
+ hintEl.textContent = "No doc loaded — drop creates a new <slug>.atelier";
1655
+ }
1656
+ }
1657
+
1658
+ private onDragEnter(e: DragEvent): void {
1659
+ if (!e.dataTransfer?.types.includes("Files")) return;
1660
+ this.hoverDepth++;
1661
+ const project = this.cb.getActiveProject();
1662
+ if (!project) return;
1663
+ // ── v1.0 UI shell rework (Kit-C) ──
1664
+ // Sidebar-half label is now project-agnostic ("Add to media"); the
1665
+ // legacy per-project span has been removed, so no interpolation here.
1666
+ // ── end Kit-C ──
1667
+ this.layoutPanes();
1668
+ this.el.classList.add("atel-drop-zone--visible");
1669
+ }
1670
+
1671
+ private onDragOver(e: DragEvent): void {
1672
+ if (!e.dataTransfer?.types.includes("Files")) return;
1673
+ e.preventDefault();
1674
+ // Highlight whichever pane the cursor is over right now.
1675
+ this.updateHover(e.clientX, e.clientY);
1676
+ }
1677
+
1678
+ private onDragLeave(_e: DragEvent): void {
1679
+ this.hoverDepth = Math.max(0, this.hoverDepth - 1);
1680
+ if (this.hoverDepth === 0) {
1681
+ this.el.classList.remove("atel-drop-zone--visible");
1682
+ this.clearHover();
1683
+ }
1684
+ }
1685
+
1686
+ /** Add `--active` to the pane the cursor is over; remove from the other. */
1687
+ private updateHover(x: number, y: number): void {
1688
+ const inSidebar = this.pointInRect(x, y, this.cb.getSidebarElement());
1689
+ this.sidebarPane.classList.toggle("atel-drop-zone__pane--active", inSidebar);
1690
+ this.canvasPane.classList.toggle("atel-drop-zone__pane--active", !inSidebar);
1691
+ }
1692
+
1693
+ private clearHover(): void {
1694
+ this.sidebarPane.classList.remove("atel-drop-zone__pane--active");
1695
+ this.canvasPane.classList.remove("atel-drop-zone__pane--active");
1696
+ }
1697
+
1698
+ private pointInRect(x: number, y: number, el: HTMLElement | null): boolean {
1699
+ if (!el) return false;
1700
+ const r = el.getBoundingClientRect();
1701
+ return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
1702
+ }
1703
+
1704
+ private async onDrop(e: DragEvent): Promise<void> {
1705
+ this.hoverDepth = 0;
1706
+ this.el.classList.remove("atel-drop-zone--visible");
1707
+ this.clearHover();
1708
+ if (!e.dataTransfer?.files || e.dataTransfer.files.length === 0) return;
1709
+ e.preventDefault();
1710
+
1711
+ const project = this.cb.getActiveProject();
1712
+ if (!project) {
1713
+ alert("No active project — select one in the sidebar first.");
1714
+ return;
1715
+ }
1716
+
1717
+ // Sidebar drop → new doc (no targetDocPath). Canvas drop with a loaded
1718
+ // doc → attach to that doc. Canvas drop with no loaded doc → fall through
1719
+ // to the new-doc path (preserve legacy "drop anywhere" behaviour).
1720
+ const droppedInSidebar = this.pointInRect(e.clientX, e.clientY, this.cb.getSidebarElement());
1721
+ const currentDocPath = this.cb.getCurrentDocPath();
1722
+ const useTarget = !droppedInSidebar && currentDocPath !== null;
1723
+
1724
+ const file = e.dataTransfer.files[0];
1725
+ this.cb.onIngestStart(file.name);
1726
+ try {
1727
+ const url = useTarget
1728
+ ? `/api/media/ingest?targetDocPath=${encodeURIComponent(currentDocPath!)}`
1729
+ : "/api/media/ingest";
1730
+ const res = await fetch(url, {
1731
+ method: "POST",
1732
+ headers: {
1733
+ "Content-Type": "application/octet-stream",
1734
+ "x-atelier-project": project,
1735
+ "x-atelier-filename": file.name,
1736
+ },
1737
+ body: await file.arrayBuffer(),
1738
+ });
1739
+ if (!res.ok) {
1740
+ const msg = await res.text();
1741
+ this.cb.onIngestProgress("error", { error: msg });
1742
+ return;
1743
+ }
1744
+ this.cb.onIngestDone(file.name);
1745
+ } catch (err) {
1746
+ this.cb.onIngestProgress("error", {
1747
+ error: err instanceof Error ? err.message : String(err),
1748
+ });
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ // ── v1.0 UI shell rework (Kit-C) ──
1754
+ // Transient toast helper — small dark card with an `--atel-accent` left border,
1755
+ // auto-dismissed after 4s. Stacks vertically in a fixed bottom-right container
1756
+ // (sits ABOVE the status bar visually because the container is anchored at
1757
+ // `bottom: 48px` which clears the 32px status bar plus a 16px breath).
1758
+ //
1759
+ // Used to surface no-target media-ingest results without thrashing the status
1760
+ // bar (which other ops — ingest progress, doc cascades — already churn). The
1761
+ // status bar still gets the canvas-attach progress envelopes; this is only for
1762
+ // the "drop file into sidebar → goes to media/" success path.
1763
+ class ToastHost {
1764
+ private static instance: ToastHost | null = null;
1765
+ private readonly stack: HTMLElement;
1766
+
1767
+ static show(message: string): void {
1768
+ if (!ToastHost.instance) ToastHost.instance = new ToastHost();
1769
+ ToastHost.instance.push(message);
1770
+ }
1771
+
1772
+ private constructor() {
1773
+ this.stack = document.createElement("div");
1774
+ this.stack.className = "atel-toast-stack";
1775
+ document.body.appendChild(this.stack);
1776
+ }
1777
+
1778
+ private push(message: string): void {
1779
+ const card = document.createElement("div");
1780
+ card.className = "atel-toast";
1781
+ card.textContent = message;
1782
+ this.stack.appendChild(card);
1783
+ // Trigger enter transition on next frame.
1784
+ requestAnimationFrame(() => card.classList.add("atel-toast--visible"));
1785
+ window.setTimeout(() => {
1786
+ card.classList.remove("atel-toast--visible");
1787
+ window.setTimeout(() => card.remove(), 200);
1788
+ }, 4000);
1789
+ }
1790
+ }
1791
+ // ── end Kit-C ──
1792
+
1793
+ // ── v1.0 sidebar drag (Kit-D) ──
1794
+ /**
1795
+ * Attach drop-target handlers for the sidebar→canvas / sidebar→timeline
1796
+ * media-ref handoff to an element. Discriminates by `dataTransfer.types` so
1797
+ * the existing FILE-drop DropZone (which looks for the `"Files"` type) is
1798
+ * untouched: this handler only engages when the drag carries the custom
1799
+ * `"application/x-atelier-media-ref"` type set by the draggable MEDIA rows.
1800
+ *
1801
+ * On drop, parses the payload, validates the path via `isValidMediaRefPath`
1802
+ * (defense-in-depth — Kit-A's server endpoint validates again), and POSTs
1803
+ * to `/api/media/ingest?source=existing` with the current doc as target.
1804
+ * The server's WS `doc-mutated` envelope drives the canvas refresh; no
1805
+ * manual reload is needed here.
1806
+ */
1807
+ function attachMediaRefDropTarget(
1808
+ el: HTMLElement,
1809
+ getActiveProject: () => string | null,
1810
+ getCurrentDocPath: () => string | null,
1811
+ ): void {
1812
+ el.addEventListener("dragenter", (e) => {
1813
+ if (!e.dataTransfer?.types.includes("application/x-atelier-media-ref")) return;
1814
+ e.preventDefault();
1815
+ el.classList.add("atel-media-ref-drop-target");
1816
+ });
1817
+ el.addEventListener("dragover", (e) => {
1818
+ if (!e.dataTransfer?.types.includes("application/x-atelier-media-ref")) return;
1819
+ e.preventDefault();
1820
+ e.dataTransfer.dropEffect = "copy";
1821
+ });
1822
+ el.addEventListener("dragleave", (e) => {
1823
+ if (!e.dataTransfer?.types.includes("application/x-atelier-media-ref")) return;
1824
+ el.classList.remove("atel-media-ref-drop-target");
1825
+ });
1826
+ el.addEventListener("drop", async (e) => {
1827
+ if (!e.dataTransfer?.types.includes("application/x-atelier-media-ref")) return;
1828
+ e.preventDefault();
1829
+ el.classList.remove("atel-media-ref-drop-target");
1830
+
1831
+ const raw = e.dataTransfer.getData("application/x-atelier-media-ref");
1832
+ let payload: MediaRefPayload;
1833
+ try {
1834
+ payload = JSON.parse(raw) as MediaRefPayload;
1835
+ } catch {
1836
+ alert("Invalid media drag payload");
1837
+ return;
1838
+ }
1839
+ if (!isValidMediaRefPath(payload.path)) {
1840
+ alert(`Invalid media path: ${payload.path}`);
1841
+ return;
1842
+ }
1843
+ const projectName = getActiveProject();
1844
+ const docPath = getCurrentDocPath();
1845
+ if (!projectName) {
1846
+ alert("No active project");
1847
+ return;
1848
+ }
1849
+ if (!docPath) {
1850
+ alert("Open a doc first to add this media to it");
1851
+ return;
1852
+ }
1853
+
1854
+ try {
1855
+ const url = `/api/media/ingest?targetDocPath=${encodeURIComponent(docPath)}&source=existing&path=${encodeURIComponent(payload.path)}`;
1856
+ // The server's media-ingest handler requires x-atelier-project to know
1857
+ // which project the request targets — same contract as the file-drop
1858
+ // path in DropZone. The `?source=existing` branch was missing it.
1859
+ const res = await fetch(url, {
1860
+ method: "POST",
1861
+ headers: { "x-atelier-project": projectName },
1862
+ });
1863
+ if (!res.ok) {
1864
+ const errBody = await res.text().catch(() => "");
1865
+ alert(`Failed to add media: ${errBody || `HTTP ${res.status}`}`);
1866
+ return;
1867
+ }
1868
+ // doc-mutated WS envelope will trigger canvas refresh; no manual refresh needed.
1869
+ } catch (err) {
1870
+ alert("Failed to add media: " + (err instanceof Error ? err.message : err));
1871
+ }
1872
+ });
1873
+ }
1874
+ // ── end v1.0 sidebar drag (Kit-D) ──
1875
+
1876
+ // ─── App shell ──────────────────────────────────────────────────────
1877
+
1878
+ export async function bootWorkspaceApp(): Promise<void> {
1879
+ injectStyles();
1880
+
1881
+ const root = document.getElementById("studio")!;
1882
+ root.innerHTML = "";
1883
+
1884
+ // Two-column shell: sidebar + main panel.
1885
+ const shell = document.createElement("div");
1886
+ shell.className = "atel-workspace-shell";
1887
+ root.appendChild(shell);
1888
+
1889
+ const mainPanel = document.createElement("div");
1890
+ mainPanel.className = "atel-workspace-main";
1891
+
1892
+ // Status bar (ingest progress + active project label)
1893
+ const statusBar = document.createElement("div");
1894
+ statusBar.className = "atel-workspace-status";
1895
+ statusBar.textContent = "Loading workspace…";
1896
+
1897
+ const mainBody = document.createElement("div");
1898
+ mainBody.className = "atel-workspace-body";
1899
+
1900
+ // ── v1.0 UI shell rework (Kit-C) ──
1901
+ // Vertical split: canvasMount (1fr, scrollable, positioned for overlays)
1902
+ // takes the old `mainBody` role; timelineMount (fixed 120px) is a sibling
1903
+ // below it for Kit-B's TimelineView. `mainBody` is now a flex column
1904
+ // container — never receives content directly. All previous mount sites
1905
+ // (AtelierStudio, MarkdownView, MediaPreview, TranscriptViewer, empty
1906
+ // canvas, error HTML, "+ add slide" overlay, DropZone canvas rect) now
1907
+ // target `canvasMount`.
1908
+ const canvasMount = document.createElement("div");
1909
+ canvasMount.className = "atel-workspace-canvas";
1910
+ const timelineMount = document.createElement("div");
1911
+ timelineMount.className = "atel-workspace-timeline";
1912
+ // Expose for Kit-B's TimelineView (new TimelineView({ container: timelineMount, ... })).
1913
+ mainBody.appendChild(canvasMount);
1914
+ mainBody.appendChild(timelineMount);
1915
+ // ── end Kit-C ──
1916
+
1917
+ mainPanel.appendChild(statusBar);
1918
+ mainPanel.appendChild(mainBody);
1919
+
1920
+ let activeProjectName: string | null = null;
1921
+ /**
1922
+ * Project-relative path of the .atelier doc currently rendered in the
1923
+ * canvas, or `null` when nothing is loaded (empty project, or active doc
1924
+ * was deleted with no fallback available). Post-pivot (Wave 1+) projects
1925
+ * can be doc-less — Kit-E removed the auto-`project.atelier` creation — so
1926
+ * the canvas must tolerate the empty state. See `showEmptyCanvas()`.
1927
+ */
1928
+ let currentDocPath: string | null = null;
1929
+ /**
1930
+ * Doc-kind metadata for the currently-loaded doc — sourced from the sidebar's
1931
+ * `DocListEntry.kind` (the AtelierDocument schema itself has no `kind` field;
1932
+ * it lives on the doc-list metadata). `null` whenever no doc is loaded.
1933
+ *
1934
+ * Drives Kit-K's "+ add slide" canvas affordance: the button is rendered
1935
+ * only when `currentDocKind === "carousel"`.
1936
+ */
1937
+ let currentDocKind: "video" | "image" | "carousel" | null = null;
1938
+
1939
+ const sidebar = new WorkspaceSidebar(shell, {
1940
+ onSelectProject: async (name) => {
1941
+ try {
1942
+ await postJson("/api/workspace/active", { name });
1943
+ await sidebar.refresh();
1944
+ activeProjectName = name;
1945
+ await openProjectDoc(name);
1946
+ } catch (err) {
1947
+ statusBar.textContent = `Failed to activate ${name}: ${err instanceof Error ? err.message : err}`;
1948
+ }
1949
+ },
1950
+ onSelectDoc: (name, entry) => openProjectDoc(name, entry.path),
1951
+ onSelectArtifact: (projectName, kind) => {
1952
+ const file = kind === "design" ? "DESIGN.md" : kind === "script" ? "SCRIPT.md" : "STORYBOARD.md";
1953
+ openMarkdown(`${projectName}/${file}`);
1954
+ },
1955
+ onSelectMedia: (projectName, entry) => openMedia(entry),
1956
+ onSelectTranscript: (projectName, entry) => openTranscript(entry),
1957
+ onCreateProject: async () => {
1958
+ const raw = window.prompt("New project name");
1959
+ if (!raw) return;
1960
+ const name = raw.trim();
1961
+ if (!name) return;
1962
+ try {
1963
+ await postJson("/api/workspace/projects", { name, activate: true });
1964
+ await sidebar.refresh();
1965
+ activeProjectName = name;
1966
+ await openProjectDoc(name);
1967
+ } catch (err) {
1968
+ alert("Failed to create project: " + (err instanceof Error ? err.message : err));
1969
+ }
1970
+ },
1971
+ onCreateDoc: async (projectName) => {
1972
+ // Post-pivot: the dialog ONLY creates docs at project root. Slide
1973
+ // creation lives in the carousel-doc canvas toolbar (Kit-K) — not here.
1974
+ const selection = await NewDocDialog.prompt();
1975
+ if (!selection) return;
1976
+ const body = { name: selection.name, kind: selection.kind };
1977
+ try {
1978
+ const result = await postJson<{ docPath: string }>(
1979
+ `/api/project/${encodeURIComponent(projectName)}/docs`,
1980
+ body,
1981
+ );
1982
+ statusBar.textContent = `Created ${result.docPath}`;
1983
+ // Sidebar will refetch via `doc:list:changed` WS broadcast.
1984
+ // Belt-and-braces: also refresh inline in case the WS bridge is down.
1985
+ await sidebar.refreshProject(projectName);
1986
+ } catch (err) {
1987
+ alert("Failed to create doc: " + (err instanceof Error ? err.message : err));
1988
+ }
1989
+ },
1990
+ onDuplicateDoc: async (projectName, entry) => {
1991
+ try {
1992
+ // Post-pivot: docs are flat root-level files. Duplicate copies the
1993
+ // file only; no SlideRef plumbing (carousel slide management is
1994
+ // Kit-K's territory in the carousel-doc toolbar).
1995
+ await postJson(
1996
+ `/api/project/${encodeURIComponent(projectName)}/docs/duplicate?path=${encodeURIComponent(entry.path)}`,
1997
+ {},
1998
+ );
1999
+ await sidebar.refreshProject(projectName);
2000
+ statusBar.textContent = `Duplicated ${entry.path}`;
2001
+ } catch (err) {
2002
+ alert("Failed to duplicate doc: " + (err instanceof Error ? err.message : err));
2003
+ }
2004
+ },
2005
+ onDeleteDoc: async (projectName, entry) => {
2006
+ // Pre-confirm copy — generic. The actual cascade counts
2007
+ // (slideRefsRemoved, refLayersDangling) are only known AFTER the server
2008
+ // processes the DELETE; surface them in the status bar on success
2009
+ // rather than pretending to know them up-front.
2010
+ const bodyCopy = `Delete <code>${escapeHtml(entry.path)}</code>? ` +
2011
+ `Any SlideRef entries pointing at it will be auto-scrubbed; ` +
2012
+ `RefVisual layers pointing at it will be left dangling.`;
2013
+ const ok = await ConfirmDialog.prompt({
2014
+ title: `Delete "${entry.path}"?`,
2015
+ body: bodyCopy,
2016
+ confirmLabel: "Delete",
2017
+ });
2018
+ if (!ok) return;
2019
+ try {
2020
+ const report = await deleteJson<DocDeleteCascadeReport>(
2021
+ `/api/project/${encodeURIComponent(projectName)}/docs?path=${encodeURIComponent(entry.path)}`,
2022
+ );
2023
+ // Surface cascade counts in the status bar — both buckets are workspace-
2024
+ // wide effects a creator likely wants to know about after the fact:
2025
+ // - slideRefsRemoved → SlideRefs auto-scrubbed from OTHER carousels
2026
+ // (this matches the spec's "Plus N slide references in M other
2027
+ // projects" copy);
2028
+ // - refLayersDangling → RefVisual layers still pointing at the now-
2029
+ // deleted doc (NOT auto-removed — surfaced as a soft warning).
2030
+ const otherSlideProjects = new Set(
2031
+ report.slideRefsRemoved
2032
+ .filter((r) => r.project !== projectName)
2033
+ .map((r) => r.project),
2034
+ );
2035
+ const parts: string[] = [];
2036
+ if (report.slideRefsRemoved.length > 0 && otherSlideProjects.size > 0) {
2037
+ parts.push(`${report.slideRefsRemoved.length} slide ref${report.slideRefsRemoved.length === 1 ? "" : "s"} in ${otherSlideProjects.size} other project${otherSlideProjects.size === 1 ? "" : "s"}`);
2038
+ }
2039
+ if (report.refLayersDangling.length > 0) {
2040
+ parts.push(`${report.refLayersDangling.length} dangling RefVisual layer${report.refLayersDangling.length === 1 ? "" : "s"}`);
2041
+ }
2042
+ const extra = parts.length > 0 ? ` (plus ${parts.join("; ")})` : "";
2043
+ statusBar.textContent = `Deleted ${entry.path}${extra}`;
2044
+ // The WS broadcast will refresh other clients; refresh inline too.
2045
+ await sidebar.refreshProject(projectName);
2046
+ } catch (err) {
2047
+ alert("Failed to delete doc: " + (err instanceof Error ? err.message : err));
2048
+ }
2049
+ },
2050
+ onDeleteMedia: async (projectName, entry) => {
2051
+ const ok = await ConfirmDialog.prompt({
2052
+ title: `Delete "${entry.name}"?`,
2053
+ body: `This will remove the media file, its transcript (if any), its sidecar notes, ` +
2054
+ `and any layers in any .atelier doc that reference it. This cannot be undone.`,
2055
+ confirmLabel: "Delete",
2056
+ });
2057
+ if (!ok) return;
2058
+ try {
2059
+ // Use deleteJson which throws on non-2xx — earlier raw fetch silently
2060
+ // swallowed 4xx/5xx and falsely reported success in the status bar.
2061
+ await deleteJson(
2062
+ `/api/project/${encodeURIComponent(projectName)}/media?file=${encodeURIComponent(entry.name)}`,
2063
+ );
2064
+ await sidebar.refresh();
2065
+ clearMain();
2066
+ statusBar.textContent = `Deleted ${entry.name}`;
2067
+ } catch (err) {
2068
+ alert("Failed to delete: " + (err instanceof Error ? err.message : err));
2069
+ }
2070
+ },
2071
+ onDeleteTranscript: async (projectName, entry) => {
2072
+ const ok = await ConfirmDialog.prompt({
2073
+ title: `Delete "${entry.name}"?`,
2074
+ body: `The transcript file will be removed. The associated media file stays put — ` +
2075
+ `you can re-transcribe later. This cannot be undone.`,
2076
+ confirmLabel: "Delete",
2077
+ });
2078
+ if (!ok) return;
2079
+ try {
2080
+ await deleteJson(`/api/file?path=${encodeURIComponent(entry.path)}`);
2081
+ await sidebar.refresh();
2082
+ clearMain();
2083
+ statusBar.textContent = `Deleted ${entry.name}`;
2084
+ } catch (err) {
2085
+ alert("Failed to delete: " + (err instanceof Error ? err.message : err));
2086
+ }
2087
+ },
2088
+ onDeleteArtifact: async (projectName, kind, path) => {
2089
+ const label = kind === "design" ? "DESIGN.md" : kind === "script" ? "SCRIPT.md" : "STORYBOARD.md";
2090
+ const ok = await ConfirmDialog.prompt({
2091
+ title: `Delete ${label}?`,
2092
+ body: `The artifact file will be removed. This cannot be undone.`,
2093
+ confirmLabel: "Delete",
2094
+ });
2095
+ if (!ok) return;
2096
+ try {
2097
+ await deleteJson(`/api/file?path=${encodeURIComponent(path)}`);
2098
+ await sidebar.refresh();
2099
+ clearMain();
2100
+ statusBar.textContent = `Deleted ${label}`;
2101
+ } catch (err) {
2102
+ alert("Failed to delete: " + (err instanceof Error ? err.message : err));
2103
+ }
2104
+ },
2105
+ onDeleteProject: async (name) => {
2106
+ const ok = await ConfirmDialog.prompt({
2107
+ title: `Delete project "${name}"?`,
2108
+ body: `This will remove the entire project directory (artifacts, media, transcripts, ` +
2109
+ `document) IRREVERSIBLY. Type the project name to confirm.`,
2110
+ confirmLabel: "Delete project",
2111
+ requireTypeName: name,
2112
+ });
2113
+ if (!ok) return;
2114
+ try {
2115
+ await deleteJson(`/api/project/${encodeURIComponent(name)}`);
2116
+ if (activeProjectName === name) activeProjectName = null;
2117
+ await sidebar.refresh();
2118
+ clearMain();
2119
+ statusBar.textContent = `Deleted project ${name}`;
2120
+ } catch (err) {
2121
+ alert("Failed to delete project: " + (err instanceof Error ? err.message : err));
2122
+ }
2123
+ },
2124
+ });
2125
+
2126
+ shell.appendChild(mainPanel);
2127
+
2128
+ // Current main-panel state
2129
+ let currentStudio: AtelierStudio | null = null;
2130
+ let currentMarkdown: MarkdownView | null = null;
2131
+ let currentMedia: MediaPreview | null = null;
2132
+ let currentTranscript: TranscriptViewer | null = null;
2133
+
2134
+ // ── v1.0 bottom timeline (Kit-B) ──
2135
+ // Persistent TimelineView mounted into the timelineMount sibling (created
2136
+ // by Kit-C's vertical split). The view is instantiated once and reused
2137
+ // across panel switches — `setDoc(null)` clears its lane to the "No clips"
2138
+ // placeholder when no doc is loaded.
2139
+ //
2140
+ // v1.0 wiring scope (read-only viewer):
2141
+ // - Doc → timeline: `openProjectDoc` calls `timelineView.setDoc(doc)`
2142
+ // after `loadDocument`; non-doc panels and `showEmptyCanvas` call
2143
+ // `setDoc(null)` so the lane reverts to the empty state.
2144
+ // - Clip click → layer-panel selection: AtelierStudio does NOT expose a
2145
+ // layer-panel selection API in its current published surface
2146
+ // (StudioOptions: mode/initialTab/allowSave/onDocumentChange/onSave
2147
+ // only — see node_modules/.../atelier-studio/dist/index.d.ts). v1.1
2148
+ // follow-up: pipe the selection into the AtelierStudio surface once it
2149
+ // ships a layer-panel selection method. For now we surface the choice
2150
+ // as a debug breadcrumb on the status bar.
2151
+ // - Ruler click → studio scrubber: AtelierStudio exposes no `seekTo`
2152
+ // method publicly (the canvas-panel's playback head is internal). v1.1
2153
+ // follow-up: wire through once a public seek API lands. The timeline
2154
+ // still updates its own playhead so the click visually registers.
2155
+ // - Scrubber → timeline playhead: AtelierStudio exposes no
2156
+ // `onFrameChange` callback in its public StudioOptions (the canvas-
2157
+ // panel emits one internally but it's not surfaced). v1.1 follow-up:
2158
+ // subscribe once exposed; the playhead stays at frame 0 in v1.0
2159
+ // unless the creator clicks the ruler.
2160
+ const timelineView = new TimelineView({
2161
+ container: timelineMount,
2162
+ onSelectClip: (selection: TimelineSelection | null) => {
2163
+ // v1.1 follow-up: forward to AtelierStudio's layer-panel selection API
2164
+ // when the studio package exposes one. v1.0 surfaces it on the status
2165
+ // bar so the click is observable (and so manual QA can confirm the
2166
+ // click-to-select path fires).
2167
+ if (selection) {
2168
+ statusBar.textContent =
2169
+ `Selected clip ${selection.layerId} on ${selection.trackId} ` +
2170
+ `(layer-panel sync is v1.1)`;
2171
+ } else {
2172
+ statusBar.textContent = "Selection cleared";
2173
+ }
2174
+ },
2175
+ onSeek: (frame: number) => {
2176
+ // v1.1 follow-up: call into AtelierStudio's seek API when one lands.
2177
+ // The TimelineView already updated its own playhead before this fires.
2178
+ statusBar.textContent = `Seek → frame ${frame} (canvas sync is v1.1)`;
2179
+ },
2180
+ });
2181
+ timelineView.mount();
2182
+ // ── end v1.0 bottom timeline (Kit-B) ──
2183
+
2184
+ const clearMain = (): void => {
2185
+ if (currentStudio) { currentStudio.destroy(); currentStudio = null; }
2186
+ if (currentMedia) { currentMedia.destroy(); currentMedia = null; }
2187
+ currentMarkdown = null;
2188
+ currentTranscript = null;
2189
+ // ── v1.0 UI shell rework (Kit-C) ── only clear the canvas half;
2190
+ // timelineMount lives below and is owned by Kit-B's TimelineView.
2191
+ canvasMount.innerHTML = "";
2192
+ // Also clear the bottom timeline so it doesn't show stale clips from
2193
+ // a previous doc when the user switches to a non-doc panel (markdown,
2194
+ // media preview) or an empty canvas state.
2195
+ timelineView.setDoc(null);
2196
+ // ── end Kit-C ──
2197
+ // Reset doc-kind metadata too — `openProjectDoc` resets it on the very
2198
+ // next line for the doc-load path, but non-doc panels (markdown / media /
2199
+ // transcript) leave the canvas without any doc loaded, so the stale kind
2200
+ // would otherwise leak across panel switches.
2201
+ currentDocKind = null;
2202
+ // Drop the "← loaded" marker — non-doc panels (media / transcript /
2203
+ // markdown) don't correspond to any sidebar doc row.
2204
+ sidebar.setCurrentDocPath(null);
2205
+ };
2206
+
2207
+ /**
2208
+ * #canvas-empty-state — centered, muted placeholder shown in the main panel
2209
+ * when no doc is loaded. Post-pivot projects can be doc-less (Kit-E removes
2210
+ * the auto-`project.atelier` creation), and selecting such a project should
2211
+ * NOT crash the canvas. Also the fallback when an active doc is deleted and
2212
+ * no other doc exists to swap in.
2213
+ *
2214
+ * The Reset / Undo / Redo / YAML / Editor toolbar lives inside the
2215
+ * `AtelierStudio` component (instantiated in `openProjectDoc`). By not
2216
+ * mounting a Studio here, the toolbar is absent for free — no per-button
2217
+ * gating needed.
2218
+ */
2219
+ function showEmptyCanvas(): void {
2220
+ clearMain();
2221
+ currentDocPath = null;
2222
+ currentDocKind = null;
2223
+ // `clearMain()` already calls `sidebar.setCurrentDocPath(null)`; no-op
2224
+ // belt-and-braces — the marker stays clear.
2225
+ statusBar.textContent = "No document loaded.";
2226
+
2227
+ const empty = document.createElement("div");
2228
+ empty.className = "atel-canvas-empty";
2229
+ empty.style.cssText = [
2230
+ "display:flex",
2231
+ "flex-direction:column",
2232
+ "align-items:center",
2233
+ "justify-content:center",
2234
+ "height:100%",
2235
+ "width:100%",
2236
+ "padding:48px 24px",
2237
+ "text-align:center",
2238
+ "color:var(--atel-text-muted, #A89F95)",
2239
+ `font-family:${theme.fontFamily}`,
2240
+ "font-size:15px",
2241
+ "line-height:1.6",
2242
+ "letter-spacing:0.01em",
2243
+ ].join(";");
2244
+
2245
+ const heading = document.createElement("div");
2246
+ heading.style.cssText = "font-size:18px;margin-bottom:18px;color:var(--atel-text-muted, #A89F95)";
2247
+ heading.textContent = "No document loaded.";
2248
+ empty.appendChild(heading);
2249
+
2250
+ const body = document.createElement("div");
2251
+ body.style.cssText = "max-width:380px;color:var(--atel-text-muted, #A89F95);opacity:0.85";
2252
+ // Two lines, mirroring the spec copy. The "+ new doc" string matches the
2253
+ // sidebar button label (atel-workspace-sidebar__new-btn--inline) verbatim
2254
+ // so the visual cue resolves.
2255
+ body.innerHTML =
2256
+ "Click a doc in the sidebar to start editing,<br>" +
2257
+ "or use <span style=\"font-family:" + theme.fontMono + ";color:var(--atel-text, #F5F0EB)\">+ new doc</span> to create one.";
2258
+ empty.appendChild(body);
2259
+
2260
+ // ── v1.0 UI shell rework (Kit-C) ── mount in canvasMount (the canvas half
2261
+ // of the vertical split), not mainBody (the flex container).
2262
+ canvasMount.appendChild(empty);
2263
+ // ── end Kit-C ──
2264
+ }
2265
+
2266
+ async function openProjectDoc(name: string, docPath?: string): Promise<void> {
2267
+ // Post-pivot: when no explicit doc is requested, pick the first doc the
2268
+ // sidebar knows about. Empty projects (Kit-E) and projects whose active
2269
+ // doc was just deleted (WS fallback below) end up here with no docs —
2270
+ // surface the empty-state visual instead of crashing on a missing file.
2271
+ let resolvedDocPath = docPath;
2272
+ if (resolvedDocPath === undefined) {
2273
+ const docs = sidebar.getDocs(name);
2274
+ if (docs.length === 0) {
2275
+ showEmptyCanvas();
2276
+ return;
2277
+ }
2278
+ resolvedDocPath = docs[0].path;
2279
+ }
2280
+
2281
+ clearMain();
2282
+ const path = `${name}/${resolvedDocPath}`;
2283
+ statusBar.textContent = path;
2284
+ currentDocPath = resolvedDocPath;
2285
+ sidebar.setCurrentDocPath(resolvedDocPath);
2286
+ // Resolve the doc's kind from the sidebar's cached doc list (the schema
2287
+ // itself has no `kind` field — it's doc-list metadata, set at create-time
2288
+ // and surfaced by the server). Falls back to null when the doc isn't in
2289
+ // the cache yet (rare race; the button just won't render that frame).
2290
+ currentDocKind = sidebar.getDocs(name).find((d) => d.path === resolvedDocPath)?.kind ?? null;
2291
+ try {
2292
+ const content = await fetchText("/api/file?path=" + encodeURIComponent(path));
2293
+ const parsed = parseAtelier(content);
2294
+ if (!parsed.success) {
2295
+ // ── v1.0 UI shell rework (Kit-C) ── canvas half, not the flex container.
2296
+ canvasMount.innerHTML = `<div style="padding:24px;color:#E25C5C">Parse error: ${escapeHtml(parsed.errors.map(e => e.path + ": " + e.message).join("; "))}</div>`;
2297
+ // ── end Kit-C ──
2298
+ return;
2299
+ }
2300
+ // Save indicator + debounced save inline; matches inline-app.ts pattern.
2301
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
2302
+ // ── v1.0 UI shell rework (Kit-C) ── mount studio in canvasMount.
2303
+ // Resolve doc-relative srcs (e.g. `media/IMG_7346.MOV`) to the file
2304
+ // API so the canvas can actually fetch image/video bytes. The doc
2305
+ // lives at `path` (cwd-relative); media paths in the doc are written
2306
+ // relative to the doc's own directory, so we join + URL-encode.
2307
+ const docDir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
2308
+ const studioSrcResolver = (src: string): string => {
2309
+ // Pass through absolute, protocol-qualified, and data URLs unchanged.
2310
+ if (/^([a-z]+:|\/|data:)/i.test(src)) return src;
2311
+ const full = docDir ? `${docDir}/${src}` : src;
2312
+ return `/api/file?path=${encodeURIComponent(full)}`;
2313
+ };
2314
+ currentStudio = new AtelierStudio(canvasMount, {
2315
+ mode: "full",
2316
+ initialTab: "editor",
2317
+ allowSave: true,
2318
+ onDocumentChange: (doc) => {
2319
+ if (saveTimer) clearTimeout(saveTimer);
2320
+ saveTimer = setTimeout(() => {
2321
+ void saveText(path, serializeAtelier(doc as never)).catch((err) => {
2322
+ console.error("save failed:", err);
2323
+ });
2324
+ }, 800);
2325
+ },
2326
+ srcResolver: studioSrcResolver,
2327
+ });
2328
+ currentStudio.setTheme(theme);
2329
+ currentStudio.loadDocument(parsed.data as Parameters<AtelierStudio["loadDocument"]>[0]);
2330
+ // Bottom timeline must populate on doc LOAD, not just on mutation.
2331
+ // Kit-α's mutation handler was the only path syncing the timeline —
2332
+ // the initial-load path was missed. Without this, the timeline stays
2333
+ // "No clips" until the user drags media in.
2334
+ timelineView.setDoc(parsed.data as never);
2335
+ // Kit-K: carousel docs get a "+ add slide" overlay button anchored to
2336
+ // the canvas top-right. The button opens NewDocDialog (retitled "Add
2337
+ // slide") and POSTs with `asCarouselSlide: true` so the new doc is
2338
+ // anchored into this carousel's slides[] server-side. v1 scope: only
2339
+ // "create new doc as slide" — the "pick existing .atelier" tab is v1.1.
2340
+ if (currentDocKind === "carousel") {
2341
+ mountAddSlideButton(name, resolvedDocPath);
2342
+ }
2343
+ } catch (err) {
2344
+ // ── v1.0 UI shell rework (Kit-C) ── canvas half, not the flex container.
2345
+ canvasMount.innerHTML = `<div style="padding:24px;color:#E25C5C">Failed to load: ${escapeHtml(err instanceof Error ? err.message : String(err))}</div>`;
2346
+ // ── end Kit-C ──
2347
+ }
2348
+ }
2349
+
2350
+ /**
2351
+ * Render the "+ add slide" button as an absolute-positioned overlay anchored
2352
+ * to the top-right of the canvas (`mainBody` is `position:relative`). The
2353
+ * button is a sibling of the AtelierStudio root, so it stays on screen as
2354
+ * the studio's own toolbar lives below it — and `clearMain()`'s
2355
+ * `innerHTML = ""` sweeps it up on the next doc load.
2356
+ *
2357
+ * On click: open NewDocDialog retitled "Add slide", then POST
2358
+ * `/api/project/:name/docs` with `asCarouselSlide: true` and the carousel
2359
+ * doc's path. The WS `doc:list:changed` broadcast triggers the sidebar
2360
+ * refresh + canvas reload; server-side cycle-detection (composeCarousel
2361
+ * Project) handles invalid pairings — surface its error inline.
2362
+ */
2363
+ function mountAddSlideButton(projectName: string, carouselDocPath: string): void {
2364
+ const btn = document.createElement("button");
2365
+ btn.type = "button";
2366
+ btn.className = "atel-add-slide-btn";
2367
+ btn.textContent = "+ add slide";
2368
+ // Positioned below the studio's own toolbar (which renders Export + YAML/
2369
+ // Editor tabs along the top); a top-right anchor here would collide with
2370
+ // those controls. ~56px clears the toolbar's height with a small margin.
2371
+ btn.style.cssText = [
2372
+ "position:absolute",
2373
+ "top:56px",
2374
+ "right:16px",
2375
+ "z-index:10",
2376
+ `font-family:${theme.fontMono}`,
2377
+ "font-size:12px",
2378
+ "letter-spacing:0.04em",
2379
+ `color:${theme.textAccent}`,
2380
+ `background:${theme.buttonBg}`,
2381
+ `border:1px solid ${theme.border}`,
2382
+ "border-radius:4px",
2383
+ "padding:6px 12px",
2384
+ "cursor:pointer",
2385
+ ].join(";");
2386
+ btn.addEventListener("mouseenter", () => {
2387
+ btn.style.background = theme.buttonHover;
2388
+ });
2389
+ btn.addEventListener("mouseleave", () => {
2390
+ btn.style.background = theme.buttonBg;
2391
+ });
2392
+ btn.addEventListener("click", async () => {
2393
+ const selection = await NewDocDialog.prompt({ title: "Add slide" });
2394
+ if (!selection) return;
2395
+ try {
2396
+ const result = await postJson<{ docPath: string }>(
2397
+ `/api/project/${encodeURIComponent(projectName)}/docs`,
2398
+ {
2399
+ name: selection.name,
2400
+ kind: selection.kind,
2401
+ asCarouselSlide: true,
2402
+ carouselDocPath,
2403
+ },
2404
+ );
2405
+ statusBar.textContent = `Added slide ${result.docPath}`;
2406
+ // Refresh sidebar (the WS `doc:list:changed` broadcast handles other
2407
+ // clients; inline refresh is belt-and-braces). The WS handler only
2408
+ // reloads the canvas when the active doc DISAPPEARS — the carousel
2409
+ // doc still exists with a new slides[] entry, so we re-open it
2410
+ // explicitly to surface the updated slide count in the canvas.
2411
+ await sidebar.refreshProject(projectName);
2412
+ await openProjectDoc(projectName, carouselDocPath);
2413
+ } catch (err) {
2414
+ alert("Failed to add slide: " + (err instanceof Error ? err.message : err));
2415
+ }
2416
+ });
2417
+ // ── v1.0 UI shell rework (Kit-C) ── overlay anchors to canvasMount; the
2418
+ // mount is position:relative so absolute children pin correctly. mainBody
2419
+ // is the flex column and would put the button alongside the timeline.
2420
+ canvasMount.appendChild(btn);
2421
+ // ── end Kit-C ──
2422
+ }
2423
+
2424
+ function openMarkdown(path: string): void {
2425
+ clearMain();
2426
+ statusBar.textContent = path;
2427
+ // ── v1.0 UI shell rework (Kit-C) ── mount in canvasMount.
2428
+ currentMarkdown = new MarkdownView(canvasMount);
2429
+ void currentMarkdown.load(path);
2430
+ }
2431
+
2432
+ function openMedia(entry: ProjectMediaEntry): void {
2433
+ clearMain();
2434
+ statusBar.textContent = entry.path;
2435
+ // ── v1.0 UI shell rework (Kit-C) ── mount in canvasMount.
2436
+ currentMedia = new MediaPreview(canvasMount);
2437
+ currentMedia.load(entry);
2438
+ // Inline delete affordance on the preview header — discoverable without
2439
+ // needing the sidebar right-click.
2440
+ const headerEl = currentMedia.el.querySelector(".atel-media-preview__header");
2441
+ if (headerEl && activeProjectName) {
2442
+ const projName = activeProjectName;
2443
+ const btn = document.createElement("button");
2444
+ btn.className = "atel-btn atel-btn--destructive";
2445
+ btn.style.cssText = "margin-left:auto;padding:6px 12px;background:#7a3a3a;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px";
2446
+ btn.textContent = "Delete";
2447
+ btn.addEventListener("click", async () => {
2448
+ const ok = await ConfirmDialog.prompt({
2449
+ title: `Delete "${entry.name}"?`,
2450
+ body: `This will remove the media file, its transcript (if any), its sidecar notes, ` +
2451
+ `and any layers in project.atelier that reference it. This cannot be undone.`,
2452
+ confirmLabel: "Delete",
2453
+ });
2454
+ if (!ok) return;
2455
+ try {
2456
+ await deleteJson(`/api/project/${encodeURIComponent(projName)}/media?file=${encodeURIComponent(entry.name)}`);
2457
+ await sidebar.refresh();
2458
+ clearMain();
2459
+ statusBar.textContent = `Deleted ${entry.name}`;
2460
+ } catch (err) {
2461
+ alert("Failed to delete: " + (err instanceof Error ? err.message : err));
2462
+ }
2463
+ });
2464
+ (headerEl as HTMLElement).style.display = "flex";
2465
+ (headerEl as HTMLElement).style.alignItems = "center";
2466
+ headerEl.appendChild(btn);
2467
+ }
2468
+ }
2469
+
2470
+ function openTranscript(entry: ProjectTranscriptEntry): void {
2471
+ clearMain();
2472
+ statusBar.textContent = entry.path;
2473
+ // ── v1.0 UI shell rework (Kit-C) ── mount in canvasMount.
2474
+ currentTranscript = new TranscriptViewer(canvasMount);
2475
+ void currentTranscript.load(entry.path, entry);
2476
+ }
2477
+
2478
+ // ─── Drag-and-drop ingest ─────────────────────────────────
2479
+ //
2480
+ // Two visual targets:
2481
+ // - **Sidebar** drop → always creates a new <slug>.atelier (legacy).
2482
+ // - **Canvas** drop with a loaded doc → POSTs `?targetDocPath=` so the
2483
+ // media attaches to the loaded doc. With no loaded doc, the canvas
2484
+ // pane degrades to the sidebar behaviour (new doc) — preserving the
2485
+ // "drop anywhere" UX from before the canvas split.
2486
+ const dropZone = new DropZone({
2487
+ getActiveProject: () => activeProjectName,
2488
+ getCurrentDocPath: () => currentDocPath,
2489
+ getSidebarElement: () => sidebar.el,
2490
+ // ── v1.0 UI shell rework (Kit-C) ── canvas overlay sizes to the canvas
2491
+ // half only — the timeline (Kit-B) lives in its own sibling and isn't a
2492
+ // drop target for the canvas-attach path.
2493
+ getCanvasElement: () => canvasMount,
2494
+ // ── end Kit-C ──
2495
+ onIngestStart: (filename) => {
2496
+ statusBar.textContent = `Ingesting ${filename}…`;
2497
+ },
2498
+ onIngestProgress: (phase, info) => {
2499
+ if (phase === "error") {
2500
+ statusBar.textContent = `Ingest failed: ${info?.error ?? "(unknown)"}`;
2501
+ } else {
2502
+ statusBar.textContent = `Ingest: ${phase}`;
2503
+ }
2504
+ },
2505
+ onIngestDone: async (filename) => {
2506
+ statusBar.textContent = `Ingested ${filename}`;
2507
+ if (activeProjectName) {
2508
+ await sidebar.refreshProject(activeProjectName);
2509
+ await openProjectDoc(activeProjectName);
2510
+ }
2511
+ },
2512
+ });
2513
+ void dropZone;
2514
+
2515
+ // ── v1.0 sidebar drag (Kit-D) ──
2516
+ // Wire the sidebar→canvas / sidebar→timeline media-ref handoff. These
2517
+ // handlers discriminate on `dataTransfer.types` so they don't clash with
2518
+ // the DropZone above (which handles OS-file drops via the "Files" type).
2519
+ attachMediaRefDropTarget(canvasMount, () => activeProjectName, () => currentDocPath);
2520
+ attachMediaRefDropTarget(timelineMount, () => activeProjectName, () => currentDocPath);
2521
+ // ── end v1.0 sidebar drag (Kit-D) ──
2522
+
2523
+ // ─── WS bridge for live updates ─────────────────────────
2524
+ connectBridge((env) => {
2525
+ if (env.type === "media:list:changed" || env.type === "transcript:added") {
2526
+ if (activeProjectName && (env as { projectName?: string }).projectName === activeProjectName) {
2527
+ void sidebar.refreshProject(activeProjectName);
2528
+ }
2529
+ } else if (env.type === "doc:list:changed") {
2530
+ // Doc surface changed (create / duplicate / delete from this or another
2531
+ // client). Refresh the sidebar, and — if the currently-loaded doc has
2532
+ // disappeared (deleted from disk, or unlinked as a slide) — fall back
2533
+ // to the first remaining doc, or show the empty-canvas state when no
2534
+ // docs remain (post-pivot, projects can legitimately be doc-less).
2535
+ const e = env as { projectName?: string };
2536
+ if (activeProjectName && e.projectName === activeProjectName) {
2537
+ const projectName = activeProjectName;
2538
+ void (async () => {
2539
+ await sidebar.refreshProject(projectName);
2540
+ const docs = sidebar.getDocs(projectName);
2541
+ const stillExists = currentDocPath !== null && docs.some((d) => d.path === currentDocPath);
2542
+ if (!stillExists) {
2543
+ if (docs.length === 0) {
2544
+ statusBar.textContent = `Loaded doc was removed — no docs remain`;
2545
+ showEmptyCanvas();
2546
+ } else if (currentDocPath !== null) {
2547
+ // Active doc vanished but other docs remain — load the first.
2548
+ statusBar.textContent = `Loaded doc was removed — falling back to ${docs[0].path}`;
2549
+ await openProjectDoc(projectName, docs[0].path);
2550
+ }
2551
+ }
2552
+ })();
2553
+ }
2554
+ } else if (env.type === "media:ingest:progress") {
2555
+ // ── v1.0 UI shell rework (Kit-C) ──
2556
+ // Pivot model: no-target ingests (the sidebar-half drop) now land in
2557
+ // `media/` and emit a `phase: "done"` envelope carrying `mediaPath` +
2558
+ // `fileKind` + `transcribePending`. Discriminator: presence of
2559
+ // `mediaPath` AND absence of `targetDocPath`. Surface those as a toast
2560
+ // (status bar is already noisy with other ops). Target-drop progress
2561
+ // (the canvas-half drop) keeps its existing status-bar behaviour.
2562
+ const e = env as {
2563
+ phase: string;
2564
+ file: string;
2565
+ error?: string;
2566
+ received?: number;
2567
+ total?: number | null;
2568
+ mediaPath?: string;
2569
+ fileKind?: string;
2570
+ transcribePending?: boolean;
2571
+ targetDocPath?: string;
2572
+ };
2573
+ if (e.phase === "error") {
2574
+ statusBar.textContent = `Ingest failed (${e.file}): ${e.error ?? "(unknown)"}`;
2575
+ } else if (e.phase === "model:download" && e.received !== undefined && e.total) {
2576
+ const pct = ((e.received / e.total) * 100).toFixed(1);
2577
+ statusBar.textContent = `Downloading model: ${pct}%`;
2578
+ } else if (e.phase === "done" && e.mediaPath && !e.targetDocPath) {
2579
+ // Basename: strip any path segments from mediaPath; fall back to `file`.
2580
+ const baseName = (e.mediaPath.split("/").pop() ?? e.file) || e.file;
2581
+ const msg = e.transcribePending
2582
+ ? `Added ${baseName} to media. Transcribing in background…`
2583
+ : `Added ${baseName} to media.`;
2584
+ ToastHost.show(msg);
2585
+ } else {
2586
+ statusBar.textContent = `Ingest ${e.file}: ${e.phase}`;
2587
+ }
2588
+ // ── end Kit-C ──
2589
+ } else if (env.type === "llm:mutation" && currentStudio) {
2590
+ // Wire LLM mutation through to the active doc (workspace mode preserves
2591
+ // the live loop the legacy single-doc mode already had).
2592
+ const e = env as { documentId: string; doc: unknown; source?: string };
2593
+ // Match WHICHEVER doc is currently loaded (post flat-docs pivot the
2594
+ // active doc isn't always `project.atelier`). Without this, mutations
2595
+ // for non-`project.atelier` docs were silently dropped, so the layers
2596
+ // panel + timeline only refreshed on a panel-switch reload.
2597
+ if (activeProjectName && currentDocPath && e.documentId === `${activeProjectName}/${currentDocPath}`) {
2598
+ currentStudio.applyMutation({ type: "doc:replace", doc: e.doc as never, source: (e.source as never) ?? "llm" });
2599
+ // Keep the timeline lane in sync with the layers panel. `applyMutation`
2600
+ // updates the studio component but the TimelineView has its own
2601
+ // `setDoc` cache that needs the same poke.
2602
+ timelineView?.setDoc(e.doc as never);
2603
+ }
2604
+ }
2605
+ });
2606
+
2607
+ await sidebar.refresh();
2608
+ // Open the active project on first paint, if one is set.
2609
+ try {
2610
+ const active = await fetchJson<ActiveProjectFile | null>("/api/workspace/active");
2611
+ if (active && !active.stale) {
2612
+ activeProjectName = active.name;
2613
+ await openProjectDoc(active.name);
2614
+ } else {
2615
+ statusBar.textContent = "Pick a project in the sidebar to begin.";
2616
+ }
2617
+ } catch {
2618
+ statusBar.textContent = "Pick a project in the sidebar to begin.";
2619
+ }
2620
+ }
2621
+
2622
+ // Augment AtelierStudio type to include applyMutation — present at runtime in
2623
+ // workspace 0.27+. Matches the same shim in inline-app.ts.
2624
+ declare module "@a-company/atelier-studio" {
2625
+ interface AtelierStudio {
2626
+ applyMutation(op: { type: "doc:replace"; doc: AtelierDoc; source: "human" | "llm" | "system" }): void;
2627
+ }
2628
+ }
2629
+
2630
+ function connectBridge(onMessage: (env: { type: string }) => void): void {
2631
+ const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/bridge";
2632
+ let socket: WebSocket | null = null;
2633
+ const open = (): void => {
2634
+ try {
2635
+ socket = new WebSocket(url);
2636
+ } catch {
2637
+ setTimeout(open, 1500);
2638
+ return;
2639
+ }
2640
+ socket.addEventListener("message", (ev) => {
2641
+ try {
2642
+ const env = JSON.parse(ev.data);
2643
+ onMessage(env);
2644
+ } catch { /* ignore */ }
2645
+ });
2646
+ socket.addEventListener("close", () => {
2647
+ setTimeout(open, 1500);
2648
+ });
2649
+ };
2650
+ open();
2651
+ }
2652
+
2653
+ // ─── Utilities ──────────────────────────────────────────────────────
2654
+
2655
+ function escapeHtml(s: string): string {
2656
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2657
+ }
2658
+
2659
+ function formatBytes(n: number): string {
2660
+ if (n < 1024) return `${n} B`;
2661
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2662
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
2663
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
2664
+ }
2665
+
2666
+ function formatTime(sec: number): string {
2667
+ const m = Math.floor(sec / 60);
2668
+ const s = (sec - m * 60).toFixed(2);
2669
+ return `${m}:${s.padStart(5, "0")}`;
2670
+ }
2671
+
2672
+ function kindIcon(kind: string): string {
2673
+ switch (kind) {
2674
+ case "video": return "🎬";
2675
+ case "image": return "🖼";
2676
+ case "audio": return "🎵";
2677
+ case "font": return "🔤";
2678
+ default: return "📄";
2679
+ }
2680
+ }
2681
+
2682
+ /** Icon for a non-manifest .atelier doc row (slide / other). */
2683
+ function docKindIcon(kind: string | undefined): string {
2684
+ switch (kind) {
2685
+ case "video": return "🎬";
2686
+ case "image": return "🖼";
2687
+ case "carousel": return "🎠";
2688
+ default: return "▤";
2689
+ }
2690
+ }
2691
+
2692
+ // ─── Inline styles for the workspace UI ─────────────────────────────
2693
+
2694
+ function injectStyles(): void {
2695
+ if (document.getElementById("atel-workspace-styles")) return;
2696
+ const style = document.createElement("style");
2697
+ style.id = "atel-workspace-styles";
2698
+ style.textContent = `
2699
+ * { box-sizing: border-box; }
2700
+ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #2C2C2C; color: #F5F0EB; }
2701
+ /* ── Typography legibility pass 2026-05-28 ──
2702
+ Inter as the base sans (was Cormorant Garamond, an editorial serif that
2703
+ lost detail in dense UI chrome). Bumped base font-size from default 16px
2704
+ to 15px on body (Inter renders denser than Cormorant at the same px), then
2705
+ individual rules below add +1-2px to every label/list/menu that was 10-12px
2706
+ so creators don't squint. Line-heights raised to 1.4-1.5 for body copy,
2707
+ antialiased fonts for crisper rendering on macOS Retina. */
2708
+ html, body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 15px; line-height: 1.45; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
2709
+ #studio { height: 100vh; width: 100vw; display: flex; }
2710
+
2711
+ .atel-workspace-shell { display: flex; flex: 1; min-height: 0; }
2712
+ .atel-workspace-sidebar {
2713
+ width: 320px; min-width: 320px; background: #333; border-right: 1px solid #4A4A4A;
2714
+ display: flex; flex-direction: column; overflow: hidden;
2715
+ }
2716
+ .atel-workspace-sidebar__header {
2717
+ padding: 16px 18px; border-bottom: 1px solid #4A4A4A;
2718
+ }
2719
+ .atel-workspace-sidebar__title {
2720
+ font-size: 12px; font-weight: 600; letter-spacing: 1.4px; color: #A89F95; text-transform: uppercase;
2721
+ }
2722
+ .atel-workspace-sidebar__section {
2723
+ margin-top: 14px; display: flex; align-items: center; justify-content: space-between;
2724
+ font-size: 11px; letter-spacing: 1.4px; text-transform: uppercase; color: #A89F95;
2725
+ }
2726
+ .atel-workspace-sidebar__new-btn {
2727
+ background: #3D3D3D; color: #F5F0EB; border: 1px solid #4A4A4A; border-radius: 3px;
2728
+ padding: 3px 10px; font-size: 11px; cursor: pointer; font-family: inherit;
2729
+ }
2730
+ .atel-workspace-sidebar__list { flex: 1; overflow-y: auto; padding: 8px 0; }
2731
+
2732
+ .atel-workspace-sidebar__project { padding: 5px 0; }
2733
+ .atel-workspace-sidebar__project-header {
2734
+ padding: 8px 16px; cursor: pointer; font-size: 14px; color: #A89F95;
2735
+ display: flex; align-items: center; gap: 6px;
2736
+ }
2737
+ .atel-workspace-sidebar__project-header:hover { color: #F5F0EB; background: #363636; }
2738
+ .atel-workspace-sidebar__project--active .atel-workspace-sidebar__project-header { color: #C75B39; }
2739
+ .atel-project-name { font-weight: 600; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; }
2740
+ .atel-active-marker { color: #C75B39; font-size: 12px; }
2741
+
2742
+ .atel-workspace-sidebar__group {
2743
+ margin: 8px 0; padding: 0 16px;
2744
+ }
2745
+ .atel-workspace-sidebar__group-label {
2746
+ font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: #6E6862; padding: 5px 8px 3px;
2747
+ }
2748
+ .atel-workspace-sidebar__item {
2749
+ padding: 6px 8px 6px 22px; font-size: 13px; cursor: pointer; color: #A89F95;
2750
+ font-family: 'SF Mono', 'Fira Code', monospace; border-radius: 3px;
2751
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2752
+ line-height: 1.4;
2753
+ }
2754
+ .atel-workspace-sidebar__item:hover { background: #363636; color: #F5F0EB; }
2755
+ /* Currently-loaded doc row — accent color + bold to mirror the active-project treatment. */
2756
+ .atel-workspace-sidebar__item--loaded { color: #C75B39; }
2757
+ .atel-workspace-sidebar__item--loaded:hover { color: #D4724E; background: #363636; }
2758
+ .atel-loaded-marker { color: #C75B39; font-size: 11px; letter-spacing: 0.5px; }
2759
+ .atel-workspace-sidebar__empty {
2760
+ padding: 6px 8px 4px 22px; font-size: 12px; color: #6E6862;
2761
+ font-family: 'SF Mono', 'Fira Code', monospace; font-style: italic;
2762
+ }
2763
+ .atel-item-icon { display: inline-block; width: 20px; }
2764
+ .atel-item-meta { color: #6E6862; font-size: 12px; }
2765
+ .atel-badge {
2766
+ display: inline-block; background: #3D3D3D; padding: 2px 6px; border-radius: 2px;
2767
+ font-size: 11px; letter-spacing: 0.5px; margin-left: 5px; color: #A89F95;
2768
+ }
2769
+ .atel-badge--ok { color: #6B8E6B; }
2770
+
2771
+ .atel-workspace-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
2772
+ .atel-workspace-status {
2773
+ height: 34px; min-height: 34px; padding: 0 18px; display: flex; align-items: center;
2774
+ background: #333; border-bottom: 1px solid #4A4A4A;
2775
+ font-size: 12px; color: #A89F95; font-family: 'SF Mono', 'Fira Code', monospace;
2776
+ }
2777
+ /* ── v1.0 UI shell rework (Kit-C) ──
2778
+ mainBody (.atel-workspace-body) is the vertical flex container:
2779
+ - canvasMount (.atel-workspace-canvas) inherits the legacy body role —
2780
+ scrollable, position:relative for overlays (drop zone, + add slide).
2781
+ - timelineMount (.atel-workspace-timeline) is a 120px sibling at the
2782
+ bottom for Kit-B's TimelineView. */
2783
+ .atel-workspace-body {
2784
+ flex: 1; min-height: 0;
2785
+ display: flex; flex-direction: column;
2786
+ }
2787
+ .atel-workspace-canvas {
2788
+ flex: 1 1 auto; min-height: 0;
2789
+ /* overflow: hidden — was 'auto', but that lets the embedded AtelierStudio
2790
+ grow past the visible viewport; the canvas-panel's letterbox-fit then
2791
+ reads getBoundingClientRect against the unconstrained height and the
2792
+ canvas spills below the timeline lane. Constraining here lets the
2793
+ studio's INTERNAL flex layout (flex:1 + min-height:0) own the height.
2794
+ Scrollable children (markdown view, etc.) manage their own overflow. */
2795
+ overflow: hidden; position: relative;
2796
+ }
2797
+ .atel-workspace-timeline {
2798
+ flex: 0 0 120px; height: 120px;
2799
+ border-top: 1px solid var(--atel-border, #4A4A4A);
2800
+ background: var(--atel-bg-2, #1f1f1f);
2801
+ overflow: hidden;
2802
+ }
2803
+ /* ── end Kit-C ── */
2804
+
2805
+ /* ── v1.0 bottom timeline (Kit-B) ──
2806
+ TimelineView (timeline-view.ts) renders a ruler + lane + playhead inside
2807
+ timelineMount. The JS sets only 'left' / 'width' inline on ticks, clips,
2808
+ and playhead — without these 'position:absolute' rules they would stack
2809
+ vertically as block divs (which is exactly the bug owner saw: the frame
2810
+ ruler numbers wrapping one-per-line on the left edge of the timeline).
2811
+ Ruler + lane are positioned containers so absolute children anchor to
2812
+ them, not to the viewport. */
2813
+ .atel-timeline-view {
2814
+ position: relative;
2815
+ width: 100%; height: 100%;
2816
+ display: flex; flex-direction: column;
2817
+ color: var(--atel-text, #F5F0EB);
2818
+ font-family: 'SF Mono', 'Fira Code', monospace;
2819
+ }
2820
+ .atel-timeline-view__ruler {
2821
+ position: relative;
2822
+ flex: 0 0 22px; height: 22px;
2823
+ border-bottom: 1px solid var(--atel-border, #4A4A4A);
2824
+ cursor: pointer;
2825
+ user-select: none;
2826
+ }
2827
+ .atel-timeline-view__tick {
2828
+ position: absolute; top: 0;
2829
+ transform: translateX(-50%);
2830
+ font-size: 11px; color: var(--atel-text-muted, #A89F95);
2831
+ padding: 4px 0;
2832
+ pointer-events: none;
2833
+ }
2834
+ .atel-timeline-view__lane {
2835
+ position: relative;
2836
+ flex: 1 1 auto; min-height: 0;
2837
+ background: var(--atel-bg-3, #181818);
2838
+ }
2839
+ .atel-timeline-view__clip {
2840
+ position: absolute; top: 6px; bottom: 6px;
2841
+ background: var(--atel-button-bg, #3D3D3D);
2842
+ border: 1px solid var(--atel-border, #4A4A4A);
2843
+ border-radius: 3px;
2844
+ overflow: hidden;
2845
+ cursor: pointer;
2846
+ display: flex; align-items: center;
2847
+ padding: 0 6px;
2848
+ box-sizing: border-box;
2849
+ }
2850
+ .atel-timeline-view__clip:hover { background: #4A4A4A; }
2851
+ .atel-timeline-view__clip--selected {
2852
+ border-color: var(--atel-accent, #C75B39);
2853
+ background: rgba(199, 91, 57, 0.15);
2854
+ }
2855
+ .atel-timeline-view__clip-label {
2856
+ font-size: 12px; color: var(--atel-text, #F5F0EB);
2857
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
2858
+ }
2859
+ .atel-timeline-view__playhead {
2860
+ position: absolute; top: 0; bottom: 0;
2861
+ width: 1px;
2862
+ background: var(--atel-accent, #C75B39);
2863
+ pointer-events: none;
2864
+ z-index: 2;
2865
+ }
2866
+ .atel-timeline-view__empty {
2867
+ position: absolute; inset: 22px 0 0 0;
2868
+ display: flex; align-items: center; justify-content: center;
2869
+ font-size: 12px; color: var(--atel-text-muted, #A89F95);
2870
+ pointer-events: none;
2871
+ }
2872
+ /* ── end Kit-B ── */
2873
+
2874
+ /* ── v1.0 sidebar drag (Kit-D) ──
2875
+ - Dim the dragged MEDIA row while a drag is in-flight (mirrors the
2876
+ OS-native ghost more cleanly than a transform).
2877
+ - When a media-ref drag is over the canvas or timeline drop target,
2878
+ paint an inset dashed accent outline + accent-tinted background so
2879
+ the creator can see where the drop will land. The class is applied
2880
+ in dragenter and removed in dragleave/drop. */
2881
+ .atel-media-row--dragging { opacity: 0.5; }
2882
+ .atel-media-ref-drop-target {
2883
+ outline: 2px dashed var(--atel-accent, #C75B39);
2884
+ outline-offset: -4px;
2885
+ background: rgba(199, 91, 57, 0.08);
2886
+ }
2887
+ /* ── end v1.0 sidebar drag (Kit-D) ── */
2888
+
2889
+ /* Markdown view */
2890
+ .atel-markdown-view { padding: 20px 32px; max-width: 880px; margin: 0 auto; color: #F5F0EB; }
2891
+ .atel-markdown-view__header {
2892
+ display: flex; align-items: center; justify-content: space-between;
2893
+ border-bottom: 1px solid #4A4A4A; padding-bottom: 12px; margin-bottom: 20px;
2894
+ }
2895
+ .atel-markdown-view__title { font-size: 14px; color: #A89F95; font-family: monospace; }
2896
+ .atel-markdown-view__btn {
2897
+ background: #3D3D3D; color: #F5F0EB; border: 1px solid #4A4A4A; border-radius: 3px;
2898
+ padding: 4px 12px; font-family: inherit; cursor: pointer; font-size: 12px; margin-left: 6px;
2899
+ }
2900
+ .atel-markdown-view__body { font-size: 15px; line-height: 1.6; }
2901
+ .atel-markdown-view__editor {
2902
+ width: 100%; min-height: 70vh; background: #1f1f1f; color: #F5F0EB;
2903
+ border: 1px solid #4A4A4A; padding: 12px; font-family: 'SF Mono', monospace; font-size: 13px;
2904
+ }
2905
+ .atel-markdown-view__frontmatter {
2906
+ border-collapse: collapse; margin-bottom: 20px; font-family: 'SF Mono', monospace; font-size: 12px;
2907
+ }
2908
+ .atel-markdown-view__frontmatter th, .atel-markdown-view__frontmatter td {
2909
+ border: 1px solid #4A4A4A; padding: 4px 10px; text-align: left;
2910
+ }
2911
+ .atel-markdown-view__frontmatter th { background: #333; color: #A89F95; }
2912
+ .atel-md-h1, .atel-md-h2, .atel-md-h3 { color: #F5F0EB; margin-top: 18px; margin-bottom: 8px; }
2913
+ .atel-md-h1 { font-size: 26px; }
2914
+ .atel-md-h2 { font-size: 20px; }
2915
+ .atel-md-h3 { font-size: 16px; }
2916
+ .atel-md-list { padding-left: 24px; }
2917
+ .atel-md-code { background: #1f1f1f; padding: 1px 4px; border-radius: 2px; font-family: 'SF Mono', monospace; font-size: 13px; }
2918
+ .atel-md-pre { background: #1f1f1f; padding: 12px; border-radius: 4px; overflow-x: auto; }
2919
+ .atel-md-quote { border-left: 3px solid #4A4A4A; padding-left: 12px; color: #A89F95; margin: 8px 0; }
2920
+
2921
+ /* Media preview */
2922
+ .atel-media-preview { padding: 20px 32px; }
2923
+ .atel-media-preview__header { margin-bottom: 16px; }
2924
+ .atel-media-preview__title { font-size: 16px; color: #F5F0EB; }
2925
+ .atel-media-preview__meta { font-size: 11px; color: #A89F95; font-family: monospace; margin-top: 2px; }
2926
+ .atel-media-preview__body { display: block; }
2927
+ .atel-media-preview__body--split {
2928
+ display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 24px;
2929
+ align-items: start;
2930
+ }
2931
+ .atel-media-preview__player { min-width: 0; }
2932
+ .atel-media-preview__transcript {
2933
+ min-width: 0; max-height: calc(100vh - 160px); overflow-y: auto;
2934
+ padding-right: 8px;
2935
+ border-left: 1px solid #4A4A4A;
2936
+ padding-left: 20px;
2937
+ }
2938
+ .atel-media-preview__transcript-loading { color: #A89F95; padding: 8px 0; font-size: 12px; }
2939
+ .atel-media-preview__transcript-segment {
2940
+ border-left: 2px solid #4A4A4A; padding: 6px 10px; margin: 5px 0;
2941
+ }
2942
+ .atel-media-preview__transcript-time {
2943
+ font-size: 11px; color: #6E6862; font-family: monospace; margin-bottom: 3px;
2944
+ }
2945
+ .atel-media-preview__transcript-text { font-size: 13px; line-height: 1.55; color: #F5F0EB; }
2946
+ .atel-media-preview__video, .atel-media-preview__image { max-width: 100%; max-height: 70vh; background: #000; }
2947
+ .atel-media-preview__audio { width: 100%; }
2948
+ .atel-media-preview__noop { color: #A89F95; padding: 24px; }
2949
+ /* Shared transcript-word styles — used by both MediaPreview's right pane
2950
+ and the standalone TranscriptViewer. */
2951
+ .atel-transcript-word { cursor: pointer; padding: 0 1px; border-radius: 2px; transition: background 80ms ease; }
2952
+ .atel-transcript-word:hover { background: rgba(199,91,57,0.18); }
2953
+ .atel-transcript-word--current {
2954
+ background: rgba(199,91,57,0.35); color: #FFF;
2955
+ }
2956
+
2957
+ /* Transcript viewer */
2958
+ .atel-transcript-viewer { padding: 20px 32px; max-width: 880px; margin: 0 auto; }
2959
+ .atel-transcript-viewer__title { font-size: 16px; color: #F5F0EB; }
2960
+ .atel-transcript-viewer__meta { font-size: 11px; color: #A89F95; font-family: monospace; margin-top: 2px; margin-bottom: 16px; }
2961
+ .atel-transcript-viewer__segment {
2962
+ border-left: 2px solid #4A4A4A; padding: 8px 12px; margin: 6px 0;
2963
+ }
2964
+ .atel-transcript-viewer__time {
2965
+ font-size: 11px; color: #6E6862; font-family: monospace; margin-bottom: 4px;
2966
+ }
2967
+ .atel-transcript-viewer__text { font-size: 14px; line-height: 1.5; }
2968
+ .atel-transcript-viewer__word { cursor: default; }
2969
+ .atel-transcript-viewer__word:hover { background: rgba(199,91,57,0.15); }
2970
+
2971
+ /* Confirm dialog — floating centered modal with backdrop */
2972
+ .atel-confirm-dialog {
2973
+ position: fixed; inset: 0; background: rgba(0,0,0,0.7);
2974
+ z-index: 9500;
2975
+ display: flex; align-items: center; justify-content: center;
2976
+ backdrop-filter: blur(2px);
2977
+ }
2978
+ .atel-confirm-dialog__card {
2979
+ background: #1F1B17; color: #F5F0EB;
2980
+ border: 1px solid #3A3530; border-radius: 10px;
2981
+ box-shadow: 0 20px 60px rgba(0,0,0,0.55);
2982
+ padding: 24px 28px; max-width: 480px; min-width: 360px;
2983
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
2984
+ }
2985
+ .atel-confirm-dialog__title {
2986
+ font-size: 18px; font-weight: 600; margin-bottom: 14px;
2987
+ color: #F5F0EB;
2988
+ }
2989
+ .atel-confirm-dialog__body {
2990
+ font-size: 13px; line-height: 1.55; color: #C5BEB7;
2991
+ margin-bottom: 18px;
2992
+ }
2993
+ .atel-confirm-dialog__type-to-confirm { margin: 0 0 16px 0; }
2994
+ .atel-confirm-dialog__type-label {
2995
+ display: block; font-size: 12px; color: #A89F95; margin-bottom: 6px;
2996
+ }
2997
+ .atel-confirm-dialog__type-input {
2998
+ width: 100%; box-sizing: border-box;
2999
+ background: #15120F; border: 1px solid #3A3530; border-radius: 4px;
3000
+ color: #F5F0EB; padding: 8px 10px; font-size: 13px;
3001
+ font-family: ui-monospace, monospace;
3002
+ }
3003
+ .atel-confirm-dialog__type-input:focus { outline: none; border-color: #C75B39; }
3004
+ .atel-confirm-dialog__buttons {
3005
+ display: flex; justify-content: flex-end; gap: 10px;
3006
+ }
3007
+ .atel-confirm-dialog__btn {
3008
+ padding: 8px 16px; font-size: 13px; border: none; border-radius: 4px;
3009
+ cursor: pointer; font-weight: 500; transition: background 120ms ease;
3010
+ }
3011
+ .atel-confirm-dialog__btn--cancel {
3012
+ background: #2A2520; color: #F5F0EB;
3013
+ }
3014
+ .atel-confirm-dialog__btn--cancel:hover { background: #34302C; }
3015
+ .atel-confirm-dialog__btn--danger {
3016
+ background: #A0432B; color: #FFFFFF;
3017
+ }
3018
+ .atel-confirm-dialog__btn--danger:hover:not(:disabled) { background: #C75B39; }
3019
+ .atel-confirm-dialog__btn--danger:disabled {
3020
+ background: #4A3530; color: #7E6E66; cursor: not-allowed;
3021
+ }
3022
+
3023
+ /* Group label with inline action (e.g. "+ new doc" next to DOCUMENT). */
3024
+ .atel-workspace-sidebar__group-label--with-action {
3025
+ display: flex; align-items: center; justify-content: space-between; gap: 6px;
3026
+ }
3027
+ .atel-workspace-sidebar__new-btn--inline {
3028
+ padding: 1px 6px; font-size: 11px; letter-spacing: 0.5px;
3029
+ }
3030
+
3031
+ /* NewDocDialog — three-tile picker, slug preview, optional slide toggle. */
3032
+ .atel-new-doc-dialog__card { min-width: 440px; max-width: 520px; }
3033
+ .atel-new-doc-dialog__tiles {
3034
+ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-bottom: 18px;
3035
+ }
3036
+ .atel-new-doc-dialog__tile {
3037
+ background: #2A2520; color: #F5F0EB; border: 1px solid #3A3530; border-radius: 6px;
3038
+ padding: 14px 8px; cursor: pointer; text-align: center;
3039
+ font-family: inherit; transition: background 120ms ease, border-color 120ms ease;
3040
+ }
3041
+ .atel-new-doc-dialog__tile:hover { background: #34302C; }
3042
+ .atel-new-doc-dialog__tile--selected {
3043
+ background: #3A1F1A; border-color: #C75B39;
3044
+ }
3045
+ .atel-new-doc-dialog__tile-icon { font-size: 28px; line-height: 1; }
3046
+ .atel-new-doc-dialog__tile-label {
3047
+ margin-top: 6px; font-size: 12px; letter-spacing: 0.5px;
3048
+ }
3049
+ .atel-new-doc-dialog__field { margin-bottom: 14px; }
3050
+ .atel-new-doc-dialog__slug-preview {
3051
+ margin-top: 6px; font-size: 11px; color: #6E6862;
3052
+ font-family: ui-monospace, monospace; min-height: 14px;
3053
+ }
3054
+ .atel-new-doc-dialog__toggle {
3055
+ display: flex; align-items: center; gap: 8px;
3056
+ font-size: 12px; color: #C5BEB7; margin: 4px 0 14px 0;
3057
+ cursor: pointer;
3058
+ }
3059
+ .atel-new-doc-dialog__toggle input { cursor: pointer; }
3060
+ .atel-new-doc-dialog__error {
3061
+ background: #3A1F1A; border: 1px solid #6A2F22; color: #E07B5C;
3062
+ padding: 8px 10px; border-radius: 4px; font-size: 12px; margin-bottom: 12px;
3063
+ }
3064
+ .atel-confirm-dialog__btn--primary {
3065
+ background: #C75B39; color: #FFFFFF;
3066
+ }
3067
+ .atel-confirm-dialog__btn--primary:hover:not(:disabled) { background: #D4724E; }
3068
+ .atel-confirm-dialog__btn--primary:disabled {
3069
+ background: #4A3530; color: #7E6E66; cursor: not-allowed;
3070
+ }
3071
+
3072
+ /* Context menu (right-click in sidebar) */
3073
+ .atel-context-menu {
3074
+ position: fixed; z-index: 9300;
3075
+ background: #1F1B17; border: 1px solid #3A3530; border-radius: 6px;
3076
+ box-shadow: 0 10px 30px rgba(0,0,0,0.45);
3077
+ padding: 4px 0; min-width: 180px;
3078
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
3079
+ font-size: 13px;
3080
+ }
3081
+ .atel-context-menu__item {
3082
+ padding: 8px 16px; cursor: pointer; color: #F5F0EB;
3083
+ }
3084
+ .atel-context-menu__item:hover { background: #2A2520; }
3085
+ .atel-context-menu__item--destructive { color: #E07B5C; }
3086
+ .atel-context-menu__item--destructive:hover { background: #3A1F1A; color: #F5F0EB; }
3087
+
3088
+ /* Drop zone overlay — split into sidebar + canvas highlight panes.
3089
+ The outer .atel-drop-zone is a transparent click-through layer; the
3090
+ two .atel-drop-zone__pane children own the visual targets. Each pane is
3091
+ absolutely positioned over its underlying element (sidebar / mainBody)
3092
+ and toggled with --active as the cursor hovers it. */
3093
+ .atel-drop-zone {
3094
+ position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 9000;
3095
+ pointer-events: none; opacity: 0; transition: opacity 150ms ease;
3096
+ }
3097
+ .atel-drop-zone--visible { opacity: 1; pointer-events: auto; }
3098
+ .atel-drop-zone__pane {
3099
+ position: fixed; display: flex; align-items: center; justify-content: center;
3100
+ background: rgba(20,20,20,0.55);
3101
+ border: 3px dashed rgba(199, 91, 57, 0.45);
3102
+ transition: background 120ms ease, border-color 120ms ease;
3103
+ box-sizing: border-box;
3104
+ }
3105
+ .atel-drop-zone__pane--active {
3106
+ background: rgba(199, 91, 57, 0.18);
3107
+ border-color: #C75B39;
3108
+ }
3109
+ .atel-drop-zone__inner {
3110
+ border-radius: 12px; padding: 32px 36px; text-align: center;
3111
+ background: #2C2C2C; max-width: 80%;
3112
+ }
3113
+ .atel-drop-zone__icon { font-size: 40px; color: #C75B39; }
3114
+ .atel-drop-zone__title { font-size: 16px; margin-top: 12px; color: #F5F0EB; }
3115
+ .atel-drop-zone__project { color: #C75B39; font-family: monospace; }
3116
+ .atel-drop-zone__hint { font-size: 11px; color: #A89F95; margin-top: 6px; }
3117
+
3118
+ /* v1.0 UI shell rework (Kit-C). Toast host: bottom-right column above the
3119
+ 32px status bar. Each .atel-toast is a small dark card with an accent
3120
+ left border, fades in for ~140ms, lives 4s, fades out for ~200ms before
3121
+ removal. Stacks vertically when multiple drops happen in quick succession. */
3122
+ .atel-toast-stack {
3123
+ position: fixed; right: 16px; bottom: 48px;
3124
+ display: flex; flex-direction: column; gap: 8px;
3125
+ z-index: 9400; pointer-events: none;
3126
+ max-width: 360px;
3127
+ }
3128
+ .atel-toast {
3129
+ background: var(--atel-bg-2, #1F1B17);
3130
+ color: var(--atel-text, #F5F0EB);
3131
+ border: 1px solid var(--atel-border, #3A3530);
3132
+ border-left: 3px solid var(--atel-accent, #C75B39);
3133
+ border-radius: 4px;
3134
+ padding: 10px 14px;
3135
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
3136
+ font-size: 12px; line-height: 1.45;
3137
+ box-shadow: 0 8px 24px rgba(0,0,0,0.45);
3138
+ opacity: 0; transform: translateY(6px);
3139
+ transition: opacity 140ms ease, transform 140ms ease;
3140
+ pointer-events: auto;
3141
+ }
3142
+ .atel-toast--visible { opacity: 1; transform: translateY(0); }
3143
+ /* ── end Kit-C ── */
3144
+ `;
3145
+ document.head.appendChild(style);
3146
+ }