makersclaw 0.7.0 → 0.8.1
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.
- package/README.md +17 -4
- package/package.json +1 -1
- package/r/activity-log.json +52 -0
- package/r/agent-changes.json +51 -0
- package/r/agent-chat.json +53 -0
- package/r/agent-column.json +85 -0
- package/r/agent-dock.json +57 -0
- package/r/agent-kit.json +49 -0
- package/r/agent-marks.json +49 -0
- package/r/agent-pane.json +93 -0
- package/r/ai-action.json +56 -0
- package/r/annotate.json +4 -4
- package/r/approval-request.json +2 -2
- package/r/approval.json +54 -0
- package/r/ask-bar.json +50 -0
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/automation-editor.json +66 -0
- package/r/automation-list.json +57 -0
- package/r/automation-model.json +118 -0
- package/r/automations-screen.json +71 -0
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +5 -5
- package/r/chat-pane.json +84 -0
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/connect-card.json +51 -0
- package/r/contact-section.json +2 -2
- package/r/core.json +18 -6
- package/r/cost.json +51 -0
- package/r/csv-import.json +3 -3
- package/r/data-picker.json +60 -0
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/first-run.json +63 -0
- package/r/fix-note.json +57 -0
- package/r/flow-canvas.json +3 -3
- package/r/flow-editor.json +79 -0
- package/r/footer.json +3 -3
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/library-shelf.json +56 -0
- package/r/map.json +2 -2
- package/r/margin-notes.json +67 -0
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/needs-you.json +55 -0
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/pace-bar.json +69 -0
- package/r/page-builder.json +3 -3
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-grid.json +52 -0
- package/r/record-notes.json +4 -4
- package/r/registry.json +4089 -1510
- package/r/run-history.json +52 -0
- package/r/run-view.json +80 -0
- package/r/schedule-card.json +54 -0
- package/r/schedule-engine.json +79 -0
- package/r/schedule-gantt.json +83 -0
- package/r/schedule-ruler.json +64 -0
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +3 -3
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +3 -3
- package/r/source-receipt.json +52 -0
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/step-picker.json +53 -0
- package/r/step-settings.json +72 -0
- package/r/suggestion.json +58 -0
- package/r/sum-bar.json +64 -0
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/tier-map.json +67 -0
- package/r/time-grid.json +73 -0
- package/r/time-strip.json +64 -0
- package/r/top-bar.json +66 -0
- package/r/transaction-list.json +2 -2
- package/r/trigger-picker.json +61 -0
- package/r/whiteboard.json +4 -4
- package/r/workflow-view.json +55 -0
- package/registry/blocks/activity-log/README.md +112 -0
- package/registry/blocks/activity-log/activity-log.jsx +351 -0
- package/registry/blocks/activity-log/block.json +33 -0
- package/registry/blocks/activity-log/demo.jsx +48 -0
- package/registry/blocks/activity-log/index.js +4 -0
- package/registry/blocks/agent-changes/README.md +94 -0
- package/registry/blocks/agent-changes/agent-changes.jsx +118 -0
- package/registry/blocks/agent-changes/block.json +28 -0
- package/registry/blocks/agent-changes/demo.jsx +39 -0
- package/registry/blocks/agent-changes/index.js +3 -0
- package/registry/blocks/agent-chat/README.md +145 -0
- package/registry/blocks/agent-chat/agent-chat.jsx +415 -0
- package/registry/blocks/agent-chat/block.json +30 -0
- package/registry/blocks/agent-chat/demo.jsx +165 -0
- package/registry/blocks/agent-chat/index.js +3 -0
- package/registry/blocks/agent-column/README.md +197 -0
- package/registry/blocks/agent-column/agent-column.jsx +492 -0
- package/registry/blocks/agent-column/block.json +67 -0
- package/registry/blocks/agent-column/column-picker.jsx +163 -0
- package/registry/blocks/agent-column/demo.jsx +50 -0
- package/registry/blocks/agent-column/index.js +7 -0
- package/registry/blocks/agent-column/model.js +164 -0
- package/registry/blocks/agent-column/receipt.jsx +483 -0
- package/registry/blocks/agent-column/spreadsheet-type.jsx +24 -0
- package/registry/blocks/agent-dock/README.md +210 -0
- package/registry/blocks/agent-dock/agent-dock.jsx +356 -0
- package/registry/blocks/agent-dock/block.json +47 -0
- package/registry/blocks/agent-dock/demo.jsx +153 -0
- package/registry/blocks/agent-dock/dock-model.js +118 -0
- package/registry/blocks/agent-dock/index.js +5 -0
- package/registry/blocks/agent-kit/README.md +73 -0
- package/registry/blocks/agent-kit/agent-kit.jsx +86 -0
- package/registry/blocks/agent-kit/block.json +10 -0
- package/registry/blocks/agent-kit/demo.jsx +35 -0
- package/registry/blocks/agent-kit/index.js +3 -0
- package/registry/blocks/agent-marks/README.md +80 -0
- package/registry/blocks/agent-marks/agent-marks.jsx +133 -0
- package/registry/blocks/agent-marks/block.json +27 -0
- package/registry/blocks/agent-marks/demo.jsx +49 -0
- package/registry/blocks/agent-marks/index.js +3 -0
- package/registry/blocks/agent-pane/README.md +225 -0
- package/registry/blocks/agent-pane/agent-pane.jsx +244 -0
- package/registry/blocks/agent-pane/block.json +41 -0
- package/registry/blocks/agent-pane/composer.jsx +6 -0
- package/registry/blocks/agent-pane/demo.jsx +233 -0
- package/registry/blocks/agent-pane/dock-context.js +10 -0
- package/registry/blocks/agent-pane/index.js +4 -0
- package/registry/blocks/agent-pane/prose.jsx +84 -0
- package/registry/blocks/agent-pane/questions.jsx +134 -0
- package/registry/blocks/agent-pane/transcript.jsx +268 -0
- package/registry/blocks/agent-pane/work.jsx +368 -0
- package/registry/blocks/ai-action/README.md +94 -0
- package/registry/blocks/ai-action/ai-action.jsx +188 -0
- package/registry/blocks/ai-action/block.json +34 -0
- package/registry/blocks/ai-action/demo.jsx +93 -0
- package/registry/blocks/ai-action/index.js +3 -0
- package/registry/blocks/annotate/README.md +14 -11
- package/registry/blocks/approval/README.md +145 -0
- package/registry/blocks/approval/approval.jsx +163 -0
- package/registry/blocks/approval/block.json +31 -0
- package/registry/blocks/approval/demo.jsx +121 -0
- package/registry/blocks/approval/index.js +3 -0
- package/registry/blocks/ask-bar/README.md +105 -0
- package/registry/blocks/ask-bar/ask-bar.jsx +166 -0
- package/registry/blocks/ask-bar/block.json +26 -0
- package/registry/blocks/ask-bar/demo.jsx +78 -0
- package/registry/blocks/ask-bar/index.js +3 -0
- package/registry/blocks/automation-editor/README.md +291 -0
- package/registry/blocks/automation-editor/automation-editor.jsx +656 -0
- package/registry/blocks/automation-editor/block.json +39 -0
- package/registry/blocks/automation-editor/demo.jsx +284 -0
- package/registry/blocks/automation-editor/index.js +4 -0
- package/registry/blocks/automation-editor/run-inputs.jsx +82 -0
- package/registry/blocks/automation-list/README.md +129 -0
- package/registry/blocks/automation-list/automation-list.jsx +476 -0
- package/registry/blocks/automation-list/block.json +35 -0
- package/registry/blocks/automation-list/demo.jsx +67 -0
- package/registry/blocks/automation-list/index.js +3 -0
- package/registry/blocks/automation-model/README.md +109 -0
- package/registry/blocks/automation-model/block.json +135 -0
- package/registry/blocks/automation-model/catalog.js +256 -0
- package/registry/blocks/automation-model/check.js +161 -0
- package/registry/blocks/automation-model/demo.jsx +42 -0
- package/registry/blocks/automation-model/edit.js +262 -0
- package/registry/blocks/automation-model/fixes.js +287 -0
- package/registry/blocks/automation-model/glyph.jsx +13 -0
- package/registry/blocks/automation-model/graph.js +410 -0
- package/registry/blocks/automation-model/index.js +33 -0
- package/registry/blocks/automation-model/overlay.js +175 -0
- package/registry/blocks/automation-model/parts.jsx +94 -0
- package/registry/blocks/automation-model/schedule.js +53 -0
- package/registry/blocks/automation-model/templates.js +128 -0
- package/registry/blocks/automation-model/values.js +29 -0
- package/registry/blocks/automation-model/words.js +439 -0
- package/registry/blocks/automations-screen/README.md +207 -0
- package/registry/blocks/automations-screen/automations-screen.jsx +452 -0
- package/registry/blocks/automations-screen/block.json +47 -0
- package/registry/blocks/automations-screen/demo.jsx +364 -0
- package/registry/blocks/automations-screen/index.js +4 -0
- package/registry/blocks/automations-screen/screen-model.js +129 -0
- package/registry/blocks/chart/cartesian.jsx +1 -1
- package/registry/blocks/chart/core.jsx +33 -23
- package/registry/blocks/chart/pie.jsx +1 -1
- package/registry/blocks/chat/README.md +4 -1
- package/registry/blocks/chat/demo.jsx +2 -2
- package/registry/blocks/chat-pane/README.md +143 -0
- package/registry/blocks/chat-pane/block.json +48 -0
- package/registry/blocks/chat-pane/chat-attach.js +166 -0
- package/registry/blocks/chat-pane/chat-composer.jsx +195 -0
- package/registry/blocks/chat-pane/chat-context.js +17 -0
- package/registry/blocks/chat-pane/chat-model.js +43 -0
- package/registry/blocks/chat-pane/chat-pane.jsx +398 -0
- package/registry/blocks/chat-pane/chats-list.jsx +52 -0
- package/registry/blocks/chat-pane/demo.jsx +103 -0
- package/registry/blocks/chat-pane/index.js +9 -0
- package/registry/blocks/connect-card/README.md +99 -0
- package/registry/blocks/connect-card/block.json +10 -0
- package/registry/blocks/connect-card/connect-card.jsx +103 -0
- package/registry/blocks/connect-card/demo.jsx +49 -0
- package/registry/blocks/connect-card/index.js +3 -0
- package/registry/blocks/cost/README.md +115 -0
- package/registry/blocks/cost/block.json +10 -0
- package/registry/blocks/cost/cost.jsx +176 -0
- package/registry/blocks/cost/demo.jsx +66 -0
- package/registry/blocks/cost/index.js +3 -0
- package/registry/blocks/data-picker/README.md +81 -0
- package/registry/blocks/data-picker/block.json +38 -0
- package/registry/blocks/data-picker/data-picker.jsx +210 -0
- package/registry/blocks/data-picker/demo.jsx +34 -0
- package/registry/blocks/data-picker/fields.js +89 -0
- package/registry/blocks/data-picker/index.js +4 -0
- package/registry/blocks/data-table/demo.jsx +1 -1
- package/registry/blocks/first-run/README.md +118 -0
- package/registry/blocks/first-run/block.json +11 -0
- package/registry/blocks/first-run/demo.jsx +88 -0
- package/registry/blocks/first-run/first-run-model.js +51 -0
- package/registry/blocks/first-run/first-run.jsx +156 -0
- package/registry/blocks/first-run/index.js +5 -0
- package/registry/blocks/first-run/probe-rules.js +26 -0
- package/registry/blocks/fix-note/README.md +79 -0
- package/registry/blocks/fix-note/block.json +35 -0
- package/registry/blocks/fix-note/demo.jsx +30 -0
- package/registry/blocks/fix-note/fix-note.jsx +235 -0
- package/registry/blocks/fix-note/index.js +3 -0
- package/registry/blocks/flow-canvas/README.md +15 -8
- package/registry/blocks/flow-editor/README.md +108 -0
- package/registry/blocks/flow-editor/block.json +44 -0
- package/registry/blocks/flow-editor/demo.jsx +115 -0
- package/registry/blocks/flow-editor/flow-canvas.jsx +568 -0
- package/registry/blocks/flow-editor/flow-editor.jsx +154 -0
- package/registry/blocks/flow-editor/index.js +6 -0
- package/registry/blocks/flow-editor/minimap.jsx +78 -0
- package/registry/blocks/flow-editor/outline.jsx +134 -0
- package/registry/blocks/footer/README.md +3 -2
- package/registry/blocks/library-shelf/README.md +117 -0
- package/registry/blocks/library-shelf/block.json +28 -0
- package/registry/blocks/library-shelf/demo.jsx +68 -0
- package/registry/blocks/library-shelf/index.js +3 -0
- package/registry/blocks/library-shelf/library-shelf.jsx +211 -0
- package/registry/blocks/library-shelf/probe-rules.js +15 -0
- package/registry/blocks/margin-notes/README.md +222 -0
- package/registry/blocks/margin-notes/block.json +10 -0
- package/registry/blocks/margin-notes/demo.jsx +144 -0
- package/registry/blocks/margin-notes/index.js +20 -0
- package/registry/blocks/margin-notes/margin-model.js +224 -0
- package/registry/blocks/margin-notes/margin-notes.jsx +465 -0
- package/registry/blocks/mind-map/README.md +4 -2
- package/registry/blocks/needs-you/README.md +177 -0
- package/registry/blocks/needs-you/block.json +34 -0
- package/registry/blocks/needs-you/demo.jsx +143 -0
- package/registry/blocks/needs-you/index.js +3 -0
- package/registry/blocks/needs-you/needs-you.jsx +441 -0
- package/registry/blocks/org-chart/README.md +4 -2
- package/registry/blocks/pace-bar/README.md +230 -0
- package/registry/blocks/pace-bar/block.json +10 -0
- package/registry/blocks/pace-bar/demo.jsx +255 -0
- package/registry/blocks/pace-bar/index.js +22 -0
- package/registry/blocks/pace-bar/pace-bar.jsx +461 -0
- package/registry/blocks/pace-bar/pace-model.js +288 -0
- package/registry/blocks/page-builder/README.md +1 -1
- package/registry/blocks/page-builder/demo.jsx +3 -3
- package/registry/blocks/pdf-viewer/README.md +4 -1
- package/registry/blocks/record-grid/README.md +100 -0
- package/registry/blocks/record-grid/block.json +28 -0
- package/registry/blocks/record-grid/demo.jsx +43 -0
- package/registry/blocks/record-grid/index.js +3 -0
- package/registry/blocks/record-grid/record-grid.jsx +251 -0
- package/registry/blocks/run-history/README.md +138 -0
- package/registry/blocks/run-history/block.json +10 -0
- package/registry/blocks/run-history/demo.jsx +74 -0
- package/registry/blocks/run-history/index.js +3 -0
- package/registry/blocks/run-history/run-history.jsx +218 -0
- package/registry/blocks/run-view/README.md +174 -0
- package/registry/blocks/run-view/block.json +50 -0
- package/registry/blocks/run-view/demo.jsx +286 -0
- package/registry/blocks/run-view/index.js +5 -0
- package/registry/blocks/run-view/run-controls.jsx +266 -0
- package/registry/blocks/run-view/run-view.jsx +236 -0
- package/registry/blocks/run-view/runs-list.jsx +99 -0
- package/registry/blocks/schedule-card/README.md +121 -0
- package/registry/blocks/schedule-card/block.json +10 -0
- package/registry/blocks/schedule-card/demo.jsx +82 -0
- package/registry/blocks/schedule-card/index.js +3 -0
- package/registry/blocks/schedule-card/schedule-card.jsx +136 -0
- package/registry/blocks/schedule-engine/README.md +137 -0
- package/registry/blocks/schedule-engine/block.json +10 -0
- package/registry/blocks/schedule-engine/demo.jsx +82 -0
- package/registry/blocks/schedule-engine/engine.js +769 -0
- package/registry/blocks/schedule-engine/index.js +9 -0
- package/registry/blocks/schedule-engine/ops.js +71 -0
- package/registry/blocks/schedule-engine/place.js +294 -0
- package/registry/blocks/schedule-engine/tree.js +79 -0
- package/registry/blocks/schedule-engine/waits.js +80 -0
- package/registry/blocks/schedule-gantt/README.md +159 -0
- package/registry/blocks/schedule-gantt/block.json +10 -0
- package/registry/blocks/schedule-gantt/demo.jsx +223 -0
- package/registry/blocks/schedule-gantt/gantt-model.js +219 -0
- package/registry/blocks/schedule-gantt/geometry.js +176 -0
- package/registry/blocks/schedule-gantt/index.js +26 -0
- package/registry/blocks/schedule-gantt/schedule-gantt.jsx +882 -0
- package/registry/blocks/schedule-gantt/sheet.jsx +372 -0
- package/registry/blocks/schedule-gantt/track.jsx +190 -0
- package/registry/blocks/schedule-ruler/README.md +148 -0
- package/registry/blocks/schedule-ruler/block.json +10 -0
- package/registry/blocks/schedule-ruler/demo.jsx +181 -0
- package/registry/blocks/schedule-ruler/index.js +21 -0
- package/registry/blocks/schedule-ruler/ruler-model.js +239 -0
- package/registry/blocks/schedule-ruler/schedule-ruler.jsx +548 -0
- package/registry/blocks/sidebar/README.md +23 -13
- package/registry/blocks/sketch-canvas/README.md +15 -9
- package/registry/blocks/source-receipt/README.md +97 -0
- package/registry/blocks/source-receipt/block.json +30 -0
- package/registry/blocks/source-receipt/demo.jsx +71 -0
- package/registry/blocks/source-receipt/index.js +3 -0
- package/registry/blocks/source-receipt/source-receipt.jsx +107 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +15 -1
- package/registry/blocks/step-picker/README.md +78 -0
- package/registry/blocks/step-picker/block.json +31 -0
- package/registry/blocks/step-picker/demo.jsx +23 -0
- package/registry/blocks/step-picker/index.js +3 -0
- package/registry/blocks/step-picker/step-picker.jsx +144 -0
- package/registry/blocks/step-settings/README.md +100 -0
- package/registry/blocks/step-settings/block.json +44 -0
- package/registry/blocks/step-settings/demo.jsx +93 -0
- package/registry/blocks/step-settings/fields.jsx +58 -0
- package/registry/blocks/step-settings/flow-settings.jsx +115 -0
- package/registry/blocks/step-settings/index.js +5 -0
- package/registry/blocks/step-settings/step-settings.jsx +290 -0
- package/registry/blocks/suggestion/README.md +171 -0
- package/registry/blocks/suggestion/block.json +40 -0
- package/registry/blocks/suggestion/demo.jsx +91 -0
- package/registry/blocks/suggestion/index.js +3 -0
- package/registry/blocks/suggestion/suggestion.jsx +436 -0
- package/registry/blocks/sum-bar/README.md +122 -0
- package/registry/blocks/sum-bar/block.json +10 -0
- package/registry/blocks/sum-bar/demo.jsx +110 -0
- package/registry/blocks/sum-bar/index.js +5 -0
- package/registry/blocks/sum-bar/sum-bar.jsx +209 -0
- package/registry/blocks/sum-bar/sum-model.js +126 -0
- package/registry/blocks/tier-map/README.md +217 -0
- package/registry/blocks/tier-map/block.json +10 -0
- package/registry/blocks/tier-map/demo.jsx +222 -0
- package/registry/blocks/tier-map/index.js +24 -0
- package/registry/blocks/tier-map/tier-map.jsx +574 -0
- package/registry/blocks/tier-map/tier-model.js +318 -0
- package/registry/blocks/time-grid/README.md +305 -0
- package/registry/blocks/time-grid/block.json +10 -0
- package/registry/blocks/time-grid/demo.jsx +279 -0
- package/registry/blocks/time-grid/grid-model.js +322 -0
- package/registry/blocks/time-grid/index.js +25 -0
- package/registry/blocks/time-grid/time-grid.jsx +1325 -0
- package/registry/blocks/time-strip/README.md +141 -0
- package/registry/blocks/time-strip/block.json +10 -0
- package/registry/blocks/time-strip/demo.jsx +129 -0
- package/registry/blocks/time-strip/index.js +19 -0
- package/registry/blocks/time-strip/strip-model.js +178 -0
- package/registry/blocks/time-strip/time-strip.jsx +260 -0
- package/registry/blocks/top-bar/README.md +149 -0
- package/registry/blocks/top-bar/block.json +35 -0
- package/registry/blocks/top-bar/demo.jsx +78 -0
- package/registry/blocks/top-bar/index.js +5 -0
- package/registry/blocks/top-bar/probe-rules.js +74 -0
- package/registry/blocks/top-bar/top-bar-model.js +152 -0
- package/registry/blocks/top-bar/top-bar.jsx +244 -0
- package/registry/blocks/trigger-picker/README.md +98 -0
- package/registry/blocks/trigger-picker/block.json +38 -0
- package/registry/blocks/trigger-picker/demo.jsx +59 -0
- package/registry/blocks/trigger-picker/index.js +3 -0
- package/registry/blocks/trigger-picker/trigger-picker.jsx +374 -0
- package/registry/blocks/whiteboard/README.md +20 -14
- package/registry/blocks/workflow-view/README.md +123 -0
- package/registry/blocks/workflow-view/block.json +10 -0
- package/registry/blocks/workflow-view/demo.jsx +90 -0
- package/registry/blocks/workflow-view/index.js +3 -0
- package/registry/blocks/workflow-view/workflow-view.jsx +203 -0
- package/src/cli.mjs +2 -2
- package/src/install.mjs +100 -22
- package/src/registry.mjs +37 -3
- package/templates/next/browser/lib/data.js +6 -1
- package/templates/next/files/README.md +909 -26
- package/templates/next/files/app/shell.jsx +54 -37
- package/templates/next/files/check/automation-format.ts +2103 -0
- package/templates/next/files/check/automation-package.ts +142 -0
- package/templates/next/files/check/run.mjs +1043 -12
- package/templates/next/files/components/ui/field.jsx +17 -4
- package/templates/next/files/components/ui/icon.jsx +13 -0
- package/templates/next/files/components/ui/index.js +3 -1
- package/templates/next/files/components/ui/layout.jsx +382 -0
- package/templates/next/files/components/ui/notice.jsx +70 -0
- package/templates/next/files/components/ui/page.jsx +124 -0
- package/templates/next/files/components/ui/stat.jsx +22 -7
- package/templates/next/files/components/ui/toast.jsx +6 -3
- package/templates/next/files/lib/automations.js +660 -0
- package/templates/next/files/lib/claw.js +244 -5
- package/templates/next/files/lib/door.js +92 -0
- package/templates/next/files/lib/hooks.js +1694 -6
- package/templates/next/files/lib/platform.js +611 -14
- package/templates/next/template.json +22 -3
package/r/cart.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Cart",
|
|
6
6
|
"description": "A shopping cart: line items with picture, option, quantity and remove, a summary (subtotal, discount code, shipping, tax, total) and Checkout. step=\"checkout\" is the checkout page: contact and delivery details beside the order summary, ending at onPay, which hands off to Paddle. No card fields. Empty and loading states built in.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"docs": "Installed to components/makersclaw/blocks/cart. Import: import { Cart } from \"@/components/makersclaw/blocks/cart\". Props and an example: components/makersclaw/blocks/cart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
56
56
|
"meta": {
|
|
57
57
|
"makersclaw": {
|
|
58
|
-
"version": "0.
|
|
58
|
+
"version": "0.8.1",
|
|
59
59
|
"useWhen": "An app that sells things: the basket before paying, and the page that collects the buyer's email and address before the payment provider takes over. Not for a subscription plan picker (a pricing table) or for an order already placed (order-detail)."
|
|
60
60
|
}
|
|
61
61
|
}
|
package/r/chart.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Chart",
|
|
6
6
|
"description": "Line, area (stacked), bar (grouped or stacked, columns or bars, value labels at the ends, a track behind), a muted or dashed series for a previous period to compare against, pie and donut charts, sparklines and heatmaps (a grid shaded by value, with a scale legend), drawn in SVG (the heatmap as an HTML table) with the workspace's chart colours: round ticks, gridlines, a tooltip and crosshair under the pointer or the arrow keys, a legend that hides a series, and a data table for screen readers.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
"path": "registry/blocks/chart/core.jsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "@components/makersclaw/blocks/chart/core.jsx",
|
|
22
|
-
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Empty, Toggle, cx } from \"../../ui\";\n\n// What every chart in this block shares: the series colours as classes,\n// nice ticks, the width of the box it sits in, the reveal, the legend, the\n// tooltip box and the hidden data table a screen reader reads. The charts\n// themselves are in cartesian.jsx, pie.jsx and sparkline.jsx.\n\n// ---- colours ---------------------------------------------------------------\n// Written out whole so Tailwind finds every class. chart-1…5 are the series\n// colours (app/globals.css); the rest are the theme's own tones, for the\n// one series that means a status (churn in the destructive tone).\nconst TONES = {\n \"chart-1\": { fill: \"fill-chart-1\", stroke: \"stroke-chart-1\", bg: \"bg-chart-1\", text: \"text-chart-1\" },\n \"chart-2\": { fill: \"fill-chart-2\", stroke: \"stroke-chart-2\", bg: \"bg-chart-2\", text: \"text-chart-2\" },\n \"chart-3\": { fill: \"fill-chart-3\", stroke: \"stroke-chart-3\", bg: \"bg-chart-3\", text: \"text-chart-3\" },\n \"chart-4\": { fill: \"fill-chart-4\", stroke: \"stroke-chart-4\", bg: \"bg-chart-4\", text: \"text-chart-4\" },\n \"chart-5\": { fill: \"fill-chart-5\", stroke: \"stroke-chart-5\", bg: \"bg-chart-5\", text: \"text-chart-5\" },\n primary: { fill: \"fill-primary\", stroke: \"stroke-primary\", bg: \"bg-primary\", text: \"text-primary\" },\n info: { fill: \"fill-info\", stroke: \"stroke-info\", bg: \"bg-info\", text: \"text-info\" },\n success: { fill: \"fill-success\", stroke: \"stroke-success\", bg: \"bg-success\", text: \"text-success\" },\n warning: { fill: \"fill-warning\", stroke: \"stroke-warning\", bg: \"bg-warning\", text: \"text-warning\" },\n danger: { fill: \"fill-destructive\", stroke: \"stroke-destructive\", bg: \"bg-destructive\", text: \"text-destructive\" },\n muted: { fill: \"fill-muted-foreground\", stroke: \"stroke-muted-foreground\", bg: \"bg-muted-foreground\", text: \"text-muted-foreground\" },\n};\n\n/** The tone a series (or a slice) wears: its own `tone` (chart-1…5, 1…5,\n * or a theme tone), else the next chart colour in order. */\nexport function toneOf(item, index) {\n const t = typeof item?.tone === \"number\" ? `chart-${item.tone}` : item?.tone;\n return TONES[t] ? t : `chart-${(index % 5) + 1}`;\n}\n\nexport function tone(t, kind) {\n return (TONES[t] ?? TONES[\"chart-1\"])[kind];\n}\n\n// ---- numbers ---------------------------------------------------------------\n\nconst COMPACT = new Intl.NumberFormat(undefined, { notation: \"compact\", maximumFractionDigits: 1 });\nconst PLAIN = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });\n\n/** The default value formatter: 1,250 · 48.2K · 1.3M. */\nexport function defaultFormat(v) {\n if (v == null || Number.isNaN(v)) return \"—\";\n return Math.abs(v) >= 10000 ? COMPACT.format(v) : PLAIN.format(v);\n}\n\n/** A value as a number, or null when there is none to draw: null,\n * undefined, an empty or blank string, or anything not a finite number.\n * null is a gap, never a zero — Number(null) and Number(\"\") are 0, and\n * until 0.5.1 a day with no data plotted as a real zero. */\nexport function num(v) {\n if (v == null || (typeof v === \"string\" && !v.trim())) return null;\n const n = typeof v === \"number\" ? v : Number(v);\n return Number.isFinite(n) ? n : null;\n}\n\n/** Points split into runs wherever one is missing (null): a line or an\n * area breaks at a gap. [p | null, …] → [[p, …], …] */\nexport function runs(points) {\n const out = [];\n let cur = [];\n for (const p of points) {\n if (p) cur.push(p);\n else if (cur.length) {\n out.push(cur);\n cur = [];\n }\n }\n if (cur.length) out.push(cur);\n return out;\n}\n\n/** Round numbers that cover [min, max] in about `count` steps. */\nexport function niceTicks(min, max, count = 5) {\n if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];\n if (min === max) {\n if (min === 0) return [0, 1];\n min = Math.min(0, min);\n max = Math.max(0, max);\n }\n const raw = (max - min) / Math.max(1, count);\n const mag = 10 ** Math.floor(Math.log10(raw));\n const err = raw / mag;\n const step = (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1) * mag;\n const lo = Math.floor(min / step) * step;\n const hi = Math.ceil(max / step) * step;\n const out = [];\n for (let v = lo; v <= hi + step / 2; v += step) out.push(Number(v.toPrecision(12)));\n return out;\n}\n\nexport function humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"\";\n}\n\n/** About how wide a label is at text-2xs, for margins and thinning. The\n * axis text is small and the estimate generous, so nothing is clipped. */\nexport function textWidth(s) {\n return String(s ?? \"\").length * 6.4 + 4;\n}\n\n/** A safe id fragment for url(#…) from React's useId. */\nexport function idFrom(uid) {\n return `c${String(uid).replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n}\n\n// ---- the box ---------------------------------------------------------------\n\n/** The width of the element, kept current by a ResizeObserver: a chart is\n * drawn for the width it has, never scaled. A callback ref, so a box that\n * comes back (after the empty state) is measured again. 0 until measured.\n *\n * const [boxRef, width] = useWidth(); <div ref={boxRef}>…</div> */\nexport function useWidth() {\n const [el, setEl] = useState(null);\n const [width, setWidth] = useState(0);\n useEffect(() => {\n if (!el) return undefined;\n setWidth(Math.floor(el.getBoundingClientRect().width));\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(Math.floor(entry.contentRect.width)));\n ro.observe(el);\n return () => ro.disconnect();\n }, [el]);\n return [setEl, width];\n}\n\n/** false for the first frame once `ready`, then true: marks draw from\n * nothing to their place. The transitions are motion-safe: classes, so\n * with reduced motion they simply appear. */\nexport function useReveal(ready) {\n const [shown, setShown] = useState(false);\n useEffect(() => {\n if (!ready || shown) return undefined;\n // A frame, so the start is painted; the timer, because a hidden tab\n // runs no frames and the chart must never stay empty.\n const done = () => setShown(true);\n const frame = requestAnimationFrame(() => requestAnimationFrame(done));\n const timer = setTimeout(done, 120);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n };\n }, [ready, shown]);\n return shown;\n}\n\n// ---- the parts every chart draws ----------------------------------------------\n\n// A square of the series' colour; a dashed series gets a short dashed line.\nfunction Swatch({ t, dim, dashed }) {\n if (dashed) {\n return (\n <svg aria-hidden=\"true\" width=\"12\" height=\"10\" viewBox=\"0 0 12 10\" className={cx(\"shrink-0\", dim && \"opacity-30\")}>\n <line x1=\"0\" y1=\"5\" x2=\"12\" y2=\"5\" strokeWidth=\"2\" strokeDasharray=\"3 2\" className={tone(t, \"stroke\")} />\n </svg>\n );\n }\n return <span aria-hidden=\"true\" className={cx(\"size-2.5 shrink-0 rounded-sm\", tone(t, \"bg\"), dim && \"opacity-30\")} />;\n}\n\n/**\n * The key to a chart's colours. With `onToggle` each entry is a toggle\n * (pressed = shown) that hides or shows its series; without it, a plain\n * list. `items` [{ key, label, tone?, value?, dashed? }] — `value` is a\n * muted note after the label (a slice's share); `dashed` draws the key as\n * a dashed line (a comparison series).\n */\nexport function ChartLegend({ items, hidden, onToggle, className, label = \"Legend\" }) {\n const list = (items ?? []).filter(Boolean);\n if (!list.length) return null;\n const off = hidden instanceof Set ? hidden : new Set(hidden ?? []);\n if (!onToggle) {\n return (\n <ul aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground\", className)}>\n {list.map((it, i) => (\n <li key={it.key} className=\"flex items-center gap-1.5\">\n <Swatch t={toneOf(it, i)} dashed={it.dashed} />\n <span className=\"text-foreground\">{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums>{it.value}</span> : null}\n </li>\n ))}\n </ul>\n );\n }\n return (\n <div role=\"group\" aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-1\", className)}>\n {list.map((it, i) => {\n const shown = !off.has(it.key);\n return (\n <Toggle\n key={it.key}\n size=\"sm\"\n variant=\"quiet\"\n pressed={shown}\n onPressedChange={() => onToggle(it.key)}\n className=\"gap-1.5 text-xs\"\n >\n <Swatch t={toneOf(it, i)} dim={!shown} dashed={it.dashed} />\n <span className={shown ? \"text-foreground\" : \"line-through\"}>{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums className=\"text-muted-foreground\">{it.value}</span> : null}\n </Toggle>\n );\n })}\n </div>\n );\n}\n\n/** The floating box that says what is under the pointer (or the keyboard).\n * `at` { x, y, flip } in the chart's pixels; flip puts it on the left. */\nexport function TooltipBox({ at, title, rows, footer }) {\n if (!at) return null;\n return (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute left-0 top-0 z-10 min-w-36 rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-lg\"\n style={{\n transform: `translate(${Math.round(at.x)}px, ${Math.round(at.y)}px) translate(${at.flip ? \"calc(-100% - 12px)\" : \"12px\"}, ${at.middle ? \"-50%\" : \"0\"})`,\n }}\n >\n {title != null ? <p className=\"mb-1.5 font-medium\">{title}</p> : null}\n <div className=\"grid gap-1\">\n {rows.map((r) => (\n <div key={r.key} className=\"flex items-center gap-2\">\n <Swatch t={r.tone} dashed={r.dashed} />\n <span className=\"flex-1 text-muted-foreground\">{r.label}</span>\n <span data-nums className=\"font-medium\">{r.value}</span>\n </div>\n ))}\n </div>\n {footer ? (\n <div className=\"mt-1.5 flex items-center justify-between gap-2 border-t border-border pt-1.5\">\n <span className=\"text-muted-foreground\">{footer.label}</span>\n <span data-nums className=\"font-medium\">{footer.value}</span>\n </div>\n ) : null}\n </div>\n );\n}\n\n/** The numbers behind the picture, for a screen reader: a real table,\n * visually hidden. `head` the column names, `body` rows of strings. */\nexport function DataTable({ caption, head, body }) {\n return (\n <table className=\"sr-only\">\n <caption>{caption}</caption>\n <thead>\n <tr>\n {head.map((h, i) => (\n <th key={i} scope=\"col\">{h}</th>\n ))}\n </tr>\n </thead>\n <tbody>\n {body.map((cells, r) => (\n <tr key={r}>\n {cells.map((c, i) =>\n i === 0 ? (\n <th key={i} scope=\"row\">{c}</th>\n ) : (\n <td key={i}>{c}</td>\n ),\n )}\n </tr>\n ))}\n </tbody>\n </table>\n );\n}\n\n/** What a chart shows with nothing to draw, at the height it would have. */\nexport function ChartEmpty({ height, title, body }) {\n return (\n <div style={{ minHeight: height }} className=\"grid\">\n <Empty icon=\"chart\" title={title} body={body} className=\"h-full content-center\" />\n </div>\n );\n}\n"
|
|
22
|
+
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Empty, Toggle, cx } from \"../../ui\";\n\n// What every chart in this block shares: the series colours as classes,\n// nice ticks, the width of the box it sits in, the reveal, the legend, the\n// tooltip box and the hidden data table a screen reader reads. The charts\n// themselves are in cartesian.jsx, pie.jsx and sparkline.jsx.\n\n// ---- colours ---------------------------------------------------------------\n// Written out whole so Tailwind finds every class. chart-1…5 are the series\n// colours (app/globals.css); the rest are the theme's own tones, for the\n// one series that means a status (churn in the destructive tone).\nconst TONES = {\n \"chart-1\": { fill: \"fill-chart-1\", stroke: \"stroke-chart-1\", bg: \"bg-chart-1\", text: \"text-chart-1\" },\n \"chart-2\": { fill: \"fill-chart-2\", stroke: \"stroke-chart-2\", bg: \"bg-chart-2\", text: \"text-chart-2\" },\n \"chart-3\": { fill: \"fill-chart-3\", stroke: \"stroke-chart-3\", bg: \"bg-chart-3\", text: \"text-chart-3\" },\n \"chart-4\": { fill: \"fill-chart-4\", stroke: \"stroke-chart-4\", bg: \"bg-chart-4\", text: \"text-chart-4\" },\n \"chart-5\": { fill: \"fill-chart-5\", stroke: \"stroke-chart-5\", bg: \"bg-chart-5\", text: \"text-chart-5\" },\n primary: { fill: \"fill-primary\", stroke: \"stroke-primary\", bg: \"bg-primary\", text: \"text-primary\" },\n info: { fill: \"fill-info\", stroke: \"stroke-info\", bg: \"bg-info\", text: \"text-info\" },\n success: { fill: \"fill-success\", stroke: \"stroke-success\", bg: \"bg-success\", text: \"text-success\" },\n warning: { fill: \"fill-warning\", stroke: \"stroke-warning\", bg: \"bg-warning\", text: \"text-warning\" },\n danger: { fill: \"fill-destructive\", stroke: \"stroke-destructive\", bg: \"bg-destructive\", text: \"text-destructive\" },\n muted: { fill: \"fill-muted-foreground\", stroke: \"stroke-muted-foreground\", bg: \"bg-muted-foreground\", text: \"text-muted-foreground\" },\n};\n\n/** The tone a series (or a slice) wears: its own `tone` (chart-1…5, 1…5,\n * or a theme tone), else the next chart colour in order. */\nexport function toneOf(item, index) {\n const t = typeof item?.tone === \"number\" ? `chart-${item.tone}` : item?.tone;\n return TONES[t] ? t : `chart-${(index % 5) + 1}`;\n}\n\nexport function tone(t, kind) {\n return (TONES[t] ?? TONES[\"chart-1\"])[kind];\n}\n\n// ---- numbers ---------------------------------------------------------------\n\nconst COMPACT = new Intl.NumberFormat(undefined, { notation: \"compact\", maximumFractionDigits: 1 });\nconst PLAIN = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });\n\n/** The default value formatter: 1,250 · 48.2K · 1.3M. */\nexport function defaultFormat(v) {\n if (v == null || Number.isNaN(v)) return \"—\";\n return Math.abs(v) >= 10000 ? COMPACT.format(v) : PLAIN.format(v);\n}\n\n/** A value as a number, or null when there is none to draw: null,\n * undefined, an empty or blank string, or anything not a finite number.\n * null is a gap, never a zero — Number(null) and Number(\"\") are 0, and\n * until 0.5.1 a day with no data plotted as a real zero. */\nexport function num(v) {\n if (v == null || (typeof v === \"string\" && !v.trim())) return null;\n const n = typeof v === \"number\" ? v : Number(v);\n return Number.isFinite(n) ? n : null;\n}\n\n/** Points split into runs wherever one is missing (null): a line or an\n * area breaks at a gap. [p | null, …] → [[p, …], …] */\nexport function runs(points) {\n const out = [];\n let cur = [];\n for (const p of points) {\n if (p) cur.push(p);\n else if (cur.length) {\n out.push(cur);\n cur = [];\n }\n }\n if (cur.length) out.push(cur);\n return out;\n}\n\n/** Round numbers that cover [min, max] in about `count` steps. */\nexport function niceTicks(min, max, count = 5) {\n if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];\n if (min === max) {\n if (min === 0) return [0, 1];\n min = Math.min(0, min);\n max = Math.max(0, max);\n }\n const raw = (max - min) / Math.max(1, count);\n const mag = 10 ** Math.floor(Math.log10(raw));\n const err = raw / mag;\n const step = (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1) * mag;\n const lo = Math.floor(min / step) * step;\n const hi = Math.ceil(max / step) * step;\n const out = [];\n for (let v = lo; v <= hi + step / 2; v += step) out.push(Number(v.toPrecision(12)));\n return out;\n}\n\nexport function humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"\";\n}\n\n/** About how wide a label is at text-2xs, for margins and thinning. The\n * axis text is small and the estimate generous, so nothing is clipped. */\nexport function textWidth(s) {\n return String(s ?? \"\").length * 6.4 + 4;\n}\n\n/** A safe id fragment for url(#…) from React's useId. */\nexport function idFrom(uid) {\n return `c${String(uid).replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n}\n\n// ---- the box ---------------------------------------------------------------\n\n/** The width of the element, kept current by a ResizeObserver: a chart is\n * drawn for the width it has, never scaled. A callback ref, so a box that\n * comes back (after the empty state) is measured again. 0 until measured.\n *\n * const [boxRef, width] = useWidth(); <div ref={boxRef}>…</div> */\nexport function useWidth() {\n const [el, setEl] = useState(null);\n const [width, setWidth] = useState(0);\n useEffect(() => {\n if (!el) return undefined;\n setWidth(Math.floor(el.getBoundingClientRect().width));\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(Math.floor(entry.contentRect.width)));\n ro.observe(el);\n return () => ro.disconnect();\n }, [el]);\n return [setEl, width];\n}\n\n/** false for the first frame once `ready`, then true: marks draw from\n * nothing to their place. The transitions are motion-safe: classes, so\n * with reduced motion they simply appear. */\nexport function useReveal(ready) {\n const [shown, setShown] = useState(false);\n useEffect(() => {\n if (!ready || shown) return undefined;\n // A frame, so the start is painted; the timer, because a hidden tab\n // runs no frames and the chart must never stay empty.\n const done = () => setShown(true);\n const frame = requestAnimationFrame(() => requestAnimationFrame(done));\n const timer = setTimeout(done, 120);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n };\n }, [ready, shown]);\n return shown;\n}\n\n// ---- the parts every chart draws ----------------------------------------------\n\n// A square of the series' colour; a dashed series gets a short dashed line.\nfunction Swatch({ t, dim, dashed }) {\n if (dashed) {\n return (\n <svg aria-hidden=\"true\" width=\"12\" height=\"10\" viewBox=\"0 0 12 10\" className={cx(\"shrink-0\", dim && \"opacity-30\")}>\n <line x1=\"0\" y1=\"5\" x2=\"12\" y2=\"5\" strokeWidth=\"2\" strokeDasharray=\"3 2\" className={tone(t, \"stroke\")} />\n </svg>\n );\n }\n return <span aria-hidden=\"true\" className={cx(\"size-2.5 shrink-0 rounded-sm\", tone(t, \"bg\"), dim && \"opacity-30\")} />;\n}\n\n/**\n * The key to a chart's colours. With `onToggle` each entry is a toggle\n * (pressed = shown) that hides or shows its series; without it, a plain\n * list. `items` [{ key, label, tone?, value?, dashed? }] — `value` is a\n * muted note after the label (a slice's share); `dashed` draws the key as\n * a dashed line (a comparison series).\n */\nexport function ChartLegend({ items, hidden, onToggle, className, label = \"Legend\" }) {\n const list = (items ?? []).filter(Boolean);\n if (!list.length) return null;\n const off = hidden instanceof Set ? hidden : new Set(hidden ?? []);\n if (!onToggle) {\n return (\n <ul aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground\", className)}>\n {list.map((it, i) => (\n <li key={it.key} className=\"flex items-center gap-1.5\">\n <Swatch t={toneOf(it, i)} dashed={it.dashed} />\n <span className=\"text-foreground\">{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums>{it.value}</span> : null}\n </li>\n ))}\n </ul>\n );\n }\n return (\n <div role=\"group\" aria-label={label} className={cx(\"flex flex-wrap items-center justify-center gap-1\", className)}>\n {list.map((it, i) => {\n const shown = !off.has(it.key);\n return (\n <Toggle\n key={it.key}\n size=\"sm\"\n variant=\"quiet\"\n pressed={shown}\n onPressedChange={() => onToggle(it.key)}\n className=\"gap-1.5 text-xs\"\n >\n <Swatch t={toneOf(it, i)} dim={!shown} dashed={it.dashed} />\n <span className={shown ? \"text-foreground\" : \"line-through\"}>{it.label ?? humanize(it.key)}</span>\n {it.value != null ? <span data-nums className=\"text-muted-foreground\">{it.value}</span> : null}\n </Toggle>\n );\n })}\n </div>\n );\n}\n\n/** The floating box that says what is under the pointer (or the keyboard).\n * `at` { x, y, flip } in the chart's pixels; flip puts it on the left. */\nexport function TooltipBox({ at, title, rows, footer }) {\n if (!at) return null;\n return (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute left-0 top-0 z-10 min-w-36 rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-lg\"\n style={{\n transform: `translate(${Math.round(at.x)}px, ${Math.round(at.y)}px) translate(${at.flip ? \"calc(-100% - 12px)\" : \"12px\"}, ${at.middle ? \"-50%\" : \"0\"})`,\n }}\n >\n {title != null ? <p className=\"mb-1.5 font-medium\">{title}</p> : null}\n <div className=\"grid gap-1\">\n {rows.map((r) => (\n <div key={r.key} className=\"flex items-center gap-2\">\n <Swatch t={r.tone} dashed={r.dashed} />\n <span className=\"flex-1 text-muted-foreground\">{r.label}</span>\n <span data-nums className=\"font-medium\">{r.value}</span>\n </div>\n ))}\n </div>\n {footer ? (\n <div className=\"mt-1.5 flex items-center justify-between gap-2 border-t border-border pt-1.5\">\n <span className=\"text-muted-foreground\">{footer.label}</span>\n <span data-nums className=\"font-medium\">{footer.value}</span>\n </div>\n ) : null}\n </div>\n );\n}\n\n/** The numbers behind the picture, for a screen reader: a real table,\n * visually hidden. `head` the column names, `body` rows of strings.\n *\n * The hiding is a div's, never the table's: a table takes `width: 1px`\n * as a least width and grows to its rows, and a browser need not clip a\n * table's overflow, so `sr-only` on the table itself left a box as wide\n * as its longest row, which pushed a phone's page past the window (the\n * store's probe at 375px, 2026-10-08). The div is 1px, clips what it\n * holds and is absolute inside the chart's own box (each chart's root\n * is `relative`); the table in it is a plain table, read as one. */\nexport function DataTable({ caption, head, body }) {\n return (\n <div className=\"sr-only\">\n <table>\n <caption>{caption}</caption>\n <thead>\n <tr>\n {head.map((h, i) => (\n <th key={i} scope=\"col\">{h}</th>\n ))}\n </tr>\n </thead>\n <tbody>\n {body.map((cells, r) => (\n <tr key={r}>\n {cells.map((c, i) =>\n i === 0 ? (\n <th key={i} scope=\"row\">{c}</th>\n ) : (\n <td key={i}>{c}</td>\n ),\n )}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n}\n\n/** What a chart shows with nothing to draw, at the height it would have. */\nexport function ChartEmpty({ height, title, body }) {\n return (\n <div style={{ minHeight: height }} className=\"grid\">\n <Empty icon=\"chart\" title={title} body={body} className=\"h-full content-center\" />\n </div>\n );\n}\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "registry/blocks/chart/cartesian.jsx",
|
|
26
26
|
"type": "registry:component",
|
|
27
27
|
"target": "@components/makersclaw/blocks/chart/cartesian.jsx",
|
|
28
|
-
"content": "\"use client\";\n\nimport { useId, useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport {\n ChartEmpty,\n ChartLegend,\n DataTable,\n TooltipBox,\n defaultFormat,\n humanize,\n idFrom,\n niceTicks,\n num,\n runs,\n textWidth,\n tone,\n toneOf,\n useReveal,\n useWidth,\n} from \"./core\";\n\n// LineChart, AreaChart and BarChart — one drawing, three marks. Rows in,\n// one x key, the series to draw; the block owns the rest: the scale and its\n// round ticks, gridlines, labels thinned to fit, a width that follows the\n// container, the tooltip and crosshair under a pointer or the arrow keys,\n// the legend that hides a series, the reveal (none with reduced motion),\n// and the words and table a screen reader gets instead of the picture.\n\n// ---- paths -------------------------------------------------------------------\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction linear(pts) {\n return pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${f(x)},${f(y)}`).join(\"\");\n}\n\nfunction step(pts) {\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 1; i < pts.length; i++) {\n const mid = (pts[i - 1][0] + pts[i][0]) / 2;\n d += `H${f(mid)}V${f(pts[i][1])}H${f(pts[i][0])}`;\n }\n return d;\n}\n\n// Monotone cubic (Fritsch–Carlson): smooth, and never overshoots a point,\n// so a curve does not invent a dip below zero or a peak that was not there.\nfunction monotone(pts) {\n const n = pts.length;\n if (n < 3) return linear(pts);\n const dx = [];\n const m = [];\n for (let i = 0; i < n - 1; i++) {\n dx[i] = pts[i + 1][0] - pts[i][0];\n m[i] = dx[i] === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx[i];\n }\n const t = new Array(n);\n t[0] = m[0];\n t[n - 1] = m[n - 2];\n for (let i = 1; i < n - 1; i++) {\n t[i] = m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i]);\n }\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 0; i < n - 1; i++) {\n const h = dx[i] / 3;\n d += `C${f(pts[i][0] + h)},${f(pts[i][1] + t[i] * h)} ${f(pts[i + 1][0] - h)},${f(pts[i + 1][1] - t[i + 1] * h)} ${f(pts[i + 1][0])},${f(pts[i + 1][1])}`;\n }\n return d;\n}\n\nfunction curvePath(pts, curve) {\n if (!pts.length) return \"\";\n if (curve === \"step\") return step(pts);\n if (curve === \"linear\") return linear(pts);\n return monotone(pts);\n}\n\n// A bar with its outer end rounded (the end away from zero).\nfunction barPath(x, y, w, h, r, side) {\n const q = Math.max(0, Math.min(r, w / 2, h / 2));\n if (!q || !side) return `M${f(x)},${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`;\n const X = f(x);\n const Y = f(y);\n if (side === \"top\") return `M${X},${f(y + h)}V${f(y + q)}Q${X},${Y} ${f(x + q)},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h)}Z`;\n if (side === \"bottom\") return `M${X},${Y}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w - q)}Q${f(x + w)},${f(y + h)} ${f(x + w)},${f(y + h - q)}V${Y}Z`;\n if (side === \"right\") return `M${X},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h - q)}Q${f(x + w)},${f(y + h)} ${f(x + w - q)},${f(y + h)}H${X}Z`;\n return `M${f(x + w)},${Y}H${f(x + q)}Q${X},${Y} ${X},${f(y + q)}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w)}Z`;\n}\n\n// ---- the chart ----------------------------------------------------------------\n\nfunction Cartesian({\n kind,\n data,\n x,\n series,\n format = defaultFormat,\n formatAxis,\n formatX = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n xLabel,\n height = 240,\n stacked = false,\n orientation = \"vertical\",\n valueLabels = false,\n track = false,\n curve = \"monotone\",\n dots = false,\n grid = true,\n xAxis = true,\n yAxis = true,\n legend,\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const uid = idFrom(useId());\n const fmt = (v) => (v == null ? \"—\" : format(v));\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n\n const rows = Array.isArray(data) ? data : [];\n // A `muted` series with no tone of its own is the grey: a comparison\n // (last period) that sits behind the series it is compared with.\n const all = useMemo(\n () => (series ?? []).map((s, i) => ({ ...s, label: s.label ?? humanize(s.key), tone: s.muted && s.tone == null ? \"muted\" : toneOf(s, i) })),\n [series],\n );\n const live = all.filter((s) => !hidden.has(s.key));\n // Lines and areas draw muted series first, so they sit underneath.\n const order = live.map((_, j) => j).sort((a, b) => Number(Boolean(live[b].muted)) - Number(Boolean(live[a].muted)));\n const horizontal = kind === \"bar\" && orientation === \"horizontal\";\n const stack = stacked && kind !== \"line\";\n const axisFormat = formatAxis ?? format;\n const showLegend = legend ?? all.length > 1;\n const shown = useReveal(width > 0);\n\n // Each series' [from, to] per row: stacked marks start where the one\n // below ended (positives up, negatives down); the rest start at zero.\n const spans = useMemo(() => {\n const up = rows.map(() => 0);\n const down = rows.map(() => 0);\n return live.map((s) =>\n rows.map((r, i) => {\n const v = num(r?.[s.key]);\n if (v == null) return null;\n if (!stack) return [0, v];\n if (v >= 0) {\n const from = up[i];\n up[i] += v;\n return [from, up[i]];\n }\n const from = down[i];\n down[i] += v;\n return [from, down[i]];\n }),\n );\n }, [rows, live, stack]); // eslint-disable-line react-hooks/exhaustive-deps\n\n let lo = 0;\n let hi = 0;\n for (const s of spans) for (const p of s) if (p) (lo = Math.min(lo, p[0], p[1])), (hi = Math.max(hi, p[0], p[1]));\n const valueLen = horizontal ? Math.max(0, width - 16) : height - 32;\n const ticks = niceTicks(lo, hi, Math.max(2, Math.min(6, Math.floor(valueLen / (horizontal ? 80 : 48)))));\n const vMin = ticks[0];\n const vMax = ticks[ticks.length - 1];\n\n const xNames = rows.map((r) => formatX(r?.[x], r));\n const tickNames = ticks.map((t) => axisFormat(t));\n\n // BarChart's value labels: one at the outer end of every bar, or of every\n // stack (its total). [{ i, j, v, text }]; j is the series (-1: a stack).\n const barLabels = [];\n if (kind === \"bar\" && valueLabels && live.length) {\n const say = (v, row, key) => (typeof valueLabels === \"function\" ? valueLabels(v, row, key) : fmt(v));\n rows.forEach((row, i) => {\n if (stack) {\n const vals = live.map((s) => num(row?.[s.key])).filter((v) => v != null);\n if (!vals.length) return;\n const v = vals.reduce((a, b) => a + b, 0);\n barLabels.push({ i, j: -1, v, text: say(v, row, null) });\n } else {\n live.forEach((s, j) => {\n const v = num(row?.[s.key]);\n if (v != null) barLabels.push({ i, j, v, text: say(v, row, s.key) });\n });\n }\n });\n }\n const labelRoom = barLabels.length ? Math.max(...barLabels.map((l) => textWidth(l.text))) + 6 : 0;\n\n // Margins from what the labels need.\n const catW = horizontal && yAxis ? Math.min(Math.max(...xNames.map(textWidth), 24) + 8, width * 0.4) : 0;\n const valW = !horizontal && yAxis ? Math.max(...tickNames.map(textWidth)) + 8 : 0;\n const m = {\n top: barLabels.length && !horizontal ? 22 : 8,\n right: horizontal ? Math.max(8, textWidth(tickNames[tickNames.length - 1]) / 2, labelRoom) : 8,\n bottom: xAxis ? 24 : 4,\n left: horizontal ? catW : valW,\n };\n // Points sit on the plot's edges (bars sit inside their band), so the\n // first and last x labels, centred on them, need room outside it.\n if (kind !== \"bar\" && xAxis && rows.length) {\n m.left = Math.max(m.left, textWidth(xNames[0]) / 2);\n m.right = Math.max(m.right, textWidth(xNames[xNames.length - 1]) / 2);\n }\n const plotW = Math.max(0, width - m.left - m.right);\n const plotH = Math.max(0, height - m.top - m.bottom);\n const n = rows.length;\n\n // Value → pixel along the value axis; index → pixel along the category axis.\n const vPos = (v) => (horizontal ? m.left + ((v - vMin) / (vMax - vMin || 1)) * plotW : m.top + plotH - ((v - vMin) / (vMax - vMin || 1)) * plotH);\n const band = (horizontal ? plotH : plotW) / Math.max(1, n);\n const cPos =\n kind === \"bar\"\n ? (i) => (horizontal ? m.top : m.left) + band * (i + 0.5)\n : (i) => m.left + (n === 1 ? plotW / 2 : (i * plotW) / (n - 1));\n const zero = vPos(Math.max(vMin, Math.min(0, vMax)));\n\n // Category labels thinned so they never collide.\n const labelEvery = horizontal\n ? Math.max(1, Math.ceil(16 / Math.max(1, band)))\n : Math.max(1, Math.ceil((Math.max(...xNames.map(textWidth), 0) + 12) / Math.max(1, kind === \"bar\" ? band : plotW / Math.max(1, n - 1))));\n\n function indexAt(e) {\n const rect = e.currentTarget.getBoundingClientRect();\n const p = horizontal ? e.clientY - rect.top : e.clientX - rect.left;\n if (kind === \"bar\") return Math.max(0, Math.min(n - 1, Math.floor((p - (horizontal ? m.top : m.left)) / band)));\n if (n === 1) return 0;\n return Math.max(0, Math.min(n - 1, Math.round(((p - m.left) / plotW) * (n - 1))));\n }\n\n function onKeyDown(e) {\n if (!n) return;\n const back = horizontal ? \"ArrowUp\" : \"ArrowLeft\";\n const fwd = horizontal ? \"ArrowDown\" : \"ArrowRight\";\n let next = null;\n if (e.key === fwd || e.key === (horizontal ? \"ArrowRight\" : \"ArrowDown\")) next = active == null ? 0 : Math.min(n - 1, active + 1);\n else if (e.key === back || e.key === (horizontal ? \"ArrowLeft\" : \"ArrowUp\")) next = active == null ? n - 1 : Math.max(0, active - 1);\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = n - 1;\n else if (e.key === \"Escape\") {\n setActive(null);\n return;\n } else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(rows[active], active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n // ---- words -------------------------------------------------------------------\n\n const summary = useMemo(() => {\n if (!n || !live.length) return \"\";\n const parts = live.map((s) => {\n const vals = rows.map((r) => num(r?.[s.key]));\n const idx = vals.reduce((best, v, i) => (v != null && (best < 0 || v > vals[best]) ? i : best), -1);\n if (idx < 0) return `${s.label}: no data`;\n const first = vals.find((v) => v != null);\n const last = [...vals].reverse().find((v) => v != null);\n return `${s.label}: ${kind === \"bar\" ? \"\" : `from ${fmt(first)} to ${fmt(last)}, `}highest ${fmt(vals[idx])} at ${xNames[idx]}`;\n });\n return `${n} ${n === 1 ? \"point\" : \"points\"}, ${xNames[0]} to ${xNames[n - 1]}. ${parts.join(\"; \")}.`;\n }, [rows, live, n]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const tipRows =\n active == null\n ? []\n : live.map((s) => ({ key: s.key, label: s.label, tone: s.tone, dashed: s.dashed, value: fmt(num(rows[active]?.[s.key])) }));\n // The stack's total: what there is, or none (—) when every value is missing.\n const total =\n active == null\n ? null\n : live.reduce((sum, s) => {\n const v = num(rows[active]?.[s.key]);\n return v == null ? sum : (sum ?? 0) + v;\n }, null);\n const announce = active == null ? \"\" : `${xNames[active]}: ${tipRows.map((r) => `${r.label} ${r.value}`).join(\", \")}`;\n\n const legendNode = showLegend ? (\n <ChartLegend\n items={all}\n hidden={hidden}\n onToggle={(key) =>\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n })\n }\n label={`${label}: series`}\n />\n ) : null;\n\n if (!n) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n // ---- marks -------------------------------------------------------------------\n\n const tipAt =\n active == null || !tooltip\n ? null\n : horizontal\n ? { x: Math.min(width - 12, Math.max(...live.map((_, k) => vPos(spans[k][active]?.[1] ?? 0)), zero)), y: cPos(active), flip: false, middle: true }\n : { x: cPos(active), y: m.top, flip: cPos(active) > width / 2 };\n if (tipAt && horizontal) tipAt.flip = tipAt.x > width * 0.6;\n\n const revealStyle = (axis) => ({\n transform: shown ? \"none\" : axis === \"x\" ? \"scaleX(0)\" : \"scaleY(0)\",\n transformOrigin: axis === \"x\" ? `${zero}px 0` : `0 ${zero}px`,\n });\n\n let marks = null;\n if (kind === \"bar\") {\n const k = live.length;\n const groupW = Math.min(band * (stack ? 0.62 : 0.8), stack ? 56 : 28 * Math.max(1, k) + 4 * (k - 1));\n const gap = k > 1 && !stack ? Math.min(4, groupW * 0.06) : 0;\n const barW = stack ? groupW : Math.max(1, (groupW - gap * (k - 1)) / Math.max(1, k));\n const r = Math.min(4, barW / 3);\n // Which series ends each stack (its outer end is the rounded one).\n const outer = rows.map((_, i) => {\n let top = -1;\n let bottom = -1;\n live.forEach((s, j) => {\n const p = spans[j][i];\n if (!p || p[1] === p[0]) return;\n if (p[1] > p[0]) top = j;\n else bottom = j;\n });\n return { top, bottom };\n });\n // Where the stacked total ends: the far end of the stack on its side.\n const stackEnd = (i, v) => {\n let end = 0;\n live.forEach((_, j) => {\n const p = spans[j][i];\n if (p) end = v >= 0 ? Math.max(end, p[1]) : Math.min(end, p[1]);\n });\n return end;\n };\n const tracks = track ? (\n <g aria-hidden=\"true\" className=\"fill-accent\">\n {rows.map((_, i) => {\n const c = cPos(i) - groupW / 2;\n return horizontal ? (\n <rect key={i} x={m.left} y={c} width={plotW} height={groupW} rx={r} />\n ) : (\n <rect key={i} x={c} y={m.top} width={groupW} height={plotH} rx={r} />\n );\n })}\n </g>\n ) : null;\n const labels = barLabels.length ? (\n <g\n aria-hidden=\"true\"\n className=\"fill-foreground text-2xs font-medium motion-safe:transition-opacity motion-safe:duration-500\"\n style={{ opacity: shown ? 1 : 0 }}\n data-nums\n >\n {barLabels.map(({ i, j, v, text }) => {\n const end = vPos(j < 0 ? stackEnd(i, v) : v);\n const c = cPos(i) + (j < 0 ? 0 : -groupW / 2 + j * (barW + gap) + barW / 2);\n const up = v >= 0;\n return horizontal ? (\n <text key={`${i}-${j}`} x={end + (up ? 6 : -6)} y={c} dy=\"0.32em\" textAnchor={up ? \"start\" : \"end\"}>\n {text}\n </text>\n ) : (\n <text key={`${i}-${j}`} x={c} y={end + (up ? -6 : 12)} textAnchor=\"middle\">\n {text}\n </text>\n );\n })}\n </g>\n ) : null;\n marks = (\n <>\n {tracks}\n <g style={revealStyle(horizontal ? \"x\" : \"y\")} className=\"motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out\">\n {live.map((s, j) => (\n <g key={s.key} className={tone(s.tone, \"fill\")}>\n {rows.map((_, i) => {\n const p = spans[j][i];\n if (!p || p[0] === p[1]) return null;\n const a = vPos(p[0]);\n const b = vPos(p[1]);\n const offset = stack ? -groupW / 2 : -groupW / 2 + j * (barW + gap);\n const c = cPos(i) + offset;\n const positive = p[1] > p[0];\n const isOuter = !stack || (positive ? outer[i].top === j : outer[i].bottom === j);\n const side = !isOuter ? null : horizontal ? (positive ? \"right\" : \"left\") : positive ? \"top\" : \"bottom\";\n const d = horizontal\n ? barPath(Math.min(a, b), c, Math.abs(b - a), barW, r, side)\n : barPath(c, Math.min(a, b), barW, Math.abs(b - a), r, side);\n return <path key={i} d={d} className={cx(active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")} />;\n })}\n </g>\n ))}\n </g>\n {labels}\n </>\n );\n } else {\n const clip = `${uid}-clip`;\n const fade = `${uid}-fade`;\n marks = (\n <>\n <defs>\n <clipPath id={clip}>\n <rect\n x={m.left - 4}\n y={0}\n width={plotW + 8}\n height={height}\n style={{ transform: shown ? \"none\" : \"scaleX(0)\", transformOrigin: `${m.left}px 0` }}\n className=\"motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out\"\n />\n </clipPath>\n {/* An alpha mask, not a colour: the area fades toward zero. */}\n <linearGradient id={`${fade}-g`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopOpacity=\"1\" />\n <stop offset=\"1\" stopOpacity=\"0.15\" />\n </linearGradient>\n <mask id={fade} style={{ maskType: \"alpha\" }} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={width} height={height}>\n <rect x={0} y={m.top} width={width} height={plotH} fill={`url(#${fade}-g)`} />\n </mask>\n </defs>\n <g clipPath={`url(#${clip})`}>\n {kind === \"area\"\n ? order.map((j) => {\n const s = live[j];\n // A dashed series is a line only: a comparison has no fill.\n if (s.dashed && !stack) return null;\n // One band per run of values: an area breaks where a value\n // is missing, as its line does — never down to zero.\n const d = runs(spans[j].map((p, i) => (p ? { x: cPos(i), top: vPos(p[1]), base: vPos(p[0]) } : null)))\n .filter((run) => run.length > 1)\n .map((run) => {\n const back = curvePath(run.map((q) => [q.x, q.base]).reverse(), curve).replace(/^M/, \"L\");\n return `${curvePath(run.map((q) => [q.x, q.top]), curve)}${back}Z`;\n })\n .join(\"\");\n if (!d) return null;\n return (\n <path\n key={s.key}\n d={d}\n className={tone(s.tone, \"fill\")}\n fillOpacity={stack ? 0.4 : s.muted ? 0.2 : 0.45}\n mask={stack ? undefined : `url(#${fade})`}\n />\n );\n })\n : null}\n {order.map((j) => {\n const s = live[j];\n // A line (and an area's edge) breaks where a value is missing;\n // a value alone between two gaps is a dot, so it still shows.\n const segs = runs(spans[j].map((p, i) => (p ? [cPos(i), vPos(p[1])] : null)));\n return (\n <g\n key={s.key}\n className={tone(s.tone, \"stroke\")}\n fill=\"none\"\n strokeWidth={s.muted ? 1.5 : 2}\n strokeDasharray={s.dashed ? \"5 4\" : undefined}\n strokeLinejoin=\"round\"\n strokeLinecap={s.dashed ? \"butt\" : \"round\"}\n >\n {segs.map((seg, k) =>\n seg.length > 1 ? (\n <path key={k} d={curvePath(seg, curve)} />\n ) : dots ? null : (\n <circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" />\n ),\n )}\n {dots\n ? spans[j].map((p, i) =>\n p ? <circle key={i} cx={cPos(i)} cy={vPos(p[1])} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" /> : null,\n )\n : null}\n </g>\n );\n })}\n </g>\n </>\n );\n }\n\n const cursor =\n active == null\n ? null\n : kind === \"bar\"\n ? horizontal\n ? <rect x={m.left} y={m.top + band * active} width={plotW} height={band} className=\"fill-muted\" />\n : <rect x={m.left + band * active} y={m.top} width={band} height={plotH} className=\"fill-muted\" />\n : <line x1={cPos(active)} x2={cPos(active)} y1={m.top} y2={m.top + plotH} className=\"stroke-muted-foreground\" strokeDasharray=\"3 3\" strokeWidth={1} />;\n\n const activeDots =\n active == null || kind === \"bar\"\n ? null\n : live.map((s, j) => {\n const p = spans[j][active];\n if (!p) return null;\n return <circle key={s.key} cx={cPos(active)} cy={vPos(p[1])} r={4.5} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={2} />;\n });\n\n return (\n <div className={cx(\"grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each value.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none overflow-visible\", onSelect && \"cursor-pointer\")}\n onPointerMove={(e) => {\n setKeyed(false);\n setActive(indexAt(e));\n }}\n onPointerDown={(e) => setActive(indexAt(e))}\n onPointerLeave={() => !keyed && setActive(null)}\n onClick={(e) => onSelect?.(rows[indexAt(e)], indexAt(e))}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {cursor}\n {grid\n ? ticks.map((t) => {\n const p = f(vPos(t));\n return horizontal ? (\n <line key={t} x1={p} x2={p} y1={m.top} y2={m.top + plotH} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n ) : (\n <line key={t} x1={m.left} x2={m.left + plotW} y1={p} y2={p} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n );\n })\n : null}\n {marks}\n {activeDots}\n <g aria-hidden=\"true\" className=\"fill-muted-foreground text-2xs\" data-nums>\n {/* The value axis. */}\n {(horizontal ? xAxis : yAxis)\n ? ticks.map((t, i) =>\n horizontal ? (\n <text key={t} x={vPos(t)} y={height - 6} textAnchor=\"middle\">\n {tickNames[i]}\n </text>\n ) : (\n <text key={t} x={m.left - 8} y={vPos(t)} dy=\"0.32em\" textAnchor=\"end\">\n {tickNames[i]}\n </text>\n ),\n )\n : null}\n {/* The category axis. */}\n {(horizontal ? yAxis : xAxis)\n ? xNames.map((name, i) => {\n if (i % labelEvery !== 0) return null;\n if (horizontal) {\n return (\n <text key={i} x={m.left - 8} y={cPos(i)} dy=\"0.32em\" textAnchor=\"end\">\n {name}\n </text>\n );\n }\n return (\n <text key={i} x={cPos(i)} y={height - 6} textAnchor=\"middle\">\n {name}\n </text>\n );\n })\n : null}\n </g>\n </svg>\n ) : null}\n {tipAt ? (\n <TooltipBox\n at={tipAt}\n title={xNames[active]}\n rows={tipRows}\n footer={stack && live.length > 1 ? { label: \"Total\", value: fmt(total) } : null}\n />\n ) : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed ? announce : \"\"}\n </div>\n </div>\n {live.length === 0 ? <p className=\"text-center text-xs text-muted-foreground\">Every series is hidden. Press one below to show it.</p> : null}\n {legendNode}\n <DataTable\n caption={label}\n head={[xLabel ?? humanize(x), ...all.map((s) => s.label)]}\n body={rows.map((r, i) => [xNames[i], ...all.map((s) => fmt(num(r?.[s.key])))])}\n />\n </div>\n );\n}\n\n/** Values over an ordered x (time): lines, optionally curved or stepped. */\nexport function LineChart(props) {\n return <Cartesian {...props} kind=\"line\" />;\n}\n\n/** Lines filled to zero; `stacked` piles the series into a total. */\nexport function AreaChart(props) {\n return <Cartesian {...props} kind=\"area\" />;\n}\n\n/** Bars per category: grouped side by side, or `stacked`; columns\n * (`orientation=\"vertical\"`, the default) or bars (`\"horizontal\"`). */\nexport function BarChart(props) {\n return <Cartesian {...props} kind=\"bar\" />;\n}\n"
|
|
28
|
+
"content": "\"use client\";\n\nimport { useId, useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport {\n ChartEmpty,\n ChartLegend,\n DataTable,\n TooltipBox,\n defaultFormat,\n humanize,\n idFrom,\n niceTicks,\n num,\n runs,\n textWidth,\n tone,\n toneOf,\n useReveal,\n useWidth,\n} from \"./core\";\n\n// LineChart, AreaChart and BarChart — one drawing, three marks. Rows in,\n// one x key, the series to draw; the block owns the rest: the scale and its\n// round ticks, gridlines, labels thinned to fit, a width that follows the\n// container, the tooltip and crosshair under a pointer or the arrow keys,\n// the legend that hides a series, the reveal (none with reduced motion),\n// and the words and table a screen reader gets instead of the picture.\n\n// ---- paths -------------------------------------------------------------------\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction linear(pts) {\n return pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${f(x)},${f(y)}`).join(\"\");\n}\n\nfunction step(pts) {\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 1; i < pts.length; i++) {\n const mid = (pts[i - 1][0] + pts[i][0]) / 2;\n d += `H${f(mid)}V${f(pts[i][1])}H${f(pts[i][0])}`;\n }\n return d;\n}\n\n// Monotone cubic (Fritsch–Carlson): smooth, and never overshoots a point,\n// so a curve does not invent a dip below zero or a peak that was not there.\nfunction monotone(pts) {\n const n = pts.length;\n if (n < 3) return linear(pts);\n const dx = [];\n const m = [];\n for (let i = 0; i < n - 1; i++) {\n dx[i] = pts[i + 1][0] - pts[i][0];\n m[i] = dx[i] === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx[i];\n }\n const t = new Array(n);\n t[0] = m[0];\n t[n - 1] = m[n - 2];\n for (let i = 1; i < n - 1; i++) {\n t[i] = m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i]);\n }\n let d = `M${f(pts[0][0])},${f(pts[0][1])}`;\n for (let i = 0; i < n - 1; i++) {\n const h = dx[i] / 3;\n d += `C${f(pts[i][0] + h)},${f(pts[i][1] + t[i] * h)} ${f(pts[i + 1][0] - h)},${f(pts[i + 1][1] - t[i + 1] * h)} ${f(pts[i + 1][0])},${f(pts[i + 1][1])}`;\n }\n return d;\n}\n\nfunction curvePath(pts, curve) {\n if (!pts.length) return \"\";\n if (curve === \"step\") return step(pts);\n if (curve === \"linear\") return linear(pts);\n return monotone(pts);\n}\n\n// A bar with its outer end rounded (the end away from zero).\nfunction barPath(x, y, w, h, r, side) {\n const q = Math.max(0, Math.min(r, w / 2, h / 2));\n if (!q || !side) return `M${f(x)},${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`;\n const X = f(x);\n const Y = f(y);\n if (side === \"top\") return `M${X},${f(y + h)}V${f(y + q)}Q${X},${Y} ${f(x + q)},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h)}Z`;\n if (side === \"bottom\") return `M${X},${Y}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w - q)}Q${f(x + w)},${f(y + h)} ${f(x + w)},${f(y + h - q)}V${Y}Z`;\n if (side === \"right\") return `M${X},${Y}H${f(x + w - q)}Q${f(x + w)},${Y} ${f(x + w)},${f(y + q)}V${f(y + h - q)}Q${f(x + w)},${f(y + h)} ${f(x + w - q)},${f(y + h)}H${X}Z`;\n return `M${f(x + w)},${Y}H${f(x + q)}Q${X},${Y} ${X},${f(y + q)}V${f(y + h - q)}Q${X},${f(y + h)} ${f(x + q)},${f(y + h)}H${f(x + w)}Z`;\n}\n\n// ---- the chart ----------------------------------------------------------------\n\nfunction Cartesian({\n kind,\n data,\n x,\n series,\n format = defaultFormat,\n formatAxis,\n formatX = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n xLabel,\n height = 240,\n stacked = false,\n orientation = \"vertical\",\n valueLabels = false,\n track = false,\n curve = \"monotone\",\n dots = false,\n grid = true,\n xAxis = true,\n yAxis = true,\n legend,\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const uid = idFrom(useId());\n const fmt = (v) => (v == null ? \"—\" : format(v));\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n\n const rows = Array.isArray(data) ? data : [];\n // A `muted` series with no tone of its own is the grey: a comparison\n // (last period) that sits behind the series it is compared with.\n const all = useMemo(\n () => (series ?? []).map((s, i) => ({ ...s, label: s.label ?? humanize(s.key), tone: s.muted && s.tone == null ? \"muted\" : toneOf(s, i) })),\n [series],\n );\n const live = all.filter((s) => !hidden.has(s.key));\n // Lines and areas draw muted series first, so they sit underneath.\n const order = live.map((_, j) => j).sort((a, b) => Number(Boolean(live[b].muted)) - Number(Boolean(live[a].muted)));\n const horizontal = kind === \"bar\" && orientation === \"horizontal\";\n const stack = stacked && kind !== \"line\";\n const axisFormat = formatAxis ?? format;\n const showLegend = legend ?? all.length > 1;\n const shown = useReveal(width > 0);\n\n // Each series' [from, to] per row: stacked marks start where the one\n // below ended (positives up, negatives down); the rest start at zero.\n const spans = useMemo(() => {\n const up = rows.map(() => 0);\n const down = rows.map(() => 0);\n return live.map((s) =>\n rows.map((r, i) => {\n const v = num(r?.[s.key]);\n if (v == null) return null;\n if (!stack) return [0, v];\n if (v >= 0) {\n const from = up[i];\n up[i] += v;\n return [from, up[i]];\n }\n const from = down[i];\n down[i] += v;\n return [from, down[i]];\n }),\n );\n }, [rows, live, stack]); // eslint-disable-line react-hooks/exhaustive-deps\n\n let lo = 0;\n let hi = 0;\n for (const s of spans) for (const p of s) if (p) (lo = Math.min(lo, p[0], p[1])), (hi = Math.max(hi, p[0], p[1]));\n const valueLen = horizontal ? Math.max(0, width - 16) : height - 32;\n const ticks = niceTicks(lo, hi, Math.max(2, Math.min(6, Math.floor(valueLen / (horizontal ? 80 : 48)))));\n const vMin = ticks[0];\n const vMax = ticks[ticks.length - 1];\n\n const xNames = rows.map((r) => formatX(r?.[x], r));\n const tickNames = ticks.map((t) => axisFormat(t));\n\n // BarChart's value labels: one at the outer end of every bar, or of every\n // stack (its total). [{ i, j, v, text }]; j is the series (-1: a stack).\n const barLabels = [];\n if (kind === \"bar\" && valueLabels && live.length) {\n const say = (v, row, key) => (typeof valueLabels === \"function\" ? valueLabels(v, row, key) : fmt(v));\n rows.forEach((row, i) => {\n if (stack) {\n const vals = live.map((s) => num(row?.[s.key])).filter((v) => v != null);\n if (!vals.length) return;\n const v = vals.reduce((a, b) => a + b, 0);\n barLabels.push({ i, j: -1, v, text: say(v, row, null) });\n } else {\n live.forEach((s, j) => {\n const v = num(row?.[s.key]);\n if (v != null) barLabels.push({ i, j, v, text: say(v, row, s.key) });\n });\n }\n });\n }\n const labelRoom = barLabels.length ? Math.max(...barLabels.map((l) => textWidth(l.text))) + 6 : 0;\n\n // Margins from what the labels need.\n const catW = horizontal && yAxis ? Math.min(Math.max(...xNames.map(textWidth), 24) + 8, width * 0.4) : 0;\n const valW = !horizontal && yAxis ? Math.max(...tickNames.map(textWidth)) + 8 : 0;\n const m = {\n top: barLabels.length && !horizontal ? 22 : 8,\n right: horizontal ? Math.max(8, textWidth(tickNames[tickNames.length - 1]) / 2, labelRoom) : 8,\n bottom: xAxis ? 24 : 4,\n left: horizontal ? catW : valW,\n };\n // Points sit on the plot's edges (bars sit inside their band), so the\n // first and last x labels, centred on them, need room outside it.\n if (kind !== \"bar\" && xAxis && rows.length) {\n m.left = Math.max(m.left, textWidth(xNames[0]) / 2);\n m.right = Math.max(m.right, textWidth(xNames[xNames.length - 1]) / 2);\n }\n const plotW = Math.max(0, width - m.left - m.right);\n const plotH = Math.max(0, height - m.top - m.bottom);\n const n = rows.length;\n\n // Value → pixel along the value axis; index → pixel along the category axis.\n const vPos = (v) => (horizontal ? m.left + ((v - vMin) / (vMax - vMin || 1)) * plotW : m.top + plotH - ((v - vMin) / (vMax - vMin || 1)) * plotH);\n const band = (horizontal ? plotH : plotW) / Math.max(1, n);\n const cPos =\n kind === \"bar\"\n ? (i) => (horizontal ? m.top : m.left) + band * (i + 0.5)\n : (i) => m.left + (n === 1 ? plotW / 2 : (i * plotW) / (n - 1));\n const zero = vPos(Math.max(vMin, Math.min(0, vMax)));\n\n // Category labels thinned so they never collide.\n const labelEvery = horizontal\n ? Math.max(1, Math.ceil(16 / Math.max(1, band)))\n : Math.max(1, Math.ceil((Math.max(...xNames.map(textWidth), 0) + 12) / Math.max(1, kind === \"bar\" ? band : plotW / Math.max(1, n - 1))));\n\n function indexAt(e) {\n const rect = e.currentTarget.getBoundingClientRect();\n const p = horizontal ? e.clientY - rect.top : e.clientX - rect.left;\n if (kind === \"bar\") return Math.max(0, Math.min(n - 1, Math.floor((p - (horizontal ? m.top : m.left)) / band)));\n if (n === 1) return 0;\n return Math.max(0, Math.min(n - 1, Math.round(((p - m.left) / plotW) * (n - 1))));\n }\n\n function onKeyDown(e) {\n if (!n) return;\n const back = horizontal ? \"ArrowUp\" : \"ArrowLeft\";\n const fwd = horizontal ? \"ArrowDown\" : \"ArrowRight\";\n let next = null;\n if (e.key === fwd || e.key === (horizontal ? \"ArrowRight\" : \"ArrowDown\")) next = active == null ? 0 : Math.min(n - 1, active + 1);\n else if (e.key === back || e.key === (horizontal ? \"ArrowLeft\" : \"ArrowUp\")) next = active == null ? n - 1 : Math.max(0, active - 1);\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = n - 1;\n else if (e.key === \"Escape\") {\n setActive(null);\n return;\n } else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(rows[active], active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n // ---- words -------------------------------------------------------------------\n\n const summary = useMemo(() => {\n if (!n || !live.length) return \"\";\n const parts = live.map((s) => {\n const vals = rows.map((r) => num(r?.[s.key]));\n const idx = vals.reduce((best, v, i) => (v != null && (best < 0 || v > vals[best]) ? i : best), -1);\n if (idx < 0) return `${s.label}: no data`;\n const first = vals.find((v) => v != null);\n const last = [...vals].reverse().find((v) => v != null);\n return `${s.label}: ${kind === \"bar\" ? \"\" : `from ${fmt(first)} to ${fmt(last)}, `}highest ${fmt(vals[idx])} at ${xNames[idx]}`;\n });\n return `${n} ${n === 1 ? \"point\" : \"points\"}, ${xNames[0]} to ${xNames[n - 1]}. ${parts.join(\"; \")}.`;\n }, [rows, live, n]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const tipRows =\n active == null\n ? []\n : live.map((s) => ({ key: s.key, label: s.label, tone: s.tone, dashed: s.dashed, value: fmt(num(rows[active]?.[s.key])) }));\n // The stack's total: what there is, or none (—) when every value is missing.\n const total =\n active == null\n ? null\n : live.reduce((sum, s) => {\n const v = num(rows[active]?.[s.key]);\n return v == null ? sum : (sum ?? 0) + v;\n }, null);\n const announce = active == null ? \"\" : `${xNames[active]}: ${tipRows.map((r) => `${r.label} ${r.value}`).join(\", \")}`;\n\n const legendNode = showLegend ? (\n <ChartLegend\n items={all}\n hidden={hidden}\n onToggle={(key) =>\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n })\n }\n label={`${label}: series`}\n />\n ) : null;\n\n if (!n) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n // ---- marks -------------------------------------------------------------------\n\n const tipAt =\n active == null || !tooltip\n ? null\n : horizontal\n ? { x: Math.min(width - 12, Math.max(...live.map((_, k) => vPos(spans[k][active]?.[1] ?? 0)), zero)), y: cPos(active), flip: false, middle: true }\n : { x: cPos(active), y: m.top, flip: cPos(active) > width / 2 };\n if (tipAt && horizontal) tipAt.flip = tipAt.x > width * 0.6;\n\n const revealStyle = (axis) => ({\n transform: shown ? \"none\" : axis === \"x\" ? \"scaleX(0)\" : \"scaleY(0)\",\n transformOrigin: axis === \"x\" ? `${zero}px 0` : `0 ${zero}px`,\n });\n\n let marks = null;\n if (kind === \"bar\") {\n const k = live.length;\n const groupW = Math.min(band * (stack ? 0.62 : 0.8), stack ? 56 : 28 * Math.max(1, k) + 4 * (k - 1));\n const gap = k > 1 && !stack ? Math.min(4, groupW * 0.06) : 0;\n const barW = stack ? groupW : Math.max(1, (groupW - gap * (k - 1)) / Math.max(1, k));\n const r = Math.min(4, barW / 3);\n // Which series ends each stack (its outer end is the rounded one).\n const outer = rows.map((_, i) => {\n let top = -1;\n let bottom = -1;\n live.forEach((s, j) => {\n const p = spans[j][i];\n if (!p || p[1] === p[0]) return;\n if (p[1] > p[0]) top = j;\n else bottom = j;\n });\n return { top, bottom };\n });\n // Where the stacked total ends: the far end of the stack on its side.\n const stackEnd = (i, v) => {\n let end = 0;\n live.forEach((_, j) => {\n const p = spans[j][i];\n if (p) end = v >= 0 ? Math.max(end, p[1]) : Math.min(end, p[1]);\n });\n return end;\n };\n const tracks = track ? (\n <g aria-hidden=\"true\" className=\"fill-accent\">\n {rows.map((_, i) => {\n const c = cPos(i) - groupW / 2;\n return horizontal ? (\n <rect key={i} x={m.left} y={c} width={plotW} height={groupW} rx={r} />\n ) : (\n <rect key={i} x={c} y={m.top} width={groupW} height={plotH} rx={r} />\n );\n })}\n </g>\n ) : null;\n const labels = barLabels.length ? (\n <g\n aria-hidden=\"true\"\n className=\"fill-foreground text-2xs font-medium motion-safe:transition-opacity motion-safe:duration-500\"\n style={{ opacity: shown ? 1 : 0 }}\n data-nums\n >\n {barLabels.map(({ i, j, v, text }) => {\n const end = vPos(j < 0 ? stackEnd(i, v) : v);\n const c = cPos(i) + (j < 0 ? 0 : -groupW / 2 + j * (barW + gap) + barW / 2);\n const up = v >= 0;\n return horizontal ? (\n <text key={`${i}-${j}`} x={end + (up ? 6 : -6)} y={c} dy=\"0.32em\" textAnchor={up ? \"start\" : \"end\"}>\n {text}\n </text>\n ) : (\n <text key={`${i}-${j}`} x={c} y={end + (up ? -6 : 12)} textAnchor=\"middle\">\n {text}\n </text>\n );\n })}\n </g>\n ) : null;\n marks = (\n <>\n {tracks}\n <g style={revealStyle(horizontal ? \"x\" : \"y\")} className=\"motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out\">\n {live.map((s, j) => (\n <g key={s.key} className={tone(s.tone, \"fill\")}>\n {rows.map((_, i) => {\n const p = spans[j][i];\n if (!p || p[0] === p[1]) return null;\n const a = vPos(p[0]);\n const b = vPos(p[1]);\n const offset = stack ? -groupW / 2 : -groupW / 2 + j * (barW + gap);\n const c = cPos(i) + offset;\n const positive = p[1] > p[0];\n const isOuter = !stack || (positive ? outer[i].top === j : outer[i].bottom === j);\n const side = !isOuter ? null : horizontal ? (positive ? \"right\" : \"left\") : positive ? \"top\" : \"bottom\";\n const d = horizontal\n ? barPath(Math.min(a, b), c, Math.abs(b - a), barW, r, side)\n : barPath(c, Math.min(a, b), barW, Math.abs(b - a), r, side);\n return <path key={i} d={d} className={cx(active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")} />;\n })}\n </g>\n ))}\n </g>\n {labels}\n </>\n );\n } else {\n const clip = `${uid}-clip`;\n const fade = `${uid}-fade`;\n marks = (\n <>\n <defs>\n <clipPath id={clip}>\n <rect\n x={m.left - 4}\n y={0}\n width={plotW + 8}\n height={height}\n style={{ transform: shown ? \"none\" : \"scaleX(0)\", transformOrigin: `${m.left}px 0` }}\n className=\"motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out\"\n />\n </clipPath>\n {/* An alpha mask, not a colour: the area fades toward zero. */}\n <linearGradient id={`${fade}-g`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopOpacity=\"1\" />\n <stop offset=\"1\" stopOpacity=\"0.15\" />\n </linearGradient>\n <mask id={fade} style={{ maskType: \"alpha\" }} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={width} height={height}>\n <rect x={0} y={m.top} width={width} height={plotH} fill={`url(#${fade}-g)`} />\n </mask>\n </defs>\n <g clipPath={`url(#${clip})`}>\n {kind === \"area\"\n ? order.map((j) => {\n const s = live[j];\n // A dashed series is a line only: a comparison has no fill.\n if (s.dashed && !stack) return null;\n // One band per run of values: an area breaks where a value\n // is missing, as its line does — never down to zero.\n const d = runs(spans[j].map((p, i) => (p ? { x: cPos(i), top: vPos(p[1]), base: vPos(p[0]) } : null)))\n .filter((run) => run.length > 1)\n .map((run) => {\n const back = curvePath(run.map((q) => [q.x, q.base]).reverse(), curve).replace(/^M/, \"L\");\n return `${curvePath(run.map((q) => [q.x, q.top]), curve)}${back}Z`;\n })\n .join(\"\");\n if (!d) return null;\n return (\n <path\n key={s.key}\n d={d}\n className={tone(s.tone, \"fill\")}\n fillOpacity={stack ? 0.4 : s.muted ? 0.2 : 0.45}\n mask={stack ? undefined : `url(#${fade})`}\n />\n );\n })\n : null}\n {order.map((j) => {\n const s = live[j];\n // A line (and an area's edge) breaks where a value is missing;\n // a value alone between two gaps is a dot, so it still shows.\n const segs = runs(spans[j].map((p, i) => (p ? [cPos(i), vPos(p[1])] : null)));\n return (\n <g\n key={s.key}\n className={tone(s.tone, \"stroke\")}\n fill=\"none\"\n strokeWidth={s.muted ? 1.5 : 2}\n strokeDasharray={s.dashed ? \"5 4\" : undefined}\n strokeLinejoin=\"round\"\n strokeLinecap={s.dashed ? \"butt\" : \"round\"}\n >\n {segs.map((seg, k) =>\n seg.length > 1 ? (\n <path key={k} d={curvePath(seg, curve)} />\n ) : dots ? null : (\n <circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" />\n ),\n )}\n {dots\n ? spans[j].map((p, i) =>\n p ? <circle key={i} cx={cPos(i)} cy={vPos(p[1])} r={3} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={1.5} strokeDasharray=\"none\" /> : null,\n )\n : null}\n </g>\n );\n })}\n </g>\n </>\n );\n }\n\n const cursor =\n active == null\n ? null\n : kind === \"bar\"\n ? horizontal\n ? <rect x={m.left} y={m.top + band * active} width={plotW} height={band} className=\"fill-muted\" />\n : <rect x={m.left + band * active} y={m.top} width={band} height={plotH} className=\"fill-muted\" />\n : <line x1={cPos(active)} x2={cPos(active)} y1={m.top} y2={m.top + plotH} className=\"stroke-muted-foreground\" strokeDasharray=\"3 3\" strokeWidth={1} />;\n\n const activeDots =\n active == null || kind === \"bar\"\n ? null\n : live.map((s, j) => {\n const p = spans[j][active];\n if (!p) return null;\n return <circle key={s.key} cx={cPos(active)} cy={vPos(p[1])} r={4.5} className={cx(tone(s.tone, \"fill\"), \"stroke-card\")} strokeWidth={2} />;\n });\n\n return (\n <div className={cx(\"relative grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each value.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none overflow-visible\", onSelect && \"cursor-pointer\")}\n onPointerMove={(e) => {\n setKeyed(false);\n setActive(indexAt(e));\n }}\n onPointerDown={(e) => setActive(indexAt(e))}\n onPointerLeave={() => !keyed && setActive(null)}\n onClick={(e) => onSelect?.(rows[indexAt(e)], indexAt(e))}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {cursor}\n {grid\n ? ticks.map((t) => {\n const p = f(vPos(t));\n return horizontal ? (\n <line key={t} x1={p} x2={p} y1={m.top} y2={m.top + plotH} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n ) : (\n <line key={t} x1={m.left} x2={m.left + plotW} y1={p} y2={p} className=\"stroke-border\" strokeDasharray={t === 0 ? undefined : \"3 3\"} />\n );\n })\n : null}\n {marks}\n {activeDots}\n <g aria-hidden=\"true\" className=\"fill-muted-foreground text-2xs\" data-nums>\n {/* The value axis. */}\n {(horizontal ? xAxis : yAxis)\n ? ticks.map((t, i) =>\n horizontal ? (\n <text key={t} x={vPos(t)} y={height - 6} textAnchor=\"middle\">\n {tickNames[i]}\n </text>\n ) : (\n <text key={t} x={m.left - 8} y={vPos(t)} dy=\"0.32em\" textAnchor=\"end\">\n {tickNames[i]}\n </text>\n ),\n )\n : null}\n {/* The category axis. */}\n {(horizontal ? yAxis : xAxis)\n ? xNames.map((name, i) => {\n if (i % labelEvery !== 0) return null;\n if (horizontal) {\n return (\n <text key={i} x={m.left - 8} y={cPos(i)} dy=\"0.32em\" textAnchor=\"end\">\n {name}\n </text>\n );\n }\n return (\n <text key={i} x={cPos(i)} y={height - 6} textAnchor=\"middle\">\n {name}\n </text>\n );\n })\n : null}\n </g>\n </svg>\n ) : null}\n {tipAt ? (\n <TooltipBox\n at={tipAt}\n title={xNames[active]}\n rows={tipRows}\n footer={stack && live.length > 1 ? { label: \"Total\", value: fmt(total) } : null}\n />\n ) : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed ? announce : \"\"}\n </div>\n </div>\n {live.length === 0 ? <p className=\"text-center text-xs text-muted-foreground\">Every series is hidden. Press one below to show it.</p> : null}\n {legendNode}\n <DataTable\n caption={label}\n head={[xLabel ?? humanize(x), ...all.map((s) => s.label)]}\n body={rows.map((r, i) => [xNames[i], ...all.map((s) => fmt(num(r?.[s.key])))])}\n />\n </div>\n );\n}\n\n/** Values over an ordered x (time): lines, optionally curved or stepped. */\nexport function LineChart(props) {\n return <Cartesian {...props} kind=\"line\" />;\n}\n\n/** Lines filled to zero; `stacked` piles the series into a total. */\nexport function AreaChart(props) {\n return <Cartesian {...props} kind=\"area\" />;\n}\n\n/** Bars per category: grouped side by side, or `stacked`; columns\n * (`orientation=\"vertical\"`, the default) or bars (`\"horizontal\"`). */\nexport function BarChart(props) {\n return <Cartesian {...props} kind=\"bar\" />;\n}\n"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"path": "registry/blocks/chart/pie.jsx",
|
|
32
32
|
"type": "registry:component",
|
|
33
33
|
"target": "@components/makersclaw/blocks/chart/pie.jsx",
|
|
34
|
-
"content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { ChartEmpty, ChartLegend, DataTable, TooltipBox, defaultFormat, humanize, num, tone, toneOf, useReveal, useWidth } from \"./core\";\n\n// PieChart — parts of one whole, one slice per row; `donut` opens the\n// middle and writes the total (or your words) there. Pointer, touch or the\n// arrow keys pick a slice: it lifts, and the tooltip gives its value and\n// share. The legend hides a slice and the rest re-share the whole.\n\nconst TAU = Math.PI * 2;\n\nfunction point(cx0, cy0, r, a) {\n return [cx0 + r * Math.cos(a), cy0 + r * Math.sin(a)];\n}\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction slicePath(c, r, inner, a0, a1) {\n // A whole circle cannot be one arc: draw it as two halves.\n if (a1 - a0 >= TAU - 1e-6) {\n const mid = a0 + Math.PI;\n return slicePath(c, r, inner, a0, mid) + slicePath(c, r, inner, mid, a1);\n }\n const large = a1 - a0 > Math.PI ? 1 : 0;\n const [x0, y0] = point(c.x, c.y, r, a0);\n const [x1, y1] = point(c.x, c.y, r, a1);\n if (!inner) return `M${f(c.x)},${f(c.y)}L${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}Z`;\n const [x2, y2] = point(c.x, c.y, inner, a1);\n const [x3, y3] = point(c.x, c.y, inner, a0);\n return `M${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}L${f(x2)},${f(y2)}A${f(inner)},${f(inner)} 0 ${large} 0 ${f(x3)},${f(y3)}Z`;\n}\n\nexport function PieChart({\n data,\n value = \"value\",\n name = \"name\",\n format = defaultFormat,\n formatName = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n height = 240,\n donut = false,\n centerLabel,\n totalLabel = \"Total\",\n legend = true,\n legendValues = \"percent\",\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n const shown = useReveal(width > 0);\n\n const rows = Array.isArray(data) ? data : [];\n const slices = useMemo(\n () =>\n rows.map((r, i) => {\n const v = num(r?.[value]);\n return {\n row: r,\n key: String(r?.[name] ?? i),\n label: formatName(r?.[name], r) || humanize(r?.[name]),\n tone: toneOf(r, i),\n // A missing value is no slice and reads —, never 0 or 0%.\n value: v == null ? null : Math.max(0, v),\n };\n }),\n [rows, name, value], // eslint-disable-line react-hooks/exhaustive-deps\n );\n const live = slices.filter((s) => !hidden.has(s.key) && s.value > 0);\n const total = live.reduce((sum, s) => sum + s.value, 0);\n const pct = (v) => (v == null ? \"—\" : total ? `${Math.round((v / total) * 1000) / 10}%` : \"0%\");\n const fmt = (v) => (v == null ? \"—\" : format(v));\n\n const size = Math.max(0, Math.min(width, height));\n const c = { x: width / 2, y: height / 2 };\n const r = Math.max(0, size / 2 - 6);\n const inner = donut ? r * 0.62 : 0;\n\n let a = -Math.PI / 2;\n const arcs = live.map((s) => {\n const span = total ? (s.value / total) * TAU : 0;\n // A gap between slices by angle, not a stroke: no guess at the colour\n // of whatever the chart sits on.\n const pad = live.length > 1 ? Math.min(0.012, span / 4) : 0;\n const out = { ...s, a0: a, a1: a + span, pad };\n a += span;\n return out;\n });\n\n function onKeyDown(e) {\n if (!arcs.length) return;\n let next = null;\n if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = active == null ? 0 : (active + 1) % arcs.length;\n else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = active == null ? arcs.length - 1 : (active - 1 + arcs.length) % arcs.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = arcs.length - 1;\n else if (e.key === \"Escape\") return setActive(null);\n else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(arcs[active].row, active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n const summary = arcs.length\n ? `${arcs.length} parts of ${format(total)}: ${arcs\n .slice()\n .sort((p, q) => q.value - p.value)\n .map((s) => `${s.label} ${format(s.value)} (${pct(s.value)})`)\n .join(\", \")}.`\n : \"\";\n\n const legendNode = legend ? (\n <ChartLegend\n items={slices.map((s) => ({\n key: s.key,\n label: s.label,\n tone: s.tone,\n value: hidden.has(s.key) ? null : legendValues === \"value\" ? fmt(s.value) : legendValues === \"percent\" ? pct(s.value) : null,\n }))}\n hidden={hidden}\n onToggle={(key) => {\n setActive(null);\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n });\n }}\n label={`${label}: parts`}\n />\n ) : null;\n\n if (!rows.length) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n const hit = active != null ? arcs[active] : null;\n let tipAt = null;\n // A donut says it in its middle; a pie needs the box.\n if (hit && tooltip && !donut) {\n const mid = (hit.a0 + hit.a1) / 2;\n const [tx, ty] = point(c.x, c.y, (r + inner) / 2 + (donut ? 0 : r * 0.15), mid);\n tipAt = { x: tx, y: ty, flip: tx > c.x, middle: true };\n }\n const centre =\n centerLabel !== undefined\n ? typeof centerLabel === \"object\" && centerLabel !== null && !(\"$$typeof\" in centerLabel)\n ? centerLabel\n : { value: centerLabel }\n : { value: format(total), label: totalLabel };\n\n return (\n <div className={cx(\"grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each part.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null && arcs.length) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none\", onSelect && \"cursor-pointer\")}\n onPointerLeave={() => !keyed && setActive(null)}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {arcs.length === 0 ? (\n <circle cx={c.x} cy={c.y} r={r} className=\"fill-muted\" />\n ) : null}\n <g\n style={{ transform: shown ? \"none\" : \"rotate(-60deg) scale(0.9)\", transformOrigin: `${c.x}px ${c.y}px`, opacity: shown ? 1 : 0 }}\n className=\"motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out\"\n >\n {arcs.map((s, i) => (\n <path\n key={s.key}\n d={slicePath(c, active === i ? r + 4 : r, inner, s.a0 + s.pad, s.a1 - s.pad)}\n className={cx(tone(s.tone, \"fill\"), active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")}\n onPointerEnter={() => {\n setKeyed(false);\n setActive(i);\n }}\n onPointerDown={() => setActive(i)}\n onClick={() => onSelect?.(s.row, i)}\n />\n ))}\n </g>\n {donut && centre ? (\n <g aria-hidden=\"true\" textAnchor=\"middle\">\n <text x={c.x} y={c.y} dy={centre.label ? \"-0.1em\" : \"0.35em\"} className=\"fill-foreground text-2xl font-semibold\" data-nums>\n {hit ? format(hit.value) : centre.value}\n </text>\n {centre.label || hit ? (\n <text x={c.x} y={c.y} dy=\"1.6em\" className=\"fill-muted-foreground text-xs\">\n {hit ? `${hit.label} · ${pct(hit.value)}` : centre.label}\n </text>\n ) : null}\n </g>\n ) : null}\n </svg>\n ) : null}\n {tipAt ? <TooltipBox at={tipAt} title={hit.label} rows={[{ key: hit.key, label: pct(hit.value), tone: hit.tone, value: format(hit.value) }]} /> : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed && hit ? `${hit.label}: ${format(hit.value)}, ${pct(hit.value)}` : \"\"}\n </div>\n </div>\n {legendNode}\n <DataTable\n caption={label}\n head={[\"\", \"Value\", \"Share\"]}\n body={slices.map((s) => [s.label, fmt(s.value), hidden.has(s.key) ? \"hidden\" : pct(s.value)])}\n />\n </div>\n );\n}\n"
|
|
34
|
+
"content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { ChartEmpty, ChartLegend, DataTable, TooltipBox, defaultFormat, humanize, num, tone, toneOf, useReveal, useWidth } from \"./core\";\n\n// PieChart — parts of one whole, one slice per row; `donut` opens the\n// middle and writes the total (or your words) there. Pointer, touch or the\n// arrow keys pick a slice: it lifts, and the tooltip gives its value and\n// share. The legend hides a slice and the rest re-share the whole.\n\nconst TAU = Math.PI * 2;\n\nfunction point(cx0, cy0, r, a) {\n return [cx0 + r * Math.cos(a), cy0 + r * Math.sin(a)];\n}\n\nconst f = (n) => Math.round(n * 100) / 100;\n\nfunction slicePath(c, r, inner, a0, a1) {\n // A whole circle cannot be one arc: draw it as two halves.\n if (a1 - a0 >= TAU - 1e-6) {\n const mid = a0 + Math.PI;\n return slicePath(c, r, inner, a0, mid) + slicePath(c, r, inner, mid, a1);\n }\n const large = a1 - a0 > Math.PI ? 1 : 0;\n const [x0, y0] = point(c.x, c.y, r, a0);\n const [x1, y1] = point(c.x, c.y, r, a1);\n if (!inner) return `M${f(c.x)},${f(c.y)}L${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}Z`;\n const [x2, y2] = point(c.x, c.y, inner, a1);\n const [x3, y3] = point(c.x, c.y, inner, a0);\n return `M${f(x0)},${f(y0)}A${f(r)},${f(r)} 0 ${large} 1 ${f(x1)},${f(y1)}L${f(x2)},${f(y2)}A${f(inner)},${f(inner)} 0 ${large} 0 ${f(x3)},${f(y3)}Z`;\n}\n\nexport function PieChart({\n data,\n value = \"value\",\n name = \"name\",\n format = defaultFormat,\n formatName = (v) => (v == null ? \"\" : String(v)),\n label = \"Chart\",\n description,\n height = 240,\n donut = false,\n centerLabel,\n totalLabel = \"Total\",\n legend = true,\n legendValues = \"percent\",\n tooltip = true,\n onSelect,\n emptyLabel = \"No data yet\",\n emptyBody,\n className,\n}) {\n const [boxRef, width] = useWidth();\n const [hidden, setHidden] = useState(() => new Set());\n const [active, setActive] = useState(null);\n const [keyed, setKeyed] = useState(false);\n const shown = useReveal(width > 0);\n\n const rows = Array.isArray(data) ? data : [];\n const slices = useMemo(\n () =>\n rows.map((r, i) => {\n const v = num(r?.[value]);\n return {\n row: r,\n key: String(r?.[name] ?? i),\n label: formatName(r?.[name], r) || humanize(r?.[name]),\n tone: toneOf(r, i),\n // A missing value is no slice and reads —, never 0 or 0%.\n value: v == null ? null : Math.max(0, v),\n };\n }),\n [rows, name, value], // eslint-disable-line react-hooks/exhaustive-deps\n );\n const live = slices.filter((s) => !hidden.has(s.key) && s.value > 0);\n const total = live.reduce((sum, s) => sum + s.value, 0);\n const pct = (v) => (v == null ? \"—\" : total ? `${Math.round((v / total) * 1000) / 10}%` : \"0%\");\n const fmt = (v) => (v == null ? \"—\" : format(v));\n\n const size = Math.max(0, Math.min(width, height));\n const c = { x: width / 2, y: height / 2 };\n const r = Math.max(0, size / 2 - 6);\n const inner = donut ? r * 0.62 : 0;\n\n let a = -Math.PI / 2;\n const arcs = live.map((s) => {\n const span = total ? (s.value / total) * TAU : 0;\n // A gap between slices by angle, not a stroke: no guess at the colour\n // of whatever the chart sits on.\n const pad = live.length > 1 ? Math.min(0.012, span / 4) : 0;\n const out = { ...s, a0: a, a1: a + span, pad };\n a += span;\n return out;\n });\n\n function onKeyDown(e) {\n if (!arcs.length) return;\n let next = null;\n if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = active == null ? 0 : (active + 1) % arcs.length;\n else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = active == null ? arcs.length - 1 : (active - 1 + arcs.length) % arcs.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = arcs.length - 1;\n else if (e.key === \"Escape\") return setActive(null);\n else if ((e.key === \"Enter\" || e.key === \" \") && onSelect && active != null) {\n e.preventDefault();\n onSelect(arcs[active].row, active);\n return;\n }\n if (next == null) return;\n e.preventDefault();\n setKeyed(true);\n setActive(next);\n }\n\n const summary = arcs.length\n ? `${arcs.length} parts of ${format(total)}: ${arcs\n .slice()\n .sort((p, q) => q.value - p.value)\n .map((s) => `${s.label} ${format(s.value)} (${pct(s.value)})`)\n .join(\", \")}.`\n : \"\";\n\n const legendNode = legend ? (\n <ChartLegend\n items={slices.map((s) => ({\n key: s.key,\n label: s.label,\n tone: s.tone,\n value: hidden.has(s.key) ? null : legendValues === \"value\" ? fmt(s.value) : legendValues === \"percent\" ? pct(s.value) : null,\n }))}\n hidden={hidden}\n onToggle={(key) => {\n setActive(null);\n setHidden((h) => {\n const next = new Set(h);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n });\n }}\n label={`${label}: parts`}\n />\n ) : null;\n\n if (!rows.length) {\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <ChartEmpty height={height} title={emptyLabel} body={emptyBody} />\n </div>\n );\n }\n\n const hit = active != null ? arcs[active] : null;\n let tipAt = null;\n // A donut says it in its middle; a pie needs the box.\n if (hit && tooltip && !donut) {\n const mid = (hit.a0 + hit.a1) / 2;\n const [tx, ty] = point(c.x, c.y, (r + inner) / 2 + (donut ? 0 : r * 0.15), mid);\n tipAt = { x: tx, y: ty, flip: tx > c.x, middle: true };\n }\n const centre =\n centerLabel !== undefined\n ? typeof centerLabel === \"object\" && centerLabel !== null && !(\"$$typeof\" in centerLabel)\n ? centerLabel\n : { value: centerLabel }\n : { value: format(total), label: totalLabel };\n\n return (\n <div className={cx(\"relative grid min-w-0 gap-3\", className)}>\n <div\n ref={boxRef}\n tabIndex={0}\n role=\"group\"\n aria-label={`${label}. Arrow keys read each part.`}\n onKeyDown={onKeyDown}\n onFocus={(e) => {\n if (e.currentTarget.matches(\":focus-visible\") && active == null && arcs.length) {\n setKeyed(true);\n setActive(0);\n }\n }}\n onBlur={() => {\n setKeyed(false);\n setActive(null);\n }}\n className=\"relative min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n style={{ height }}\n >\n {width > 0 ? (\n <svg\n role=\"img\"\n aria-label={`${label}. ${description ?? summary}`}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n className={cx(\"block touch-pan-y select-none\", onSelect && \"cursor-pointer\")}\n onPointerLeave={() => !keyed && setActive(null)}\n >\n {/* raw: an SVG drawing — no kit part draws a chart. */}\n {arcs.length === 0 ? (\n <circle cx={c.x} cy={c.y} r={r} className=\"fill-muted\" />\n ) : null}\n <g\n style={{ transform: shown ? \"none\" : \"rotate(-60deg) scale(0.9)\", transformOrigin: `${c.x}px ${c.y}px`, opacity: shown ? 1 : 0 }}\n className=\"motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out\"\n >\n {arcs.map((s, i) => (\n <path\n key={s.key}\n d={slicePath(c, active === i ? r + 4 : r, inner, s.a0 + s.pad, s.a1 - s.pad)}\n className={cx(tone(s.tone, \"fill\"), active != null && active !== i && \"opacity-60\", \"motion-safe:transition-opacity\")}\n onPointerEnter={() => {\n setKeyed(false);\n setActive(i);\n }}\n onPointerDown={() => setActive(i)}\n onClick={() => onSelect?.(s.row, i)}\n />\n ))}\n </g>\n {donut && centre ? (\n <g aria-hidden=\"true\" textAnchor=\"middle\">\n <text x={c.x} y={c.y} dy={centre.label ? \"-0.1em\" : \"0.35em\"} className=\"fill-foreground text-2xl font-semibold\" data-nums>\n {hit ? format(hit.value) : centre.value}\n </text>\n {centre.label || hit ? (\n <text x={c.x} y={c.y} dy=\"1.6em\" className=\"fill-muted-foreground text-xs\">\n {hit ? `${hit.label} · ${pct(hit.value)}` : centre.label}\n </text>\n ) : null}\n </g>\n ) : null}\n </svg>\n ) : null}\n {tipAt ? <TooltipBox at={tipAt} title={hit.label} rows={[{ key: hit.key, label: pct(hit.value), tone: hit.tone, value: format(hit.value) }]} /> : null}\n <div className=\"sr-only\" aria-live=\"polite\">\n {keyed && hit ? `${hit.label}: ${format(hit.value)}, ${pct(hit.value)}` : \"\"}\n </div>\n </div>\n {legendNode}\n <DataTable\n caption={label}\n head={[\"\", \"Value\", \"Share\"]}\n body={slices.map((s) => [s.label, fmt(s.value), hidden.has(s.key) ? \"hidden\" : pct(s.value)])}\n />\n </div>\n );\n}\n"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"path": "registry/blocks/chart/sparkline.jsx",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"docs": "Installed to components/makersclaw/blocks/chart. Import: import { LineChart, AreaChart, BarChart, PieChart, Sparkline, ChartLegend, HeatmapChart, ScaleLegend, scaleStep, scaleShade } from \"@/components/makersclaw/blocks/chart\". Props and an example: components/makersclaw/blocks/chart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
77
77
|
"meta": {
|
|
78
78
|
"makersclaw": {
|
|
79
|
-
"version": "0.
|
|
79
|
+
"version": "0.8.1",
|
|
80
80
|
"useWhen": "Numbers the founder should see as a shape: revenue by month, deals by stage, pipeline by source, a KPI's trend. Not for a single figure with no history (a stat), exact lookups (a table), or records on a timeline (a calendar)."
|
|
81
81
|
}
|
|
82
82
|
}
|
package/r/chat-pane.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "chat-pane",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "ChatPane",
|
|
6
|
+
"description": "The desk's chat pane as the app's own, in the app's theme: the chrome around a conversation. A head (title, New chat, Chats, close) with the past chats dropping over the chat; before the first ask one line, the recent chats and the suggestions; the conversation, kept at the newest words until the member scrolls up, with a jump to latest; a bar for what waits on the member; and the desk's composer: ONE rounded box with the words on top and under them a row, + (attach) at the left and the microphone and a round arrow to send at the right, the arrow a stop while a turn runs, a quiet hint under the box. Files by + or paste or drop, dictation, the box closed while something waits. A starting line handed down by the dock (openAgent) is sent as the member's first words in a fresh chat, or put in the box. It draws what useAgentPane() returns (lib/hooks.js) and decides nothing about the lines of the conversation: those are its children (agent-pane draws the agent's).",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/blocks/chat-pane/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/chat-pane/index.js",
|
|
16
|
+
"content": "// The ChatPane block — `makersclaw add chat-pane`. Fixed: compose it\n// through its props (README.md beside this file). The model's helpers are\n// plain functions; they work on the server too.\nexport { ChatPane } from \"./chat-pane\";\nexport { ChatComposer } from \"./chat-composer\";\nexport { ChatsList } from \"./chats-list\";\nexport { ChatDockContext } from \"./chat-context\";\nexport { ago, startStep } from \"./chat-model\";\nexport { MAX_BYTES, MAX_FILES, addFiles, filesFromTransfer, formatBytes, readFile, useDictation } from \"./chat-attach\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/chat-pane/chat-pane.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/chat-pane/chat-pane.jsx",
|
|
22
|
+
"content": "\"use client\";\n\nimport { useContext, useEffect, useRef, useState } from \"react\";\nimport { Button, Icon, IconButton, Spinner, cx } from \"../../ui\";\nimport { addFiles, filesFromTransfer, readFile, useDictation } from \"./chat-attach\";\nimport { ChatDockContext } from \"./chat-context\";\nimport { ChatComposer } from \"./chat-composer\";\nimport { ChatsList } from \"./chats-list\";\nimport { startStep } from \"./chat-model\";\n\n// ChatPane — the desk's chat pane (claw's web/src/os/windows/artifact/\n// ChatPane.tsx and the composer of its EveChat), as the app's own, in the\n// app's theme (0.8.1, X5; the founder: \"add claw's ChatPane as a block in\n// the makersclaw template\"). It is the chrome around a conversation:\n//\n// head the title, New chat, Chats (the list drops over the chat,\n// the desk's column mode), close\n// empty before the first ask: one line, the past chats, the\n// suggestions\n// scroll the conversation, which stays at the newest words until the\n// member scrolls up, with a jump to latest\n// bar what waits on the member, or says the agent is working\n// composer ONE box: the words on top; under them + (attach) at the\n// left, the microphone and a round arrow at the right, the\n// arrow a stop while a turn runs; a quiet hint under it\n//\n// It is not the conversation's lines: those are the children. agent-pane\n// draws the agent's (the plan, the work folded, the questions, what it\n// made); any lines will do. Nothing here asks the agent anything: `chat`\n// is an object of the shape useAgentPane() returns (lib/hooks.js), and the\n// pane calls its send, stop, newChat and openSession.\n//\n// A \"have the agent do it\" button reaches the pane through the dock:\n// openAgent({ ask }) (blocks/agent-dock) hands the pane a starting line,\n// which it sends as the member's first words in a fresh chat (or puts in\n// the box). The agent asks only for what it is missing, in the chat.\n//\n// chat the object useAgentPane() returns (required)\n// title the head's words, and the box's label (default \"Ask the agent\")\n// suggestions asks that fit the moment, before the first ask\n// empty one line before the first ask\n// placeholder the box's words before the first ask; once there is a\n// conversation it says \"Ask a follow-up…\" (D29) unless\n// `chat.sameConversation` is false (the next message starts a\n// new request, and the box keeps the app's own words)\n// closedWords the box's words while `blocked` (\"Answer above first\")\n// hint a quiet line under the box\n// recent past chats listed before the first ask (default 5; 0 none)\n// bare no head (inside a Sheet or a Card that has its own)\n// fill take the container's height (default); false: compact,\n// the conversation scrolls inside a capped height\n// onClose a close in the head; inside an AgentDock it is the dock's\n// started there is a conversation to draw (else the empty state)\n// busy a turn runs: the arrow is a stop while the box is empty\n// blocked something waits on the member: the whole box is closed\n// bar a node between the conversation and the box\n// children the conversation's lines\n// className layout around it. Never colour.\nexport function ChatPane({\n chat,\n title = \"Ask the agent\",\n suggestions = [],\n empty,\n placeholder,\n closedWords = \"Not available right now\",\n hint,\n recent = 5,\n bare = false,\n fill = true,\n onClose: onCloseProp,\n started = false,\n busy = false,\n blocked = false,\n bar,\n className,\n children,\n}) {\n const dock = useContext(ChatDockContext);\n const onClose = onCloseProp ?? dock?.close;\n const [text, setText] = useState(\"\");\n const [listOpen, setListOpen] = useState(false);\n const [stopping, setStopping] = useState(false);\n const [atBottom, setAtBottom] = useState(true);\n const [files, setFiles] = useState([]);\n const [attachError, setAttachError] = useState(null);\n const [encoding, setEncoding] = useState(false);\n const [dragOver, setDragOver] = useState(false);\n const fileInput = useRef(null);\n const textarea = useRef(null);\n const dragDepth = useRef(0);\n const listRef = useRef(null);\n const rootRef = useRef(null);\n const stick = useRef(true);\n const chatRef = useRef(chat);\n chatRef.current = chat;\n\n const chats = chat?.chats && !chat.chats.unavailable ? chat.chats : null;\n const canNew = typeof chat?.newChat === \"function\";\n const canStop = busy && !blocked && Boolean(chat?.request?.id) && Boolean(chat?.stop);\n\n useEffect(() => {\n if (!busy) setStopping(false);\n }, [busy]);\n\n // Dictation adds finished phrases to the box.\n const dictation = useDictation((words) => setText((t) => (t.trim() ? `${t.trimEnd()} ${words}` : words)));\n\n const attach = (incoming) => {\n const r = addFiles(files, incoming);\n setFiles(r.files);\n setAttachError(r.error);\n };\n\n // The Chats list closes on a press outside it and on Escape.\n useEffect(() => {\n if (!listOpen) return undefined;\n const onDown = (e) => {\n if (!rootRef.current?.querySelector(\"[data-chats]\")?.contains(e.target) && !e.target.closest?.(\"[data-chats-toggle]\")) setListOpen(false);\n };\n const onKey = (e) => {\n if (e.key === \"Escape\") setListOpen(false);\n };\n document.addEventListener(\"mousedown\", onDown);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"mousedown\", onDown);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [listOpen]);\n\n // The newest words stay in view while the agent writes — until the\n // member scrolls up to read, which stops it following.\n useEffect(() => {\n const el = listRef.current;\n if (el && stick.current) el.scrollTop = el.scrollHeight;\n }, [chat?.view, chat?.turns, chat?.pending, chat?.state, chat?.needs?.at, started]);\n const onScroll = () => {\n const el = listRef.current;\n if (!el) return;\n const near = el.scrollHeight - el.scrollTop - el.clientHeight < 48;\n stick.current = near;\n setAtBottom(near);\n };\n const jump = () => {\n const el = listRef.current;\n if (el) el.scrollTo({ top: el.scrollHeight, behavior: \"smooth\" });\n };\n\n const submit = async (words) => {\n const typed = String(words ?? text).trim();\n const sending = words === undefined ? files : [];\n // Files without words still go: the agent is told what they are.\n const t = typed || (sending.length ? `Attached: ${sending.map((f) => f.name).join(\", \")}` : \"\");\n if (!t || !chat?.send || encoding) return;\n dictation.stop();\n stick.current = true;\n if (sending.length === 0) {\n setText(\"\");\n chat.send(t);\n return;\n }\n setEncoding(true);\n try {\n const read = await Promise.all(sending.map(readFile));\n setText(\"\");\n setFiles([]);\n setAttachError(null);\n chat.send(t, { files: read });\n } catch {\n setAttachError(\"A file could not be read. Try attaching it again.\");\n } finally {\n setEncoding(false);\n }\n };\n const stop = () => {\n if (stopping || !chat?.stop) return;\n setStopping(true);\n chat.stop();\n };\n\n // A starting line from the dock (openAgent). Fresh: a new conversation\n // first, then the line sent as its first words, so the agent asks only\n // for what it is missing. Or prefilled for the member to finish. The pane\n // tells the dock it took it; a pane that has not mounted yet takes it when\n // it does.\n const start = dock?.start ?? null;\n const takenRef = useRef(null);\n const newedRef = useRef(null);\n useEffect(() => {\n if (!start) return;\n const step = startStep({\n start,\n handled: takenRef.current,\n sessionId: chat?.sessionId ?? null,\n blocked,\n canNew,\n newed: newedRef.current === start.nonce,\n });\n if (step === \"none\" || step === \"wait\") return;\n if (step === \"new-chat\") {\n newedRef.current = start.nonce;\n setListOpen(false);\n chatRef.current?.newChat?.();\n return;\n }\n takenRef.current = start.nonce;\n if (step === \"send\") {\n stick.current = true;\n chatRef.current?.send?.(String(start.ask).trim());\n } else {\n // Words already in the box are the member's: the line goes under them.\n setText((t) => (t.trim() ? `${t.trimEnd()}\\n${start.ask}` : String(start.ask)));\n requestAnimationFrame(() => {\n const el = textarea.current;\n if (!el || el.disabled) return;\n el.focus({ preventScroll: true });\n el.setSelectionRange(el.value.length, el.value.length);\n });\n }\n dock?.taken?.(start.nonce);\n }, [start, chat?.sessionId, blocked, canNew, dock]);\n\n const followUp = started && chat?.sameConversation !== false;\n const words = blocked ? closedWords : followUp ? \"Ask a follow-up…\" : (placeholder ?? \"Ask anything…\");\n\n return (\n <div\n ref={rootRef}\n className={cx(\"@container relative flex min-w-0 flex-col bg-background text-sm text-foreground\", fill && \"h-full min-h-0\", className)}\n onDragEnter={(e) => {\n if (!e.dataTransfer?.types?.includes(\"Files\") || blocked) return;\n e.preventDefault();\n dragDepth.current += 1;\n setDragOver(true);\n }}\n onDragLeave={(e) => {\n if (!e.dataTransfer?.types?.includes(\"Files\")) return;\n dragDepth.current = Math.max(0, dragDepth.current - 1);\n if (dragDepth.current === 0) setDragOver(false);\n }}\n onDragOver={(e) => {\n if (e.dataTransfer?.types?.includes(\"Files\")) e.preventDefault();\n }}\n onDrop={(e) => {\n if (!e.dataTransfer?.types?.includes(\"Files\")) return;\n e.preventDefault();\n dragDepth.current = 0;\n setDragOver(false);\n if (!blocked) attach(filesFromTransfer(e.dataTransfer));\n }}\n >\n {dragOver ? (\n <div className=\"pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-[var(--radius)] border-2 border-dashed border-primary bg-background/80 text-sm font-medium\">\n Drop files to attach\n </div>\n ) : null}\n {!bare ? (\n <div className=\"flex h-10 shrink-0 items-center gap-1 border-b border-border pl-3.5 pr-2 font-semibold\">\n <Icon name=\"sparkles\" size={16} className=\"shrink-0 text-primary\" />\n <span className=\"min-w-0 flex-1 truncate\">{title}</span>\n {canNew ? (\n <IconButton\n icon=\"plus\"\n size=\"sm\"\n label=\"New chat\"\n onClick={() => {\n setListOpen(false);\n chat.newChat();\n }}\n />\n ) : null}\n {chats ? (\n <Button size=\"sm\" variant=\"quiet\" data-chats-toggle=\"\" aria-expanded={listOpen} aria-label=\"Chats\" onClick={() => setListOpen((v) => !v)}>\n <Icon name=\"history\" size={14} />\n <span className=\"hidden @[16rem]:inline\">Chats</span>\n {chats.items.length > 0 ? <span className=\"text-xs text-muted-foreground\">{chats.items.length}</span> : null}\n </Button>\n ) : null}\n {onClose ? <IconButton icon=\"x\" size=\"sm\" label=\"Close\" onClick={onClose} /> : null}\n </div>\n ) : null}\n\n {listOpen && chats ? (\n <div data-chats=\"\" className=\"absolute inset-x-0 z-10 max-h-72 overflow-y-auto border-b border-border bg-popover text-popover-foreground shadow-md\" style={{ top: bare ? 0 : \"2.5rem\" }}>\n <ChatsList\n chats={chats}\n currentId={chat?.sessionId}\n onPick={(id) => {\n setListOpen(false);\n chat.openSession?.(id);\n }}\n />\n </div>\n ) : null}\n\n <div className={cx(\"relative\", fill ? \"min-h-0 flex-1\" : \"\")}>\n <div\n ref={listRef}\n onScroll={onScroll}\n role=\"log\"\n aria-live=\"polite\"\n aria-label={title}\n className={cx(\"grid min-w-0 content-start gap-3 overflow-y-auto overscroll-contain p-3.5\", fill ? \"h-full min-h-0\" : started ? \"max-h-96\" : \"\")}\n >\n {started ? (\n children\n ) : (\n <>\n {empty ? <p className=\"text-muted-foreground\">{empty}</p> : null}\n {chat?.loading ? (\n <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <Spinner size=\"sm\" label=\"Loading\" /> Opening the chat…\n </div>\n ) : null}\n {chats && recent > 0 && !chat?.loading ? (\n <section aria-label=\"Recent chats\" className=\"grid min-w-0 gap-0.5\">\n <h3 className=\"text-xs font-medium text-muted-foreground\">Recent chats</h3>\n <div className=\"-mx-3.5\">\n <ChatsList chats={{ ...chats, items: chats.items.slice(0, recent) }} currentId={chat?.sessionId} onPick={(id) => chat.openSession?.(id)} />\n </div>\n {chats.items.length > recent ? (\n <Button variant=\"link\" size=\"inherit\" className=\"justify-self-start text-xs\" onClick={() => setListOpen(true)}>\n All {chats.items.length} chats\n </Button>\n ) : null}\n </section>\n ) : null}\n {suggestions.length ? (\n <div className=\"flex min-w-0 flex-wrap gap-1.5\">\n {suggestions.map((s) => (\n <button\n key={s}\n type=\"button\"\n onClick={() => submit(s)}\n className=\"flex max-w-full min-w-0 items-start gap-1.5 rounded-[var(--radius)] border border-border px-2.5 py-1 text-left text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring\"\n >\n <Icon name=\"sparkles\" size={12} className=\"mt-0.5 shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 break-words\">{s}</span>\n </button>\n ))}\n </div>\n ) : null}\n </>\n )}\n </div>\n {!atBottom && started ? (\n <IconButton icon=\"arrow-down\" size=\"sm\" label=\"Jump to latest\" onClick={jump} className=\"absolute bottom-2 right-3 shadow-md\" />\n ) : null}\n </div>\n\n {bar}\n {chat?.error ? <p className=\"shrink-0 px-3.5 pt-2 text-xs text-destructive\">{chat.error}</p> : null}\n {chat?.notice ? (\n <p role=\"status\" className=\"shrink-0 px-3.5 pt-2 text-xs text-muted-foreground\">\n {chat.notice}\n </p>\n ) : null}\n {chat?.unavailable ? (\n <p className=\"shrink-0 px-3.5 pt-2 text-xs text-muted-foreground\">\n This chat needs a newer Makersclaw.{\" \"}\n {chat.request ? (\n <Button variant=\"link\" size=\"inherit\" onClick={() => chat.openOnDesk?.()}>\n Open it on the desk\n </Button>\n ) : null}\n </p>\n ) : null}\n {attachError || dictation.error ? (\n <p role=\"alert\" className=\"shrink-0 px-3.5 pt-2 text-xs text-destructive\">\n {attachError ?? dictation.error}\n </p>\n ) : null}\n\n <ChatComposer\n className=\"shrink-0 px-3 pb-3 pt-2\"\n label={title}\n value={text}\n onChange={setText}\n onSubmit={() => submit()}\n onStop={stop}\n onAttach={attach}\n files={files}\n onRemoveFile={(i) => setFiles((all) => all.filter((_, j) => j !== i))}\n busy={busy}\n canStop={canStop}\n stopping={stopping}\n blocked={blocked}\n sending={Boolean(chat?.sending)}\n encoding={encoding}\n dictation={dictation}\n placeholder={words}\n hint={hint}\n textareaRef={textarea}\n fileInputRef={fileInput}\n />\n </div>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/chat-pane/chat-composer.jsx",
|
|
26
|
+
"type": "registry:component",
|
|
27
|
+
"target": "@components/makersclaw/blocks/chat-pane/chat-composer.jsx",
|
|
28
|
+
"content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Icon, cx } from \"../../ui\";\nimport { filesFromTransfer, formatBytes } from \"./chat-attach\";\n\n// ChatComposer — the desk's message box (claw's ChatComposer, as the app\n// draws it): ONE rounded box. The words on top, full width, growing with\n// what is typed; under them a row with + (attach) at the left, and at the\n// right the microphone and a round arrow to send. While a turn runs, a round\n// stop takes the arrow's place. A quiet hint can sit under the box.\n//\n// It draws the box and nothing else: the words, the files and the dictation\n// are the pane's (so a drop anywhere on the pane, a prefilled line and a\n// sent suggestion all reach the same box).\n//\n// value, onChange the words (controlled)\n// onSubmit() Enter, or the arrow\n// onStop() the stop (shown while `busy` and `canStop`)\n// onAttach(File[]) the + and a paste\n// files, onRemoveFile(i) what is attached, as chips inside the box\n// busy a turn runs: the arrow is a stop while the box is empty\n// canStop, stopping the stop can be pressed / has been\n// blocked something waits on the member: the whole box is closed\n// sending, encoding the arrow waits\n// dictation useDictation()'s object (no microphone where the\n// browser has none)\n// placeholder, label the box's words, and its name for a screen reader\n// hint a quiet line under the box\n// textareaRef the pane's handle on the box (focus, caret)\n\nconst ROUND = \"inline-flex size-8 shrink-0 items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:cursor-not-allowed disabled:opacity-40\";\n\nexport function ChatComposer({\n value,\n onChange,\n onSubmit,\n onStop,\n onAttach,\n files = [],\n onRemoveFile,\n busy = false,\n canStop = false,\n stopping = false,\n blocked = false,\n sending = false,\n encoding = false,\n dictation,\n placeholder,\n label = \"Message\",\n hint,\n textareaRef,\n fileInputRef,\n className,\n}) {\n const empty = !value.trim() && files.length === 0;\n const stopShown = busy && canStop && empty;\n\n // The box grows with its words, up to its max-h; a hidden box measures 0\n // and is left natural (pinning 0px would stick).\n useEffect(() => {\n const el = textareaRef?.current;\n if (!el) return;\n el.style.height = \"auto\";\n if (el.scrollHeight > 0) el.style.height = `${el.scrollHeight}px`;\n }, [value, textareaRef]);\n\n return (\n <div className={cx(\"grid min-w-0 gap-1.5\", className)}>\n <form\n data-chat-composer=\"\"\n className=\"grid min-w-0 gap-2 rounded-xl border border-border bg-card px-3 pb-2 pt-2.5 text-card-foreground shadow-sm transition-colors focus-within:border-ring motion-reduce:transition-none\"\n onSubmit={(e) => {\n e.preventDefault();\n onSubmit?.();\n }}\n >\n {files.length > 0 ? (\n <ul aria-label=\"Files to send\" className=\"flex min-w-0 flex-wrap gap-1.5\">\n {files.map((f, i) => (\n <li key={`${f.name}-${i}`} className=\"inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md border border-border py-0.5 pl-2 pr-1 text-xs\">\n <Icon name=\"paperclip\" size={12} className=\"shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 truncate\">{f.name}</span>\n <span className=\"shrink-0 text-muted-foreground\">{formatBytes(f.size)}</span>\n <button\n type=\"button\"\n aria-label={`Remove ${f.name}`}\n title={`Remove ${f.name}`}\n onClick={() => onRemoveFile?.(i)}\n className=\"inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"x\" size={12} />\n </button>\n </li>\n ))}\n </ul>\n ) : null}\n\n <textarea\n ref={textareaRef}\n aria-label={label}\n rows={1}\n placeholder={placeholder}\n value={value}\n disabled={blocked}\n onChange={(e) => onChange?.(e.target.value)}\n onPaste={(e) => {\n const pasted = filesFromTransfer(e.clipboardData);\n if (pasted.length > 0) {\n e.preventDefault();\n onAttach?.(pasted);\n }\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n e.preventDefault();\n onSubmit?.();\n }\n }}\n className=\"block max-h-40 min-h-6 w-full min-w-0 resize-none overflow-y-auto bg-transparent text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n />\n\n {dictation?.listening ? (\n <p role=\"status\" className=\"flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n <span aria-hidden=\"true\" className=\"size-1.5 shrink-0 animate-pulse rounded-full bg-destructive motion-reduce:animate-none\" />\n Listening…\n {dictation.interim ? <span className=\"min-w-0 truncate italic\">{dictation.interim}</span> : null}\n </p>\n ) : null}\n\n <div className=\"flex items-center gap-1.5\">\n <input\n ref={fileInputRef}\n type=\"file\"\n multiple\n hidden\n aria-hidden=\"true\"\n tabIndex={-1}\n onChange={(e) => {\n onAttach?.(Array.from(e.target.files ?? []));\n e.target.value = \"\";\n }}\n />\n <button\n type=\"button\"\n aria-label=\"Attach a file\"\n title=\"Attach a file\"\n disabled={blocked || encoding}\n onClick={() => fileInputRef?.current?.click()}\n className={cx(ROUND, \"rounded-lg border border-border text-muted-foreground hover:bg-accent hover:text-foreground\")}\n >\n <Icon name=\"plus\" size={16} />\n </button>\n <span className=\"flex-1\" />\n {dictation?.supported ? (\n <button\n type=\"button\"\n aria-label={dictation.listening ? \"Stop dictating\" : \"Dictate\"}\n title={dictation.listening ? \"Stop dictating\" : \"Dictate\"}\n aria-pressed={dictation.listening}\n disabled={blocked}\n onClick={dictation.toggle}\n className={cx(ROUND, \"rounded-lg hover:bg-accent\", dictation.listening ? \"text-destructive\" : \"text-muted-foreground hover:text-foreground\")}\n >\n <Icon name=\"mic\" size={16} />\n </button>\n ) : null}\n {stopShown ? (\n <button\n type=\"button\"\n aria-label={stopping ? \"Stopping…\" : \"Stop\"}\n title={stopping ? \"Stopping…\" : \"Stop\"}\n disabled={stopping}\n onClick={onStop}\n className={cx(ROUND, \"rounded-full bg-primary text-primary-foreground hover:opacity-90\")}\n >\n <Icon name=\"stop\" size={12} />\n </button>\n ) : (\n <button\n type=\"submit\"\n aria-label=\"Send\"\n title=\"Send\"\n disabled={blocked || encoding || sending || empty}\n className={cx(ROUND, \"rounded-full bg-primary text-primary-foreground hover:opacity-90\")}\n >\n <Icon name=\"arrow-up\" size={16} />\n </button>\n )}\n </div>\n </form>\n {hint ? <p className=\"px-1 text-xs text-muted-foreground\">{hint}</p> : null}\n </div>\n );\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/blocks/chat-pane/chats-list.jsx",
|
|
32
|
+
"type": "registry:component",
|
|
33
|
+
"target": "@components/makersclaw/blocks/chat-pane/chats-list.jsx",
|
|
34
|
+
"content": "\"use client\";\n\nimport { cx } from \"../../ui\";\nimport { ago } from \"./chat-model\";\n\n/**\n * The member's past chats, newest first, one line each: the title, a dot on\n * one that needs them (warning) or is working (info), and its age. The\n * pane's Chats overlay and its empty state both draw this.\n *\n * chats { items: [{ sessionId, title, mark: \"needs\" | \"working\" | null, at }],\n * loading, error }\n * currentId the open chat's session\n * onPick (sessionId) => void\n */\nexport function ChatsList({ chats, currentId, onPick }) {\n if (chats.items.length === 0)\n return (\n <p className=\"px-3.5 py-3 text-xs text-muted-foreground\">{chats.loading ? \"Loading chats…\" : chats.error ? \"The chats did not load.\" : \"No chats yet. Your first message starts one.\"}</p>\n );\n return (\n <nav aria-label=\"Past chats\" className=\"grid\">\n {chats.items.map((c) => (\n <button\n key={c.sessionId}\n type=\"button\"\n aria-current={c.sessionId === currentId ? \"true\" : undefined}\n onClick={() => onPick(c.sessionId)}\n className={cx(\n \"flex min-w-0 items-center gap-2 px-3.5 py-2 text-left text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring\",\n c.sessionId === currentId && \"bg-muted font-medium\",\n )}\n >\n <span className=\"min-w-0 flex-1 truncate\" title={c.title}>\n {c.title}\n </span>\n {c.mark ? (\n <span\n role=\"img\"\n aria-label={c.mark === \"needs\" ? \"Needs you\" : \"Working\"}\n title={c.mark === \"needs\" ? \"Needs you\" : \"Working\"}\n className={cx(\"size-2 shrink-0 rounded-full\", c.mark === \"needs\" ? \"bg-warning\" : \"animate-pulse bg-info\")}\n />\n ) : null}\n <time className=\"shrink-0 text-muted-foreground\" dateTime={c.at ?? undefined}>\n {ago(c.at)}\n </time>\n </button>\n ))}\n </nav>\n );\n}\n"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "registry/blocks/chat-pane/chat-context.js",
|
|
38
|
+
"type": "registry:component",
|
|
39
|
+
"target": "@components/makersclaw/blocks/chat-pane/chat-context.js",
|
|
40
|
+
"content": "\"use client\";\n\nimport { createContext } from \"react\";\n\n// What a ChatPane placed inside an AgentDock (blocks/agent-dock) is told:\n//\n// close folds the dock (the head's close, when the app gave no onClose)\n// mode \"column\" or \"sheet\"\n// start the starting line a \"have the agent do it\" button asked for\n// (openAgent({ ask }), blocks/agent-dock), or null:\n// { ask, send, fresh, nonce }\n// taken (nonce) => void — the pane took it; the dock drops it\n//\n// Null outside a dock, where nothing changes. It lives here, beside the\n// pane, so the pane never imports the dock; agent-pane re-exports it as\n// AgentDockContext, which is the name the dock and apps have always used.\nexport const ChatDockContext = createContext(null);\n"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "registry/blocks/chat-pane/chat-model.js",
|
|
44
|
+
"type": "registry:component",
|
|
45
|
+
"target": "@components/makersclaw/blocks/chat-pane/chat-model.js",
|
|
46
|
+
"content": "// chat-pane: the rules that need no React, as plain functions (they work on\n// the server too, and test/chat-pane.test.mjs holds them).\n\n/** \"now\", \"5m\", \"3h\", \"2d\", else the date: a past chat's age. */\nexport function ago(iso, now = Date.now()) {\n const t = iso ? Date.parse(iso) : Number.NaN;\n if (!Number.isFinite(t)) return \"\";\n const s = Math.max(0, Math.round((now - t) / 1000));\n if (s < 60) return \"now\";\n if (s < 3600) return `${Math.floor(s / 60)}m`;\n if (s < 86400) return `${Math.floor(s / 3600)}h`;\n if (s < 7 * 86400) return `${Math.floor(s / 86400)}d`;\n return new Date(t).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" });\n}\n\n/**\n * What the pane does next with a starting line the dock holds (openAgent in\n * blocks/agent-dock). A \"have the agent do it\" button does not open a form:\n * it hands the agent its first line, and the agent asks only for what it is\n * missing, in the chat.\n *\n * start { ask, send, fresh, nonce } or null\n * handled the nonce this pane already took, or null\n * sessionId the conversation open now (null: a fresh one)\n * blocked the box is closed (something waits on the member)\n * canNew the chat can start a new conversation (chat.newChat)\n * newed newChat was already called for this nonce\n *\n * → \"none\" nothing to do\n * \"new-chat\" start a fresh conversation first (fresh, and one is open)\n * \"wait\" the fresh conversation has not replaced the old one yet\n * \"send\" send the line as the member's first words\n * \"prefill\" put the line in the box for the member to finish (send is\n * false, or the box is closed and cannot send)\n */\nexport function startStep({ start, handled = null, sessionId = null, blocked = false, canNew = true, newed = false } = {}) {\n if (!start || !String(start.ask ?? \"\").trim()) return \"none\";\n if (handled !== null && handled === start.nonce) return \"none\";\n if (start.send === false || blocked) return \"prefill\";\n const fresh = start.fresh !== false && canNew;\n if (fresh && sessionId) return newed ? \"wait\" : \"new-chat\";\n return \"send\";\n}\n"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "registry/blocks/chat-pane/chat-attach.js",
|
|
50
|
+
"type": "registry:component",
|
|
51
|
+
"target": "@components/makersclaw/blocks/chat-pane/chat-attach.js",
|
|
52
|
+
"content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n// The composer's parts (0.8.1, D27 phase 4): files to send with the words,\n// and dictation. Both are the browser's own — nothing here reads a door. The\n// platform checks the files again (agent-app app-attachments.ts) and the\n// limits below are its. They lived in agent-pane/composer.jsx until the\n// chat-pane block (X5); that file now re-exports them from here.\n\n/** At most this many files, and this many bytes together, per message. */\nexport const MAX_FILES = 4;\nexport const MAX_BYTES = 3 * 1024 * 1024;\n\nexport function formatBytes(bytes) {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * Add `incoming` files to `current`, within the limits.\n * → { files, error } `error` is words for the member, or null\n */\nexport function addFiles(current, incoming) {\n const files = [...current];\n let error = null;\n let total = files.reduce((n, f) => n + f.size, 0);\n for (const file of incoming) {\n if (files.length >= MAX_FILES) {\n error = `Send at most ${MAX_FILES} files at a time.`;\n break;\n }\n if (file.size === 0) {\n error = `${file.name} is empty.`;\n continue;\n }\n if (total + file.size > MAX_BYTES) {\n error = `Files can be up to ${formatBytes(MAX_BYTES)} together here. Attach a smaller one, or attach it in the Makersclaw window.`;\n continue;\n }\n total += file.size;\n files.push(file);\n }\n return { files, error };\n}\n\n/** A File as the ask takes it: { name, type, data } with a base64 data URL. */\nexport function readFile(file) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve({ name: file.name, type: file.type || \"application/octet-stream\", data: String(reader.result) });\n reader.onerror = () => reject(reader.error ?? new Error(\"read failed\"));\n reader.readAsDataURL(file);\n });\n}\n\n/** Files off a paste or a drop. A pasted image is named \"image.png\" by the\n * browser; stamp it so several stay apart in the thread. */\nexport function filesFromTransfer(dt) {\n if (!dt) return [];\n const out = [];\n for (const item of Array.from(dt.items ?? [])) {\n if (item.kind !== \"file\") continue;\n const file = item.getAsFile();\n if (!file) continue;\n out.push(\n file.name === \"image.png\" && file.type.startsWith(\"image/\")\n ? new File([file], `pasted-${new Date().toISOString().replace(/[:.]/g, \"-\")}.png`, { type: file.type })\n : file,\n );\n }\n return out;\n}\n\nfunction recognitionCtor() {\n if (typeof window === \"undefined\") return null;\n return window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null;\n}\n\n/**\n * Dictation: the microphone to text through the browser's speech\n * recognition (the desk's composer does the same). `onText(words)` hears\n * each finished phrase. `supported` is false where the browser has none,\n * and the pane draws no microphone. `error` says when the microphone is\n * refused (a frame the desk has not allowed it in, or the member said no).\n */\nexport function useDictation(onText) {\n const [supported] = useState(() => recognitionCtor() !== null);\n const [listening, setListening] = useState(false);\n const [interim, setInterim] = useState(\"\");\n const [error, setError] = useState(null);\n const recRef = useRef(null);\n const wantRef = useRef(false);\n const onTextRef = useRef(onText);\n useLayoutEffect(() => {\n onTextRef.current = onText;\n }, [onText]);\n\n const stop = useCallback(() => {\n wantRef.current = false;\n recRef.current?.stop();\n recRef.current = null;\n setListening(false);\n setInterim(\"\");\n }, []);\n\n const start = useCallback(() => {\n const Ctor = recognitionCtor();\n if (!Ctor || recRef.current) return;\n setError(null);\n const rec = new Ctor();\n rec.continuous = true;\n rec.interimResults = true;\n rec.lang = (typeof navigator !== \"undefined\" && navigator.language) || \"en-US\";\n rec.onresult = (event) => {\n let interimText = \"\";\n for (let i = event.resultIndex; i < event.results.length; i += 1) {\n const result = event.results[i];\n const transcript = result[0]?.transcript ?? \"\";\n if (result.isFinal) {\n if (transcript.trim()) onTextRef.current(transcript.trim());\n } else {\n interimText += transcript;\n }\n }\n setInterim(interimText);\n };\n rec.onend = () => {\n recRef.current = null;\n setInterim(\"\");\n if (wantRef.current) {\n // Silence ends a recognition; while the toggle is on, listen again.\n try {\n rec.start();\n recRef.current = rec;\n return;\n } catch {\n wantRef.current = false;\n }\n }\n setListening(false);\n };\n rec.onerror = (event) => {\n if (event.error === \"not-allowed\" || event.error === \"service-not-allowed\") {\n wantRef.current = false;\n setListening(false);\n setError(\"The microphone is not allowed here. Allow it for this site, or type instead.\");\n }\n };\n wantRef.current = true;\n try {\n rec.start();\n } catch {\n wantRef.current = false;\n setError(\"The microphone could not start.\");\n return;\n }\n recRef.current = rec;\n setListening(true);\n }, []);\n\n const toggle = useCallback(() => (listening ? stop() : start()), [listening, start, stop]);\n useEffect(() => stop, [stop]);\n return { supported, listening, interim, error, toggle, stop };\n}\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "registry/blocks/chat-pane/README.md",
|
|
56
|
+
"type": "registry:file",
|
|
57
|
+
"target": "@components/makersclaw/blocks/chat-pane/README.md",
|
|
58
|
+
"content": "# ChatPane\n\nThe desk's chat pane, as the app's own, in the app's theme (0.8.1). The\nfounder, after reviewing the app agent's message box next to the\nAutomations window's: \"add claw's ChatPane as a block in the makersclaw\ntemplate\". This is that port: claw's `web/src/os/windows/artifact/ChatPane`\nand the composer of its `EveChat`, in JSX, with the kit's `Icon` and\n`Button` and the app's tokens, talking to the agent through the app's own\nhook (`useAgentPane`, `lib/hooks.js`) instead of `eve/client`.\n\nIt is the **chrome around a conversation**:\n\n- **The head**: the title, **New chat**, **Chats** (the past chats drop over\n the conversation, newest first, with a dot on one that needs you or is\n working), and a close.\n- **The empty state**: before the first ask, one line, the recent chats\n (\"No chats yet. Your first message starts one.\" when there are none), and\n the app's suggestions.\n- **The conversation**: its lines are the children. It stays at the newest\n words until the member scrolls up, then offers a jump to latest.\n- **The bar** between the conversation and the box: what waits on the member,\n or that the agent is working.\n- **The message box**: the desk's composer. **One rounded box.** The words\n on top, full width, growing with what is typed. Under them a row: **+**\n (attach) at the left, and at the right the **microphone** and a **round\n arrow** to send. **While a turn runs, a round stop takes the arrow's\n place** (once the member types, the arrow comes back: a message sent then\n waits its turn, as before). A quiet `hint` can sit under the box.\n- **Files** by the + button, by paste and by a drop anywhere on the pane (up\n to 4 files and 3 MB together); **dictation** where the browser has speech\n recognition; the box **closed** while something waits on the member, with\n the reason in its placeholder.\n- **A starting line** from the dock: `openAgent({ ask })` (blocks/agent-dock).\n The pane sends it as the member's first words in a new chat, or puts it\n in the box. See \"Have the agent do it\" below.\n\n```jsx\nimport { ChatPane } from \"../components/blocks/chat-pane\"; // a Makersclaw app (\"./blocks/chat-pane\" from components/)\nimport { ChatPane } from \"@/components/makersclaw/blocks/chat-pane\"; // a shadcn project\n```\n\nA client component. It needs the kit and nothing else; it is not tied to the\nagent's lines. **For the agent's conversation use `agent-pane`**, which\ndraws the plan, the work folded into \"Worked through N steps\", the\nquestions, the yes and what the agent made, inside this pane. Use `ChatPane`\nitself for lines of your own in the desk's frame.\n\n## What was ported, and what was not\n\nPorted: the head (New chat, Chats), the past chats list and its marks, the\nempty state, the scroll that follows, the composer (its layout, the\nattachments, the dictation, the send and stop), the closed box, and the\nhand-off seam in a form the app needs (the starting line).\n\nNot ported, because the app has no such thing: the desk's **rail** beside the\nchat (an app's agent lives in the dock, a column; the Chats overlay is the\ncolumn mode's), the **binding** (headers, scope, the session pointer: the\napp's `useAgentPane` holds those), **`openSession`/`pendingLine`** (the desk's\nhand-off of a request into the agent's chat; the app's starting line does\nthat job), **`chatMarks` \"In Makersclaw\"** (an ask made in Makersclaw shows\nas a pointer in the conversation instead) and the wallet gate (the platform\nrefuses a turn with no credits and says so).\n\n## Props\n\n- `chat` — what `useAgentPane()` returns. Required. The pane calls its\n `send(text, { files })`, `stop()`, `newChat()` and `openSession(id)` and\n reads `sessionId`, `request`, `chats`, `view`/`turns`/`pending`/`state`\n (to follow the newest words), `sameConversation`, `sending`, `loading`,\n `error`, `notice`, `unavailable`.\n- `title` — the head's words, and the box's label. Default `\"Ask the agent\"`.\n- `suggestions` — asks that fit the moment, as chips before the first ask.\n- `empty` — one line before the first ask.\n- `placeholder` — the box's words before the first ask. Once there is a\n conversation it says \"Ask a follow-up…\" unless `chat.sameConversation` is\n false.\n- `closedWords` — the box's words while `blocked` (\"Answer above first\").\n- `hint` — a quiet line under the box (\"Enter sends · Shift+Enter is a new\n line\").\n- `recent` — past chats listed before the first ask. Default `5`; `0` none.\n- `bare` — no head, inside a `Sheet` or a `Card` that has its own.\n- `fill` — default `true`: the pane takes its container's height and the\n conversation scrolls inside it. `false`: compact, a capped height.\n- `onClose` — a close in the head. Inside an `agent-dock` it is the dock's.\n- `started` — there is a conversation to draw (else the empty state).\n- `busy` — a turn runs: the arrow is a stop while the box is empty.\n- `blocked` — something waits on the member: the whole box is closed.\n- `bar` — a node between the conversation and the box.\n- `children` — the conversation's lines.\n- `className` — layout around it. Never colour.\n\n`ChatComposer` is also exported, for a box of your own around the same\nframe: `value`, `onChange`, `onSubmit`, `onStop`, `onAttach`, `files`,\n`onRemoveFile`, `busy`, `canStop`, `stopping`, `blocked`, `sending`,\n`encoding`, `dictation` (`useDictation()`), `placeholder`, `label`, `hint`,\n`textareaRef`, `fileInputRef`. `ChatsList` draws the past chats.\n\n## Have the agent do it\n\nA button that says the agent will do the work does not open a form. It calls\n`openAgent(\"Draft a project plan for me\")` (blocks/agent-dock). The dock\nopens, tells this pane the line through `ChatDockContext`, and the pane:\n\n1. starts a new chat (if one is open; `fresh: false` carries on in it),\n2. sends the line as the member's first words, once,\n3. and tells the dock it took it.\n\nWith `send: false` it puts the line in the box instead, with the cursor at\nits end (words the member already typed stay, and the line goes under them). A closed box (something waits on the member) always takes the line\nas a prefill. The pure rule is `startStep(...)` in `chat-model.js`.\n\n## The model (plain functions)\n\n`ago(iso)`, `startStep({ start, handled, sessionId, blocked, canNew, newed })`,\nand the composer's parts: `addFiles`, `filesFromTransfer`, `readFile`,\n`formatBytes`, `MAX_FILES`, `MAX_BYTES`, `useDictation`.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useAgentPane } from \"../lib/hooks\";\nimport { ChatPane } from \"../components/blocks/chat-pane\";\n\nexport default function Notes() {\n const chat = useAgentPane({ key: \"notes\" });\n const messages = chat.view?.messages ?? [];\n return (\n <ChatPane\n chat={chat}\n title=\"Ask about your notes\"\n suggestions={[\"Summarise this week\"]}\n hint=\"Enter sends · Shift+Enter is a new line\"\n started={messages.length > 0}\n busy={chat.state === \"working\"}\n >\n {messages.map((m) => (\n <p key={m.id}>{m.text}</p>\n ))}\n </ChatPane>\n );\n}\n```\n"
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"categories": [
|
|
62
|
+
"chat",
|
|
63
|
+
"composer",
|
|
64
|
+
"message box",
|
|
65
|
+
"ask",
|
|
66
|
+
"assistant",
|
|
67
|
+
"conversation",
|
|
68
|
+
"new chat",
|
|
69
|
+
"chats",
|
|
70
|
+
"send",
|
|
71
|
+
"stop",
|
|
72
|
+
"attach",
|
|
73
|
+
"dictate",
|
|
74
|
+
"copilot",
|
|
75
|
+
"sidebar"
|
|
76
|
+
],
|
|
77
|
+
"docs": "Installed to components/makersclaw/blocks/chat-pane. Import: import { ChatPane, ChatComposer, ChatsList, ChatDockContext, ago, startStep, MAX_BYTES, MAX_FILES, addFiles, filesFromTransfer, formatBytes, readFile, useDictation } from \"@/components/makersclaw/blocks/chat-pane\". Props and an example: components/makersclaw/blocks/chat-pane/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
78
|
+
"meta": {
|
|
79
|
+
"makersclaw": {
|
|
80
|
+
"version": "0.8.1",
|
|
81
|
+
"useWhen": "Any app chat with the agent: the pane inside an agent-dock, a compact card beside a record, a sheet. Use agent-pane when the conversation is the agent's (it puts the plan, the work, the questions and the yes inside this pane); use chat-pane itself to draw your own lines in the desk's frame, or just the composer (ChatComposer). Not for a chat between people (chat) or an agent run that is commissioned from a verb and watched (job-panel)."
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|