@dailies-studio/shot-editor 0.1.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 (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,564 @@
1
+ // The left sidebar's File tab: scenes, the layer tree inside the open one, and
2
+ // how to connect an agent (McpSection.tsx).
3
+ //
4
+ // TWO LEVELS, ONE BIT. `state.sceneId` decides which scene's layers are listed,
5
+ // and it is the same bit the timeline's macro/micro split reads and the URL
6
+ // mirrors. There is no second "which scene am I looking at" anywhere.
7
+ //
8
+ // THE TREE IS RENDERED FROM `scene.layers` AND `children`, never from
9
+ // `layersById` — the outline is the forest, and `order` is a separate axis that
10
+ // decides PAINT. Listing by `order` would put a group's children next to
11
+ // strangers, and listing by `layersById` would have no order at all.
12
+ import { useEffect, useState } from "react";
13
+ import { IconChevron, useContextMenu } from "@dailies-studio/ui";
14
+ import { componentDefsOf, homeOf, instancesOf, layerChildren, parentOf } from "@dailies-studio/shot/doc";
15
+ import type { Layer3D } from "@dailies-studio/shot/doc";
16
+ import { useShotEditor, useStore } from "../../state/context.js";
17
+ import { layerMenuEntries, menuTargetsFor } from "../../state/menu.js";
18
+ import {
19
+ TRIANGLE_SOFT_BUDGET,
20
+ formatTriangles,
21
+ sceneBudgetWarning,
22
+ sumTriangles,
23
+ } from "../../state/triangles.js";
24
+ import { McpSection } from "./McpSection.js";
25
+ import {
26
+ COMPONENT_ICON,
27
+ EYE_ICON,
28
+ EYE_OFF_ICON,
29
+ LOCK_ICON,
30
+ SCENE_ICON,
31
+ UNLOCK_ICON,
32
+ kindIcon,
33
+ } from "./icons.js";
34
+
35
+ export function FilePanel() {
36
+ const store = useStore();
37
+ const doc = useShotEditor((s) => s.doc);
38
+ const sceneId = useShotEditor((s) => s.sceneId);
39
+ const isolation = useShotEditor((s) => s.isolation);
40
+ const editingDef = useShotEditor((s) => s.editingDef);
41
+ const [query, setQuery] = useState("");
42
+ /** Collapsed subtree roots. PER-SESSION on purpose, never a workspace pref:
43
+ * prefs survive document switches and layer ids are document-scoped, so a
44
+ * persisted set would carry stale ids into every other document. */
45
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
46
+ /** The last plainly-clicked row — shift-click ranges from it over the
47
+ * VISIBLE flattened order, the Figma/Blender behaviour. */
48
+ const [anchor, setAnchor] = useState<string | null>(null);
49
+ /**
50
+ * BOTH ARE DROPPED WHEN THE DOCUMENT CHANGES, and that is not tidiness.
51
+ * This panel is mounted unconditionally and never keyed (App.tsx), and a
52
+ * document switch swaps `state.doc` underneath it rather than remounting
53
+ * it — while ids are deterministic slugs, so `l3_group` exists in nearly
54
+ * every shot. Kept, the set folds a group in the NEW document that nobody
55
+ * folded, and the anchor makes the author's first shift-click there a range
56
+ * measured from a click they made in a different document.
57
+ */
58
+ const docId = doc?.id ?? null;
59
+ useEffect(() => {
60
+ setCollapsed(new Set());
61
+ setAnchor(null);
62
+ }, [docId]);
63
+
64
+ if (doc === null) return null;
65
+ const scene = sceneId === null ? undefined : doc.scenesById[sceneId];
66
+ const defs = componentDefsOf(doc);
67
+ // The forest the outline shows: a definition's while one is being edited,
68
+ // the open shot's otherwise. ONE lookup, so the rows below never branch.
69
+ const home = homeOf(doc, editingDef ?? sceneId ?? "");
70
+ const trimmedQuery = query.trim().toLowerCase();
71
+ const rootIds =
72
+ home === null
73
+ ? []
74
+ : isolation === null || editingDef !== null
75
+ ? home.layers
76
+ : layerChildren(home.layersById[isolation] ?? ({} as Layer3D));
77
+
78
+ const toggleCollapse = (id: string): void => {
79
+ setCollapsed((prev) => {
80
+ const next = new Set(prev);
81
+ if (next.has(id)) next.delete(id);
82
+ else next.add(id);
83
+ return next;
84
+ });
85
+ };
86
+
87
+ const rowClick = (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void => {
88
+ const selection = store.getState().selection;
89
+ if (e.shiftKey && anchor !== null && home !== null) {
90
+ // The range is over WHAT IS ON SCREEN — the same flattening the rows
91
+ // render from, so a collapsed group's invisible children are never
92
+ // swept into a selection the author cannot see.
93
+ const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
94
+ const a = flat.indexOf(anchor);
95
+ const b = flat.indexOf(id);
96
+ if (a !== -1 && b !== -1) {
97
+ store.select(flat.slice(Math.min(a, b), Math.max(a, b) + 1));
98
+ return;
99
+ }
100
+ // A stale anchor (deleted, scoped away) falls through to a plain click.
101
+ }
102
+ if (e.ctrlKey || e.metaKey) {
103
+ store.select(selection.includes(id) ? selection.filter((x) => x !== id) : [...selection, id]);
104
+ setAnchor(id);
105
+ return;
106
+ }
107
+ store.select([id]);
108
+ setAnchor(id);
109
+ };
110
+
111
+ return (
112
+ <div className="file-panel" data-testid="file-panel">
113
+ <label className="file-search">
114
+ <input
115
+ type="search"
116
+ value={query}
117
+ placeholder="Search layers"
118
+ aria-label="Search layers"
119
+ onChange={(e) => setQuery(e.target.value)}
120
+ />
121
+ </label>
122
+
123
+ <div className="file-section-label">Shots</div>
124
+ <ul className="scene-list">
125
+ {doc.scenes.map((id) => {
126
+ const s = doc.scenesById[id];
127
+ if (s === undefined) return null;
128
+ return (
129
+ <li key={id}>
130
+ <button
131
+ type="button"
132
+ className={`scene-row${id === sceneId ? " is-current" : ""}`}
133
+ aria-current={id === sceneId}
134
+ // `focusScene`, not `setScene`: a bare pin leaves the playhead
135
+ // in whatever scene it was already in, and every store call
136
+ // that pairs `state.sceneId` with `masterToScene(state.frame)`
137
+ // — insertion, block placement, split, camera shots — then
138
+ // reads its scene from one scene and its frame from another.
139
+ // The stage renders the playhead's scene, so a pin the playhead
140
+ // is not in is also a scene the author cannot see.
141
+ onClick={() => store.focusScene(id)}
142
+ >
143
+ <span className="layer-kind" aria-hidden="true">
144
+ {SCENE_ICON}
145
+ </span>
146
+ <span className="row-label">{s.name}</span>
147
+ </button>
148
+ </li>
149
+ );
150
+ })}
151
+ </ul>
152
+
153
+ {/* COMPONENTS, above the layers and below the shots — a definition is
154
+ not in any shot, and the list is what makes a zero-instance one
155
+ reachable at all. Absent entirely when the document has none, so a
156
+ shot that never made one looks exactly as it did. */}
157
+ {Object.keys(defs).length === 0 ? null : (
158
+ <>
159
+ <div className="file-section-label">Components</div>
160
+ <ul className="component-list" data-testid="component-list">
161
+ {Object.values(defs).map((d) => (
162
+ <li key={d.id} className={`component-row${d.id === editingDef ? " is-current" : ""}`}>
163
+ <span className="layer-kind" aria-hidden="true">
164
+ {COMPONENT_ICON}
165
+ </span>
166
+ <button
167
+ type="button"
168
+ className="layer-name"
169
+ aria-current={d.id === editingDef}
170
+ // Scoping the OUTLINE, not the stage: a definition's layers
171
+ // are in no scene and have no camera to point at them.
172
+ onClick={() => store.editComponent(d.id === editingDef ? null : d.id)}
173
+ >
174
+ {d.name}
175
+ </button>
176
+ <span className="component-count" aria-hidden="true">
177
+ {instancesOf(doc, d.id).length}
178
+ </span>
179
+ <button
180
+ type="button"
181
+ className="layer-flag"
182
+ aria-label={`Place ${d.name}`}
183
+ title="Place another one"
184
+ onClick={() => store.placeComponent(d.id)}
185
+ >
186
+ +
187
+ </button>
188
+ </li>
189
+ ))}
190
+ </ul>
191
+ </>
192
+ )}
193
+
194
+ <div className="file-section-label">{editingDef === null ? "Layers" : "Inside"}</div>
195
+ {/* THE WAY BACK OUT. Isolation scopes the outline to one subtree, and a
196
+ scoped list with no path above it reads as "the rest of my layers are
197
+ gone" — which is exactly what an author reports. Each crumb is a
198
+ button, so leaving is one click from any depth rather than N presses
199
+ of Escape. */}
200
+ {scene !== undefined && isolation !== null ? (
201
+ <nav className="isolation-crumbs" data-testid="isolation-crumbs">
202
+ <button type="button" className="crumb" onClick={() => store.exitIsolationAll()}>
203
+ {scene.name}
204
+ </button>
205
+ {crumbsFor(scene, isolation).map((id, i, all) => (
206
+ <button
207
+ key={id}
208
+ type="button"
209
+ className={`crumb${i === all.length - 1 ? " is-current" : ""}`}
210
+ aria-current={i === all.length - 1}
211
+ onClick={() => store.enterIsolation(id)}
212
+ >
213
+ {scene.layersById[id]?.label ?? id}
214
+ </button>
215
+ ))}
216
+ </nav>
217
+ ) : null}
218
+ {home === null ? null : (
219
+ <ul className="layer-list" data-testid="layer-list">
220
+ <LayerRows
221
+ // The CONTAINER, which is a definition while one is being edited.
222
+ // Every op the rows drive takes a container id, so the drag, the
223
+ // rename and the flags all work inside a definition unchanged.
224
+ scene={home}
225
+ containerId={editingDef ?? sceneId ?? ""}
226
+ // SCOPED to the isolated subtree's own children, so the outline
227
+ // says the same thing the stage does about what is being edited.
228
+ ids={rootIds}
229
+ depth={0}
230
+ parent={editingDef === null ? isolation : null}
231
+ query={trimmedQuery}
232
+ collapsed={collapsed}
233
+ onToggleCollapse={toggleCollapse}
234
+ onRowClick={rowClick}
235
+ />
236
+ </ul>
237
+ )}
238
+
239
+ {/* Last, and below the layer tree: connecting an agent is about the
240
+ workspace, not the document being edited right now. */}
241
+ <McpSection />
242
+ </div>
243
+ );
244
+ }
245
+
246
+ function LayerRows({
247
+ scene,
248
+ containerId,
249
+ ids,
250
+ depth,
251
+ parent,
252
+ query,
253
+ collapsed,
254
+ onToggleCollapse,
255
+ onRowClick,
256
+ }: {
257
+ scene: { layersById: Record<string, Layer3D> };
258
+ /** The scene OR component definition these layers live in. Every op the rows
259
+ * drive takes one, so a definition's interior is editable through exactly
260
+ * the same drag, rename and flag handlers. */
261
+ containerId: string;
262
+ ids: readonly string[];
263
+ depth: number;
264
+ /** The layer these rows are children of, or null at the scene root. A drop
265
+ * BETWEEN rows lands here; a drop ON one lands inside that row. */
266
+ parent: string | null;
267
+ query: string;
268
+ collapsed: ReadonlySet<string>;
269
+ onToggleCollapse: (id: string) => void;
270
+ onRowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
271
+ }) {
272
+ return (
273
+ <>
274
+ {ids.map((id, index) => {
275
+ const layer = scene.layersById[id];
276
+ if (layer === undefined) return null;
277
+ const children = layerChildren(layer);
278
+ // A search hides a row only when NEITHER it nor anything below it
279
+ // matches — hiding a matching layer because its group did not match
280
+ // would make the search unable to find anything nested.
281
+ if (query.length > 0 && !subtreeMatches(scene, layer, query)) return null;
282
+ // A SEARCH OVERRIDES A COLLAPSE: a match under a collapsed group must
283
+ // still be findable, the same rule the subtree gate above states. The
284
+ // one flattening in `flattenVisible` mirrors both, so a range select
285
+ // and the rendered rows can never disagree about what is visible.
286
+ const open = !collapsed.has(id) || query.length > 0;
287
+ return (
288
+ <LayerRow
289
+ key={id}
290
+ layer={layer}
291
+ depth={depth}
292
+ index={index}
293
+ parent={parent}
294
+ containerId={containerId}
295
+ // NO DISCLOSURE WHILE FILTERING. `open` is forced true by the
296
+ // search, so a chevron here would be a control that visibly does
297
+ // nothing on click — and then folds the group the moment the
298
+ // search is cleared, from a press the author had already seen
299
+ // fail. The fold is not a meaningful state while a filter decides
300
+ // what is on screen, so the row simply does not offer one.
301
+ hasChildren={children.length > 0 && query.length === 0}
302
+ open={open}
303
+ onToggleCollapse={onToggleCollapse}
304
+ onRowClick={onRowClick}
305
+ >
306
+ {children.length > 0 && open ? (
307
+ <ul className="layer-children">
308
+ <LayerRows
309
+ scene={scene}
310
+ containerId={containerId}
311
+ ids={children}
312
+ depth={depth + 1}
313
+ parent={layer.id}
314
+ query={query}
315
+ collapsed={collapsed}
316
+ onToggleCollapse={onToggleCollapse}
317
+ onRowClick={onRowClick}
318
+ />
319
+ </ul>
320
+ ) : null}
321
+ </LayerRow>
322
+ );
323
+ })}
324
+ </>
325
+ );
326
+ }
327
+
328
+ /**
329
+ * The row order AS RENDERED — same recursion, same predicates as `LayerRows`
330
+ * (subtree match, collapse-with-search-override) — for the shift range.
331
+ * Kept beside the renderer so a predicate change is one edit away from both.
332
+ */
333
+ function flattenVisible(
334
+ scene: { layersById: Record<string, Layer3D> },
335
+ ids: readonly string[],
336
+ collapsed: ReadonlySet<string>,
337
+ query: string,
338
+ ): string[] {
339
+ const out: string[] = [];
340
+ for (const id of ids) {
341
+ const layer = scene.layersById[id];
342
+ if (layer === undefined) continue;
343
+ if (query.length > 0 && !subtreeMatches(scene, layer, query)) continue;
344
+ out.push(id);
345
+ const children = layerChildren(layer);
346
+ const open = !collapsed.has(id) || query.length > 0;
347
+ if (children.length > 0 && open) out.push(...flattenVisible(scene, children, collapsed, query));
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /** The isolated layer and every ancestor above it, outermost first. The path
353
+ * the breadcrumb walks, and the reason entering a nested subtree is reversible
354
+ * one level at a time. */
355
+ function crumbsFor(scene: { layersById: Record<string, Layer3D> }, id: string): string[] {
356
+ const out: string[] = [];
357
+ let at: string | null = id;
358
+ for (let i = 0; at !== null && i <= Object.keys(scene.layersById).length; i += 1) {
359
+ out.push(at);
360
+ at = parentOf(scene as never, at);
361
+ }
362
+ return out.reverse();
363
+ }
364
+
365
+ /** Which third of a row the pointer is over. The middle is the biggest, so
366
+ * "drop it in there" is the easy gesture and reordering is the deliberate
367
+ * one — Figma's proportions, and for the same reason. */
368
+ function dropZone(e: { clientY: number }, row: HTMLElement): "before" | "into" | "after" {
369
+ const rect = row.getBoundingClientRect();
370
+ const t = (e.clientY - rect.top) / Math.max(1, rect.height);
371
+ if (t < 0.28) return "before";
372
+ if (t > 0.72) return "after";
373
+ return "into";
374
+ }
375
+
376
+ function subtreeMatches(
377
+ scene: { layersById: Record<string, Layer3D> },
378
+ layer: Layer3D,
379
+ query: string,
380
+ ): boolean {
381
+ if (layer.label.toLowerCase().includes(query)) return true;
382
+ return layerChildren(layer).some((id) => {
383
+ const child = scene.layersById[id];
384
+ return child !== undefined && subtreeMatches(scene, child, query);
385
+ });
386
+ }
387
+
388
+ function LayerRow({
389
+ layer,
390
+ depth,
391
+ index,
392
+ parent,
393
+ containerId,
394
+ hasChildren,
395
+ open,
396
+ onToggleCollapse,
397
+ onRowClick,
398
+ children,
399
+ }: {
400
+ layer: Layer3D;
401
+ depth: number;
402
+ index: number;
403
+ parent: string | null;
404
+ /** Scene or definition — see `LayerRows`. */
405
+ containerId: string;
406
+ hasChildren: boolean;
407
+ open: boolean;
408
+ onToggleCollapse: (id: string) => void;
409
+ onRowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
410
+ children: React.ReactNode;
411
+ }) {
412
+ const store = useStore();
413
+ const selected = useShotEditor((s) => s.selection.includes(layer.id));
414
+ // Two primitives, never the record: `useShotEditor` compares with `Object.is`
415
+ // per notification, so returning a slice of the map would re-render every row
416
+ // on every unrelated change.
417
+ const triangles = useShotEditor((s) => s.meshTriangles[layer.id]);
418
+ const sceneTriangles = useShotEditor((s) => sumTriangles(s.meshTriangles));
419
+ const overBudget = triangles !== undefined && sceneTriangles > TRIANGLE_SOFT_BUDGET;
420
+ const [renaming, setRenaming] = useState(false);
421
+ const [over, setOver] = useState<"before" | "into" | "after" | null>(null);
422
+ const menu = useContextMenu();
423
+
424
+ return (
425
+ <li className="layer-item">
426
+ {menu.menu}
427
+ <div
428
+ className={`layer-row${selected ? " is-selected" : ""}${over === null ? "" : ` is-drop-${over}`}`}
429
+ style={{ paddingLeft: 8 + depth * 12 }}
430
+ data-layer={layer.id}
431
+ // The SAME menu the stage opens, from the same builder. A row and a
432
+ // click on the canvas are two ways of pointing at one layer, and a
433
+ // Delete that meant different things in the two would be worse than
434
+ // having only one of them.
435
+ onContextMenu={(e) => {
436
+ e.preventDefault();
437
+ // Select first, unless the row is already part of the selection — in
438
+ // which case the whole selection stands and the menu acts on all of
439
+ // it. The stage's `onContextMenu` policy, deliberately identical.
440
+ const state = store.getState();
441
+ const ids = state.selection.includes(layer.id) ? state.selection : [layer.id];
442
+ if (!state.selection.includes(layer.id)) store.select([layer.id]);
443
+ const next = store.getState();
444
+ menu.open({ x: e.clientX, y: e.clientY }, layerMenuEntries(store, next, menuTargetsFor(next, ids)));
445
+ }}
446
+ // THE OUTLINE DRAG. Dropping on the MIDDLE of a row puts the layer
447
+ // inside it; dropping near an edge puts it beside it, among that row's
448
+ // own siblings. Both go through `moveLayer`, which re-bases the
449
+ // transform so the tree changes and the picture does not.
450
+ draggable
451
+ onDragStart={(e) => e.dataTransfer.setData("text/plain", layer.id)}
452
+ onDragOver={(e) => {
453
+ e.preventDefault();
454
+ setOver(dropZone(e, e.currentTarget));
455
+ }}
456
+ onDragLeave={() => setOver(null)}
457
+ onDrop={(e) => {
458
+ e.preventDefault();
459
+ const zone = dropZone(e, e.currentTarget);
460
+ setOver(null);
461
+ const from = e.dataTransfer.getData("text/plain");
462
+ if (from.length === 0 || from === layer.id) return;
463
+ if (zone === "into") store.moveLayerTo(from, { parent: layer.id });
464
+ else store.moveLayerTo(from, { parent, index: zone === "before" ? index : index + 1 });
465
+ }}
466
+ >
467
+ {/* The disclosure chevron, a SEPARATE button so the name button's
468
+ textContent stays the label alone. Leaf rows get a spacer, so the
469
+ kind icons line up whether or not a row can fold. */}
470
+ {hasChildren ? (
471
+ <button
472
+ type="button"
473
+ className={`layer-toggle${open ? " is-open" : ""}`}
474
+ aria-expanded={open}
475
+ aria-label={`${open ? "Collapse" : "Expand"} ${layer.label}`}
476
+ onClick={() => onToggleCollapse(layer.id)}
477
+ >
478
+ <IconChevron />
479
+ </button>
480
+ ) : (
481
+ <span className="layer-toggle-spacer" aria-hidden="true" />
482
+ )}
483
+ {/* OUTSIDE the name button, never inside it: the button's textContent is
484
+ the layer's label and nothing else — filePanel.test.tsx compares it
485
+ by equality, and the label is also what a screen reader announces. */}
486
+ <span className="layer-kind" aria-hidden="true">
487
+ {kindIcon(layer.kind)}
488
+ </span>
489
+ {renaming ? (
490
+ <input
491
+ className="layer-rename"
492
+ autoFocus
493
+ defaultValue={layer.label}
494
+ aria-label={`Rename ${layer.label}`}
495
+ onBlur={(e) => {
496
+ store.renameLayer(layer.id, e.target.value);
497
+ setRenaming(false);
498
+ }}
499
+ onKeyDown={(e) => {
500
+ if (e.key === "Enter") e.currentTarget.blur();
501
+ if (e.key === "Escape") setRenaming(false);
502
+ }}
503
+ />
504
+ ) : (
505
+ <button
506
+ type="button"
507
+ className="layer-name"
508
+ // The panel owns the click policy (shift = range over the visible
509
+ // order, ctrl/meta = toggle, plain = select + anchor) because only
510
+ // it knows the flattened row order a range is measured over.
511
+ onClick={(e) => onRowClick(layer.id, e)}
512
+ onDoubleClick={() => setRenaming(true)}
513
+ >
514
+ {layer.label}
515
+ </button>
516
+ )}
517
+ {/* The model's cost, for the layers that HAVE one. A sibling span for
518
+ the kind icon's reason: `layer-name`'s textContent is the label and
519
+ nothing else. Absent until the model has loaded, and absent forever
520
+ for a kind that carries no geometry — a badge reading 0 would be a
521
+ claim, and "not measured" is the honest state. */}
522
+ {triangles === undefined ? null : (
523
+ <span
524
+ className={`layer-tris${overBudget ? " is-over-budget" : ""}`}
525
+ aria-hidden="true"
526
+ title={overBudget ? sceneBudgetWarning(sceneTriangles) : `${triangles.toLocaleString()} triangles`}
527
+ >
528
+ {formatTriangles(triangles)}
529
+ </span>
530
+ )}
531
+ {/* The hover-eye and the lock. `aria-pressed` rather than a checkbox
532
+ because they are toggles on a row, and a screen reader should hear
533
+ the layer's name with them. */}
534
+ <button
535
+ type="button"
536
+ className="layer-flag"
537
+ aria-pressed={layer.hidden === true}
538
+ aria-label={`${layer.hidden === true ? "Show" : "Hide"} ${layer.label}`}
539
+ onClick={() => {
540
+ store.select([layer.id]);
541
+ store.toggleFlag("hidden");
542
+ }}
543
+ >
544
+ {layer.hidden === true ? EYE_OFF_ICON : EYE_ICON}
545
+ </button>
546
+ <button
547
+ type="button"
548
+ className="layer-flag"
549
+ aria-pressed={layer.locked === true}
550
+ aria-label={`${layer.locked === true ? "Unlock" : "Lock"} ${layer.label}`}
551
+ onClick={() => {
552
+ store.select([layer.id]);
553
+ store.toggleFlag("locked");
554
+ }}
555
+ >
556
+ {layer.locked === true ? LOCK_ICON : UNLOCK_ICON}
557
+ </button>
558
+ </div>
559
+ {children}
560
+ </li>
561
+ );
562
+ }
563
+
564
+
@@ -0,0 +1,66 @@
1
+ // The File tab's MCP section: how to attach a coding agent to this workspace.
2
+ // Lives where History used to — connecting an agent is a thing an author does
3
+ // every session, and buried at the bottom of the collapsed Manual nobody
4
+ // found it.
5
+ //
6
+ // On a CLOUD workspace the Worker serves `GET /auth/mcp-token` (cookie-authed)
7
+ // with a ready-to-paste `claude mcp add` command scoped to this user's own
8
+ // /mcp endpoint — render that with a Copy button. A local server has no such
9
+ // route, so any non-JSON answer, non-200, or missing field means "local": show
10
+ // the `connect` instructions. The fallback needs no cloud-detection meta — the
11
+ // route's absence IS the signal.
12
+ import { useEffect, useState } from "react";
13
+
14
+ export function McpSection() {
15
+ const [command, setCommand] = useState<string | null>(null);
16
+ const [copied, setCopied] = useState(false);
17
+
18
+ useEffect(() => {
19
+ let alive = true;
20
+ void (async () => {
21
+ try {
22
+ const res = await fetch("/auth/mcp-token", { headers: { accept: "application/json" } });
23
+ if (!res.ok) return;
24
+ const body = (await res.json()) as { command?: unknown };
25
+ if (alive && typeof body.command === "string") setCommand(body.command);
26
+ } catch {
27
+ // Local server, offline, or an HTML answer — the static text below
28
+ // is already the right instruction set.
29
+ }
30
+ })();
31
+ return () => {
32
+ alive = false;
33
+ };
34
+ }, []);
35
+
36
+ return (
37
+ <div className="mcp-section" data-testid="mcp-section">
38
+ <div className="file-section-label">MCP</div>
39
+ {command === null ? (
40
+ <p className="help-note">
41
+ Run <code>npx dailies-studio connect</code> in this project, then start Claude Code in the
42
+ same folder — it picks up <code>.mcp.json</code> and can edit this scene.
43
+ </p>
44
+ ) : (
45
+ <>
46
+ <p className="help-note">
47
+ Paste into your terminal — Claude Code connects to this workspace over MCP:
48
+ </p>
49
+ <div className="help-command">
50
+ <code>{command}</code>
51
+ <button
52
+ type="button"
53
+ onClick={() => {
54
+ void navigator.clipboard?.writeText(command);
55
+ setCopied(true);
56
+ window.setTimeout(() => setCopied(false), 1500);
57
+ }}
58
+ >
59
+ {copied ? "Copied" : "Copy"}
60
+ </button>
61
+ </div>
62
+ </>
63
+ )}
64
+ </div>
65
+ );
66
+ }