makersclaw 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +73 -7
  2. package/package.json +3 -2
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +65 -9
  117. package/src/install.mjs +360 -21
  118. package/src/lockfile.mjs +468 -0
  119. package/src/pm.mjs +473 -0
  120. package/src/registry.mjs +125 -1
  121. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  122. package/templates/next/browser/app/layout.jsx +94 -0
  123. package/templates/next/browser/app/page.jsx +60 -0
  124. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  125. package/templates/next/browser/lib/actions.js +16 -0
  126. package/templates/next/browser/lib/data.js +43 -0
  127. package/templates/next/files/README.md +818 -82
  128. package/templates/next/files/app/claw-alive/route.js +2 -2
  129. package/templates/next/files/app/frame.js +134 -9
  130. package/templates/next/files/app/layout.jsx +2 -2
  131. package/templates/next/files/check/run.mjs +1491 -53
  132. package/templates/next/files/components/ui/item.jsx +2 -2
  133. package/templates/next/files/components/ui/notice.jsx +1 -1
  134. package/templates/next/files/components/ui/separator.jsx +1 -1
  135. package/templates/next/files/lib/actions.js +171 -52
  136. package/templates/next/files/lib/brain-server.js +96 -0
  137. package/templates/next/files/lib/brain.js +413 -0
  138. package/templates/next/files/lib/claw.js +11 -0
  139. package/templates/next/files/lib/data.js +120 -30
  140. package/templates/next/files/lib/db.js +97 -17
  141. package/templates/next/files/lib/door.js +680 -0
  142. package/templates/next/files/next.config.mjs +85 -9
  143. package/templates/next/files/package.json +3 -3
  144. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  145. package/templates/next/files/surfaces.js +3 -2
  146. package/templates/next/pnpm-lock.yaml +5870 -0
  147. package/templates/next/template.json +13 -6
  148. package/templates/next/files/schema.sql +0 -22
@@ -5,7 +5,7 @@
5
5
  "title": "Navigation Menu",
6
6
  "description": "A site's top nav: plain links, and triggers that open a mega-menu panel of links (icon, title, one-line description) with an optional featured card; hover with intent, click, arrow keys and Escape; a menu button and a sheet of collapsible groups on a phone.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -45,7 +45,7 @@
45
45
  "docs": "Installed to components/makersclaw/blocks/navigation-menu. Import: import { NavigationMenu } from \"@/components/makersclaw/blocks/navigation-menu\". Props and an example: components/makersclaw/blocks/navigation-menu/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
46
  "meta": {
47
47
  "makersclaw": {
48
- "version": "0.5.2",
48
+ "version": "0.7.0",
49
49
  "useWhen": "The header of a public-facing site or a product with many destinations: a marketing site, a docs hub, a customer portal. Not for an app's own sidebar or a row of tabs that switch views on one page."
50
50
  }
51
51
  }
@@ -5,7 +5,7 @@
5
5
  "title": "OrderDetail",
6
6
  "description": "One placed order on a page: its number, status and date, the items, the delivery address and contact, a timeline from placed to delivered (Steps), and the totals, with Track order and Invoice actions. Loading, error and not-found states built in.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -47,7 +47,7 @@
47
47
  "docs": "Installed to components/makersclaw/blocks/order-detail. Import: import { OrderDetail } from \"@/components/makersclaw/blocks/order-detail\". Props and an example: components/makersclaw/blocks/order-detail/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
48
48
  "meta": {
49
49
  "makersclaw": {
50
- "version": "0.5.2",
50
+ "version": "0.7.0",
51
51
  "useWhen": "A buyer or an operator opens one order: \"where is my order\", an order confirmation page, a support view of a purchase. Not for a list of orders (data-table) or for a basket before paying (cart)."
52
52
  }
53
53
  }
package/r/org-chart.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "OrgChart",
6
6
  "description": "An org chart on flow-canvas: people laid out top down from a flat list (each names a manager), managers centred over their teams, a team with no managers in it as a compact column, every line drawn for you. Teams fold past the first levels behind a chip that says how many are hidden; search finds a person, unfolds the way to them, centres them and lights their chain of managers; a card opens a details sheet (reports to, direct reports, team size). Colour strips and a legend by department. Optional: drag a card onto another to change someone's manager (async { ok }, optimistic, with Undo). Keyboard throughout.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/flow-canvas.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/flow-canvas.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -38,7 +38,7 @@
38
38
  "path": "registry/blocks/org-chart/README.md",
39
39
  "type": "registry:file",
40
40
  "target": "@components/makersclaw/blocks/org-chart/README.md",
41
- "content": "# OrgChart\n\nWho reports to whom, top down. Give it people as a flat list — each row\nnames its manager — and it draws the tree: managers centred over their\nteams, a team with no managers in it as a compact column (twelve cards in\na row read worse than a list), every line in place.\n\nTeams past the first levels start folded; the chip under a manager says\nhow many report to them and, folded, how many are hidden. **Find a\nperson** (or press `/`) unfolds the way down to them, centres them and\nlights their chain of managers. A click selects a card; a second click\n(or Enter) opens a details sheet: who they report to, their direct\nreports, the team's size. Cards carry a colour strip by department, with\na legend on top.\n\nWith `onChangeManager`, drag a card onto another to move that person to a\nnew manager — shown at once, saved through your callback, undone if it\nfails, with an Undo in the toast.\n\nIt is built on `flow-canvas` (which draws it, pans and zooms): this block\nbuilds the tree, lays it out and owns the keys.\n\n```jsx\nimport { OrgChart } from \"../components/blocks/org-chart\"; // a Makersclaw app (\"./blocks/org-chart\" from components/)\nimport { OrgChart } from \"@/components/makersclaw/blocks/org-chart\"; // a shadcn project\n```\n\n`OrgChart` is a client component: render it from a `\"use client\"`\ncomponent. `makersclaw add org-chart` adds `flow-canvas` with it (and its\nnpm package, `@xyflow/react`: run `npm install` after). The helpers\n(`buildOrg`, `orgToText`…) are plain functions and work on the server too.\n\n## The data\n\nOne row per person, as a table holds them:\n\n```js\n[\n { id: \"p1\", name: \"Amara Okafor\", title: \"Chief Executive Officer\", department: \"Leadership\" },\n { id: \"p2\", name: \"Daniel Reyes\", title: \"VP Engineering\", department: \"Engineering\", managerId: \"p1\", avatar: \"https://…\", email: \"daniel@…\", location: \"Lisbon\" },\n]\n```\n\n- `id` — required, unique. `name`, `title` — the card's two lines.\n- `managerId` (or the field named by `managerKey`) — the manager's `id`.\n Missing, or naming someone not in the list: the person is the top of a\n tree (several tops sit side by side). A loop of managers is broken, and\n never hangs the chart.\n- `avatar` — an image URL; without one the card shows initials.\n- `department` (or the field named by `groupBy`) — the card's colour strip\n and the legend. Colours are the theme's chart hues, given in the order\n groups first appear.\n- `email`, `location` — shown in the details sheet. Any other field is\n kept and handed back to your callbacks.\n\n## Props\n\n- `people` — required, the rows above. An empty list shows a friendly\n empty state.\n- `managerKey` — the field naming the manager (default `\"managerId\"`).\n- `groupBy` — the field to colour by (default `\"department\"`), or\n `(person) => group`; `null` for no colours or legend.\n- `defaultDepth` — how many levels show before teams fold (default `3`:\n the top, their reports, and theirs).\n- `onChangeManager(person, managerId)` — `async`, return `{ ok: true }` or\n `{ ok: false, error? }` (a throw counts as failed). Turns on dragging a\n card onto another (with a mouse; on touch a drag always pans). The move\n shows at once; a failure puts the person back and says why. Update\n `people` when it succeeds (the chart keeps the move until you do).\n- `onOpenPerson(person)` — open your own view of a person (a record page,\n your own Sheet) instead of the built-in details sheet.\n- `renderDetails(person)` — more for the built-in details sheet, under the\n reports: kit parts (their open deals, a \"Message\" button).\n- `onSelectionChange(person | null)` — the selected card changed.\n- `height` — px or any CSS length, default `640`. A height class in\n `className` (`h-dvh`, `h-full`, `flex-1`) replaces it.\n- `label` — the chart's accessible name (default \"Org chart\").\n- `className` — layout around it (margin, width, height). Never colour.\n\n## Keyboard and pointer\n\n- `/` finds a person. Arrows walk the chart: ↑ to the manager, ↓ into the\n team (unfolding it), ← → along the team (and across to the next team\n at the end of a row); in a column ↑ ↓ go up and down it.\n- Space folds or unfolds the selected person's team; Enter opens their\n details; Home goes to the top; Esc clears the selection; ⇧1 fits the\n chart to the screen.\n- A plain drag on the canvas pans, pinch or ⌘-scroll zooms, the chip under\n a manager folds their team. The zoom bar (bottom right) has fit, zoom\n and a list view for screen readers.\n\n## The helpers\n\n- `buildOrg(people, { managerKey })` → `{ roots, byId }`: each node\n `{ id, person, parentId, depth, children, total }` (`total`: everyone\n under them).\n- `chainOf(byId, id)` → the chain of managers, top first, ending with them.\n- `orgToText(people, { managerKey })` — one person a line, indented under\n their manager, \"Daniel Reyes — VP Engineering (10)\": for an agent or a\n search column.\n- `layoutOrg(roots, { collapsed(node), stack = true })` → `{ items,\n bounds }`: every visible card's box and the line up to its manager.\n- `groupHues(people, key)` → Map(group → hue 1..5), the colours the chart\n uses (for a Legend or Badge elsewhere).\n\n## Example\n\n```jsx\n// components/TeamChart.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { OrgChart } from \"./blocks/org-chart\";\n\nexport default function TeamChart({ employees }) {\n const [people, setPeople] = useState(employees); // rows of public.employees\n return (\n <OrgChart\n people={people}\n managerKey=\"manager_id\"\n onChangeManager={async (person, managerId) => {\n const res = await updateRow({ source: \"public.employees\", id: person.id, patch: { manager_id: managerId } });\n if (res.ok) setPeople((list) => list.map((p) => (p.id === person.id ? { ...p, manager_id: managerId } : p)));\n return res;\n }}\n className=\"h-[calc(100dvh-8rem)]\"\n />\n );\n}\n```\n\n## What it will not do\n\n- Show a person under two managers (dotted lines, matrix orgs): one\n manager each.\n- Edit a person's details: open your own form from `onOpenPerson` or put\n a button in `renderDetails`.\n- Export an image or print.\n- Render on the server: the server shows the people as text until the\n canvas mounts. For a read-only page that must be rich in the HTML,\n render `orgToText` yourself.\n\nA chart it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add org-chart` restores them.\n"
41
+ "content": "# OrgChart\n\nWho reports to whom, top down. Give it people as a flat list — each row\nnames its manager — and it draws the tree: managers centred over their\nteams, a team with no managers in it as a compact column (twelve cards in\na row read worse than a list), every line in place.\n\nTeams past the first levels start folded; the chip under a manager says\nhow many report to them and, folded, how many are hidden. **Find a\nperson** (or press `/`) unfolds the way down to them, centres them and\nlights their chain of managers. A click selects a card; a second click\n(or Enter) opens a details sheet: who they report to, their direct\nreports, the team's size. Cards carry a colour strip by department, with\na legend on top.\n\nWith `onChangeManager`, drag a card onto another to move that person to a\nnew manager — shown at once, saved through your callback, undone if it\nfails, with an Undo in the toast.\n\nIt is built on `flow-canvas` (which draws it, pans and zooms): this block\nbuilds the tree, lays it out and owns the keys.\n\n```jsx\nimport { OrgChart } from \"../components/blocks/org-chart\"; // a Makersclaw app (\"./blocks/org-chart\" from components/)\nimport { OrgChart } from \"@/components/makersclaw/blocks/org-chart\"; // a shadcn project\n```\n\n`OrgChart` is a client component: render it from a `\"use client\"`\ncomponent. `makersclaw add org-chart` adds `flow-canvas` with it (and its\nnpm package, `@xyflow/react`: run `npm install` after). The helpers\n(`buildOrg`, `orgToText`…) are plain functions and work on the server too.\n\n## The data\n\nOne row per person, as a table holds them:\n\n```js\n[\n { id: \"p1\", name: \"Amara Okafor\", title: \"Chief Executive Officer\", department: \"Leadership\" },\n { id: \"p2\", name: \"Daniel Reyes\", title: \"VP Engineering\", department: \"Engineering\", managerId: \"p1\", avatar: \"https://…\", email: \"daniel@…\", location: \"Lisbon\" },\n]\n```\n\n- `id` — required, unique. `name`, `title` — the card's two lines.\n- `managerId` (or the field named by `managerKey`) — the manager's `id`.\n Missing, or naming someone not in the list: the person is the top of a\n tree (several tops sit side by side). A loop of managers is broken, and\n never hangs the chart.\n- `avatar` — an image URL; without one the card shows initials.\n- `department` (or the field named by `groupBy`) — the card's colour strip\n and the legend. Colours are the theme's chart hues, given in the order\n groups first appear.\n- `email`, `location` — shown in the details sheet. Any other field is\n kept and handed back to your callbacks.\n\n## Props\n\n- `people` — required, the rows above. An empty list shows a friendly\n empty state.\n- `managerKey` — the field naming the manager (default `\"managerId\"`).\n- `groupBy` — the field to colour by (default `\"department\"`), or\n `(person) => group`; `null` for no colours or legend.\n- `defaultDepth` — how many levels show before teams fold (default `3`:\n the top, their reports, and theirs).\n- `onChangeManager(person, managerId)` — `async`, return `{ ok: true }` or\n `{ ok: false, error? }` (a throw counts as failed). Turns on dragging a\n card onto another (with a mouse; on touch a drag always pans). The move\n shows at once; a failure puts the person back and says why. Update\n `people` when it succeeds (the chart keeps the move until you do).\n- `onOpenPerson(person)` — open your own view of a person (a record page,\n your own Sheet) instead of the built-in details sheet.\n- `renderDetails(person)` — more for the built-in details sheet, under the\n reports: kit parts (their open deals, a \"Message\" button).\n- `onSelectionChange(person | null)` — the selected card changed.\n- `height` — px or any CSS length, default `640`. A height class in\n `className` (`h-dvh`, `h-full`, `flex-1`) replaces it.\n- `label` — the chart's accessible name (default \"Org chart\").\n- `className` — layout around it (margin, width, height). Never colour.\n\n## Keyboard and pointer\n\n- `/` finds a person. Arrows walk the chart: ↑ to the manager, ↓ into the\n team (unfolding it), ← → along the team (and across to the next team\n at the end of a row); in a column ↑ ↓ go up and down it.\n- Space folds or unfolds the selected person's team; Enter opens their\n details; Home goes to the top; Esc clears the selection; ⇧1 fits the\n chart to the screen.\n- A plain drag on the canvas pans, pinch or ⌘-scroll zooms, the chip under\n a manager folds their team. The zoom bar (bottom right) has fit, zoom\n and a list view for screen readers.\n\n## The helpers\n\n- `buildOrg(people, { managerKey })` → `{ roots, byId }`: each node\n `{ id, person, parentId, depth, children, total }` (`total`: everyone\n under them).\n- `chainOf(byId, id)` → the chain of managers, top first, ending with them.\n- `orgToText(people, { managerKey })` — one person a line, indented under\n their manager, \"Daniel Reyes — VP Engineering (10)\": for an agent or a\n search column.\n- `layoutOrg(roots, { collapsed(node), stack = true })` → `{ items,\n bounds }`: every visible card's box and the line up to its manager.\n- `groupHues(people, key)` → Map(group → hue 1..5), the colours the chart\n uses (for a Legend or Badge elsewhere).\n\n## Example\n\n```jsx\n// surfaces/team.jsx — client: the chart, read and rearranged\n\"use client\";\nimport { useRows, whenLanded } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { OrgChart } from \"../components/blocks/org-chart\";\n\nexport default function TeamChart() {\n const { rows: people, loading } = useRows({ source: \"employees\" });\n if (loading) return null;\n return (\n <OrgChart\n people={people}\n managerKey=\"manager_id\"\n onChangeManager={async (person, managerId) => {\n const res = await updateRow({ source: \"employees\", id: person.id, patch: { manager_id: managerId } });\n if (res.ok) await whenLanded(); // the people are read again: the chart draws the new manager\n return res;\n }}\n className=\"h-[calc(100dvh-8rem)]\"\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Show a person under two managers (dotted lines, matrix orgs): one\n manager each.\n- Edit a person's details: open your own form from `onOpenPerson` or put\n a button in `renderDetails`.\n- Export an image or print.\n- Render on the server: the server shows the people as text until the\n canvas mounts. For a read-only page that must be rich in the HTML,\n render `orgToText` yourself.\n\nA chart it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add org-chart` restores them.\n"
42
42
  }
