@amaster.ai/pi-lark 0.1.6 → 0.1.7

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 (178) hide show
  1. package/README.md +5 -1
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/skills/lark-apps/SKILL.md +24 -12
  11. package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
  12. package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
  13. package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
  14. package/skills/lark-apps/creative-design/assets/index.html +27 -0
  15. package/skills/lark-apps/creative-design/creative-design.md +239 -0
  16. package/skills/lark-apps/creative-design/references/aily.md +39 -0
  17. package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
  18. package/skills/lark-apps/creative-design/references/charts.md +165 -0
  19. package/skills/lark-apps/creative-design/references/claude.md +36 -0
  20. package/skills/lark-apps/creative-design/references/codex.md +32 -0
  21. package/skills/lark-apps/creative-design/references/data-report.md +108 -0
  22. package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
  23. package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
  24. package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
  25. package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
  26. package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
  27. package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
  28. package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
  29. package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
  30. package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
  31. package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
  32. package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
  33. package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
  34. package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
  35. package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
  36. package/skills/lark-apps/references/lark-apps-automation.md +80 -2
  37. package/skills/lark-apps/references/lark-apps-cache.md +61 -0
  38. package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
  39. package/skills/lark-apps/references/lark-apps-create.md +1 -2
  40. package/skills/lark-apps/references/lark-apps-db.md +1 -1
  41. package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
  42. package/skills/lark-apps/references/lark-apps-file.md +2 -2
  43. package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
  44. package/skills/lark-apps/references/lark-apps-html-publish.md +4 -8
  45. package/skills/lark-apps/references/lark-apps-init.md +1 -1
  46. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  47. package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
  48. package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
  49. package/skills/lark-apps/references/lark-apps-release-create.md +2 -2
  50. package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
  51. package/skills/lark-base/SKILL.md +20 -13
  52. package/skills/lark-base/references/lark-base-cell-value.md +3 -3
  53. package/skills/lark-base/references/lark-base-data-query.md +11 -4
  54. package/skills/lark-base/references/lark-base-field-create.md +4 -0
  55. package/skills/lark-base/references/lark-base-field-json.md +4 -4
  56. package/skills/lark-base/references/lark-base-field-update.md +17 -1
  57. package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
  58. package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
  59. package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
  60. package/skills/lark-base/references/lark-base-form-submit.md +16 -7
  61. package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
  62. package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
  63. package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
  64. package/skills/lark-base/references/lark-base-role-guide.md +11 -0
  65. package/skills/lark-base/references/lark-base-view-set-filter.md +11 -137
  66. package/skills/lark-base/references/role-config.md +31 -5
  67. package/skills/lark-calendar/SKILL.md +14 -8
  68. package/skills/lark-calendar/references/lark-calendar-create.md +6 -5
  69. package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
  70. package/skills/lark-calendar/references/lark-calendar-room-find.md +2 -1
  71. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +1 -0
  72. package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -1
  73. package/skills/lark-calendar/references/lark-calendar-update.md +10 -4
  74. package/skills/lark-contact/SKILL.md +19 -3
  75. package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
  76. package/skills/lark-doc/references/lark-doc-fetch.md +10 -2
  77. package/skills/lark-doc/references/lark-doc-whiteboard.md +9 -8
  78. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +41 -0
  79. package/skills/lark-doc/references/lark-doc-xml.md +4 -3
  80. package/skills/lark-drive/SKILL.md +25 -45
  81. package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
  82. package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
  83. package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
  84. package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
  85. package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
  86. package/skills/lark-drive/references/lark-drive-comment-location.md +9 -15
  87. package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
  88. package/skills/lark-drive/references/lark-drive-download.md +5 -1
  89. package/skills/lark-drive/references/lark-drive-list-comments.md +25 -68
  90. package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
  91. package/skills/lark-drive/references/lark-drive-member-add.md +2 -2
  92. package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
  93. package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
  94. package/skills/lark-drive/references/lark-drive-preview.md +11 -1
  95. package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
  96. package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
  97. package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
  98. package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
  99. package/skills/lark-drive/references/lark-drive-search.md +7 -1
  100. package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
  101. package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
  102. package/skills/lark-drive/references/lark-drive-upload.md +1 -0
  103. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
  104. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
  105. package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
  106. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
  107. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
  108. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
  109. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
  110. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
  111. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
  112. package/skills/lark-drive/references/lark-drive-workflow.md +5 -4
  113. package/skills/lark-event/SKILL.md +1 -0
  114. package/skills/lark-event/references/lark-event-application.md +38 -0
  115. package/skills/lark-im/SKILL.md +1 -1
  116. package/skills/lark-im/references/card/card-2.0-schema.md +1 -1
  117. package/skills/lark-im/references/card/lark-im-card-style.md +4 -4
  118. package/skills/lark-im/references/card/resource/icons.md +14 -0
  119. package/skills/lark-im/references/lark-im-flag-list.md +8 -7
  120. package/skills/lark-okr/SKILL.md +71 -26
  121. package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
  122. package/skills/lark-okr/references/lark-okr-create.md +173 -0
  123. package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
  124. package/skills/lark-okr/references/lark-okr-entities.md +1 -0
  125. package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
  126. package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
  127. package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
  128. package/skills/lark-slides/SKILL.md +115 -68
  129. package/skills/lark-slides/references/asset-planning.md +6 -4
  130. package/skills/lark-slides/references/iconpark.md +2 -2
  131. package/skills/lark-slides/references/lark-slides-create.md +16 -8
  132. package/skills/lark-slides/references/lark-slides-history.md +132 -0
  133. package/skills/lark-slides/references/lark-slides-media-upload.md +2 -3
  134. package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +7 -11
  135. package/skills/lark-slides/references/lark-slides-replace-slide.md +0 -3
  136. package/skills/lark-slides/references/lark-slides-screenshot.md +4 -4
  137. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +219 -0
  138. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +6 -5
  139. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +2 -2
  140. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +2 -3
  141. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +65 -30
  142. package/skills/lark-slides/references/planning-layer.md +11 -10
  143. package/skills/lark-slides/references/slides_chart_demo.xml +1416 -1
  144. package/skills/lark-slides/references/slides_xml_schema_definition.xml +492 -76
  145. package/skills/lark-slides/references/troubleshooting.md +25 -7
  146. package/skills/lark-slides/references/validation-checklist.md +53 -16
  147. package/skills/lark-slides/references/visual-planning.md +25 -22
  148. package/skills/lark-slides/references/xml-schema-quick-ref.md +281 -45
  149. package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
  150. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +1650 -165
  151. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +3139 -513
  152. package/skills/lark-task/SKILL.md +7 -0
  153. package/skills/lark-task/references/lark-task-complete.md +6 -2
  154. package/skills/lark-task/references/lark-task-create.md +9 -0
  155. package/skills/lark-task/references/lark-task-update.md +6 -2
  156. package/skills/lark-whiteboard/SKILL.md +13 -12
  157. package/skills/lark-whiteboard/elements/layout.md +1 -1
  158. package/skills/lark-whiteboard/elements/schema.md +2 -2
  159. package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
  160. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
  161. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +7 -17
  162. package/skills/lark-whiteboard/routes/dsl.md +3 -3
  163. package/skills/lark-whiteboard/routes/mermaid.md +2 -2
  164. package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
  165. package/skills/lark-whiteboard/routes/svg.md +11 -6
  166. package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
  167. package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
  168. package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
  169. package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
  170. package/skills/lark-whiteboard/scenes/treemap.md +1 -1
  171. package/skills/lark-wiki/SKILL.md +1 -0
  172. package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -80
  173. package/skills/lark-slides/references/examples.md +0 -91
  174. package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -331
  175. package/skills/lark-slides/references/lark-slides-xml-get.md +0 -100
  176. package/skills/lark-slides/references/slide-templates.md +0 -201
  177. package/skills/lark-slides/references/slides_demo.xml +0 -226
  178. package/skills/lark-slides/references/xml-format-guide.md +0 -433
