@1agh/maude 0.45.1 → 0.46.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 (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -12,6 +12,10 @@ import { createRoot } from 'react-dom/client';
12
12
  // import that Bun erases), so this pulls only string constants into the client
13
13
  // bundle — no React, no input-router. See the tool-cursor handler below.
14
14
  import { resolveToolCursor } from '../canvas-cursors.ts';
15
+ // feature-2-print-artboards T2 — the single source (print/units.ts) for the
16
+ // Inspector's paper-preset picker, same "pull only pure math/data" shape as
17
+ // canvas-cursors.ts above.
18
+ import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
15
19
  import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
16
20
  import { canvasUrl } from './canvas-url.js';
17
21
  import ChatPanel from './panels/ChatPanel.jsx';
@@ -29,7 +33,7 @@ import GenerateDialog from './generate-dialog.jsx';
29
33
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
30
34
  import SettingsPanel from './panels/SettingsPanel.jsx';
31
35
  import StickerPicker from './panels/StickerPicker.jsx';
32
- import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
36
+ import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, Select, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
33
37
  import {
34
38
  ALargeSmall as LuALargeSmall,
35
39
  AlignCenter as LuAlignCenter,
@@ -960,6 +964,37 @@ const PNG_SCALES = [
960
964
  { value: 3, label: '3× (max)' },
961
965
  ];
962
966
 
967
+ // feature-2-print-artboards T6 — the PNG card's resolution picker folds the
968
+ // legacy 1×/2×/3× multiplier and the new physical-DPI ladder (T4) into ONE
969
+ // select: `kind:'scale'` entries send `options.scale`, `kind:'dpi'` entries
970
+ // send `options.dpi` (exporters/png.ts resolveDeviceScale — dpi wins over
971
+ // scale when both could apply). Default stays 2× (unchanged UX for anyone
972
+ // not exporting for print).
973
+ const PNG_RESOLUTIONS = [
974
+ { id: '1x', label: '1× (native)', kind: 'scale', value: 1 },
975
+ { id: '2x', label: '2× (retina)', kind: 'scale', value: 2 },
976
+ { id: '3x', label: '3× (max)', kind: 'scale', value: 3 },
977
+ { id: 'dpi150', label: '150 dpi', kind: 'dpi', value: 150 },
978
+ { id: 'dpi300', label: '300 dpi (print)', kind: 'dpi', value: 300 },
979
+ { id: 'dpi600', label: '600 dpi (high-res print)', kind: 'dpi', value: 600 },
980
+ ];
981
+ const PNG_RESOLUTION_DEFAULT = '2x';
982
+
983
+ // feature-2-print-artboards T5/T6 (dogfood follow-up) — the PDF page itself
984
+ // is always vector (text/shapes never rasterize), but any RASTER content ON
985
+ // the artboard — a dropped photo, a large-format piece authored at a
986
+ // fraction of its real physical size (e.g. a billboard at 1:10 scale) —
987
+ // still embeds as a bitmap whose density this controls. Default "Auto (1×)"
988
+ // is today's unchanged behavior; PDF has no legacy scale concept, so unlike
989
+ // PNG_RESOLUTIONS this is DPI-only.
990
+ const PDF_DPI_OPTIONS = [
991
+ { id: 'auto', label: 'Auto (1×)', value: undefined },
992
+ { id: 'dpi150', label: '150 dpi', value: 150 },
993
+ { id: 'dpi300', label: '300 dpi (print)', value: 300 },
994
+ { id: 'dpi600', label: '600 dpi (high-res print)', value: 600 },
995
+ ];
996
+ const PDF_DPI_DEFAULT = 'auto';
997
+
963
998
  // feature-bulk-media-insert — best-effort MIME guess from a filename extension.
964
999
  // Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
965
1000
  // no type) for the destination-toggle's image/video/audio split; the actual
@@ -1288,6 +1323,18 @@ function ExportDialog({
1288
1323
  initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
1289
1324
  );
1290
1325
  const [scale, setScale] = useState(2);
1326
+ // feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
1327
+ // PNG_RESOLUTIONS above).
1328
+ const [pngResId, setPngResId] = useState(PNG_RESOLUTION_DEFAULT);
1329
+ // feature-2-print-artboards T5/T6 — PDF print options. Sent unconditionally
1330
+ // on every PDF export; the server no-ops them for a non-print artboard (no
1331
+ // `print` JSX prop → applyPrintBoxesAndMarks never runs), so there's no
1332
+ // need to detect the artboard's kind client-side before showing these.
1333
+ const [pdfIncludeBleed, setPdfIncludeBleed] = useState(true);
1334
+ const [pdfMarksOpen, setPdfMarksOpen] = useState(false);
1335
+ const [pdfMarksCrop, setPdfMarksCrop] = useState(false);
1336
+ const [pdfMarksRegistration, setPdfMarksRegistration] = useState(false);
1337
+ const [pdfDpiId, setPdfDpiId] = useState(PDF_DPI_DEFAULT);
1291
1338
  // DDR-148 addendum — mp4/webm of a registered video-comp render through
1292
1339
  // renderMediaOnWeb, which produces real audio (Remotion owns the
1293
1340
  // TransitionSeries/volume-closure timeline). gif has no audio track at all
@@ -1338,12 +1385,31 @@ function ExportDialog({
1338
1385
  }
1339
1386
  setBusy(true);
1340
1387
  setStatus(null);
1341
- // `scale` drives PNG size AND video resolution (deviceScaleFactor → encoder
1342
- // dims); temporal formats were previously fixed at the tiny native size.
1343
- const options = card.format === 'png' || card.temporal ? { scale } : {};
1388
+ // `scale` drives video resolution (deviceScaleFactor → encoder dims);
1389
+ // temporal formats were previously fixed at the tiny native size. PNG's
1390
+ // resolution now comes from pngResId (T4/T6 — scale OR dpi).
1391
+ const options = card.temporal ? { scale } : {};
1392
+ if (card.format === 'png') {
1393
+ const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) || PNG_RESOLUTIONS[1];
1394
+ if (res.kind === 'dpi') options.dpi = res.value;
1395
+ else options.scale = res.value;
1396
+ }
1344
1397
  // Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
1345
1398
  // format, so the checkbox never renders for it (see hasAudioToggle below).
1346
1399
  if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
1400
+ // feature-2-print-artboards T5/T6 — always sent on a PDF export; the
1401
+ // adapter no-ops includeBleed/marks for a non-print artboard (T5).
1402
+ if (card.format === 'pdf') {
1403
+ options.pdfPrint = {
1404
+ includeBleed: pdfIncludeBleed,
1405
+ marks: { crop: pdfMarksCrop, registration: pdfMarksRegistration },
1406
+ };
1407
+ // Dogfood follow-up — raster CONTENT on the artboard (a dropped photo,
1408
+ // large-format art) still needs a real capture density; the page
1409
+ // itself stays vector regardless of this.
1410
+ const pdfDpi = PDF_DPI_OPTIONS.find((d) => d.id === pdfDpiId)?.value;
1411
+ if (pdfDpi !== undefined) options.dpi = pdfDpi;
1412
+ }
1347
1413
  // Scope targeting hints (resolveScope reads these): `artboardId` makes
1348
1414
  // "Active artboard" export the right screen instead of `:first-of-type`;
1349
1415
  // `selection` makes "Current selection" export the selected element. Mirrors
@@ -1428,14 +1494,42 @@ function ExportDialog({
1428
1494
  </select>
1429
1495
  </div>
1430
1496
  )}
1431
- {!card.handoff && (card.format === 'png' || card.temporal) && (
1497
+ {!card.handoff && card.format === 'png' && (
1432
1498
  <div className="st-dialog-row">
1433
1499
  <label className="st-dialog-lbl" htmlFor="st-export-size">
1434
- {card.temporal ? 'Resolution' : 'Size'}
1500
+ Resolution
1435
1501
  </label>
1436
1502
  <select
1437
1503
  id="st-export-size"
1438
1504
  className="st-select"
1505
+ value={pngResId}
1506
+ onChange={(e) => setPngResId(e.target.value)}
1507
+ >
1508
+ {PNG_RESOLUTIONS.map((r) => (
1509
+ <option key={r.id} value={r.id}>
1510
+ {r.label}
1511
+ </option>
1512
+ ))}
1513
+ </select>
1514
+ </div>
1515
+ )}
1516
+ {!card.handoff && card.format === 'png' && (
1517
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1518
+ {(() => {
1519
+ const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) || PNG_RESOLUTIONS[1];
1520
+ const factor = res.kind === 'dpi' ? res.value / 96 : res.value;
1521
+ return `${res.label} ≈ ${Math.round(1440 * factor)}×${Math.round(900 * factor)} for a 1440×900 artboard.`;
1522
+ })()}
1523
+ </div>
1524
+ )}
1525
+ {!card.handoff && card.temporal && (
1526
+ <div className="st-dialog-row">
1527
+ <label className="st-dialog-lbl" htmlFor="st-export-temporal-size">
1528
+ Resolution
1529
+ </label>
1530
+ <select
1531
+ id="st-export-temporal-size"
1532
+ className="st-select"
1439
1533
  value={scale}
1440
1534
  onChange={(e) => setScale(Number(e.target.value))}
1441
1535
  >
@@ -1447,16 +1541,86 @@ function ExportDialog({
1447
1541
  </select>
1448
1542
  </div>
1449
1543
  )}
1450
- {!card.handoff && card.format === 'png' && (
1451
- <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1452
- Resolution multiplier — {scale}× ≈ {1440 * scale}×{900 * scale} for a 1440×900 artboard.
1453
- </div>
1454
- )}
1455
1544
  {!card.handoff && card.temporal && (
1456
1545
  <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1457
1546
  {scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
1458
1547
  </div>
1459
1548
  )}
1549
+ {!card.handoff && card.format === 'pdf' && (
1550
+ <>
1551
+ <div className="st-dialog-row">
1552
+ <label className="st-dialog-lbl" htmlFor="st-export-pdf-dpi">
1553
+ Image quality
1554
+ </label>
1555
+ <select
1556
+ id="st-export-pdf-dpi"
1557
+ className="st-select"
1558
+ value={pdfDpiId}
1559
+ onChange={(e) => setPdfDpiId(e.target.value)}
1560
+ >
1561
+ {PDF_DPI_OPTIONS.map((d) => (
1562
+ <option key={d.id} value={d.id}>
1563
+ {d.label}
1564
+ </option>
1565
+ ))}
1566
+ </select>
1567
+ </div>
1568
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1569
+ The page stays vector; this only sets the capture density for raster content on it
1570
+ (dropped photos, large-format art).
1571
+ </div>
1572
+ <div className="st-dialog-row">
1573
+ <label
1574
+ className="st-dialog-lbl"
1575
+ htmlFor="st-export-pdf-bleed"
1576
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
1577
+ >
1578
+ <input
1579
+ id="st-export-pdf-bleed"
1580
+ type="checkbox"
1581
+ checked={pdfIncludeBleed}
1582
+ onChange={(e) => setPdfIncludeBleed(e.target.checked)}
1583
+ />
1584
+ Include bleed
1585
+ </label>
1586
+ </div>
1587
+ <div className="st-dialog-row">
1588
+ <button
1589
+ type="button"
1590
+ className="st-dialog-lbl"
1591
+ style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}
1592
+ onClick={() => setPdfMarksOpen((v) => !v)}
1593
+ aria-expanded={pdfMarksOpen}
1594
+ >
1595
+ {pdfMarksOpen ? '▾' : '▸'} Marks
1596
+ </button>
1597
+ </div>
1598
+ {pdfMarksOpen && (
1599
+ <div style={{ padding: '0 12px 4px', display: 'flex', flexDirection: 'column', gap: 6 }}>
1600
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
1601
+ <input
1602
+ type="checkbox"
1603
+ checked={pdfMarksCrop}
1604
+ onChange={(e) => setPdfMarksCrop(e.target.checked)}
1605
+ />
1606
+ Crop marks
1607
+ </label>
1608
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
1609
+ <input
1610
+ type="checkbox"
1611
+ checked={pdfMarksRegistration}
1612
+ onChange={(e) => setPdfMarksRegistration(e.target.checked)}
1613
+ />
1614
+ Registration marks
1615
+ </label>
1616
+ </div>
1617
+ )}
1618
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1619
+ Bleed and marks apply to print (<code>kind="print"</code>) artboards only.
1620
+ {scope === 'canvas-as-separate' ? ' One PDF page per artboard.' : ''}
1621
+ </div>
1622
+ </>
1623
+ )}
1460
1624
  {!card.handoff && (card.format === 'mp4' || card.format === 'webm') && (
1461
1625
  <div className="st-dialog-row">
1462
1626
  <label
@@ -2933,6 +3097,11 @@ function EditDropdown({ onAction, onClose, hasCanvas }) {
2933
3097
  { id: 'new-artboard:laptop', label: 'New artboard: Laptop' },
2934
3098
  { id: 'new-artboard:tablet', label: 'New artboard: Tablet' },
2935
3099
  { id: 'new-artboard:mobile', label: 'New artboard: Mobile' },
3100
+ // feature-2-print-artboards T2 — "+ Artboard" quick-insert must
3101
+ // set kind="print" + the print prop together (the plan's own
3102
+ // gotcha for this task), not just a plain digital-sized board.
3103
+ { id: 'new-artboard:print-a4', label: 'New artboard: A4 (print)' },
3104
+ { id: 'new-artboard:print-letter', label: 'New artboard: Letter (print)' },
2936
3105
  ]
2937
3106
  : []),
2938
3107
  ]}
@@ -2968,6 +3137,8 @@ function Menubar({
2968
3137
  onToggleZoomCtl,
2969
3138
  presentMode,
2970
3139
  onTogglePresent,
3140
+ printGuidesVisible,
3141
+ onTogglePrintGuides,
2971
3142
  postToActiveCanvas,
2972
3143
  onOpenWhatsNew,
2973
3144
  onOpenReadiness,
@@ -3105,6 +3276,18 @@ function Menubar({
3105
3276
  checked: presentMode,
3106
3277
  disabled: !activePath || isSystem,
3107
3278
  },
3279
+ // feature-2-print-artboards T3 — per-canvas persisted (overlays.print in
3280
+ // view.json), same lane as the foundation's `guides` key; NOT gated on the
3281
+ // active artboard actually being kind="print" (mirrors minimap/zoomctl,
3282
+ // which aren't content-gated either — the overlay itself renders nothing
3283
+ // for a non-print artboard regardless of this flag).
3284
+ {
3285
+ id: 'print-guides',
3286
+ label: 'Show print guides',
3287
+ shortcut: '',
3288
+ checked: printGuidesVisible,
3289
+ disabled: !activePath || isSystem,
3290
+ },
3108
3291
  ];
3109
3292
 
3110
3293
  const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
@@ -3242,6 +3425,7 @@ function Menubar({
3242
3425
  else if (id === 'minimap') onToggleMinimap?.();
3243
3426
  else if (id === 'zoomctl') onToggleZoomCtl?.();
3244
3427
  else if (id === 'present') onTogglePresent?.();
3428
+ else if (id === 'print-guides') onTogglePrintGuides?.();
3245
3429
  }}
3246
3430
  onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
3247
3431
  hasCanvas={!!activePath && !isSystem}
@@ -4324,6 +4508,43 @@ function cssSplitUnit(v) {
4324
4508
  // click re-derives it live — the "tab is there, then it's gone" flicker.
4325
4509
  function mergeSelClientFields(incoming, prev) {
4326
4510
  if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
4511
+ // Dogfood follow-up (print artboards) — a whole-ARTBOARD selection has NO
4512
+ // data-cd-id (the frame chrome carries only data-dc-screen), so the id
4513
+ // identity check below never matched it and every server echo returned
4514
+ // `incoming` bare — wiping `attrs` (which carries data-dc-kind/-print for
4515
+ // the ArtboardKnobs panel) seconds after each fresh click. The panel then
4516
+ // silently fell back to kind='digital' + screen presets even though the
4517
+ // artboard was print. Match an id-less pair by the artboard-shaped
4518
+ // SELECTOR + file — NOT by artboardId: the server projection
4519
+ // (inspect.ts `enrich()`) doesn't round-trip `artboardId` at all, so an
4520
+ // artboardId comparison always sees undefined on the echo side and never
4521
+ // matches (the second-round bug on this exact spot). The selector IS the
4522
+ // identity here — `[data-dc-screen="<id>"]` — and enrich() preserves it.
4523
+ if (!incoming.id && !prev.id) {
4524
+ const isArtboardSel = (s) =>
4525
+ typeof s.selector === 'string' && s.selector.startsWith('[data-dc-screen=');
4526
+ if (
4527
+ isArtboardSel(incoming) &&
4528
+ isArtboardSel(prev) &&
4529
+ incoming.selector === prev.selector &&
4530
+ incoming.file === prev.file
4531
+ ) {
4532
+ return {
4533
+ ...incoming,
4534
+ authored: incoming.authored ?? prev.authored,
4535
+ computed: incoming.computed ?? prev.computed,
4536
+ customStyles: incoming.customStyles ?? prev.customStyles,
4537
+ attrs: incoming.attrs ?? prev.attrs,
4538
+ // enrich() drops artboardId/worldW/worldH from the echo entirely —
4539
+ // restore them from the local snapshot so ArtboardKnobs keeps its
4540
+ // exact W/H and the resize/kind writers keep their target id.
4541
+ artboardId: incoming.artboardId ?? prev.artboardId,
4542
+ worldW: incoming.worldW ?? prev.worldW,
4543
+ worldH: incoming.worldH ?? prev.worldH,
4544
+ };
4545
+ }
4546
+ return incoming;
4547
+ }
4327
4548
  if (!incoming.id || incoming.id !== prev.id) return incoming;
4328
4549
  return {
4329
4550
  ...incoming,
@@ -6760,8 +6981,52 @@ const ARTBOARD_LAYOUT_OPTIONS = [
6760
6981
  ['grid', 'Grid'],
6761
6982
  ];
6762
6983
 
6763
- function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardStyle }) {
6984
+ // feature-1-artboard-kinds-foundation, T8 — kind picker options. Order
6985
+ // mirrors the DCArtboard `kind` union; 'digital' is the implicit default
6986
+ // (writes `kind: null`, clearing the explicit prop, per applySetArtboardKind).
6987
+ const ARTBOARD_KIND_OPTIONS = [
6988
+ ['digital', 'Digital'],
6989
+ ['print', 'Print'],
6990
+ ['web', 'Web'],
6991
+ ['video', 'Video'],
6992
+ ];
6993
+
6994
+ function ArtboardKnobs({
6995
+ el,
6996
+ cfg,
6997
+ onResizeArtboard,
6998
+ onSetArtboardHug,
6999
+ onSetArtboardStyle,
7000
+ onSetArtboardKind,
7001
+ onSetArtboardPrint,
7002
+ }) {
6764
7003
  const artboardId = resolveArtboardIdFromSelection(el);
7004
+ // Dogfood (artboard panel ↔ shared inspector controls) — the panel now uses
7005
+ // the SAME control library + design-token plumbing as CssKnobs (NumberField
7006
+ // / Segmented / Select / ColorField+TokenPopover / ValueTokenField), so Bg
7007
+ // binds color variables and Pad/Gap bind space variables exactly like any
7008
+ // CSS-panel row. Token resolution mirrors CssKnobs' own tokenGroups/
7009
+ // flatTokens closures over useAllDsTokens.
7010
+ const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
7011
+ const _activeDs = activeDsNameFor(el.file, cfg);
7012
+ const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
7013
+ const tokenGroups = (familyKey) =>
7014
+ allDs
7015
+ .map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
7016
+ .filter((g) => g.names.length);
7017
+ const flatTokens = (familyKey) =>
7018
+ tokenGroups(familyKey).flatMap((g) => (g.names || []).map((n) => ({ name: n, value: g.vals?.[n] || '' })));
7019
+ // Resolve a `var(--x)` binding to its concrete value across every DS (for
7020
+ // the Bg swatch color); a raw value passes through.
7021
+ const resolveTokenValue = (v) => {
7022
+ const m = /^var\(\s*(--[\w-]+)\s*\)$/.exec(v || '');
7023
+ if (!m) return v || '';
7024
+ for (const d of allDs) {
7025
+ const val = d.vals?.[m[1]];
7026
+ if (val) return val;
7027
+ }
7028
+ return '';
7029
+ };
6765
7030
  // Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
6766
7031
  // undefined for a selection that reached here via a code path predating
6767
7032
  // that field (a canvas iframe that hasn't remounted since); fall back to
@@ -6798,6 +7063,63 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
6798
7063
  const padding = el.attrs?.['data-dc-padding'] ?? '';
6799
7064
  const layoutMode = el.attrs?.['data-dc-layout'] ?? '';
6800
7065
  const gap = el.attrs?.['data-dc-gap'] ?? '';
7066
+ // T8 — `data-dc-kind` is DCArtboard's RESOLVED kind (readBackAttrs always
7067
+ // emits it, unlike the optional-override attrs above — see T1's comment on
7068
+ // why), so this reads 'digital' even for an implicit/unmigrated artboard.
7069
+ const kind = el.attrs?.['data-dc-kind'] || 'digital';
7070
+ // feature-2-print-artboards T2 — paper/orientation/bleed. Read-back mirrors
7071
+ // `data-dc-kind` above: a small JSON attr DCArtboard stamps for exactly this
7072
+ // panel (canvas-lib.tsx readBackAttrs), since `print` (unlike bg/padding/…)
7073
+ // is object-valued, not a scalar.
7074
+ let print = null;
7075
+ try {
7076
+ print = el.attrs?.['data-dc-print'] ? JSON.parse(el.attrs['data-dc-print']) : null;
7077
+ } catch {
7078
+ /* malformed attr — treat as absent */
7079
+ }
7080
+ const setKind = (nextKind) => {
7081
+ if (!artboardId) return;
7082
+ // Picking "Digital" clears the explicit prop back to the implicit
7083
+ // default (applySetArtboardKind's `kind: null` path) rather than writing
7084
+ // a redundant `kind="digital"`.
7085
+ onSetArtboardKind?.(artboardId, nextKind === 'digital' ? null : nextKind);
7086
+ // Dogfood fix — switching TO "print" with no print prop yet left the
7087
+ // artboard in a half-configured state: the guides overlay renders
7088
+ // nothing (print-overlay-content.tsx requires the prop) and the PDF
7089
+ // exporter has no bleed to read, even though the Kind chip already
7090
+ // shows "print". Seed a default A4 print prop + its resolved px size in
7091
+ // the SAME gesture, mirroring the "+ Artboard: print preset" flow —
7092
+ // switching a DIGITAL artboard to print should never require a second,
7093
+ // easy-to-miss step in the (still-collapsed) Print section below.
7094
+ if (nextKind === 'print' && !print) {
7095
+ const defaults = { paper: 'a4' };
7096
+ let resolved;
7097
+ try {
7098
+ resolved = resolvePrintArtboard(defaults);
7099
+ } catch {
7100
+ resolved = null;
7101
+ }
7102
+ if (resolved) {
7103
+ onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
7104
+ onSetArtboardPrint?.(artboardId, defaults);
7105
+ }
7106
+ }
7107
+ };
7108
+ const setPrint = (patch) => {
7109
+ if (!artboardId) return;
7110
+ const next = { paper: 'a4', ...print, ...patch };
7111
+ let resolved;
7112
+ try {
7113
+ resolved = resolvePrintArtboard(next);
7114
+ } catch {
7115
+ return; // unknown paper id — ignore rather than write garbage
7116
+ }
7117
+ // Design Decision 2 — the picker writes BOTH the resolved px size (via
7118
+ // the existing resize lane, DDR-027) and the `print` intent prop, in the
7119
+ // same gesture, so geometry and intent never drift apart.
7120
+ onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
7121
+ onSetArtboardPrint?.(artboardId, next);
7122
+ };
6801
7123
  const setHug = (nextFixed) => {
6802
7124
  if (!artboardId) return;
6803
7125
  const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
@@ -6813,162 +7135,196 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
6813
7135
  <span className="st-cp-sechd">Artboard</span>
6814
7136
  </div>
6815
7137
  <div style={{ display: 'flex', gap: 8, padding: '4px 12px' }}>
6816
- <div className="st-cp-num">
6817
- <span className="st-cp-numlead" aria-hidden="true">
6818
- W
6819
- </span>
6820
- <input
6821
- className="st-cp-numin"
6822
- type="number"
6823
- min="1"
6824
- aria-label="artboard width"
6825
- key={`w:${w ?? ''}`}
6826
- defaultValue={w ?? ''}
6827
- onKeyDown={(e) => {
6828
- if (e.key === 'Enter') e.currentTarget.blur();
6829
- }}
6830
- onBlur={(e) => commitSize(Number.parseFloat(e.currentTarget.value), null)}
6831
- />
6832
- </div>
6833
- <div className="st-cp-num">
6834
- <span className="st-cp-numlead" aria-hidden="true">
6835
- H
6836
- </span>
6837
- <input
6838
- className="st-cp-numin"
6839
- type="number"
6840
- min="1"
6841
- aria-label="artboard height"
6842
- readOnly={!fixed}
6843
- title={fixed ? undefined : 'Hug — grows to fit content. Switch to Fixed to set an exact height.'}
6844
- key={`h:${h ?? ''}:${fixed}`}
6845
- defaultValue={h ?? ''}
6846
- onKeyDown={(e) => {
6847
- if (e.key === 'Enter') e.currentTarget.blur();
6848
- }}
6849
- onBlur={(e) => {
6850
- if (!fixed) return;
6851
- commitSize(null, Number.parseFloat(e.currentTarget.value));
6852
- }}
6853
- />
6854
- </div>
7138
+ <NumberField
7139
+ value={w ?? 0}
7140
+ min={1}
7141
+ ariaLabel="artboard width"
7142
+ lead="W"
7143
+ steppers={false}
7144
+ onCommit={(n) => commitSize(n, null)}
7145
+ />
7146
+ <NumberField
7147
+ value={h ?? 0}
7148
+ min={1}
7149
+ ariaLabel="artboard height"
7150
+ lead="H"
7151
+ steppers={false}
7152
+ scrub={fixed}
7153
+ onCommit={(n) => {
7154
+ // Hug — height is content-driven; ignore a typed value rather
7155
+ // than silently switching modes.
7156
+ if (fixed) commitSize(null, n);
7157
+ }}
7158
+ />
6855
7159
  </div>
6856
7160
  <div style={{ padding: '0 12px 8px' }}>
6857
- <select
6858
- className="st-cp-nsel"
6859
- aria-label="artboard size preset"
6860
- value={activePreset ?? ''}
6861
- onChange={(e) => {
6862
- const p = SCREEN_PRESETS[e.currentTarget.value];
6863
- if (p) commitSize(p.width, p.height);
6864
- }}
6865
- >
6866
- <option value="" disabled>
6867
- {activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
6868
- </option>
6869
- {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
6870
- <option key={key} value={key}>
6871
- {p.label} — {p.width}×{p.height}
7161
+ {kind === 'print' ? (
7162
+ // Dogfood follow-up — a PRINT artboard's "Preset size…" dropdown
7163
+ // showed only the screen presets (Desktop/Laptop/Tablet/Mobile),
7164
+ // which are meaningless for paper. Show the paper ladder here
7165
+ // instead, writing through the SAME setPrint lane as the Print
7166
+ // section below (resolved px + print prop together).
7167
+ <select
7168
+ className="st-cp-nsel"
7169
+ aria-label="artboard paper preset"
7170
+ value={print?.paper ?? ''}
7171
+ onChange={(e) => setPrint({ paper: e.currentTarget.value })}
7172
+ >
7173
+ <option value="" disabled>
7174
+ Paper size…
6872
7175
  </option>
6873
- ))}
6874
- </select>
7176
+ {PAPER_PRESETS.map((p) => (
7177
+ <option key={p.id} value={p.id}>
7178
+ {p.label} — {p.width}×{p.height}
7179
+ {p.unit}
7180
+ </option>
7181
+ ))}
7182
+ </select>
7183
+ ) : (
7184
+ <select
7185
+ className="st-cp-nsel"
7186
+ aria-label="artboard size preset"
7187
+ value={activePreset ?? ''}
7188
+ onChange={(e) => {
7189
+ const p = SCREEN_PRESETS[e.currentTarget.value];
7190
+ if (p) commitSize(p.width, p.height);
7191
+ }}
7192
+ >
7193
+ <option value="" disabled>
7194
+ {activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
7195
+ </option>
7196
+ {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
7197
+ <option key={key} value={key}>
7198
+ {p.label} — {p.width}×{p.height}
7199
+ </option>
7200
+ ))}
7201
+ </select>
7202
+ )}
6875
7203
  </div>
6876
7204
  <div style={{ padding: '0 12px 8px' }}>
6877
- <div className="st-cp-modeseg" role="group" aria-label="artboard height sizing mode">
6878
- <span className="st-cp-modeax" aria-hidden="true">
6879
- H
6880
- </span>
6881
- {[
6882
- [false, 'Hug'],
6883
- [true, 'Fixed'],
6884
- ].map(([val, label]) => (
6885
- <button
6886
- key={label}
6887
- type="button"
6888
- className={`st-cp-modebtn${fixed === val ? ' is-active' : ''}`}
6889
- aria-pressed={fixed === val}
6890
- onClick={() => setHug(val)}
6891
- title={`${label} height`}
6892
- >
6893
- {label}
6894
- </button>
6895
- ))}
6896
- </div>
7205
+ <Segmented
7206
+ value={fixed ? 'fixed' : 'hug'}
7207
+ ariaLabel="artboard height sizing mode"
7208
+ options={[
7209
+ { value: 'hug', label: 'Hug' },
7210
+ { value: 'fixed', label: 'Fixed' },
7211
+ ]}
7212
+ onChange={(v) => setHug(v === 'fixed')}
7213
+ />
6897
7214
  </div>
6898
7215
  <div className="st-cp-sechd-row">
6899
- <span className="st-cp-sechd">Style</span>
7216
+ <span className="st-cp-sechd">Kind</span>
6900
7217
  </div>
6901
- <div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
6902
- <div className="st-cp-num" style={{ width: '100%' }}>
6903
- <span className="st-cp-numlead" aria-hidden="true">
6904
- Bg
6905
- </span>
6906
- <input
6907
- className="st-cp-numin"
6908
- type="text"
6909
- aria-label="artboard background"
6910
- placeholder="var(--bg-1)"
6911
- key={`bg:${bg}`}
6912
- defaultValue={bg}
6913
- onKeyDown={(e) => {
6914
- if (e.key === 'Enter') e.currentTarget.blur();
6915
- }}
6916
- onBlur={(e) => commitStyle({ background: e.currentTarget.value.trim() || null })}
6917
- />
6918
- </div>
6919
- <select
6920
- className="st-cp-nsel"
6921
- aria-label="artboard body layout"
6922
- value={layoutMode}
6923
- onChange={(e) => commitStyle({ layout: e.currentTarget.value || null })}
6924
- >
6925
- {ARTBOARD_LAYOUT_OPTIONS.map(([val, label]) => (
6926
- <option key={val} value={val}>
6927
- {label}
6928
- </option>
6929
- ))}
6930
- </select>
6931
- <div style={{ display: 'flex', gap: 8 }}>
6932
- <div className="st-cp-num">
6933
- <span className="st-cp-numlead" aria-hidden="true">
6934
- Pad
6935
- </span>
6936
- <input
6937
- className="st-cp-numin"
6938
- type="number"
6939
- min="0"
6940
- aria-label="artboard padding"
6941
- key={`pad:${padding}`}
6942
- defaultValue={padding}
6943
- onKeyDown={(e) => {
6944
- if (e.key === 'Enter') e.currentTarget.blur();
6945
- }}
6946
- onBlur={(e) => {
6947
- const v = e.currentTarget.value;
6948
- commitStyle({ padding: v === '' ? null : Number.parseFloat(v) });
6949
- }}
7218
+ <div style={{ padding: '0 12px 8px' }}>
7219
+ <Select
7220
+ value={kind}
7221
+ ariaLabel="artboard kind"
7222
+ options={ARTBOARD_KIND_OPTIONS.map(([value, label]) => ({ value, label }))}
7223
+ onChange={setKind}
7224
+ />
7225
+ </div>
7226
+ {kind === 'print' ? (
7227
+ <>
7228
+ <div className="st-cp-sechd-row">
7229
+ <span className="st-cp-sechd">Print</span>
7230
+ </div>
7231
+ {/* Paper picker lives in the Artboard section's own preset dropdown
7232
+ above (it REPLACES the screen presets for kind="print") — this
7233
+ section carries the print-specific residue: orientation + bleed. */}
7234
+ <div style={{ padding: '0 12px 8px' }}>
7235
+ <Segmented
7236
+ value={print?.orientation ?? 'portrait'}
7237
+ ariaLabel="paper orientation"
7238
+ options={[
7239
+ { value: 'portrait', label: 'Portrait' },
7240
+ { value: 'landscape', label: 'Landscape' },
7241
+ ]}
7242
+ onChange={(v) => setPrint({ orientation: v })}
6950
7243
  />
6951
7244
  </div>
6952
- <div className="st-cp-num">
6953
- <span className="st-cp-numlead" aria-hidden="true">
6954
- Gap
6955
- </span>
6956
- <input
6957
- className="st-cp-numin"
6958
- type="number"
6959
- min="0"
6960
- aria-label="artboard gap"
6961
- key={`gap:${gap}`}
6962
- defaultValue={gap}
6963
- onKeyDown={(e) => {
6964
- if (e.key === 'Enter') e.currentTarget.blur();
6965
- }}
6966
- onBlur={(e) => {
6967
- const v = e.currentTarget.value;
6968
- commitStyle({ gap: v === '' ? null : Number.parseFloat(v) });
6969
- }}
7245
+ <div style={{ padding: '0 12px 8px' }}>
7246
+ <NumberField
7247
+ value={Number.isFinite(print?.bleedMm) ? print.bleedMm : 3}
7248
+ min={0}
7249
+ step={0.5}
7250
+ ariaLabel="bleed, millimeters"
7251
+ // Single-glyph lead — the drag-handle slot is sized for 1–2
7252
+ // chars ("W"/"Pad"); a full word clips. mm suffix + aria carry
7253
+ // the meaning.
7254
+ lead="B"
7255
+ steppers={false}
7256
+ unitSlot={
7257
+ <span className="st-cp-numsuffix" aria-hidden="true">
7258
+ mm
7259
+ </span>
7260
+ }
7261
+ onCommit={(n) => setPrint({ bleedMm: n })}
6970
7262
  />
6971
7263
  </div>
7264
+ </>
7265
+ ) : null}
7266
+ <div className="st-cp-sechd-row">
7267
+ <span className="st-cp-sechd">Style</span>
7268
+ </div>
7269
+ <div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
7270
+ {(() => {
7271
+ // Bg — the CssKnobs `color()` recipe: TokenPopover swatch as the
7272
+ // flush prefix inside ColorField (HSV picker + per-DS variables).
7273
+ const bgResolved = resolveTokenValue(bg);
7274
+ const bgBound = /var\(\s*--/.test(bg);
7275
+ const bgDisplay = bgBound
7276
+ ? bg.replace(/^var\(\s*|\s*\)$/g, '').replace(/^--/, '').replace(/-/g, ' ')
7277
+ : bg;
7278
+ return (
7279
+ <ColorField
7280
+ swatch={
7281
+ <TokenPopover
7282
+ kind="color"
7283
+ swatchClassName="st-cp-cf-sw"
7284
+ groups={tokenGroups('color')}
7285
+ current={bg}
7286
+ activeDs={_activeDs}
7287
+ swatchBg={bgResolved}
7288
+ seedHex={cssColorToHex(bgResolved) || '#000000'}
7289
+ onPick={(v) => commitStyle({ background: v || null })}
7290
+ label="artboard background colour"
7291
+ />
7292
+ }
7293
+ displayValue={bgDisplay}
7294
+ bound={bgBound}
7295
+ ariaLabel="artboard background"
7296
+ onValue={(v) => commitStyle({ background: (v || '').trim() || null })}
7297
+ />
7298
+ );
7299
+ })()}
7300
+ <Select
7301
+ value={layoutMode}
7302
+ ariaLabel="artboard body layout"
7303
+ options={ARTBOARD_LAYOUT_OPTIONS.map(([value, label]) => ({
7304
+ value,
7305
+ label,
7306
+ }))}
7307
+ onChange={(v) => commitStyle({ layout: v || null })}
7308
+ />
7309
+ <div style={{ display: 'flex', gap: 8 }}>
7310
+ <ValueTokenField
7311
+ value={/^var\(/.test(padding) ? padding : Number.parseFloat(padding) || 0}
7312
+ tokens={flatTokens('space')}
7313
+ ariaLabel="artboard padding"
7314
+ lead="Pad"
7315
+ min={0}
7316
+ onChange={(v) =>
7317
+ commitStyle({ padding: typeof v === 'string' ? v : v > 0 ? v : null })
7318
+ }
7319
+ />
7320
+ <ValueTokenField
7321
+ value={/^var\(/.test(gap) ? gap : Number.parseFloat(gap) || 0}
7322
+ tokens={flatTokens('space')}
7323
+ ariaLabel="artboard gap"
7324
+ lead="Gap"
7325
+ min={0}
7326
+ onChange={(v) => commitStyle({ gap: typeof v === 'string' ? v : v > 0 ? v : null })}
7327
+ />
6972
7328
  </div>
6973
7329
  </div>
6974
7330
  </section>
@@ -7016,6 +7372,8 @@ function InspectorPanel({
7016
7372
  onResizeArtboard,
7017
7373
  onSetArtboardHug,
7018
7374
  onSetArtboardStyle,
7375
+ onSetArtboardKind,
7376
+ onSetArtboardPrint,
7019
7377
  onUndoRedo,
7020
7378
  editScope,
7021
7379
  tab: tabProp,
@@ -7584,9 +7942,12 @@ function InspectorPanel({
7584
7942
  ) : !el.id && resolveArtboardIdFromSelection(el) ? (
7585
7943
  <ArtboardKnobs
7586
7944
  el={el}
7945
+ cfg={cfg}
7587
7946
  onResizeArtboard={onResizeArtboard}
7588
7947
  onSetArtboardHug={onSetArtboardHug}
7589
7948
  onSetArtboardStyle={onSetArtboardStyle}
7949
+ onSetArtboardKind={onSetArtboardKind}
7950
+ onSetArtboardPrint={onSetArtboardPrint}
7590
7951
  />
7591
7952
  ) : (
7592
7953
  <CssKnobs
@@ -7680,6 +8041,32 @@ function App() {
7680
8041
  }, delay)
7681
8042
  );
7682
8043
  }, []);
8044
+ // Dogfood follow-up — the ArtboardKnobs panel (Kind/Print/Style/Hug/W-H)
8045
+ // writes via structuralWrite, which never refreshed the Inspector's OWN
8046
+ // `attrs` snapshot afterward: the artboard itself re-rendered correctly
8047
+ // (new kind, new size) but the Inspector kept showing the PRE-edit values
8048
+ // (e.g. "Digital" right after picking "Print") until the user manually
8049
+ // re-clicked the artboard. Mirrors scheduleHaloRestore's retry ladder
8050
+ // (the HMR reload lands async, so a single immediate post races it) but
8051
+ // for a bare ARTBOARD selection (no data-cd-id to key off of — compares
8052
+ // by artboardId instead of id).
8053
+ const artboardResyncTimersRef = useRef([]);
8054
+ const scheduleArtboardResync = useCallback((artboardId, file) => {
8055
+ if (!artboardId || !file) return;
8056
+ for (const t of artboardResyncTimersRef.current) clearTimeout(t);
8057
+ artboardResyncTimersRef.current = [50, 450, 1200, 2500, 5000].map((delay) =>
8058
+ setTimeout(() => {
8059
+ const cur = selectedRef.current;
8060
+ const c1 = Array.isArray(cur) ? cur[0] : cur;
8061
+ if (!c1 || c1.artboardId !== artboardId || c1.file !== file) return; // superseded
8062
+ const frame = iframesRef.current.get(file);
8063
+ if (!frame?.contentWindow) return;
8064
+ try {
8065
+ frame.contentWindow.postMessage({ dgn: 'select-by-id', artboardId }, '*');
8066
+ } catch {}
8067
+ }, delay)
8068
+ );
8069
+ }, []);
7683
8070
  // Phase 12.1 (DDR-138) — after a reorder writes source, the HMR reload remounts
7684
8071
  // the canvas and the positional data-cd-id of the moved element (and everything
7685
8072
  // after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
@@ -8195,6 +8582,12 @@ function App() {
8195
8582
  const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
8196
8583
  const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
8197
8584
  const [presentMode, setPresentMode] = useState(false);
8585
+ // feature-2-print-artboards T3 — "Show print guides". Per-CANVAS persisted
8586
+ // (view.json `overlays.print`, via /_api/canvas-meta), not a global
8587
+ // localStorage pref like minimap/zoomctl — mirrors the foundation's
8588
+ // `overlays.guides` lane. Re-read whenever the active canvas changes (see
8589
+ // the activeArtboards effect below); sent only to the active iframe.
8590
+ const [printGuidesVisible, setPrintGuidesVisible] = useState(false);
8198
8591
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
8199
8592
  // so the top bar no longer mirrors it.)
8200
8593
  // activeArtboards — real artboard count of the open canvas, read from its
@@ -8625,6 +9018,24 @@ function App() {
8625
9018
  setPresentMode(false);
8626
9019
  broadcastChrome({ present: false });
8627
9020
  }, [broadcastChrome]);
9021
+ // feature-2-print-artboards T3 — per-canvas persisted, so (unlike
9022
+ // broadcastChrome above) this targets ONLY the active iframe and PATCHes
9023
+ // view.json's `overlays.print` through the same GET-merge/PATCH-split lane
9024
+ // the foundation built for `overlays.guides` (api.ts normalizeOverlays is a
9025
+ // flat Record<string, boolean> — no server change needed for a new key).
9026
+ const togglePrintGuides = useCallback(() => {
9027
+ if (!activePath || activePath === SYSTEM_TAB) return;
9028
+ setPrintGuidesVisible((v) => {
9029
+ const next = !v;
9030
+ postToActiveCanvas({ dgn: 'view-chrome', print: next });
9031
+ fetch('/_api/canvas-meta', {
9032
+ method: 'PATCH',
9033
+ headers: { 'Content-Type': 'application/json' },
9034
+ body: JSON.stringify({ file: activePath, patch: { overlays: { print: next } } }),
9035
+ }).catch(() => {});
9036
+ return next;
9037
+ });
9038
+ }, [activePath, postToActiveCanvas]);
8628
9039
 
8629
9040
  // P3 (Plan C) — local git user for the menubar presence avatar. One-shot.
8630
9041
  useEffect(() => {
@@ -8647,6 +9058,7 @@ function App() {
8647
9058
  useEffect(() => {
8648
9059
  if (!activePath || activePath === SYSTEM_TAB) {
8649
9060
  setActiveArtboards(0);
9061
+ setPrintGuidesVisible(false);
8650
9062
  return;
8651
9063
  }
8652
9064
  let cancelled = false;
@@ -8656,9 +9068,15 @@ function App() {
8656
9068
  if (cancelled) return;
8657
9069
  const n = Array.isArray(meta?.artboards) ? meta.artboards.length : 0;
8658
9070
  setActiveArtboards(n);
9071
+ // feature-2-print-artboards T3 — reflect THIS canvas's own persisted
9072
+ // print-guides flag in the menu checkbox when switching tabs.
9073
+ setPrintGuidesVisible(meta?.overlays?.print === true);
8659
9074
  })
8660
9075
  .catch(() => {
8661
- if (!cancelled) setActiveArtboards(0);
9076
+ if (!cancelled) {
9077
+ setActiveArtboards(0);
9078
+ setPrintGuidesVisible(false);
9079
+ }
8662
9080
  });
8663
9081
  return () => {
8664
9082
  cancelled = true;
@@ -9945,6 +10363,15 @@ function App() {
9945
10363
  if (e.source === activeWin && typeof m.artboardId === 'string') {
9946
10364
  deleteArtboardShellRef.current?.(m.artboardId);
9947
10365
  }
10366
+ } else if (m.dgn === 'set-artboard-kind-request') {
10367
+ // feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
10368
+ // kind" submenu (inside the untrusted iframe). `kind: null` clears
10369
+ // back to the implicit default.
10370
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
10371
+ const okKind = m.kind === null || typeof m.kind === 'string';
10372
+ if (e.source === activeWin && typeof m.artboardId === 'string' && okKind) {
10373
+ setArtboardKindShellRef.current?.(m.artboardId, m.kind);
10374
+ }
9948
10375
  } else if (m.dgn === 'open-inspector') {
9949
10376
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
9950
10377
  // feature-photo-editor (Task 16) — an optional `tab` lands the panel on a
@@ -10793,20 +11220,6 @@ function App() {
10793
11220
  [structuralWrite]
10794
11221
  );
10795
11222
 
10796
- // Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
10797
- // id (server 422s a dup, negligible with a random suffix) + a preset label/dims
10798
- // and inserts an empty <DCArtboard> after the last one (runtime default-grid
10799
- // places it; DDR-027). The new frame is then selectable + resizable.
10800
- const onInsertArtboard = useCallback(
10801
- (presetKey) => {
10802
- const p = SCREEN_PRESETS[presetKey];
10803
- if (!p) return;
10804
- const id = `s${Math.random().toString(36).slice(2, 8)}`;
10805
- insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
10806
- },
10807
- [insertArtboardShell]
10808
- );
10809
-
10810
11223
  const resizeArtboardShell = useCallback(
10811
11224
  (artboardId, width, height) => {
10812
11225
  structuralWrite(
@@ -10818,10 +11231,14 @@ function App() {
10818
11231
  // during the drag (no HMR yet); on a rejected/failed write, tell it to
10819
11232
  // restore the pre-drag box so a phantom resize doesn't linger.
10820
11233
  onFail: () => postToActiveCanvas({ dgn: 'resize-artboard-failed' }),
11234
+ // Dogfood follow-up — resync the Inspector's own W/H (and any other
11235
+ // attrs) once the HMR reload lands, so a paper-preset pick doesn't
11236
+ // leave the fields showing the pre-resize size.
11237
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
10821
11238
  }
10822
11239
  );
10823
11240
  },
10824
- [structuralWrite, postToActiveCanvas]
11241
+ [structuralWrite, postToActiveCanvas, scheduleArtboardResync, activePath]
10825
11242
  );
10826
11243
 
10827
11244
  const deleteArtboardShell = useCallback(
@@ -10839,18 +11256,96 @@ function App() {
10839
11256
  structuralWrite(
10840
11257
  '/_api/set-artboard-hug',
10841
11258
  { artboardId, fixed, freezeHeight },
10842
- { label: fixed ? 'pin artboard height' : 'hug artboard height' }
11259
+ {
11260
+ label: fixed ? 'pin artboard height' : 'hug artboard height',
11261
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
11262
+ }
10843
11263
  );
10844
11264
  },
10845
- [structuralWrite]
11265
+ [structuralWrite, scheduleArtboardResync, activePath]
10846
11266
  );
10847
11267
 
10848
11268
  // Artboard "more settings" (background/padding/layout/gap) from ArtboardKnobs.
10849
11269
  const setArtboardStyleShell = useCallback(
10850
11270
  (artboardId, patch) => {
10851
- structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, { label: 'artboard style' });
11271
+ structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, {
11272
+ label: 'artboard style',
11273
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
11274
+ });
10852
11275
  },
10853
- [structuralWrite]
11276
+ [structuralWrite, scheduleArtboardResync, activePath]
11277
+ );
11278
+ // feature-1-artboard-kinds-foundation, T8 — kind-switch surfaces. Direct
11279
+ // callable from ArtboardKnobs (Inspector); the context-menu submenu (inside
11280
+ // the iframe) reaches the SAME endpoint via the postMessage ref below.
11281
+ //
11282
+ // Dogfood follow-up — every one of these ArtboardKnobs writers now resyncs
11283
+ // the Inspector's OWN selection snapshot once the HMR reload lands
11284
+ // (scheduleArtboardResync's retry ladder). Before this fix the ARTBOARD
11285
+ // re-rendered correctly (new kind, new paper size) but the Inspector kept
11286
+ // showing PRE-edit attrs (e.g. "Digital" right after picking "Print",
11287
+ // "Preset size…" still listing screen presets) until the user manually
11288
+ // re-clicked the artboard — structuralWrite's onOk never refreshed
11289
+ // `selected`, only recorded undo history.
11290
+ const setArtboardKindShell = useCallback(
11291
+ (artboardId, kind) => {
11292
+ structuralWrite('/_api/set-artboard-kind', { artboardId, kind }, {
11293
+ label: 'artboard kind',
11294
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
11295
+ });
11296
+ },
11297
+ [structuralWrite, scheduleArtboardResync, activePath]
11298
+ );
11299
+ // feature-2-print-artboards T2 — paper/orientation/bleed/margins. Direct
11300
+ // Inspector-only callable (no canvas-origin postMessage path — same shape
11301
+ // as setArtboardStyleShell, unlike setArtboardKindShell which also has a
11302
+ // context-menu caller inside the iframe).
11303
+ const setArtboardPrintShell = useCallback(
11304
+ (artboardId, print) => {
11305
+ structuralWrite('/_api/set-artboard-print', { artboardId, print }, {
11306
+ label: 'artboard print',
11307
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
11308
+ });
11309
+ },
11310
+ [structuralWrite, scheduleArtboardResync, activePath]
11311
+ );
11312
+
11313
+ // Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
11314
+ // id (server 422s a dup, negligible with a random suffix) + a preset label/dims
11315
+ // and inserts an empty <DCArtboard> after the last one (runtime default-grid
11316
+ // places it; DDR-027). The new frame is then selectable + resizable.
11317
+ const onInsertArtboard = useCallback(
11318
+ (presetKey) => {
11319
+ const id = `s${Math.random().toString(36).slice(2, 8)}`;
11320
+ // feature-2-print-artboards T2 — kind="print" + the print prop must
11321
+ // land together, so the created artboard is never a plain digital
11322
+ // board sized to paper by coincidence. structuralWrite serializes
11323
+ // calls in submission order (editApplyChainRef), so these three
11324
+ // sequential POSTs land as insert → kind → print, in order.
11325
+ if (presetKey.startsWith('print-')) {
11326
+ const paper = presetKey.slice('print-'.length);
11327
+ let resolved;
11328
+ try {
11329
+ resolved = resolvePrintArtboard({ paper });
11330
+ } catch {
11331
+ return;
11332
+ }
11333
+ const preset = PAPER_PRESETS.find((p) => p.id === paper);
11334
+ insertArtboardShell({
11335
+ id,
11336
+ label: preset ? preset.label : paper.toUpperCase(),
11337
+ width: resolved.widthPx,
11338
+ height: resolved.heightPx,
11339
+ });
11340
+ setArtboardKindShell(id, 'print');
11341
+ setArtboardPrintShell(id, { paper });
11342
+ return;
11343
+ }
11344
+ const p = SCREEN_PRESETS[presetKey];
11345
+ if (!p) return;
11346
+ insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
11347
+ },
11348
+ [insertArtboardShell, setArtboardKindShell, setArtboardPrintShell]
10854
11349
  );
10855
11350
  // Refs the (stale-closure) onMessage handlers below read.
10856
11351
  const deleteElementShellRef = useRef(null);
@@ -10858,6 +11353,7 @@ function App() {
10858
11353
  const insertArtboardShellRef = useRef(null);
10859
11354
  const resizeArtboardShellRef = useRef(null);
10860
11355
  const deleteArtboardShellRef = useRef(null);
11356
+ const setArtboardKindShellRef = useRef(null);
10861
11357
  const duplicateElementShellRef = useRef(null);
10862
11358
  const copyStyleRef = useRef(null);
10863
11359
  const pasteStyleRef = useRef(null);
@@ -10867,6 +11363,7 @@ function App() {
10867
11363
  insertArtboardShellRef.current = insertArtboardShell;
10868
11364
  resizeArtboardShellRef.current = resizeArtboardShell;
10869
11365
  deleteArtboardShellRef.current = deleteArtboardShell;
11366
+ setArtboardKindShellRef.current = setArtboardKindShell;
10870
11367
  duplicateElementShellRef.current = duplicateElementShell;
10871
11368
  copyStyleRef.current = copyStyle;
10872
11369
  pasteStyleRef.current = pasteStyle;
@@ -10876,6 +11373,7 @@ function App() {
10876
11373
  insertArtboardShell,
10877
11374
  resizeArtboardShell,
10878
11375
  deleteArtboardShell,
11376
+ setArtboardKindShell,
10879
11377
  duplicateElementShell,
10880
11378
  copyStyle,
10881
11379
  pasteStyle,
@@ -11624,6 +12122,8 @@ function App() {
11624
12122
  onResizeArtboard={resizeArtboardShell}
11625
12123
  onSetArtboardHug={setArtboardHugShell}
11626
12124
  onSetArtboardStyle={setArtboardStyleShell}
12125
+ onSetArtboardKind={setArtboardKindShell}
12126
+ onSetArtboardPrint={setArtboardPrintShell}
11627
12127
  editScope={editScope}
11628
12128
  onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
11629
12129
  photoSel={photoSel}
@@ -11733,6 +12233,8 @@ function App() {
11733
12233
  onToggleZoomCtl={toggleZoomCtl}
11734
12234
  presentMode={presentMode}
11735
12235
  onTogglePresent={togglePresent}
12236
+ printGuidesVisible={printGuidesVisible}
12237
+ onTogglePrintGuides={togglePrintGuides}
11736
12238
  postToActiveCanvas={postToActiveCanvas}
11737
12239
  onOpenReadiness={() => setReadinessOpen(true)}
11738
12240
  onOpenQuickSetup={() => setQuickSetupOpen(true)}