43
43
  ],
44
44
  "categories": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/org-chart. Import: import { OrgChart, buildOrg, layoutOrg, chainOf, orgToText, groupHues } from \"@/components/makersclaw/blocks/org-chart\". Props and an example: components/makersclaw/blocks/org-chart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.5.2",
62
+ "version": "0.7.0",
63
63
  "useWhen": "A screen that shows who reports to whom: a team or company directory's chart, an account's buying committee as a hierarchy, a reporting line to check before a reorg. Not for a free-form map of people and notes (account-map, flow-canvas), a plain list of people (data-table), or a tree of ideas (mind-map)."
64
64
  }
65
65
  }
@@ -5,7 +5,7 @@
5
5
  "title": "PageBuilder",
6
6
  "description": "A visual page editor (GrapesJS core) in the workspace theme: our own clean panels instead of GrapesJS's chrome — an Add panel of section and basic blocks (click or drag), a Layers tree, a Design panel limited to theme-token choices (colours, type, spacing, width, corners, borders), a device toggle (desktop, tablet, phone), undo/redo, preview, and a friendly empty state with starter templates. Pages style with CSS variables, so a saved page repaints with the theme. Saves out as { html, css, project }; PageView shows a saved page in an app.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "grapesjs@0.22.16",
@@ -22,7 +22,7 @@
22
22
  "path": "registry/blocks/page-builder/page-builder.jsx",
23
23
  "type": "registry:component",
24
24
  "target": "@components/makersclaw/blocks/page-builder/page-builder.jsx",
