@gtanczyk/diorama 0.0.0-stage → 0.2.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 (302) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,181 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
2
+ import type { CreativeChoices, IdeaReview as Review } from '../src/world/idea-review.ts';
3
+ import { call } from './api.ts';
4
+ import { IdeaCard } from './idea-card.tsx';
5
+ import { Logo } from './atelier.tsx';
6
+ import { IdeaStoryboard } from './idea-storyboard.tsx';
7
+ import type { SketchApproval } from '../src/world/sketch.ts';
8
+ import { ReviewSketch } from './review-sketch.tsx';
9
+ import { CustomLook, type Look } from './custom-look.tsx';
10
+ import { Icon } from './icons.tsx';
11
+
12
+ export type ReviewInput = { idea: string; photos: Array<{ name: string; data: string }>; videos: string[]; places?: number; language?: string };
13
+ type Decision = { review: Review; answer: string; style: string; styleReference?: string };
14
+
15
+ function ReviewOption({ textOnly, ...props }: Parameters<typeof IdeaCard>[0] & { textOnly: boolean }) {
16
+ if (!textOnly) return <IdeaCard {...props} />;
17
+ return <button type="button" class={`review-text-option${props.selected ? ' selected' : ''}`} aria-label={props.label} aria-pressed={props.selected} hidden={props.hidden} disabled={props.disabled} onClick={props.onPick}>
18
+ {props.selected && <span aria-hidden="true">✓</span>}<span>{props.label}</span>
19
+ </button>;
20
+ }
21
+
22
+ function AnsweredChoice({ decision, input, editing, busy, onEdit, onCancel, onChoose }: {
23
+ decision: Decision; input: ReviewInput; editing: boolean; busy: boolean;
24
+ onEdit: () => void; onCancel: () => void; onChoose: (answer: string) => void;
25
+ }) {
26
+ const q = decision.review.questions[0];
27
+ const [draft, setDraft] = useState(decision.answer);
28
+ const [writing, setWriting] = useState(false);
29
+ useLayoutEffect(() => { setDraft(decision.answer); setWriting(!q.options.includes(decision.answer)); }, [editing, decision.answer]);
30
+ return <section class={`review-exchange${editing ? ' is-editing' : ' is-collapsed'}`} aria-label={`Decision: ${q.question}`}>
31
+ <div class="review-decision-heading"><p class="review-says">{q.question}</p><button type="button" class="ghost small" disabled={busy} aria-label={`${editing ? 'Cancel change' : 'Change answer'}: ${q.question}`} onClick={editing ? onCancel : onEdit}>{editing ? 'Cancel' : 'Change'}</button></div>
32
+ <div class={q.presentation === 'text' ? 'review-text-options' : 'review-cards'} data-lightbox-group>{q.options.map((option, i) => {
33
+ const visual = q.visuals?.[i];
34
+ return <ReviewOption key={i} textOnly={q.presentation === 'text'} label={option} image={visual?.photo !== undefined ? input.photos[visual.photo]?.data : undefined} idea={input.idea} scene={visual?.scene ?? `${q.question} — ${option}`} style={decision.style || undefined} styleReference={decision.styleReference} references={input.photos.slice(0, 4).map(p => p.data)} selected={option === decision.answer} hidden={!editing && option !== decision.answer} disabled={busy} onPick={() => editing ? onChoose(option) : onEdit()} />;
35
+ })}</div>
36
+ {!q.options.includes(decision.answer) && !editing && <button type="button" class="review-written-choice" disabled={busy} onClick={onEdit}>{decision.answer}</button>}
37
+ {editing && <>
38
+ {!writing && <button type="button" class="ghost small" disabled={busy} onClick={() => setWriting(true)}>Something else…</button>}
39
+ {writing && <form class="review-compose" onSubmit={e => { e.preventDefault(); if (draft.trim()) onChoose(draft.trim()); }}>
40
+ <input aria-label="Change your reply" maxLength={600} disabled={busy} value={draft} onInput={e => setDraft(e.currentTarget.value)} />
41
+ <button type="submit" class="ghost" disabled={busy || !draft.trim()}>Save change</button>
42
+ </form>}
43
+ <p class="detail">Changing this choice updates what comes after it.</p>
44
+ </>}
45
+ </section>;
46
+ }
47
+
48
+ export function IdeaReview({ input, busy, error, onBuild, onBack }: {
49
+ input: ReviewInput; busy: boolean; error: string;
50
+ onBuild: (choices?: CreativeChoices, sketch?: SketchApproval) => void; onBack: () => void;
51
+ }) {
52
+ const section = useRef<HTMLElement>(null);
53
+ const latest = useRef<HTMLDivElement>(null);
54
+ const looks = useRef<HTMLElement>(null);
55
+ const [review, setReview] = useState<Review | null>(null);
56
+ const [failure, setFailure] = useState('');
57
+ const [attempt, setAttempt] = useState(0);
58
+ const [decisions, setDecisions] = useState<Decision[]>([]);
59
+ const answers = useMemo(() => decisions.map(d => ({ question: d.review.questions[0].question, answer: d.answer })), [decisions]);
60
+ const [editing, setEditing] = useState<number | null>(null);
61
+ const [style, setStyle] = useState('');
62
+ const [styleChoice, setStyleChoice] = useState<{ title: string; scene: string; options: Look[]; reference?: string } | null>(null);
63
+ const [showStyles, setShowStyles] = useState(false);
64
+ const [looksSeen, setLooksSeen] = useState(false);
65
+ const [customLook, setCustomLook] = useState(false);
66
+ const [draft, setDraft] = useState('');
67
+ const [writing, setWriting] = useState(false);
68
+ const [fullIdea, setFullIdea] = useState(false);
69
+ const [preview, setPreview] = useState<{ choices?: CreativeChoices } | null>(null);
70
+ const submitting = useRef(false);
71
+ const replying = useRef(false);
72
+ const generation = useRef(0);
73
+ useEffect(() => { section.current?.focus({ preventScroll: true }); section.current?.scrollIntoView({ block: 'start' }); }, []);
74
+ useEffect(() => { if (answers.length) latest.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [review, answers.length]);
75
+ useEffect(() => {
76
+ // One useful follow-up is enough. No endlessly expanding interview.
77
+ if (answers.length >= 2 || preview) return;
78
+ let current = true;
79
+ const request = ++generation.current;
80
+ setFailure(''); setReview(null);
81
+ void call<Review>('idea_review', { ...input, ...(answers.length && { choices: { answers, ...(style && { style }) } }) })
82
+ .then(r => { if (current && generation.current === request) { setReview(r); replying.current = false; } })
83
+ .catch(e => { if (current && generation.current === request) { setFailure(e.message); replying.current = false; } });
84
+ return () => { current = false; };
85
+ }, [input, attempt, answers, preview]);
86
+ useEffect(() => { if (!busy) submitting.current = false; }, [busy]);
87
+ const question = answers.length < 2 ? review?.questions[0] : undefined;
88
+ const reply = (text: string, index?: number) => {
89
+ const source = index === undefined ? review : decisions[index]?.review;
90
+ if (!source?.questions[0] || !text.trim() || busy || replying.current) return;
91
+ if (index !== undefined && text.trim() === decisions[index].answer) { setEditing(null); return; }
92
+ generation.current++;
93
+ replying.current = true;
94
+ const at = index ?? decisions.length;
95
+ setDecisions(d => [...d.slice(0, at), { review: source, answer: text.trim(), style: index === undefined ? style : d[index].style, styleReference: index === undefined ? styleChoice?.reference : d[index].styleReference }]);
96
+ setEditing(null);
97
+ setPreview(null);
98
+ setFailure('');
99
+ if (at < 1) setReview(null);
100
+ setDraft('');
101
+ setWriting(false);
102
+ setShowStyles(false);
103
+ };
104
+ const chosen = () => answers.length || style ? { answers, ...(style && { style, ...(styleChoice?.reference && { styleReference: styleChoice.reference }) }) } : undefined;
105
+ const build = (sketch?: SketchApproval) => {
106
+ if (busy || submitting.current) return;
107
+ submitting.current = true;
108
+ // Skip the remaining conversation, not what the creator already told us.
109
+ onBuild(preview?.choices ?? chosen(), sketch);
110
+ };
111
+ const waiting = !review && !failure && answers.length < 2;
112
+ const readyToBuild = answers.length >= 2 || !!review && !review.questions.length;
113
+ const canPreview = readyToBuild && !busy && editing === null && !showStyles;
114
+ const references = input.photos.slice(0, 4).map(p => p.data);
115
+ const styleOptions: Look[] = styleChoice?.options ?? review?.styles ?? [];
116
+ const styleScene = styleChoice?.scene ?? review?.understanding ?? input.idea;
117
+ const offerLooks = readyToBuild && decisions.length > 0 && decisions.every(d => d.review.questions[0].presentation === 'text') && !looksSeen && !style && styleOptions.length > 0 && !preview && editing === null;
118
+ useEffect(() => { if (offerLooks) looks.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [offerLooks]);
119
+ const chooseLook = (look: Look) => {
120
+ if (look.description !== style || look.reference !== styleChoice?.reference) setPreview(null);
121
+ setLooksSeen(true); setStyle(look.description);
122
+ setStyleChoice({ title: look.title, scene: styleScene, options: [...styleOptions.filter(s => s.description !== look.description), look], reference: look.reference });
123
+ setCustomLook(false); setShowStyles(false);
124
+ };
125
+ const openCustomLook = () => { setLooksSeen(true); setEditing(null); setShowStyles(true); setCustomLook(true); };
126
+ const edit = (index: number) => { if (!busy) { replying.current = false; setEditing(index); setShowStyles(false); } };
127
+ return <section ref={section} tabIndex={-1} class={`idea-review${waiting && !preview ? ' is-thinking' : ''}`} aria-label="Shape your world">
128
+ <header class="review-header"><button type="button" class="ghost small" disabled={busy} aria-label="Back to idea" onClick={onBack}>← Edit idea</button><Logo size={30} /><span>Let’s shape your world</span></header>
129
+ <div class="idea-review-body">
130
+ <div class="review-origin"><span class="review-kicker">Your idea</span><p class="review-reply">{!fullIdea && input.idea.length > 240 ? `${input.idea.slice(0, 240)}…` : input.idea}{input.idea.length > 240 && <button type="button" class="ghost small review-expand" aria-expanded={fullIdea} onClick={() => setFullIdea(v => !v)}>{fullIdea ? 'Show less' : 'Read full idea'}</button>}</p>{input.photos.length > 0 && <div class="review-attachments">{input.photos.map(p => <img src={p.data} alt={p.name} title={p.name} />)}</div>}</div>
131
+ {decisions.map((decision, i) => <AnsweredChoice key={i} decision={decision} input={input} editing={editing === i} busy={busy} onEdit={() => edit(i)} onCancel={() => setEditing(null)} onChoose={answer => reply(answer, i)} />)}
132
+ {style && styleChoice && <section class="review-exchange is-collapsed" aria-label="Chosen look">
133
+ <div class="review-decision-heading"><p class="review-says">The look of your world</p><button type="button" class="ghost small" disabled={busy} onClick={() => { setEditing(null); setShowStyles(true); }}>Change look</button></div>
134
+ <div class="review-cards" data-lightbox-group><IdeaCard label={styleChoice.title} idea={input.idea} scene={styleChoice.scene} style={style} styleReference={styleChoice.reference} references={references} selected disabled={busy} onPick={() => { setEditing(null); setShowStyles(true); }} /></div>
135
+ </section>}
136
+ {(showStyles || offerLooks) && <section ref={looks} class="review-current" aria-label="Looks for your world">
137
+ <p class="review-says">{offerLooks ? 'Let’s picture your world.' : 'Which look?'}</p>
138
+ {customLook ? <CustomLook input={input} scene={styleScene} busy={busy} onChoose={chooseLook} onClose={() => setCustomLook(false)} /> : <>
139
+ <div class="review-cards" data-lightbox-group>{styleOptions.map(s => <IdeaCard key={s.description} label={s.title} idea={input.idea} scene={styleScene} style={s.description} styleReference={s.reference} references={references} selected={s.description === style} disabled={busy} onPick={() => chooseLook(s)} />)}</div>
140
+ <button type="button" class="ghost small" disabled={busy} onClick={openCustomLook}><Icon name="pencil" /> My own style…</button>
141
+ </>}
142
+ {!customLook && <><p class="detail">Or let Diorama choose a style from your idea and pictures.</p>
143
+ <div class="review-style-actions">
144
+ <button type="button" class="amber small" disabled={busy} onClick={() => { if (style) setPreview(null); setLooksSeen(true); setStyle(''); setStyleChoice(null); setShowStyles(false); }}>Let Diorama choose</button>
145
+ <button type="button" class="ghost small review-style-cancel" disabled={busy} onClick={() => { setLooksSeen(true); setShowStyles(false); }}>{offerLooks ? 'Not now' : 'Cancel'}</button>
146
+ </div></>}
147
+ </section>}
148
+ {preview && <div hidden={editing !== null || showStyles}><IdeaStoryboard input={input} choices={preview.choices} busy={busy} error={error} onBuild={build} onSkip={() => build()} /></div>}
149
+ {!preview && <div hidden={editing !== null || showStyles || offerLooks}><div ref={latest} class="review-current" aria-live="polite">
150
+ {waiting && <div class="review-thinking"><ReviewSketch /><div class="review-thinking-copy"><span class="review-kicker">At the drawing table</span><p class="review-says" role="status">{answers.length ? 'Got it. One moment…' : 'Let me look at your idea…'}</p><p class="review-aside">A few sketches before the adventure.</p><span class="review-ink-trail" aria-hidden="true" /></div></div>}
151
+ {failure && <div role="alert"><p class="review-says">Let’s go with your idea. We can work out the details along the way.</p><button type="button" class="ghost small" disabled={busy} onClick={() => setAttempt(a => a + 1)}>Try asking again</button><details class="detail"><summary>Details</summary>{failure}</details></div>}
152
+ {!waiting && !failure && <>
153
+ <p class="review-says">{question?.question ?? 'Your story is ready for its first sketches.'}</p>
154
+ {!question && <p class="review-aside">Select <strong>Choose cast &amp; places</strong> below to see your characters and locations.</p>}
155
+ {question?.photo !== undefined && input.photos[question.photo] && <img class="review-reference" src={input.photos[question.photo].data} alt={input.photos[question.photo].name} />}
156
+ {question?.video && <p class="detail">Video {input.videos.indexOf(question.video.id) + 1} · {Math.floor(question.video.seconds / 60)}:{String(Math.floor(question.video.seconds % 60)).padStart(2, '0')}</p>}
157
+ {question && <>
158
+ <div class={question.presentation === 'text' ? 'review-text-options' : 'review-cards'} data-lightbox-group>{question.options.map((option, i) => {
159
+ const visual = question.visuals?.[i];
160
+ return <ReviewOption key={`${question.question}:${i}`} textOnly={question.presentation === 'text'} label={option} image={visual?.photo !== undefined ? input.photos[visual.photo]?.data : undefined} idea={input.idea} scene={visual?.scene ?? `${question.question} — ${option}`} style={style || undefined} styleReference={styleChoice?.reference} references={references} disabled={busy} onPick={() => reply(option)} />;
161
+ })}</div>
162
+ {(writing || !question.options.length) && <form class="review-compose" onSubmit={e => { e.preventDefault(); reply(draft); }}>
163
+ <input aria-label="Your reply" maxLength={600} disabled={busy} placeholder="Or tell me in your own words…" value={draft} onInput={e => setDraft(e.currentTarget.value)} />
164
+ <button type="submit" class="ghost" aria-label="Send reply" disabled={busy || !draft.trim()}>↑</button>
165
+ </form>}
166
+ </>}
167
+ <div class="review-question-actions">
168
+ {question && !writing && question.options.length > 0 && <button type="button" class="ghost small" disabled={busy} onClick={() => setWriting(true)}>Something else…</button>}
169
+ {!style && !!styleOptions.length && <button type="button" class="ghost small review-explore-looks" disabled={busy} onClick={() => { setLooksSeen(true); setShowStyles(true); }}><Icon name="brush" />Show me some looks</button>}
170
+ <button type="button" class="ghost small review-explore-looks" disabled={busy} onClick={openCustomLook}><Icon name="pencil" />My own style…</button>
171
+ </div>
172
+ </>}
173
+ {error && <p role="alert" class="error">{error}</p>}
174
+ </div></div>}
175
+ </div>
176
+ {(!preview || editing !== null || showStyles) && <footer>
177
+ <button type="button" class="ghost" aria-busy={busy} disabled={busy} onClick={() => build()}>{busy && <span class="review-spinner" aria-hidden="true" />}{busy ? 'Starting the build…' : readyToBuild ? 'Skip & build now' : 'Skip & build'}</button>
178
+ <button type="button" class="primary" disabled={!canPreview} aria-busy={waiting && !busy} title={!readyToBuild ? 'Finish the questions before choosing your cast and places.' : undefined} onClick={() => { if (canPreview) setPreview({ choices: chosen() }); }}>{waiting && !busy ? <><span class="review-spinner" aria-hidden="true" />Thinking…</> : 'Choose cast & places'}</button>
179
+ </footer>}
180
+ </section>;
181
+ }
@@ -0,0 +1,134 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { Sketch, SketchApproval, SketchAsset } from '../src/world/sketch.ts';
3
+ import type { CreativeChoices } from '../src/world/idea-review.ts';
4
+ import type { ReviewInput } from './idea-review.tsx';
5
+ import { call } from './api.ts';
6
+ import { ReviewSketch } from './review-sketch.tsx';
7
+ import { ConceptEdit, type ConceptChange } from './concept-edit.tsx';
8
+
9
+ // Two paintings at a time. Leaving the preview prevents queued calls from starting.
10
+ let painting = 0;
11
+ const queue: Array<() => void> = [];
12
+ async function turn<T>(work: () => Promise<T>): Promise<T> {
13
+ if (painting >= 2) await new Promise<void>(resolve => queue.push(resolve));
14
+ else painting++;
15
+ try { return await work(); } finally { const next = queue.shift(); if (next) next(); else painting--; }
16
+ }
17
+
18
+ function Concept({ sketch, asset, disabled, onSelect, onBusy }: {
19
+ sketch: string; asset: SketchAsset; disabled: boolean;
20
+ onSelect: (take: number) => void; onBusy: (busy: boolean, working: boolean) => void;
21
+ }) {
22
+ const [versions, setVersions] = useState<Record<number, string>>({});
23
+ const [largeVersions, setLargeVersions] = useState<Record<number, string>>({});
24
+ const [directions, setDirections] = useState<Record<number, string>>({});
25
+ const [editing, setEditing] = useState(false);
26
+ const [revision, setRevision] = useState<ConceptChange & { baseTake?: number }>({});
27
+ const [nextTake, setNextTake] = useState(1);
28
+ const [selected, setSelected] = useState(0);
29
+ const [requested, setRequested] = useState(0);
30
+ const [attempt, setAttempt] = useState(0);
31
+ const [loading, setLoading] = useState(true);
32
+ const [paintingNow, setPaintingNow] = useState(false);
33
+ const [previousImage, setPreviousImage] = useState('');
34
+ const [error, setError] = useState('');
35
+ useEffect(() => {
36
+ let current = true;
37
+ setLoading(true); setPaintingNow(false); setError(''); onBusy(true, true);
38
+ void turn(async () => {
39
+ if (!current) return;
40
+ setPaintingNow(true);
41
+ const result = await call<{ image: string; largeImage?: string; nextTake?: number }>('idea_sketch_image', { id: sketch, kind: asset.kind, key: asset.key, take: requested, ...revision });
42
+ if (current) {
43
+ setPreviousImage(versions[selected] ?? ''); setVersions(v => ({ ...v, [requested]: result.image }));
44
+ setLargeVersions(v => ({ ...v, [requested]: result.largeImage ?? result.image }));
45
+ setDirections(v => ({ ...v, [requested]: revision.change || (revision.reference ? 'Your picture reference' : '') }));
46
+ setNextTake(result.nextTake ?? requested + 1); setEditing(false); setSelected(requested); onSelect(requested);
47
+ }
48
+ }).catch(e => { if (current) setError(e.message); }).finally(() => { if (current) setLoading(false); });
49
+ return () => { current = false; };
50
+ }, [sketch, asset.kind, asset.key, requested, attempt, revision]);
51
+ useEffect(() => { onBusy(loading || editing, loading); }, [loading, editing]);
52
+ const regenerate = (change: ConceptChange = {}) => {
53
+ if (disabled || loading || nextTake > 9) return;
54
+ setLoading(true); onBusy(true, true); setError('');
55
+ setRevision({ ...change, baseTake: selected }); setRequested(nextTake); setNextTake(nextTake + 1);
56
+ };
57
+ const progress = paintingNow ? 'Sketching…' : 'Waiting for a brush…';
58
+ return <article class={`concept-card ${asset.kind}${editing ? ' is-editing' : ''}${loading ? paintingNow ? ' is-painting' : ' is-queued' : ''}`} aria-label={asset.label} aria-busy={loading}>
59
+ <div class="concept-picture">
60
+ {versions[selected] ? <>
61
+ {previousImage && <img class="concept-previous" src={previousImage} alt="" aria-hidden="true" />}
62
+ <img key={selected} class="concept-art" src={versions[selected]} alt={asset.label} />
63
+ </> : <span class="concept-placeholder">{loading && (paintingNow ? <ReviewSketch compact /> : <span class="concept-paper-stack" aria-hidden="true"><i /><i /><i /></span>)}<span>{loading ? progress : 'Preview unavailable'}</span>{loading && <span class="concept-brush-trail" aria-hidden="true" />}</span>}
64
+ {versions[selected] && <span class="concept-watermark">PREVIEW</span>}
65
+ {versions[selected] && <button type="button" class="concept-zoom" data-lightbox={largeVersions[selected] ?? versions[selected]} aria-label={`Enlarge ${asset.label}`} title="See it big"><span aria-hidden="true">⤢</span></button>}
66
+ {loading && versions[selected] && <span class="concept-loading" role="status"><span class="concept-brush-trail" aria-hidden="true" />{paintingNow ? 'Sketching another version…' : 'Waiting for a brush…'}</span>}
67
+ </div>
68
+ <div class="concept-caption"><strong>{asset.label}</strong><span>{asset.role === 'hero' ? 'You play' : asset.role === 'npc' ? 'Someone you meet' : 'A place to explore'}</span></div>
69
+ {directions[selected] && <p class="concept-direction">“{directions[selected]}”</p>}
70
+ {Object.keys(versions).length > 1 && <div class="concept-versions" aria-label={`Versions of ${asset.label}`}>{Object.entries(versions).map(([take, image]) => <button type="button" disabled={disabled || loading} aria-label={`${asset.label}, version ${Number(take) + 1}`} aria-pressed={selected === Number(take)} onClick={() => { if (selected !== Number(take)) setPreviousImage(versions[selected]); setSelected(Number(take)); onSelect(Number(take)); }}><img src={image} alt="" /></button>)}</div>}
71
+ {error ? <div class="concept-error"><button type="button" class="ghost small" disabled={disabled || loading} onClick={() => setAttempt(n => n + 1)}>Retry preview</button><details class="detail"><summary>Details</summary>{error}</details></div>
72
+ : <button type="button" class="ghost small" disabled={disabled || loading || nextTake > 9} onClick={() => regenerate()}>{loading ? paintingNow ? 'Sketching…' : 'Queued' : nextTake > 9 ? 'Choose a version above' : 'Another version'}</button>}
73
+ {versions[selected] && !editing && <button type="button" class="ghost small concept-change" disabled={disabled || loading || nextTake > 9} onClick={() => setEditing(true)}>Change…</button>}
74
+ {editing && <ConceptEdit label={asset.label} disabled={disabled || loading || nextTake > 9} onGenerate={regenerate} onClose={() => setEditing(false)} />}
75
+ </article>;
76
+ }
77
+
78
+ export function IdeaStoryboard({ input, choices, busy, error, onBuild, onSkip }: {
79
+ input: ReviewInput; choices?: CreativeChoices; busy: boolean; error: string;
80
+ onBuild: (approval: SketchApproval) => void; onSkip: () => void;
81
+ }) {
82
+ const [sketch, setSketch] = useState<Sketch | null>(null);
83
+ const [failure, setFailure] = useState('');
84
+ const [expanding, setExpanding] = useState(false);
85
+ const [expandOpen, setExpandOpen] = useState(false);
86
+ const [wish, setWish] = useState('');
87
+ const [expandError, setExpandError] = useState('');
88
+ const [attempt, setAttempt] = useState(0);
89
+ const [selections, setSelections] = useState<Record<string, number>>({});
90
+ const [pending, setPending] = useState<Record<string, boolean>>({});
91
+ const [working, setWorking] = useState<Record<string, boolean>>({});
92
+ const submitting = useRef(false);
93
+ useEffect(() => {
94
+ let current = true;
95
+ setFailure('');
96
+ void call<Sketch>('idea_sketch', { ...input, ...(choices && { choices }) }).then(s => { if (current) setSketch(s); }).catch(e => { if (current) setFailure(e.message); });
97
+ return () => { current = false; };
98
+ }, [input, choices, attempt]);
99
+ useEffect(() => { if (!busy) submitting.current = false; }, [busy]);
100
+ const ready = !expanding && !!sketch && sketch.assets.every(a => Number.isInteger(selections[`${a.kind}:${a.key}`]) && !pending[`${a.kind}:${a.key}`]);
101
+ const generating = expanding || !sketch && !failure || Object.values(working).some(Boolean);
102
+ const expand = async () => {
103
+ if (!sketch || !ready || wish.trim().length < 3) return;
104
+ setExpanding(true); setExpandError('');
105
+ try { const revised = await call<Sketch>('idea_sketch_expand', { id: sketch.id, wish: wish.trim() }); setSketch(revised); setExpandOpen(false); setWish(''); }
106
+ catch (error) { setExpandError((error as Error).message); }
107
+ finally { setExpanding(false); }
108
+ };
109
+ return <div class="idea-storyboard" data-lightbox-group>
110
+ {!sketch && !failure ? <div class="review-thinking"><ReviewSketch /><div class="review-thinking-copy"><span class="review-kicker">The first sketches</span><p class="review-says" role="status">Finding your characters and places…</p><p class="review-aside">Your cast. Your corners of the world.</p><span class="review-ink-trail" aria-hidden="true" /></div></div> : <p class="review-says">{sketch ? <>Meet <em>your world.</em></> : 'The preview needs another try.'}</p>}
111
+ {sketch && <>
112
+ <p class="storyboard-pitch">{sketch.pitch}</p>
113
+ <p class="detail">A first look. Try another version on any card. The game will use your picks as visual references.</p>
114
+ {(['character', 'place'] as const).map(kind => <section class="concept-group" aria-label={kind === 'character' ? 'Your cast' : 'Your places'}>
115
+ <h3>{kind === 'character' ? 'Who’s in the story' : 'Where you’ll go'}</h3>
116
+ <div class={`concept-grid ${kind}`}>{sketch.assets.filter(a => a.kind === kind).map(asset => {
117
+ const key = `${asset.kind}:${asset.key}`;
118
+ return <Concept key={key} sketch={sketch.id} asset={asset} disabled={busy} onSelect={take => setSelections(s => ({ ...s, [key]: take }))} onBusy={(value, active) => { setPending(p => ({ ...p, [key]: value })); setWorking(p => ({ ...p, [key]: active })); }} />;
119
+ })}</div>
120
+ </section>)}
121
+ </>}
122
+ {sketch && <section class="storyboard-expand">
123
+ {!expandOpen ? <button type="button" class="ghost" disabled={busy || !ready} onClick={() => setExpandOpen(true)}>Add characters or places…</button> : <form onSubmit={e => { e.preventDefault(); void expand(); }}>
124
+ <label>Who else, or where else?<textarea value={wish} maxLength={600} disabled={expanding} placeholder="For example: two more fans and a place beneath the stands" onInput={e => setWish(e.currentTarget.value)} /></label>
125
+ <p class="detail">Your current choices stay. New sketches use additional model calls; worlds can have up to eight places.</p>
126
+ <div class="concept-edit-tools"><button type="button" class="ghost small" disabled={expanding} onClick={() => { setExpandOpen(false); setExpandError(''); }}>Cancel</button><button type="submit" class="primary small" disabled={busy || !ready || wish.trim().length < 3}>{expanding ? 'Adding…' : 'Add to my world'}</button></div>
127
+ {expandError && <p role="alert" class="error">{expandError}</p>}
128
+ </form>}
129
+ </section>}
130
+ {failure && <div role="alert"><button type="button" class="ghost" disabled={busy} onClick={() => setAttempt(n => n + 1)}>Retry preview</button><details class="detail"><summary>Details</summary>{failure}</details></div>}
131
+ {error && <p class="error" role="alert">{error}</p>}
132
+ <footer><button type="button" class="ghost" disabled={busy} onClick={onSkip}>Skip & build</button><button type="button" class="primary" aria-busy={busy || generating} disabled={busy || !ready} onClick={() => { if (ready && sketch && !submitting.current) { submitting.current = true; onBuild({ id: sketch.id, selections }); } }}>{(busy || generating) && <span class="review-spinner" aria-hidden="true" />}{busy ? 'Starting the build…' : 'Build this version'}</button></footer>
133
+ </div>;
134
+ }
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>Diorama Studio</title>
8
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
9
+ <link rel="stylesheet" href="/app.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ <script type="module" src="/app.js"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,33 @@
1
+ // Jobs and build steps in plain words, with place and character names instead of slugs and step ids.
2
+ const name = (key: string) => key.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
3
+ /** A build step as people say it. */
4
+ const STEP_WORDS: Record<string, string> = {
5
+ world: 'writing the story', story: 'writing the story', layout: 'planning the places', paint: 'painting', masks: 'cutting out things',
6
+ plates: 'painting changed things', icons: 'drawing the items', derive: 'measuring depth and walk', backdrop: 'painting the far view',
7
+ weather: 'setting the weather', reactions: 'choosing scene reactions', cast: 'painting the cast', model: 'building 3D models', sprites: 'painting sprites',
8
+ sound: 'making sounds', voices: 'giving voices', faces: 'animating speaking faces', verify: 'play testing',
9
+ };
10
+ export const stepWords = (s: string) => STEP_WORDS[s] ?? name(s).toLowerCase();
11
+ /** A job's label ("fix princess-castle from paint", "masks · tram-stop") in plain words with place names, no step ids. */
12
+ export function jobWords(label: string): string {
13
+ let m: RegExpExecArray | null;
14
+ if ((m = /^fix (\S+) from (\S+)/.exec(label))) return `repairing ${name(m[1])}, starting again at ${stepWords(m[2])}`;
15
+ if (/^build\b/.test(label)) return 'building the world';
16
+ if ((m = /^(\S+) · (\S+)$/.exec(label))) return `${stepWords(m[1])} in ${name(m[2])}`;
17
+ if ((m = /^animate (\S+)/.exec(label))) return `moving ${name(m[1])} into 3D`;
18
+ if ((m = /^cast (\S+)/.exec(label))) return `repainting ${name(m[1])}`;
19
+ if ((m = /^voice (\S+)/.exec(label))) return `a new voice for ${name(m[1])}`;
20
+ if ((m = /^icon (\S+)/.exec(label))) return `redrawing ${name(m[1]).toLowerCase()}`;
21
+ if ((m = /^walk (\S+)/.exec(label))) return `the walk of ${name(m[1])}`;
22
+ if ((m = /^(fog|water area|water|weather) (\S+?):?(?: |$)/.exec(label))) return `the ${m[1]} in ${name(m[2])}`;
23
+ if (STEP_WORDS[label]) return STEP_WORDS[label];
24
+ return label.replace(/\b[a-z0-9]+(?:-[a-z0-9]+)+\b/g, (k) => name(k));
25
+ }
26
+
27
+ /** A finished job in the past tense, for the bell's "is ready": "Repaired Stand, rebuilt from painting", not what it was doing. */
28
+ export function doneWords(label: string): string {
29
+ let m: RegExpExecArray | null;
30
+ if ((m = /^fix (\S+) from (\S+)/.exec(label))) return `Repaired ${name(m[1])}: rebuilt from ${stepWords(m[2]).replace(/^planning the places$/, 'the plan')}`;
31
+ if (/^build\b/.test(label)) return 'Built the world';
32
+ return `Finished: ${jobWords(label)}`;
33
+ }
@@ -0,0 +1,49 @@
1
+ // Pictures open over the Studio, not in a new tab: any element with data-lightbox="<url>" opens it full size.
2
+ // Mounted once, like the tips; Esc, a click outside or ✕ closes it. ← and → go through the other pictures beside it
3
+ // (the closest [data-lightbox-group], else the page).
4
+ import { useEffect, useRef, useState } from 'preact/hooks';
5
+
6
+ export function Lightbox() {
7
+ const [shown, setShown] = useState<{ all: string[]; at: number } | null>(null);
8
+ const src = shown?.all[shown.at] ?? null;
9
+ const isOpen = useRef(false);
10
+ isOpen.current = !!src;
11
+ const setSrc = (s: null) => setShown(s);
12
+ const step = (by: number) => setShown((v) => (v && v.all.length > 1 ? { ...v, at: (v.at + by + v.all.length) % v.all.length } : v));
13
+ useEffect(() => {
14
+ const open = (e: MouseEvent) => {
15
+ const el = (e.target as Element | null)?.closest?.('[data-lightbox]');
16
+ if (!el) return;
17
+ e.preventDefault();
18
+ e.stopPropagation();
19
+ const scope = el.closest('[data-lightbox-group]') ?? document;
20
+ const all = [...new Set([...scope.querySelectorAll('[data-lightbox]')].map((x) => x.getAttribute('data-lightbox')!))];
21
+ setShown({ all, at: Math.max(0, all.indexOf(el.getAttribute('data-lightbox')!)) });
22
+ };
23
+ // Captured first and kept to itself while a picture is open: Esc closes the picture only, never also the
24
+ // drawer, view or step it was opened from (their Esc handlers listen after this one).
25
+ const esc = (e: KeyboardEvent) => {
26
+ if (!isOpen.current || !['Escape', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
27
+ e.preventDefault();
28
+ e.stopImmediatePropagation();
29
+ if (e.key === 'Escape') setSrc(null);
30
+ if (e.key === 'ArrowLeft') step(-1);
31
+ if (e.key === 'ArrowRight') step(1);
32
+ };
33
+ addEventListener('click', open, true);
34
+ addEventListener('keydown', esc, true);
35
+ return () => (removeEventListener('click', open, true), removeEventListener('keydown', esc, true));
36
+ }, []);
37
+ if (!src) return null;
38
+ return (
39
+ <div class="lightbox" role="dialog" aria-label="Picture" onClick={(e) => e.target === e.currentTarget && setSrc(null)}>
40
+ <img src={src} alt="" />
41
+ {shown!.all.length > 1 && <>
42
+ <button type="button" class="prev" aria-label="Previous" title="Previous (←)" onClick={() => step(-1)}>‹</button>
43
+ <button type="button" class="next" aria-label="Next" title="Next (→)" onClick={() => step(1)}>›</button>
44
+ <span class="count">{shown!.at + 1} / {shown!.all.length}</span>
45
+ </>}
46
+ <button type="button" class="close" aria-label="Close" title="Close (Esc)" onClick={() => setSrc(null)}>✕</button>
47
+ </div>
48
+ );
49
+ }