cozyclay 3.2.0 → 3.2.1

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 (185) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
@@ -2,12 +2,21 @@
2
2
  // (bin/update-routes.mjs) read and driven from the page. Pure apart from the
3
3
  // injected fetch, timers, storage and window, so test/verify-update-client.mjs
4
4
  // drives it under Node. A page without window.__COZYCLAY_RUNTIME__ (the hosted
5
- // site, Vite dev) makes no request at all.
5
+ // site, Vite dev) makes no request at all. Tabs share a dismiss, an install
6
+ // start and a phase change at once (U2): over a BroadcastChannel, or through a
7
+ // storage event where there is none. A hint only makes a tab fetch now; the
8
+ // launcher's answer stays the only phase a tab shows. Restart now (U2b, #960)
9
+ // hands the launcher to the new version and waits for it to answer.
6
10
  export const UPDATE_URL = "/__cozyclay/update";
11
+ export const RESTART_URL = "/__cozyclay/update/restart";
12
+ export const RESTART_WAIT_MS = 30_000;
13
+ export const RESTART_POLL_MS = 1000;
7
14
  export const DISMISS_KEY = "cozyclay:update-dismissed";
8
15
  export const POLL_MS = 6 * 60 * 60 * 1000;
9
16
  export const VISIBLE_STALE_MS = 60 * 60 * 1000;
10
17
  export const UPDATING_POLL_MS = 2000;
18
+ export const CHANNEL_NAME = "cozyclay:update";
19
+ export const HINT_KEY = "cozyclay:update-hint";
11
20
 
12
21
  const VERSION = /^\d+\.\d+\.\d+$/;
13
22
  const KINDS = new Set(["global", "npx", "clone"]);
@@ -42,16 +51,17 @@ export function normalizeUpdateInfo(body) {
42
51
  phase,
43
52
  errorClass: phase === "failed" ? (ERROR_CLASSES.has(body.errorClass) ? body.errorClass : "other") : null,
44
53
  durationMs: Number.isFinite(body.durationMs) && body.durationMs >= 0 ? body.durationMs : null,
54
+ installed: version(body.installed),
45
55
  // Opened in a new tab, so only the project's own release page is accepted.
46
56
  notesUrl: latest && body.notesUrl === `${NOTES_PREFIX}${latest}` ? body.notesUrl : null,
47
57
  command: kind === "npx" ? NPX_COMMAND : GLOBAL_COMMAND,
48
58
  };
49
59
  }
50
60
 