25
- "content": "\"use client\";\n\n// PageBuilder — a visual page editor (GrapesJS core) in the workspace\n// theme, behind one set of props.\n//\n// What is ours and what is GrapesJS's: GrapesJS keeps the page model, the\n// canvas (an iframe), selection, drag and drop, text editing, undo and the\n// HTML/CSS export. Everything around the canvas is ours, in the kit: the\n// top bar (title, save state, device toggle, undo/redo, preview, Save),\n// the Add / Layers panel, the Design panel (token choices only), the empty\n// state with templates, and a bottom bar with sheets on a narrow screen.\n// GrapesJS runs with <Canvas> from @grapesjs/react, so it creates none of\n// its own panels; theme.js repaints the few things it still draws.\n//\n// Client only, one static bundle: the modules load with the page (they are\n// safe on the server — see grapes.js); the editor is created in an effect\n// after the mount guard. Until then — on the server and in the first client\n// paint — the same frame shows with a skeleton.\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, IconButton, Menu, Sheet, Skeleton, Tabs, TabPanel, ToggleGroup, Tooltip, cx, toast } from \"../../ui\";\nimport { BlocksProvider, Canvas, GjsEditor, grapesjs, useEditorMaybe } from \"./grapes\";\nimport { cleanCss, cleanHtml, normalizeBlock, templatePage } from \"./page\";\nimport { AddPanel, DesignPanel, LayersPanel, TemplateGrid, nameOf, topLevel, useLatest } from \"./panels\";\nimport { BASE_CSS, BASIC_BLOCKS, SECTION_BLOCKS, STARTER_TEMPLATES } from \"./sections\";\nimport { FRAME_CSS, GJS_THEME, GJS_VARS } from \"./theme\";\nimport { KEEP_TOKENS, STYLE_GROUPS, fontFaceCss, fontLinks, tokenCss } from \"./tokens\";\n\n/** A ref for the builder's handle: `const page = usePageBuilder()`, then\n * `<PageBuilder builderRef={page} />` and `page.current?.getPage()`. */\nexport function usePageBuilder() {\n return useRef(null);\n}\n\nexport const DEVICES = [\n { id: \"desktop\", label: \"Desktop\", icon: \"monitor\", width: \"\" },\n { id: \"tablet\", label: \"Tablet\", icon: \"tablet\", width: \"768px\", widthMedia: \"992px\" },\n { id: \"mobile\", label: \"Phone\", icon: \"smartphone\", width: \"390px\", widthMedia: \"480px\" },\n];\n\n/** The page as it goes out: { html, css, project }. */\nexport function readPage(editor) {\n if (!editor) return { html: \"\", css: \"\", project: null };\n return {\n html: cleanHtml(editor.getHtml()),\n css: cleanCss(editor.getCss({ avoidProtected: true }) ?? \"\"),\n project: editor.getProjectData(),\n };\n}\n\nfunction builtInBlocks(builtIns) {\n if (builtIns === false || builtIns === \"none\") return [];\n if (builtIns === \"basics\") return BASIC_BLOCKS;\n if (builtIns === \"sections\") return SECTION_BLOCKS;\n return [...SECTION_BLOCKS, ...BASIC_BLOCKS];\n}\n\nexport function PageBuilder(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n return mounted ? <Builder {...props} /> : <Placeholder {...props} />;\n}\n\nconst ROOT = cx(\n \"@container relative isolate flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground\",\n KEEP_TOKENS,\n);\n\nfunction Placeholder({ title = \"Untitled page\", height = 720, className }) {\n return (\n <div className={cx(ROOT, className)} style={{ height }} aria-busy=\"true\">\n <div className=\"flex h-14 shrink-0 items-center gap-3 border-b border-border px-4\">\n <span className=\"truncate text-sm font-medium\">{title}</span>\n <span className=\"ml-auto\">\n <Skeleton className=\"h-9 w-20\" />\n </span>\n </div>\n <div className=\"flex min-h-0 flex-1\">\n <div className=\"hidden w-72 shrink-0 border-r border-border p-4 @5xl:block\">\n <Skeleton className=\"mb-4 h-9 w-full\" />\n <div className=\"grid grid-cols-2 gap-2\">\n {[0, 1, 2, 3, 4, 5].map((i) => (\n <Skeleton key={i} shape=\"block\" className=\"aspect-[16/10] w-full\" />\n ))}\n </div>\n </div>\n <div className=\"grid min-w-0 flex-1 place-items-center bg-muted p-6\">\n <span className=\"sr-only\">Loading the page editor…</span>\n <div className=\"grid w-full max-w-xl gap-3\" aria-hidden=\"true\">\n <Skeleton className=\"mx-auto h-4 w-40\" />\n <Skeleton className=\"mx-auto h-8 w-4/5\" />\n <Skeleton className=\"mx-auto h-4 w-3/5\" />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\nfunction Builder(props) {\n const { page, height = 720, className } = props;\n const rootRef = useRef(null);\n // Read once: GrapesJS takes its options when the editor is created. To\n // open another page, change the component's `key` (or use the handle).\n const [options] = useState(() => {\n const opts = {\n height: \"100%\",\n width: \"100%\",\n storageManager: false,\n undoManager: { trackSelection: false },\n telemetry: false,\n noticeOnUnload: false,\n cssIcons: \"\",\n jsInHtml: false,\n avoidInlineStyle: true,\n selectorManager: { componentFirst: true },\n richTextEditor: { actions: [\"bold\", \"italic\", \"link\"] },\n deviceManager: {\n default: \"desktop\",\n devices: DEVICES.map((d) => ({ id: d.id, name: d.label, width: d.width, widthMedia: d.widthMedia ?? \"\" })),\n },\n canvas: { frameStyle: \"\", styles: fontLinks() },\n protectedCss: \"* { box-sizing: border-box; } body { margin: 0; }\",\n parser: { optionsHtml: { allowScripts: false, allowUnsafeAttr: false, allowUnsafeAttrValue: false } },\n };\n if (page?.project) opts.projectData = page.project;\n else {\n opts.components = cleanHtml(page?.html ?? \"\");\n opts.style = cleanCss(page?.css ?? \"\");\n }\n return opts;\n });\n\n return (\n <div ref={rootRef} className={cx(ROOT, GJS_VARS, GJS_THEME, className)} style={{ height }}>\n <GjsEditor grapesjs={grapesjs} options={options} className=\"flex min-h-0 flex-1 flex-col\">\n <Shell {...props} rootRef={rootRef} />\n </GjsEditor>\n </div>\n );\n}\n\nfunction Shell({\n rootRef,\n title = \"Untitled page\",\n onSave,\n onChange,\n blocks: extraBlocks,\n builtIns = true,\n templates = STARTER_TEMPLATES,\n styles = STYLE_GROUPS,\n actions,\n saveLabel = \"Save\",\n onUploadImage,\n builderRef,\n}) {\n const editor = useEditorMaybe();\n const [ready, setReady] = useState(false);\n const [rev, setRev] = useState(0);\n const [selected, setSelected] = useState(null);\n const [device, setDevice] = useState(\"desktop\");\n const [preview, setPreview] = useState(false);\n const [dragging, setDragging] = useState(false);\n const [empty, setEmpty] = useState(false);\n const [startBlank, setStartBlank] = useState(false);\n const [dirty, setDirty] = useState(false);\n const [status, setStatus] = useState(\"idle\"); // idle · saving · saved · error\n const [history, setHistory] = useState({ undo: false, redo: false });\n const [tab, setTab] = useState(\"add\");\n const [sheet, setSheet] = useState(null); // add · layers · design · templates\n const saving = useRef(false);\n const outline = useRef(false);\n\n const blocks = useMemo(\n () => [...builtInBlocks(builtIns), ...(extraBlocks ?? [])].map((b, i) => ({ ...normalizeBlock(b, i), level: b.level ?? (b.category === \"Sections\" || b.id === \"section\" ? \"section\" : \"element\") })),\n [builtIns, extraBlocks],\n );\n const blockCss = useMemo(() => [BASE_CSS, ...blocks.map((b) => b.css).filter(Boolean)].join(\"\\n\"), [blocks]);\n const onChangeRef = useLatest(onChange);\n const onSaveRef = useLatest(onSave);\n\n // ---- the editor's life -----------------------------------------------------\n\n // Register (and re-register) the blocks with GrapesJS; their CSS joins\n // the page's rules (only rules a page uses are exported).\n useEffect(() => {\n if (!editor) return;\n const bm = editor.Blocks;\n bm.getAll().reset([]);\n for (const b of blocks) {\n bm.add(b.id, { label: b.label, category: b.category, content: b.content ?? b.html, select: true, activate: false });\n }\n if (ready) editor.Css.addRules(blockCss);\n }, [editor, blocks, blockCss, ready]);\n\n useEffect(() => {\n if (!editor) return;\n const bump = () => setRev((r) => r + 1);\n const sync = () => {\n const um = editor.UndoManager;\n setHistory({ undo: um.hasUndo(), redo: um.hasRedo() });\n setDirty(editor.getDirtyCount() > 0);\n setEmpty(editor.getWrapper()?.components().length === 0);\n };\n const onSelect = () => {\n setSelected(editor.getSelected() ?? null);\n bump();\n };\n let timer = null;\n const onUpdate = () => {\n bump();\n sync();\n if (!onChangeRef.current) return;\n clearTimeout(timer);\n timer = setTimeout(() => onChangeRef.current?.(readPage(editor)), 500);\n };\n // Picture double-click opens GrapesJS's asset modal by default; ours is\n // the Design panel's picture field.\n const openAssets = {\n run: () => {\n const c = editor.getSelected();\n if (rootRef.current && rootRef.current.getBoundingClientRect().width < 1024) setSheet(\"design\");\n // The Design panel may be drawn twice (the side panel, hidden on a\n // narrow screen, and the sheet): focus the one that shows.\n setTimeout(() => {\n const fields = rootRef.current?.ownerDocument.querySelectorAll(`[id=\"pb-src-${c?.cid}\"]`) ?? [];\n [...fields].find((el) => el.offsetParent !== null)?.focus();\n }, 80);\n },\n };\n editor.Commands.add(\"core:open-assets\", openAssets);\n editor.Commands.add(\"open-assets\", openAssets);\n\n const onLoad = () => {\n editor.Css.addRules(blockCss);\n const wrapper = editor.getWrapper();\n wrapper?.set({ selectable: false, hoverable: false, highlightable: false, name: \"Page\" });\n editor.runCommand(\"core:component-outline\");\n outline.current = true;\n editor.UndoManager.clear();\n editor.clearDirtyCount();\n sync();\n setReady(true);\n };\n editor.on(\"component:toggled\", onSelect);\n // \"update\" is GrapesJS's \"the page changed\" (it counts toward the dirty\n // count); hover and selection changes do not fire it.\n editor.on(\"update undo redo\", onUpdate);\n editor.on(\"component:styleUpdate component:add component:remove layer:component\", bump);\n editor.onReady(onLoad);\n return () => {\n clearTimeout(timer);\n editor.off(\"component:toggled\", onSelect);\n editor.off(\"update undo redo\", onUpdate);\n editor.off(\"component:styleUpdate component:add component:remove layer:component\", bump);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editor]);\n\n // Paint the frame in the theme: the resolved tokens, the app's fonts and\n // the frame's base. Again whenever the light/dark scheme or theme changes.\n useEffect(() => {\n if (!editor || !ready) return;\n let fonts = null;\n let raf = 0;\n const paint = () => {\n const doc = editor.Canvas.getDocument();\n if (!doc?.head) return;\n fonts ??= fontFaceCss();\n let el = doc.getElementById(\"pb-theme\");\n if (!el) {\n el = doc.createElement(\"style\");\n el.id = \"pb-theme\";\n doc.head.appendChild(el);\n }\n el.textContent = `${fonts}\\n${tokenCss(rootRef.current)}\\n${FRAME_CSS}`;\n // A link in the page never navigates the editor's frame.\n if (!doc.__pbLinks) {\n doc.__pbLinks = true;\n doc.addEventListener(\"click\", (e) => e.target?.closest?.(\"a[href]\") && e.preventDefault(), true);\n }\n };\n const later = () => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(paint);\n };\n paint();\n editor.on(\"canvas:frame:load:body\", later);\n const watch = new MutationObserver(later);\n const opts = { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\", \"data-scheme\"] };\n watch.observe(document.documentElement, opts);\n if (document.body) watch.observe(document.body, opts);\n const scoped = rootRef.current?.closest?.(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) watch.observe(scoped, opts);\n const mq = typeof matchMedia !== \"undefined\" ? matchMedia(\"(prefers-color-scheme: dark)\") : null;\n mq?.addEventListener?.(\"change\", later);\n return () => {\n cancelAnimationFrame(raf);\n editor.off(\"canvas:frame:load:body\", later);\n watch.disconnect();\n mq?.removeEventListener?.(\"change\", later);\n };\n }, [editor, ready, rootRef]);\n\n // The canvas follows its box (panels folding, preview, the window).\n useEffect(() => {\n if (!editor || !ready) return;\n const box = editor.Canvas.getElement()?.parentElement;\n if (!box || typeof ResizeObserver === \"undefined\") return;\n let raf = 0;\n const ro = new ResizeObserver(() => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => editor.refresh());\n });\n ro.observe(box);\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [editor, ready]);\n\n // ---- actions ---------------------------------------------------------------------\n\n const select = useCallback(\n (c) => {\n if (!editor || !c) return;\n editor.select(c);\n editor.Canvas.scrollTo(c, { behavior: \"smooth\", block: \"center\" });\n },\n [editor],\n );\n\n const addBlock = useCallback(\n (block) => {\n if (!editor) return;\n const content = block.content ?? block.html;\n const wrapper = editor.getWrapper();\n const sel = editor.getSelected();\n let target = wrapper;\n let at;\n if (sel && sel !== wrapper) {\n if (block.level === \"section\") {\n at = topLevel(editor, sel).index() + 1;\n } else if (!sel.is(\"text\") && !sel.is(\"link\") && !sel.is(\"image\") && sel.get(\"droppable\") !== false) {\n target = sel;\n } else {\n target = sel.parent() ?? wrapper;\n at = sel.index() + 1;\n }\n }\n const added = target.append(content, at === undefined ? {} : { at });\n setStartBlank(true);\n setSheet(null);\n if (added?.[0]) setTimeout(() => select(added[0]), 30);\n },\n [editor, select],\n );\n\n const applyTemplate = useCallback(\n (template, { quiet = false } = {}) => {\n if (!editor) return;\n const before = editor.getProjectData();\n const wasEmpty = editor.getWrapper()?.components().length === 0;\n const next = templatePage(template);\n if (next?.project) editor.loadProjectData(next.project);\n else {\n editor.setComponents(cleanHtml(next?.html ?? \"\"));\n if (next?.css) editor.Css.addRules(cleanCss(next.css));\n }\n editor.Css.addRules(blockCss);\n editor.UndoManager.clear();\n editor.select();\n setSheet(null);\n setStartBlank(true);\n setRev((r) => r + 1);\n setEmpty(editor.getWrapper()?.components().length === 0);\n if (!wasEmpty && !quiet) {\n toast(`Started from “${template.label}”`, {\n description: \"Your previous page was replaced.\",\n action: {\n label: \"Undo\",\n onClick: () => {\n editor.loadProjectData(before);\n editor.Css.addRules(blockCss);\n setRev((r) => r + 1);\n },\n },\n });\n }\n },\n [editor, blockCss],\n );\n\n const act = useCallback(\n (what) => {\n const c = editor?.getSelected();\n if (!c) return;\n const parent = c.parent();\n const i = c.index();\n if (what === \"parent\" && parent && parent !== editor.getWrapper()) select(parent);\n else if ((what === \"up\" || what === \"down\") && parent) {\n if (what === \"up\" ? i <= 0 : i >= parent.components().length - 1) return;\n // move() takes the slot to insert before, counted before the move.\n c.move(parent, { at: what === \"up\" ? i - 1 : i + 2 });\n select(c);\n } else if (what === \"duplicate\" && parent) {\n const copy = c.clone();\n parent.append(copy, { at: i + 1 });\n select(copy);\n } else if (what === \"delete\") {\n const name = nameOf(editor, c);\n c.remove();\n editor.select();\n setSheet((s) => (s === \"design\" ? null : s));\n toast(`${name} deleted`, { action: { label: \"Undo\", onClick: () => editor.UndoManager.undo() } });\n }\n },\n [editor, select],\n );\n\n const save = useCallback(async () => {\n if (!editor || saving.current) return { ok: false };\n saving.current = true;\n setStatus(\"saving\");\n const snapshot = editor.getDirtyCount();\n let res;\n try {\n res = onSaveRef.current ? await onSaveRef.current(readPage(editor)) : { ok: true };\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n saving.current = false;\n if (res?.ok) {\n // Edits made while saving stay unsaved.\n if (editor.getDirtyCount() === snapshot) editor.clearDirtyCount();\n setDirty(editor.getDirtyCount() > 0);\n setStatus(\"saved\");\n } else {\n setStatus(\"error\");\n toast.error(\"The page was not saved\", {\n description: res?.error ?? \"Your changes are still here. Try again in a moment.\",\n action: { label: \"Try again\", onClick: () => saveRef.current?.() },\n });\n }\n return res ?? { ok: false };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editor]);\n const saveRef = useLatest(save);\n\n const pickDevice = useCallback(\n (id) => {\n if (!id || !editor) return;\n editor.Devices.select(id);\n setDevice(id);\n },\n [editor],\n );\n\n const togglePreview = useCallback(\n (on) => {\n if (!editor) return;\n if (on) {\n editor.select();\n if (editor.Commands.isActive(\"core:component-outline\")) editor.stopCommand(\"core:component-outline\");\n editor.getModel().stopDefault();\n } else {\n editor.getModel().runDefault();\n if (outline.current) editor.runCommand(\"core:component-outline\");\n }\n setSheet(null);\n setPreview(on);\n },\n [editor],\n );\n\n // ⌘/Ctrl+S saves; Esc leaves preview. Undo, redo and delete are\n // GrapesJS's own keymaps (⌘Z, ⇧⌘Z, Backspace), live in the frame too.\n useEffect(() => {\n if (!editor) return;\n const onKey = (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"s\") {\n e.preventDefault();\n saveRef.current?.();\n } else if (e.key === \"Escape\" && preview) togglePreview(false);\n };\n const frameWin = () => editor.Canvas.getWindow();\n document.addEventListener(\"keydown\", onKey);\n let fw = null;\n const attach = () => {\n fw?.removeEventListener(\"keydown\", onKey);\n fw = frameWin();\n fw?.addEventListener(\"keydown\", onKey);\n };\n if (ready) attach();\n return () => {\n document.removeEventListener(\"keydown\", onKey);\n fw?.removeEventListener(\"keydown\", onKey);\n };\n }, [editor, ready, preview, togglePreview, saveRef]);\n\n // The handle.\n useEffect(() => {\n if (!builderRef) return;\n builderRef.current = editor\n ? {\n editor,\n getPage: () => readPage(editor),\n save: () => saveRef.current?.(),\n addBlock: (id) => {\n const b = blocks.find((x) => x.id === id);\n if (b) addBlock(b);\n return Boolean(b);\n },\n loadTemplate: (t) => {\n const tpl = typeof t === \"string\" ? templates.find((x) => x.id === t) : t;\n if (tpl) applyTemplate(tpl);\n return Boolean(tpl);\n },\n setPage: (p) => applyTemplate(p ?? {}, { quiet: true }),\n undo: () => editor.UndoManager.undo(),\n redo: () => editor.UndoManager.redo(),\n setDevice: pickDevice,\n preview: (on = true) => togglePreview(on),\n }\n : null;\n }, [builderRef, editor, blocks, templates, addBlock, applyTemplate, pickDevice, togglePreview, saveRef]);\n\n // ---- render ------------------------------------------------------------------------\n\n const showEmpty = ready && empty && !startBlank && !dragging && !preview;\n const statusText =\n status === \"saving\" ? \"Saving…\" : status === \"error\" ? \"Not saved\" : dirty ? \"Unsaved changes\" : status === \"saved\" ? \"All changes saved\" : \"No changes yet\";\n\n const addPanel = (\n <AddPanel editor={editor} blocks={blocks} templates={templates} onAdd={addBlock} onDragging={setDragging} onTemplates={() => setSheet(\"templates\")} />\n );\n const layersPanel = <LayersPanel editor={editor} selected={selected} rev={rev} onSelect={select} />;\n const designPanel = <DesignPanel editor={editor} selected={selected} rev={rev} groups={styles} onUploadImage={onUploadImage} onAction={act} />;\n\n const devices = (\n <ToggleGroup\n aria-label=\"Preview width\"\n size=\"sm\"\n value={device}\n onChange={pickDevice}\n items={DEVICES.map((d) => ({ value: d.id, label: d.label, icon: d.icon, iconOnly: true }))}\n />\n );\n\n return (\n <>\n {/* top bar */}\n <header className=\"flex h-14 shrink-0 items-center gap-2 border-b border-border px-3 @3xl:px-4\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium leading-tight\">{title}</p>\n <p className={cx(\"truncate text-xs leading-tight\", status === \"error\" ? \"text-destructive\" : \"text-muted-foreground\")} aria-live=\"polite\">\n {statusText}\n </p>\n </div>\n <div className=\"hidden @3xl:block\">{devices}</div>\n <div className=\"flex items-center gap-0.5\">\n <Tooltip label=\"Undo (⌘Z)\">\n <IconButton icon=\"undo\" label=\"Undo\" size=\"sm\" disabled={!history.undo || preview} onClick={() => editor?.UndoManager.undo()} />\n </Tooltip>\n <Tooltip label=\"Redo (⇧⌘Z)\">\n <IconButton icon=\"redo\" label=\"Redo\" size=\"sm\" disabled={!history.redo || preview} onClick={() => editor?.UndoManager.redo()} />\n </Tooltip>\n </div>\n <span className=\"hidden @xl:block\">\n <Button variant={preview ? \"primary\" : \"secondary\"} size=\"sm\" icon={preview ? \"eye-off\" : \"eye\"} onClick={() => togglePreview(!preview)} aria-pressed={preview}>\n {preview ? \"Exit preview\" : \"Preview\"}\n </Button>\n </span>\n <span className=\"@3xl:hidden\">\n <Menu\n label=\"More\"\n items={[\n { type: \"radio\", label: \"Width\", value: device, onValueChange: pickDevice, options: DEVICES.map((d) => ({ value: d.id, label: d.label })) },\n { type: \"separator\" },\n { label: preview ? \"Exit preview\" : \"Preview\", icon: \"eye\", onSelect: () => togglePreview(!preview) },\n templates?.length ? { label: \"Start from a template\", icon: \"file\", onSelect: () => setSheet(\"templates\") } : null,\n ]}\n />\n </span>\n {actions}\n <Button variant=\"primary\" size=\"sm\" loading={status === \"saving\"} disabled={!ready || (!dirty && status !== \"error\")} onClick={save}>\n {saveLabel}\n </Button>\n </header>\n\n <div className=\"relative flex min-h-0 flex-1\">\n {/* left: add + layers */}\n {!preview ? (\n <aside aria-label=\"Blocks and layers\" className=\"hidden w-72 shrink-0 flex-col border-r border-border bg-background @5xl:flex\">\n <Tabs\n aria-label=\"Panel\"\n tabs={[\n { key: \"add\", label: \"Add\" },\n { key: \"layers\", label: \"Layers\" },\n ]}\n value={tab}\n onChange={setTab}\n className=\"shrink-0 px-2\"\n />\n <TabPanel tab=\"add\" value={tab} className=\"flex min-h-0 flex-1 flex-col\">\n {addPanel}\n </TabPanel>\n <TabPanel tab=\"layers\" value={tab} className=\"min-h-0 flex-1 overflow-y-auto\">\n {layersPanel}\n </TabPanel>\n </aside>\n ) : null}\n\n {/* the canvas */}\n <div className=\"relative min-w-0 flex-1 bg-muted\">\n <Canvas className=\"absolute inset-0\" />\n {!ready ? (\n <div className=\"absolute inset-0 grid place-items-center bg-muted p-6\" aria-hidden=\"true\">\n <div className=\"grid w-full max-w-xl gap-3\">\n <Skeleton className=\"mx-auto h-4 w-40\" />\n <Skeleton className=\"mx-auto h-8 w-4/5\" />\n <Skeleton className=\"mx-auto h-4 w-3/5\" />\n </div>\n </div>\n ) : null}\n {showEmpty ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center overflow-y-auto p-4\">\n <div className=\"pointer-events-auto grid w-full max-w-2xl gap-5 rounded-xl border border-border bg-popover p-5 text-popover-foreground shadow-sm @3xl:p-6\">\n <div className=\"grid gap-1\">\n <p className=\"text-lg font-semibold\">Start your page</p>\n <p className=\"text-sm text-muted-foreground\">\n {templates?.length ? \"Pick a template to begin with, or start blank and add sections one at a time.\" : \"Add a section from the Add panel, or drag one onto the page.\"}\n </p>\n </div>\n {templates?.length ? <TemplateGrid templates={templates} onPick={applyTemplate} compact /> : null}\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button\n variant={templates?.length ? \"secondary\" : \"primary\"}\n size=\"sm\"\n icon=\"plus\"\n onClick={() => {\n setStartBlank(true);\n setTab(\"add\");\n if (rootRef.current && rootRef.current.getBoundingClientRect().width < 1024) setSheet(\"add\");\n }}\n >\n {templates?.length ? \"Start blank\" : \"Add a section\"}\n </Button>\n </div>\n </div>\n </div>\n ) : null}\n {preview ? (\n <div className=\"pointer-events-none absolute inset-x-0 bottom-4 flex justify-center\">\n <Button variant=\"primary\" size=\"sm\" icon=\"eye-off\" onClick={() => togglePreview(false)} className=\"pointer-events-auto shadow-md\">\n Exit preview · Esc\n </Button>\n </div>\n ) : null}\n </div>\n\n {/* right: design */}\n {!preview ? (\n <aside aria-label=\"Design\" className=\"hidden w-72 shrink-0 flex-col border-l border-border bg-background @5xl:flex\">\n <div className=\"flex h-11 shrink-0 items-center border-b border-border px-4\">\n <h2 className=\"text-sm font-medium\">Design</h2>\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto pt-4\">{designPanel}</div>\n </aside>\n ) : null}\n </div>\n\n {/* narrow: a bottom bar and sheets */}\n {!preview ? (\n <nav aria-label=\"Editor panels\" className=\"grid shrink-0 grid-cols-3 border-t border-border bg-background @5xl:hidden\">\n <Button variant=\"quiet\" icon=\"plus\" onClick={() => setSheet(\"add\")}>\n Add\n </Button>\n <Button variant=\"quiet\" icon=\"layers\" onClick={() => setSheet(\"layers\")}>\n Layers\n </Button>\n <Button variant=\"quiet\" icon=\"palette\" onClick={() => setSheet(\"design\")} disabled={!ready}>\n <span className=\"truncate\">{selected ? `Design · ${nameOf(editor, selected)}` : \"Design\"}</span>\n </Button>\n </nav>\n ) : null}\n\n <Sheet open={sheet === \"add\"} onClose={() => setSheet(null)} title=\"Add a block\" description=\"Tap a block to add it below the selection.\">\n <div className=\"-mx-4 -my-4 flex min-h-[60vh] flex-col pt-3\">{sheet === \"add\" ? addPanel : null}</div>\n </Sheet>\n <Sheet open={sheet === \"layers\"} onClose={() => setSheet(null)} title=\"Layers\">\n <div className=\"-mx-2\">{sheet === \"layers\" ? layersPanel : null}</div>\n </Sheet>\n <Sheet open={sheet === \"design\"} onClose={() => setSheet(null)} title=\"Design\">\n <div className=\"-mx-4\">{sheet === \"design\" ? designPanel : null}</div>\n </Sheet>\n <Sheet open={sheet === \"templates\"} onClose={() => setSheet(null)} side=\"center\" title=\"Start from a template\" description=\"A template replaces the page. You can undo it right after.\">\n {sheet === \"templates\" ? <TemplateGrid templates={templates ?? []} onPick={applyTemplate} /> : null}\n </Sheet>\n\n {/* GrapesJS needs the blocks flagged custom before it is created. */}\n <BlocksProvider>{() => <></>}</BlocksProvider>\n </>\n );\n}\n"
25
+ "content": "\"use client\";\n\n// PageBuilder — a visual page editor (GrapesJS core) in the workspace\n// theme, behind one set of props.\n//\n// What is ours and what is GrapesJS's: GrapesJS keeps the page model, the\n// canvas (an iframe), selection, drag and drop, text editing, undo and the\n// HTML/CSS export. Everything around the canvas is ours, in the kit: the\n// top bar (title, save state, device toggle, undo/redo, preview, Save),\n// the Add / Layers panel, the Design panel (token choices only), the empty\n// state with templates, and a bottom bar with sheets on a narrow screen.\n// GrapesJS runs with <Canvas> from @grapesjs/react, so it creates none of\n// its own panels; theme.js repaints the few things it still draws.\n//\n// Client only, one bundle: the modules load with the page (they are\n// safe on the server — see grapes.js); the editor is created in an effect\n// after the mount guard. Until then — on the server and in the first client\n// paint — the same frame shows with a skeleton.\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, IconButton, Menu, Sheet, Skeleton, Tabs, TabPanel, ToggleGroup, Tooltip, cx, toast } from \"../../ui\";\nimport { BlocksProvider, Canvas, GjsEditor, grapesjs, useEditorMaybe } from \"./grapes\";\nimport { cleanCss, cleanHtml, normalizeBlock, templatePage } from \"./page\";\nimport { AddPanel, DesignPanel, LayersPanel, TemplateGrid, nameOf, topLevel, useLatest } from \"./panels\";\nimport { BASE_CSS, BASIC_BLOCKS, SECTION_BLOCKS, STARTER_TEMPLATES } from \"./sections\";\nimport { FRAME_CSS, GJS_THEME, GJS_VARS } from \"./theme\";\nimport { KEEP_TOKENS, STYLE_GROUPS, fontFaceCss, fontLinks, tokenCss } from \"./tokens\";\n\n/** A ref for the builder's handle: `const page = usePageBuilder()`, then\n * `<PageBuilder builderRef={page} />` and `page.current?.getPage()`. */\nexport function usePageBuilder() {\n return useRef(null);\n}\n\nexport const DEVICES = [\n { id: \"desktop\", label: \"Desktop\", icon: \"monitor\", width: \"\" },\n { id: \"tablet\", label: \"Tablet\", icon: \"tablet\", width: \"768px\", widthMedia: \"992px\" },\n { id: \"mobile\", label: \"Phone\", icon: \"smartphone\", width: \"390px\", widthMedia: \"480px\" },\n];\n\n/** The page as it goes out: { html, css, project }. */\nexport function readPage(editor) {\n if (!editor) return { html: \"\", css: \"\", project: null };\n return {\n html: cleanHtml(editor.getHtml()),\n css: cleanCss(editor.getCss({ avoidProtected: true }) ?? \"\"),\n project: editor.getProjectData(),\n };\n}\n\nfunction builtInBlocks(builtIns) {\n if (builtIns === false || builtIns === \"none\") return [];\n if (builtIns === \"basics\") return BASIC_BLOCKS;\n if (builtIns === \"sections\") return SECTION_BLOCKS;\n return [...SECTION_BLOCKS, ...BASIC_BLOCKS];\n}\n\nexport function PageBuilder(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n return mounted ? <Builder {...props} /> : <Placeholder {...props} />;\n}\n\nconst ROOT = cx(\n \"@container relative isolate flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground\",\n KEEP_TOKENS,\n);\n\nfunction Placeholder({ title = \"Untitled page\", height = 720, className }) {\n return (\n <div className={cx(ROOT, className)} style={{ height }} aria-busy=\"true\">\n <div className=\"flex h-14 shrink-0 items-center gap-3 border-b border-border px-4\">\n <span className=\"truncate text-sm font-medium\">{title}</span>\n <span className=\"ml-auto\">\n <Skeleton className=\"h-9 w-20\" />\n </span>\n </div>\n <div className=\"flex min-h-0 flex-1\">\n <div className=\"hidden w-72 shrink-0 border-r border-border p-4 @5xl:block\">\n <Skeleton className=\"mb-4 h-9 w-full\" />\n <div className=\"grid grid-cols-2 gap-2\">\n {[0, 1, 2, 3, 4, 5].map((i) => (\n <Skeleton key={i} shape=\"block\" className=\"aspect-[16/10] w-full\" />\n ))}\n </div>\n </div>\n <div className=\"grid min-w-0 flex-1 place-items-center bg-muted p-6\">\n <span className=\"sr-only\">Loading the page editor…</span>\n <div className=\"grid w-full max-w-xl gap-3\" aria-hidden=\"true\">\n <Skeleton className=\"mx-auto h-4 w-40\" />\n <Skeleton className=\"mx-auto h-8 w-4/5\" />\n <Skeleton className=\"mx-auto h-4 w-3/5\" />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\nfunction Builder(props) {\n const { page, height = 720, className } = props;\n const rootRef = useRef(null);\n // Read once: GrapesJS takes its options when the editor is created. To\n // open another page, change the component's `key` (or use the handle).\n const [options] = useState(() => {\n const opts = {\n height: \"100%\",\n width: \"100%\",\n storageManager: false,\n undoManager: { trackSelection: false },\n telemetry: false,\n noticeOnUnload: false,\n cssIcons: \"\",\n jsInHtml: false,\n avoidInlineStyle: true,\n selectorManager: { componentFirst: true },\n richTextEditor: { actions: [\"bold\", \"italic\", \"link\"] },\n deviceManager: {\n default: \"desktop\",\n devices: DEVICES.map((d) => ({ id: d.id, name: d.label, width: d.width, widthMedia: d.widthMedia ?? \"\" })),\n },\n canvas: { frameStyle: \"\", styles: fontLinks() },\n protectedCss: \"* { box-sizing: border-box; } body { margin: 0; }\",\n parser: { optionsHtml: { allowScripts: false, allowUnsafeAttr: false, allowUnsafeAttrValue: false } },\n };\n if (page?.project) opts.projectData = page.project;\n else {\n opts.components = cleanHtml(page?.html ?? \"\");\n opts.style = cleanCss(page?.css ?? \"\");\n }\n return opts;\n });\n\n return (\n <div ref={rootRef} className={cx(ROOT, GJS_VARS, GJS_THEME, className)} style={{ height }}>\n <GjsEditor grapesjs={grapesjs} options={options} className=\"flex min-h-0 flex-1 flex-col\">\n <Shell {...props} rootRef={rootRef} />\n </GjsEditor>\n </div>\n );\n}\n\nfunction Shell({\n rootRef,\n title = \"Untitled page\",\n onSave,\n onChange,\n blocks: extraBlocks,\n builtIns = true,\n templates = STARTER_TEMPLATES,\n styles = STYLE_GROUPS,\n actions,\n saveLabel = \"Save\",\n onUploadImage,\n builderRef,\n}) {\n const editor = useEditorMaybe();\n const [ready, setReady] = useState(false);\n const [rev, setRev] = useState(0);\n const [selected, setSelected] = useState(null);\n const [device, setDevice] = useState(\"desktop\");\n const [preview, setPreview] = useState(false);\n const [dragging, setDragging] = useState(false);\n const [empty, setEmpty] = useState(false);\n const [startBlank, setStartBlank] = useState(false);\n const [dirty, setDirty] = useState(false);\n const [status, setStatus] = useState(\"idle\"); // idle · saving · saved · error\n const [history, setHistory] = useState({ undo: false, redo: false });\n const [tab, setTab] = useState(\"add\");\n const [sheet, setSheet] = useState(null); // add · layers · design · templates\n const saving = useRef(false);\n const outline = useRef(false);\n\n const blocks = useMemo(\n () => [...builtInBlocks(builtIns), ...(extraBlocks ?? [])].map((b, i) => ({ ...normalizeBlock(b, i), level: b.level ?? (b.category === \"Sections\" || b.id === \"section\" ? \"section\" : \"element\") })),\n [builtIns, extraBlocks],\n );\n const blockCss = useMemo(() => [BASE_CSS, ...blocks.map((b) => b.css).filter(Boolean)].join(\"\\n\"), [blocks]);\n const onChangeRef = useLatest(onChange);\n const onSaveRef = useLatest(onSave);\n\n // ---- the editor's life -----------------------------------------------------\n\n // Register (and re-register) the blocks with GrapesJS; their CSS joins\n // the page's rules (only rules a page uses are exported).\n useEffect(() => {\n if (!editor) return;\n const bm = editor.Blocks;\n bm.getAll().reset([]);\n for (const b of blocks) {\n bm.add(b.id, { label: b.label, category: b.category, content: b.content ?? b.html, select: true, activate: false });\n }\n if (ready) editor.Css.addRules(blockCss);\n }, [editor, blocks, blockCss, ready]);\n\n useEffect(() => {\n if (!editor) return;\n const bump = () => setRev((r) => r + 1);\n const sync = () => {\n const um = editor.UndoManager;\n setHistory({ undo: um.hasUndo(), redo: um.hasRedo() });\n setDirty(editor.getDirtyCount() > 0);\n setEmpty(editor.getWrapper()?.components().length === 0);\n };\n const onSelect = () => {\n setSelected(editor.getSelected() ?? null);\n bump();\n };\n let timer = null;\n const onUpdate = () => {\n bump();\n sync();\n if (!onChangeRef.current) return;\n clearTimeout(timer);\n timer = setTimeout(() => onChangeRef.current?.(readPage(editor)), 500);\n };\n // Picture double-click opens GrapesJS's asset modal by default; ours is\n // the Design panel's picture field.\n const openAssets = {\n run: () => {\n const c = editor.getSelected();\n if (rootRef.current && rootRef.current.getBoundingClientRect().width < 1024) setSheet(\"design\");\n // The Design panel may be drawn twice (the side panel, hidden on a\n // narrow screen, and the sheet): focus the one that shows.\n setTimeout(() => {\n const fields = rootRef.current?.ownerDocument.querySelectorAll(`[id=\"pb-src-${c?.cid}\"]`) ?? [];\n [...fields].find((el) => el.offsetParent !== null)?.focus();\n }, 80);\n },\n };\n editor.Commands.add(\"core:open-assets\", openAssets);\n editor.Commands.add(\"open-assets\", openAssets);\n\n const onLoad = () => {\n editor.Css.addRules(blockCss);\n const wrapper = editor.getWrapper();\n wrapper?.set({ selectable: false, hoverable: false, highlightable: false, name: \"Page\" });\n editor.runCommand(\"core:component-outline\");\n outline.current = true;\n editor.UndoManager.clear();\n editor.clearDirtyCount();\n sync();\n setReady(true);\n };\n editor.on(\"component:toggled\", onSelect);\n // \"update\" is GrapesJS's \"the page changed\" (it counts toward the dirty\n // count); hover and selection changes do not fire it.\n editor.on(\"update undo redo\", onUpdate);\n editor.on(\"component:styleUpdate component:add component:remove layer:component\", bump);\n editor.onReady(onLoad);\n return () => {\n clearTimeout(timer);\n editor.off(\"component:toggled\", onSelect);\n editor.off(\"update undo redo\", onUpdate);\n editor.off(\"component:styleUpdate component:add component:remove layer:component\", bump);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editor]);\n\n // Paint the frame in the theme: the resolved tokens, the app's fonts and\n // the frame's base. Again whenever the light/dark scheme or theme changes.\n useEffect(() => {\n if (!editor || !ready) return;\n let fonts = null;\n let raf = 0;\n const paint = () => {\n const doc = editor.Canvas.getDocument();\n if (!doc?.head) return;\n fonts ??= fontFaceCss();\n let el = doc.getElementById(\"pb-theme\");\n if (!el) {\n el = doc.createElement(\"style\");\n el.id = \"pb-theme\";\n doc.head.appendChild(el);\n }\n el.textContent = `${fonts}\\n${tokenCss(rootRef.current)}\\n${FRAME_CSS}`;\n // A link in the page never navigates the editor's frame.\n if (!doc.__pbLinks) {\n doc.__pbLinks = true;\n doc.addEventListener(\"click\", (e) => e.target?.closest?.(\"a[href]\") && e.preventDefault(), true);\n }\n };\n const later = () => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(paint);\n };\n paint();\n editor.on(\"canvas:frame:load:body\", later);\n const watch = new MutationObserver(later);\n const opts = { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\", \"data-scheme\"] };\n watch.observe(document.documentElement, opts);\n if (document.body) watch.observe(document.body, opts);\n const scoped = rootRef.current?.closest?.(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) watch.observe(scoped, opts);\n const mq = typeof matchMedia !== \"undefined\" ? matchMedia(\"(prefers-color-scheme: dark)\") : null;\n mq?.addEventListener?.(\"change\", later);\n return () => {\n cancelAnimationFrame(raf);\n editor.off(\"canvas:frame:load:body\", later);\n watch.disconnect();\n mq?.removeEventListener?.(\"change\", later);\n };\n }, [editor, ready, rootRef]);\n\n // The canvas follows its box (panels folding, preview, the window).\n useEffect(() => {\n if (!editor || !ready) return;\n const box = editor.Canvas.getElement()?.parentElement;\n if (!box || typeof ResizeObserver === \"undefined\") return;\n let raf = 0;\n const ro = new ResizeObserver(() => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => editor.refresh());\n });\n ro.observe(box);\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [editor, ready]);\n\n // ---- actions ---------------------------------------------------------------------\n\n const select = useCallback(\n (c) => {\n if (!editor || !c) return;\n editor.select(c);\n editor.Canvas.scrollTo(c, { behavior: \"smooth\", block: \"center\" });\n },\n [editor],\n );\n\n const addBlock = useCallback(\n (block) => {\n if (!editor) return;\n const content = block.content ?? block.html;\n const wrapper = editor.getWrapper();\n const sel = editor.getSelected();\n let target = wrapper;\n let at;\n if (sel && sel !== wrapper) {\n if (block.level === \"section\") {\n at = topLevel(editor, sel).index() + 1;\n } else if (!sel.is(\"text\") && !sel.is(\"link\") && !sel.is(\"image\") && sel.get(\"droppable\") !== false) {\n target = sel;\n } else {\n target = sel.parent() ?? wrapper;\n at = sel.index() + 1;\n }\n }\n const added = target.append(content, at === undefined ? {} : { at });\n setStartBlank(true);\n setSheet(null);\n if (added?.[0]) setTimeout(() => select(added[0]), 30);\n },\n [editor, select],\n );\n\n const applyTemplate = useCallback(\n (template, { quiet = false } = {}) => {\n if (!editor) return;\n const before = editor.getProjectData();\n const wasEmpty = editor.getWrapper()?.components().length === 0;\n const next = templatePage(template);\n if (next?.project) editor.loadProjectData(next.project);\n else {\n editor.setComponents(cleanHtml(next?.html ?? \"\"));\n if (next?.css) editor.Css.addRules(cleanCss(next.css));\n }\n editor.Css.addRules(blockCss);\n editor.UndoManager.clear();\n editor.select();\n setSheet(null);\n setStartBlank(true);\n setRev((r) => r + 1);\n setEmpty(editor.getWrapper()?.components().length === 0);\n if (!wasEmpty && !quiet) {\n toast(`Started from “${template.label}”`, {\n description: \"Your previous page was replaced.\",\n action: {\n label: \"Undo\",\n onClick: () => {\n editor.loadProjectData(before);\n editor.Css.addRules(blockCss);\n setRev((r) => r + 1);\n },\n },\n });\n }\n },\n [editor, blockCss],\n );\n\n const act = useCallback(\n (what) => {\n const c = editor?.getSelected();\n if (!c) return;\n const parent = c.parent();\n const i = c.index();\n if (what === \"parent\" && parent && parent !== editor.getWrapper()) select(parent);\n else if ((what === \"up\" || what === \"down\") && parent) {\n if (what === \"up\" ? i <= 0 : i >= parent.components().length - 1) return;\n // move() takes the slot to insert before, counted before the move.\n c.move(parent, { at: what === \"up\" ? i - 1 : i + 2 });\n select(c);\n } else if (what === \"duplicate\" && parent) {\n const copy = c.clone();\n parent.append(copy, { at: i + 1 });\n select(copy);\n } else if (what === \"delete\") {\n const name = nameOf(editor, c);\n c.remove();\n editor.select();\n setSheet((s) => (s === \"design\" ? null : s));\n toast(`${name} deleted`, { action: { label: \"Undo\", onClick: () => editor.UndoManager.undo() } });\n }\n },\n [editor, select],\n );\n\n const save = useCallback(async () => {\n if (!editor || saving.current) return { ok: false };\n saving.current = true;\n setStatus(\"saving\");\n const snapshot = editor.getDirtyCount();\n let res;\n try {\n res = onSaveRef.current ? await onSaveRef.current(readPage(editor)) : { ok: true };\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n saving.current = false;\n if (res?.ok) {\n // Edits made while saving stay unsaved.\n if (editor.getDirtyCount() === snapshot) editor.clearDirtyCount();\n setDirty(editor.getDirtyCount() > 0);\n setStatus(\"saved\");\n } else {\n setStatus(\"error\");\n toast.error(\"The page was not saved\", {\n description: res?.error ?? \"Your changes are still here. Try again in a moment.\",\n action: { label: \"Try again\", onClick: () => saveRef.current?.() },\n });\n }\n return res ?? { ok: false };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editor]);\n const saveRef = useLatest(save);\n\n const pickDevice = useCallback(\n (id) => {\n if (!id || !editor) return;\n editor.Devices.select(id);\n setDevice(id);\n },\n [editor],\n );\n\n const togglePreview = useCallback(\n (on) => {\n if (!editor) return;\n if (on) {\n editor.select();\n if (editor.Commands.isActive(\"core:component-outline\")) editor.stopCommand(\"core:component-outline\");\n editor.getModel().stopDefault();\n } else {\n editor.getModel().runDefault();\n if (outline.current) editor.runCommand(\"core:component-outline\");\n }\n setSheet(null);\n setPreview(on);\n },\n [editor],\n );\n\n // ⌘/Ctrl+S saves; Esc leaves preview. Undo, redo and delete are\n // GrapesJS's own keymaps (⌘Z, ⇧⌘Z, Backspace), live in the frame too.\n useEffect(() => {\n if (!editor) return;\n const onKey = (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"s\") {\n e.preventDefault();\n saveRef.current?.();\n } else if (e.key === \"Escape\" && preview) togglePreview(false);\n };\n const frameWin = () => editor.Canvas.getWindow();\n document.addEventListener(\"keydown\", onKey);\n let fw = null;\n const attach = () => {\n fw?.removeEventListener(\"keydown\", onKey);\n fw = frameWin();\n fw?.addEventListener(\"keydown\", onKey);\n };\n if (ready) attach();\n return () => {\n document.removeEventListener(\"keydown\", onKey);\n fw?.removeEventListener(\"keydown\", onKey);\n };\n }, [editor, ready, preview, togglePreview, saveRef]);\n\n // The handle.\n useEffect(() => {\n if (!builderRef) return;\n builderRef.current = editor\n ? {\n editor,\n getPage: () => readPage(editor),\n save: () => saveRef.current?.(),\n addBlock: (id) => {\n const b = blocks.find((x) => x.id === id);\n if (b) addBlock(b);\n return Boolean(b);\n },\n loadTemplate: (t) => {\n const tpl = typeof t === \"string\" ? templates.find((x) => x.id === t) : t;\n if (tpl) applyTemplate(tpl);\n return Boolean(tpl);\n },\n setPage: (p) => applyTemplate(p ?? {}, { quiet: true }),\n undo: () => editor.UndoManager.undo(),\n redo: () => editor.UndoManager.redo(),\n setDevice: pickDevice,\n preview: (on = true) => togglePreview(on),\n }\n : null;\n }, [builderRef, editor, blocks, templates, addBlock, applyTemplate, pickDevice, togglePreview, saveRef]);\n\n // ---- render ------------------------------------------------------------------------\n\n const showEmpty = ready && empty && !startBlank && !dragging && !preview;\n const statusText =\n status === \"saving\" ? \"Saving…\" : status === \"error\" ? \"Not saved\" : dirty ? \"Unsaved changes\" : status === \"saved\" ? \"All changes saved\" : \"No changes yet\";\n\n const addPanel = (\n <AddPanel editor={editor} blocks={blocks} templates={templates} onAdd={addBlock} onDragging={setDragging} onTemplates={() => setSheet(\"templates\")} />\n );\n const layersPanel = <LayersPanel editor={editor} selected={selected} rev={rev} onSelect={select} />;\n const designPanel = <DesignPanel editor={editor} selected={selected} rev={rev} groups={styles} onUploadImage={onUploadImage} onAction={act} />;\n\n const devices = (\n <ToggleGroup\n aria-label=\"Preview width\"\n size=\"sm\"\n value={device}\n onChange={pickDevice}\n items={DEVICES.map((d) => ({ value: d.id, label: d.label, icon: d.icon, iconOnly: true }))}\n />\n );\n\n return (\n <>\n {/* top bar */}\n <header className=\"flex h-14 shrink-0 items-center gap-2 border-b border-border px-3 @3xl:px-4\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium leading-tight\">{title}</p>\n <p className={cx(\"truncate text-xs leading-tight\", status === \"error\" ? \"text-destructive\" : \"text-muted-foreground\")} aria-live=\"polite\">\n {statusText}\n </p>\n </div>\n <div className=\"hidden @3xl:block\">{devices}</div>\n <div className=\"flex items-center gap-0.5\">\n <Tooltip label=\"Undo (⌘Z)\">\n <IconButton icon=\"undo\" label=\"Undo\" size=\"sm\" disabled={!history.undo || preview} onClick={() => editor?.UndoManager.undo()} />\n </Tooltip>\n <Tooltip label=\"Redo (⇧⌘Z)\">\n <IconButton icon=\"redo\" label=\"Redo\" size=\"sm\" disabled={!history.redo || preview} onClick={() => editor?.UndoManager.redo()} />\n </Tooltip>\n </div>\n <span className=\"hidden @xl:block\">\n <Button variant={preview ? \"primary\" : \"secondary\"} size=\"sm\" icon={preview ? \"eye-off\" : \"eye\"} onClick={() => togglePreview(!preview)} aria-pressed={preview}>\n {preview ? \"Exit preview\" : \"Preview\"}\n </Button>\n </span>\n <span className=\"@3xl:hidden\">\n <Menu\n label=\"More\"\n items={[\n { type: \"radio\", label: \"Width\", value: device, onValueChange: pickDevice, options: DEVICES.map((d) => ({ value: d.id, label: d.label })) },\n { type: \"separator\" },\n { label: preview ? \"Exit preview\" : \"Preview\", icon: \"eye\", onSelect: () => togglePreview(!preview) },\n templates?.length ? { label: \"Start from a template\", icon: \"file\", onSelect: () => setSheet(\"templates\") } : null,\n ]}\n />\n </span>\n {actions}\n <Button variant=\"primary\" size=\"sm\" loading={status === \"saving\"} disabled={!ready || (!dirty && status !== \"error\")} onClick={save}>\n {saveLabel}\n </Button>\n </header>\n\n <div className=\"relative flex min-h-0 flex-1\">\n {/* left: add + layers */}\n {!preview ? (\n <aside aria-label=\"Blocks and layers\" className=\"hidden w-72 shrink-0 flex-col border-r border-border bg-background @5xl:flex\">\n <Tabs\n aria-label=\"Panel\"\n tabs={[\n { key: \"add\", label: \"Add\" },\n { key: \"layers\", label: \"Layers\" },\n ]}\n value={tab}\n onChange={setTab}\n className=\"shrink-0 px-2\"\n />\n <TabPanel tab=\"add\" value={tab} className=\"flex min-h-0 flex-1 flex-col\">\n {addPanel}\n </TabPanel>\n <TabPanel tab=\"layers\" value={tab} className=\"min-h-0 flex-1 overflow-y-auto\">\n {layersPanel}\n </TabPanel>\n </aside>\n ) : null}\n\n {/* the canvas */}\n <div className=\"relative min-w-0 flex-1 bg-muted\">\n <Canvas className=\"absolute inset-0\" />\n {!ready ? (\n <div className=\"absolute inset-0 grid place-items-center bg-muted p-6\" aria-hidden=\"true\">\n <div className=\"grid w-full max-w-xl gap-3\">\n <Skeleton className=\"mx-auto h-4 w-40\" />\n <Skeleton className=\"mx-auto h-8 w-4/5\" />\n <Skeleton className=\"mx-auto h-4 w-3/5\" />\n </div>\n </div>\n ) : null}\n {showEmpty ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center overflow-y-auto p-4\">\n <div className=\"pointer-events-auto grid w-full max-w-2xl gap-5 rounded-xl border border-border bg-popover p-5 text-popover-foreground shadow-sm @3xl:p-6\">\n <div className=\"grid gap-1\">\n <p className=\"text-lg font-semibold\">Start your page</p>\n <p className=\"text-sm text-muted-foreground\">\n {templates?.length ? \"Pick a template to begin with, or start blank and add sections one at a time.\" : \"Add a section from the Add panel, or drag one onto the page.\"}\n </p>\n </div>\n {templates?.length ? <TemplateGrid templates={templates} onPick={applyTemplate} compact /> : null}\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button\n variant={templates?.length ? \"secondary\" : \"primary\"}\n size=\"sm\"\n icon=\"plus\"\n onClick={() => {\n setStartBlank(true);\n setTab(\"add\");\n if (rootRef.current && rootRef.current.getBoundingClientRect().width < 1024) setSheet(\"add\");\n }}\n >\n {templates?.length ? \"Start blank\" : \"Add a section\"}\n </Button>\n </div>\n </div>\n </div>\n ) : null}\n {preview ? (\n <div className=\"pointer-events-none absolute inset-x-0 bottom-4 flex justify-center\">\n <Button variant=\"primary\" size=\"sm\" icon=\"eye-off\" onClick={() => togglePreview(false)} className=\"pointer-events-auto shadow-md\">\n Exit preview · Esc\n </Button>\n </div>\n ) : null}\n </div>\n\n {/* right: design */}\n {!preview ? (\n <aside aria-label=\"Design\" className=\"hidden w-72 shrink-0 flex-col border-l border-border bg-background @5xl:flex\">\n <div className=\"flex h-11 shrink-0 items-center border-b border-border px-4\">\n <h2 className=\"text-sm font-medium\">Design</h2>\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto pt-4\">{designPanel}</div>\n </aside>\n ) : null}\n </div>\n\n {/* narrow: a bottom bar and sheets */}\n {!preview ? (\n <nav aria-label=\"Editor panels\" className=\"grid shrink-0 grid-cols-3 border-t border-border bg-background @5xl:hidden\">\n <Button variant=\"quiet\" icon=\"plus\" onClick={() => setSheet(\"add\")}>\n Add\n </Button>\n <Button variant=\"quiet\" icon=\"layers\" onClick={() => setSheet(\"layers\")}>\n Layers\n </Button>\n <Button variant=\"quiet\" icon=\"palette\" onClick={() => setSheet(\"design\")} disabled={!ready}>\n <span className=\"truncate\">{selected ? `Design · ${nameOf(editor, selected)}` : \"Design\"}</span>\n </Button>\n </nav>\n ) : null}\n\n <Sheet open={sheet === \"add\"} onClose={() => setSheet(null)} title=\"Add a block\" description=\"Tap a block to add it below the selection.\">\n <div className=\"-mx-4 -my-4 flex min-h-[60vh] flex-col pt-3\">{sheet === \"add\" ? addPanel : null}</div>\n </Sheet>\n <Sheet open={sheet === \"layers\"} onClose={() => setSheet(null)} title=\"Layers\">\n <div className=\"-mx-2\">{sheet === \"layers\" ? layersPanel : null}</div>\n </Sheet>\n <Sheet open={sheet === \"design\"} onClose={() => setSheet(null)} title=\"Design\">\n <div className=\"-mx-4\">{sheet === \"design\" ? designPanel : null}</div>\n </Sheet>\n <Sheet open={sheet === \"templates\"} onClose={() => setSheet(null)} side=\"center\" title=\"Start from a template\" description=\"A template replaces the page. You can undo it right after.\">\n {sheet === \"templates\" ? <TemplateGrid templates={templates ?? []} onPick={applyTemplate} /> : null}\n </Sheet>\n\n {/* GrapesJS needs the blocks flagged custom before it is created. */}\n <BlocksProvider>{() => <></>}</BlocksProvider>\n </>\n );\n}\n"
26
26
  },
