zark-design 1.0.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 (31) hide show
  1. package/README.md +60 -0
  2. package/bin/cli.js +177 -0
  3. package/package.json +31 -0
  4. package/templates/REFERENCE.md +376 -0
  5. package/templates/SHOWCASE.html +254 -0
  6. package/templates/assets/zark-icon.png +0 -0
  7. package/templates/assets/zark-logo.png +0 -0
  8. package/templates/brand.jsx +89 -0
  9. package/templates/components.jsx +385 -0
  10. package/templates/design-canvas.jsx +789 -0
  11. package/templates/foundations.jsx +363 -0
  12. package/templates/icons.jsx +65 -0
  13. package/templates/layouts.jsx +232 -0
  14. package/templates/patterns.jsx +268 -0
  15. package/templates/primitives.jsx +306 -0
  16. package/templates/tokens.css +306 -0
  17. package/templates/visual-references/icon-zark.png +0 -0
  18. package/templates/visual-references/logo-zark-principal.png +0 -0
  19. package/templates/visual-references/pasted-1777605750385-0.png +0 -0
  20. package/templates/visual-references/pasted-1777605766298-0.png +0 -0
  21. package/templates/visual-references/pasted-1777605775820-0.png +0 -0
  22. package/templates/visual-references/pasted-1777605789833-0.png +0 -0
  23. package/templates/visual-references/pasted-1777605802420-0.png +0 -0
  24. package/templates/visual-references/pasted-1777605812470-0.png +0 -0
  25. package/templates/visual-references/pasted-1777605817688-0.png +0 -0
  26. package/templates/visual-references/pasted-1777605828485-0.png +0 -0
  27. package/templates/visual-references/pasted-1777605837137-0.png +0 -0
  28. package/templates/visual-references/pasted-1777605849789-0.png +0 -0
  29. package/templates/visual-references/pasted-1777605864942-0.png +0 -0
  30. package/templates/visual-references/pasted-1777605877920-0.png +0 -0
  31. package/templates/visual-references/pasted-1777605897353-0.png +0 -0
