cozyclay 3.0.2 → 3.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 (221) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +22 -56
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-task-scopes.js +69 -0
  51. package/src/bus-task-targets.js +48 -0
  52. package/src/bus-tasks.js +181 -0
  53. package/src/command-bus.js +15 -40
  54. package/src/command-history.js +36 -0
  55. package/src/commands/cast.js +10 -3
  56. package/src/commands/motion.js +2 -1
  57. package/src/commands/motion3-compile.js +51 -27
  58. package/src/commands/motion3.js +13 -7
  59. package/src/commands/project.js +1 -1
  60. package/src/commands/shared.js +9 -0
  61. package/src/commands/shot.js +16 -4
  62. package/src/controls.jsx +0 -11
  63. package/src/cuts.js +11 -0
  64. package/src/domains/cast.js +3 -2
  65. package/src/domains/motion.js +3 -10
  66. package/src/domains/playback.js +32 -0
  67. package/src/domains/scenes.js +3 -22
  68. package/src/domains/shots.js +5 -5
  69. package/src/dualview.jsx +11 -15
  70. package/src/hierarchy-panel.css +18 -4
  71. package/src/hierarchy-panel.jsx +12 -11
  72. package/src/kits/held.js +3 -2
  73. package/src/light-blockers.js +2 -2
  74. package/src/main.jsx +5 -0
  75. package/src/motion/ground.js +5 -14
  76. package/src/motion/playback-frame-store.js +79 -0
  77. package/src/motion/playback-probe.js +16 -0
  78. package/src/motion/playback-subject.js +7 -0
  79. package/src/motion/timeline.css +43 -5
  80. package/src/motion/timeline.jsx +4 -3
  81. package/src/motion3/body.js +37 -5
  82. package/src/motion3/carried.js +11 -3
  83. package/src/motion3/compile-job.js +1 -1
  84. package/src/motion3/compiler/anchors.js +33 -5
  85. package/src/motion3/compiler/compile.js +27 -4
  86. package/src/motion3/compiler/finger-close.js +137 -0
  87. package/src/motion3/compiler/grip.js +9 -3
  88. package/src/motion3/compiler/limbs.js +18 -3
  89. package/src/motion3/compiler/pose.js +6 -0
  90. package/src/motion3/compiler/socket-grip.js +34 -10
  91. package/src/motion3/compiler/validate-objects.js +17 -2
  92. package/src/motion3/compiler/validate-parts.js +2 -1
  93. package/src/motion3/compiler/vocab.js +4 -4
  94. package/src/motion3/describe.js +2 -2
  95. package/src/motion3/docs.js +1 -1
  96. package/src/motion3/fix/index.js +7 -3
  97. package/src/motion3/flight.js +38 -0
  98. package/src/motion3/free-motion.js +10 -7
  99. package/src/motion3/load.js +125 -0
  100. package/src/motion3/mass.js +23 -0
  101. package/src/motion3/object-keys.js +11 -6
  102. package/src/motion3/object-pose.js +11 -3
  103. package/src/motion3/pipeline.js +5 -0
  104. package/src/motion3/qa/explain.js +3 -1
  105. package/src/motion3/qa/grasp-checks.js +123 -0
  106. package/src/motion3/qa/hints-grasp.js +71 -0
  107. package/src/motion3/qa/index.js +3 -1
  108. package/src/motion3/qa/object-checks.js +7 -2
  109. package/src/motion3/qa/patch.js +2 -1
  110. package/src/motion3/qa/spec-checks.js +21 -1
  111. package/src/motion3/schema/fields.js +3 -3
  112. package/src/motion3/schema/topics.js +11 -10
  113. package/src/motion3/schema/types.js +5 -1
  114. package/src/motion3/seat.js +84 -0
  115. package/src/motion3/settle.js +11 -6
  116. package/src/motion3/shapes.js +70 -0
  117. package/src/motion3/skills/basic.js +61 -13
  118. package/src/motion3/skills/catch.js +71 -0
  119. package/src/motion3/skills/index.js +13 -4
  120. package/src/motion3/skills/info.js +14 -1
  121. package/src/motion3/skills/reach-human.js +139 -0
  122. package/src/motion3/skills/stance.js +92 -0
  123. package/src/motion3/skills/throw.js +72 -0
  124. package/src/motion3/sockets.js +21 -4
  125. package/src/motion3/views.js +28 -5
  126. package/src/object-catalog.jsx +0 -4
  127. package/src/object-gizmo.jsx +2 -1
  128. package/src/object-path.js +1 -1
  129. package/src/panels/ProjectPanel.jsx +42 -21
  130. package/src/panels/details.css +15 -4
  131. package/src/popover.js +37 -0
  132. package/src/posestudio.jsx +15 -10
  133. package/src/project-browser-art.jsx +19 -0
  134. package/src/project-browser.css +74 -121
  135. package/src/project-browser.jsx +36 -60
  136. package/src/props.jsx +4 -7
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +3 -7
  139. package/src/scenes.js +4 -5
  140. package/src/settings-menu.jsx +1 -20
  141. package/src/shell/BottomDock.jsx +9 -9
  142. package/src/shell/MenuBar.jsx +1 -9
  143. package/src/shell/PreferencesDialog.jsx +3 -4
  144. package/src/shell/ViewportToolbar.jsx +3 -3
  145. package/src/shell/add-camera-shot.js +11 -0
  146. package/src/shell/glass-regions.css +3 -2
  147. package/src/shell/glass.css +0 -5
  148. package/src/shell/lens-scrub-math.js +51 -0
  149. package/src/shell/lens-scrub.css +73 -0
  150. package/src/shell/lens-scrub.jsx +98 -0
  151. package/src/shell/mode.css +6 -5
  152. package/src/shell/preferences.css +1 -32
  153. package/src/shell/shell.css +18 -1
  154. package/src/shell/studio-shell-context.js +5 -0
  155. package/src/shot-frame.js +26 -0
  156. package/src/stage-canvas-rigs.jsx +7 -8
  157. package/src/stage-characters.jsx +26 -0
  158. package/src/stage-gizmo.js +43 -0
  159. package/src/stage-labels-ko.js +0 -6
  160. package/src/stage-startup-defaults.js +3 -9
  161. package/src/starter-scenes.js +5 -11
  162. package/src/studio-agent-commands.js +2 -2
  163. package/src/studio-agent-protocol.js +2 -7
  164. package/src/studio-app-binding.js +3 -3
  165. package/src/studio-elements.js +1 -1
  166. package/src/studio-error-codes.js +1 -1
  167. package/src/studio-motion-visual.js +4 -4
  168. package/src/styles/themes.css +13 -0
  169. package/src/styles.css +6 -674
  170. package/src/text/text-object.jsx +1 -1
  171. package/src/ui.jsx +8 -16
  172. package/src/use-embed-bridge.js +3 -6
  173. package/src/use-render-activity.js +3 -1
  174. package/src/view-ghosts.jsx +5 -3
  175. package/src/viewport-visible-rect.js +37 -0
  176. package/src/workflow/AgentPanel.jsx +75 -42
  177. package/src/workflow/AgentProgressCard.jsx +1 -1
  178. package/src/workflow/TaskBoard.jsx +102 -0
  179. package/src/workflow/TaskInline.jsx +114 -0
  180. package/src/workflow/agent-client.js +11 -25
  181. package/src/workflow/agent-panel.css +14 -1
  182. package/src/workflow/attachment-image.js +10 -3
  183. package/src/workflow/attachment-video.js +91 -0
  184. package/src/workflow/task-board.css +98 -0
  185. package/src/workflow/task-board.js +169 -0
  186. package/tools/check-eslint.mjs +2 -1
  187. package/tools/check-knip.mjs +2 -1
  188. package/tools/dev/pages/greybox-to-video.html +2 -3
  189. package/tools/dev/pages/previs-software.html +2 -2
  190. package/tools/dev/pages/privacy.html +3 -7
  191. package/tools/dev/pages/seedance-camera-control.html +2 -2
  192. package/tools/dev-full.mjs +11 -2
  193. package/tools/eslint-baseline.json +6 -6
  194. package/tools/knip-baseline.json +0 -11
  195. package/tools/loc-baseline.json +30 -31
  196. package/tools/metrics-snapshot.mjs +43 -5
  197. package/tools/render-start-thumbs.mjs +175 -0
  198. package/tools/run-tests.mjs +7 -14
  199. package/tools/sitemap.mjs +1 -1
  200. package/dist/assets/analytics-DbKG0NgD.js +0 -6
  201. package/dist/assets/app-34i-iUy1.js +0 -5088
  202. package/dist/assets/app-f-fkV5uP.css +0 -1
  203. package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
  204. package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
  205. package/dist/assets/landing-mq5bnqFE.js +0 -1
  206. package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
  207. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  208. package/dist/assets/workflow-dUhurYUc.js +0 -179
  209. package/dist/scenes/city-block.cclayproject +0 -1
  210. package/src/burn-in.js +0 -91
  211. package/src/camera-tutorial.jsx +0 -520
  212. package/src/facing-marks.js +0 -88
  213. package/src/first-shot-handoff.js +0 -44
  214. package/src/panels/motion.css +0 -197
  215. package/src/set-pieces.jsx +0 -183
  216. package/src/tutorial-analytics.js +0 -86
  217. package/src/tutorial-walk.js +0 -17
  218. package/src/use-camera-tutorial.js +0 -50
  219. package/src/use-semantic-state.js +0 -12
  220. package/tools/metrics/archive-traffic.mjs +0 -138
  221. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -1032,14 +1032,25 @@