27
27
  {
28
28
  "path": "registry/blocks/page-builder/panels.jsx",
@@ -34,7 +34,7 @@
34
34
  "path": "registry/blocks/page-builder/page-view.jsx",
35
35
  "type": "registry:component",
36
36
  "target": "@components/makersclaw/blocks/page-builder/page-view.jsx",
37
- "content": "// PageView — shows a page the builder made, inside an app, in the theme.\n// Server-safe (no hooks): a landing page can render on the server.\n//\n// The page's CSS names theme tokens (var(--color-…)); inside the app they\n// resolve to the workspace theme, so the page repaints with it. KEEP_TOKENS\n// on the wrapper makes sure Tailwind emits every token the CSS may name.\n// The HTML is cleaned (no scripts, handlers or javascript: links) and the\n// CSS loses any body / html / * rule before either reaches the page.\nimport { cx } from \"../../ui\";\nimport { cleanCss, cleanHtml, pageDocument } from \"./page\";\nimport { KEEP_TOKENS, fontFaceCss, fontLinks, tokenCss } from \"./tokens\";\n\nexport function PageView({ page, label, className }) {\n const html = cleanHtml(page?.html);\n const css = cleanCss(page?.css);\n return (\n <div\n role={label ? \"region\" : undefined}\n aria-label={label || undefined}\n className={cx(\"bg-background font-sans text-[16px] leading-[1.55] text-foreground [&_img]:max-w-full\", KEEP_TOKENS, className)}\n >\n {css ? <style>{css}</style> : null}\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </div>\n );\n}\n\n/** Browser only: the page as a standalone HTML document with the theme\n * resolved where `root` sits (colours, faces, radius) — for a download or\n * a static host that does not load the app's stylesheet. */\nexport function standaloneDocument(page, { root, title = \"Page\", lang = \"en\" } = {}) {\n return pageDocument(page, { title, lang, tokens: tokenCss(root), fonts: fontLinks(), fontFaces: fontFaceCss() });\n}\n"
37
+ "content": "// PageView — shows a page the builder made, inside an app, in the theme.\n// Server-safe (no hooks): a landing page can render on the server.\n//\n// The page's CSS names theme tokens (var(--color-…)); inside the app they\n// resolve to the workspace theme, so the page repaints with it. KEEP_TOKENS\n// on the wrapper makes sure Tailwind emits every token the CSS may name.\n// The HTML is cleaned (no scripts, handlers or javascript: links) and the\n// CSS loses any body / html / * rule before either reaches the page.\nimport { cx } from \"../../ui\";\nimport { cleanCss, cleanHtml, pageDocument } from \"./page\";\nimport { KEEP_TOKENS, fontFaceCss, fontLinks, tokenCss } from \"./tokens\";\n\nexport function PageView({ page, label, className }) {\n const html = cleanHtml(page?.html);\n const css = cleanCss(page?.css);\n return (\n <div\n role={label ? \"region\" : undefined}\n aria-label={label || undefined}\n className={cx(\"bg-background font-sans text-[16px] leading-[1.55] text-foreground [&_img]:max-w-full\", KEEP_TOKENS, className)}\n >\n {css ? <style>{css}</style> : null}\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </div>\n );\n}\n\n/** Browser only: the page as a standalone HTML document with the theme\n * resolved where `root` sits (colours, faces, radius) — for a download or\n * a plain file host that does not load the app's stylesheet. */\nexport function standaloneDocument(page, { root, title = \"Page\", lang = \"en\" } = {}) {\n return pageDocument(page, { title, lang, tokens: tokenCss(root), fonts: fontLinks(), fontFaces: fontFaceCss() });\n}\n"
38
38
  },