@@ -0,0 +1,789 @@
1
+
2
+ // DesignCanvas.jsx — Figma-ish design canvas wrapper
3
+ // Warm gray grid bg + Sections + Artboards + PostIt notes.
4
+ // Artboards are reorderable (grip-drag), deletable, labels/titles are
5
+ // inline-editable, and any artboard can be opened in a fullscreen focus
6
+ // overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
7
+ // via the host bridge. No assets, no deps.
8
+ //
9
+ // Usage:
10
+ // <DesignCanvas>
11
+ // <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
12
+ // <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
13
+ // <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
14
+ // </DCSection>
15
+ // </DesignCanvas>
16
+
17
+ const DC = {
18
+ bg: '#f0eee9',
19
+ grid: 'rgba(0,0,0,0.06)',
20
+ label: 'rgba(60,50,40,0.7)',
21
+ title: 'rgba(40,30,20,0.85)',
22
+ subtitle: 'rgba(60,50,40,0.6)',
23
+ postitBg: '#fef4a8',
24
+ postitText: '#5a4a2a',
25
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
26
+ };
27
+
28
+ // One-time CSS injection (classes are dc-prefixed so they don't collide with
29
+ // the hosted design's own styles).
30
+ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
31
+ const s = document.createElement('style');
32
+ s.id = 'dc-styles';
33
+ s.textContent = [
34
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
35
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
36
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
37
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
38
+ '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
39
+ '.dc-card{transition:box-shadow .15s,transform .15s}',
40
+ '.dc-card *{scrollbar-width:none}',
41
+ '.dc-card *::-webkit-scrollbar{display:none}',
42
+ // Per-artboard header: grip + label on the left, delete/expand on the
43
+ // right. Single flex row; when the artboard's on-screen width is too
44
+ // narrow for both the label yields (ellipsis, then hidden entirely below
45
+ // ~4ch via the container query) and the buttons stay on the row.
46
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
47
+ ' display:flex;align-items:center;container-type:inline-size}',
48
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
49
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
50
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
51
+ '.dc-grip:active{cursor:grabbing}',
52
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
53
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
54
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
55
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
56
+ // until the card is moused.
57
+ '@container (max-width: 110px){',
58
+ ' .dc-labeltext{display:none}',
59
+ ' .dc-grip{opacity:0}',
60
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
61
+ '}',
62
+ '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
63
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
64
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
65
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
66
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}',
67
+ '.dc-expand,.dc-delete{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
68
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
69
+ ' font:inherit;transition:background .12s,color .12s}',
70
+ '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
71
+ '.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}',
72
+ '.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;',
73
+ ' font-size:12px;font-weight:500}',
74
+ '.dc-delete.dc-confirm:hover{background:#b5563a}',
75
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
76
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
77
+ // DCViewport on every transform update and inherits to all descendants —
78
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
79
+ // it the same way.
80
+ //
81
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
82
+ // matter) with its world-space width set to card-width / inv-zoom so that
83
+ // after counter-scaling its on-screen width exactly matches the card's —
84
+ // that's what lets the container query + text-overflow behave against the
85
+ // card's visible edge at every zoom level.
86
+ //
87
+ // The section head uses CSS zoom instead of transform so its layout box
88
+ // grows with the counter-scale, pushing the card row down — otherwise the
89
+ // constant-screen-size title would overflow into the (shrinking) world-
90
+ // space gap and overlap the artboard headers at low zoom.
91
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
92
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
93
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
94
+ ].join('\n');
95
+ document.head.appendChild(s);
96
+ }
97
+
98
+ const DCCtx = React.createContext(null);
99
+
100
+ // ─────────────────────────────────────────────────────────────
101
+ // DesignCanvas — stateful wrapper around the pan/zoom viewport.
102
+ // Owns runtime state (per-section order, renamed titles/labels, hidden
103
+ // artboards, focused artboard). Order/titles/labels/hidden persist to a
104
+ // .design-canvas.state.json
105
+ // sidecar next to the HTML. Reads go via plain fetch() so the saved
106
+ // arrangement is visible anywhere the HTML + sidecar are served together
107
+ // (omelette preview, direct link, downloaded zip). Writes go through the
108
+ // host's window.omelette bridge — editing requires the omelette runtime.
109
+ // Focus is ephemeral.
110
+ // ─────────────────────────────────────────────────────────────
111
+ const DC_STATE_FILE = '.design-canvas.state.json';
112
+
113
+ function DesignCanvas({ children, minScale, maxScale, style }) {
114
+ const [state, setState] = React.useState({ sections: {}, focus: null });
115
+ // Hold rendering until the sidecar read settles so the saved order/titles
116
+ // appear on first paint (no source-order flash). didRead gates writes until
117
+ // the read settles so the empty initial state can't clobber a slow read;
118
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
119
+ // hydration.
120
+ const [ready, setReady] = React.useState(false);
121
+ const didRead = React.useRef(false);
122
+ const skipNextWrite = React.useRef(false);
123
+
124
+ React.useEffect(() => {
125
+ let off = false;
126
+ fetch('./' + DC_STATE_FILE)
127
+ .then((r) => (r.ok ? r.json() : null))
128
+ .then((saved) => {
129
+ if (off || !saved || !saved.sections) return;
130
+ skipNextWrite.current = true;
131
+ setState((s) => ({ ...s, sections: saved.sections }));
132
+ })
133
+ .catch(() => {})
134
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
135
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
136
+ return () => { off = true; clearTimeout(t); };
137
+ }, []);
138
+
139
+ React.useEffect(() => {
140
+ if (!didRead.current) return;
141
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
142
+ const t = setTimeout(() => {
143
+ window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
144
+ }, 250);
145
+ return () => clearTimeout(t);
146
+ }, [state.sections]);
147
+
148
+ // Build registries synchronously from children so FocusOverlay can read
149
+ // them in the same render. Only direct DCSection > DCArtboard children are
150
+ // walked — wrapping them in other elements opts out of focus/reorder.
151
+ const registry = {}; // slotId -> { sectionId, artboard }
152
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
153
+ const sectionOrder = [];
154
+ React.Children.forEach(children, (sec) => {
155
+ if (!sec || sec.type !== DCSection) return;
156
+ const sid = sec.props.id ?? sec.props.title;
157
+ if (!sid) return;
158
+ sectionOrder.push(sid);
159
+ const persisted = state.sections[sid] || {};
160
+ const abs = [];
161
+ React.Children.forEach(sec.props.children, (ab) => {
162
+ if (!ab || ab.type !== DCArtboard) return;
163
+ const aid = ab.props.id ?? ab.props.label;
164
+ if (aid) abs.push([aid, ab]);
165
+ });
166
+ // hidden is scoped to one source revision — when the agent regenerates
167
+ // (artboard-ID set changes), prior deletes don't apply to new content.
168
+ const srcKey = abs.map(([k]) => k).join('\x1f');
169
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
170
+ const srcIds = [];
171
+ abs.forEach(([aid, ab]) => {
172
+ if (hidden.includes(aid)) return;
173
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
174
+ srcIds.push(aid);
175
+ });
176
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
177
+ sectionMeta[sid] = {
178
+ title: persisted.title ?? sec.props.title,
179
+ subtitle: sec.props.subtitle,
180
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
181
+ };
182
+ });
183
+
184
+ const api = React.useMemo(() => ({
185
+ state,
186
+ section: (id) => state.sections[id] || {},
187
+ patchSection: (id, p) => setState((s) => ({
188
+ ...s,
189
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
190
+ })),
191
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
192
+ }), [state]);
193
+
194
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
195
+ React.useEffect(() => {
196
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
197
+ const onPd = (e) => {
198
+ const ae = document.activeElement;
199
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
200
+ };
201
+ document.addEventListener('keydown', onKey);
202
+ document.addEventListener('pointerdown', onPd, true);
203
+ return () => {
204
+ document.removeEventListener('keydown', onKey);
205
+ document.removeEventListener('pointerdown', onPd, true);
206
+ };
207
+ }, [api]);
208
+
209
+ return (
210
+ <DCCtx.Provider value={api}>
211
+ <DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
212
+ {state.focus && registry[state.focus] && (
213
+ <DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
214
+ )}
215
+ </DCCtx.Provider>
216
+ );
217
+ }
218
+
219
+ // ─────────────────────────────────────────────────────────────
220
+ // DCViewport — transform-based pan/zoom (internal)
221
+ //
222
+ // Input mapping (Figma-style):
223
+ // • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
224
+ // • trackpad scroll → pan (two-finger)
225
+ // • mouse wheel → zoom (notched; distinguished from trackpad scroll)
226
+ // • middle-drag / primary-drag-on-bg → pan
227
+ //
228
+ // Transform state lives in a ref and is written straight to the DOM
229
+ // (translate3d + will-change) so wheel ticks don't go through React —
230
+ // keeps pans at 60fps on dense canvases.
231
+ // ─────────────────────────────────────────────────────────────
232
+ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
233
+ const vpRef = React.useRef(null);
234
+ const worldRef = React.useRef(null);
235
+ const tf = React.useRef({ x: 0, y: 0, scale: 1 });
236
+ // Persist viewport across reloads so the user lands back where they were
237
+ // after an agent edit or browser refresh. The sandbox origin is already
238
+ // per-project; pathname keeps multiple canvas files in one project apart.
239
+ const tfKey = 'dc-viewport:' + location.pathname;
240
+ const saveT = React.useRef(0);
241
+
242
+ const lastPostedScale = React.useRef();
243
+ const apply = React.useCallback(() => {
244
+ const { x, y, scale } = tf.current;
245
+ const el = worldRef.current;
246
+ if (!el) return;
247
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
248
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
249
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
250
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
251
+ // ticks leave scale unchanged — skip the cross-frame post for those.
252
+ if (lastPostedScale.current !== scale) {
253
+ lastPostedScale.current = scale;
254
+ window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
255
+ }
256
+ clearTimeout(saveT.current);
257
+ saveT.current = setTimeout(() => {
258
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
259
+ }, 200);
260
+ }, [tfKey]);
261
+
262
+ React.useLayoutEffect(() => {
263
+ const flush = () => {
264
+ clearTimeout(saveT.current);
265
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
266
+ };
267
+ try {
268
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
269
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
270
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
271
+ apply();
272
+ }
273
+ } catch {}
274
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
275
+ // window doesn't drop the last pan/zoom.
276
+ window.addEventListener('pagehide', flush);
277
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
278
+ }, []);
279
+
280
+ React.useEffect(() => {
281
+ const vp = vpRef.current;
282
+ if (!vp) return;
283
+
284
+ const zoomAt = (cx, cy, factor) => {
285
+ const r = vp.getBoundingClientRect();
286
+ const px = cx - r.left, py = cy - r.top;
287
+ const t = tf.current;
288
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
289
+ const k = next / t.scale;
290
+ // keep the world point under the cursor fixed
291
+ t.x = px - (px - t.x) * k;
292
+ t.y = py - (py - t.y) * k;
293
+ t.scale = next;
294
+ apply();
295
+ };
296
+
297
+ // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
298
+ // line-mode deltas (Firefox) or large integer pixel deltas with no X
299
+ // component (Chrome/Safari, typically multiples of 100/120). Trackpad
300
+ // two-finger scroll sends small/fractional pixel deltas, often with
301
+ // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
302
+ const isMouseWheel = (e) =>
303
+ e.deltaMode !== 0 ||
304
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
305
+
306
+ const onWheel = (e) => {
307
+ e.preventDefault();
308
+ if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
309
+ if (e.ctrlKey) {
310
+ // trackpad pinch (or explicit ctrl+wheel)
311
+ zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
312
+ } else if (isMouseWheel(e)) {
313
+ // notched mouse wheel — fixed-ratio step per click
314
+ zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
315
+ } else {
316
+ // trackpad two-finger scroll — pan
317
+ tf.current.x -= e.deltaX;
318
+ tf.current.y -= e.deltaY;
319
+ apply();
320
+ }
321
+ };
322
+
323
+ // Safari sends native gesture* events for trackpad pinch with a smooth
324
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
325
+ // better feel there. No-ops on other browsers. Safari also fires
326
+ // ctrlKey wheel events during the same pinch — isGesturing makes
327
+ // onWheel drop those entirely so they neither zoom nor pan.
328
+ let gsBase = 1;
329
+ let isGesturing = false;
330
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
331
+ const onGestureChange = (e) => {
332
+ e.preventDefault();
333
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
334
+ };
335
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
336
+
337
+ // Drag-pan: middle button anywhere, or primary button on canvas
338
+ // background (anything that isn't an artboard or an inline editor).
339
+ let drag = null;
340
+ const onPointerDown = (e) => {
341
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
342
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
343
+ e.preventDefault();
344
+ vp.setPointerCapture(e.pointerId);
345
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
346
+ vp.style.cursor = 'grabbing';
347
+ };
348
+ const onPointerMove = (e) => {
349
+ if (!drag || e.pointerId !== drag.id) return;
350
+ tf.current.x += e.clientX - drag.lx;
351
+ tf.current.y += e.clientY - drag.ly;
352
+ drag.lx = e.clientX; drag.ly = e.clientY;
353
+ apply();
354
+ };
355
+ const onPointerUp = (e) => {
356
+ if (!drag || e.pointerId !== drag.id) return;
357
+ vp.releasePointerCapture(e.pointerId);
358
+ drag = null;
359
+ vp.style.cursor = '';
360
+ };
361
+
362
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
363
+ // visible midpoint stays fixed — matching the host's iframe-zoom feel.
364
+ const onHostMsg = (e) => {
365
+ const d = e.data;
366
+ if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
367
+ const r = vp.getBoundingClientRect();
368
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
369
+ } else if (d && d.type === '__dc_probe') {
370
+ // Host's [readyGen] reset asks whether a canvas is present; it
371
+ // fires on the iframe's native 'load', which for canvases with
372
+ // images/fonts is after our mount-time announce, so re-announce.
373
+ // Clear the pan-tick guard so apply() re-posts the current scale
374
+ // even if it's unchanged — the host just reset dcScale to 1.
375
+ window.parent.postMessage({ type: '__dc_present' }, '*');
376
+ lastPostedScale.current = undefined;
377
+ apply();
378
+ }
379
+ };
380
+ window.addEventListener('message', onHostMsg);
381
+ // Announce canvas mode so the host toolbar proxies its % control here
382
+ // instead of scaling the iframe element (which would just shrink the
383
+ // viewport window of an infinite canvas). The apply() that follows emits
384
+ // the initial __dc_zoom so the toolbar % is correct before first pinch.
385
+ // lastPostedScale reset mirrors the __dc_probe handler: the layout
386
+ // effect's restore-path apply() may already have posted the restored
387
+ // scale (before __dc_present), so clear the guard to re-post it in order.
388
+ window.parent.postMessage({ type: '__dc_present' }, '*');
389
+ lastPostedScale.current = undefined;
390
+ apply();
391
+
392
+ vp.addEventListener('wheel', onWheel, { passive: false });
393
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
394
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
395
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
396
+ vp.addEventListener('pointerdown', onPointerDown);
397
+ vp.addEventListener('pointermove', onPointerMove);
398
+ vp.addEventListener('pointerup', onPointerUp);
399
+ vp.addEventListener('pointercancel', onPointerUp);
400
+ return () => {
401
+ window.removeEventListener('message', onHostMsg);
402
+ vp.removeEventListener('wheel', onWheel);
403
+ vp.removeEventListener('gesturestart', onGestureStart);
404
+ vp.removeEventListener('gesturechange', onGestureChange);
405
+ vp.removeEventListener('gestureend', onGestureEnd);
406
+ vp.removeEventListener('pointerdown', onPointerDown);
407
+ vp.removeEventListener('pointermove', onPointerMove);
408
+ vp.removeEventListener('pointerup', onPointerUp);
409
+ vp.removeEventListener('pointercancel', onPointerUp);
410
+ };
411
+ }, [apply, minScale, maxScale]);
412
+
413
+ const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
414
+ return (
415
+ <div
416
+ ref={vpRef}
417
+ className="design-canvas"
418
+ style={{
419
+ height: '100vh', width: '100vw',
420
+ background: DC.bg,
421
+ overflow: 'hidden',
422
+ overscrollBehavior: 'none',
423
+ touchAction: 'none',
424
+ position: 'relative',
425
+ fontFamily: DC.font,
426
+ boxSizing: 'border-box',
427
+ ...style,
428
+ }}
429
+ >
430
+ <div
431
+ ref={worldRef}
432
+ style={{
433
+ position: 'absolute', top: 0, left: 0,
434
+ transformOrigin: '0 0',
435
+ willChange: 'transform',
436
+ width: 'max-content', minWidth: '100%',
437
+ minHeight: '100%',
438
+ padding: '60px 0 80px',
439
+ }}
440
+ >
441
+ <div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
442
+ {children}
443
+ </div>
444
+ </div>
445
+ );
446
+ }
447
+
448
+ // ─────────────────────────────────────────────────────────────
449
+ // DCSection — editable title + h-row of artboards in persisted order
450
+ // ─────────────────────────────────────────────────────────────
451
+ function DCSection({ id, title, subtitle, children, gap = 48 }) {
452
+ const ctx = React.useContext(DCCtx);
453
+ const sid = id ?? title;
454
+ const all = React.Children.toArray(children);
455
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
456
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
457
+ const sec = (ctx && sid && ctx.section(sid)) || {};
458
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
459
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
460
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
461
+ const srcKey = allIds.join('\x1f');
462
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
463
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
464
+
465
+ const order = React.useMemo(() => {
466
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
467
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
468
+ }, [sec.order, srcOrder.join('|')]);
469
+
470
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
471
+
472
+ // marginBottom counter-scales so the on-screen gap between sections stays
473
+ // constant — otherwise at low zoom the (world-space) gap collapses while
474
+ // the screen-constant sectionhead below it doesn't, and the title reads as
475
+ // belonging to the section above. paddingBottom below is just enough for
476
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
477
+ // the title sits tight against its own row at every zoom.
478
+ return (
479
+ <div data-dc-section={sid}
480
+ style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
481
+ <div style={{ padding: '0 60px' }}>
482
+ <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
483
+ <DCEditable tag="div" value={sec.title ?? title}
484
+ onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
485
+ style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
486
+ {subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
487
+ </div>
488
+ </div>
489
+ <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
490
+ {order.map((k) => (
491
+ <DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
492
+ label={(sec.labels || {})[k] ?? byId[k].props.label}
493
+ onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
494
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
495
+ onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
496
+ hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
497
+ srcKey,
498
+ }))}
499
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
500
+ ))}
501
+ </div>
502
+ {rest}
503
+ </div>
504
+ );
505
+ }
506
+
507
+ // DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
508
+ function DCArtboard() { return null; }
509
+
510
+ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
511
+ const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
512
+ const id = rawId ?? rawLabel;
513
+ const ref = React.useRef(null);
514
+ const delRef = React.useRef(null);
515
+ const [confirming, setConfirming] = React.useState(false);
516
+
517
+ // Two-click delete: first click arms the button (turns into an inline
518
+ // "Delete?" pill), second click commits. Any pointerdown outside the
519
+ // button disarms.
520
+ React.useEffect(() => {
521
+ if (!confirming) return;
522
+ const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); };
523
+ document.addEventListener('pointerdown', off, true);
524
+ return () => document.removeEventListener('pointerdown', off, true);
525
+ }, [confirming]);
526
+
527
+ // Live drag-reorder: dragged card sticks to cursor; siblings slide into
528
+ // their would-be slots in real time via transforms. DOM order only
529
+ // changes on drop.
530
+ const onGripDown = (e) => {
531
+ e.preventDefault(); e.stopPropagation();
532
+ const me = ref.current;
533
+ // translateX is applied in local (pre-scale) space but pointer deltas and
534
+ // getBoundingClientRect().left are screen-space — divide by the viewport's
535
+ // current scale so the dragged card tracks the cursor at any zoom level.
536
+ const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
537
+ const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
538
+ const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
539
+ const slotXs = homes.map((h) => h.x);
540
+ const startIdx = order.indexOf(id);
541
+ const startX = e.clientX;
542
+ let liveOrder = order.slice();
543
+ me.classList.add('dc-dragging');
544
+
545
+ const layout = () => {
546
+ for (const h of homes) {
547
+ if (h.id === id) continue;
548
+ const slot = liveOrder.indexOf(h.id);
549
+ h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
550
+ }
551
+ };
552
+
553
+ const move = (ev) => {
554
+ const dx = ev.clientX - startX;
555
+ me.style.transform = `translateX(${dx / scale}px)`;
556
+ const cur = homes[startIdx].x + dx;
557
+ let nearest = 0, best = Infinity;
558
+ for (let i = 0; i < slotXs.length; i++) {
559
+ const d = Math.abs(slotXs[i] - cur);
560
+ if (d < best) { best = d; nearest = i; }
561
+ }
562
+ if (liveOrder.indexOf(id) !== nearest) {
563
+ liveOrder = order.filter((k) => k !== id);
564
+ liveOrder.splice(nearest, 0, id);
565
+ layout();
566
+ }
567
+ };
568
+
569
+ const up = () => {
570
+ document.removeEventListener('pointermove', move);
571
+ document.removeEventListener('pointerup', up);
572
+ const finalSlot = liveOrder.indexOf(id);
573
+ me.classList.remove('dc-dragging');
574
+ me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
575
+ // After the settle transition, kill transitions + clear transforms +
576
+ // commit the reorder in the same frame so there's no visual snap-back.
577
+ setTimeout(() => {
578
+ for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
579
+ if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
580
+ requestAnimationFrame(() => requestAnimationFrame(() => {
581
+ for (const h of homes) h.el.style.transition = '';
582
+ }));
583
+ }, 180);
584
+ };
585
+ document.addEventListener('pointermove', move);
586
+ document.addEventListener('pointerup', up);
587
+ };
588
+
589
+ return (
590
+ <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
591
+ <div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
592
+ <div className="dc-labelrow">
593
+ <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
594
+ <svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
595
+ </div>
596
+ <div className="dc-labeltext" onClick={onFocus} title="Click to focus">
597
+ <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
598
+ style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
599
+ </div>
600
+ </div>
601
+ <div className="dc-btns">
602
+ <button ref={delRef} className={'dc-delete' + (confirming ? ' dc-confirm' : '')}
603
+ onClick={() => { if (confirming) onDelete(); else setConfirming(true); }}
604
+ title={confirming ? 'Click again to delete' : 'Delete'}>
605
+ {confirming
606
+ ? <>
607
+ <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6"/></svg>
608
+ Delete?
609
+ </>
610
+ : <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6M5 5.5v3M7 5.5v3"/></svg>}
611
+ </button>
612
+ <button className="dc-expand" onClick={onFocus} title="Focus">
613
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
614
+ </button>
615
+ </div>
616
+ </div>
617
+ <div className="dc-card"
618
+ style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
619
+ {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
620
+ </div>
621
+ </div>
622
+ );
623
+ }
624
+
625
+ // Inline rename — commits on blur or Enter.
626
+ function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
627
+ const T = tag;
628
+ return (
629
+ <T className="dc-editable" contentEditable suppressContentEditableWarning
630
+ onClick={onClick}
631
+ onPointerDown={(e) => e.stopPropagation()}
632
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
633
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
634
+ style={style}>{value}</T>
635
+ );
636
+ }
637
+
638
+ // ─────────────────────────────────────────────────────────────
639
+ // Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
640
+ // sections, Esc or backdrop click to exit.
641
+ // ─────────────────────────────────────────────────────────────
642
+ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
643
+ const ctx = React.useContext(DCCtx);
644
+ const { sectionId, artboard } = entry;
645
+ const sec = ctx.section(sectionId);
646
+ const meta = sectionMeta[sectionId];
647
+ const peers = meta.slotIds;
648
+ const aid = artboard.props.id ?? artboard.props.label;
649
+ const idx = peers.indexOf(aid);
650
+ const secIdx = sectionOrder.indexOf(sectionId);
651
+
652
+ const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
653
+ const goSection = (d) => {
654
+ // Sections whose artboards are all deleted have slotIds:[] — step past
655
+ // them to the next non-empty section so ↑/↓ doesn't dead-end.
656
+ const n = sectionOrder.length;
657
+ for (let i = 1; i < n; i++) {
658
+ const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
659
+ const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
660
+ if (first) { ctx.setFocus(`${ns}/${first}`); return; }
661
+ }
662
+ };
663
+
664
+ React.useEffect(() => {
665
+ const k = (e) => {
666
+ if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
667
+ if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
668
+ if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
669
+ if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
670
+ };
671
+ document.addEventListener('keydown', k);
672
+ return () => document.removeEventListener('keydown', k);
673
+ });
674
+
675
+ const { width = 260, height = 480, children } = artboard.props;
676
+ const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
677
+ React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
678
+ const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
679
+
680
+ const [ddOpen, setDd] = React.useState(false);
681
+ const Arrow = ({ dir, onClick }) => (
682
+ <button onClick={(e) => { e.stopPropagation(); onClick(); }}
683
+ style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
684
+ border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
685
+ width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
686
+ display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
687
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
688
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
689
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
690
+ <path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
691
+ </button>
692
+ );
693
+
694
+ // Portal to body so position:fixed is the real viewport regardless of any
695
+ // transform on DesignCanvas's ancestors (including the canvas zoom itself).
696
+ return ReactDOM.createPortal(
697
+ <div onClick={() => ctx.setFocus(null)}
698
+ onWheel={(e) => e.preventDefault()}
699
+ style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
700
+ fontFamily: DC.font, color: '#fff' }}>
701
+
702
+ {/* top bar: section dropdown (left) · close (right) */}
703
+ <div onClick={(e) => e.stopPropagation()}
704
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
705
+ <div style={{ position: 'relative' }}>
706
+ <button onClick={() => setDd((o) => !o)}
707
+ style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
708
+ borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
709
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
710
+ <span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
711
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
712
+ </span>
713
+ {meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
714
+ </button>
715
+ {ddOpen && (
716
+ <div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
717
+ boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
718
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
719
+ <button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
720
+ style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
721
+ background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
722
+ padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
723
+ {sectionMeta[sid].title}
724
+ </button>
725
+ ))}
726
+ </div>
727
+ )}
728
+ </div>
729
+ <div style={{ flex: 1 }} />
730
+ <button onClick={() => ctx.setFocus(null)}
731
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
732
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
733
+ style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
734
+ borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
735
+ </div>
736
+
737
+ {/* card centered, label + index below — only the card itself stops
738
+ propagation so any backdrop click (including the margins around
739
+ the card) exits focus */}
740
+ <div
741
+ style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
742
+ <div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
743
+ <div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
744
+ boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
745
+ {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
746
+ </div>
747
+ </div>
748
+ <div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
749
+ {(sec.labels || {})[aid] ?? artboard.props.label}
750
+ <span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
751
+ </div>
752
+ </div>
753
+
754
+ <Arrow dir="left" onClick={() => go(-1)} />
755
+ <Arrow dir="right" onClick={() => go(1)} />
756
+
757
+ {/* dots */}
758
+ <div onClick={(e) => e.stopPropagation()}
759
+ style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
760
+ {peers.map((p, i) => (
761
+ <button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
762
+ style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
763
+ background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
764
+ ))}
765
+ </div>
766
+ </div>,
767
+ document.body,
768
+ );
769
+ }
770
+
771
+ // ─────────────────────────────────────────────────────────────
772
+ // Post-it — absolute-positioned sticky note
773
+ // ─────────────────────────────────────────────────────────────
774
+ function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
775
+ return (
776
+ <div style={{
777
+ position: 'absolute', top, left, right, bottom, width,
778
+ background: DC.postitBg, padding: '14px 16px',
779
+ fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
780
+ fontSize: 14, lineHeight: 1.4, color: DC.postitText,
781
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
782
+ transform: `rotate(${rotate}deg)`,
783
+ zIndex: 5,
784
+ }}>{children}</div>
785
+ );
786
+ }
787
+
788
+ Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
789
+