create-imagine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/bin/create-imagine.js +407 -0
  4. package/package.json +19 -0
  5. package/templates/blank/README.md +96 -0
  6. package/templates/blank/gitignore +6 -0
  7. package/templates/blank/index.html +13 -0
  8. package/templates/blank/package.json +42 -0
  9. package/templates/blank/projects/example/figures/hello-world.tsx +45 -0
  10. package/templates/blank/projects/example/manifest.ts +44 -0
  11. package/templates/blank/projects/example/project.ts +16 -0
  12. package/templates/blank/projects/example/props.json +4 -0
  13. package/templates/blank/scripts/list.ts +46 -0
  14. package/templates/blank/scripts/projects.ts +43 -0
  15. package/templates/blank/scripts/render.ts +288 -0
  16. package/templates/blank/scripts/server.ts +117 -0
  17. package/templates/blank/src/core/__tests__/controls.test.ts +14 -0
  18. package/templates/blank/src/core/__tests__/manifest.test.ts +45 -0
  19. package/templates/blank/src/core/__tests__/props.test.ts +9 -0
  20. package/templates/blank/src/core/controls.ts +14 -0
  21. package/templates/blank/src/core/manifest.ts +134 -0
  22. package/templates/blank/src/core/props.ts +7 -0
  23. package/templates/blank/src/framework/Figure.tsx +34 -0
  24. package/templates/blank/src/framework/__tests__/sizing.test.ts +29 -0
  25. package/templates/blank/src/framework/charts/Axes.tsx +103 -0
  26. package/templates/blank/src/framework/charts/GridLines.tsx +59 -0
  27. package/templates/blank/src/framework/charts/Legend.tsx +31 -0
  28. package/templates/blank/src/framework/charts/Series.tsx +50 -0
  29. package/templates/blank/src/framework/charts/scales.ts +19 -0
  30. package/templates/blank/src/framework/diagrams/primitives.tsx +134 -0
  31. package/templates/blank/src/framework/layout/PanelGrid.tsx +60 -0
  32. package/templates/blank/src/framework/math/MathSvg.tsx +35 -0
  33. package/templates/blank/src/framework/math/mathjax.ts +64 -0
  34. package/templates/blank/src/framework/sizing.ts +28 -0
  35. package/templates/blank/src/framework/theme.ts +35 -0
  36. package/templates/blank/src/framework/types.ts +42 -0
  37. package/templates/blank/src/main.tsx +11 -0
  38. package/templates/blank/src/studio/StudioApp.tsx +130 -0
  39. package/templates/blank/src/studio/StudioRoot.tsx +14 -0
  40. package/templates/blank/src/studio/base64url.ts +8 -0
  41. package/templates/blank/src/studio/figureLoader.ts +30 -0
  42. package/templates/blank/src/studio/projectLoader.ts +40 -0
  43. package/templates/blank/src/studio/propsApi.ts +26 -0
  44. package/templates/blank/src/studio/routes/FigureView.tsx +365 -0
  45. package/templates/blank/src/studio/routes/ProjectHome.tsx +107 -0
  46. package/templates/blank/src/studio/routes/ProjectsHome.tsx +63 -0
  47. package/templates/blank/src/studio/routes/RenderView.tsx +123 -0
  48. package/templates/blank/src/studio/studio.css +540 -0
  49. package/templates/blank/src/studio/useProjectProps.ts +129 -0
  50. package/templates/blank/src/vite-env.d.ts +2 -0
  51. package/templates/blank/tsconfig.json +20 -0
  52. package/templates/blank/vite.config.ts +82 -0
  53. package/templates/blank/vitest.config.ts +8 -0
  54. package/templates/example/README.md +96 -0
  55. package/templates/example/gitignore +6 -0
  56. package/templates/example/index.html +13 -0
  57. package/templates/example/package.json +42 -0
  58. package/templates/example/projects/example/figures/ai-agent-architecture.tsx +133 -0
  59. package/templates/example/projects/example/figures/equation.tsx +29 -0
  60. package/templates/example/projects/example/figures/hello-world.tsx +45 -0
  61. package/templates/example/projects/example/figures/line-chart.tsx +80 -0
  62. package/templates/example/projects/example/figures/multi-panel.tsx +51 -0
  63. package/templates/example/projects/example/figures/pipeline-diagram.tsx +51 -0
  64. package/templates/example/projects/example/manifest.ts +161 -0
  65. package/templates/example/projects/example/project.ts +31 -0
  66. package/templates/example/projects/example/props.json +10 -0
  67. package/templates/example/public/projects/example/previews/ai-agent-architecture--default.png +0 -0
  68. package/templates/example/public/projects/example/previews/equation--default.png +0 -0
  69. package/templates/example/public/projects/example/previews/hello-world--default.png +0 -0
  70. package/templates/example/public/projects/example/previews/line-chart--default.png +0 -0
  71. package/templates/example/public/projects/example/previews/multi-panel--default.png +0 -0
  72. package/templates/example/public/projects/example/previews/pipeline-diagram--default.png +0 -0
  73. package/templates/example/scripts/list.ts +46 -0
  74. package/templates/example/scripts/projects.ts +43 -0
  75. package/templates/example/scripts/render.ts +288 -0
  76. package/templates/example/scripts/server.ts +117 -0
  77. package/templates/example/src/core/__tests__/controls.test.ts +14 -0
  78. package/templates/example/src/core/__tests__/manifest.test.ts +45 -0
  79. package/templates/example/src/core/__tests__/props.test.ts +9 -0
  80. package/templates/example/src/core/controls.ts +14 -0
  81. package/templates/example/src/core/manifest.ts +134 -0
  82. package/templates/example/src/core/props.ts +7 -0
  83. package/templates/example/src/framework/Figure.tsx +34 -0
  84. package/templates/example/src/framework/__tests__/sizing.test.ts +29 -0
  85. package/templates/example/src/framework/charts/Axes.tsx +103 -0
  86. package/templates/example/src/framework/charts/GridLines.tsx +59 -0
  87. package/templates/example/src/framework/charts/Legend.tsx +31 -0
  88. package/templates/example/src/framework/charts/Series.tsx +50 -0
  89. package/templates/example/src/framework/charts/scales.ts +19 -0
  90. package/templates/example/src/framework/diagrams/primitives.tsx +134 -0
  91. package/templates/example/src/framework/layout/PanelGrid.tsx +60 -0
  92. package/templates/example/src/framework/math/MathSvg.tsx +35 -0
  93. package/templates/example/src/framework/math/mathjax.ts +64 -0
  94. package/templates/example/src/framework/sizing.ts +28 -0
  95. package/templates/example/src/framework/theme.ts +35 -0
  96. package/templates/example/src/framework/types.ts +42 -0
  97. package/templates/example/src/main.tsx +11 -0
  98. package/templates/example/src/studio/StudioApp.tsx +130 -0
  99. package/templates/example/src/studio/StudioRoot.tsx +14 -0
  100. package/templates/example/src/studio/base64url.ts +8 -0
  101. package/templates/example/src/studio/figureLoader.ts +30 -0
  102. package/templates/example/src/studio/projectLoader.ts +40 -0
  103. package/templates/example/src/studio/propsApi.ts +26 -0
  104. package/templates/example/src/studio/routes/FigureView.tsx +365 -0
  105. package/templates/example/src/studio/routes/ProjectHome.tsx +107 -0
  106. package/templates/example/src/studio/routes/ProjectsHome.tsx +63 -0
  107. package/templates/example/src/studio/routes/RenderView.tsx +123 -0
  108. package/templates/example/src/studio/studio.css +540 -0
  109. package/templates/example/src/studio/useProjectProps.ts +129 -0
  110. package/templates/example/src/vite-env.d.ts +2 -0
  111. package/templates/example/tsconfig.json +20 -0
  112. package/templates/example/vite.config.ts +82 -0
  113. package/templates/example/vitest.config.ts +8 -0