39
39
  {
40
40
  "path": "registry/blocks/page-builder/thumbs.jsx",
@@ -70,13 +70,13 @@
70
70
  "path": "registry/blocks/page-builder/page.js",
71
71
  "type": "registry:component",
72
72
  "target": "@components/makersclaw/blocks/page-builder/page.js",
73
- "content": "// Page helpers — plain functions of plain data, server-safe.\n//\n// A page, as the builder hands it out (onSave, onChange, the handle):\n// { html, css, project }\n// html the body's content, cleaned (no <script>, no on* handlers, no\n// javascript: links)\n// css the rules the page uses, token-based (var(--color-…)); no body\n// or * rules, so it can sit inside an app page\n// project GrapesJS project JSON — what to store to edit the page again\n// Pass `project` back as `page` to reopen it; `html`/`css` alone also open\n// (as a fresh project), which is how a template or an import starts.\n\n/** The base the page's content sits on: the theme's canvas, text and face.\n * PageView gives the same with Tailwind classes; pageDocument() writes it. */\nexport const PAGE_BASE_CSS =\n \"body{margin:0;background-color:var(--color-background);color:var(--color-foreground);\" +\n \"font-family:var(--theme-font-sans);font-size:16px;line-height:1.55;-webkit-font-smoothing:antialiased}\" +\n \"*,*::before,*::after{box-sizing:border-box}img{max-width:100%}\";\n\n/** A page with nothing in it. */\nexport function emptyPage() {\n return { html: \"\", css: \"\", project: null };\n}\n\n/** True when the page has no content: no project components, no html. */\nexport function isPageEmpty(page) {\n if (!page) return true;\n const frames = page.project?.pages?.[0]?.frames;\n if (frames?.length) {\n const root = frames[0]?.component;\n const kids = root?.components;\n if (Array.isArray(kids) && kids.length) return false;\n if (typeof kids === \"string\" && kids.trim()) return false;\n return !textOf(page.html);\n }\n return !textOf(page.html) && !/<(img|hr|video|iframe)\\b/i.test(page.html ?? \"\");\n}\n\nfunction textOf(html) {\n return String(html ?? \"\")\n .replace(/<style[\\s\\S]*?<\\/style>/gi, \"\")\n .replace(/<[^>]*>/g, \"\")\n .replace(/&nbsp;/g, \" \")\n .trim();\n}\n\n/** Remove what a page must never carry into an app: scripts, inline event\n * handlers, javascript: URLs. The editor already emits none; this is for\n * HTML that came from elsewhere (an import, an agent). */\nexport function cleanHtml(html) {\n return String(html ?? \"\")\n .replace(/<script\\b[\\s\\S]*?<\\/script\\s*>/gi, \"\")\n .replace(/<script\\b[^>]*\\/?>/gi, \"\")\n .replace(/\\s+on[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi, \"\")\n .replace(/(href|src)\\s*=\\s*([\"'])\\s*javascript:[^\"']*\\2/gi, '$1=\"#\"')\n .replace(/^\\s*<body[^>]*>/i, \"\")\n .replace(/<\\/body>\\s*$/i, \"\")\n .trim();\n}\n\n/** Drop the rules that would restyle a host page (body, html, *), keep the\n * rest. */\nexport function cleanCss(css) {\n return String(css ?? \"\")\n .replace(/(?:^|(?<=[{}]))\\s*(?:html|body|\\*)(?:\\s*,\\s*(?:html|body|\\*))*\\s*\\{[^{}]*\\}/g, \"\")\n .trim();\n}\n\n/** A block as the builder uses it: every field filled. */\nexport function normalizeBlock(block, i = 0) {\n const id = String(block?.id ?? `block-${i + 1}`);\n return {\n id,\n label: block?.label ?? id,\n category: block?.category ?? \"Sections\",\n description: block?.description ?? \"\",\n thumb: block?.thumb ?? \"blank\",\n html: block?.html ?? \"\",\n content: block?.content ?? null,\n css: block?.css ?? \"\",\n };\n}\n\n/** A template as the builder loads it: { html, css } or { project }. */\nexport function templatePage(template) {\n if (!template) return null;\n if (template.project) return { project: template.project };\n return { html: template.html ?? \"\", css: template.css ?? \"\" };\n}\n\nconst escapeText = (s) => String(s ?? \"\").replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" })[c]);\n\n/** A standalone HTML document for the page — for a download, an email\n * preview, a static host. `tokens` is the `:root{…}` rule tokenCss()\n * returns in the browser (the theme, resolved); without it the page reads\n * the variables from whatever stylesheet the host adds. */\nexport function pageDocument(page, { title = \"Page\", lang = \"en\", tokens = \"\", fonts = [], fontFaces = \"\" } = {}) {\n const links = fonts.map((href) => `<link rel=\"stylesheet\" href=\"${escapeText(href)}\">`).join(\"\");\n return (\n `<!doctype html><html lang=\"${escapeText(lang)}\"><head><meta charset=\"utf-8\">` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">` +\n `<title>${escapeText(title)}</title>${links}` +\n `<style>${fontFaces}\\n${tokens}\\n${PAGE_BASE_CSS}\\n${cleanCss(page?.css)}</style>` +\n `</head><body>${cleanHtml(page?.html)}</body></html>`\n );\n}\n\n/** Walk a project's components (depth first) — for a consumer that wants\n * to read what a page holds without the editor (e.g. list its sections). */\nexport function projectSections(project) {\n const root = project?.pages?.[0]?.frames?.[0]?.component;\n const kids = Array.isArray(root?.components) ? root.components : [];\n return kids.map((c, i) => ({\n index: i,\n name: c.name ?? c.attributes?.[\"data-gjs-name\"] ?? c.tagName ?? c.type ?? \"Section\",\n classes: (c.classes ?? []).map((k) => (typeof k === \"string\" ? k : k.name)).filter(Boolean),\n }));\n}\n"
73
+ "content": "// Page helpers — plain functions of plain data, server-safe.\n//\n// A page, as the builder hands it out (onSave, onChange, the handle):\n// { html, css, project }\n// html the body's content, cleaned (no <script>, no on* handlers, no\n// javascript: links)\n// css the rules the page uses, token-based (var(--color-…)); no body\n// or * rules, so it can sit inside an app page\n// project GrapesJS project JSON — what to store to edit the page again\n// Pass `project` back as `page` to reopen it; `html`/`css` alone also open\n// (as a fresh project), which is how a template or an import starts.\n\n/** The base the page's content sits on: the theme's canvas, text and face.\n * PageView gives the same with Tailwind classes; pageDocument() writes it. */\nexport const PAGE_BASE_CSS =\n \"body{margin:0;background-color:var(--color-background);color:var(--color-foreground);\" +\n \"font-family:var(--theme-font-sans);font-size:16px;line-height:1.55;-webkit-font-smoothing:antialiased}\" +\n \"*,*::before,*::after{box-sizing:border-box}img{max-width:100%}\";\n\n/** A page with nothing in it. */\nexport function emptyPage() {\n return { html: \"\", css: \"\", project: null };\n}\n\n/** True when the page has no content: no project components, no html. */\nexport function isPageEmpty(page) {\n if (!page) return true;\n const frames = page.project?.pages?.[0]?.frames;\n if (frames?.length) {\n const root = frames[0]?.component;\n const kids = root?.components;\n if (Array.isArray(kids) && kids.length) return false;\n if (typeof kids === \"string\" && kids.trim()) return false;\n return !textOf(page.html);\n }\n return !textOf(page.html) && !/<(img|hr|video|iframe)\\b/i.test(page.html ?? \"\");\n}\n\nfunction textOf(html) {\n return String(html ?? \"\")\n .replace(/<style[\\s\\S]*?<\\/style>/gi, \"\")\n .replace(/<[^>]*>/g, \"\")\n .replace(/&nbsp;/g, \" \")\n .trim();\n}\n\n/** Remove what a page must never carry into an app: scripts, inline event\n * handlers, javascript: URLs. The editor already emits none; this is for\n * HTML that came from elsewhere (an import, an agent). */\nexport function cleanHtml(html) {\n return String(html ?? \"\")\n .replace(/<script\\b[\\s\\S]*?<\\/script\\s*>/gi, \"\")\n .replace(/<script\\b[^>]*\\/?>/gi, \"\")\n .replace(/\\s+on[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi, \"\")\n .replace(/(href|src)\\s*=\\s*([\"'])\\s*javascript:[^\"']*\\2/gi, '$1=\"#\"')\n .replace(/^\\s*<body[^>]*>/i, \"\")\n .replace(/<\\/body>\\s*$/i, \"\")\n .trim();\n}\n\n/** Drop the rules that would restyle a host page (body, html, *), keep the\n * rest. */\nexport function cleanCss(css) {\n return String(css ?? \"\")\n .replace(/(?:^|(?<=[{}]))\\s*(?:html|body|\\*)(?:\\s*,\\s*(?:html|body|\\*))*\\s*\\{[^{}]*\\}/g, \"\")\n .trim();\n}\n\n/** A block as the builder uses it: every field filled. */\nexport function normalizeBlock(block, i = 0) {\n const id = String(block?.id ?? `block-${i + 1}`);\n return {\n id,\n label: block?.label ?? id,\n category: block?.category ?? \"Sections\",\n description: block?.description ?? \"\",\n thumb: block?.thumb ?? \"blank\",\n html: block?.html ?? \"\",\n content: block?.content ?? null,\n css: block?.css ?? \"\",\n };\n}\n\n/** A template as the builder loads it: { html, css } or { project }. */\nexport function templatePage(template) {\n if (!template) return null;\n if (template.project) return { project: template.project };\n return { html: template.html ?? \"\", css: template.css ?? \"\" };\n}\n\nconst escapeText = (s) => String(s ?? \"\").replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" })[c]);\n\n/** A standalone HTML document for the page — for a download, an email\n * preview, a plain file host. `tokens` is the `:root{…}` rule tokenCss()\n * returns in the browser (the theme, resolved); without it the page reads\n * the variables from whatever stylesheet the host adds. */\nexport function pageDocument(page, { title = \"Page\", lang = \"en\", tokens = \"\", fonts = [], fontFaces = \"\" } = {}) {\n const links = fonts.map((href) => `<link rel=\"stylesheet\" href=\"${escapeText(href)}\">`).join(\"\");\n return (\n `<!doctype html><html lang=\"${escapeText(lang)}\"><head><meta charset=\"utf-8\">` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">` +\n `<title>${escapeText(title)}</title>${links}` +\n `<style>${fontFaces}\\n${tokens}\\n${PAGE_BASE_CSS}\\n${cleanCss(page?.css)}</style>` +\n `</head><body>${cleanHtml(page?.html)}</body></html>`\n );\n}\n\n/** Walk a project's components (depth first) — for a consumer that wants\n * to read what a page holds without the editor (e.g. list its sections). */\nexport function projectSections(project) {\n const root = project?.pages?.[0]?.frames?.[0]?.component;\n const kids = Array.isArray(root?.components) ? root.components : [];\n return kids.map((c, i) => ({\n index: i,\n name: c.name ?? c.attributes?.[\"data-gjs-name\"] ?? c.tagName ?? c.type ?? \"Section\",\n classes: (c.classes ?? []).map((k) => (typeof k === \"string\" ? k : k.name)).filter(Boolean),\n }));\n}\n"
74
74
  },
75
75
  {
76
76
  "path": "registry/blocks/page-builder/README.md",
77
77
  "type": "registry:file",
78
78
  "target": "@components/makersclaw/blocks/page-builder/README.md",
79
- "content": "# PageBuilder\n\nA visual page editor in the workspace theme, calm like Webflow's canvas\nwithout its cockpit. The page sits in the middle at desktop, tablet or\nphone width. On the left, **Add** lists blocks as small wireframes —\nsections (hero, features, logos, quote, pricing, questions, call to action,\nfooter) and basics (heading, text, button, image, two columns, list,\ndivider, space). Click one to add it below the selection, or drag it onto\nthe page. **Layers** shows the page as a tree: select, fold, hide while\nediting. On the right, **Design** changes the selected element's look,\nlimited to the theme's choices: text colour, background, text size,\nweight, typeface, alignment, spacing, width, corners and border. It also\nedits a link's address or a picture's source and description, and moves,\nduplicates or deletes the element. The top bar has the save state, the\ndevice toggle, undo and redo, Preview and Save. A new page opens on a\nfriendly empty state with starter templates. On a narrow screen the\npanels move to a bottom bar and open as sheets.\n\nIt is [GrapesJS](https://github.com/GrapesJS/grapesjs) (BSD-3-Clause, the\nfree core — never the commercial Studio SDK) with its React wrapper\n[@grapesjs/react](https://github.com/GrapesJS/react) (MIT). GrapesJS does\nthe page model, the canvas, selection, drag and drop, text editing, undo\nand the export. The panels, the top bar, the empty state and the Design\nchoices are ours, built from the kit. GrapesJS's own panels, style manager,\ntrait manager, asset modal and code viewer are never created. The blocks\nbuilt on this one — `landing-builder` first — import it, never `grapesjs`\ndirectly.\n\n```jsx\nimport { PageBuilder, PageView } from \"../components/blocks/page-builder\"; // a Makersclaw app (\"./blocks/page-builder\" from components/)\nimport { PageBuilder, PageView } from \"@/components/makersclaw/blocks/page-builder\"; // a shadcn project\n```\n\n`PageBuilder` is a client component: render it from a `\"use client\"`\ncomponent. `PageView` and the helpers (`pageDocument`, `cleanHtml`,\n`cleanCss`, `isPageEmpty`, `projectSections`…) work on the server too.\n\n`makersclaw add page-builder` adds `grapesjs` and `@grapesjs/react`\n(pinned, exact) to package.json: run `npm install` after. They load with\nthe page, with no `import()`. The editor is created after the first render.\nUntil then, and on the server, the frame shows a skeleton.\n\n## The data\n\nThe builder hands out a page as three things. You get it from `onSave`,\nfrom `onChange`, and from the handle's `getPage()`:\n\n```js\n{\n html: \"<section class=\\\"pb-section pb-center\\\" …>…</section>…\", // the body's content, cleaned\n css: \".pb-section{padding:88px 24px;background-color:var(--color-background);…}#i4kd{…}\",\n project: { pages: [ … ], styles: [ … ], assets: [], … }, // GrapesJS project JSON\n}\n```\n\n- **Store `project`** (a `jsonb` column). It is what reopens the page for\n editing: pass it back as `page={{ project }}`.\n- **Show `html` + `css`** with `PageView`, or publish them. They are the\n source of truth for what visitors see. They are derived from `project`\n at save time, so store them next to it instead of recomputing them.\n- `page={{ html, css }}` also opens, as a fresh project. This is how a\n template, an import or an agent-written page starts.\n- The HTML is cleaned: no `<script>`, no `on*` handlers, no `javascript:`\n links. The CSS has no `body`, `html` or `*` rules. Only rules the page\n uses are exported.\n\n### Pages keep the theme\n\n**A page never holds a colour. It names theme tokens, so it repaints when\nthe theme changes.** Every Design choice writes a CSS variable:\n`var(--color-primary)`, `var(--color-muted-foreground)`,\n`var(--theme-font-display)`, `var(--theme-radius)`. These are the same\nvariables the app's Tailwind utilities read. The built-in blocks' CSS\n(`BASE_CSS`, `pb-` classes) uses them too. Sizes and spacing are plain\n`rem`/`px` steps.\n\n- **In the editor**, the canvas is an iframe that cannot see the app's\n stylesheet. The builder resolves each token where it sits (its light or\n dark scheme and any `[data-theme]` scope) and writes the values into the\n frame. It does this again when the theme changes. It also copies the\n app's `@font-face` rules, so the page uses the theme's typefaces.\n- **In the app**, `PageView` shows the page. The tokens resolve to the\n workspace theme. Tailwind emits a theme variable only when some source\n names it, so this block names every token a page may use\n (`KEEP_TOKENS`). Installing the block is enough.\n- **Outside the app** (a download, a static host),\n `standaloneDocument(page, { root })` returns a full HTML document with\n the tokens resolved and inlined. `pageDocument(page, { tokens })` is the\n same function for the server, if you pass it the `:root{…}` string\n yourself.\n\n## Props\n\n| Prop | Default | |\n| --- | --- | --- |\n| `page` | empty | The page to open: `{ project }` or `{ html, css }`. Read once, when the editor is created. To open another page, change the component's `key` or call the handle's `setPage`. |\n| `onSave` | — | `async (page) => ({ ok, error? })`. Called by Save and ⌘/Ctrl+S with `{ html, css, project }`. On `{ ok: false }`, the page stays unsaved and a toast shows your `error` with \"Try again\". The top bar says \"Not saved\". |\n| `onChange` | — | `(page) => void`, 500 ms after the last edit. Use it for autosave or a live preview elsewhere. |\n| `title` | `\"Untitled page\"` | The name in the top bar. |\n| `blocks` | `[]` | Your own blocks, added after the built-ins. See \"Your own sections\". |\n| `builtIns` | `true` | Which built-in blocks to offer: `true` (sections and basics), `\"sections\"`, `\"basics\"` or `false`. |\n| `templates` | `STARTER_TEMPLATES` | Whole pages offered in the empty state and in \"Start from a template\". Pass `[]` for none. |\n| `styles` | `STYLE_GROUPS` | The Design panel's choices. See \"The Design choices\". |\n| `onUploadImage` | — | `async (file) => ({ ok, url, error? })`. When given, a picture's field gets an Upload button. Without it, people paste an address. |\n| `actions` | — | Extra top-bar controls before Save, such as landing-builder's Publish. Use kit `Button`s at `size=\"sm\"`. |\n| `saveLabel` | `\"Save\"` | The Save button's words. |\n| `builderRef` | — | A ref from `usePageBuilder()`. It receives the handle. |\n| `height` | `720` | The builder's height: a number (px) or any CSS length (`\"min(760px, 88dvh)\"`). It fills its width. |\n| `className` | — | Added to the frame. |\n\n### The handle\n\n```jsx\nconst builder = usePageBuilder();\n<PageBuilder builderRef={builder} … />\nbuilder.current?.getPage(); // { html, css, project }\n```\n\n`getPage()` · `save()` (runs `onSave`, returns its result) ·\n`addBlock(id)` · `loadTemplate(idOrTemplate)` · `setPage({ html, css } |\n{ project })` · `undo()` · `redo()` · `setDevice(\"desktop\" | \"tablet\" |\n\"mobile\")` · `preview(on = true)` · `editor` (the GrapesJS editor, for what\nthis block does not wrap; changes made through it are yours to keep\ntoken-only).\n\n## Your own sections\n\nThis is how `landing-builder` builds on this block. A block is plain data:\n\n```js\nconst HERO_WAITLIST = {\n id: \"hero-waitlist\", // unique across built-ins and yours\n label: \"Waitlist hero\", // the tile's name\n category: \"Hero\", // the Add panel's group heading\n thumb: \"split\", // a wireframe from THUMB_NAMES\n description: \"Headline, one line and an email button.\", // the tile's tooltip\n level: \"section\", // \"section\": added between top-level sections ·\n // \"element\": added inside the selected box\n // (default: \"section\" when category is \"Sections\")\n html: `<section class=\"lb-hero\" data-gjs-name=\"Waitlist hero\">…</section>`,\n css: `.lb-hero{padding:96px 24px;background-color:var(--color-muted);color:var(--color-foreground)}`,\n};\n<PageBuilder blocks={[HERO_WAITLIST]} builtIns=\"basics\" templates={LANDING_TEMPLATES} />\n```\n\n- `html` is the markup, or use `content`, a GrapesJS component definition,\n in its place. `data-gjs-name` names the element in Layers. Any\n `data-gjs-*` attribute sets that GrapesJS property: for example,\n `data-gjs-draggable=\"false\"` or `data-gjs-removable=\"false\"` lock a part.\n- `css` is added to the page once, when the editor opens. **Name only\n tokens for colour** (`var(--color-…)`, from `COLOR_TOKENS`) and\n `--theme-font-*` / `--theme-radius` for faces and corners. Then your\n sections repaint with the theme. Prefix your classes (`lb-`), and reuse\n the built-in `pb-` classes (`pb-button`, `pb-h2`, `pb-lead`,\n `pb-container`…) when they fit. Add an `@media (max-width: 768px)` rule\n for the phone layout. The canvas's phone width is a real narrow frame,\n so media queries work there.\n- Several blocks may share one `css` string. The same rule added twice is\n one rule.\n\nA **template** is a whole page:\n`{ id, label, description?, thumbs?: [\"hero\", \"features\", \"cta\"], html, css? }`\nor `{ id, label, project }`. `thumbs` draws its card, up to four sections\ntall. Picking a template replaces the page. A toast offers Undo when there\nwas something to replace.\n\n`BlockThumb` and `PageThumb` draw the same wireframes elsewhere, for\nexample in a landing-builder gallery of templates.\n\n## The Design choices\n\n`STYLE_GROUPS` is a list of groups. Each group is one row of choices:\n\n```js\n{\n id: \"fill\", label: \"Background\", kind: \"swatch\" | \"segmented\" | \"select\",\n for: [\"link\", \"box\"], // which elements show it: text · link · image · box (all if left out)\n match: \"background-color\", // the property read back to show the current choice\n options: [\n { value: \"\", label: \"Default\", css: { \"background-color\": \"\" } }, // \"\" = reset: the block's own CSS applies\n { value: \"primary\", label: \"Primary\", css: { \"background-color\": \"var(--color-primary)\" } },\n ],\n}\n```\n\nPicking an option writes its `css` onto that element only (an `#id`\nrule). Any property another option of the group sets is cleared. \"Reset\"\nbeside a group removes the group's properties. \"Clear this element's\nstyle\" removes all of them. To offer fewer or different choices, pass\nyour own list, filtered from or modelled on `STYLE_GROUPS`. A swatch\noption's `value` is the token name it paints (`primary` →\n`var(--color-primary)`). `groupsFor`, `currentOption` and `patchFor` are\nthe helpers the panel uses.\n\n## Mouse, keyboard and touch\n\n- Click an element to select it. Double-click text to edit the words\n (bold, italic and link in the small toolbar). Double-click a picture to\n jump to its field. Drag the selected element's handle to move it.\n- ⌘/Ctrl+S saves. ⌘/Ctrl+Z undoes, ⇧⌘Z redoes. Backspace or Delete\n removes the selection (with Undo in a toast when done from the panel).\n Esc leaves Preview.\n- The block tiles, the layer tree (↑ ↓ to move, ← → to fold), the swatches\n (arrow keys) and every control are reachable by keyboard. A tile adds\n on Enter.\n- On a phone, tap a tile to add it. Dragging is for a mouse.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { PageBuilder, PageView } from \"../components/blocks/page-builder\";\nimport { saveRow } from \"../lib/db\";\n\nexport function LandingEditor({ row }) {\n return (\n <PageBuilder\n key={row.id}\n title={row.title}\n page={row.project ? { project: row.project } : { html: row.html, css: row.css }}\n onSave={async ({ html, css, project }) => {\n const res = await saveRow(\"pages\", row.id, { html, css, project });\n return res.ok ? { ok: true } : { ok: false, error: \"The page was not saved. Check your connection and try again.\" };\n }}\n />\n );\n}\n\n// the public page, on the server:\nexport function LandingPage({ row }) {\n return <PageView page={row} label={row.title} />;\n}\n```\n\n## What it will not do\n\n- No live multiplayer. Two people editing the same page is last save wins.\n- No free colour picker, hex field or arbitrary CSS. The Design panel\n offers the theme's choices only, by design. A block's own `css` is where\n a fixed design lives.\n- Styles apply at every width. The canvas's device toggle previews the\n page's own media queries. It does not keep per-device overrides.\n- No forms that submit, and no scripts. The HTML is cleaned of them. A\n signup form belongs to the app (a block beside `PageView`), not to the\n page.\n- No asset library. Pictures are an address, or an upload through\n `onUploadImage`.\n- No publishing, domains or SEO fields. Those belong to `landing-builder`\n and the app.\n\n## Credits\n\nGrapesJS © Artur Arseniev, BSD-3-Clause. @grapesjs/react © Artur\nArseniev, MIT. Both are used unmodified, as npm dependencies. The block\nships none of their code. GrapesJS's stylesheet is imported from the\npackage and repainted with the workspace's tokens.\n"
79
+ "content": "# PageBuilder\n\nA visual page editor in the workspace theme, calm like Webflow's canvas\nwithout its cockpit. The page sits in the middle at desktop, tablet or\nphone width. On the left, **Add** lists blocks as small wireframes —\nsections (hero, features, logos, quote, pricing, questions, call to action,\nfooter) and basics (heading, text, button, image, two columns, list,\ndivider, space). Click one to add it below the selection, or drag it onto\nthe page. **Layers** shows the page as a tree: select, fold, hide while\nediting. On the right, **Design** changes the selected element's look,\nlimited to the theme's choices: text colour, background, text size,\nweight, typeface, alignment, spacing, width, corners and border. It also\nedits a link's address or a picture's source and description, and moves,\nduplicates or deletes the element. The top bar has the save state, the\ndevice toggle, undo and redo, Preview and Save. A new page opens on a\nfriendly empty state with starter templates. On a narrow screen the\npanels move to a bottom bar and open as sheets.\n\nIt is [GrapesJS](https://github.com/GrapesJS/grapesjs) (BSD-3-Clause, the\nfree core — never the commercial Studio SDK) with its React wrapper\n[@grapesjs/react](https://github.com/GrapesJS/react) (MIT). GrapesJS does\nthe page model, the canvas, selection, drag and drop, text editing, undo\nand the export. The panels, the top bar, the empty state and the Design\nchoices are ours, built from the kit. GrapesJS's own panels, style manager,\ntrait manager, asset modal and code viewer are never created. The blocks\nbuilt on this one — `landing-builder` first — import it, never `grapesjs`\ndirectly.\n\n```jsx\nimport { PageBuilder, PageView } from \"../components/blocks/page-builder\"; // a Makersclaw app (\"./blocks/page-builder\" from components/)\nimport { PageBuilder, PageView } from \"@/components/makersclaw/blocks/page-builder\"; // a shadcn project\n```\n\n`PageBuilder` is a client component: render it from a `\"use client\"`\ncomponent. `PageView` and the helpers (`pageDocument`, `cleanHtml`,\n`cleanCss`, `isPageEmpty`, `projectSections`…) work on the server too.\n\n`makersclaw add page-builder` adds `grapesjs` and `@grapesjs/react`\n(pinned, exact) to package.json: run `npm install` after. They load with\nthe page, with no `import()`. The editor is created after the first render.\nUntil then, and on the server, the frame shows a skeleton.\n\n## The data\n\nThe builder hands out a page as three things. You get it from `onSave`,\nfrom `onChange`, and from the handle's `getPage()`:\n\n```js\n{\n html: \"<section class=\\\"pb-section pb-center\\\" …>…</section>…\", // the body's content, cleaned\n css: \".pb-section{padding:88px 24px;background-color:var(--color-background);…}#i4kd{…}\",\n project: { pages: [ … ], styles: [ … ], assets: [], … }, // GrapesJS project JSON\n}\n```\n\n- **Store `project`** (a `jsonb` column). It is what reopens the page for\n editing: pass it back as `page={{ project }}`.\n- **Show `html` + `css`** with `PageView`, or publish them. They are the\n source of truth for what visitors see. They are derived from `project`\n at save time, so store them next to it instead of recomputing them.\n- `page={{ html, css }}` also opens, as a fresh project. This is how a\n template, an import or an agent-written page starts.\n- The HTML is cleaned: no `<script>`, no `on*` handlers, no `javascript:`\n links. The CSS has no `body`, `html` or `*` rules. Only rules the page\n uses are exported.\n\n### Pages keep the theme\n\n**A page never holds a colour. It names theme tokens, so it repaints when\nthe theme changes.** Every Design choice writes a CSS variable:\n`var(--color-primary)`, `var(--color-muted-foreground)`,\n`var(--theme-font-display)`, `var(--theme-radius)`. These are the same\nvariables the app's Tailwind utilities read. The built-in blocks' CSS\n(`BASE_CSS`, `pb-` classes) uses them too. Sizes and spacing are plain\n`rem`/`px` steps.\n\n- **In the editor**, the canvas is an iframe that cannot see the app's\n stylesheet. The builder resolves each token where it sits (its light or\n dark scheme and any `[data-theme]` scope) and writes the values into the\n frame. It does this again when the theme changes. It also copies the\n app's `@font-face` rules, so the page uses the theme's typefaces.\n- **In the app**, `PageView` shows the page. The tokens resolve to the\n workspace theme. Tailwind emits a theme variable only when some source\n names it, so this block names every token a page may use\n (`KEEP_TOKENS`). Installing the block is enough.\n- **Outside the app** (a download, a plain file host),\n `standaloneDocument(page, { root })` returns a full HTML document with\n the tokens resolved and inlined. `pageDocument(page, { tokens })` is the\n same function for the server, if you pass it the `:root{…}` string\n yourself.\n\n## Props\n\n| Prop | Default | |\n| --- | --- | --- |\n| `page` | empty | The page to open: `{ project }` or `{ html, css }`. Read once, when the editor is created. To open another page, change the component's `key` or call the handle's `setPage`. |\n| `onSave` | — | `async (page) => ({ ok, error? })`. Called by Save and ⌘/Ctrl+S with `{ html, css, project }`. On `{ ok: false }`, the page stays unsaved and a toast shows your `error` with \"Try again\". The top bar says \"Not saved\". |\n| `onChange` | — | `(page) => void`, 500 ms after the last edit. Use it for autosave or a live preview elsewhere. |\n| `title` | `\"Untitled page\"` | The name in the top bar. |\n| `blocks` | `[]` | Your own blocks, added after the built-ins. See \"Your own sections\". |\n| `builtIns` | `true` | Which built-in blocks to offer: `true` (sections and basics), `\"sections\"`, `\"basics\"` or `false`. |\n| `templates` | `STARTER_TEMPLATES` | Whole pages offered in the empty state and in \"Start from a template\". Pass `[]` for none. |\n| `styles` | `STYLE_GROUPS` | The Design panel's choices. See \"The Design choices\". |\n| `onUploadImage` | — | `async (file) => ({ ok, url, error? })`. When given, a picture's field gets an Upload button. Without it, people paste an address. |\n| `actions` | — | Extra top-bar controls before Save, such as landing-builder's Publish. Use kit `Button`s at `size=\"sm\"`. |\n| `saveLabel` | `\"Save\"` | The Save button's words. |\n| `builderRef` | — | A ref from `usePageBuilder()`. It receives the handle. |\n| `height` | `720` | The builder's height: a number (px) or any CSS length (`\"min(760px, 88dvh)\"`). It fills its width. |\n| `className` | — | Added to the frame. |\n\n### The handle\n\n```jsx\nconst builder = usePageBuilder();\n<PageBuilder builderRef={builder} … />\nbuilder.current?.getPage(); // { html, css, project }\n```\n\n`getPage()` · `save()` (runs `onSave`, returns its result) ·\n`addBlock(id)` · `loadTemplate(idOrTemplate)` · `setPage({ html, css } |\n{ project })` · `undo()` · `redo()` · `setDevice(\"desktop\" | \"tablet\" |\n\"mobile\")` · `preview(on = true)` · `editor` (the GrapesJS editor, for what\nthis block does not wrap; changes made through it are yours to keep\ntoken-only).\n\n## Your own sections\n\nThis is how `landing-builder` builds on this block. A block is plain data:\n\n```js\nconst HERO_WAITLIST = {\n id: \"hero-waitlist\", // unique across built-ins and yours\n label: \"Waitlist hero\", // the tile's name\n category: \"Hero\", // the Add panel's group heading\n thumb: \"split\", // a wireframe from THUMB_NAMES\n description: \"Headline, one line and an email button.\", // the tile's tooltip\n level: \"section\", // \"section\": added between top-level sections ·\n // \"element\": added inside the selected box\n // (default: \"section\" when category is \"Sections\")\n html: `<section class=\"lb-hero\" data-gjs-name=\"Waitlist hero\">…</section>`,\n css: `.lb-hero{padding:96px 24px;background-color:var(--color-muted);color:var(--color-foreground)}`,\n};\n<PageBuilder blocks={[HERO_WAITLIST]} builtIns=\"basics\" templates={LANDING_TEMPLATES} />\n```\n\n- `html` is the markup, or use `content`, a GrapesJS component definition,\n in its place. `data-gjs-name` names the element in Layers. Any\n `data-gjs-*` attribute sets that GrapesJS property: for example,\n `data-gjs-draggable=\"false\"` or `data-gjs-removable=\"false\"` lock a part.\n- `css` is added to the page once, when the editor opens. **Name only\n tokens for colour** (`var(--color-…)`, from `COLOR_TOKENS`) and\n `--theme-font-*` / `--theme-radius` for faces and corners. Then your\n sections repaint with the theme. Prefix your classes (`lb-`), and reuse\n the built-in `pb-` classes (`pb-button`, `pb-h2`, `pb-lead`,\n `pb-container`…) when they fit. Add an `@media (max-width: 768px)` rule\n for the phone layout. The canvas's phone width is a real narrow frame,\n so media queries work there.\n- Several blocks may share one `css` string. The same rule added twice is\n one rule.\n\nA **template** is a whole page:\n`{ id, label, description?, thumbs?: [\"hero\", \"features\", \"cta\"], html, css? }`\nor `{ id, label, project }`. `thumbs` draws its card, up to four sections\ntall. Picking a template replaces the page. A toast offers Undo when there\nwas something to replace.\n\n`BlockThumb` and `PageThumb` draw the same wireframes elsewhere, for\nexample in a landing-builder gallery of templates.\n\n## The Design choices\n\n`STYLE_GROUPS` is a list of groups. Each group is one row of choices:\n\n```js\n{\n id: \"fill\", label: \"Background\", kind: \"swatch\" | \"segmented\" | \"select\",\n for: [\"link\", \"box\"], // which elements show it: text · link · image · box (all if left out)\n match: \"background-color\", // the property read back to show the current choice\n options: [\n { value: \"\", label: \"Default\", css: { \"background-color\": \"\" } }, // \"\" = reset: the block's own CSS applies\n { value: \"primary\", label: \"Primary\", css: { \"background-color\": \"var(--color-primary)\" } },\n ],\n}\n```\n\nPicking an option writes its `css` onto that element only (an `#id`\nrule). Any property another option of the group sets is cleared. \"Reset\"\nbeside a group removes the group's properties. \"Clear this element's\nstyle\" removes all of them. To offer fewer or different choices, pass\nyour own list, filtered from or modelled on `STYLE_GROUPS`. A swatch\noption's `value` is the token name it paints (`primary` →\n`var(--color-primary)`). `groupsFor`, `currentOption` and `patchFor` are\nthe helpers the panel uses.\n\n## Mouse, keyboard and touch\n\n- Click an element to select it. Double-click text to edit the words\n (bold, italic and link in the small toolbar). Double-click a picture to\n jump to its field. Drag the selected element's handle to move it.\n- ⌘/Ctrl+S saves. ⌘/Ctrl+Z undoes, ⇧⌘Z redoes. Backspace or Delete\n removes the selection (with Undo in a toast when done from the panel).\n Esc leaves Preview.\n- The block tiles, the layer tree (↑ ↓ to move, ← → to fold), the swatches\n (arrow keys) and every control are reachable by keyboard. A tile adds\n on Enter.\n- On a phone, tap a tile to add it. Dragging is for a mouse.\n\n## Example\n\n```jsx\n// surfaces/landing.jsx — client: edit one page\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { PageBuilder, PageView } from \"../components/blocks/page-builder\";\n\nexport default function LandingEditor() {\n const id = useSearchParams().get(\"row\");\n const { row, loading } = useRow({ source: \"pages\", id });\n if (loading || !row) return null;\n return (\n <PageBuilder\n key={row.id}\n title={row.title}\n page={row.project ? { project: row.project } : { html: row.html, css: row.css }}\n onSave={async ({ html, css, project }) => {\n const res = await updateRow({ source: \"pages\", id: row.id, patch: { html, css, project } });\n return res.ok ? { ok: true } : { ok: false, error: \"The page was not saved. Check your connection and try again.\" };\n }}\n />\n );\n}\n\n// The finished page, for a surface of its own that reads the row with the same useRow:\nexport function LandingPage({ row }) {\n return <PageView page={row} label={row.title} />;\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- No live multiplayer. Two people editing the same page is last save wins.\n- No free colour picker, hex field or arbitrary CSS. The Design panel\n offers the theme's choices only, by design. A block's own `css` is where\n a fixed design lives.\n- Styles apply at every width. The canvas's device toggle previews the\n page's own media queries. It does not keep per-device overrides.\n- No forms that submit, and no scripts. The HTML is cleaned of them. A\n signup form belongs to the app (a block beside `PageView`), not to the\n page.\n- No asset library. Pictures are an address, or an upload through\n `onUploadImage`.\n- No publishing, domains or SEO fields. Those belong to `landing-builder`\n and the app.\n\n## Credits\n\nGrapesJS © Artur Arseniev, BSD-3-Clause. @grapesjs/react © Artur\nArseniev, MIT. Both are used unmodified, as npm dependencies. The block\nships none of their code. GrapesJS's stylesheet is imported from the\npackage and repainted with the workspace's tokens.\n"
80
80
  }
81
81
  ],