1032
1032
  background: var(--select, #e8a33d);
1033
1033
  }
1034
1034
 
1035
+ /* The menu is portaled to <body> (ProjectPanel.jsx); the layer that carries
1036
+ the wrap classes there has no box of its own. Surface and layer are the
1037
+ shared popover tokens (themes.css). */
1038
+ .v2-project-menu.project-menu-layer {
1039
+ display: contents;
1040
+ }
1041
+
1035
1042
  .v2-project-menu.v2-project-menu .project-menu {
1043
+ position: fixed;
1044
+ z-index: var(--z-popover, 90);
1036
1045
  box-sizing: border-box;
1037
1046
  min-width: var(--details-menu-width, 200px);
1038
1047
  padding: var(--details-gap, 4px);
1039
- border: var(--details-border-width, 1px) solid var(--row-divider, #1d1d20);
1040
- border-radius: var(--r-2, 6px);
1041
- background: var(--surface-panel, #151517);
1042
- box-shadow: none;
1048
+ border: var(--details-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
1049
+ border-radius: var(--popover-radius, 6px);
1050
+ background: var(--popover-surface, var(--surface-raised, #1e1e21));
1051
+ -webkit-backdrop-filter: var(--popover-blur, none);
1052
+ backdrop-filter: var(--popover-blur, none);
1053
+ box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
1043
1054
  }
1044
1055
 
1045
1056
  .v2-project-menu.v2-project-menu .project-menu button {
package/src/popover.js ADDED
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ // Shell popovers (the project menu, dropdowns, the Outliner context menu) are
4
+ // exclusive: opening one announces itself and every other open one closes, so
5
+ // two menus never stack over each other. Their surface and layer are the
6
+ // --popover-* / --z-popover tokens in src/styles/themes.css.
7
+ const POPOVER_OPEN_EVENT = "cozyclay:popover-open";
8
+
9
+ /**
10
+ * Join the exclusive popover group while `open` is truthy. A new truthy
11
+ * `open` value (a context menu re-opened at another row) announces again.
12
+ * With `insideRefs`, a mousedown outside every ref also closes the popover.
13
+ */
14
+ export function usePopover(open, close, insideRefs = null) {
15
+ const live = useRef({ close, insideRefs });
16
+ useEffect(() => {
17
+ live.current = { close, insideRefs };
18
+ });
19
+ useEffect(() => {
20
+ if (!open) return undefined;
21
+ const token = {};
22
+ window.dispatchEvent(new CustomEvent(POPOVER_OPEN_EVENT, { detail: token }));
23
+ const onOtherOpen = (event) => {
24
+ if (event.detail !== token) live.current.close();
25
+ };
26
+ const onDocMouseDown = (event) => {
27
+ const refs = live.current.insideRefs;
28
+ if (refs && !refs.some((ref) => ref.current?.contains(event.target))) live.current.close();
29
+ };
30
+ window.addEventListener(POPOVER_OPEN_EVENT, onOtherOpen);
31
+ document.addEventListener("mousedown", onDocMouseDown);
32
+ return () => {
33
+ window.removeEventListener(POPOVER_OPEN_EVENT, onOtherOpen);
34
+ document.removeEventListener("mousedown", onDocMouseDown);
35
+ };
36
+ }, [open]);
37
+ }
@@ -423,13 +423,10 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
423
423
  // not-yet-focused sphere just took focus (nothing to bake). An
424
424
  // empty-canvas click releases focus too.
425
425
  if (!d.moved) {
426
- if (d.kind === "empty") {
427
- if (focusIdRef.current) focusRef.current?.(null);
428
- return;
429
- }
430
- if (d.wasFocused && !d.axisDir) {
431
- focusRef.current?.(null); // toggle off
432
- }
426
+ // (An empty press is always wasFocused: it only begins while focused.)
427
+ // A gizmo volume can win the raycast over the sphere centre, so a
428
+ // no-move press on the centre (d.centerClick) toggles off too.
429
+ if (d.wasFocused && (!d.axisDir || d.centerClick)) focusRef.current?.(null); // toggle off
433
430
  // axis-arrow clicks and fresh-sphere clicks: nothing to bake
434
431
  return;
435
432
  }
@@ -842,7 +839,7 @@ const CLICK_PX = 4;
842
839
  if (kind === "empty") {
843
840
  // An empty-canvas press: no drag, just a click candidate that
844
841
  // releases focus on pointerup (see onUp).
845
- dragRef.current = { kind: "empty", trackId: "empty", downXY, moved: false };
842
+ dragRef.current = { kind: "empty", trackId: "empty", downXY, moved: false, wasFocused: true };
846
843
  window.addEventListener("pointermove", handlersRef.current.onMove);
847
844
  window.addEventListener("pointerup", handlersRef.current.onUp);
848
845
  window.addEventListener("pointercancel", handlersRef.current.onUp);
@@ -860,6 +857,7 @@ const CLICK_PX = 4;
860
857
  }
861
858
  raycaster.setFromCamera(ndc, camera);
862
859
  let plane;
860
+ const rect = gl.domElement.getBoundingClientRect(), c = origin.clone().project(camera);
863
861
  const ringDrag = !!axisDir && (kind === "fk" || kind === "swing");
864
862
  if (ringDrag) {
865
863
  // A ring turns about its axis: the pointer is read on the ring's own plane.
@@ -892,6 +890,8 @@ const CLICK_PX = 4;
892
890
  // a no-move click toggles focus OFF only then; a click on a fresh
893
891
  // (unfocused) handle must keep the focus it just took.
894
892
  wasFocused: focusIdRef.current === track.id,
893
+ // Press landed within CLICK_PX of the sphere's projected centre.
894
+ centerClick: Math.hypot(downXY[0] - (rect.left + (c.x + 1) / 2 * rect.width), downXY[1] - (rect.top + (1 - c.y) / 2 * rect.height)) <= CLICK_PX,
895
895
  };
896
896
  if (kind === "fk" || kind === "swing" || kind === "body") {
897
897
  // Swing/translate start state, frozen at drag start so moves stay
@@ -944,8 +944,7 @@ const CLICK_PX = 4;
944
944
  handleRefs.current[p.track.id]?.userData.ikExposed !== false
945
945
  );
946
946
  if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused);
947
- else picks = picks.filter((p) => !p.axisDir); // spheres only
948
- if (selectionOnly) picks = picks.filter((p) => !p.axisDir);
947
+ else picks = picks.filter((p) => !p.axisDir); // spheres only
949
948
  if (!picks.length) {
950
949
  if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
951
950
  return;
@@ -985,10 +984,16 @@ const CLICK_PX = 4;
985
984
  }
986
985
  setHoverAxis((current) => (current === axis ? current : axis));
987
986
  };
987
+ // Esc releases the focused handle (same path as the toggle-off click).
988
+ const onKey = (ev) => {
989
+ if (ev.key === "Escape" && focusIdRef.current && !/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target?.tagName)) focusRef.current?.(null);
990
+ };
988
991
  raycaster.layers.enable(POSER_LAYER);
989
992
  el.addEventListener("pointerdown", onDown, true);
990
993
  el.addEventListener("pointermove", onHover);
994
+ window.addEventListener("keydown", onKey);
991
995
  return () => {
996
+ window.removeEventListener("keydown", onKey);
992
997
  el.removeEventListener("pointerdown", onDown, true);
993
998
  el.removeEventListener("pointermove", onHover);
994
999
  raycaster.layers.disable(POSER_LAYER);
@@ -0,0 +1,19 @@
1
+ // Start-screen art: the brand mark and the template thumbnail that fills both
2
+ // the card and the larger preview. Thumbnails are real studio renders, one per
3
+ // theme, made by tools/render-start-thumbs.mjs into public/thumbs/. A template
4
+ // without a render (a starter added since the last run) keeps the plain tile
5
+ // its wrapper already paints.
6
+ import { useState } from "react";
7
+ import { useUiTheme } from "./theme.js";
8
+
9
+ export function TemplateThumb({ id }) {
10
+ const theme = useUiTheme();
11
+ const src = `/thumbs/${id}-${theme}.webp`;
12
+ const [failed, setFailed] = useState(null);
13
+ if (failed === src) return null;
14
+ return <img className="v2-start-art" src={src} alt="" aria-hidden="true" draggable="false" decoding="async" onError={() => setFailed(src)} />;
15
+ }
16
+
17
+ export function BrandMark() {
18
+ return <img className="v2-start-brand-mark" src="/icons/icon-192.png" alt="" aria-hidden="true" draggable="false" width="26" height="26" />;
19
+ }
@@ -102,11 +102,17 @@
102
102
  }
103
103
 
104
104
  .v2-start-screen .v2-start-brand-mark {
105
+ display: block;
106
+ box-sizing: border-box;
105
107
  width: var(--start-brand-mark, 26px);
106
108
  height: var(--start-brand-mark, 26px);
107
109
  flex: 0 0 var(--start-brand-mark, 26px);
108
- border-radius: var(--r-2, 6px);
109
- background: var(--select, #e8a33d);
110
+ padding: 1px;
111
+ border-radius: 7px;
112
+ /* The frog keeps its own blue on its own white tile in both themes. */
113
+ background: #fff;
114
+ box-shadow: 0 0 0 var(--start-border-width, 1px) color-mix(in srgb, var(--text-1, #e4e4e7) 10%, transparent);
115
+ object-fit: contain;
110
116
  }
111
117
 
112
118
  .v2-start-screen .v2-start-nav-list {
@@ -196,30 +202,7 @@
196
202
  color: var(--text-1, #e4e4e7);
197
203
  }
198
204
 
199
- .v2-start-screen .v2-start-tabs {
200
- display: flex;
201
- align-items: center;
202
- gap: var(--start-tab-gap, 20px);
203
- min-height: var(--start-tab-height, 24px);
204
- }
205
-
206
- .v2-start-screen .v2-start-tab {
207
- height: var(--start-tab-height, 24px);
208
- padding: 0 0 var(--start-tab-underline-space, 8px);
209
- border: 0;
210
- border-bottom: var(--start-active-width, 1.5px) solid var(--transparent, transparent);
211
- background: var(--transparent, transparent);
212
- color: var(--text-3, #636369);
213
- white-space: nowrap;
214
- }
215
-
216
- .v2-start-screen .v2-start-tab:hover,
217
- .v2-start-screen .v2-start-tab.active {
218
- border-bottom-color: var(--select, #e8a33d);
219
- color: var(--text-selected, #fff);
220
- }
221
-
222
- /* Project mode (previs flag): a two-up segmented switch above the tabs. Each
205
+ /* Project mode (previs flag): a two-up segmented switch above the cards. Each
223
206
  segment carries its label and the one-line promise of what the mode makes. */
224
207
  .v2-start-screen .v2-start-mode {
225
208
  display: grid;
@@ -291,69 +274,27 @@
291
274
  }
292
275
 
293
276
  .v2-start-screen .v2-start-template-art {
294
- position: relative;
277
+ display: block;
278
+ overflow: hidden;
295
279
  width: var(--start-template-art-width, 100%);
296
280
  aspect-ratio: 16 / 10;
297
- overflow: hidden;
298
281
  border-radius: var(--r-2, 6px);
282
+ outline: var(--start-border-width, 1px) solid color-mix(in srgb, var(--text-1, #e4e4e7) 9%, transparent);
283
+ outline-offset: -1px;
299
284
  background: var(--surface-raised, #1e1e21);
300
- box-shadow: inset 0 0 0 var(--start-border-width, 1px) var(--transparent, transparent);
301
- }
302
-
303
- .v2-start-screen .v2-start-template-art::before,
304
- .v2-start-screen .v2-start-template-art::after {
305
- content: "";
306
- position: absolute;
307
- display: block;
308
- background: var(--surface-active, #26262a);
309
- }
310
-
311
- .v2-start-screen .v2-start-template-art::before {
312
- left: var(--start-art-block-left, 12%);
313
- bottom: var(--start-art-block-bottom, 16%);
314
- width: var(--start-art-block-width, 36%);
315
- height: var(--start-art-block-height, 48%);
316
- }
317
-
318
- .v2-start-screen .v2-start-template-art::after {
319
- right: var(--start-art-block-right, 12%);
320
- bottom: var(--start-art-block-bottom, 16%);
321
- width: var(--start-art-small-width, 20%);
322
- height: var(--start-art-small-height, 24%);
323
- background: var(--field, #0f0f11);
324
- }
325
-
326
- .v2-start-screen .v2-start-template-art.sample::before {
327
- width: var(--start-sample-block-width, 54%);
328
- height: var(--start-sample-block-height, 28%);
329
- background: var(--primary, #7258a0);
330
- }
331
-
332
- .v2-start-screen .v2-start-template-art.sample::after {
333
- width: var(--start-sample-small-width, 10%);
334
- height: var(--start-sample-small-height, 52%);
335
- background: var(--camera, #a78bfa);
336
285
  }
337
286
 
338
287
  .v2-start-screen .v2-start-template.selected .v2-start-template-art {
339
- box-shadow: inset 0 0 0 var(--start-selected-width, 1.5px) var(--select, #e8a33d);
288
+ outline: var(--start-selected-width, 1.5px) solid var(--select, #e8a33d);
289
+ outline-offset: -1.5px;
340
290
  }
341
291
 
342
292
  .v2-start-screen .v2-start-template:hover .v2-start-template-art {
343
- box-shadow: inset 0 0 0 var(--start-hover-width, 1px) var(--text-3, #636369);
293
+ outline-color: var(--text-3, #636369);
344
294
  }
345
295
 
346
296
  .v2-start-screen .v2-start-template.selected:hover .v2-start-template-art {
347
- box-shadow: inset 0 0 0 var(--start-selected-width, 1.5px) var(--select, #e8a33d);
348
- }
349
-
350
- .v2-start-screen .v2-start-template-code {
351
- position: absolute;
352
- left: var(--sp-1, 8px);
353
- bottom: var(--sp-1, 8px);
354
- color: var(--text-off, #4f4f55);
355
- font-family: var(--mono, 'JetBrains Mono'), ui-monospace, monospace;
356
- font-size: var(--start-code-size, 10px);
297
+ outline-color: var(--select, #e8a33d);
357
298
  }
358
299
 
359
300
  .v2-start-screen .v2-start-template-copy {
@@ -382,6 +323,43 @@
382
323
  white-space: nowrap;
383
324
  }
384
325
 
326
+ /* Open-project card: same slot and caption as a template, dashed art. */
327
+ .v2-start-screen .v2-start-add-card {
328
+ min-width: var(--start-template-min-width, 0);
329
+ display: flex;
330
+ flex-direction: column;
331
+ gap: var(--sp-2, 12px);
332
+ padding: 0;
333
+ border: 0;
334
+ background: var(--transparent, transparent);
335
+ color: var(--text-1, #e4e4e7);
336
+ text-align: left;
337
+ }
338
+
339
+ .v2-start-screen .v2-start-add-art {
340
+ display: grid;
341
+ place-items: center;
342
+ width: var(--start-template-art-width, 100%);
343
+ aspect-ratio: 16 / 10;
344
+ border: var(--start-border-width, 1px) dashed var(--text-3, #636369);
345
+ border-radius: var(--r-2, 6px);
346
+ color: var(--text-3, #636369);
347
+ transition: border-color 120ms ease, color 120ms ease;
348
+ }
349
+
350
+ .v2-start-screen .v2-start-add-card:hover .v2-start-add-art,
351
+ .v2-start-screen .v2-start-add-card:focus-visible .v2-start-add-art {
352
+ border-color: var(--select, #e8a33d);
353
+ color: var(--select, #e8a33d);
354
+ }
355
+
356
+ .v2-start-screen .v2-start-add-card.selected .v2-start-add-art {
357
+ border-style: solid;
358
+ border-color: var(--select, #e8a33d);
359
+ box-shadow: inset 0 0 0 0.5px var(--select, #e8a33d);
360
+ color: var(--select, #e8a33d);
361
+ }
362
+
385
363
  /* --------------------------------------------------------------- recent --- */
386
364
 
387
365
  .v2-start-screen .v2-start-recent-block,
@@ -510,11 +488,6 @@
510
488
  padding: var(--sp-1, 8px) 0;
511
489
  }
512
490
 
513
- .v2-start-screen .v2-start-file-actions {
514
- margin-top: auto;
515
- padding-top: var(--sp-1, 8px);
516
- }
517
-
518
491
  .v2-start-screen .v2-start-learn {
519
492
  max-width: var(--start-learn-max-width, 520px);
520
493
  display: flex;
@@ -537,6 +510,11 @@
537
510
  line-height: 1.55;
538
511
  }
539
512
 
513
+ .v2-start-screen .v2-start-file-actions {
514
+ margin-top: auto;
515
+ padding-top: var(--sp-1, 8px);
516
+ }
517
+
540
518
  /* --------------------------------------------------------------- preview --- */
541
519
 
542
520
  .v2-start-screen .v2-start-preview {
@@ -550,51 +528,26 @@
550
528
  }
551
529
 
552
530
  .v2-start-screen .v2-start-preview-art {
553
- position: relative;
554
- width: var(--start-preview-art-width, 100%);
531
+ display: block;
532
+ flex: 0 0 auto;
533
+ /* A framed card inset to the copy column below it, not an edge-to-edge band. */
534
+ width: auto;
535
+ margin: var(--start-preview-copy-pad-x, 20px) var(--start-preview-copy-pad-x, 20px) 0;
555
536
  aspect-ratio: 16 / 9;
556
- display: flex;
557
- align-items: flex-end;
558
- justify-content: flex-start;
559
- padding: var(--sp-1, 8px);
537
+ overflow: hidden;
538
+ border-radius: var(--r-2, 6px);
539
+ outline: var(--start-border-width, 1px) solid color-mix(in srgb, var(--text-1, #e4e4e7) 9%, transparent);
540
+ outline-offset: -1px;
560
541
  background: var(--surface-raised, #1e1e21);
561
542
  }
562
543
 
563
- .v2-start-screen .v2-start-preview-art::before,
564
- .v2-start-screen .v2-start-preview-art::after {
565
- content: "";
566
- position: absolute;
544
+ /* Template art: a real studio render (public/thumbs, one per theme). The
545
+ wrapper paints a plain tile underneath for a template without one. */
546
+ .v2-start-screen .v2-start-art {
567
547
  display: block;
568
- background: var(--surface-active, #26262a);
569
- }
570
-
571
- .v2-start-screen .v2-start-preview-art::before {
572
- left: var(--preview-block-left, 14%);
573
- bottom: var(--preview-block-bottom, 16%);
574
- width: var(--preview-block-width, 34%);
575
- height: var(--preview-block-height, 50%);
576
- }
577
-
578
- .v2-start-screen .v2-start-preview-art::after {
579
- right: var(--preview-block-right, 14%);
580
- bottom: var(--preview-block-bottom, 16%);
581
- width: var(--preview-small-width, 18%);
582
- height: var(--preview-small-height, 28%);
583
- background: var(--primary, #7258a0);
584
- }
585
-
586
- .v2-start-screen .v2-start-preview-art.sample::before {
587
- width: var(--preview-sample-width, 58%);
588
- height: var(--preview-sample-height, 30%);
589
- background: var(--camera, #a78bfa);
590
- }
591
-
592
- .v2-start-screen .v2-start-preview-art span {
593
- position: relative;
594
- z-index: var(--start-art-label-layer, 1);
595
- color: var(--text-off, #4f4f55);
596
- font-family: var(--mono, 'JetBrains Mono'), ui-monospace, monospace;
597
- font-size: var(--start-preview-label-size, 10.5px);
548
+ width: 100%;
549
+ height: 100%;
550
+ object-fit: cover;
598
551
  }
599
552
 
600
553
  .v2-start-screen .v2-start-preview-copy {
@@ -14,21 +14,16 @@ import {
14
14
  storeProjectsDirectory,
15
15
  DEFAULT_PREVIS_MODE,
16
16
  } from "./project.js";
17
+ import { BrandMark, TemplateThumb } from "./project-browser-art.jsx";
17
18
  import "./project-browser.css";
18
19
 
19
20
  const APP_VERSION = packageInfo.version;
20
- const CATEGORIES = ["All", "Film", "Dialogue", "Action", "Vehicle"];
21
- const BLANK_TEMPLATE = Object.freeze({
22
- id: "blank-stage",
23
- name: "Blank Stage",
24
- category: "All",
25
- description: "An empty stage for blocking a new shot.",
26
- kind: "blank",
27
- code: "NEW",
28
- });
21
+ // The "+" card is the one blank entry: an empty stage under a fresh name (no
22
+ // starterId, so Create takes the blank path). Its preview is the blank-stage render.
23
+ const NEW_PROJECT = Object.freeze({ id: "new-project", thumbId: "blank-stage", kind: "blank", defaultName: "Untitled Project", get name() { return ko("New project", "새 프로젝트"); }, get description() { return ko("Start from an empty stage.", "빈 무대에서 시작합니다."); } });
29
24
 
30
25
  // The two project modes the New view offers.
31
- // Storyboard starts from an empty stage only; Animation keeps every template.
26
+ // Storyboard starts from an empty stage only; Animation also offers the starters.
32
27
  const MODE_OPTIONS = [
33
28
  {
34
29
  id: "storyboard",
@@ -46,6 +41,9 @@ const MODE_OPTIONS = [
46
41
  // path. The v2 chooser already has the name, so this short-lived handoff seeds
47
42
  // the existing ProjectNameDialog and lets its normal submit path commit it.
48
43
  let pendingProjectName = null;
44
+ function stagePendingProjectName(name) {
45
+ pendingProjectName = name;
46
+ }
49
47
 
50
48
  function formatDate(value) {
51
49
  const date = new Date(value);
@@ -58,10 +56,8 @@ function sampleTemplate(scene) {
58
56
  id: `sample-${scene.id}`,
59
57
  starterId: scene.id,
60
58
  name: scene.name,
61
- category: "Action",
62
59
  description: scene.blurb,
63
60
  kind: "sample",
64
- code: "SAMPLE",
65
61
  };
66
62
  }
67
63
 
@@ -103,20 +99,17 @@ export function ProjectNameDialog({ open, initialName = "My Project", onCancel,
103
99
 
104
100
  /**
105
101
  * v2 start / project screen. Recent projects still come from the browser-local
106
- * project store, while today's starter scenes become the template cards.
102
+ * project store, while the bundled starter scenes (none today) become template
103
+ * cards before the "+" card.
107
104
  */
108
105
  export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew, onClose, startup = false, starters = [], onStarter }) {
109
106
  const starterTemplates = useMemo(() => starters.map(sampleTemplate), [starters]);
110
107
  const [previsMode, setPrevisMode] = useState(DEFAULT_PREVIS_MODE);
111
- const templates = useMemo(
112
- () => (previsMode === "storyboard" ? [BLANK_TEMPLATE] : [BLANK_TEMPLATE, ...starterTemplates]),
113
- [previsMode, starterTemplates],
114
- );
115
- const defaultTemplate = starterTemplates[0] ?? BLANK_TEMPLATE;
108
+ const templates = previsMode === "storyboard" ? [] : starterTemplates;
109
+ const defaultTemplate = starterTemplates[0] ?? NEW_PROJECT;
116
110
  const [activeNav, setActiveNav] = useState(startup ? "new" : "recent");
117
- const [category, setCategory] = useState("All");
118
111
  const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id);
119
- const [name, setName] = useState(currentName || defaultTemplate.name || "My Project");
112
+ const [name, setName] = useState(currentName || defaultTemplate.defaultName || defaultTemplate.name || "My Project");
120
113
  const [frameRate, setFrameRate] = useState("24");
121
114
  const [units, setUnits] = useState("Meters");
122
115
  const [recents, setRecents] = useState([]);
@@ -156,15 +149,14 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
156
149
 
157
150
  // File › New reaches this screen with unsaved work, so Create can be refused
158
151
  // by the discard confirmation. A name left behind then must not seed a later
159
- // Save's name dialog once the screen closes.
152
+ // Save's name dialog once the screen closes. The clear waits for the end of
153
+ // the commit: a Create that closes this screen opens the name dialog in the
154
+ // same commit, and its effect takes the name first.
160
155
  useEffect(() => () => {
161
- pendingProjectName = null;
156
+ queueMicrotask(() => stagePendingProjectName(null));
162
157
  }, []);
163
158
 
164
- const selectedTemplate = templates.find((template) => template.id === selectedTemplateId) ?? templates[0];
165
- const visibleTemplates = category === "All"
166
- ? templates
167
- : templates.filter((template) => template.category === "All" || template.category === category);
159
+ const selectedTemplate = templates.find((template) => template.id === selectedTemplateId) ?? NEW_PROJECT;
168
160
  const canCreate = name.trim().length > 0;
169
161
  const createReason = ko("Enter a project name to create a project", "프로젝트를 만들려면 이름을 입력하세요");
170
162
  const locationLabel = folder?.name || ko("Browser project store", "브라우저 프로젝트 저장소");
@@ -201,17 +193,17 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
201
193
  const selectTemplate = (template) => {
202
194
  setSelectedTemplateId(template.id);
203
195
  // A name the previous card (or the open project) filled in follows the new card; a typed one stays.
204
- setName((current) => !current.trim() || current.trim() === selectedTemplate?.name || current.trim() === currentName ? template.name : current);
196
+ setName((current) => !current.trim() || [selectedTemplate?.name, selectedTemplate?.defaultName, currentName].includes(current.trim()) ? template.defaultName ?? template.name : current);
205
197
  };
206
198
 
207
199
  const choosePrevisMode = (mode) => {
208
200
  setPrevisMode(mode);
209
201
  // Storyboard offers the blank stage only, so a starter selection cannot
210
202
  // survive the switch.
211
- if (mode !== "storyboard" || selectedTemplate?.id === BLANK_TEMPLATE.id) return;
212
- setSelectedTemplateId(BLANK_TEMPLATE.id);
203
+ if (mode !== "storyboard" || !selectedTemplate?.starterId) return;
204
+ setSelectedTemplateId(NEW_PROJECT.id);
213
205
  // A name the starter card filled in would now describe the wrong stage.
214
- setName((current) => (current.trim() === selectedTemplate?.name ? BLANK_TEMPLATE.name : current));
206
+ setName((current) => (current.trim() === selectedTemplate?.name ? NEW_PROJECT.defaultName : current));
215
207
  };
216
208
 
217
209
  const createProject = (event) => {
@@ -222,7 +214,7 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
222
214
  onStarter?.(selectedTemplate.starterId, projectName, { previsMode: "animation" });
223
215
  return;
224
216
  }
225
- pendingProjectName = projectName;
217
+ stagePendingProjectName(projectName);
226
218
  onNew(projectName, { previsMode });
227
219
  };
228
220
 
@@ -234,7 +226,6 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
234
226
  const navItems = [
235
227
  { id: "recent", label: ko("Recent", "최근") },
236
228
  { id: "new", label: ko("New Project", "새 프로젝트") },
237
- { id: "samples", label: ko("Samples", "샘플") },
238
229
  { id: "learn", label: ko("Learn", "배우기") },
239
230
  ];
240
231
 
@@ -243,7 +234,7 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
243
234
  <div className={`v2-start-window project-browser${startup ? " startup" : ""}`} role="dialog" aria-modal="true" aria-label={ko("Choose project", "프로젝트 선택")}>
244
235
  <nav className="v2-start-nav" aria-label={ko("Project navigation", "프로젝트 탐색")}>
245
236
  <div className="v2-start-brand">
246
- <span className="v2-start-brand-mark" aria-hidden="true" />
237
+ <BrandMark />
247
238
  <strong>CozyClay</strong>
248
239
  </div>
249
240
  <div className="v2-start-nav-list">
@@ -265,7 +256,7 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
265
256
  <main className="v2-start-main">
266
257
  <div className="v2-start-main-head">
267
258
  <div className="v2-start-title-row">
268
- <h1>{activeNav === "recent" ? ko("Recent", "최근") : activeNav === "samples" ? ko("Samples", "샘플") : activeNav === "learn" ? ko("Learn", "배우기") : ko("New Project", "새 프로젝트")}</h1>
259
+ <h1>{activeNav === "recent" ? ko("Recent", "최근") : activeNav === "learn" ? ko("Learn", "배우기") : ko("New Project", "새 프로젝트")}</h1>
269
260
  {activeNav !== "new" && <button type="button" className="v2-start-close x" onClick={onClose} aria-label={ko("Close", "닫기")}>×</button>}
270
261
  </div>
271
262
  {activeNav === "new" && (
@@ -286,22 +277,6 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
286
277
  ))}
287
278
  </div>
288
279
  )}
289
- {(activeNav === "new" || activeNav === "samples") && (
290
- <div className="v2-start-tabs" role="tablist" aria-label={ko("Template categories", "템플릿 카테고리")}>
291
- {CATEGORIES.map((entry) => (
292
- <button
293
- type="button"
294
- key={entry}
295
- role="tab"
296
- aria-selected={category === entry}
297
- className={`v2-start-tab${category === entry ? " active" : ""}`}
298
- onClick={() => setCategory(entry)}
299
- >
300
- {ko(entry, entry === "All" ? "전체" : entry === "Film" ? "영화" : entry === "Dialogue" ? "대화" : entry === "Action" ? "액션" : "차량")}
301
- </button>
302
- ))}
303
- </div>
304
- )}
305
280
  </div>
306
281
 
307
282
  {activeNav === "learn" ? (
@@ -327,7 +302,7 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
327
302
  ) : (
328
303
  <>
329
304
  <div className="v2-start-template-grid" role="list" aria-label={ko("Project templates", "프로젝트 템플릿")}>
330
- {visibleTemplates.map((template) => (
305
+ {templates.map((template) => (
331
306
  <button
332
307
  type="button"
333
308
  role="listitem"
@@ -337,15 +312,20 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
337
312
  onClick={() => selectTemplate(template)}
338
313
  onDoubleClick={() => template.starterId && onStarter?.(template.starterId)}
339
314
  >
340
- <span className={`v2-start-template-art ${template.kind}`} aria-hidden="true">
341
- <span className="v2-start-template-code">{template.code}</span>
342
- </span>
315
+ <span className={`v2-start-template-art ${template.kind}`}><TemplateThumb id={template.starterId ?? template.id} /></span>
343
316
  <span className="v2-start-template-copy">
344
317
  <strong>{template.name}</strong>
345
318
  <span>{template.description}</span>
346
319
  </span>
347
320
  </button>
348
321
  ))}
322
+ <button type="button" role="listitem" className={`v2-start-add-card${selectedTemplate?.id === NEW_PROJECT.id ? " selected" : ""}`} data-testid="start-new-project-card" onClick={() => { selectTemplate(NEW_PROJECT); requestAnimationFrame(() => nameInputRef.current?.select()); }}>
323
+ <span className="v2-start-add-art" aria-hidden="true"><svg viewBox="0 0 20 20" width="20" height="20"><path d="M10 3.5v13M3.5 10h13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg></span>
324
+ <span className="v2-start-template-copy">
325
+ <strong>{ko("New project", "새 프로젝트")}</strong>
326
+ <span>{ko("Start from an empty stage", "빈 무대에서 시작")}</span>
327
+ </span>
328
+ </button>
349
329
  </div>
350
330
 
351
331
  <div className="v2-start-recent-block">
@@ -357,15 +337,11 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
357
337
  </div>
358
338
  </>
359
339
  )}
360
- {activeNav !== "recent" && activeNav !== "learn" && (
361
- <div className="v2-start-file-actions">
362
- <button type="button" className="v2-start-file-button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button>
363
- </div>
364
- )}
340
+ {activeNav !== "recent" && activeNav !== "learn" && <div className="v2-start-file-actions"><button type="button" className="v2-start-file-button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button></div>}
365
341
  </main>
366
342
 
367
343
  <aside className="v2-start-preview" data-testid="start-project-preview" aria-label={ko("Project preview", "프로젝트 미리보기")}>
368
- <div className={`v2-start-preview-art ${selectedTemplate?.kind || "blank"}`} aria-hidden="true" />
344
+ <div className={`v2-start-preview-art ${selectedTemplate?.kind || "blank"}`}><TemplateThumb id={selectedTemplate.starterId ?? selectedTemplate.thumbId} /></div>
369
345
  <div className="v2-start-preview-copy">
370
346
  <strong>{selectedTemplate?.name}</strong>
371
347
  <span>{selectedTemplate?.description}</span>