@@ -0,0 +1,1432 @@
1
+ /* BEGIN USAGE */
2
+ // DesignCanvas.jsx — Figma-ish design canvas wrapper
3
+ // Sections + Artboards + PostIt notes.
4
+ // Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
5
+ // Artboards are reorderable (grip-drag), deletable, labels/titles are
6
+ // inline-editable, and any artboard can be opened in a fullscreen focus
7
+ // overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
8
+ // via the host bridge. No assets, no deps.
9
+ //
10
+ // Usage:
11
+ // <DesignCanvas>
12
+ // <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
13
+ // <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
14
+ // <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
15
+ // </DCSection>
16
+ // </DesignCanvas>
17
+ //
18
+ // Artboards are static design frames, not scroll regions — never use
19
+ // height: 100% + overflow: auto/scroll on inner elements; size each artboard
20
+ // to fit its content. Height behavior:
21
+ // • omit height → the card grows to fit its content (no vertical clipping);
22
+ // this is the right default for full pages / long screens.
23
+ // • height={N} → a fixed N-px frame that CLIPS overflow (overflow:hidden) —
24
+ // use only for deliberately cropped thumbnails (e.g. A/B tiles).
25
+ // width is always a fixed frame width (default 260); content wider than it is
26
+ // clipped, so set width to the real design width for full pages.
27
+ //
28
+ // When placing a device frame inside a DCArtboard, pass chromeless to
29
+ // suppress the card chrome and let the artboard size to its content:
30
+ // <DCArtboard chromeless><IOSDevice>…</IOSDevice></DCArtboard>
31
+ //
32
+ // On mobile (UA-detected) the canvas is browse-only: one-finger drag pans
33
+ // (starting on artboards too), two-finger pinch zooms, the zoom control is
34
+ // hidden, and drag-reorder / inline-rename are disabled. Tap on an artboard
35
+ // label still opens the focus overlay.
36
+ /* END USAGE */
37
+
38
+ const DC = {
39
+ bg: '#FCFCFC',
40
+ label: '#8F959E',
41
+ title: '#1F2329',
42
+ subtitle: '#646A73',
43
+ postitBg: '#fef4a8',
44
+ postitText: '#5a4a2a',
45
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
46
+ };
47
+
48
+ // ── 移动端判定:复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义
49
+ // (跨域产物 iframe 无法 import 该包,同 deck-stage 的先例)。刻意不用视口
50
+ // 断点——桌面工作台的预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
51
+ // 启动时一次性判定,不随 resize 抖动。
52
+ const DC_MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
53
+ const dcIsMobile = typeof navigator !== 'undefined' &&
54
+ (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
55
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
56
+
57
+ // One-time CSS injection (classes are dc-prefixed so they don't collide with
58
+ // the hosted design's own styles).
59
+ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
60
+ const s = document.createElement('style');
61
+ s.id = 'dc-styles';
62
+ s.textContent = [
63
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}',
64
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}',
65
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
66
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
67
+ '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #336DF4;transform:scale(1.02)}',
68
+ // isolation:isolate contains artboard content's z-indexes so a
69
+ // z-indexed child (sticky navbar etc.) can't paint over .dc-header or
70
+ // the .dc-menu popover that drops into the top of the card.
71
+ '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
72
+ '.dc-card *{scrollbar-width:none}',
73
+ '.dc-card *::-webkit-scrollbar{display:none}',
74
+ // Focus-overlay content isn't inside .dc-card, so the scrollbar-hiding
75
+ // rules above don't reach it — re-hide scrollbars for the scaled,
76
+ // scrollable content shown in DCFocusOverlay.
77
+ '.dc-focus-content *{scrollbar-width:none}',
78
+ '.dc-focus-content *::-webkit-scrollbar{display:none}',
79
+ // Per-artboard header: grip + label on the left, delete/expand on the
80
+ // right. Single flex row; when the artboard's on-screen width is too
81
+ // narrow for both the label yields (ellipsis, then hidden entirely below
82
+ // ~4ch via the container query) and the buttons stay on the row.
83
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
84
+ ' display:flex;align-items:center;container-type:inline-size}',
85
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
86
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
87
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
88
+ '.dc-grip:active{cursor:grabbing}',
89
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
90
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
91
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
92
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
93
+ // until the card is moused.
94
+ '@container (max-width: 110px){',
95
+ ' .dc-labeltext{display:none}',
96
+ ' .dc-grip{opacity:0}',
97
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
98
+ '}',
99
+ '.dc-labeltext:hover{background:rgba(31,35,41,0.05)}',
100
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
101
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
102
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
103
+ '[data-dc-slot]:hover .dc-header,[data-dc-slot]:hover .dc-header .dc-editable{color:#646A73!important}',
104
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
105
+ // On touch devices (no hover) the per-artboard action bar (kebab + expand)
106
+ // can't be reveal-on-hover and tends to stick visible after a tap — hide it
107
+ // entirely on mobile. The grip + label stay for per-screen context.
108
+ '@media (hover: none){.dc-btns{display:none}}',
109
+ // Mobile (UA-detected, .dc-mobile on the viewport root) is browse-only:
110
+ // drag-reorder is disabled there, so the grip is dead weight — hide it.
111
+ '.dc-mobile .dc-grip{display:none}',
112
+ // Mobile is browse-only: kill text selection + iOS long-press callout
113
+ // inside the canvas. iOS interprets a drag over selectable TEXT as a
114
+ // selection gesture and steals the pointer (pointercancel) — touch-action
115
+ // only suppresses native pan/zoom, not selection — so text-dense
116
+ // artboards became un-pannable while image-heavy ones panned fine.
117
+ '.dc-mobile, .dc-mobile *{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}',
118
+ // Prototype content routinely carries inner scrollers (the device/window
119
+ // frames all have overflow:auto bodies). On iOS a touch starting inside a
120
+ // scrollable element is consumed by THAT scroller (native scroll attempt
121
+ // + pointercancel), so pans could never start on those artboards while
122
+ // plain-HTML cards panned fine. Mobile canvas is browse-only: kill native
123
+ // touch handling on everything inside the cards so gestures always reach
124
+ // the viewport state machine. Desktop (no .dc-mobile) keeps inner scroll.
125
+ '.dc-mobile [data-dc-slot] *{touch-action:none !important}',
126
+ // Pinch updates --dc-inv-zoom per frame (PC semantics: chrome size AND
127
+ // section spacing stay constant). The per-frame relayout that implies
128
+ // white-flashed on WKWebView when the whole world was one paint layer —
129
+ // every head resize invalidated and re-rasterized ALL content. Promote
130
+ // each card (and head) to its own compositing layer: relayout then only
131
+ // MOVES card layers (compositor, no repaint) and repaints just the tiny
132
+ // head layers. contain:paint bounds invalidation to the card box.
133
+ // contain:paint must live on .dc-card, NOT the slot: .dc-header is an
134
+ // abs-positioned slot child sitting entirely ABOVE the slot box
135
+ // (bottom:100%) — paint containment on the slot would clip it out of
136
+ // rendering AND hit-testing, killing the label tap that is mobile's only
137
+ // focus-overlay entry. will-change (the layer-promotion half of the
138
+ // white-flash fix) stays on the slot so header + card share one layer.
139
+ '.dc-mobile [data-dc-slot]{will-change:transform}',
140
+ '.dc-mobile .dc-card{contain:paint}',
141
+ '.dc-mobile .dc-sectionhead{will-change:transform}',
142
+ '[data-dc-slot]:hover .dc-card.chromeless>*, [data-dc-slot]:hover .dc-card:not(.chromeless){box-shadow:0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)}',
143
+ '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;',
144
+ ' background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;',
145
+ ' font:inherit;transition:background .12s,color .12s}',
146
+ '.dc-expand:hover,.dc-kebab:hover{background:rgba(31,35,41,0.05);color:#646A73}',
147
+ // Slot hosting an open menu floats above later siblings (which otherwise
148
+ // paint on top — same z-index:auto, later DOM order) so the popup isn't
149
+ // clipped by the next card.
150
+ '[data-dc-slot]:has(.dc-menu){z-index:10}',
151
+ '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;',
152
+ ' box-shadow:0 4px 8px 0 rgba(0,0,0,0.03),0 3px 6px -6px rgba(0,0,0,0.05),0 6px 18px 6px rgba(0,0,0,0.03);padding:3px;min-width:120px;z-index:10}',
153
+ '.dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;',
154
+ ' border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;',
155
+ ' color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
156
+ '.dc-menu button:hover{background:rgba(31,35,41,0.05)}',
157
+ '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
158
+ '.dc-menu .dc-danger{color:#F54A45}',
159
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
160
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
161
+ // DCViewport on every transform update and inherits to all descendants —
162
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
163
+ // it the same way.
164
+ //
165
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
166
+ // matter) with its world-space width set to card-width / inv-zoom so that
167
+ // after counter-scaling its on-screen width exactly matches the card's —
168
+ // that's what lets the container query + text-overflow behave against the
169
+ // card's visible edge at every zoom level.
170
+ //
171
+ // The section head uses CSS zoom instead of transform so its layout box
172
+ // grows with the counter-scale, pushing the card row down — otherwise the
173
+ // constant-screen-size title would overflow into the (shrinking) world-
174
+ // space gap and overlap the artboard headers at low zoom.
175
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
176
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left;will-change:transform}',
177
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
178
+ // On-canvas zoom control (bottom-right pill). Stays screen-fixed because
179
+ // it renders outside worldRef. z-index 50 keeps it above world content but
180
+ // below the focus overlay (z-index 100). The dropdown reuses .dc-menu and
181
+ // only overrides its positioning to open upward (.dc-zoom-menu).
182
+ '.dc-zoom{position:absolute;bottom:16px;right:20px;z-index:50;display:flex;align-items:center;',
183
+ ' background:#fff;border-radius:8px;padding:4px;font-family:inherit;',
184
+ ' border:0.5px solid #DEE0E3;box-shadow:0 2px 4px -4px rgba(31,35,41,0.02),0 4px 8px 0 rgba(31,35,41,0.02),0 4px 16px 4px rgba(31,35,41,0.03)}',
185
+ '.dc-zoom-btn{height:24px;min-width:24px;border-radius:6px;border:none;cursor:pointer;padding:0;',
186
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
187
+ ' font:inherit;transition:background .12s,color .12s}',
188
+ '.dc-zoom-btn:hover{background:#1f23290d}',
189
+ '.dc-zoom-btn:disabled{opacity:.35;cursor:default;background:transparent;color:rgba(60,50,40,.7)}',
190
+ '.dc-zoom-toggle{position:relative;display:flex}',
191
+ '.dc-zoom-trigger{padding:0 6px;gap:4px}',
192
+ '.dc-zoom-pct{min-width:36px;text-align:center;font-size:12px;font-weight:400;color:#1F2329}',
193
+ '.dc-zoom-menu{top:auto;bottom:100%;left:50%;transform:translateX(-50%);margin-top:0;margin-bottom:6px;min-width:114px}',
194
+ '.dc-zoom-menu button{display:flex;align-items:center;justify-content:space-between;gap:8px}',
195
+ ].join('\n');
196
+ document.head.appendChild(s);
197
+ }
198
+
199
+ const DCCtx = React.createContext(null);
200
+
201
+ // Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
202
+ // DCSection/DCArtboard children from the type-based walks below.
203
+ function dcFlatten(children) {
204
+ const out = [];
205
+ React.Children.forEach(children, (c) => {
206
+ if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
207
+ else out.push(c);
208
+ });
209
+ return out;
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────
213
+ // DesignCanvas — stateful wrapper around the pan/zoom viewport.
214
+ // Owns runtime state (per-section order, renamed titles/labels, hidden
215
+ // artboards, focused artboard). Order/titles/labels/hidden persist to a
216
+ // .design-canvas.state.json
217
+ // sidecar next to the HTML. Reads go via plain fetch() so the saved
218
+ // arrangement is visible anywhere the HTML + sidecar are served together
219
+ // (miaoda preview, direct link, downloaded zip). Writes post the sidecar
220
+ // content up to the miaoda host via postMessage — editing requires the miaoda runtime.
221
+ // Focus is ephemeral.
222
+ // ─────────────────────────────────────────────────────────────
223
+ const DC_STATE_FILE = '.design-canvas.state.json';
224
+
225
+ // Persist a sidecar file back to the host.
226
+ function miaodaWriteFile(path, content) {
227
+ try {
228
+ window.parent.postMessage({ type: 'miaoda:bridge:write-file', path: path, content: content }, '*');
229
+ } catch (e) {
230
+ /* no parent — ignore */
231
+ }
232
+ return Promise.resolve();
233
+ }
234
+
235
+ function DesignCanvas({ children, minScale, maxScale, style }) {
236
+ const [state, setState] = React.useState({ sections: {}, focus: null });
237
+ // Hold rendering until the sidecar read settles so the saved order/titles
238
+ // appear on first paint (no source-order flash). didRead gates writes until
239
+ // the read settles so the empty initial state can't clobber a slow read;
240
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
241
+ // hydration.
242
+ const [ready, setReady] = React.useState(false);
243
+ const didRead = React.useRef(false);
244
+ const skipNextWrite = React.useRef(false);
245
+
246
+ React.useEffect(() => {
247
+ let off = false;
248
+ fetch('./' + DC_STATE_FILE)
249
+ .then((r) => (r.ok ? r.json() : null))
250
+ .then((saved) => {
251
+ if (off || !saved || !saved.sections) return;
252
+ skipNextWrite.current = true;
253
+ setState((s) => ({ ...s, sections: saved.sections }));
254
+ })
255
+ .catch(() => {})
256
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
257
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
258
+ return () => { off = true; clearTimeout(t); };
259
+ }, []);
260
+
261
+ React.useEffect(() => {
262
+ if (!didRead.current) return;
263
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
264
+ const t = setTimeout(() => {
265
+ miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
266
+ }, 250);
267
+ return () => clearTimeout(t);
268
+ }, [state.sections]);
269
+
270
+ // Build registries synchronously from children so FocusOverlay can read
271
+ // them in the same render. Fragments are flattened; wrapping in other
272
+ // elements still opts out of focus/reorder.
273
+ const registry = {}; // slotId -> { sectionId, artboard }
274
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
275
+ const sectionOrder = [];
276
+ dcFlatten(children).forEach((sec) => {
277
+ if (!sec || sec.type !== DCSection) return;
278
+ const sid = sec.props.id ?? sec.props.title;
279
+ if (!sid) return;
280
+ sectionOrder.push(sid);
281
+ const persisted = state.sections[sid] || {};
282
+ const abs = [];
283
+ dcFlatten(sec.props.children).forEach((ab) => {
284
+ if (!ab || ab.type !== DCArtboard) return;
285
+ const aid = ab.props.id ?? ab.props.label;
286
+ if (aid) abs.push([aid, ab]);
287
+ });
288
+ // hidden is scoped to one source revision — when the agent regenerates
289
+ // (artboard-ID set changes), prior deletes don't apply to new content.
290
+ const srcKey = abs.map(([k]) => k).join('\x1f');
291
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
292
+ const srcIds = [];
293
+ abs.forEach(([aid, ab]) => {
294
+ if (hidden.includes(aid)) return;
295
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
296
+ srcIds.push(aid);
297
+ });
298
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
299
+ sectionMeta[sid] = {
300
+ title: persisted.title ?? sec.props.title,
301
+ subtitle: sec.props.subtitle,
302
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
303
+ };
304
+ });
305
+
306
+ const api = React.useMemo(() => ({
307
+ state,
308
+ section: (id) => state.sections[id] || {},
309
+ patchSection: (id, p) => setState((s) => ({
310
+ ...s,
311
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
312
+ })),
313
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
314
+ }), [state]);
315
+
316
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
317
+ React.useEffect(() => {
318
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
319
+ const onPd = (e) => {
320
+ const ae = document.activeElement;
321
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
322
+ };
323
+ document.addEventListener('keydown', onKey);
324
+ document.addEventListener('pointerdown', onPd, true);
325
+ return () => {
326
+ document.removeEventListener('keydown', onKey);
327
+ document.removeEventListener('pointerdown', onPd, true);
328
+ };
329
+ }, [api]);
330
+
331
+ // dcOverlay opt-in: render outside DCViewport so the component is free from
332
+ // worldRef's transform (pan/zoom). Unmarked children stay in the canvas.
333
+ const flat = dcFlatten(children);
334
+ const overlayChildren = flat.filter(c => c && c.type?.dcOverlay);
335
+ const worldChildren = flat.filter(c => !c || !c.type?.dcOverlay);
336
+
337
+ return (
338
+ <DCCtx.Provider value={api}>
339
+ <DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && worldChildren}</DCViewport>
340
+ {ready && overlayChildren}
341
+ {state.focus && registry[state.focus] && (
342
+ <DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
343
+ )}
344
+ </DCCtx.Provider>
345
+ );
346
+ }
347
+
348
+ // ─────────────────────────────────────────────────────────────
349
+ // DCZoomControl — bottom-right zoom pill (zoom-in / % + presets / zoom-out)
350
+ //
351
+ // Renders outside worldRef so it stays screen-fixed. data-miaoda-chrome marks
352
+ // it as non-background so the viewport's pointerdown doesn't start a pan on it
353
+ // (the native vp listener can't be stopped by React's synthetic propagation,
354
+ // so the attribute — not stopPropagation — is what prevents the pan). Buttons
355
+ // step to the nearest 10% grid, clamped to the canvas scale range; the dropdown
356
+ // offers common presets and opens upward.
357
+ // ─────────────────────────────────────────────────────────────
358
+ const DC_ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
359
+
360
+ function DCZoomControl({ scalePct, minScale, maxScale, onZoomTo }) {
361
+ const [open, setOpen] = React.useState(false);
362
+ const rootRef = React.useRef(null);
363
+ const minPct = Math.round(minScale * 100);
364
+ const maxPct = Math.round(maxScale * 100);
365
+ const snapUp = Math.min(maxPct, (Math.floor(scalePct / 10) + 1) * 10);
366
+ const snapDown = Math.max(minPct, (Math.ceil(scalePct / 10) - 1) * 10);
367
+
368
+ React.useEffect(() => {
369
+ if (!open) return;
370
+ const onDocDown = (e) => {
371
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
372
+ };
373
+ document.addEventListener('pointerdown', onDocDown);
374
+ return () => document.removeEventListener('pointerdown', onDocDown);
375
+ }, [open]);
376
+
377
+ return (
378
+ <div
379
+ ref={rootRef}
380
+ className="dc-zoom"
381
+ data-miaoda-chrome=""
382
+ onPointerDown={(e) => e.stopPropagation()}
383
+ >
384
+ <button
385
+ type="button"
386
+ className="dc-zoom-btn"
387
+ disabled={scalePct >= maxPct}
388
+ onClick={() => onZoomTo(snapUp)}
389
+ >
390
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
391
+ <path d="M12.4957 11.5517L14.9105 13.9668C15.1702 14.2266 15.1684 14.648 14.9087 14.9077C14.649 15.1674 14.2275 15.1692 13.9678 14.9095L11.5527 12.4943C10.3631 13.4696 8.87188 14.0016 7.33366 13.9993C3.65166 13.9993 0.666992 11.0147 0.666992 7.33268C0.666992 3.65068 3.65166 0.666016 7.33366 0.666016C11.0157 0.666016 14.0003 3.65068 14.0003 7.33268C14.0003 8.93368 13.436 10.4027 12.4957 11.5517ZM6.66699 6.66602V5.33268C6.66699 4.96449 6.96547 4.66602 7.33366 4.66602C7.70185 4.66602 8.00033 4.96449 8.00033 5.33268V6.66602H9.33366C9.70185 6.66602 10.0003 6.96449 10.0003 7.33268C10.0003 7.70087 9.70185 7.99935 9.33366 7.99935H8.00033V9.33268C8.00033 9.70087 7.70185 9.99935 7.33366 9.99935C6.96547 9.99935 6.66699 9.70087 6.66699 9.33268V7.99935H5.33366C4.96547 7.99935 4.66699 7.70087 4.66699 7.33268C4.66699 6.96449 4.96547 6.66602 5.33366 6.66602H6.66699ZM7.33366 12.666C10.2793 12.666 12.667 10.2783 12.667 7.33268C12.667 4.38702 10.2793 1.99935 7.33366 1.99935C4.38799 1.99935 2.00033 4.38702 2.00033 7.33268C2.00033 10.2783 4.38799 12.666 7.33366 12.666Z" fill="#1F2329"/>
392
+ </svg>
393
+ </button>
394
+
395
+ <div className="dc-zoom-toggle">
396
+ <button
397
+ type="button"
398
+ className="dc-zoom-btn dc-zoom-trigger"
399
+ onClick={() => setOpen((o) => !o)}
400
+ >
401
+ <span className="dc-zoom-pct">{scalePct}%</span>
402
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
403
+ <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
404
+ </svg>
405
+ </button>
406
+ {open && (
407
+ <div className="dc-menu dc-zoom-menu" onPointerDown={(e) => e.stopPropagation()}>
408
+ {DC_ZOOM_PRESETS.map((p) => (
409
+ <button type="button" key={p} onClick={() => { onZoomTo(p); setOpen(false); }}>
410
+ <span>{p}%</span>
411
+ {p === scalePct && (
412
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
413
+ <path d="M2.00002 5.64639C2.09378 5.55266 2.22094 5.5 2.35352 5.5C2.4861 5.5 2.61325 5.55266 2.70702 5.64639L4.74277 7.68189L9.27827 3.14639C9.37203 3.05266 9.49919 3 9.63177 3C9.76435 3 9.8915 3.05266 9.98527 3.14639L10.3388 3.49989C10.3852 3.54633 10.4221 3.60145 10.4472 3.66213C10.4723 3.72281 10.4853 3.78784 10.4853 3.85352C10.4853 3.9192 10.4723 3.98423 10.4472 4.04491C10.4221 4.10558 10.3852 4.16071 10.3388 4.20714L5.09627 9.44965C5.04984 9.4961 4.99471 9.53295 4.93403 9.55808C4.87336 9.58322 4.80832 9.59616 4.74264 9.59616C4.67697 9.59616 4.61193 9.58322 4.55125 9.55808C4.49058 9.53295 4.43545 9.4961 4.38902 9.44965L1.64652 6.70714C1.60007 6.66071 1.56322 6.60558 1.53808 6.54491C1.51294 6.48423 1.5 6.4192 1.5 6.35352C1.5 6.28784 1.51294 6.22281 1.53808 6.16213C1.56322 6.10145 1.60007 6.04633 1.64652 5.99989L2.00002 5.64639Z" fill="#1F2329"/>
414
+ </svg>
415
+ )}
416
+ </button>
417
+ ))}
418
+ </div>
419
+ )}
420
+ </div>
421
+
422
+ <button
423
+ type="button"
424
+ className="dc-zoom-btn"
425
+ disabled={scalePct <= minPct}
426
+ onClick={() => onZoomTo(snapDown)}
427
+ >
428
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
429
+ <path d="M12.4957 11.5517L14.9111 13.9674C15.1707 14.227 15.1697 14.6496 14.9102 14.9092C14.6506 15.1688 14.228 15.1697 13.9684 14.9101L11.5527 12.4943C10.3631 13.4696 8.87188 14.0016 7.33366 13.9993C3.65166 13.9993 0.666992 11.0147 0.666992 7.33268C0.666992 3.65068 3.65166 0.666016 7.33366 0.666016C11.0157 0.666016 14.0003 3.65068 14.0003 7.33268C14.0003 8.93368 13.436 10.4027 12.4957 11.5517ZM7.33366 12.666C10.2793 12.666 12.667 10.2783 12.667 7.33268C12.667 4.38702 10.2793 1.99935 7.33366 1.99935C4.38799 1.99935 2.00033 4.38702 2.00033 7.33268C2.00033 10.2783 4.38799 12.666 7.33366 12.666ZM4.66699 7.33268C4.66699 6.96449 4.96547 6.66602 5.33366 6.66602H9.33366C9.70185 6.66602 10.0003 6.96449 10.0003 7.33268C10.0003 7.70087 9.70185 7.99935 9.33366 7.99935H5.33366C4.96547 7.99935 4.66699 7.70087 4.66699 7.33268Z" fill="#1F2329"/>
430
+ </svg>
431
+ </button>
432
+ </div>
433
+ );
434
+ }
435
+
436
+ // ─────────────────────────────────────────────────────────────
437
+ // DCViewport — transform-based pan/zoom (internal)
438
+ //
439
+ // Input mapping (Figma-style):
440
+ // • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
441
+ // • Ctrl/⌘ + wheel → zoom
442
+ // • trackpad two-finger / mouse wheel → pan
443
+ // • middle-drag / primary-drag-on-bg → pan
444
+ //
445
+ // Transform state lives in a ref and is written straight to the DOM
446
+ // (translate3d + will-change) so wheel ticks don't go through React —
447
+ // keeps pans at 60fps on dense canvases.
448
+ // ─────────────────────────────────────────────────────────────
449
+ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
450
+ const vpRef = React.useRef(null);
451
+ const worldRef = React.useRef(null);
452
+ // Pre-fit fallback scale. A fresh canvas is reframed by the first-visit fit
453
+ // (below) once content mounts; localStorage restore overrides this on
454
+ // revisits. This 50% only shows in the gap before either runs.
455
+ const tf = React.useRef({ x: 0, y: 0, scale: 0.5 });
456
+ // Persist viewport across reloads so the user lands back where they were
457
+ // after an agent edit or browser refresh. The sandbox origin is already
458
+ // per-project; pathname keeps multiple canvas files in one project apart.
459
+ const tfKey = 'dc-viewport:' + location.pathname;
460
+ const saveT = React.useRef(0);
461
+
462
+ const lastPostedScale = React.useRef();
463
+ // Imperative handle for the on-canvas zoom control to zoom around the
464
+ // viewport centre (same path as the host's set-zoom message). Assigned in
465
+ // the pointer/wheel effect below where zoomAt is in scope.
466
+ const zoomCenteredRef = React.useRef(null);
467
+ // Percentage shown by the zoom control. Synced from apply() on every scale
468
+ // change — React bails out when the rounded value is unchanged, so pan ticks
469
+ // (scale unchanged) cost nothing. Lazily seeded from the persisted transform
470
+ // so the first paint already shows the restored — or default 50% — zoom.
471
+ const [scalePct, setScalePct] = React.useState(() => {
472
+ try {
473
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
474
+ if (s && Number.isFinite(s.scale)) {
475
+ return Math.round(Math.min(maxScale, Math.max(minScale, s.scale)) * 100);
476
+ }
477
+ } catch {}
478
+ return Math.round(tf.current.scale * 100);
479
+ });
480
+ const apply = React.useCallback(() => {
481
+ const { x, y, scale } = tf.current;
482
+ const el = worldRef.current;
483
+ if (!el) return;
484
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
485
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
486
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
487
+ // Keep the on-canvas zoom control's % readout in sync. React bails out on
488
+ // an unchanged rounded value, so pan ticks (scale unchanged) don't render.
489
+ // Mobile renders no zoom control — a per-frame setState during pinch
490
+ // would re-render the whole subtree every frame (visible repaint flicker
491
+ // on phones) for a readout nobody consumes, so skip it there entirely.
492
+ if (!dcIsMobile) setScalePct(Math.round(scale * 100));
493
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
494
+ // ticks leave scale unchanged — skip the cross-frame post for those.
495
+ if (lastPostedScale.current !== scale) {
496
+ lastPostedScale.current = scale;
497
+ window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
498
+ }
499
+ clearTimeout(saveT.current);
500
+ saveT.current = setTimeout(() => {
501
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
502
+ }, 200);
503
+ }, [tfKey]);
504
+
505
+ // Whether the mount-time restore found a saved viewport — when it did, the
506
+ // first-visit fit below is skipped so revisits keep the user's own view.
507
+ const restoredView = React.useRef(false);
508
+ const didFit = React.useRef(false);
509
+
510
+ React.useLayoutEffect(() => {
511
+ const flush = () => {
512
+ clearTimeout(saveT.current);
513
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
514
+ };
515
+ try {
516
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
517
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
518
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
519
+ restoredView.current = true;
520
+ }
521
+ } catch {}
522
+ // Apply before first paint whether we restored or fell back to the default
523
+ // (scale 0.5) so the canvas never flashes at the wrong zoom. On a fresh
524
+ // canvas the first-visit fit (below) reframes once content mounts.
525
+ apply();
526
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
527
+ // window doesn't drop the last pan/zoom.
528
+ window.addEventListener('pagehide', flush);
529
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
530
+ }, []);
531
+
532
+ // First-visit framing: once the (ready-gated) content mounts, scale + centre
533
+ // the whole canvas so every artboard fits with a ≥64px safe margin on all
534
+ // sides. Skipped when a saved viewport was restored. Runs as a layout effect
535
+ // on the commit that first paints content, so there's no un-framed flash.
536
+ React.useLayoutEffect(() => {
537
+ if (didFit.current || restoredView.current) return;
538
+ const vp = vpRef.current, world = worldRef.current;
539
+ if (!vp || !world) return;
540
+ const slots = world.querySelectorAll('[data-dc-slot]');
541
+ if (!slots.length) return; // content not mounted yet — wait for the ready commit
542
+ const heads = world.querySelectorAll('.dc-sectionhead');
543
+ // Fit margin from the screen edges. Mobile screens are narrow — 64px a
544
+ // side eats a third of the width, so fit flush-ish at 16px there.
545
+ const PAD = dcIsMobile ? 16 : 64;
546
+
547
+ // Content bounding box in WORLD space at the currently applied transform.
548
+ // X extent from cards only (section-head blocks are full width and would
549
+ // over-report); Y from cards + titles so the top heading is kept in frame.
550
+ const measure = () => {
551
+ const vpr = vp.getBoundingClientRect();
552
+ const t = tf.current;
553
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
554
+ slots.forEach((el) => {
555
+ const r = el.getBoundingClientRect();
556
+ minX = Math.min(minX, (r.left - vpr.left - t.x) / t.scale);
557
+ maxX = Math.max(maxX, (r.right - vpr.left - t.x) / t.scale);
558
+ minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
559
+ maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
560
+ });
561
+ heads.forEach((el) => {
562
+ const r = el.getBoundingClientRect();
563
+ minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
564
+ maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
565
+ });
566
+ return { vpr, minX, minY, w: maxX - minX, h: maxY - minY };
567
+ };
568
+
569
+ // Content SCREEN width is linear in scale (screenW = s·Wworld) and SCREEN
570
+ // height is affine (screenH = s·P + Q): titles and inter-section gaps
571
+ // counter-scale with zoom, so they hold a constant screen size while cards
572
+ // scale. Sample two scales to solve the coefficients, then pick the largest
573
+ // scale that keeps both axes within the viewport minus a PAD margin — exact,
574
+ // no iterate-to-converge.
575
+ const s1 = tf.current.scale;
576
+ const m1 = measure();
577
+ if (!(m1.w > 0) || !(m1.h > 0)) { didFit.current = true; return; }
578
+ const s2 = s1 * 0.7;
579
+ tf.current.scale = s2; apply();
580
+ const m2 = measure();
581
+
582
+ const { vpr } = m1;
583
+ const avW = vpr.width - PAD * 2, avH = vpr.height - PAD * 2;
584
+ const Wworld = m1.w; // screenW / s, scale-invariant
585
+ const P = (s1 * m1.h - s2 * m2.h) / (s1 - s2); // d(screenH)/d(scale)
586
+ const Q = s1 * m1.h - s1 * P; // constant screen-space part
587
+ const fitW = avW / Wworld;
588
+ const fitH = P > 0 ? (avH - Q) / P : Infinity;
589
+ const next = Math.max(minScale, Math.min(maxScale, Math.min(fitW, fitH)));
590
+
591
+ // Apply the solved scale, then centre using the extent measured AT that
592
+ // scale so margins come out symmetric (exactly PAD on the binding axis).
593
+ tf.current.scale = next; apply();
594
+ const mf = measure();
595
+ tf.current.x = (vpr.width - mf.w * next) / 2 - mf.minX * next;
596
+ tf.current.y = (vpr.height - mf.h * next) / 2 - mf.minY * next;
597
+ apply();
598
+ didFit.current = true;
599
+ });
600
+
601
+ React.useEffect(() => {
602
+ const vp = vpRef.current;
603
+ if (!vp) return;
604
+
605
+ const zoomAt = (cx, cy, factor) => {
606
+ const r = vp.getBoundingClientRect();
607
+ const px = cx - r.left, py = cy - r.top;
608
+ const t = tf.current;
609
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
610
+ const k = next / t.scale;
611
+ // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
612
+ // marginBottom) reflow on every scale change, vertically shifting the
613
+ // world layout — so a world point mathematically pinned under the cursor
614
+ // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
615
+ // Anchor the DOM element under the cursor instead: record its screen Y,
616
+ // apply the transform + --dc-inv-zoom, then cancel whatever vertical
617
+ // drift the reflow introduced so it stays put on screen.
618
+ let marker = null, markerY0 = 0;
619
+ if (k !== 1) {
620
+ const hit = document.elementFromPoint(cx, cy);
621
+ marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
622
+ if (marker) markerY0 = marker.getBoundingClientRect().top;
623
+ }
624
+ // keep the world point under the cursor fixed
625
+ t.x = px - (px - t.x) * k;
626
+ t.y = py - (py - t.y) * k;
627
+ t.scale = next;
628
+ apply();
629
+ if (marker) {
630
+ // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
631
+ // departure after the --dc-inv-zoom reflow is the layout drift.
632
+ const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
633
+ if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
634
+ }
635
+ };
636
+
637
+ // On-canvas zoom control: zoom to a target scale around the viewport
638
+ // centre, matching the host set-zoom path (onHostMsg). Closes over the
639
+ // live zoomAt / vp / tf, so it always uses the current transform.
640
+ zoomCenteredRef.current = (targetScale) => {
641
+ const r = vp.getBoundingClientRect();
642
+ const clamped = Math.min(maxScale, Math.max(minScale, targetScale));
643
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, clamped / tf.current.scale);
644
+ };
645
+
646
+ // Zoom is gated behind a modifier only: the browser sets ctrlKey for a
647
+ // trackpad pinch, and Ctrl/⌘ + wheel is the explicit mouse-zoom gesture.
648
+ // Every unmodified scroll — trackpad two-finger OR mouse wheel — pans.
649
+ // This is Figma's mapping, and it sidesteps a classification that can't be
650
+ // done reliably: a fast vertical trackpad flick's peak frame is identical
651
+ // to a mouse-wheel notch (deltaMode 0, deltaX 0, large integer deltaY), so
652
+ // routing pan-vs-zoom from a single wheel event misfires — a quick two-
653
+ // finger swipe used to jump into zoom. Keying off the modifier removes the
654
+ // ambiguity at the source.
655
+ //
656
+ // isNotchedWheel now only picks the zoom *step size* inside the zoom
657
+ // branch (fixed ratio for discrete wheels / Firefox line-mode, continuous
658
+ // exp for a smooth pinch) — misjudging it is a harmless granularity
659
+ // difference, never a pan↔zoom flip.
660
+ const isNotchedWheel = (e) =>
661
+ e.deltaMode !== 0 ||
662
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
663
+
664
+ const onWheel = (e) => {
665
+ e.preventDefault();
666
+ if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
667
+ if (e.ctrlKey || e.metaKey) {
668
+ zoomAt(e.clientX, e.clientY,
669
+ isNotchedWheel(e) ? Math.exp(-Math.sign(e.deltaY) * 0.18) : Math.exp(-e.deltaY * 0.01));
670
+ } else {
671
+ // Unmodified scroll pans. Normalize line/page deltas so a notched
672
+ // wheel moves a sensible distance instead of a few pixels per notch.
673
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? vp.clientHeight : 1;
674
+ tf.current.x -= e.deltaX * unit;
675
+ tf.current.y -= e.deltaY * unit;
676
+ apply();
677
+ }
678
+ };
679
+
680
+ // Safari sends native gesture* events for trackpad pinch with a smooth
681
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
682
+ // better feel there. No-ops on other browsers. Safari also fires
683
+ // ctrlKey wheel events during the same pinch — isGesturing makes
684
+ // onWheel drop those entirely so they neither zoom nor pan.
685
+ //
686
+ // On mobile the pointer-based two-finger pinch below is the single zoom
687
+ // source. iOS fires BOTH pointer events and these WebKit gesture* events
688
+ // for the same two fingers — zooming from both would double-apply, so on
689
+ // mobile gesture* degrades to preventDefault only (still suppressing the
690
+ // native page zoom).
691
+ let gsBase = 1;
692
+ let isGesturing = false;
693
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
694
+ const onGestureChange = (e) => {
695
+ e.preventDefault();
696
+ if (dcIsMobile) return;
697
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
698
+ };
699
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
700
+
701
+ // Drag-pan: middle button anywhere, or primary button on canvas
702
+ // background (anything that isn't an artboard or an inline editor).
703
+ let drag = null;
704
+ const onPointerDown = (e) => {
705
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable, [data-miaoda-chrome]');
706
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
707
+ e.preventDefault();
708
+ vp.setPointerCapture(e.pointerId);
709
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
710
+ vp.style.cursor = 'grabbing';
711
+ };
712
+ const onPointerMove = (e) => {
713
+ if (!drag || e.pointerId !== drag.id) return;
714
+ tf.current.x += e.clientX - drag.lx;
715
+ tf.current.y += e.clientY - drag.ly;
716
+ drag.lx = e.clientX; drag.ly = e.clientY;
717
+ apply();
718
+ };
719
+ const onPointerUp = (e) => {
720
+ if (!drag || e.pointerId !== drag.id) return;
721
+ vp.releasePointerCapture(e.pointerId);
722
+ drag = null;
723
+ vp.style.cursor = '';
724
+ };
725
+
726
+ // ── Mobile touch gestures (replaces the desktop pointer mapping above):
727
+ // one-finger drag pans — starting on artboards too, since cards fill a
728
+ // phone viewport and background is scarce; two-finger pinch zooms about
729
+ // the midpoint while the midpoint's own travel pans (Figma semantics, one
730
+ // gesture frames the view). A tap/pan slop keeps label taps working: no
731
+ // capture happens within the slop, so a clean tap still clicks through to
732
+ // focus; past it the viewport captures the pointer and the click retargets
733
+ // harmlessly. No fling/inertia — release stops the canvas (spec §8).
734
+ const touches = new Map(); // pointerId -> last {x, y}
735
+ let mobMode = null; // null | 'tap' | 'pan' | 'pinch'
736
+ let tapStart = null; // down point for slop arbitration
737
+ let pinchDist = 0; // previous-frame finger distance
738
+ let pinchMid = null; // previous-frame midpoint
739
+ const MOB_SLOP = 9; // px of travel before a tap becomes a pan
740
+
741
+ // First two insertion-ordered touches define the pinch; a third finger is
742
+ // tracked but inert (its moves shift neither midpoint nor distance).
743
+ const pinchGeom = () => {
744
+ const [a, b] = [...touches.values()];
745
+ return { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
746
+ };
747
+
748
+ const onTouchDown = (e) => {
749
+ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
750
+ if (touches.size === 1) {
751
+ mobMode = 'tap';
752
+ tapStart = { x: e.clientX, y: e.clientY };
753
+ } else if (touches.size === 2) {
754
+ mobMode = 'pinch';
755
+ const g = pinchGeom();
756
+ pinchDist = g.d;
757
+ pinchMid = { x: g.mx, y: g.my };
758
+ try { vp.setPointerCapture(e.pointerId); } catch {}
759
+ }
760
+ };
761
+ const onTouchMove = (e) => {
762
+ const t = touches.get(e.pointerId);
763
+ if (!t) return;
764
+ const px = t.x, py = t.y;
765
+ t.x = e.clientX; t.y = e.clientY;
766
+ if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
767
+ mobMode = 'pan';
768
+ try { vp.setPointerCapture(e.pointerId); } catch {}
769
+ }
770
+ if (mobMode === 'pan') {
771
+ tf.current.x += t.x - px;
772
+ tf.current.y += t.y - py;
773
+ apply();
774
+ } else if (mobMode === 'pinch') {
775
+ const g = pinchGeom();
776
+ tf.current.x += g.mx - pinchMid.x;
777
+ tf.current.y += g.my - pinchMid.y;
778
+ pinchMid = { x: g.mx, y: g.my };
779
+ // zoomAt applies the pan mutation above too; degenerate distances
780
+ // (fingers together) skip the zoom but still need the pan applied.
781
+ // Same full zoomAt as the desktop trackpad-pinch path (gesturechange):
782
+ // per-frame --dc-inv-zoom keeps chrome size AND section spacing
783
+ // constant throughout the gesture, with the same drift compensation.
784
+ if (pinchDist > 0 && g.d > 0) zoomAt(g.mx, g.my, g.d / pinchDist);
785
+ else apply();
786
+ pinchDist = g.d;
787
+ }
788
+ };
789
+ const onTouchUp = (e) => {
790
+ if (!touches.delete(e.pointerId)) return;
791
+ try { vp.releasePointerCapture(e.pointerId); } catch {}
792
+ if (touches.size === 0) {
793
+ mobMode = null;
794
+ } else if (touches.size === 1) {
795
+ // Pinch collapsing to one finger hands off to pan with the surviving
796
+ // finger as the new baseline — no jump.
797
+ mobMode = 'pan';
798
+ } else if (mobMode === 'pinch') {
799
+ // 3+ fingers losing one: re-baseline so the new leading pair doesn't
800
+ // read as a sudden midpoint/distance delta.
801
+ const g = pinchGeom();
802
+ pinchDist = g.d;
803
+ pinchMid = { x: g.mx, y: g.my };
804
+ }
805
+ };
806
+
807
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
808
+ // visible midpoint stays fixed — matching the host's iframe-zoom feel.
809
+ const onHostMsg = (e) => {
810
+ const d = e.data;
811
+ if (d && d.type === 'miaoda:canvas:set-zoom' && typeof d.scale === 'number') {
812
+ const r = vp.getBoundingClientRect();
813
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
814
+ } else if (d && d.type === 'miaoda:canvas:probe') {
815
+ // Host's [readyGen] reset asks whether a canvas is present; it
816
+ // fires on the iframe's native 'load', which for canvases with
817
+ // images/fonts is after our mount-time announce, so re-announce.
818
+ // Clear the pan-tick guard so apply() re-posts the current scale
819
+ // even if it's unchanged — the host just reset dcScale to 1.
820
+ window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
821
+ lastPostedScale.current = undefined;
822
+ apply();
823
+ }
824
+ };
825
+ window.addEventListener('message', onHostMsg);
826
+ // Announce canvas mode so the host toolbar proxies its % control here
827
+ // instead of scaling the iframe element (which would just shrink the
828
+ // viewport window of an infinite canvas). The apply() that follows emits
829
+ // the initial miaoda:canvas:zoom so the toolbar % is correct before first pinch.
830
+ // lastPostedScale reset mirrors the miaoda:canvas:probe handler: the layout
831
+ // effect's restore-path apply() may already have posted the restored
832
+ // scale (before miaoda:canvas:present), so clear the guard to re-post it in order.
833
+ window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
834
+ lastPostedScale.current = undefined;
835
+ apply();
836
+
837
+ // Mobile swaps the desktop pointer mapping (bg-only pan) for the touch
838
+ // state machine; wheel/gesture listeners stay on both (harmless without
839
+ // the hardware, and a bluetooth mouse on Android still pans via wheel).
840
+ const pd = dcIsMobile ? onTouchDown : onPointerDown;
841
+ const pm = dcIsMobile ? onTouchMove : onPointerMove;
842
+ const pu = dcIsMobile ? onTouchUp : onPointerUp;
843
+ vp.addEventListener('wheel', onWheel, { passive: false });
844
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
845
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
846
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
847
+ vp.addEventListener('pointerdown', pd);
848
+ vp.addEventListener('pointermove', pm);
849
+ vp.addEventListener('pointerup', pu);
850
+ vp.addEventListener('pointercancel', pu);
851
+ return () => {
852
+ window.removeEventListener('message', onHostMsg);
853
+ vp.removeEventListener('wheel', onWheel);
854
+ vp.removeEventListener('gesturestart', onGestureStart);
855
+ vp.removeEventListener('gesturechange', onGestureChange);
856
+ vp.removeEventListener('gestureend', onGestureEnd);
857
+ vp.removeEventListener('pointerdown', pd);
858
+ vp.removeEventListener('pointermove', pm);
859
+ vp.removeEventListener('pointerup', pu);
860
+ vp.removeEventListener('pointercancel', pu);
861
+ };
862
+ }, [apply, minScale, maxScale]);
863
+
864
+ return (
865
+ <div
866
+ ref={vpRef}
867
+ className={dcIsMobile ? 'design-canvas dc-mobile' : 'design-canvas'}
868
+ style={{
869
+ height: '100vh', width: '100vw',
870
+ background: DC.bg,
871
+ overflow: 'hidden',
872
+ overscrollBehavior: 'none',
873
+ touchAction: 'none',
874
+ position: 'relative',
875
+ fontFamily: DC.font,
876
+ boxSizing: 'border-box',
877
+ ...style,
878
+ }}
879
+ >
880
+ <div
881
+ ref={worldRef}
882
+ style={{
883
+ position: 'absolute', top: 0, left: 0,
884
+ transformOrigin: '0 0',
885
+ willChange: 'transform',
886
+ width: 'max-content', minWidth: '100%',
887
+ minHeight: '100%',
888
+ padding: '60px 0 80px',
889
+ }}
890
+ >
891
+ {children}
892
+ </div>
893
+ {/* Mobile zooms by pinch — the control is not rendered at all (host
894
+ set-zoom messages still work; the protocol is independent of it). */}
895
+ {!dcIsMobile && (
896
+ <DCZoomControl
897
+ scalePct={scalePct}
898
+ minScale={minScale}
899
+ maxScale={maxScale}
900
+ onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
901
+ />
902
+ )}
903
+ </div>
904
+ );
905
+ }
906
+
907
+ // ─────────────────────────────────────────────────────────────
908
+ // DCSection — editable title + h-row of artboards in persisted order
909
+ // ─────────────────────────────────────────────────────────────
910
+ function DCSection({ id, title, subtitle, children, gap = 48 }) {
911
+ const ctx = React.useContext(DCCtx);
912
+ const sid = id ?? title;
913
+ const all = React.Children.toArray(dcFlatten(children));
914
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
915
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
916
+ const sec = (ctx && sid && ctx.section(sid)) || {};
917
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
918
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
919
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
920
+ const srcKey = allIds.join('\x1f');
921
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
922
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
923
+
924
+ const order = React.useMemo(() => {
925
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
926
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
927
+ }, [sec.order, srcOrder.join('|')]);
928
+
929
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
930
+
931
+ // marginBottom counter-scales so the on-screen gap between sections stays
932
+ // constant — otherwise at low zoom the (world-space) gap collapses while
933
+ // the screen-constant sectionhead below it doesn't, and the title reads as
934
+ // belonging to the section above. paddingBottom below is just enough for
935
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
936
+ // the title sits tight against its own row at every zoom.
937
+ return (
938
+ <div data-dc-section={sid}
939
+ style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
940
+ <div style={{ padding: '0 60px' }}>
941
+ <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
942
+ <DCEditable tag="div" value={sec.title ?? title}
943
+ onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
944
+ style={{ fontSize: 20, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 2, display: 'inline-block', lineHeight: '28px' }} />
945
+ {subtitle && <div style={{ fontSize: 14, color: DC.subtitle, lineHeight: '22px' }}>{subtitle}</div>}
946
+ </div>
947
+ </div>
948
+ <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
949
+ {order.map((k) => (
950
+ <DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
951
+ label={(sec.labels || {})[k] ?? byId[k].props.label}
952
+ onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
953
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
954
+ onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
955
+ hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
956
+ srcKey,
957
+ }))}
958
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
959
+ ))}
960
+ </div>
961
+ {rest}
962
+ </div>
963
+ );
964
+ }
965
+
966
+ // DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
967
+ function DCArtboard() { return null; }
968
+
969
+ // Per-artboard export (kind: 'png' | 'html'). Both paths share the same
970
+ // self-contained clone: computed styles baked in, @font-face / <img> /
971
+ // inline-style background-image urls inlined as data URIs. PNG wraps the
972
+ // clone in foreignObject→canvas at 3× the artboard's natural width×height
973
+ // (same pipeline the host uses for page captures); HTML wraps it in a
974
+ // minimal standalone document. Both are independent of viewport zoom.
975
+ async function dcExport(node, w, h, name, kind) {
976
+ try { await document.fonts.ready; } catch {}
977
+ const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
978
+ const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
979
+ })).catch(() => url);
980
+
981
+ // Collect @font-face rules. ss.cssRules throws SecurityError on
982
+ // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
983
+ // the CSS text directly (those endpoints send ACAO:*) and regex-extract
984
+ // the blocks. @import and @media/@supports are walked so nested
985
+ // @font-face rules aren't missed.
986
+ const fontRules = [], pending = [], seen = new Set();
987
+ const scrapeCss = (href) => {
988
+ if (seen.has(href)) return; seen.add(href);
989
+ pending.push(fetch(href).then((r) => r.text()).then((css) => {
990
+ for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
991
+ for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
992
+ scrapeCss(new URL(m[1], href).href);
993
+ }).catch(() => {}));
994
+ };
995
+ const walk = (rules, base) => {
996
+ for (const r of rules) {
997
+ if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
998
+ else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
999
+ const ibase = r.styleSheet.href || base;
1000
+ try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
1001
+ } else if (r.cssRules) walk(r.cssRules, base);
1002
+ }
1003
+ };
1004
+ for (const ss of document.styleSheets) {
1005
+ const base = ss.href || location.href;
1006
+ try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
1007
+ }
1008
+ while (pending.length) await pending.shift();
1009
+ const fontCss = (await Promise.all(fontRules.map(async (rule) => {
1010
+ let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
1011
+ while ((m = re.exec(rule.css))) {
1012
+ if (m[2].indexOf('data:') === 0) continue;
1013
+ let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
1014
+ out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
1015
+ }
1016
+ return out;
1017
+ }))).join('\n');
1018
+
1019
+ const cloneStyled = (src) => {
1020
+ if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
1021
+ const dst = src.cloneNode(false);
1022
+ if (src.nodeType === 1) {
1023
+ const cs = getComputedStyle(src); let txt = '';
1024
+ for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
1025
+ dst.setAttribute('style', txt + 'animation:none;transition:none;');
1026
+ if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
1027
+ }
1028
+ for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
1029
+ return dst;
1030
+ };
1031
+ const clone = cloneStyled(node);
1032
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
1033
+ // Drop the card's own shadow/radius so the export is a flush w×h rect;
1034
+ // the artboard's own background (if any) is already in the computed style.
1035
+ clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
1036
+
1037
+ const jobs = [];
1038
+ clone.querySelectorAll('img').forEach((el) => {
1039
+ const s = el.getAttribute('src');
1040
+ if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
1041
+ });
1042
+ [clone, ...clone.querySelectorAll('*')].forEach((el) => {
1043
+ const bg = el.style.backgroundImage; if (!bg) return;
1044
+ let m; const re = /url\(["']?([^"')]+)["']?\)/g;
1045
+ while ((m = re.exec(bg))) {
1046
+ const tok = m[0], url = m[1];
1047
+ if (url.indexOf('data:') === 0) continue;
1048
+ jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
1049
+ }
1050
+ });
1051
+ await Promise.all(jobs);
1052
+
1053
+ const xml = new XMLSerializer().serializeToString(clone);
1054
+ const save = (blob, ext) => {
1055
+ if (!blob) return;
1056
+ const a = document.createElement('a');
1057
+ a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
1058
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1059
+ };
1060
+
1061
+ if (kind === 'html') {
1062
+ const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
1063
+ (fontCss ? '<style>' + fontCss + '</style>' : '') +
1064
+ '</head><body style="margin:0">' + xml + '</body></html>';
1065
+ return save(new Blob([html], { type: 'text/html' }), 'html');
1066
+ }
1067
+
1068
+ // PNG: the SVG's own width/height must be the output resolution — an
1069
+ // <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
1070
+ // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
1071
+ // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
1072
+ // the HTML at full resolution.
1073
+ const px = 3;
1074
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
1075
+ '" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
1076
+ (fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
1077
+ const img = new Image();
1078
+ await new Promise((res, rej) => {
1079
+ img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
1080
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
1081
+ });
1082
+ const cv = document.createElement('canvas');
1083
+ cv.width = w * px; cv.height = h * px;
1084
+ cv.getContext('2d').drawImage(img, 0, 0);
1085
+ cv.toBlob((blob) => save(blob, 'png'), 'image/png');
1086
+ }
1087
+
1088
+ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
1089
+ // Read height off the raw props (not a destructure default) so an *absent*
1090
+ // height can opt into auto-grow while an explicit height={N} — even 480 —
1091
+ // still means a fixed, content-clipping frame (thumbnail semantics).
1092
+ const { id: rawId, label: rawLabel, width = 260, chromeless = false, children, style = {} } = artboard.props;
1093
+ const hasHeight = artboard.props.height != null;
1094
+ const height = hasHeight ? artboard.props.height : undefined;
1095
+ const id = rawId ?? rawLabel;
1096
+ const ref = React.useRef(null);
1097
+ const cardRef = React.useRef(null);
1098
+ const menuRef = React.useRef(null);
1099
+ const [menuOpen, setMenuOpen] = React.useState(false);
1100
+ const [confirming, setConfirming] = React.useState(false);
1101
+
1102
+ // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
1103
+ // the menu — first click arms the row, second commits; closing disarms.
1104
+ React.useEffect(() => {
1105
+ if (!menuOpen) { setConfirming(false); return; }
1106
+ const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
1107
+ document.addEventListener('pointerdown', off, true);
1108
+ return () => document.removeEventListener('pointerdown', off, true);
1109
+ }, [menuOpen]);
1110
+
1111
+ const doExport = (kind) => {
1112
+ setMenuOpen(false);
1113
+ if (!cardRef.current) return;
1114
+ // Unicode-aware sanitize: keep letters (incl. CJK/accented), digits,
1115
+ // whitespace, dot/underscore/hyphen; collapse everything else to '_'.
1116
+ // ASCII-only \w would strip Chinese labels down to a bare '_'.
1117
+ const name = String(label || id || 'artboard').replace(/[^\p{L}\p{N}\s._-]+/gu, '_');
1118
+ const ew = cardRef.current.offsetWidth || width;
1119
+ const eh = cardRef.current.offsetHeight || height;
1120
+ dcExport(cardRef.current, ew, eh, name, kind)
1121
+ .catch((e) => console.error('[design-canvas] export failed:', e));
1122
+ };
1123
+
1124
+ // Live drag-reorder: dragged card sticks to cursor; siblings slide into
1125
+ // their would-be slots in real time via transforms. DOM order only
1126
+ // changes on drop.
1127
+ const onGripDown = (e) => {
1128
+ e.preventDefault(); e.stopPropagation();
1129
+ const me = ref.current;
1130
+ // translateX is applied in local (pre-scale) space but pointer deltas and
1131
+ // getBoundingClientRect().left are screen-space — divide by the viewport's
1132
+ // current scale so the dragged card tracks the cursor at any zoom level.
1133
+ const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
1134
+ const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
1135
+ const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
1136
+ const slotXs = homes.map((h) => h.x);
1137
+ const startIdx = order.indexOf(id);
1138
+ const startX = e.clientX;
1139
+ let liveOrder = order.slice();
1140
+ me.classList.add('dc-dragging');
1141
+
1142
+ const layout = () => {
1143
+ for (const h of homes) {
1144
+ if (h.id === id) continue;
1145
+ const slot = liveOrder.indexOf(h.id);
1146
+ h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
1147
+ }
1148
+ };
1149
+
1150
+ const move = (ev) => {
1151
+ const dx = ev.clientX - startX;
1152
+ me.style.transform = `translateX(${dx / scale}px)`;
1153
+ const cur = homes[startIdx].x + dx;
1154
+ let nearest = 0, best = Infinity;
1155
+ for (let i = 0; i < slotXs.length; i++) {
1156
+ const d = Math.abs(slotXs[i] - cur);
1157
+ if (d < best) { best = d; nearest = i; }
1158
+ }
1159
+ if (liveOrder.indexOf(id) !== nearest) {
1160
+ liveOrder = order.filter((k) => k !== id);
1161
+ liveOrder.splice(nearest, 0, id);
1162
+ layout();
1163
+ }
1164
+ };
1165
+
1166
+ const up = () => {
1167
+ document.removeEventListener('pointermove', move);
1168
+ document.removeEventListener('pointerup', up);
1169
+ const finalSlot = liveOrder.indexOf(id);
1170
+ me.classList.remove('dc-dragging');
1171
+ me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
1172
+ // After the settle transition, kill transitions + clear transforms +
1173
+ // commit the reorder in the same frame so there's no visual snap-back.
1174
+ setTimeout(() => {
1175
+ for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
1176
+ if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
1177
+ requestAnimationFrame(() => requestAnimationFrame(() => {
1178
+ for (const h of homes) h.el.style.transition = '';
1179
+ }));
1180
+ }, 180);
1181
+ };
1182
+ document.addEventListener('pointermove', move);
1183
+ document.addEventListener('pointerup', up);
1184
+ };
1185
+
1186
+ return (
1187
+ <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
1188
+ {/* Desktop stops pointerdown so header interactions never start a
1189
+ bg-pan; mobile pans FROM chrome too (slop arbitration keeps label
1190
+ taps working), so the event must bubble to the viewport there. */}
1191
+ <div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={dcIsMobile ? undefined : (e) => e.stopPropagation()}>
1192
+ <div className="dc-labelrow">
1193
+ <div className="dc-grip" onPointerDown={dcIsMobile ? undefined : onGripDown} title="Drag to reorder">
1194
+ <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>
1195
+ </div>
1196
+ <div className="dc-labeltext" onClick={onFocus} title="Click to focus">
1197
+ <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
1198
+ style={{ fontSize: 14, fontWeight: 300, color: DC.label, lineHeight: '22px' }} />
1199
+ </div>
1200
+ </div>
1201
+ <div className="dc-btns">
1202
+ <div ref={menuRef} style={{ position: 'relative' }}>
1203
+ <button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
1204
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
1205
+ </button>
1206
+ {menuOpen && (
1207
+ <div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
1208
+ <button onClick={() => doExport('png')}>下载 PNG</button>
1209
+ <button onClick={() => doExport('html')}>下载 HTML</button>
1210
+ <button className="dc-danger"
1211
+ onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
1212
+ {confirming ? '确认删除' : '删除'}
1213
+ </button>
1214
+ </div>
1215
+ )}
1216
+ </div>
1217
+ <button className="dc-expand" onClick={onFocus} title="Focus">
1218
+ <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>
1219
+ </button>
1220
+ </div>
1221
+ </div>
1222
+ <div ref={cardRef} className={"dc-card" + (chromeless ? ' chromeless' : '')}
1223
+ style={{ ...(chromeless ? {} : {
1224
+ width,
1225
+ // explicit height → fixed frame (overflow:hidden clips a tall page);
1226
+ // no height → the box grows to its content height. overflow:hidden
1227
+ // stays either way so the rounded corners clip and content wider than
1228
+ // the frame is trimmed to `width`; with auto height nothing is clipped
1229
+ // vertically because the box is exactly the content height.
1230
+ ...(hasHeight ? { height } : {}),
1231
+ borderRadius: 4, border: '1px solid #DEE0E3', overflow: 'hidden', background: '#fff',
1232
+ }), ...style }}>
1233
+ {children || <div style={{ minHeight: 160, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
1234
+ </div>
1235
+ </div>
1236
+ );
1237
+ }
1238
+
1239
+ // Inline rename — commits on blur or Enter.
1240
+ function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
1241
+ const T = tag;
1242
+ // Mobile is browse-only: render plain text — no contentEditable (a tap
1243
+ // would pop the soft keyboard), no stopPropagation (pans start on labels
1244
+ // too), and no onClick swallow (desktop uses it to keep an edit-click from
1245
+ // bubbling; on mobile the tap should bubble so a label tap opens focus).
1246
+ if (dcIsMobile) {
1247
+ return <T className="dc-editable" style={style}>{value}</T>;
1248
+ }
1249
+ return (
1250
+ <T className="dc-editable" contentEditable suppressContentEditableWarning
1251
+ onClick={onClick}
1252
+ onPointerDown={(e) => e.stopPropagation()}
1253
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
1254
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
1255
+ style={style}>{value}</T>
1256
+ );
1257
+ }
1258
+
1259
+ // ─────────────────────────────────────────────────────────────
1260
+ // Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
1261
+ // sections, Esc or backdrop click to exit.
1262
+ // ─────────────────────────────────────────────────────────────
1263
+ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
1264
+ const ctx = React.useContext(DCCtx);
1265
+ const { sectionId, artboard } = entry;
1266
+ const meta = sectionMeta[sectionId];
1267
+ const peers = meta.slotIds;
1268
+ const aid = artboard.props.id ?? artboard.props.label;
1269
+ const idx = peers.indexOf(aid);
1270
+ const secIdx = sectionOrder.indexOf(sectionId);
1271
+
1272
+ const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
1273
+ const goSection = (d) => {
1274
+ // Sections whose artboards are all deleted have slotIds:[] — step past
1275
+ // them to the next non-empty section so ↑/↓ doesn't dead-end.
1276
+ const n = sectionOrder.length;
1277
+ for (let i = 1; i < n; i++) {
1278
+ const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
1279
+ const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
1280
+ if (first) { ctx.setFocus(`${ns}/${first}`); return; }
1281
+ }
1282
+ };
1283
+
1284
+ React.useEffect(() => {
1285
+ const k = (e) => {
1286
+ if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
1287
+ if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
1288
+ if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
1289
+ if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
1290
+ };
1291
+ document.addEventListener('keydown', k);
1292
+ return () => document.removeEventListener('keydown', k);
1293
+ });
1294
+
1295
+ const { width = 260, chromeless = false, children } = artboard.props;
1296
+ const hasHeight = artboard.props.height != null;
1297
+ const height = hasHeight ? artboard.props.height : undefined;
1298
+ // A fixed-frame card (non-chromeless WITH an explicit height) uses width/
1299
+ // height directly. Otherwise the card has NO fixed height and its rendered
1300
+ // size describes the content, not a frame — either a chromeless artboard
1301
+ // (device frame / auto layout, both axes intrinsic) or a non-chromeless
1302
+ // artboard that omitted height and grows to its content (frame `width`, auto
1303
+ // height). Fitting such content to a default height would wrap it in an
1304
+ // undersized clip box and magnify the clipped slice — so measure the real
1305
+ // rendered size first and fit-scale to that, mirroring the canvas card.
1306
+ const autoSize = chromeless || !hasHeight;
1307
+ const measureRef = React.useRef(null);
1308
+ const [nat, setNat] = React.useState(null); // { aid, w, h } measured natural size
1309
+ React.useLayoutEffect(() => {
1310
+ if (!autoSize) return;
1311
+ const el = measureRef.current;
1312
+ if (el) setNat({ aid, w: el.offsetWidth, h: el.offsetHeight });
1313
+ }, [aid, autoSize]);
1314
+ const useMeasured = autoSize && nat && nat.aid === aid && nat.w > 0;
1315
+ const needsMeasure = autoSize && !useMeasured;
1316
+ const cw = useMeasured ? nat.w : width;
1317
+ const ch = useMeasured ? nat.h : (height ?? 480);
1318
+
1319
+ const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
1320
+ React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
1321
+ // Fill the viewport leaving a safe margin — 70px left/right, 32px top/bottom.
1322
+ // Take the smaller of the width-fit / height-fit ratios so the whole artboard
1323
+ // stays fully visible; no upper cap, so small frames still fill the screen.
1324
+ const MARGIN_X = 70;
1325
+ const MARGIN_Y = 32;
1326
+ const scale = Math.max(0.05, Math.min((vp.w - MARGIN_X * 2) / cw, (vp.h - MARGIN_Y * 2) / ch));
1327
+
1328
+ // Chrome (arrows / close / dots) is hidden until the cursor sits on the
1329
+ // dark backdrop (anywhere that isn't the interactive content). Hiding also
1330
+ // sets pointer-events:none so hidden controls are click-through and never
1331
+ // block content interaction or a backdrop-click exit.
1332
+ const [chromeVisible, setChromeVisible] = React.useState(false);
1333
+ const contentRef = React.useRef(null);
1334
+ const chromeStyle = { opacity: chromeVisible ? 1 : 0, pointerEvents: chromeVisible ? 'auto' : 'none' };
1335
+ const Arrow = ({ dir, onClick }) => (
1336
+ <button onClick={(e) => { e.stopPropagation(); onClick(); }}
1337
+ style={{ position: 'absolute', top: '50%', [dir]: 20, transform: 'translateY(-50%)',
1338
+ border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgb(255,255,255)',
1339
+ width: 36, height: 36, borderRadius: 22, fontSize: 16, cursor: 'pointer',
1340
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1341
+ transition: 'background .15s, opacity .18s ease', ...chromeStyle }}
1342
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
1343
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
1344
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1345
+ <path d={dir === 'left' ? 'M10.8619 1.52925C11.1223 1.7896 11.1223 2.21171 10.8619 2.47206L5.33333 8.00065L10.8619 13.5292C11.1223 13.7896 11.1223 14.2117 10.8619 14.4721C10.6016 14.7324 10.1795 14.7324 9.91912 14.4721L4.39052 8.94346C3.86983 8.42276 3.86982 7.57854 4.39052 7.05784L9.91912 1.52925C10.1795 1.2689 10.6016 1.2689 10.8619 1.52925Z' : 'M5.13765 14.4721C4.8773 14.2117 4.8773 13.7896 5.13765 13.5292L10.6662 8.00065L5.13765 2.47206C4.8773 2.21171 4.8773 1.7896 5.13764 1.52925C5.39799 1.2689 5.8201 1.2689 6.08045 1.52925L11.609 7.05784C12.1297 7.57854 12.1298 8.42276 11.6091 8.94346L6.08045 14.4721C5.82011 14.7324 5.398 14.7324 5.13765 14.4721Z'} fill="currentColor" /></svg>
1346
+ </button>
1347
+ );
1348
+
1349
+ // Portal to body so position:fixed is the real viewport regardless of any
1350
+ // transform on DesignCanvas's ancestors (including the canvas zoom itself).
1351
+ return ReactDOM.createPortal(
1352
+ <div onClick={() => ctx.setFocus(null)}
1353
+ onMouseMove={(e) => setChromeVisible(!(contentRef.current && contentRef.current.contains(e.target)))}
1354
+ onMouseLeave={() => setChromeVisible(false)}
1355
+ onWheel={(e) => { if (!(contentRef.current && contentRef.current.contains(e.target))) e.preventDefault(); }}
1356
+ style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,.8)', backdropFilter: 'blur(4px)',
1357
+ fontFamily: DC.font, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
1358
+
1359
+ {/* content fills the viewport to its safe margin (70px left/right, 32px
1360
+ top/bottom), centered — interactive & scrollable. Only the content
1361
+ stops propagation, so any backdrop click (the dark margin around it)
1362
+ exits focus. */}
1363
+ <div ref={contentRef} className="dc-focus-content" onClick={(e) => e.stopPropagation()}
1364
+ style={{ width: cw * scale, height: ch * scale, position: 'relative',
1365
+ // hidden until the auto-size measure pass below reports the real size
1366
+ visibility: needsMeasure ? 'hidden' : 'visible' }}>
1367
+ {/* Auto-size cards render a hidden measure pass first, then re-render
1368
+ scaled. Chromeless measures both axes (inline-block → intrinsic
1369
+ size); a non-chromeless auto card keeps its frame `width` and lets
1370
+ height run natural. Card chrome (bg / radius / shadow / overflow:auto)
1371
+ is added only for a non-chromeless box — matching the canvas card —
1372
+ giving it a scroll viewport for anything it still clips; scrollbars
1373
+ are hidden by the .dc-focus-content CSS. A chromeless box carries
1374
+ none of it, so its content owns its look. */}
1375
+ <div ref={measureRef} style={needsMeasure
1376
+ ? (chromeless ? { display: 'inline-block' } : { width })
1377
+ : { width: cw, height: ch, transform: `scale(${scale})`, transformOrigin: 'top left',
1378
+ ...(chromeless ? {} : { background: '#fff', borderRadius: 2, overflow: 'auto', boxShadow: '0 20px 80px rgba(0,0,0,.4)' }) }}>
1379
+ {children || <div style={{ minHeight: 160, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
1380
+ </div>
1381
+ </div>
1382
+
1383
+ {/* close — top-right, revealed on backdrop hover */}
1384
+ <button onClick={() => ctx.setFocus(null)}
1385
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
1386
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}
1387
+ style={{ position: 'absolute', top: 20, right: 24, border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgb(255,255,255)',
1388
+ width: 36, height: 36, borderRadius: 22, fontSize: 16, cursor: 'pointer', lineHeight: 1,
1389
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1390
+ transition: 'background .12s, opacity .18s ease', ...chromeStyle }}>
1391
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1392
+ <path d="M13.4713 13.4722C13.7301 13.2134 13.732 12.7957 13.4732 12.5369L8.93693 8.00064L13.4731 3.46444C13.7319 3.20562 13.7301 2.78788 13.4713 2.52907C13.2125 2.27026 12.7947 2.26838 12.5359 2.5272C12.2771 2.78601 7.99969 7.0634 7.99969 7.0634L3.46345 2.52716C3.20464 2.26835 2.7869 2.27022 2.52809 2.52903C2.26928 2.78784 2.2674 3.20558 2.52621 3.4644L7.06246 8.00064L2.52618 12.5369C2.26737 12.7957 2.26924 13.2135 2.52806 13.4723C2.78687 13.7311 3.20461 13.733 3.46342 13.4742L7.99969 8.93788L12.5359 13.4741C12.7947 13.7329 13.2125 13.731 13.4713 13.4722Z" fill="currentColor"/>
1393
+ </svg>
1394
+ </button>
1395
+
1396
+ <Arrow dir="left" onClick={() => go(-1)} />
1397
+ <Arrow dir="right" onClick={() => go(1)} />
1398
+
1399
+ {/* dots — revealed on backdrop hover */}
1400
+ <div onClick={(e) => e.stopPropagation()}
1401
+ style={{ position: 'absolute', bottom: 13, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8,
1402
+ transition: 'opacity .18s ease', ...chromeStyle }}>
1403
+ {peers.map((p, i) => (
1404
+ <button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
1405
+ style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
1406
+ background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
1407
+ ))}
1408
+ </div>
1409
+ </div>,
1410
+ document.body,
1411
+ );
1412
+ }
1413
+
1414
+ // ─────────────────────────────────────────────────────────────
1415
+ // Post-it — absolute-positioned sticky note
1416
+ // ─────────────────────────────────────────────────────────────
1417
+ function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
1418
+ return (
1419
+ <div style={{
1420
+ position: 'absolute', top, left, right, bottom, width,
1421
+ background: DC.postitBg, padding: '14px 16px',
1422
+ fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
1423
+ fontSize: 14, lineHeight: 1.4, color: DC.postitText,
1424
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
1425
+ transform: `rotate(${rotate}deg)`,
1426
+ zIndex: 5,
1427
+ }}>{children}</div>
1428
+ );
1429
+ }
1430
+
1431
+ Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
1432
+