82
82
  "categories": [
@@ -98,7 +98,7 @@
98
98
  "docs": "Installed to components/makersclaw/blocks/page-builder. Import: import { PageBuilder, usePageBuilder, readPage, DEVICES, PageView, standaloneDocument, BlockThumb, PageThumb, THUMB_NAMES, BASE_CSS, SECTION_BLOCKS, BASIC_BLOCKS, STARTER_TEMPLATES, STYLE_GROUPS, COLOR_TOKENS, groupsFor, currentOption, patchFor, tokenCss, fontFaceCss, fontLinks, PAGE_BASE_CSS, emptyPage, isPageEmpty, cleanHtml, cleanCss, normalizeBlock, templatePage, pageDocument, projectSections } from \"@/components/makersclaw/blocks/page-builder\". Props and an example: components/makersclaw/blocks/page-builder/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
99
99
  "meta": {
100
100
  "makersclaw": {
101
- "version": "0.5.2",
101
+ "version": "0.7.0",
102
102
  "useWhen": "A screen where someone builds or edits a web page visually: a landing page, a waitlist or launch page, a simple site section, an announcement page. landing-builder is built on it (section templates, publishing). Not for rich text in a document (doc-editor), an email body (newsletter-builder), a form of fields (record-form) or a freeform drawing (sketch-canvas)."
103
103
  }
104
104
  }
@@ -5,7 +5,7 @@
5
5
  "title": "PaymentMethods",
6
6
  "description": "A customer's SAVED payment methods as cards: brand, last four digits, expiry (expired and expiring soon flagged) and a Default badge; Make default, Remove behind a Confirm, and Add, which hands off to the payment provider (Paddle). Never a card-number field. Empty, loading and error states built in.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -44,7 +44,7 @@
44
44
  "docs": "Installed to components/makersclaw/blocks/payment-methods. Import: import { PaymentMethods } from \"@/components/makersclaw/blocks/payment-methods\". Props and an example: components/makersclaw/blocks/payment-methods/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
45
45
  "meta": {
46
46
  "makersclaw": {
47
- "version": "0.5.2",
47
+ "version": "0.7.0",
48
48
  "useWhen": "A billing or account page where a customer manages the cards and wallets on file. Not for taking a payment (cart's checkout step hands off to Paddle) or for a list of charges (transaction-list)."
49
49
  }
50
50
  }
@@ -5,7 +5,7 @@
5
5
  "title": "PdfDocument",
6
6
  "description": "Make PDFs in the workspace theme (@react-pdf/renderer): a small set of document primitives (headings, text, label/value fields, tables with repeating headers, totals, callouts, badges, rows and columns) and a clean ready-made quote / estimate / invoice layout, rendered in the browser to a Blob with renderPdf(doc). The theme's colours are read from the page when the PDF is made (a PDF cannot read CSS variables): white paper, near-black ink and the theme's primary as the one accent. Also a live-preview hook (usePdf), a Download PDF button, and the quote sums and money formatting as plain functions.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@react-pdf/renderer@4.9.0"
@@ -72,7 +72,7 @@
72
72
  "docs": "Installed to components/makersclaw/blocks/pdf-document. Import: import { renderPdf, usePdf, PdfDownloadButton, downloadBlob, registerPdfFont, PdfDocument, PdfStack, PdfRow, PdfCol, PdfText, PdfHeading, PdfLabel, PdfField, PdfDivider, PdfCallout, PdfBadge, PdfTable, PdfTotals, PdfLines, PdfView, PdfRawText, PdfImage, PdfLink, PdfThemeProvider, usePdfTheme, PDF_TYPE, QuoteDocument, quoteTotals, lineAmount, round2, formatMoney, formatQuantity, formatPdfDate, resolvePdfTheme, buildPdfTheme, DEFAULT_PDF_THEME } from \"@/components/makersclaw/blocks/pdf-document\". Props and an example: components/makersclaw/blocks/pdf-document/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
73
73
  "meta": {
74
74
  "makersclaw": {
75
- "version": "0.5.2",
75
+ "version": "0.7.0",
76
76
  "useWhen": "A screen produces a document someone downloads, prints, emails or signs: a quote, an invoice, an estimate, a receipt, a statement, a certificate, a one-page report. The quote-builder and proposal blocks print through it. Not for showing an existing PDF on screen (pdf-viewer) or for a web page people read in the app (doc-page)."
77
77
  }
78
78
  }