51
- async function request(fetchImpl, init, win) {
61
+ async function request(fetchImpl, init, win, url = UPDATE_URL) {
52
62
  if (!hasLauncherRuntime(win) || typeof fetchImpl !== "function") return null;
53
63
  try {
54
- const response = await fetchImpl(UPDATE_URL, { cache: "no-store", credentials: "same-origin", ...init });
64
+ const response = await fetchImpl(url, { cache: "no-store", credentials: "same-origin", ...init });
55
65
  if (!response?.ok) return null;
56
66
  return normalizeUpdateInfo(await response.json());
57
67
  } catch {
@@ -65,6 +75,16 @@ export const fetchUpdateInfo = (fetchImpl = globalThis.fetch?.bind(globalThis),
65
75
  /** POST: ask the launcher to install `latest`. The 202 body, or null (403/409/network). No body is sent. */
66
76
  export const startUpdate = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win);
67
77
 
78
+ /** POST: hand the launcher to the installed version. The 202 body, or null (403/409/network). No body is sent. */
79
+ export const requestRestart = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win, RESTART_URL);
80
+
81
+ /** After the new launcher answers: reload only a saved project. True when it reloaded. */
82
+ export function reloadAfterRestart({ dirty, reload }) {
83
+ if (dirty) return false;
84
+ reload();
85
+ return true;
86
+ }
87
+
68
88
  /** patch | minor | major: how far `current` is behind `latest`. */
69
89
  export function behindOf(current, latest) {
70
90
  const [a, b] = [current, latest].map((value) => (version(value) ?? "0.0.0").split(".").map(Number));
@@ -83,16 +103,24 @@ export function updateBlocked({ agentRunning = false, exportStatus = null, recSt
83
103
 
84
104
  const HIDDEN = Object.freeze({ view: "hidden", actions: [] });
85
105
 
86
- /** The chip and dialog view for one poll result and the local dismiss state ({ dismissed }). */
106
+ const RESTART_ACTIONS = { restarting: [], restarted: ["reload"], restart_timeout: ["copy_command"] };
107
+
108
+ /**
109
+ * The chip and dialog view for one poll result and the local state: { dismissed }, and { restart }
110
+ * (restarting | restarted | restart_timeout) once Restart now ran here or in another tab.
111
+ */
87
112
  export function nextUpdateState(info, local = {}) {
88
113
  if (!info || !info.enabled) return HIDDEN;
89
114
  const base = {
90
115
  current: info.current, latest: info.latest, kind: info.kind, notesUrl: info.notesUrl,
91
116
  command: info.command, canSelfUpdate: info.canSelfUpdate, errorClass: null,
92
117
  };
118
+ if (RESTART_ACTIONS[local.restart]) return { ...base, latest: local.target ?? info.latest, view: local.restart, command: RESTART_COMMAND, actions: RESTART_ACTIONS[local.restart] };
93
119
  const oneClick = info.kind === "global" && info.canSelfUpdate;
94
- if (info.phase === "updating") return { ...base, view: "updating", actions: [] };
95
- if (info.phase === "restart_needed") return { ...base, view: "restart_needed", command: RESTART_COMMAND, actions: ["copy_command"] };
120
+ // "Installing X" and "Updated to X" name the version npm installs, which the registry may have passed since.
121
+ const installed = info.installed ?? info.latest;
122
+ if (info.phase === "updating") return { ...base, latest: installed, view: "updating", actions: [] };
123
+ if (info.phase === "restart_needed") return { ...base, latest: installed, view: "restart_needed", command: RESTART_COMMAND, actions: ["restart", "copy_command"] };
96
124
  if (info.phase === "failed") {
97
125
  return { ...base, view: "failed", errorClass: info.errorClass ?? "other", command: GLOBAL_COMMAND,
98
126
  actions: oneClick ? ["retry", "copy_command"] : ["copy_command"] };
@@ -115,6 +143,13 @@ export function writeDismissed(latest, storage = safeStorage()) {
115
143
  try { storage.setItem(DISMISS_KEY, latest); return true; } catch { return false; }
116
144
  }
117
145
 
146
+ const HINTS = new Set(["dismiss", "install", "phase", "restart"]);
147
+
148
+ // A missing or throwing BroadcastChannel is null: hints then travel as a storage event.
149
+ function openUpdateChannel() {
150
+ try { return typeof globalThis.BroadcastChannel === "function" ? new globalThis.BroadcastChannel(CHANNEL_NAME) : null; } catch { return null; }
151
+ }
152
+
118
153
  const defaultTimers = {
119
154
  set: (fn, ms) => setTimeout(fn, ms),
120
155
  clear: (timer) => clearTimeout(timer),
@@ -131,40 +166,97 @@ const defaultTimers = {
131
166
  * The poll loop: once after the first frame and idle, every 6 h, on a visible
132
167
  * tab whose last check is over 1 h old, every 2 s while installing. `onState`
133
168
  * gets { state, info } after every change. Returns { dismiss, update, stop }.
169
+ * `channel` is a BroadcastChannel-shaped object (default: one is opened when
170
+ * the browser has it); null sends the other tabs' hints through `storage`.
171
+ * restart() and a restart hint poll every 1 s, failures included, until the
172
+ * launcher's `current` is the installed version, for up to 30 s.
134
173
  */
135
174
  export function createUpdatePoller({
136
175
  fetchImpl = globalThis.fetch?.bind(globalThis), onState = () => {}, timers = defaultTimers,
137
- storage = safeStorage(), win = globalThis.window, doc = globalThis.document,
176
+ storage = safeStorage(), win = globalThis.window, doc = globalThis.document, channel: injected,
138
177
  } = {}) {
139
- const noop = { dismiss() {}, update: async () => null, stop() {} };
178
+ const noop = { dismiss() {}, update: async () => null, restart: async () => null, stop() {} };
140
179
  if (!hasLauncherRuntime(win)) return noop;
141
- let info = null, timer = null, stopped = false, inflight = null, lastCheck = -Infinity;
142
- let dismissed = readDismissed(storage);
143
- const emit = () => { if (!stopped) onState({ state: nextUpdateState(info, { dismissed }), info }); };
180
+ const channel = injected === undefined ? openUpdateChannel() : injected;
181
+ let info = null, timer = null, stopped = false, inflight = null, lastCheck = -Infinity, hints = 0;
182
+ let dismissed = readDismissed(storage), restart = null, target = null, following = null;
183
+ const emit = () => { if (!stopped) onState({ state: nextUpdateState(info, { dismissed, restart, target }), info }); };
144
184
  const schedule = () => {
145
185
  if (stopped) return;
146
186
  if (timer !== null) timers.clear(timer);
147
187
  timer = timers.set(check, info?.phase === "updating" ? UPDATING_POLL_MS : POLL_MS);
148
188
  };
149
- const apply = (next) => { if (next) { info = next; emit(); } };
150
- function check() {
189
+ // Tell the other tabs; a lost hint only costs them the wait for their next poll.
190
+ const hint = (type, latest = null) => {
191
+ try {
192
+ if (channel) channel.postMessage({ type, latest });
193
+ else if (type !== "dismiss") storage?.setItem(HINT_KEY, `${type}:${timers.now()}:${(hints += 1)}:${version(latest) ?? ""}`);
194
+ } catch { /* best effort */ }
195
+ };
196
+ // Emitted first, so this tab's update:finished claim is in storage before any other tab re-polls.
197
+ // A change a hint led to is not hinted again: the other tabs had the same hint.
198
+ const apply = (next, cause = "phase") => {
199
+ if (!next) return;
200
+ const changed = info !== null && info.phase !== next.phase;
201
+ info = next;
202
+ emit();
203
+ if (cause === "install" || (changed && cause === "phase")) hint(cause);
204
+ };
205
+ function check(cause = "phase") {
151
206
  if (stopped) return Promise.resolve(info);
152
207
  if (inflight) return inflight;
153
208
  // An early check (a visible tab) replaces the pending poll; the schedule never doubles.
154
209
  if (timer !== null) { timers.clear(timer); timer = null; }
155
210
  lastCheck = timers.now();
156
211
  inflight = fetchUpdateInfo(fetchImpl, win);
157
- return inflight.then((next) => { inflight = null; apply(next); schedule(); return info; });
212
+ return inflight.then((next) => { inflight = null; apply(next, cause); schedule(); return info; });
158
213
  }
159
214
  const onVisible = () => {
160
215
  if (doc?.visibilityState === "visible" && timers.now() - lastCheck > VISIBLE_STALE_MS) void check();
161
216
  };
217
+ // A fetch already in flight may predate the hint, so one more follows it.
218
+ const refresh = () => (inflight ? inflight.then(() => check("hinted")) : check("hinted"));
219
+ // The old launcher stops answering for a moment, so a failed fetch is only another try.
220
+ const followRestart = (to) => {
221
+ if (following || !to) return following ?? Promise.resolve(null);
222
+ restart = "restarting";
223
+ target = to;
224
+ emit();
225
+ const deadline = timers.now() + RESTART_WAIT_MS;
226
+ following = (async () => {
227
+ for (;;) {
228
+ await new Promise((done) => timers.set(done, RESTART_POLL_MS));
229
+ if (stopped) return null;
230
+ const next = await fetchUpdateInfo(fetchImpl, win);
231
+ if (next?.current === to) { info = next; restart = "restarted"; break; }
232
+ if (timers.now() >= deadline) { restart = "restart_timeout"; break; }
233
+ }
234
+ emit();
235
+ return restart === "restarted" ? "ok" : "timeout";
236
+ })();
237
+ return following;
238
+ };
239
+ // The version the launcher installed, not the registry's latest: a newer release may appear while it waits.
240
+ const restartTarget = (latest) => version(latest) ?? (info?.phase === "restart_needed" ? info.installed ?? info.latest : null);
241
+ const onHint = (type, latest = null) => {
242
+ if (stopped || !HINTS.has(type)) return;
243
+ if (type === "restart") return void followRestart(restartTarget(latest));
244
+ if (type !== "dismiss") return void refresh();
245
+ dismissed = readDismissed(storage) ?? version(latest) ?? dismissed;
246
+ emit();
247
+ };
248
+ const onMessage = (event) => onHint(event?.data?.type, event?.data?.latest);
162
249
  const onStorage = (event) => {
250
+ if (event?.key === HINT_KEY) { const [type, , , latest] = String(event.newValue ?? "").split(":"); return onHint(type, latest || null); }
163
251
  if (event?.key !== DISMISS_KEY && event?.key !== null) return;
164
252
  dismissed = readDismissed(storage);
165
253
  emit();
166
254
  };
167
- try { doc?.addEventListener?.("visibilitychange", onVisible); win?.addEventListener?.("storage", onStorage); } catch { /* no listeners: the 6 h poll still runs */ }
255
+ try {
256
+ doc?.addEventListener?.("visibilitychange", onVisible);
257
+ win?.addEventListener?.("storage", onStorage);
258
+ channel?.addEventListener?.("message", onMessage);
259
+ } catch { /* no listeners: the 6 h poll still runs */ }
168
260
  timers.afterFirstFrame(() => { if (!stopped) void check(); });
169
261
  return {
170
262
  dismiss(latest) {
@@ -172,17 +264,31 @@ export function createUpdatePoller({
172
264
  writeDismissed(latest, storage);
173
265
  dismissed = latest;
174
266
  emit();
267
+ hint("dismiss", latest);
175
268
  },
176
269
  async update() {
177
270
  const next = await startUpdate(fetchImpl, win);
178
- apply(next);
271
+ apply(next, "install");
179
272
  schedule();
180
273
  return next;
181
274
  },
275
+ // Resolves to the outcome (ok | timeout), or null when the launcher refused.
276
+ async restart() {
277
+ const to = restartTarget(null);
278
+ if (!to || following) return following;
279
+ if (!(await requestRestart(fetchImpl, win))) return null;
280
+ hint("restart", to);
281
+ return followRestart(to);
282
+ },
182
283
  stop() {
183
284
  stopped = true;
184
285
  if (timer !== null) timers.clear(timer);
185
- try { doc?.removeEventListener?.("visibilitychange", onVisible); win?.removeEventListener?.("storage", onStorage); } catch { /* best effort */ }
286
+ try {
287
+ doc?.removeEventListener?.("visibilitychange", onVisible);
288
+ win?.removeEventListener?.("storage", onStorage);
289
+ channel?.removeEventListener?.("message", onMessage);
290
+ channel?.close?.();
291
+ } catch { /* best effort */ }
186
292
  },
187
293
  };
188
294
  }
@@ -1,4 +1,5 @@
1
- // update:notice_shown, update:clicked and update:finished (the U1 update notice).
1
+ // update:notice_shown, update:clicked, update:finished (the U1 update notice) and
2
+ // update:restarted (Restart now, #960).
2
3
  // Only closed values leave: the install kind, how far behind (patch/minor/major),
3
4
  // the button, the outcome, the npm error class and a duration bucket. Never a
4
5
  // version string, a path, a command or npm's output. All three are passive
@@ -10,7 +11,8 @@ import { bucketLong } from "../analytics-strict.js";
10
11
  const CLICKS_PER_SESSION = 10;
11
12
  const FINISHED_PER_SESSION = 3;
12
13
  const NOTICE_KINDS = new Set(["global", "npx"]);
13
- const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes"]);
14
+ const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes", "restart"]);
15
+ const RESTART_OUTCOMES = new Set(["ok", "timeout"]);
14
16
  // The install this browser already reported, shared by the tabs of one profile:
15
17
  // several tabs (and several polls) see the same terminal phase, the event leaves once.
16
18
  const REPORTED_KEY = "cozyclay:update-reported";
@@ -31,7 +33,7 @@ export function noteNoticeShown(state) {
31
33
  }
32
34
  }
33
35
 
34
- /** A notice button: update_now (also Try again), copy_command, later, dismiss or release_notes. */
36
+ /** A notice button: update_now (also Try again), copy_command, later, dismiss, release_notes or restart. */
35
37
  export function noteUpdateClicked(action, kind) {
36
38
  try {
37
39
  if (!CLICK_ACTIONS.has(action) || !NOTICE_KINDS.has(kind)) return;
@@ -41,6 +43,16 @@ export function noteUpdateClicked(action, kind) {
41
43
  }
42
44
  }
43
45
 
46
+ /** Restart now ended in the tab that pressed it: the new launcher answered (ok) or 30 s passed (timeout). Once per session. */
47
+ export function noteUpdateRestarted(outcome) {
48
+ try {
49
+ if (!RESTART_OUTCOMES.has(outcome)) return;
50
+ if (admit("update:restarted", 1)) send("update:restarted", { outcome });
51
+ } catch {
52
+ // Telemetry must never affect the restart.
53
+ }
54
+ }
55
+
44
56
  function claim(key) {
45
57
  if (reportedHere.has(key)) return false;
46
58
  reportedHere.add(key);
@@ -1,11 +1,15 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createAgentTransport } from "./workflow/agent-client.js";
3
3
  import { requestAgentImage, stylizeErrorMessage } from "./agent-image-client.js";
4
- import { blueprintErrorMessage, blueprintRequest } from "./sculpt-blueprint.js";
5
- import { agentSessionReady, createCommandTap, sculptModelChoice, sculptTargetFromSelection, storeSculptModel } from "./sculpt-mode.js";
4
+ import { blueprintErrorMessage, blueprintRequest, blueprintViewImages } from "./sculpt-blueprint.js";
5
+ import { browserSculptImages } from "./commands/sculpt-images.js";
6
+ import { agentSessionReady, chatgptSignedIn, createCommandTap, sculptModelChoice, sculptTargetFromSelection, storeSculptModel } from "./sculpt-mode.js";
6
7
  import { SCULPT_KIND } from "./scene-objects.js";
8
+ import { overlayOwnsEscape, sculptPartTree } from "./sculpt-pick.js";
9
+ import { GIZMO_HOTKEYS } from "./stage-startup-defaults.js";
10
+ import { ko } from "./locale.js";
7
11
 
8
- export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId }) {
12
+ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId, appContext }) {
9
13
  // Sculpt mode (#739): one sculpt on an isolated turntable, the Sculpt panel
10
14
  // and its agent run. The target is the sculpt selected on entry, or a new one
11
15
  // the first run creates; picking another sculpt while here retargets.
@@ -17,6 +21,8 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
17
21
  const [sculptRun, setSculptRun] = useState(null);
18
22
  const sculptRunRef = useRef(null);
19
23
  const [sculptAgentStatus, setSculptAgentStatus] = useState("unknown");
24
+ // Whether the sidecar holds a ChatGPT token: the blueprint's image model needs one (#970).
25
+ const [sculptChatgpt, setSculptChatgpt] = useState(false);
20
26
  // The model Sculpt runs on (its own pick, remembered apart from the chat model) and the list it comes from.
21
27
  const [sculptModels, setSculptModels] = useState({ state: "loading", providers: [], models: [] });
22
28
  const [sculptModelPick, setSculptModelPick] = useState({ model: "", effort: null });
@@ -47,8 +53,8 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
47
53
  let live = true;
48
54
  const read = () => {
49
55
  sculptTransport.status().then(
50
- (status) => { if (live) setSculptAgentStatus(agentSessionReady(status) ? "ready" : "signed-out"); },
51
- () => { if (live) setSculptAgentStatus("signed-out"); },
56
+ (status) => { if (live) { setSculptAgentStatus(agentSessionReady(status) ? "ready" : "signed-out"); setSculptChatgpt(chatgptSignedIn(status)); } },
57
+ () => { if (live) { setSculptAgentStatus("signed-out"); setSculptChatgpt(false); } },
52
58
  );
53
59
  // The Agent panel's catalogue: a key added or removed elsewhere changes what can run.
54
60
  sculptTransport.models().then(
@@ -65,6 +71,50 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
65
71
  window.addEventListener("focus", read);
66
72
  return () => { live = false; window.removeEventListener("focus", read); };
67
73
  }, [sculptMode, sculptTransport]);
74
+ // Part picking (#740 S7): one part of the target, chosen on the turntable or
75
+ // in the panel's tree. A new target or leaving the mode drops it; Esc drops
76
+ // it before Esc reaches the scene selection, unless an open menu or dialog takes that Esc.
77
+ const [sculptPart, setSculptPart] = useState(null);
78
+ useEffect(() => { setSculptPart(null); }, [sculptMode, sculptTargetId]);
79
+ // The part gizmo (#740 S8): its tool, and the last refusal or notice it shows under the tree.
80
+ const [partTool, setPartTool] = useState("move");
81
+ const [partNotice, setPartNotice] = useState(null);
82
+ useEffect(() => { setPartNotice(null); }, [sculptPart?.partId, sculptPart?.mirrored, sculptTargetId]);
83
+ useEffect(() => {
84
+ if (!sculptMode || !sculptPart) return undefined;
85
+ const onKey = (event) => {
86
+ if (event.target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
87
+ // Delete with a part picked would take the whole sculpt: refused, Esc first.
88
+ if (event.key === "Delete" || event.key === "Backspace") {
89
+ event.stopPropagation();
90
+ event.preventDefault();
91
+ setPartNotice(ko("Delete removes the whole sculpt. Press Esc to deselect the part first.", "Delete는 조형물 전체를 지워요. 먼저 Esc로 부품 선택을 해제하세요.", "Delete 会删除整个雕塑。请先按 Esc 取消部件选择。"));
92
+ return;
93
+ }
94
+ if (event.key !== "Escape" || overlayOwnsEscape()) return;
95
+ event.stopPropagation();
96
+ setSculptPart(null);
97
+ };
98
+ window.addEventListener("keydown", onKey, true);
99
+ return () => window.removeEventListener("keydown", onKey, true);
100
+ }, [sculptMode, sculptPart]);
101
+ const sculptRecipe = sculptTarget?.recipe;
102
+ const sculptTree = useMemo(() => sculptPartTree(sculptRecipe), [sculptRecipe]);
103
+ // A recipe edit that removed the part leaves nothing selected.
104
+ const selectedPart = sculptPart && sculptTree.some((row) => row.id === sculptPart.partId) ? sculptPart : null;
105
+ // W/E/R: the selected part's move, rotate and scale; with no part, the turntable views.
106
+ const onKey = (code) => (selectedPart ? setPartTool(GIZMO_HOTKEYS[code]) : requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[code]));
107
+ const tools = [["W", "move", ko("Move part", "부품 이동", "移动部件")], ["E", "rotate", ko("Rotate part", "부품 회전", "旋转部件")], ["R", "scale", ko("Scale part (Shift: uniform)", "부품 크기 (Shift: 균등)", "缩放部件(Shift:等比)")]]
108
+ .map(([key, id, name]) => ({ key, id, name, active: partTool === id, pick: () => setPartTool(id) }));
109
+ // One drag, one write: the command refuses what cannot be drawn and its reason is shown.
110
+ const commit = (edit) => {
111
+ if (!edit || !sculptTarget || !appContext) return false;
112
+ let receipt;
113
+ try { receipt = appContext.bus.run("object.sculpt", { id: sculptTarget.id, edits: [edit] }, { origin: "ui" }); } catch (error) { receipt = { ok: false, message: error?.message }; }
114
+ setPartNotice(receipt?.ok === false ? `${ko("Edit refused", "편집 거부", "编辑被拒绝")}: ${receipt.message ?? ""}` : null);
115
+ return receipt?.ok !== false;
116
+ };
117
+ const sculptParts = { tree: sculptTree, selected: selectedPart, onSelect: setSculptPart, tool: partTool, tools, onKey, commit, notice: partNotice };
68
118
  // The blueprint step: the reference turned into a four-view model sheet by one image request.
69
119
  const [sculptBlueprint, setSculptBlueprint] = useState({ state: "idle", pose: "keep", notes: "", sheet: null, error: null, startedAt: null });
70
120
  const blueprintAbortRef = useRef(null);
@@ -78,7 +128,11 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
78
128
  try {
79
129
  const result = await requestAgentImage(blueprintRequest({ reference: sculptReference, pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }), { signal: controller.signal });
80
130
  if (blueprintAbortRef.current !== controller) return;
81
- setSculptBlueprint((current) => ({ ...current, state: "ready", sheet: { dataUrl: result.dataUrl, width: result.width, height: result.height, ms: Date.now() - startedAt } }));
131
+ // Cut into views (#970): side and back go to Start next to the reference; a view whose mask fails is dropped with a notice.
132
+ let cropped;
133
+ try { cropped = await blueprintViewImages(result.dataUrl, browserSculptImages); } catch (error) { cropped = { views: [], notices: [`The sheet could not be cut into views: ${error?.message || error}`] }; }
134
+ if (blueprintAbortRef.current !== controller) return;
135
+ setSculptBlueprint((current) => ({ ...current, state: "ready", sheet: { dataUrl: result.dataUrl, width: result.width, height: result.height, ms: Date.now() - startedAt, views: cropped.views, notices: cropped.notices } }));
82
136
  } catch (error) {
83
137
  if (blueprintAbortRef.current !== controller) return;
84
138
  const cancelled = controller.signal.aborted;
@@ -91,5 +145,5 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
91
145
  controller?.abort();
92
146
  setSculptBlueprint((current) => ({ ...current, state: current.sheet ? "ready" : "idle" }));
93
147
  }
94
- return { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint };
148
+ return { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptChatgpt, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint, sculptParts };
95
149
  }
@@ -1,7 +1,8 @@
1
1
  // The closed `agent:turn_failed.failure_code` vocabulary, browser side. The
2
2
  // sidecar keeps an identical table in bin/agent/agent-failure-codes.mjs (it
3
3
  // cannot import src/); test/verify-agent-execution.mjs holds both to the same
4
- // answers. Only the structured code and HTTP status are read, never a message.
4
+ // answers. Only the error's name, structured code and HTTP status are read,
5
+ // never a message.
5
6
  //
6
7
  // key_refused the model provider rejected the credentials (provider
7
8
  // 401/403, structured or message-only: runner `unauthorized`)
@@ -14,9 +15,15 @@
14
15
  // LIVE_HUB_UNAVAILABLE: no connected editor)
15
16
  // busy a turn is already running, or the target is busy
16
17
  // invalid_request the sidecar rejected the request (400, INVALID_*, other 4xx)
17
- // upstream provider outage, overload, 5xx, network or unclassified
18
- // aborted / tool_failed / unknown are assigned by the turn, not by this table
19
- export const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown",
18
+ // upstream provider trouble: HTTP 5xx, or the runner's `overloaded`,
19
+ // `upstream`, `truncated` (and the provider's `server_error`)
20
+ // network the request never got an HTTP answer: a fetch TypeError
21
+ // (offline, refused, DNS) or a socket code (ECONNRESET,
22
+ // ENOTFOUND, ...), on the error or its `cause`
23
+ // unknown anything else, including an error whose fields throw
24
+ // aborted / tool_failed are assigned by the turn, not by this table
25
+ // Rows before the network/unknown split carry `upstream` for all three.
26
+ export const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "network", "unknown",
20
27
  "key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]);
21
28
 
22
29
  const BY_CODE = {
@@ -24,24 +31,36 @@ const BY_CODE = {
24
31
  STALE_SCENE: "stale_scene", STALE_TARGET: "stale_scene", STALE_ENVIRONMENT: "stale_scene",
25
32
  CAPABILITY_MISSING: "capability_missing", LIVE_HUB_UNAVAILABLE: "capability_missing",
26
33
  busy: "busy", TARGET_BUSY: "busy", SCOPE_BUSY: "busy", TARGET_NOT_READY: "busy",
34
+ // A sidecar frame whose code is already this table's fallback answer passes through.
35
+ network: "network", unknown: "unknown",
27
36
  };
37
+ // The runner's provider codes read as upstream, after the status: a 4xx beside one is the request's fault.
38
+ const PROVIDER_CODES = new Set(["overloaded", "upstream", "truncated", "server_error"]);
39
+ const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "EPIPE",
40
+ "ENETUNREACH", "ENETDOWN", "EHOSTUNREACH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
41
+ const codeOf = (value) => (typeof value?.code === "string" ? value.code : "");
28
42
 
29
- /** `{ code, status }` -> failure code. Foreign getters fall back to upstream. */
43
+ /** `{ name, code, status }` -> failure code. Foreign getters read as unknown. */
30
44
  export function transportFailureCode(error) {
31
45
  try {
32
- const code = typeof error?.code === "string" ? error.code : "";
46
+ const code = codeOf(error);
33
47
  const status = error?.status;
34
48
  if (Object.hasOwn(BY_CODE, code)) return BY_CODE[code];
35
49
  if (code.startsWith("INVALID_")) return "invalid_request";
36
50
  if (status === 401 || status === 403) return "auth";
37
51
  if (status === 429) return "rate_limited";
38
- return Number.isInteger(status) && status >= 400 && status < 500 ? "invalid_request" : "upstream";
39
- } catch { return "upstream"; }
52
+ if (Number.isInteger(status) && status >= 400 && status < 500) return "invalid_request";
53
+ if (Number.isInteger(status) && status >= 500 && status < 600) return "upstream";
54
+ if (PROVIDER_CODES.has(code)) return "upstream";
55
+ if (Number.isInteger(status)) return "unknown";
56
+ if (NETWORK_CODES.has(code) || NETWORK_CODES.has(codeOf(error?.cause)) || error?.name === "TypeError") return "network";
57
+ return "unknown";
58
+ } catch { return "unknown"; }
40
59
  }
41
60
 
42
61
  /** A refused turn: the sidecar's `{ error: { code } }` body plus the HTTP status. */
43
62
  export async function refusalFailureCode(response) {
44
63
  let code;
45
64
  try { const body = await response.clone().json(); code = typeof body?.error?.code === "string" ? body.error.code : undefined; } catch { /* status alone */ }
46
- try { return transportFailureCode({ code, status: response?.status }); } catch { return "upstream"; }
65
+ try { return transportFailureCode({ code, status: response?.status }); } catch { return "unknown"; }
47
66
  }
@@ -142,7 +142,7 @@ export function startAgentTurn({ surface, capture, now, correlationId = null })
142
142
  return {
143
143
  turnId,
144
144
  cancel: () => finish("cancelled", "aborted"),
145
- fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "upstream"),
145
+ fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "unknown"),
146
146
  /** A tool.start / tool.done frame the panel sees (after replay dedupe). */
147
147
  tool(event) {
148
148
  if (!terminal) advisory(() => tally.note(event), null);
@@ -1,4 +1,4 @@
1
- // The Studio task board in the agent panel (parallel tasks, P3). Task mode is a per-browser switch (the panel's Tasks
1
+ // The Studio task board in the agent panel (parallel tasks, P3). Task mode is a per-browser switch, on by default (the panel's Tasks
2
2
  // toggle) or the server's COZYCLAY_STUDIO_TASKS flag: the chat then talks to the main agent (POST /agent/main), which
3
3
  // answers at once and dispatches tasks; this board shows them. Pure parts here (the reducer, labels, the switch's
4
4
  // storage); the network side is createTaskBoardClient; the view is TaskBoard.jsx.
@@ -13,7 +13,7 @@ import { parseSseChunk } from "./agent-client.js";
13
13
 
14
14
  const TASKS_KEY = "cozyclay.agent.tasks";
15
15
  export function readStoredTaskMode(storage = globalThis.localStorage) {
16
- try { return storage?.getItem(TASKS_KEY) === "on"; } catch { return false; }
16
+ try { return storage?.getItem(TASKS_KEY) !== "off"; } catch { return true; } // on unless the author turned it off
17
17
  }
18
18
  export function storeTaskMode(enabled, storage = globalThis.localStorage) {
19
19
  try { storage?.setItem(TASKS_KEY, enabled ? "on" : "off"); } catch { /* storage may be unavailable */ }
@@ -12,7 +12,7 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
12
12
  PAGES_DIR = os.path.join(os.path.dirname(__file__), "pages")
13
13
 
14
14
  NAV = """ <nav>
15
- <a href="/#try">Try it</a>
15
+ <a href="/app/">Try it</a>
16
16
  <a href="/greybox-to-video/">Greybox to video</a>
17
17
  <a href="/previs-software/">Previs software</a>
18
18
  <a href="/ai-camera-control/">Camera control</a>
@@ -72,6 +72,25 @@
72
72
  </div>
73
73
  </section>
74
74
 
75
+ <section>
76
+ <h2>What the models actually accept</h2>
77
+ <p>
78
+ Seedance 2.5's product page names this input: "Provides advanced capabilities such as white-model control
79
+ and green-screen editing, combined with professional camera movement and performance blocking." Its showcase
80
+ prompt directs the model to "Use the white-model reference video as the sole guide for camera movement,
81
+ pacing, framing, subject motion, and blocking." That white-model reference video is a plain RGB blockout
82
+ clip, which is exactly what the keyframe pack exports. MiniMax H3 reference-to-video, the Wan 3.0 hosted
83
+ <code>reference_video</code>, LTX Desktop's "3D Render to Photoreal" ("keeping the exact composition, camera
84
+ move and layout of the input") and fal render-to-real all consume the same clip.
85
+ </p>
86
+ <p>
87
+ The caveat is Kling Motion Control: "Avoid cuts, camera movement, etc.; otherwise, the video may be
88
+ trimmed." So send it a single character, one continuous take.
89
+ </p>
90
+ <p class="note">None of the top hosted APIs (Seedance, Kling, Veo 3.1, Runway Act-Two, MiniMax)
91
+ documents a depth or pose control-video input.</p>
92
+ </section>
93
+
75
94
  <section>
76
95
  <h2>The block-then-generate loop</h2>
77
96
  <ol>
@@ -107,11 +126,11 @@
107
126
  <h2>Do it in the browser</h2>
108
127
  <p>
109
128
  CozyClay is open source previs that runs in a Chromium tab. It exists for exactly this loop: block,
110
- camera, export, generate. The <a href="/#try">playground on the home page</a> runs the studio in the page, and
129
+ camera, export, generate. The <a href="/app/">browser studio</a> runs it in a tab, and
111
130
  <code>npx cozyclay</code> runs it on your machine.
112
131
  </p>
113
132
  <div class="cta">
114
- <a class="btn primary" href="/#try">Try it in your browser</a>
133
+ <a class="btn primary" href="/app/">Try it in your browser</a>
115
134
  <a class="btn ghost" href="https://github.com/NomaDamas/CozyClay">View on GitHub</a>
116
135
  </div>
117
136
  </section>
@@ -64,8 +64,8 @@
64
64
  <p>
65
65
  The honest split is time, not money. Blender can do everything on this page and will take an
66
66
  afternoon to learn just the camera. CozyClay does one thing (block, camera, cut, export) and expects
67
- you to be productive in the first ten minutes; the <a href="/#try">playground on the home page</a>
68
- runs it in the page.
67
+ you to be productive in the first ten minutes; the <a href="/app/">browser studio</a>
68
+ runs it in a tab.
69
69
  </p>
70
70
  </section>
71
71
 
@@ -115,7 +115,7 @@
115
115
  <li><strong>Everything, given time:</strong> Blender.</li>
116
116
  </ul>
117
117
  <div class="cta">
118
- <a class="btn primary" href="/#try">Try CozyClay in your browser</a>
118
+ <a class="btn primary" href="/app/">Try CozyClay in your browser</a>
119
119
  <a class="btn ghost" href="https://github.com/NomaDamas/CozyClay">Source on GitHub</a>
120
120
  </div>
121
121
  </section>