@@ -0,0 +1,365 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Link, useNavigate, useParams } from 'react-router-dom';
3
+ import { inferControlsFromProps } from '../../core/controls';
4
+ import type { FigureControl, FigureVariant, ProjectDefinition } from '../../core/manifest';
5
+ import { resolveSize } from '../../framework/sizing';
6
+ import { loadFigureComponent } from '../figureLoader';
7
+ import { loadProject } from '../projectLoader';
8
+ import { useProjectProps } from '../useProjectProps';
9
+
10
+ type ZoomMode = { kind: 'fit' } | { kind: 'percent'; value: number };
11
+
12
+ function useElementSize(ref: React.RefObject<HTMLElement>) {
13
+ const [size, setSize] = useState({ width: 0, height: 0 });
14
+
15
+ useEffect(() => {
16
+ const el = ref.current;
17
+ if (!el) return;
18
+
19
+ const ro = new ResizeObserver((entries) => {
20
+ const entry = entries[0];
21
+ if (!entry) return;
22
+ setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
23
+ });
24
+ ro.observe(el);
25
+ return () => ro.disconnect();
26
+ }, [ref]);
27
+
28
+ return size;
29
+ }
30
+
31
+ function coerceZoomValue(value: number) {
32
+ if (!Number.isFinite(value)) return 100;
33
+ return Math.max(5, Math.min(800, Math.round(value)));
34
+ }
35
+
36
+ export function FigureView() {
37
+ const navigate = useNavigate();
38
+ const params = useParams();
39
+ const projectId = params.projectId ?? '';
40
+ const figureId = params.figureId ?? '';
41
+ const variantId = params.variantId;
42
+ const [FigureComponent, setFigureComponent] = useState<React.ComponentType<any> | null>(null);
43
+ const [zoom, setZoom] = useState<ZoomMode>({ kind: 'fit' });
44
+ const [checker, setChecker] = useState(false);
45
+ const [project, setProject] = useState<ProjectDefinition | null>(null);
46
+ const [projectError, setProjectError] = useState<string | null>(null);
47
+
48
+ useEffect(() => {
49
+ if (!projectId) return;
50
+ setProject(null);
51
+ setProjectError(null);
52
+ loadProject(projectId).then(setProject, (err) => setProjectError(String(err?.message ?? err)));
53
+ }, [projectId]);
54
+
55
+ const fig = project?.figures.find((f) => f.id === figureId);
56
+ const variant: FigureVariant | undefined = useMemo(() => {
57
+ if (!fig) return undefined;
58
+ if (variantId) return fig.variants.find((v) => v.id === variantId) ?? fig.variants[0];
59
+ return fig.variants[0];
60
+ }, [fig, variantId]);
61
+
62
+ const propsState = useProjectProps(projectId);
63
+
64
+ useEffect(() => {
65
+ if (!fig) return;
66
+ setFigureComponent(null);
67
+ loadFigureComponent(projectId, fig.moduleKey).then((Component) => setFigureComponent(() => Component), (err) => {
68
+ // eslint-disable-next-line no-console
69
+ console.error(err);
70
+ setFigureComponent(() => () => (
71
+ <div className="empty">
72
+ <div className="emptyTitle">Failed to load figure module</div>
73
+ <div className="emptyBody mono">{String(err?.message ?? err)}</div>
74
+ </div>
75
+ ));
76
+ });
77
+ }, [projectId, fig?.moduleKey]);
78
+
79
+ const surfaceRef = useRef<HTMLDivElement | null>(null);
80
+ const surfaceSize = useElementSize(surfaceRef);
81
+
82
+ const size = useMemo(() => {
83
+ if (!fig || !variant) return { width: 100, height: 100, source: { unit: 'px' as const, width: 100, height: 100 } };
84
+ return resolveSize(variant.size ?? fig.size);
85
+ }, [fig, variant]);
86
+
87
+ const scale = useMemo(() => {
88
+ if (zoom.kind === 'percent') return zoom.value / 100;
89
+ const pad = 24;
90
+ const w = Math.max(1, surfaceSize.width - pad * 2);
91
+ const h = Math.max(1, surfaceSize.height - pad * 2);
92
+ return Math.max(0.05, Math.min(8, Math.min(w / size.width, h / size.height)));
93
+ }, [zoom, surfaceSize.width, surfaceSize.height, size.width, size.height]);
94
+
95
+ if (projectError) {
96
+ return (
97
+ <div className="page">
98
+ <div className="empty">
99
+ <div className="emptyTitle">Failed to load project</div>
100
+ <div className="emptyBody mono">{projectError}</div>
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ if (!fig || !variant) {
107
+ return (
108
+ <div className="page">
109
+ <div className="empty">
110
+ <div className="emptyTitle">Figure not found</div>
111
+ <div className="emptyBody">
112
+ Go back to <Link to={`/project/${encodeURIComponent(projectId)}`}>project</Link>.
113
+ </div>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ const controls: FigureControl[] = (() => {
120
+ const explicit = [...(fig.controls ?? []), ...(variant.controls ?? [])];
121
+ if (explicit.length) return explicit;
122
+ return inferControlsFromProps(variant.props ?? {});
123
+ })();
124
+
125
+ // const size = ... (already defined above)
126
+ const background = variant.background ?? 'white';
127
+ const variantOverrides = propsState.getVariantOverrides(fig.id, variant.id);
128
+ const effectiveVariantProps = { ...(variant.props ?? {}), ...variantOverrides };
129
+
130
+ const props = {
131
+ width: size.width,
132
+ height: size.height,
133
+ background,
134
+ ...(effectiveVariantProps as any)
135
+ };
136
+
137
+ const mmText = size.mm && size.dpi ? `${size.mm.width}×${size.mm.height} mm @ ${size.dpi}dpi` : null;
138
+
139
+ return (
140
+ <div className="page figurePage">
141
+ <div className="pageHeader figureHeader">
142
+ <div className="figureHeaderLeft">
143
+ <div className="pageTitle">{fig.title}</div>
144
+ <div className="pageSubtitle">
145
+ <span className="mono">{fig.id}</span> • {size.width}×{size.height} px{mmText ? ` • ${mmText}` : ''}
146
+ </div>
147
+ </div>
148
+ <div className="figureHeaderRight">
149
+ <label className="label">
150
+ Variant
151
+ <select
152
+ className="select"
153
+ value={variant.id}
154
+ onChange={(e) =>
155
+ navigate(
156
+ `/project/${encodeURIComponent(projectId)}/figure/${encodeURIComponent(fig.id)}/${encodeURIComponent(
157
+ e.target.value
158
+ )}`
159
+ )
160
+ }
161
+ >
162
+ {fig.variants.map((v) => (
163
+ <option key={v.id} value={v.id}>
164
+ {v.title ? `${v.title} (${v.id})` : v.id}
165
+ </option>
166
+ ))}
167
+ </select>
168
+ </label>
169
+
170
+ <div className="toolbarGroup">
171
+ <button className="btn btnSmall" onClick={() => setZoom({ kind: 'fit' })}>
172
+ Fit
173
+ </button>
174
+ <button className="btn btnSmall" onClick={() => setZoom({ kind: 'percent', value: 100 })}>
175
+ 100%
176
+ </button>
177
+ <button className="btn btnSmall" onClick={() => setZoom({ kind: 'percent', value: 200 })}>
178
+ 200%
179
+ </button>
180
+ </div>
181
+
182
+ <label className="label">
183
+ Zoom
184
+ <input
185
+ className="input"
186
+ type="number"
187
+ min={5}
188
+ max={800}
189
+ step={5}
190
+ value={zoom.kind === 'percent' ? zoom.value : Math.round(scale * 100)}
191
+ onChange={(e) => setZoom({ kind: 'percent', value: coerceZoomValue(Number(e.target.value)) })}
192
+ />
193
+ </label>
194
+
195
+ <label className="checkbox">
196
+ <input type="checkbox" checked={checker} onChange={(e) => setChecker(e.target.checked)} /> Checkerboard
197
+ </label>
198
+
199
+ <Link
200
+ className="btn btnSmall"
201
+ to={`/render/${encodeURIComponent(projectId)}/${encodeURIComponent(fig.id)}/${encodeURIComponent(variant.id)}`}
202
+ target="_blank"
203
+ >
204
+ Render route
205
+ </Link>
206
+ </div>
207
+ </div>
208
+
209
+ <div className="figureBody">
210
+ <div className={`previewSurface ${checker ? 'checker' : ''}`} ref={surfaceRef}>
211
+ <div className="previewScale" style={{ transform: `scale(${scale})` }}>
212
+ <div id="figure-root" style={{ width: size.width, height: size.height }}>
213
+ {FigureComponent ? <FigureComponent {...props} /> : <div className="loading">Loading…</div>}
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <aside className="controlsPanel" aria-label="Controls">
219
+ <div className="controlsHeader">
220
+ <div className="controlsTitle">Controls</div>
221
+ <div className="controlsActions">
222
+ <button
223
+ className="btn btnSmall"
224
+ onClick={() => propsState.resetVariantOverrides(fig.id, variant.id)}
225
+ title="Clear saved overrides for this variant"
226
+ >
227
+ Reset
228
+ </button>
229
+ <button
230
+ className="btn btnSmall"
231
+ onClick={async () => {
232
+ const json = JSON.stringify(variantOverrides, null, 2);
233
+ try {
234
+ await navigator.clipboard.writeText(json);
235
+ } catch {
236
+ window.prompt('Copy overrides JSON:', json);
237
+ }
238
+ }}
239
+ title="Copy overrides JSON"
240
+ >
241
+ Copy JSON
242
+ </button>
243
+ </div>
244
+ </div>
245
+
246
+ <div className="controlsStatus">
247
+ {propsState.readOnly ? (
248
+ <span className="statusMuted" title={propsState.loadError ?? undefined}>
249
+ Saving disabled
250
+ </span>
251
+ ) : propsState.saveStatus === 'saving' ? (
252
+ <span className="statusMuted">Saving…</span>
253
+ ) : propsState.saveStatus === 'saved' ? (
254
+ <span className="statusOk">Saved</span>
255
+ ) : propsState.saveStatus === 'error' ? (
256
+ <span className="statusErr">Save failed</span>
257
+ ) : (
258
+ <span className="statusMuted">Edits auto-save</span>
259
+ )}
260
+ {propsState.saveError ? <div className="statusErr mono">{propsState.saveError}</div> : null}
261
+ </div>
262
+
263
+ {controls.length ? (
264
+ <div className="controlsGrid">
265
+ {controls.map((c, idx) => {
266
+ const key = c.key;
267
+ const label = c.label ?? key;
268
+ const currentValue = (effectiveVariantProps as any)[key];
269
+
270
+ if (c.kind === 'text') {
271
+ const value = typeof currentValue === 'string' ? currentValue : currentValue == null ? '' : String(currentValue);
272
+ return (
273
+ <label key={`${key}:${idx}`} className="control">
274
+ <div className="controlLabel">{label}</div>
275
+ {c.multiline ? (
276
+ <textarea
277
+ className="textarea"
278
+ rows={5}
279
+ placeholder={c.placeholder}
280
+ value={value}
281
+ onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.value)}
282
+ />
283
+ ) : (
284
+ <input
285
+ className="input"
286
+ type="text"
287
+ placeholder={c.placeholder}
288
+ value={value}
289
+ onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.value)}
290
+ />
291
+ )}
292
+ </label>
293
+ );
294
+ }
295
+
296
+ if (c.kind === 'number') {
297
+ const value = typeof currentValue === 'number' && Number.isFinite(currentValue) ? String(currentValue) : '';
298
+ return (
299
+ <label key={`${key}:${idx}`} className="control">
300
+ <div className="controlLabel">{label}</div>
301
+ <input
302
+ className="input"
303
+ type="number"
304
+ min={c.min}
305
+ max={c.max}
306
+ step={c.step}
307
+ value={value}
308
+ onChange={(e) => {
309
+ const s = e.target.value;
310
+ if (!s) propsState.setVariantOverride(fig.id, variant.id, key, undefined);
311
+ else {
312
+ const n = Number(s);
313
+ propsState.setVariantOverride(fig.id, variant.id, key, Number.isFinite(n) ? n : undefined);
314
+ }
315
+ }}
316
+ />
317
+ </label>
318
+ );
319
+ }
320
+
321
+ if (c.kind === 'boolean') {
322
+ const checked = Boolean(currentValue);
323
+ return (
324
+ <label key={`${key}:${idx}`} className="control controlCheckbox">
325
+ <input
326
+ type="checkbox"
327
+ checked={checked}
328
+ onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.checked)}
329
+ />
330
+ <div className="controlLabel">{label}</div>
331
+ </label>
332
+ );
333
+ }
334
+
335
+ if (c.kind === 'select') {
336
+ const value = typeof currentValue === 'string' ? currentValue : currentValue == null ? '' : String(currentValue);
337
+ return (
338
+ <label key={`${key}:${idx}`} className="control">
339
+ <div className="controlLabel">{label}</div>
340
+ <select
341
+ className="select"
342
+ value={value}
343
+ onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.value)}
344
+ >
345
+ {c.options.map((opt) => (
346
+ <option key={opt.value} value={opt.value}>
347
+ {opt.label}
348
+ </option>
349
+ ))}
350
+ </select>
351
+ </label>
352
+ );
353
+ }
354
+
355
+ return null;
356
+ })}
357
+ </div>
358
+ ) : (
359
+ <div className="controlsEmpty">No editable props found.</div>
360
+ )}
361
+ </aside>
362
+ </div>
363
+ </div>
364
+ );
365
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { Link, useParams } from 'react-router-dom';
3
+ import type { ProjectDefinition } from '../../core/manifest';
4
+ import { resolveSize } from '../../framework/sizing';
5
+ import { loadProject } from '../projectLoader';
6
+
7
+ export function ProjectHome() {
8
+ const params = useParams();
9
+ const projectId = params.projectId ?? '';
10
+ const [project, setProject] = useState<ProjectDefinition | null>(null);
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ if (!projectId) return;
15
+ setProject(null);
16
+ setError(null);
17
+ loadProject(projectId).then(setProject, (err) => setError(String(err?.message ?? err)));
18
+ }, [projectId]);
19
+
20
+ const examples = project?.examples ?? [];
21
+
22
+ const figures = useMemo(() => project?.figures ?? [], [project]);
23
+
24
+ if (error) {
25
+ return (
26
+ <div className="page">
27
+ <div className="empty">
28
+ <div className="emptyTitle">Failed to load project</div>
29
+ <div className="emptyBody mono">{error}</div>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ if (!project) {
36
+ return (
37
+ <div className="page">
38
+ <div className="loading">Loading…</div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ return (
44
+ <div className="page">
45
+ <div className="pageHeader">
46
+ <div>
47
+ <div className="pageTitle">{project.title}</div>
48
+ <div className="pageSubtitle">
49
+ <span className="mono">{project.id}</span>
50
+ {project.description ? ` • ${project.description}` : ''}
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ {examples.length ? (
56
+ <div className="section">
57
+ <div className="sectionTitle">Gallery</div>
58
+ <div className="galleryGrid">
59
+ {examples.map((ex) => (
60
+ <Link
61
+ key={`${ex.figureId}/${ex.variantId}`}
62
+ className="galleryItem"
63
+ to={`/project/${encodeURIComponent(project.id)}/figure/${encodeURIComponent(ex.figureId)}/${encodeURIComponent(
64
+ ex.variantId
65
+ )}`}
66
+ >
67
+ <img className="galleryImg" src={ex.src} alt={ex.caption ?? `${ex.figureId}/${ex.variantId}`} loading="lazy" />
68
+ <div className="galleryCaption">{ex.caption ?? `${ex.figureId}/${ex.variantId}`}</div>
69
+ </Link>
70
+ ))}
71
+ </div>
72
+ </div>
73
+ ) : null}
74
+
75
+ <div className="section">
76
+ <div className="sectionTitle">Figures</div>
77
+ <div className="cardGrid">
78
+ {figures.map((f) => {
79
+ const r = resolveSize(f.size);
80
+ const mmText = r.mm && r.dpi ? ` (${r.mm.width}×${r.mm.height} mm @ ${r.dpi}dpi)` : '';
81
+ return (
82
+ <Link
83
+ key={f.id}
84
+ to={`/project/${encodeURIComponent(project.id)}/figure/${encodeURIComponent(f.id)}`}
85
+ className="card"
86
+ >
87
+ <div className="cardTitle">{f.title}</div>
88
+ <div className="cardBody">
89
+ <div className="cardMeta">
90
+ <span className="mono">{f.id}</span>
91
+ </div>
92
+ <div className="cardMeta">
93
+ {r.width}×{r.height} px{mmText}
94
+ </div>
95
+ <div className="cardMeta">
96
+ {f.variants.length} variant{f.variants.length === 1 ? '' : 's'}
97
+ </div>
98
+ </div>
99
+ </Link>
100
+ );
101
+ })}
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
@@ -0,0 +1,63 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import type { ProjectDefinition } from '../../core/manifest';
4
+ import { loadAllProjects } from '../projectLoader';
5
+
6
+ export function ProjectsHome() {
7
+ const [projects, setProjects] = useState<ProjectDefinition[] | null>(null);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ useEffect(() => {
11
+ loadAllProjects().then(setProjects, (err) => setError(String(err?.message ?? err)));
12
+ }, []);
13
+
14
+ return (
15
+ <div className="page">
16
+ <div className="pageHeader">
17
+ <div>
18
+ <div className="pageTitle">Projects</div>
19
+ <div className="pageSubtitle">
20
+ Create a folder in <span className="mono">{'projects/<id>'}</span> to add a new project (restart dev server).
21
+ </div>
22
+ </div>
23
+ </div>
24
+
25
+ {error ? (
26
+ <div className="empty">
27
+ <div className="emptyTitle">Failed to load projects</div>
28
+ <div className="emptyBody mono">{error}</div>
29
+ </div>
30
+ ) : null}
31
+
32
+ <div className="cardGrid">
33
+ {(projects ?? []).map((p) => (
34
+ <Link key={p.id} to={`/project/${encodeURIComponent(p.id)}`} className="card projectCard">
35
+ <div className="cardTitle">{p.title}</div>
36
+ <div className="cardBody">
37
+ <div className="cardMeta">
38
+ <span className="mono">{p.id}</span>
39
+ <span className="dot">•</span>
40
+ <span>{p.figures.length} figures</span>
41
+ </div>
42
+ {p.description ? <div className="cardMeta">{p.description}</div> : null}
43
+ {p.examples?.length ? (
44
+ <div className="thumbRow" aria-label="Example previews">
45
+ {p.examples.slice(0, 3).map((ex) => (
46
+ <img
47
+ key={`${ex.figureId}/${ex.variantId}`}
48
+ className="thumb"
49
+ src={ex.src}
50
+ alt={ex.caption ?? `${ex.figureId}/${ex.variantId}`}
51
+ loading="lazy"
52
+ />
53
+ ))}
54
+ </div>
55
+ ) : null}
56
+ </div>
57
+ </Link>
58
+ ))}
59
+ {!projects && !error ? <div className="loading">Loading…</div> : null}
60
+ </div>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,123 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useLocation, useParams } from 'react-router-dom';
3
+ import type { FigureVariant, ProjectDefinition } from '../../core/manifest';
4
+ import { resolveSize } from '../../framework/sizing';
5
+ import { loadFigureComponent } from '../figureLoader';
6
+ import { waitForMathTasks } from '../../framework/math/mathjax';
7
+ import { loadProject } from '../projectLoader';
8
+ import { base64UrlDecodeToString } from '../base64url';
9
+
10
+ declare global {
11
+ interface Window {
12
+ __IMAGINE_READY__?: boolean;
13
+ }
14
+ }
15
+
16
+ function setPageBackground(bg: 'white' | 'transparent') {
17
+ const val = bg === 'transparent' ? 'transparent' : '#ffffff';
18
+ document.documentElement.style.background = val;
19
+ document.body.style.background = val;
20
+ }
21
+
22
+ async function waitForPendingMathDom(maxMs: number) {
23
+ const start = Date.now();
24
+ while (Date.now() - start < maxMs) {
25
+ const pending = document.querySelector('[data-imagine-math="pending"]');
26
+ if (!pending) return;
27
+ await new Promise((r) => setTimeout(r, 25));
28
+ }
29
+ }
30
+
31
+ export function RenderView() {
32
+ const location = useLocation();
33
+ const params = useParams();
34
+ const projectId = params.projectId ?? '';
35
+ const figureId = params.figureId ?? '';
36
+ const variantId = params.variantId;
37
+ const [FigureComponent, setFigureComponent] = useState<React.ComponentType<any> | null>(null);
38
+ const [project, setProject] = useState<ProjectDefinition | null>(null);
39
+
40
+ const propsOverride = useMemo(() => {
41
+ const encoded = new URLSearchParams(location.search).get('props');
42
+ if (!encoded) return null;
43
+ try {
44
+ const decoded = base64UrlDecodeToString(encoded);
45
+ const parsed = JSON.parse(decoded);
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
47
+ return parsed as Record<string, unknown>;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }, [location.search]);
52
+
53
+ useEffect(() => {
54
+ if (!projectId) return;
55
+ setProject(null);
56
+ loadProject(projectId).then(setProject, (err) => {
57
+ // eslint-disable-next-line no-console
58
+ console.error(err);
59
+ setProject(() => null);
60
+ window.__IMAGINE_READY__ = true;
61
+ });
62
+ }, [projectId]);
63
+
64
+ const fig = project?.figures.find((f) => f.id === figureId);
65
+ const variant: FigureVariant | undefined = useMemo(() => {
66
+ if (!fig) return undefined;
67
+ if (variantId) return fig.variants.find((v) => v.id === variantId) ?? fig.variants[0];
68
+ return fig.variants[0];
69
+ }, [fig, variantId]);
70
+
71
+ useEffect(() => {
72
+ window.__IMAGINE_READY__ = false;
73
+ if (!fig) return;
74
+ setFigureComponent(null);
75
+ loadFigureComponent(projectId, fig.moduleKey).then((Component) => setFigureComponent(() => Component), (err) => {
76
+ // eslint-disable-next-line no-console
77
+ console.error(err);
78
+ setFigureComponent(() => () => null);
79
+ });
80
+ }, [projectId, fig?.moduleKey]);
81
+
82
+ useEffect(() => {
83
+ if (!fig || !variant || !FigureComponent) return;
84
+ (async () => {
85
+ window.__IMAGINE_READY__ = false;
86
+ document.body.style.margin = '0';
87
+ const bg = (variant?.background ?? 'white') as 'white' | 'transparent';
88
+ setPageBackground(bg);
89
+
90
+ await (document as any).fonts?.ready?.catch(() => undefined);
91
+
92
+ // Let figure effects run at least once, then wait for math (if any).
93
+ await new Promise((r) => setTimeout(r, 0));
94
+ await Promise.race([waitForMathTasks(), new Promise((r) => setTimeout(r, 30_000))]);
95
+ await waitForPendingMathDom(30_000);
96
+
97
+ await new Promise(requestAnimationFrame);
98
+ window.__IMAGINE_READY__ = true;
99
+ })().catch((err) => {
100
+ // eslint-disable-next-line no-console
101
+ console.error(err);
102
+ window.__IMAGINE_READY__ = true;
103
+ });
104
+ }, [fig?.id, variant?.id, variant?.background, FigureComponent, location.search]);
105
+
106
+ if (!fig || !variant) return null;
107
+
108
+ const size = resolveSize(variant.size ?? fig.size);
109
+ const background = variant.background ?? 'white';
110
+ const mergedProps = { ...(variant.props ?? {}), ...(propsOverride ?? {}) };
111
+ const props = {
112
+ width: size.width,
113
+ height: size.height,
114
+ background,
115
+ ...(mergedProps as any)
116
+ };
117
+
118
+ return (
119
+ <div id="figure-root" style={{ width: size.width, height: size.height, overflow: 'hidden' }}>
120
+ {FigureComponent ? <FigureComponent {...props} /> : null}
121
+ </div>
122
+ );
123
+ }