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/tier-map.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "tier-map",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "TierMap",
|
|
6
|
+
"description": "A map of cards in tiers, from a flat list where each item names its parent. The first tier is the tops; pressing a card opens the cards that serve it as the next tier beneath, joined by elbow lines (pressing it again closes them), and ONE LAYER OPENS AT A TIME. A card with something under it says how many (\"6 serving it\") and whether it is open. A card holds a small kicker line with a mark and a person's face, a title, a progress bar in a status colour with its percent, and one muted foot line. Items that sit under nothing and are not tops are shown together in an unparented group with a note and a slot for an action; items marked ghost (drafts, counted nowhere) are drawn dashed in a group of their own and can serve each other. The lines are CSS only (each tier is a grid of equal columns and the lines are placed in percent), so nothing is measured; below the width a tier needs to sit in one row it stacks, the lines become a rail down its left, and the cards go full width. Arrow keys walk the cards (left and right beside, down into the first kid, up to the parent, Escape closes). A loop of parents is broken, and a parent that is not in the list makes the item unparented. Loading, failed, empty and read-only are drawn; the model (buildTierModel, openPath, togglePath, neighbour, elbowOf) is exported as plain functions.",
|
|
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/tier-map/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/tier-map/index.js",
|
|
16
|
+
"content": "// The TierMap block — `makersclaw add tier-map`. Fixed: compose it through\n// its props (README.md beside this file). The model's helpers are plain\n// functions; they work on the server too.\nexport { TierMap, TierCard } from \"./tier-map\";\nexport {\n CARD_REM,\n MAX_COLUMNS,\n NEVER,\n TONES,\n barOf,\n buildTierModel,\n centreOf,\n countText,\n elbowOf,\n fitKey,\n neighbour,\n openPath,\n pathTo,\n siblingsOf,\n thresholdRem,\n tiersOf,\n togglePath,\n toneOf,\n} from \"./tier-model\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/tier-map/tier-map.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/tier-map/tier-map.jsx",
|
|
22
|
+
"content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Button, Card, Empty, Icon, Notice, Skeleton, cx } from \"../../ui\";\nimport { barOf, buildTierModel, countText, elbowOf, fitKey, neighbour, openPath, tiersOf, togglePath, toneOf } from \"./tier-model\";\n\n// A map of cards in tiers, from a flat list where each item names its parent.\n// The first tier is the tops; pressing a card opens its kids as the next tier\n// beneath it (pressing it again closes them), so ONE LAYER OPENS AT A TIME.\n// Items with no place in the tree sit together in an \"unparented\" group;\n// drafts (`ghost`) sit in a group of their own, dashed, and can serve drafts.\n//\n// Props in, callbacks out (`onSelect`, `onOpenChange`): the block never reads\n// or writes data. The elbow lines are CSS only: every tier is a grid of equal\n// columns and the lines are placed in percent of its width, so nothing is\n// measured, there is no observer, and the server draws what the browser\n// draws. Below the width a tier needs to sit in one row, it stacks and the\n// lines become a rail down its left. README.md beside this file lists every\n// prop.\n\n// ---- layout classes (literal, so Tailwind sees every one)\n//\n// Each table is keyed by a tier's fit key (tier-model.js `fitKey`): the most\n// equal columns this tier, or any tier above it, needs. The width a key asks\n// for is `thresholdRem` (11 rem a column); test/tier-map.test.mjs checks the\n// numbers below against it. Key 9 is a tier too wide to ever sit in a row.\n\n// A tier's grid: from its width, one row of equal columns (--cols); below it, a stack.\nconst GRID = {\n 1: \"@min-[0rem]:grid-cols-(--cols) @min-[0rem]:gap-y-0\",\n 2: \"@min-[22rem]:grid-cols-(--cols) @min-[22rem]:gap-y-0\",\n 3: \"@min-[33rem]:grid-cols-(--cols) @min-[33rem]:gap-y-0\",\n 4: \"@min-[44rem]:grid-cols-(--cols) @min-[44rem]:gap-y-0\",\n 5: \"@min-[55rem]:grid-cols-(--cols) @min-[55rem]:gap-y-0\",\n 6: \"@min-[66rem]:grid-cols-(--cols) @min-[66rem]:gap-y-0\",\n 7: \"@min-[77rem]:grid-cols-(--cols) @min-[77rem]:gap-y-0\",\n 8: \"@min-[88rem]:grid-cols-(--cols) @min-[88rem]:gap-y-0\",\n 9: \"\",\n};\n\n// A card's cell stops being a box from the tier's width, so its card and its kids are the tier's own grid items.\nconst CELL = {\n 1: \"@min-[0rem]:contents\",\n 2: \"@min-[22rem]:contents\",\n 3: \"@min-[33rem]:contents\",\n 4: \"@min-[44rem]:contents\",\n 5: \"@min-[55rem]:contents\",\n 6: \"@min-[66rem]:contents\",\n 7: \"@min-[77rem]:contents\",\n 8: \"@min-[88rem]:contents\",\n 9: \"\",\n};\n\n// The card itself, from the tier's width: its own column (--c) in row 1, centred in it.\nconst SLOT = {\n 1: \"@min-[0rem]:col-start-(--c) @min-[0rem]:row-start-1 @min-[0rem]:justify-items-center @min-[0rem]:px-1.5\",\n 2: \"@min-[22rem]:col-start-(--c) @min-[22rem]:row-start-1 @min-[22rem]:justify-items-center @min-[22rem]:px-1.5\",\n 3: \"@min-[33rem]:col-start-(--c) @min-[33rem]:row-start-1 @min-[33rem]:justify-items-center @min-[33rem]:px-1.5\",\n 4: \"@min-[44rem]:col-start-(--c) @min-[44rem]:row-start-1 @min-[44rem]:justify-items-center @min-[44rem]:px-1.5\",\n 5: \"@min-[55rem]:col-start-(--c) @min-[55rem]:row-start-1 @min-[55rem]:justify-items-center @min-[55rem]:px-1.5\",\n 6: \"@min-[66rem]:col-start-(--c) @min-[66rem]:row-start-1 @min-[66rem]:justify-items-center @min-[66rem]:px-1.5\",\n 7: \"@min-[77rem]:col-start-(--c) @min-[77rem]:row-start-1 @min-[77rem]:justify-items-center @min-[77rem]:px-1.5\",\n 8: \"@min-[88rem]:col-start-(--c) @min-[88rem]:row-start-1 @min-[88rem]:justify-items-center @min-[88rem]:px-1.5\",\n 9: \"\",\n};\n\n// The block under an open card: from the parent tier's width, a whole row of its own (row 2).\nconst KIDS = {\n 1: \"@min-[0rem]:row-start-2 @min-[0rem]:mt-0\",\n 2: \"@min-[22rem]:row-start-2 @min-[22rem]:mt-0\",\n 3: \"@min-[33rem]:row-start-2 @min-[33rem]:mt-0\",\n 4: \"@min-[44rem]:row-start-2 @min-[44rem]:mt-0\",\n 5: \"@min-[55rem]:row-start-2 @min-[55rem]:mt-0\",\n 6: \"@min-[66rem]:row-start-2 @min-[66rem]:mt-0\",\n 7: \"@min-[77rem]:row-start-2 @min-[77rem]:mt-0\",\n 8: \"@min-[88rem]:row-start-2 @min-[88rem]:mt-0\",\n 9: \"\",\n};\n\n// ...flush left once its own tier sits in a row; before that it hangs indented off the rail.\nconst INDENT = {\n 1: \"@min-[0rem]:ml-0\",\n 2: \"@min-[22rem]:ml-0\",\n 3: \"@min-[33rem]:ml-0\",\n 4: \"@min-[44rem]:ml-0\",\n 5: \"@min-[55rem]:ml-0\",\n 6: \"@min-[66rem]:ml-0\",\n 7: \"@min-[77rem]:ml-0\",\n 8: \"@min-[88rem]:ml-0\",\n 9: \"\",\n};\n\n// The strip of lines above the kids: only from the parent tier's width.\nconst HEAD = {\n 1: \"hidden @min-[0rem]:block\",\n 2: \"hidden @min-[22rem]:block\",\n 3: \"hidden @min-[33rem]:block\",\n 4: \"hidden @min-[44rem]:block\",\n 5: \"hidden @min-[55rem]:block\",\n 6: \"hidden @min-[66rem]:block\",\n 7: \"hidden @min-[77rem]:block\",\n 8: \"hidden @min-[88rem]:block\",\n 9: \"hidden\",\n};\n\n// The bar across the kids' tops: only from the kids' tier width.\nconst BAR = {\n 1: \"hidden @min-[0rem]:block\",\n 2: \"hidden @min-[22rem]:block\",\n 3: \"hidden @min-[33rem]:block\",\n 4: \"hidden @min-[44rem]:block\",\n 5: \"hidden @min-[55rem]:block\",\n 6: \"hidden @min-[66rem]:block\",\n 7: \"hidden @min-[77rem]:block\",\n 8: \"hidden @min-[88rem]:block\",\n 9: \"hidden\",\n};\n\n// The line from a parent that sits in a row to the rail of kids that stack.\nconst LOW = {\n 1: \"hidden @max-[0rem]:block\",\n 2: \"hidden @max-[22rem]:block\",\n 3: \"hidden @max-[33rem]:block\",\n 4: \"hidden @max-[44rem]:block\",\n 5: \"hidden @max-[55rem]:block\",\n 6: \"hidden @max-[66rem]:block\",\n 7: \"hidden @max-[77rem]:block\",\n 8: \"hidden @max-[88rem]:block\",\n 9: \"block\",\n};\n\n// A kid's elbow to the rail, only while its own tier is stacked.\nconst STUB = {\n 1: \"@min-[0rem]:before:hidden @min-[0rem]:after:hidden\",\n 2: \"@min-[22rem]:before:hidden @min-[22rem]:after:hidden\",\n 3: \"@min-[33rem]:before:hidden @min-[33rem]:after:hidden\",\n 4: \"@min-[44rem]:before:hidden @min-[44rem]:after:hidden\",\n 5: \"@min-[55rem]:before:hidden @min-[55rem]:after:hidden\",\n 6: \"@min-[66rem]:before:hidden @min-[66rem]:after:hidden\",\n 7: \"@min-[77rem]:before:hidden @min-[77rem]:after:hidden\",\n 8: \"@min-[88rem]:before:hidden @min-[88rem]:after:hidden\",\n 9: \"\",\n};\n\n// The elbow to the rail while a tier is stacked (the kids of a stacked tier\n// hang off a line down their left). The last kid's line ends at its elbow.\nconst RAIL_CELL =\n \"relative before:absolute before:-left-3 before:top-6 before:w-3 before:border-t before:border-input after:absolute after:-left-3 after:bottom-0 after:border-l after:border-input\";\n// A kid's line starts 0.75 rem above it, at the bottom of the card (or the\n// kid) above; the last kid's line stops at its elbow.\nconst RAIL_TOP = \"after:-top-3\";\nconst RAIL_END = \"after:bottom-auto after:h-9\";\nconst railOf = (i, count) => cx(RAIL_CELL, RAIL_TOP, i === count - 1 && RAIL_END);\n\nconst FILL = {\n ink: \"bg-foreground\",\n quiet: \"bg-muted-foreground\",\n info: \"bg-info\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n};\n\nconst GROUP_HEAD = \"text-2xs font-medium uppercase tracking-wider text-muted-foreground\";\nconst CARD_BOX = \"w-full min-w-0 max-w-[26rem]\";\n\n// ---- one context for the tiers\n\nconst Ctx = createContext(null);\nconst useTiers = () => useContext(Ctx);\n\nconst sameId = (a, b) => a != null && b != null && String(a) === String(b);\n\n// ---- the card\n\n/**\n * One card. `state` is what the map knows about it:\n * { id, item, open, selected, count, tier, ghost, serves, pressable,\n * holderProps, primaryProps, toggleProps, select, toggle }\n * A `renderCard` of your own spreads `holderProps` on its root and\n * `primaryProps` (and `toggleProps`) on the buttons that take the focus.\n */\nfunction TierCard({ node, serves }) {\n const t = useTiers();\n const { item } = node;\n const open = t.openIds.has(node.id);\n const kids = node.kids.length;\n const selected = sameId(t.selected, node.id);\n const ghost = node.ghost;\n const hasOpen = node.group === \"tree\" && kids > 0;\n const pressable = Boolean(t.onSelect) || Boolean(item.href) || hasOpen;\n const roving = t.tabId === node.id ? 0 : -1;\n const kidsId = `${t.uid}-kids-${node.id}`;\n\n const toggle = () => t.toggle(node.id);\n const select = () => {\n if (t.onSelect) t.onSelect(item);\n else if (hasOpen) toggle();\n };\n const state = {\n id: node.id,\n item,\n open,\n selected,\n count: kids,\n tier: node.tier,\n ghost,\n serves,\n pressable,\n select,\n toggle: hasOpen ? toggle : undefined,\n holderProps: { \"data-tier-id\": node.id },\n primaryProps: {\n \"data-tier-card\": node.id,\n tabIndex: roving,\n onClick: select,\n onFocus: () => t.setTab(node.id),\n \"aria-current\": selected ? \"true\" : undefined,\n \"aria-expanded\": hasOpen && !t.onSelect ? open : undefined,\n \"aria-controls\": hasOpen && !t.onSelect && open ? kidsId : undefined,\n },\n toggleProps: hasOpen\n ? { \"data-tier-toggle\": node.id, tabIndex: roving, onClick: toggle, onFocus: () => t.setTab(node.id), \"aria-expanded\": open, \"aria-controls\": open ? kidsId : undefined }\n : null,\n };\n\n const custom = t.renderCard ? t.renderCard(item, state) : null;\n if (custom != null && custom !== false) return custom;\n\n const bar = barOf(item);\n const tone = toneOf(item.tone, bar?.share);\n const lead = item.mark != null || item.kicker != null || item.by != null;\n const body = (\n <>\n {lead ? (\n <span className=\"flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n {item.mark != null ? <span className=\"inline-flex shrink-0 items-center\">{item.mark}</span> : null}\n {item.kicker != null ? <span className=\"min-w-0 truncate\">{item.kicker}</span> : null}\n {item.by != null ? <span className=\"ml-auto inline-flex shrink-0 items-center\">{item.by}</span> : null}\n </span>\n ) : null}\n <span className=\"block font-semibold leading-snug [overflow-wrap:anywhere]\">{item.title}</span>\n {bar ? (\n <span className=\"flex items-center gap-2 text-xs\">\n <span aria-hidden=\"true\" className=\"relative h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted\">\n <span className={cx(\"absolute inset-y-0 left-0 w-(--fill) rounded-full\", FILL[tone])} style={{ \"--fill\": `${bar.pct}%` }} />\n </span>\n <span data-nums className=\"shrink-0 font-semibold\">\n {bar.pct}%\n </span>\n </span>\n ) : null}\n {serves ? <span className=\"block text-xs text-muted-foreground [overflow-wrap:anywhere]\">{serves}</span> : null}\n {item.foot != null ? <span className=\"block text-xs text-muted-foreground [overflow-wrap:anywhere]\">{item.foot}</span> : null}\n </>\n );\n const look = cx(\n \"grid w-full min-w-0 gap-1.5 p-3 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n pressable ? \"hover:bg-accent\" : \"cursor-default\",\n );\n const primary = item.href ? (\n <a href={item.href} {...state.primaryProps} className={look}>\n {body}\n </a>\n ) : (\n <button type=\"button\" {...state.primaryProps} aria-disabled={pressable ? undefined : \"true\"} className={look}>\n {body}\n </button>\n );\n const ring = selected ? \"ring-2 ring-ring\" : open ? \"ring-1 ring-primary\" : \"\";\n\n return (\n <div {...state.holderProps} className={CARD_BOX}>\n <Card as=\"div\" padding=\"none\" className={cx(ring, ghost && \"border-dashed\")}>\n {primary}\n {state.toggleProps ? (\n <button\n type=\"button\"\n {...state.toggleProps}\n className=\"flex w-full min-w-0 items-center gap-1.5 border-t border-border px-3 py-1.5 text-left text-xs text-muted-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\"\n >\n <Icon name=\"chevron-right\" size={14} className={cx(\"shrink-0 transition-transform motion-reduce:transition-none\", open && \"rotate-90\")} />\n <span className=\"min-w-0\">\n {countText(kids, t.childrenLabel)}\n {open ? ` · ${t.openWord}` : \"\"}\n </span>\n </button>\n ) : null}\n </Card>\n </div>\n );\n}\n\nexport { TierCard };\n\n// ---- the tiers\n\nfunction Tier({ ids, depth, above, hangs }) {\n const t = useTiers();\n const count = ids.length;\n const n = fitKey(Math.max(count, above));\n const openId = t.path[depth] ?? null;\n return (\n <div className={cx(\"grid grid-cols-1 gap-y-3\", GRID[n])} style={{ \"--cols\": `repeat(${count}, minmax(0, 1fr))` }}>\n {ids.map((id, i) => {\n const node = t.model.nodes.get(id);\n return (\n <div\n key={id}\n className={cx(\"min-w-0\", hangs ? cx(railOf(i, count), STUB[n]) : \"relative\", CELL[n])}\n style={{ \"--c\": i + 1 }}\n >\n <div className={cx(\"grid min-w-0 justify-items-start\", SLOT[n])}>\n <TierCard node={node} />\n </div>\n {id === openId ? <Kids node={node} depth={depth} fit={n} index={i} count={count} /> : null}\n </div>\n );\n })}\n </div>\n );\n}\n\n// What hangs under an open card: the strip of lines (from the width its tier\n// sits in a row) and the next tier.\nfunction Kids({ node, depth, fit, index, count }) {\n const t = useTiers();\n const kidFit = fitKey(Math.max(node.kids.length, fit));\n const e = elbowOf(index, count, node.kids.length);\n return (\n <div id={`${t.uid}-kids-${node.id}`} className={cx(\"col-span-full mt-3 min-w-0\", KIDS[fit])}>\n <div aria-hidden=\"true\" className={cx(\"relative h-8\", HEAD[fit])} style={{ \"--p\": `${e.parent}%` }}>\n <span className=\"absolute left-(--p) top-0 h-1/2 border-l border-input\" />\n <div className={cx(\"absolute inset-0\", LOW[kidFit])}>\n <span className=\"absolute left-3 top-1/2 w-(--back) border-t border-input\" style={{ \"--back\": `calc(${e.parent}% - 0.75rem)` }} />\n <span className=\"absolute left-3 top-1/2 bottom-0 border-l border-input\" />\n </div>\n <div className={cx(\"absolute inset-0\", BAR[kidFit])}>\n <span className=\"absolute left-(--from) top-1/2 w-(--across) border-t border-input\" style={{ \"--from\": `${e.from}%`, \"--across\": `${e.to - e.from}%` }} />\n {e.kids.map((at, i) => (\n <span key={i} className=\"absolute left-(--at) top-1/2 bottom-0 border-l border-input\" style={{ \"--at\": `${at}%` }} />\n ))}\n </div>\n </div>\n <div className={cx(\"ml-6 min-w-0\", INDENT[kidFit])}>\n <Tier ids={node.kids} depth={depth + 1} above={fit} hangs />\n </div>\n </div>\n );\n}\n\n// ---- the drafts: a forest that is always open, a rail down each tree\n\nfunction GhostBranch({ id }) {\n const t = useTiers();\n const node = t.model.nodes.get(id);\n const parent = node.parent != null ? t.model.nodes.get(node.parent) : null;\n const serves = node.up == null && parent && t.servesLabel ? `${t.servesLabel} ${parent.item.title}` : null;\n return (\n <div className=\"grid min-w-0 content-start gap-3\">\n <TierCard node={node} serves={serves} />\n {node.kids.length ? (\n <div className=\"ml-6 grid min-w-0 gap-3\">\n {node.kids.map((kid, i) => (\n <div key={kid} className={cx(\"min-w-0\", railOf(i, node.kids.length))}>\n <GhostBranch id={kid} />\n </div>\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n\n// ---- the whole map\n\n/**\n * The map. Props: `items`, `tops`, `topsLabel`, `label`, `childrenLabel`,\n * `openWord`, `unparentedLabel`, `unparentedNote`, `ghostLabel`, `ghostNote`,\n * `servesLabel`, `open`, `defaultOpen`, `onOpenChange`, `onSelect`,\n * `selected`, `renderCard`, `loading`, `error`, `onRetry`, `empty`,\n * `className`.\n */\nexport function TierMap({\n items,\n tops,\n topsLabel,\n label = \"Tiers\",\n childrenLabel = \"below\",\n openWord = \"open\",\n unparentedLabel = \"Not under anything\",\n unparentedNote,\n ghostLabel = \"Drafts\",\n ghostNote,\n servesLabel = \"Serves\",\n open,\n defaultOpen,\n onOpenChange,\n onSelect,\n selected,\n renderCard,\n loading = false,\n error,\n onRetry,\n empty,\n className,\n}) {\n const uid = useId().replace(/:/g, \"\");\n const root = useRef(null);\n const model = useMemo(() => buildTierModel(items, { tops }), [items, tops]);\n const [inner, setInner] = useState(() => (Array.isArray(defaultOpen) ? defaultOpen : []));\n const [tab, setTab] = useState(null);\n const [focusReq, setFocusReq] = useState(null);\n\n const path = useMemo(() => openPath(model, open !== undefined ? open : inner), [model, open, inner]);\n const openIds = useMemo(() => new Set(path), [path]);\n\n // The cards on screen, in the order the keys walk them.\n const shown = useMemo(() => {\n const ids = [];\n for (const tier of tiersOf(model, path)) ids.push(...tier.ids);\n ids.push(...model.unparented, ...model.ghostOrder.map((g) => g.id));\n return ids;\n }, [model, path]);\n const tabId = tab != null && shown.includes(tab) ? tab : shown[0] ?? null;\n\n function commit(next) {\n if (open === undefined) setInner(next);\n if (onOpenChange) onOpenChange(next);\n }\n function toggle(id) {\n const next = togglePath(model, path, id);\n if (next !== path) commit(next);\n }\n\n // A key moves the focus to a card that may not be drawn yet (↓ on a closed\n // card opens it first), so the move waits for the render that draws it.\n const pathKey = path.join(\"\\u0000\");\n useEffect(() => {\n if (focusReq == null || !root.current) return;\n const cards = root.current.querySelectorAll(\"[data-tier-card]\");\n for (const el of cards) {\n if (el.getAttribute(\"data-tier-card\") === focusReq) {\n el.focus();\n break;\n }\n }\n setFocusReq(null);\n }, [focusReq, pathKey]);\n\n function onKeyDown(e) {\n if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;\n const holder = e.target instanceof Element ? e.target.closest(\"[data-tier-id]\") : null;\n if (!holder) return;\n const id = holder.getAttribute(\"data-tier-id\");\n const node = model.nodes.get(id);\n if (!node) return;\n const go = (to) => {\n if (to == null) return;\n e.preventDefault();\n setTab(to);\n setFocusReq(to);\n };\n if (e.key === \"ArrowDown\") {\n if (!node.kids.length) return;\n if (node.group === \"tree\" && !openIds.has(id)) toggle(id);\n go(node.kids[0]);\n } else if (e.key === \"Escape\") {\n if (node.group !== \"tree\") return;\n if (openIds.has(id)) {\n e.preventDefault();\n toggle(id);\n } else if (node.up != null) {\n e.preventDefault();\n toggle(node.up);\n go(node.up);\n }\n } else if ([\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) {\n go(neighbour(model, id, e.key));\n }\n }\n\n const ctx = {\n model,\n path,\n openIds,\n uid,\n tabId,\n setTab,\n toggle,\n onSelect,\n selected,\n renderCard,\n childrenLabel,\n openWord,\n servesLabel,\n };\n\n if (loading) {\n return (\n <div aria-busy=\"true\" aria-label={`${label}, loading`} className={cx(\"@container min-w-0\", className)}>\n <div className=\"grid grid-cols-[repeat(auto-fill,minmax(min(100%,12rem),1fr))] gap-3\">\n {[0, 1, 2].map((i) => (\n <Skeleton key={i} shape=\"rect\" className=\"h-24 w-full\" />\n ))}\n </div>\n </div>\n );\n }\n if (error) {\n return (\n <div className={cx(\"@container min-w-0\", className)}>\n <Notice\n tone=\"danger\"\n action={\n onRetry ? (\n <Button size=\"sm\" onClick={onRetry}>\n Try again\n </Button>\n ) : undefined\n }\n >\n {typeof error === \"string\" || typeof error === \"object\" ? error : \"The map could not be loaded.\"}\n </Notice>\n </div>\n );\n }\n if (!model.order.length) {\n return <div className={cx(\"@container min-w-0\", className)}>{empty ?? <Empty icon=\"network\" title=\"Nothing to map yet\" body=\"Cards appear here once there is something to put on the map.\" />}</div>;\n }\n\n const parked = model.unparented;\n const drafts = model.ghostOrder.length;\n return (\n <Ctx.Provider value={ctx}>\n <div ref={root} role=\"group\" aria-label={label} onKeyDown={onKeyDown} className={cx(\"@container grid min-w-0 gap-8\", className)}>\n {model.tops.length ? (\n <section aria-label={typeof topsLabel === \"string\" ? topsLabel : label} className=\"grid min-w-0 gap-3\">\n {topsLabel ? <h3 className={GROUP_HEAD}>{topsLabel}</h3> : null}\n <Tier ids={model.tops} depth={0} above={1} hangs={false} />\n </section>\n ) : null}\n {parked.length ? (\n <section aria-label={typeof unparentedLabel === \"string\" ? unparentedLabel : \"Unparented\"} className=\"grid min-w-0 gap-3\">\n <h3 className={GROUP_HEAD}>\n {unparentedLabel} {\"·\"} {parked.length}\n </h3>\n <div className=\"grid min-w-0 gap-4 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] @2xl:items-start\">\n <div className=\"grid grid-cols-[repeat(auto-fill,minmax(min(100%,14rem),1fr))] gap-3\">\n {parked.map((id) => (\n <div key={id} className=\"grid min-w-0 justify-items-start\">\n <TierCard node={model.nodes.get(id)} />\n </div>\n ))}\n </div>\n {unparentedNote ? <div className=\"grid min-w-0 content-start gap-2 text-sm text-muted-foreground\">{unparentedNote}</div> : null}\n </div>\n </section>\n ) : null}\n {drafts ? (\n <section aria-label={typeof ghostLabel === \"string\" ? ghostLabel : \"Drafts\"} className=\"grid min-w-0 gap-3\">\n <h3 className={GROUP_HEAD}>{ghostLabel}</h3>\n {ghostNote ? <div className=\"text-sm text-muted-foreground\">{ghostNote}</div> : null}\n <div className=\"grid grid-cols-[repeat(auto-fill,minmax(min(100%,16rem),1fr))] gap-3\">\n {model.ghostRoots.map((id) => (\n <GhostBranch key={id} id={id} />\n ))}\n </div>\n </section>\n ) : null}\n </div>\n </Ctx.Provider>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/tier-map/tier-model.js",
|
|
26
|
+
"type": "registry:component",
|
|
27
|
+
"target": "@components/makersclaw/blocks/tier-map/tier-model.js",
|
|
28
|
+
"content": "// The tier map's data and arithmetic — plain functions, no React, no DOM.\n// They run anywhere (the server, an agent's tool, a test).\n//\n// Items come in flat, the way a table holds them: each row names its\n// parent. buildTierModel turns them into a forest and sorts every item into\n// one of three places:\n//\n// tree reachable from a top: drawn in tiers, one layer open at a time\n// unparented real (not a draft), not reachable from any top\n// ghost a draft (`ghost: true`): drawn apart, dashed, never counted\n//\n// A parent that is not in the list is no parent. A loop of parents is\n// broken at the item that comes first in the list, which becomes parentless.\n// Nothing here hangs on bad data.\n\n/** Equal columns of this many rem, at least, are what a tier needs to sit in one row. */\nexport const CARD_REM = 11;\n/** A tier of more cards than this never sits in one row; it stacks. */\nexport const MAX_COLUMNS = 8;\n\n/**\n * The key a tier's layout classes are looked up by: how many equal columns\n * the widest tier so far needs, 1 to MAX_COLUMNS, or NEVER when a tier has\n * too many cards to sit in one row.\n */\nexport const NEVER = MAX_COLUMNS + 1;\nexport function fitKey(count) {\n const n = Math.floor(Number(count));\n if (!(n > 1)) return 1;\n return n > MAX_COLUMNS ? NEVER : n;\n}\n\n/** The container width, in rem, from which a tier of this fit key sits in one row (Infinity: never). */\nexport function thresholdRem(key) {\n if (key >= NEVER) return Infinity;\n return key <= 1 ? 0 : key * CARD_REM;\n}\n\nconst idOf = (value) => (value == null || value === \"\" ? null : String(value));\n\n/**\n * Flat items to a model.\n *\n * items [{ id, parent?, ghost?, ... }]; any other key is kept on `item`\n * tops ids drawn as the first tier, in that order. Default: the real\n * items that have no parent and have children.\n *\n * Returns { nodes, order, tops, unparented, ghostRoots, ghostOrder }.\n * A node is { id, item, ghost, parent, up, kids, tier, group, total }:\n * parent the id it names, after bad ones and loops are dropped (or null)\n * up who it hangs under on screen (null for a top, an unparented card)\n * kids ids drawn under it when it is opened (a draft's kids are drafts)\n * tier 0 for a top, 1 for its kids… (-1 for what is not in the tree)\n * group \"tree\" | \"unparented\" | \"ghost\"\n * total everything under it, counting kids of kids\n */\nexport function buildTierModel(items, { tops } = {}) {\n const nodes = new Map();\n const order = [];\n for (const item of Array.isArray(items) ? items : []) {\n const id = idOf(item?.id);\n if (id == null || nodes.has(id)) continue;\n nodes.set(id, { id, item, ghost: item.ghost === true, parent: null, up: null, kids: [], tier: -1, group: \"unparented\", total: 0 });\n order.push(id);\n }\n\n // The parent each item names, if it is somebody in the list and not itself.\n for (const id of order) {\n const node = nodes.get(id);\n const parent = idOf(node.item.parent);\n node.parent = parent != null && parent !== id && nodes.has(parent) ? parent : null;\n }\n breakLoops(nodes, order);\n\n // Kids: a real item under a real parent; a draft under a draft.\n for (const id of order) {\n const node = nodes.get(id);\n const parent = node.parent == null ? null : nodes.get(node.parent);\n if (parent && parent.ghost === node.ghost) parent.kids.push(id);\n }\n\n // The tops.\n const real = (id) => nodes.has(id) && !nodes.get(id).ghost;\n let topIds;\n if (Array.isArray(tops)) topIds = [...new Set(tops.map(idOf).filter((id) => id != null && real(id)))];\n else topIds = order.filter((id) => real(id) && nodes.get(id).parent == null && nodes.get(id).kids.length > 0);\n const topSet = new Set(topIds);\n // A top is drawn as a top and nowhere else, so it leaves its parent's kids.\n for (const id of topIds) {\n const parent = nodes.get(id).parent;\n if (parent != null) nodes.get(parent).kids = nodes.get(parent).kids.filter((k) => k !== id);\n }\n\n // Walk down from the tops; the first way in wins.\n const walk = (id, tier, up) => {\n const node = nodes.get(id);\n node.tier = tier;\n node.up = up;\n node.group = \"tree\";\n let total = 0;\n for (const kid of node.kids) {\n if (nodes.get(kid).group === \"tree\") continue;\n walk(kid, tier + 1, id);\n total += 1 + nodes.get(kid).total;\n }\n node.kids = node.kids.filter((k) => nodes.get(k).up === id && nodes.get(k).group === \"tree\");\n node.total = total;\n };\n for (const id of topIds) walk(id, 0, null);\n\n // Real items the tops do not reach.\n const unparented = order.filter((id) => !nodes.get(id).ghost && nodes.get(id).group !== \"tree\");\n for (const id of unparented) {\n const node = nodes.get(id);\n node.kids = [];\n node.up = null;\n }\n\n // Drafts: a forest of their own, parents first.\n const ghostRoots = [];\n const ghostOrder = [];\n for (const id of order) {\n const node = nodes.get(id);\n if (!node.ghost) continue;\n node.group = \"ghost\";\n node.up = node.parent != null && nodes.get(node.parent).ghost ? node.parent : null;\n if (node.up == null) ghostRoots.push(id);\n }\n const lay = (id, depth) => {\n ghostOrder.push({ id, depth });\n const node = nodes.get(id);\n node.tier = depth;\n let total = 0;\n for (const kid of node.kids) {\n lay(kid, depth + 1);\n total += 1 + nodes.get(kid).total;\n }\n node.total = total;\n };\n for (const id of ghostRoots) lay(id, 0);\n\n return { nodes, order, tops: topIds, unparented, ghostRoots, ghostOrder, isTop: (id) => topSet.has(id) };\n}\n\n// A chain of parents that comes back to itself: the member that comes first\n// in the list becomes parentless, so the rest hang under it.\nfunction breakLoops(nodes, order) {\n const place = new Map(order.map((id, i) => [id, i]));\n const seen = new Map(); // id -> 1 on the walk now, 2 done\n for (const start of order) {\n if (seen.has(start)) continue;\n const walked = [];\n let cur = start;\n while (cur != null && !seen.has(cur)) {\n seen.set(cur, 1);\n walked.push(cur);\n cur = nodes.get(cur).parent;\n }\n if (cur != null && seen.get(cur) === 1) {\n const loop = walked.slice(walked.indexOf(cur));\n let first = loop[0];\n for (const id of loop) if (place.get(id) < place.get(first)) first = id;\n nodes.get(first).parent = null;\n }\n for (const id of walked) seen.set(id, 2);\n }\n}\n\n// ---- the open path\n\n/**\n * The ids that are open, one per tier: the first is a top, the second one\n * of its kids, and so on. Whatever cannot be (an id that is not there, one\n * with nothing under it, one that is not under the one before) ends the\n * path there.\n */\nexport function openPath(model, requested) {\n const out = [];\n let level = model.tops;\n for (const raw of Array.isArray(requested) ? requested : []) {\n const id = idOf(raw);\n if (id == null || !level.includes(id)) break;\n const node = model.nodes.get(id);\n if (!node.kids.length) break;\n out.push(id);\n level = node.kids;\n }\n return out;\n}\n\n/**\n * The path after pressing a card in the tree: the card opens (and what was\n * open at its tier and below closes), or, if it was open, it closes with\n * everything under it. Any other id leaves the path as it is.\n */\nexport function togglePath(model, path, id) {\n const key = idOf(id);\n const node = key == null ? null : model.nodes.get(key);\n if (!node || node.group !== \"tree\" || !node.kids.length) return path;\n const base = path.slice(0, node.tier);\n return path[node.tier] === key ? base : [...base, key];\n}\n\n/** The ids to open so that this one is on screen: its parents, top first. */\nexport function pathTo(model, id) {\n const out = [];\n let node = model.nodes.get(idOf(id));\n if (!node || node.group !== \"tree\") return out;\n while (node && node.up != null) {\n out.unshift(node.up);\n node = model.nodes.get(node.up);\n }\n return out;\n}\n\n/** The tiers on screen for a path: [{ ids, openId }], the tops first. */\nexport function tiersOf(model, path) {\n const tiers = [];\n let ids = model.tops;\n for (let i = 0; ids.length; i += 1) {\n const openId = path[i] ?? null;\n tiers.push({ ids, openId });\n if (openId == null) break;\n ids = model.nodes.get(openId).kids;\n }\n return tiers;\n}\n\n// ---- the keys\n\n/** The cards beside this one: same parent, same group, in order. */\nexport function siblingsOf(model, id) {\n const node = model.nodes.get(idOf(id));\n if (!node) return [];\n if (node.group === \"unparented\") return model.unparented;\n if (node.group === \"ghost\") return node.up == null ? model.ghostRoots : model.nodes.get(node.up).kids;\n return node.up == null ? model.tops : model.nodes.get(node.up).kids;\n}\n\n/**\n * Where an arrow key goes from a card: ← → the card beside it, ↓ its first\n * kid, ↑ its parent, Home and End the first and last beside it. null when\n * there is nowhere to go.\n */\nexport function neighbour(model, id, key) {\n const node = model.nodes.get(idOf(id));\n if (!node) return null;\n const side = siblingsOf(model, node.id);\n const at = side.indexOf(node.id);\n switch (key) {\n case \"ArrowLeft\":\n return side[at - 1] ?? null;\n case \"ArrowRight\":\n return side[at + 1] ?? null;\n case \"Home\":\n return side[0] ?? null;\n case \"End\":\n return side[side.length - 1] ?? null;\n case \"ArrowDown\":\n return node.kids[0] ?? null;\n case \"ArrowUp\":\n return node.up ?? null;\n default:\n return null;\n }\n}\n\n// ---- what a card says\n\n/** \"6 serving it\": the count and the app's words for what is under a card. */\nexport function countText(count, label = \"below\") {\n const text = typeof label === \"function\" ? label(count) : `${count} ${label}`;\n return String(text);\n}\n\n/** A bar on a card: { pct, share } for a value 0..1, or null (no bar). A draft has none. */\nexport function barOf(item) {\n if (!item || item.ghost === true) return null;\n const v = item.value;\n if (v == null || v === \"\" || typeof v === \"boolean\") return null;\n const n = Number(v);\n if (!Number.isFinite(n)) return null;\n const share = Math.min(1, Math.max(0, n));\n return { share, pct: Math.round(share * 100) };\n}\n\nexport const TONES = [\"ink\", \"quiet\", \"info\", \"success\", \"warning\", \"danger\"];\n\n/**\n * The bar's colour: one of TONES. `auto` (or none) is ink, and success when\n * the bar is full; a word that is not a tone is ink. A status only.\n */\nexport function toneOf(tone, share) {\n if (TONES.includes(tone)) return tone;\n return share != null && share >= 1 ? \"success\" : \"ink\";\n}\n\n// ---- the lines between tiers (percentages of the tier's width)\n\nconst round = (n) => Math.round(n * 1000) / 1000;\n\n/** The middle of column `index` of `count` equal columns, in percent of the row. */\nexport function centreOf(index, count) {\n return count > 0 ? round(((index + 0.5) / count) * 100) : 50;\n}\n\n/**\n * The elbow from a card to the row of its kids, as positions in percent:\n * `parent` (where the line leaves the card), `from` and `to` (the bar across,\n * from the leftmost to the rightmost of the parent and every kid), `kids`\n * (where each kid's line drops).\n */\nexport function elbowOf(parentIndex, parentCount, kidCount) {\n const parent = centreOf(parentIndex, parentCount);\n const kids = Array.from({ length: kidCount }, (_, i) => centreOf(i, kidCount));\n const all = [parent, ...kids];\n return { parent, from: Math.min(...all), to: Math.max(...all), kids };\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/blocks/tier-map/README.md",
|
|
32
|
+
"type": "registry:file",
|
|
33
|
+
"target": "@components/makersclaw/blocks/tier-map/README.md",
|
|
34
|
+
"content": "# TierMap\n\nCards in tiers, from a flat list where each item names its parent.\n\nThe first tier is the tops. Press a card and the cards under it open as the\nnext tier beneath, joined to it by elbow lines. Press it again and they close.\n**One layer opens at a time**: opening a card closes what was open beside it.\nA card with something under it says how many (\"6 serving it\") and whether it\nis open.\n\nItems that sit under nothing and are not tops are shown together in an\n*unparented* group, with a note and a slot for an action. Items marked `ghost`\n(drafts: counted nowhere yet) are drawn dashed in a group of their own, and a\ndraft can serve another draft.\n\nIt draws a structure the app has already worked out. It moves nothing between\nparents and collects nothing. For a person-and-manager chart you pan and zoom\nuse `org-chart`; for a free map of ideas `mind-map`; for nodes and edges you\ndraw `flow-canvas`; for a table with rows that expand `data-table`.\n\nBorn in a goals tracker, on the screen \"How goals fit\": company goals in the\nfirst tier, the team goals that serve them beneath. The same block holds\ninitiatives under goals, work under a roadmap theme, an account's\nsub-accounts, a strategy map.\n\n```jsx\nimport { TierMap } from \"../components/blocks/tier-map\"; // a Makersclaw app (\"./blocks/tier-map\" from components/)\nimport { TierMap } from \"@/components/makersclaw/blocks/tier-map\"; // a shadcn project\n```\n\nA client component. No other block and no npm package. The lines are CSS only:\neach tier is a grid of equal columns and the lines are placed in percent of\nits width, so nothing is measured, there is no observer, and the server draws\nwhat the browser draws. The helpers (`buildTierModel`, `openPath`,\n`togglePath`, `neighbour`, `elbowOf`...) are plain functions and work on the\nserver too.\n\n## The data\n\nOne row per card, as a table holds them:\n\n```js\n[\n { id: \"c1\", title: \"Reach $1M in annual revenue\", kicker: \"Company\", by: <Avatar name=\"Dana\" size=\"xs\" />, value: 0.62, tone: \"warning\", foot: \"6 teams serve it\" },\n { id: \"s1\", parent: \"c1\", title: \"Close the enterprise gap\", kicker: \"Sales\", value: 0.58 },\n { id: \"g1\", ghost: true, title: \"Grow MRR to $80k\", kicker: \"Draft\" },\n]\n```\n\n- `id` required, unique (a repeat is ignored). Numbers are read as text.\n- `parent` the parent's `id`. Missing, empty, itself, or **an id that is not in\n the list**: the card has no parent. A **loop** of parents (a names b, b names\n a) is broken at the card that comes first in the list, which becomes\n parentless, and never hangs the map.\n- `title` the card's heading. Long words break; nothing overflows.\n- `kicker` the small line above the title (\"Sales\", \"Sub-account\").\n- `mark` a node before the kicker (the app's status mark: a `StatusDot`, an\n icon).\n- `by` a node at the card's top right (an `Avatar`). It sits inside the\n card's button, so it must not be interactive.\n- `value` 0 to 1: a bar with its percent. Missing, `null` or not a number: no\n bar. A draft never has one.\n- `tone` the bar's colour, **a status only**: `ink` (default) · `quiet` · `info`\n · `success` · `warning` · `danger` · `auto` (ink, and success when full).\n- `foot` one muted line at the card's foot.\n- `ghost` `true`: a draft. Dashed, no bar, never in the tiers, never counted.\n A draft under a draft is drawn under it; a draft under a real card shows\n `Serves <that card's title>`.\n- `href` the card is also a link (the app's record page).\n- Any other key is kept and handed back to `onSelect` and `renderCard`.\n\nWhere a card goes:\n\n| It is | and it goes |\n| --- | --- |\n| a top (named in `tops`, or by default a real card with no parent and kids) | the first tier |\n| under a top, or under a card in the tiers | the tier beneath its parent when that card is open |\n| real, and not reachable from any top | the unparented group |\n| `ghost` | the drafts group |\n\nA card named in `tops` is drawn as a top and nowhere else, even if it names a\nparent. A real card whose parent is a draft is unparented.\n\n## Props\n\n- `items` required. The rows above. With none, `empty` shows.\n- `tops` ids drawn as the first tier, in that order. Default: the real cards\n with no parent that have kids. Name them yourself to show a top that has\n nothing under it yet.\n- `topsLabel` the heading over the first tier (\"Company · Q3 2026\"). A node.\n- `label` the map's name for a reader. Default \"Tiers\".\n- `childrenLabel` the words after the count on a card with kids: a string\n (\"serving it\" gives \"6 serving it\") or `(count) => text`. Default \"below\".\n- `openWord` said after the count while a card is open. Default \"open\".\n- `open` the open path, controlled: an array of ids, **one per tier** (a top,\n then one of its kids, then one of theirs). An id that cannot be open there\n ends the path at that point.\n- `defaultOpen` the same, uncontrolled (the initial path). Default none.\n- `onOpenChange(ids)` called with the new path when a card opens or closes.\n- `onSelect(item)` makes a card's title and body a button that selects it (open\n the record); the count and the chevron then open the tier. Without it the\n whole card opens the tier, and a card with nothing under it does nothing.\n- `selected` the id marked as current.\n- `unparentedLabel` the group's heading. Default \"Not under anything\"; the\n count is added (\"Serves nothing · 1\"). The group is drawn only when there is\n something in it.\n- `unparentedNote` a node beside the cards: the note and the action, as you\n compose them (\"Serves nothing: is it work the company needs, or work nobody\n asked for?\" and a Button \"Link to a company goal\").\n- `ghostLabel` the drafts group's heading. Default \"Drafts\".\n- `ghostNote` a node under that heading.\n- `servesLabel` the word before the parent's title on a draft that serves a\n real card. Default \"Serves\"; `\"\"` leaves the line out.\n- `renderCard(item, state)` replaces the default card. Return nothing (`null`)\n to keep the default for that card. `state` is `{ id, item, open, selected,\n count, tier, ghost, serves, pressable, select, toggle, holderProps,\n primaryProps, toggleProps }`. Spread `holderProps` on your root,\n `primaryProps` on the element that takes the focus, and `toggleProps` (null\n when the card has nothing under it) on the button that opens the tier; the\n arrow keys, the roving focus and the tier lines depend on them.\n- `loading` three placeholder cards.\n- `error` the words for a failure (a string or a node). Pass plain words:\n the block shows them as given. With `onRetry`, a Try again button.\n- `empty` a node shown when there are no items. Default: \"Nothing to map yet\".\n- `className` layout: width and margin. Never colour.\n\n`TierCard` is exported too, for a `renderCard` that wants the default card in\nsome cases. Helpers, pure and without React: `buildTierModel(items, { tops })`,\n`openPath(model, ids)`, `togglePath(model, path, id)`, `pathTo(model, id)`\n(the ids to open so that a card is on screen), `tiersOf`, `neighbour`,\n`siblingsOf`, `elbowOf`, `centreOf`, `barOf`, `toneOf`, `countText`, `fitKey`,\n`thresholdRem`.\n\n## Example\n\n```jsx\nconst [path, setPath] = useState([\"c1\"]);\n\n<TierMap\n label=\"How goals fit\"\n items={goals} // [{ id, parent, title, kicker, by, value, tone, ghost }]\n tops={companyGoalIds}\n topsLabel=\"Company · Q3 2026\"\n childrenLabel={(n) => `${n} serving it`}\n open={path}\n onOpenChange={setPath}\n selected={goalId}\n onSelect={(goal) => router.push(`?goal=${goal.id}`)}\n unparentedLabel=\"Serves nothing\"\n unparentedNote={\n <>\n <p>Is it work the company needs, or work nobody asked for?</p>\n <Button size=\"sm\" onClick={linkIt}>Link to a company goal</Button>\n </>\n }\n ghostLabel=\"Q4 2026 drafts · aligned already\"\n/>\n\n// Open the parents of a goal a link points at:\nimport { buildTierModel, pathTo } from \"../components/blocks/tier-map\";\nconst path = pathTo(buildTierModel(goals, { tops: companyGoalIds }), goalId);\n```\n\n## Keys\n\nCards are buttons (a link when they have an `href`), with one tab stop for the\nwhole map; the arrows move inside it.\n\n- Left and right: the card beside, in the same tier. Home and End: the first\n and the last.\n- Down: into the first card under this one (opening it first if it is closed).\n- Up: to the parent.\n- Escape: close this card's tier; on a card that is not open, close its\n parent's tier and move to the parent.\n- Enter and Space press the focused button: select the card, or open its tier.\n\n## Layout\n\nEverything follows the block's own width (container queries), never the\nwindow's. A tier sits in **one row of equal columns** when the container has\n11 rem for each of its cards (and for each card of the tiers above it, so a\ntier never sits in a row under one that stacks); cards are at most 26 rem wide\nand centred in their column, and an elbow runs from the open card across to\neach card under it. A tier of more than eight cards always stacks.\n\nBelow that, the tier **stacks**: cards go one under another, full width, and\nthe cards under an open card hang from a line down their left. In between\n(the parent in a row, its kids too many for one), the line leaves the parent\nand runs left to that rail.\n\n## States drawn\n\n- **Empty**, **one top with nothing under it**, **many tops**, **one open**.\n- **A deep path**: three tiers open, controlled or not.\n- **Very long titles, kickers, feet and names**: they wrap or truncate; no\n card grows past its column.\n- **Narrow** (a 320 px container): tiers stack, cards full width, a rail.\n- **Wide** (past 1000 px): a tier of six in one row with its elbows.\n- **Unparented group** with its note and action; **drafts group** where a draft\n serves a draft and one serves a real card.\n- **A loop of parents** (broken), **a parent that is not in the list**, a card\n that names itself.\n- **Read-only** (no `onSelect`): a card only opens its tier.\n- **Loading**, **failed** (with Try again).\n- Reduced motion: the chevron does not turn.\n\n## Notes for the app that places it\n\n- **Colour is a signal.** The bar is ink unless the goal is behind (warning,\n danger) or done (success). Work out the status yourself; the block has no\n words for \"behind\" and no thresholds.\n- Put the numbers in words as well: `foot` is the place (\"Own key results 82%\").\n- The count on a card is the cards drawn under it. A draft is never counted.\n- Keep the open path in the address (`?open=c1,s1`) if a member should be able\n to send the page; `pathTo` gives the path for a card a link points at.\n- It does not fetch. Give it every item of the map at once; a list of hundreds\n is fine, only the open tiers are drawn.\n"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"categories": [
|
|
38
|
+
"tiers",
|
|
39
|
+
"tree",
|
|
40
|
+
"hierarchy",
|
|
41
|
+
"parent",
|
|
42
|
+
"children",
|
|
43
|
+
"map",
|
|
44
|
+
"goals",
|
|
45
|
+
"okr",
|
|
46
|
+
"strategy",
|
|
47
|
+
"initiatives",
|
|
48
|
+
"roadmap",
|
|
49
|
+
"sub-accounts",
|
|
50
|
+
"serves",
|
|
51
|
+
"alignment",
|
|
52
|
+
"elbow",
|
|
53
|
+
"connector",
|
|
54
|
+
"drafts",
|
|
55
|
+
"ghost",
|
|
56
|
+
"unparented",
|
|
57
|
+
"cards",
|
|
58
|
+
"progress"
|
|
59
|
+
],
|
|
60
|
+
"docs": "Installed to components/makersclaw/blocks/tier-map. Import: import { TierMap, TierCard, CARD_REM, MAX_COLUMNS, NEVER, TONES, barOf, buildTierModel, centreOf, countText, elbowOf, fitKey, neighbour, openPath, pathTo, siblingsOf, thresholdRem, tiersOf, togglePath, toneOf } from \"@/components/makersclaw/blocks/tier-map\". Props and an example: components/makersclaw/blocks/tier-map/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
61
|
+
"meta": {
|
|
62
|
+
"makersclaw": {
|
|
63
|
+
"version": "0.8.1",
|
|
64
|
+
"useWhen": "Any screen that shows what serves what, one layer at a time: a company's goals and the goals beneath them, a strategy map, initiatives under goals, work under a roadmap theme, an account and its sub-accounts, a category tree whose members are cards, a product and its components. Draws a structure the app has already worked out; it does not move cards between parents or collect anything. Not for a person-and-manager chart with zoom and drag (org-chart), a free-form map of ideas (mind-map), a full node-and-edge canvas (flow-canvas), a table with expandable rows (data-table), or a board of columns (board)."
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
package/r/time-grid.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "time-grid",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Time grid",
|
|
6
|
+
"description": "Days side by side on an hour grid, with the agent's proposals in pencil. What is already there is ink (events in their kind's colour, a lock on the ones another calendar owns); what the agent suggests is pencil: a hatched card on a dashed edge sitting on its hour, with a tag hanging off it that quotes the words it came from (\"Thursday at 3pm your time\"). Keep inks a block in (the same block, so its colours fade from pencil to ink; kept together they ink one by one), Dismiss drops it, Back to pencil takes a kept one back. \"Keep all\" says what it will do before it does it (\"Keeps 7 of 8 in ink: 5 Focus, 2 Admin. Leaves 1 in pencil: it clashes with Standup 9:30.\"). A clash is hatched where a proposal overlaps something already there, before anything is kept. A goals strip (GoalStrip) can sit above the days, goals in pencil until kept, with a total against a target that turns red once it is over. Blocks are events, not tasks: drag to move or resize (snapping to 15 minutes), drag across free working hours to make one, drop an item in from a list, arrow keys move a focused block, J and K walk the proposals that are waiting. Hours outside the working day are hatched but still there; a now line on today; one day at a time under a strip of days when the container is narrow. Loading, failed, empty and read-only are drawn.",
|
|
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/time-grid/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/time-grid/index.js",
|
|
16
|
+
"content": "// The TimeGrid block — `makersclaw add time-grid`. 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 { TimeGrid, GoalStrip, TIME_GRID_MIME, timeGridDragProps } from \"./time-grid\";\nexport {\n PENCIL_STATES,\n clashesOf,\n clock,\n durationWords,\n gridLanes,\n hoursOf,\n isWaiting,\n loadOf,\n lookOf,\n namesOf,\n newlyInked,\n planKeepAll,\n previewOf,\n readItem,\n tagPlacement,\n toHHMM,\n toMin,\n walkProposal,\n waitingOrder,\n} from \"./grid-model\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/time-grid/time-grid.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/time-grid/time-grid.jsx",
|
|
22
|
+
"content": "\"use client\";\n\n// Days side by side on an hour grid, with the agent's proposals in pencil.\n// What is already there is ink: events in their kind's colour, a lock on the\n// ones another calendar owns. What the agent suggests is pencil: a hatched\n// card on a dashed edge, on its hour, with a tag hanging off it that quotes\n// the words it came from. Keep inks a block in (the same block, so its\n// colours fade from pencil to ink; kept together they ink one by one), Keep\n// all says what it will do before it does it, a clash is hatched where two\n// things overlap, and a strip of goals can sit above the days.\n//\n// Props in, callbacks out: the block never reads or writes data. Moving a\n// block, making one by dragging across free hours, dropping an item in from\n// a list, Keep, Dismiss and Keep all each go out through one callback and\n// come back as new props; a save that fails puts the block back and says so.\n// README.md beside this file is the contract.\n//\n// Geometry never depends on data, so nothing shifts when rows arrive: the\n// header, the hour rows and the columns are fixed; blocks are absolute.\n// Narrow containers (a container query, no JS measuring) show one day at a\n// time under a strip of the days.\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, Icon, Notice, Skeleton, cx } from \"../../ui\";\nimport {\n clamp,\n clashesOf,\n clock,\n dateNum,\n durationWords,\n gridLanes,\n keyChange,\n lookOf,\n namesOf,\n newlyInked,\n planKeepAll,\n previewOf,\n readItem,\n snapFloor,\n snapRound,\n span,\n tagPlacement,\n toHHMM,\n toMin,\n walkProposal,\n weekdayIndex,\n} from \"./grid-model\";\n\nexport const TIME_GRID_MIME = \"application/x-time-grid-item\";\n\n// The item being dragged in from outside, in this window. dragover cannot\n// read dataTransfer (the browser protects it until drop), so the preview\n// reads this instead.\nlet carried = null;\n\n/** Spread onto a list item to make it droppable on a TimeGrid:\n * <li {...timeGridDragProps({ id, title, minutes: 60 })}>… */\nexport function timeGridDragProps(item) {\n return {\n draggable: true,\n onDragStart(e) {\n carried = item;\n try {\n e.dataTransfer.setData(TIME_GRID_MIME, JSON.stringify(item));\n e.dataTransfer.setData(\"text/plain\", String(item?.title ?? \"\"));\n e.dataTransfer.effectAllowed = \"copyMove\";\n } catch {\n // some browsers refuse custom types; `carried` still works\n }\n },\n onDragEnd() {\n carried = null;\n },\n };\n}\n\n// ---- looks: tokens only, as literal class names so Tailwind sees each ----\n\nconst WASH = {\n neutral: \"border-border bg-muted\",\n primary: \"border-primary bg-primary/15\",\n info: \"border-info bg-info/15\",\n success: \"border-success bg-success/15\",\n warning: \"border-warning bg-warning/15\",\n danger: \"border-destructive bg-destructive/15\",\n};\nconst WASH_HUE = { 1: \"border-chart-1 bg-chart-1/15\", 2: \"border-chart-2 bg-chart-2/15\", 3: \"border-chart-3 bg-chart-3/15\", 4: \"border-chart-4 bg-chart-4/15\", 5: \"border-chart-5 bg-chart-5/15\" };\nconst SOLID = \"border-primary bg-primary text-primary-foreground\";\n\nconst EDGE = {\n neutral: \"border-muted-foreground\",\n primary: \"border-primary\",\n info: \"border-info\",\n success: \"border-success\",\n warning: \"border-warning\",\n danger: \"border-destructive\",\n};\nconst EDGE_HUE = { 1: \"border-chart-1\", 2: \"border-chart-2\", 3: \"border-chart-3\", 4: \"border-chart-4\", 5: \"border-chart-5\" };\n\nconst INK_TEXT = {\n neutral: \"text-muted-foreground\",\n primary: \"text-primary\",\n info: \"text-info\",\n success: \"text-success\",\n warning: \"text-warning\",\n danger: \"text-destructive\",\n};\nconst INK_TEXT_HUE = { 1: \"text-chart-1\", 2: \"text-chart-2\", 3: \"text-chart-3\", 4: \"text-chart-4\", 5: \"text-chart-5\" };\n\nconst NOTE_TONE = {\n neutral: \"text-muted-foreground\",\n primary: \"text-primary\",\n info: \"text-info\",\n success: \"text-success\",\n warning: \"text-warning\",\n danger: \"text-destructive\",\n};\n\nconst HATCH = \"bg-[image:repeating-linear-gradient(135deg,transparent_0_5px,var(--color-border)_5px_6px)]\";\nconst OFF_HATCH = \"bg-[image:repeating-linear-gradient(135deg,transparent_0_6px,var(--color-border)_6px_7px)] bg-muted/40\";\nconst CLASH_HATCH = \"bg-[image:repeating-linear-gradient(135deg,var(--color-warning)_0_3px,transparent_3px_7px)]\";\n\n// Column templates, written out whole so Tailwind sees each class.\nconst WIDE_COLS = {\n 1: \"@min-[36rem]:grid-cols-[3rem_repeat(1,minmax(0,1fr))]\",\n 2: \"@min-[36rem]:grid-cols-[3rem_repeat(2,minmax(0,1fr))]\",\n 3: \"@min-[36rem]:grid-cols-[3rem_repeat(3,minmax(0,1fr))]\",\n 4: \"@min-[36rem]:grid-cols-[3rem_repeat(4,minmax(0,1fr))]\",\n 5: \"@min-[36rem]:grid-cols-[3rem_repeat(5,minmax(0,1fr))]\",\n 6: \"@min-[36rem]:grid-cols-[3rem_repeat(6,minmax(0,1fr))]\",\n 7: \"@min-[36rem]:grid-cols-[3rem_repeat(7,minmax(0,1fr))]\",\n};\nconst STRIP_COLS = { 1: \"grid-cols-1\", 2: \"grid-cols-2\", 3: \"grid-cols-3\", 4: \"grid-cols-4\", 5: \"grid-cols-5\", 6: \"grid-cols-6\", 7: \"grid-cols-7\" };\n\nconst WORDS = {\n allDay: \"all day\",\n keep: \"Keep\",\n dismiss: \"Dismiss\",\n unkeep: \"Back to pencil\",\n keepAll: \"Keep all\",\n pencilled: \"pencilled in, waiting for you\",\n unsure: \"Unsure\",\n failed: \"Didn’t add\",\n replaced: \"Replaced\",\n was: \"Was\",\n clash: \"Clash\",\n locked: \"locked\",\n drafting: \"Pencilling in…\",\n newBlock: \"New block\",\n empty: \"Nothing on these days yet.\",\n retry: \"Try again\",\n back: \"went back: the change was not saved.\",\n};\n\nconst reducedMotion = () => typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\n/** Words typed into, or a key meant for something else: J and K are left alone. */\nfunction isTyping(el) {\n if (!el || el.nodeType !== 1) return false;\n return Boolean(el.closest?.(\"input, textarea, select, [contenteditable=''], [contenteditable='true']\"));\n}\n\nconst cut = (s) => String(s ?? \"\");\n\n/** One look for one item: the classes of its shell. */\nfunction shellOf(look, kind, pencilColor) {\n const tone = kind.tone ?? \"neutral\";\n const hue = kind.hue;\n const edge = hue ? EDGE_HUE[hue] : (EDGE[tone] ?? EDGE.neutral);\n if (look === \"ink\") {\n if (kind.solid) return cx(\"border text-primary-foreground\", SOLID);\n return cx(\"border text-foreground\", hue ? WASH_HUE[hue] : (WASH[tone] ?? WASH.neutral));\n }\n const pencil = cx(\"bg-card text-foreground\", HATCH);\n if (look === \"pencil\") return cx(pencil, \"border-[1.5px] border-dashed\", pencilColor === \"kind\" ? edge : EDGE.neutral);\n if (look === \"unsure\") return cx(pencil, \"border-2 border-dotted\", pencilColor === \"kind\" ? edge : EDGE.neutral);\n if (look === \"failed\") return \"border-[1.5px] border-solid border-destructive bg-card text-foreground\";\n if (look === \"replaced\") return cx(pencil, \"border-[1.5px] border-dashed border-muted-foreground opacity-60\");\n return \"border-[1.5px] border-dotted border-input bg-transparent text-muted-foreground\"; // was\n}\n\nfunction labelToneOf(look, kind, clashed) {\n if (clashed) return \"text-warning\";\n if (look === \"failed\") return \"text-destructive\";\n if (look === \"ink\") return \"text-inherit\";\n return kind.hue ? INK_TEXT_HUE[kind.hue] : (INK_TEXT[kind.tone ?? \"neutral\"] ?? INK_TEXT.neutral);\n}\n\n// ---- the inside of a block ------------------------------------------------\n\nfunction Face({ item, h, kind, look, label, fmt, clashed, pencilClassName, words }) {\n const compact = h < 26;\n const pencilish = look !== \"ink\";\n const struck = look === \"replaced\" || item.struck;\n const chip = label ? (\n <span className={cx(\"min-w-0 shrink-0 whitespace-nowrap text-2xs font-semibold leading-tight\", labelToneOf(look, kind, clashed))}>{label}</span>\n ) : null;\n const icon = item.locked || kind.locked ? \"lock\" : item.done ? \"check\" : (item.icon ?? kind.icon);\n return (\n <span className={cx(\"flex h-full min-w-0 flex-col overflow-hidden text-left\", compact ? \"justify-center px-1.5 py-0\" : \"gap-0.5 px-2 py-1\")}>\n {!compact && chip}\n <span className={cx(\"flex min-w-0 gap-1\", compact ? \"items-center\" : \"items-start\")}>\n {compact ? chip : null}\n {icon ? <Icon name={icon} size={12} className={cx(\"shrink-0\", !compact && \"mt-0.5\")} /> : null}\n <span\n className={cx(\n \"min-w-0\",\n compact ? \"truncate\" : \"line-clamp-3\",\n pencilish && pencilClassName,\n \"text-xs font-medium leading-tight\",\n struck && \"line-through decoration-1\",\n )}\n >\n {item.title}\n </span>\n </span>\n {!compact && h >= 38 ? (\n <span data-nums className={cx(\"truncate font-mono text-2xs leading-tight\", look === \"ink\" && kind.solid ? \"\" : \"text-muted-foreground\")}>\n {span(item.s, item.e, fmt)}\n {item.caption ? `, ${item.caption}` : \"\"}\n </span>\n ) : null}\n {!compact && h >= 62 && item.sub ? <span className=\"line-clamp-2 text-2xs leading-tight text-muted-foreground\">{item.sub}</span> : null}\n {!compact && h >= 62 && item.note && pencilish ? (\n <span className=\"mt-auto flex min-w-0 items-center gap-1 text-2xs leading-tight text-muted-foreground\">\n <Icon name=\"pencil\" size={10} className=\"shrink-0\" />\n <span className=\"truncate\">{item.note}</span>\n </span>\n ) : null}\n <span className=\"sr-only\">{look === \"pencil\" || look === \"unsure\" ? words.pencilled : \"\"}</span>\n </span>\n );\n}\n\n/** Keep and Dismiss (or Back to pencil), small enough for a block. */\nfunction MiniActions({ item, look, onAction, busy, words, tall, show }) {\n const run = (e, id) => {\n e.stopPropagation();\n onAction?.(item, id);\n };\n const stop = (e) => e.stopPropagation();\n const base =\n \"inline-flex h-5 items-center justify-center gap-1 rounded-[4px] border px-1.5 text-2xs font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50\";\n if (look === \"ink\") {\n return (\n <span className={cx(\"absolute right-1 top-1 z-10 flex gap-0.5\", !show && \"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100\")}>\n <button type=\"button\" disabled={busy} onPointerDown={stop} onClick={(e) => run(e, \"unkeep\")} className={cx(base, \"border-border bg-card text-foreground\")}>\n {words.unkeep}\n </button>\n </span>\n );\n }\n return (\n <span className={cx(\"absolute z-10 flex gap-0.5\", tall ? \"inset-x-1.5 bottom-1\" : \"right-0.5 top-0.5\", !tall && !show && \"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100\")}>\n <button type=\"button\" disabled={busy} onPointerDown={stop} onClick={(e) => run(e, \"keep\")} className={cx(base, \"border-primary bg-primary text-primary-foreground\")}>\n <Icon name=\"check\" size={10} />\n <span className={tall ? \"\" : \"sr-only\"}>{words.keep}</span>\n </button>\n <button type=\"button\" disabled={busy} onPointerDown={stop} onClick={(e) => run(e, \"dismiss\")} aria-label={words.dismiss} title={words.dismiss} className={cx(base, \"border-border bg-card px-1 text-foreground\")}>\n <Icon name=\"x\" size={10} />\n </button>\n </span>\n );\n}\n\n// ---- the grid ---------------------------------------------------------------\n\nexport function TimeGrid({\n days = [],\n events = [],\n proposals = [],\n kinds = {},\n today,\n now,\n startHour = 5,\n endHour = 21,\n snap = 15,\n hourHeight = 40,\n workStart,\n workEnd,\n scrollTo,\n height,\n locale = \"en-GB\",\n formatTime = clock,\n corner,\n above,\n dayNotes,\n renderDayHeader,\n pencilColor = \"neutral\",\n pencilClassName,\n tags = \"selected\",\n clashAmong = false,\n keepAll = false,\n selectedId,\n defaultSelectedId = null,\n onSelect,\n onOpen,\n onAction,\n onKeepAll,\n onChange,\n onCompose,\n onDrop,\n explainBlock,\n explainOffHours,\n draft,\n drafting,\n status = \"ready\",\n error,\n onRetry,\n readOnly = false,\n keys = true,\n words: wordsIn,\n emptyText,\n label = \"Week\",\n className,\n}) {\n const words = useMemo(() => ({ ...WORDS, ...(wordsIn ?? {}) }), [wordsIn]);\n const keepWordsId = useId();\n const list = days;\n const lo = startHour * 60;\n const hi = endHour * 60;\n const ppm = hourHeight / 60;\n const bodyHeight = (endHour - startHour) * hourHeight;\n const ws = toMin(workStart);\n const we = toMin(workEnd);\n const box = { lo, hi, ws, we, snap };\n const editable = !readOnly && Boolean(onChange);\n\n const [innerSel, setInnerSel] = useState(defaultSelectedId);\n const sel = selectedId !== undefined ? selectedId : innerSel;\n const select = (id) => {\n if (selectedId === undefined) setInnerSel(id);\n onSelect?.(id);\n };\n\n const [day, setDay] = useState(() => (list.includes(today) ? today : list[0]));\n useEffect(() => {\n if (!list.includes(day)) setDay(list.includes(today) ? today : list[0]);\n }, [list.join(\",\"), today]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const root = useRef(null);\n const scroller = useRef(null);\n const cols = useRef(new Map()); // date → the day's body element\n const gesture = useRef(null);\n const suppressClick = useRef(false);\n const latest = useRef({});\n const [preview, setPreview] = useState(null);\n const [overrides, setOverrides] = useState(() => new Map());\n const [pending, setPending] = useState([]); // drops waiting for their row\n const [dropHint, setDropHint] = useState(null);\n const [note, setNote] = useState(null); // { date, minute, node }\n const [problem, setProblem] = useState(null); // { message, retry }\n const [busy, setBusy] = useState(() => new Set());\n const [keepingAll, setKeepingAll] = useState(false);\n const [clockMin, setClockMin] = useState(null);\n const [, tick] = useState(0);\n\n // The now line: the viewer's clock after mount (the server's is not\n // theirs), unless the page says what time it is.\n useEffect(() => {\n if (now != null) return undefined;\n const t = () => {\n const d = new Date();\n setClockMin(d.getHours() * 60 + d.getMinutes());\n };\n t();\n const id = setInterval(t, 60_000);\n return () => clearInterval(id);\n }, [now]);\n const nowMin = now != null ? toMin(now) : clockMin;\n\n useEffect(() => {\n if (!note) return undefined;\n const id = setTimeout(() => setNote(null), 9000);\n return () => clearTimeout(id);\n }, [note]);\n\n // Open on the working day when the hours scroll (a grid with a height).\n const scrolledByHand = useRef(false);\n useLayoutEffect(() => {\n const el = scroller.current;\n if (!el || !height) return undefined;\n const target = Math.max(0, ((toMin(scrollTo) ?? ws ?? 8 * 60) - lo) * ppm - 12);\n const apply = () => {\n if (!scrolledByHand.current && scroller.current) scroller.current.scrollTop = target;\n };\n apply();\n const id = setTimeout(apply, 120);\n const mark = () => {\n if (Math.abs((scroller.current?.scrollTop ?? target) - target) > 2) scrolledByHand.current = true;\n };\n el.addEventListener(\"scroll\", mark, { passive: true });\n return () => {\n clearTimeout(id);\n el.removeEventListener(\"scroll\", mark);\n };\n }, [status]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- what is drawn: ink and pencil in one list, so a block that is kept\n // stays the same element and its colours fade from pencil to ink.\n const items = useMemo(() => {\n const out = [];\n const seen = new Set();\n const put = (raw, pencil) => {\n const it = readItem(raw, { days: list, lo, hi, snap, pencil });\n if (!it || seen.has(it.id)) return;\n seen.add(it.id);\n const o = overrides.get(it.id);\n if (o && !it.allDay) {\n out.push({ ...it, date: o.date, s: toMin(o.start), e: toMin(o.end) });\n } else out.push(it);\n };\n for (const r of events ?? []) put(r, false);\n for (const r of proposals ?? []) put(r, true);\n return out;\n }, [events, proposals, overrides, list.join(\",\"), lo, hi, snap]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const looks = useMemo(() => new Map(items.map((i) => [i.id, lookOf(i)])), [items]);\n const clashes = useMemo(() => clashesOf(items, { among: clashAmong }), [items, clashAmong]);\n const plan = useMemo(() => planKeepAll(items, { clashes, kinds, skip: keepAll && keepAll.skip ? keepAll.skip : [\"clash\"], fmt: formatTime }), [items, clashes, kinds, keepAll, formatTime]);\n\n // Blocks that were pencil and are ink now, in reading order: they ink in one by one.\n const before = useRef(new Map());\n const fresh = newlyInked(before.current, items, list);\n useEffect(() => {\n before.current = new Map(looks);\n if (fresh.length < 2) return undefined;\n const id = setTimeout(() => tick((n) => n + 1), 220 * fresh.length + 900);\n return () => clearTimeout(id);\n }); // runs after every render on purpose: `before` must always be the render just drawn\n\n // A saved change stays on screen until the rows carry it.\n useEffect(() => {\n latest.current.items = items;\n setOverrides((cur) => {\n if (!cur.size) return cur;\n const next = new Map(cur);\n const rows = [...(events ?? []), ...(proposals ?? [])];\n for (const [id, o] of cur) {\n const b = rows.find((x) => String(x.id) === id);\n if (!b || (b.date === o.date && toMin(b.start) === toMin(o.start) && toMin(b.end) === toMin(o.end))) next.delete(id);\n }\n return next.size === cur.size ? cur : next;\n });\n setPending((cur) => (cur.length ? cur.filter((p) => !(p.done || matches(p, [...(events ?? []), ...(proposals ?? [])]))) : cur));\n }, [events, proposals]); // eslint-disable-line react-hooks/exhaustive-deps\n\n function matches(p, rows) {\n return rows.some((b) => b.date === p.date && toMin(b.start) === p.start && toMin(b.end) === p.end);\n }\n\n const byDay = useMemo(() => {\n const map = new Map(list.map((d) => [d, { timed: [], allDay: [], was: [] }]));\n for (const it of items) {\n const slot = map.get(it.date);\n if (!slot) continue;\n if (it.allDay) slot.allDay.push(it);\n else if (looks.get(it.id) === \"was\") slot.was.push(it);\n else slot.timed.push(it);\n }\n for (const slot of map.values()) slot.lanes = gridLanes(slot.timed.map((b) => ({ id: b.id, s: b.s, e: b.e })));\n return map;\n }, [items, looks, list.join(\",\")]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const kindOf = (it) => kinds[it.kind] ?? {};\n const waitingCount = plan.total;\n\n // ---- geometry: pointer → (day, minute)\n function slotAt(x, y, fixedDate) {\n let date = fixedDate ?? null;\n let rect = null;\n if (date) rect = cols.current.get(date)?.getBoundingClientRect() ?? null;\n else {\n let best = null;\n for (const d of list) {\n const r = cols.current.get(d)?.getBoundingClientRect();\n if (!r || r.width === 0) continue;\n const dist = x < r.left ? r.left - x : x > r.right ? x - r.right : 0;\n if (!best || dist < best.dist) best = { d, r, dist };\n }\n if (best) {\n date = best.d;\n rect = best.r;\n }\n }\n if (!date || !rect) return null;\n return { date, minute: lo + (y - rect.top) / ppm };\n }\n const offHours = (m) => (ws != null && m < ws) || (we != null && m >= we);\n\n // ---- gestures: window listeners, so a drag that leaves the grid (or a\n // pointer event dispatched from a script) still lands.\n const listeners = useRef(null);\n if (!listeners.current) {\n listeners.current = {\n move: (e) => latest.current.onMove?.(e),\n up: (e) => latest.current.onUp?.(e),\n cancel: () => latest.current.cancel?.(),\n };\n }\n function attach() {\n window.addEventListener(\"pointermove\", listeners.current.move);\n window.addEventListener(\"pointerup\", listeners.current.up);\n window.addEventListener(\"pointercancel\", listeners.current.cancel);\n window.addEventListener(\"blur\", listeners.current.cancel);\n }\n function detach() {\n window.removeEventListener(\"pointermove\", listeners.current.move);\n window.removeEventListener(\"pointerup\", listeners.current.up);\n window.removeEventListener(\"pointercancel\", listeners.current.cancel);\n window.removeEventListener(\"blur\", listeners.current.cancel);\n stopAutoScroll();\n }\n useEffect(() => () => detach(), []); // eslint-disable-line react-hooks/exhaustive-deps\n\n // Auto-scroll near the scroller's edges while dragging. An interval, not\n // animation frames: a hidden tab never runs those.\n const autoScroll = useRef(null);\n function stopAutoScroll() {\n if (autoScroll.current) clearInterval(autoScroll.current.id);\n autoScroll.current = null;\n }\n function maybeAutoScroll(y) {\n const el = scroller.current;\n if (!el || !height) return;\n const r = el.getBoundingClientRect();\n const dir = y < r.top + 36 ? -1 : y > r.bottom - 36 ? 1 : 0;\n if (!dir) return stopAutoScroll();\n if (autoScroll.current?.dir === dir) return;\n stopAutoScroll();\n autoScroll.current = {\n dir,\n id: setInterval(() => {\n el.scrollTop += dir * 14;\n const g = gesture.current;\n if (g?.active && g.last) {\n const p = previewOf(g, slotAt(g.last.x, g.last.y, g.kind === \"move\" ? undefined : g.date), box);\n if (p) {\n g.preview = p;\n setPreview(p);\n }\n }\n }, 30),\n };\n }\n\n const pointerPreview = (g, x, y) => previewOf(g, slotAt(x, y, g.kind === \"move\" ? undefined : g.date), box);\n\n latest.current.onMove = (e) => {\n const g = gesture.current;\n if (!g || (e.pointerId != null && g.pointerId != null && e.pointerId !== g.pointerId)) return;\n // Released outside the window: the page never saw the pointerup.\n if (e.pointerType === \"mouse\" && e.buttons === 0) return latest.current.cancel();\n g.last = { x: e.clientX, y: e.clientY };\n if (!g.active) {\n if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < 4) return;\n g.active = true;\n if (g.kind === \"held\") {\n // A block that cannot move: say why, once, where it is.\n gesture.current = null;\n detach();\n const node = explainBlock?.(g.item);\n if (node) setNote({ date: g.item.date, minute: g.item.s, node });\n return;\n }\n }\n const p = pointerPreview(g, e.clientX, e.clientY);\n if (p) {\n g.preview = p;\n setPreview(p);\n }\n maybeAutoScroll(e.clientY);\n };\n\n latest.current.onUp = (e) => {\n const g = gesture.current;\n if (!g || (e.pointerId != null && g.pointerId != null && e.pointerId !== g.pointerId)) return;\n gesture.current = null;\n detach();\n if (g.kind === \"held\") return;\n const p = g.active ? (pointerPreview(g, e.clientX, e.clientY) ?? g.preview) : null;\n setPreview(null);\n if (g.kind === \"create\") {\n const range = p ?? {\n date: g.date,\n start: clamp(g.anchor, ws ?? lo, (we ?? hi) - 60),\n end: clamp(g.anchor + 60, (ws ?? lo) + snap, we ?? hi),\n };\n onCompose?.({ date: range.date, start: toHHMM(range.start), end: toHHMM(range.end) });\n return;\n }\n if (!g.active || !p) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n if (p.date === g.item.date && p.start === g.s0 && p.end === g.e0) return;\n commitChange(g.item, p);\n };\n\n latest.current.cancel = () => {\n if (!gesture.current) return;\n gesture.current = null;\n detach();\n setPreview(null);\n };\n\n const fail = (message, retry) => setProblem({ message, retry });\n\n async function commitChange(item, p) {\n const id = item.id;\n const next = { date: p.date, start: toHHMM(p.start), end: toHHMM(p.end) };\n setProblem(null);\n setOverrides((m) => new Map(m).set(id, next));\n let result;\n try {\n result = await onChange?.(item, { ...next, mode: p.mode, from: { date: item.date, start: toHHMM(item.s), end: toHHMM(item.e) } }, { proposal: item.pencil });\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const n = new Map(m);\n n.delete(id);\n return n;\n });\n fail(`“${item.title}” ${words.back}${result.error ? ` ${result.error}` : \"\"}`, () => commitChange(item, p));\n }\n }\n\n async function runAction(item, id) {\n if (!onAction || readOnly) return;\n setProblem(null);\n setBusy((s) => new Set(s).add(item.id));\n let result;\n try {\n result = await onAction(item, id);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy((s) => {\n const n = new Set(s);\n n.delete(item.id);\n return n;\n });\n if (result && result.ok === false) fail(`“${item.title}”: ${result.error ?? \"that did not save.\"}`, () => runAction(item, id));\n }\n\n async function runKeepAll() {\n if (!onKeepAll || readOnly || !plan.keep.length) return;\n setProblem(null);\n setKeepingAll(true);\n let result;\n try {\n result = await onKeepAll(plan);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setKeepingAll(false);\n if (result && result.ok === false) fail(result.error ?? \"None of it was kept.\", runKeepAll);\n }\n\n function onBlockPointerDown(e, it) {\n if (gesture.current) latest.current.cancel();\n if (it.allDay) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const held = !editable || it.draggable === false || it.locked || kindOf(it).locked || looks.get(it.id) === \"failed\" || looks.get(it.id) === \"replaced\";\n if (held) {\n gesture.current = { kind: \"held\", item: it, x0: e.clientX, y0: e.clientY, pointerId: e.pointerId ?? null, active: false };\n attach();\n return;\n }\n const edge = e.target.closest?.(\"[data-edge]\")?.getAttribute(\"data-edge\");\n const at = slotAt(e.clientX, e.clientY, it.date);\n gesture.current = {\n kind: edge === \"start\" ? \"start\" : edge === \"end\" ? \"end\" : \"move\",\n id: it.id,\n item: it,\n date: it.date,\n s0: it.s,\n e0: it.e,\n offset: at ? at.minute - it.s : 0,\n x0: e.clientX,\n y0: e.clientY,\n pointerId: e.pointerId ?? null,\n active: false,\n };\n attach();\n }\n\n function onColumnPointerDown(e, date) {\n if (e.target !== e.currentTarget) return;\n if (gesture.current) latest.current.cancel();\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n select(null);\n const at = slotAt(e.clientX, e.clientY, date);\n if (!at) return;\n setNote(null);\n if (offHours(at.minute) && explainOffHours) {\n const node = explainOffHours({ date, minute: at.minute, before: ws != null && at.minute < ws });\n if (node) setNote({ date, minute: Math.round(at.minute), node });\n return;\n }\n if (!onCompose || readOnly) return;\n e.preventDefault();\n gesture.current = {\n kind: \"create\",\n date,\n anchor: clamp(snapFloor(at.minute, snap), ws ?? lo, (we ?? hi) - snap),\n x0: e.clientX,\n y0: e.clientY,\n pointerId: e.pointerId ?? null,\n active: false,\n };\n attach();\n }\n\n // Esc cancels a drag or a note, and clears the selection.\n useEffect(() => {\n const onKey = (e) => {\n if (e.key !== \"Escape\") return;\n if (gesture.current) latest.current.cancel();\n else setNote(null);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, []);\n\n // ---- drops from outside (a task list beside the grid)\n function onDragOver(e) {\n if (!onDrop || readOnly) return;\n const ours = carried || [...(e.dataTransfer?.types ?? [])].includes(TIME_GRID_MIME);\n if (!ours) return;\n e.preventDefault();\n if (e.dataTransfer) e.dataTransfer.dropEffect = \"copy\";\n const at = slotAt(e.clientX, e.clientY);\n if (!at) return;\n const minutes = clamp(Number(carried?.minutes) || 60, snap, hi - lo);\n const s = clamp(snapFloor(at.minute, snap), lo, hi - minutes);\n setDropHint({ date: at.date, start: s, end: s + minutes, title: carried?.title ?? \"\" });\n }\n function onDragLeave(e) {\n if (!e.currentTarget.contains(e.relatedTarget)) setDropHint(null);\n }\n async function onDropHere(e) {\n if (!onDrop || readOnly) return;\n let item = carried;\n try {\n const raw = e.dataTransfer?.getData(TIME_GRID_MIME);\n if (raw) item = JSON.parse(raw);\n } catch {\n // keep `carried`\n }\n const hint = dropHint;\n setDropHint(null);\n carried = null;\n if (!item) return;\n e.preventDefault();\n const at = slotAt(e.clientX, e.clientY);\n const minutes = clamp(Number(item.minutes) || 60, snap, hi - lo);\n const s = at ? clamp(snapFloor(at.minute, snap), lo, hi - minutes) : hint?.start;\n const date = at?.date ?? hint?.date;\n if (date == null || s == null) return;\n const key = `drop-${item.id}-${Date.now()}`;\n const ghost = { key, date, start: s, end: s + minutes, title: item.title ?? \"\", done: false };\n setPending((cur) => [...cur, ghost]);\n let result;\n try {\n result = await onDrop(item, { date, start: toHHMM(s), end: toHHMM(s + minutes) });\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n if (result && result.ok === false) {\n setPending((cur) => cur.filter((p) => p.key !== key));\n fail(`“${item.title}” was not placed.${result.error ? ` ${result.error}` : \"\"}`);\n return;\n }\n if (matches(ghost, [...(events ?? []), ...(proposals ?? [])])) setPending((cur) => cur.filter((p) => p.key !== key));\n else setPending((cur) => cur.map((p) => (p.key === key ? { ...p, done: true } : p)));\n }\n\n // ---- keyboard: a focused block moves by the snap, a day, or grows\n function onBlockKeyDown(e, it) {\n if (!editable || it.draggable === false || it.locked || kindOf(it).locked || it.allDay) return;\n const p = keyChange(e.key, e.shiftKey, it, { days: list, lo, hi, snap });\n if (!p) return;\n e.preventDefault();\n if (p.date === it.date && p.start === it.s && p.end === it.e) return;\n commitChange(it, p);\n }\n\n // J and K walk the proposals that are waiting.\n function onRootKeyDown(e) {\n if (!keys || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return;\n if (e.key === \"Escape\") {\n if (sel != null) select(null);\n return;\n }\n const dir = e.key === \"j\" || e.key === \"J\" ? 1 : e.key === \"k\" || e.key === \"K\" ? -1 : 0;\n if (!dir) return;\n const id = walkProposal(items, list, sel, dir);\n if (id == null) return;\n e.preventDefault();\n select(id);\n const it = items.find((i) => i.id === id);\n if (it && list.includes(it.date)) setDay(it.date);\n requestAnimationFrame(() => {\n const el = root.current?.querySelector(`[data-block=\"${CSS.escape(id)}\"]`);\n el?.scrollIntoView?.({ block: \"nearest\", behavior: reducedMotion() ? \"auto\" : \"smooth\" });\n });\n }\n\n // ---- drawing\n const isToday = (d) => d === today;\n const top = (min) => (min - lo) * ppm;\n const fmtDay = useMemo(() => {\n try {\n return new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" });\n } catch {\n return new Intl.DateTimeFormat(\"en-GB\", { weekday: \"short\", timeZone: \"UTC\" });\n }\n }, [locale]);\n const fmtLong = useMemo(() => {\n try {\n return new Intl.DateTimeFormat(locale, { weekday: \"long\", timeZone: \"UTC\" });\n } catch {\n return new Intl.DateTimeFormat(\"en-GB\", { weekday: \"long\", timeZone: \"UTC\" });\n }\n }, [locale]);\n const weekday = (d, long) => {\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(d);\n if (!m) return \"\";\n return (long ? fmtLong : fmtDay).format(new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))));\n };\n const hours = [];\n for (let h = startHour + 1; h < endHour; h += 1) hours.push(h);\n\n const headerFor = (d, i, compact) => {\n if (renderDayHeader) return renderDayHeader(d, { isToday: isToday(d), index: i, compact, selected: d === day });\n const n = dayNotes?.[d];\n return (\n <span className={cx(\"flex min-w-0 gap-x-1.5\", compact ? \"flex-col items-center\" : \"items-baseline\")}>\n <span className={cx(\"text-xs font-medium\", isToday(d) ? \"text-primary\" : \"text-muted-foreground\")}>{weekday(d)}</span>\n <span data-nums className={cx(\"text-sm font-semibold\", isToday(d) && \"rounded-full bg-primary px-1.5 text-primary-foreground\")}>\n {dateNum(d)}\n </span>\n {n?.text ? (\n <span data-nums className={cx(\"min-w-0 truncate text-2xs font-medium\", compact ? \"\" : \"ml-auto\", NOTE_TONE[n.tone ?? \"neutral\"] ?? NOTE_TONE.neutral)}>\n {n.text}\n </span>\n ) : null}\n </span>\n );\n };\n\n const floating = (b, h, look = \"ink\", kind = {}) => (\n <div\n aria-hidden=\"true\"\n className={cx(\"pointer-events-none absolute inset-x-[3px] z-40 rounded-[4px] shadow-lg transition-[top,height] duration-75 ease-out\", shellOf(look, kind, pencilColor))}\n style={{ top: top(b.s) + 1, height: h }}\n >\n <Face item={b} h={h} kind={kind} look={look} fmt={formatTime} words={words} pencilClassName={pencilClassName} />\n </div>\n );\n\n const labelOf = (it, look, clashed) => {\n if (it.label != null) return it.label;\n if (clashed) return `${words.clash} · ${namesOf(clashed, formatTime, 1)}`;\n if (look === \"unsure\") return `${words.unsure}${kindOf(it).label ? ` · ${kindOf(it).label}` : \"\"}`;\n if (look === \"failed\") return words.failed;\n if (look === \"replaced\") return words.replaced;\n if (look === \"was\") return words.was;\n if (look === \"pencil\") return kindOf(it).label ?? \"\";\n return null;\n };\n\n const showKeepAll = Boolean(keepAll) && !readOnly && waitingCount > 0;\n const selectedItem = sel != null ? items.find((i) => i.id === String(sel)) : null;\n\n // ---- loading / failed\n if (status === \"failed\") {\n return (\n <div className={cx(\"@container\", className)} role=\"group\" aria-label={label}>\n <Notice tone=\"danger\" title={words.failedTitle ?? \"The week did not load\"} action={onRetry ? <Button size=\"sm\" onClick={onRetry}>{words.retry}</Button> : null}>\n {error ?? \"Something went wrong reading the week.\"}\n </Notice>\n </div>\n );\n }\n const loading = status === \"loading\";\n\n return (\n <div\n ref={root}\n className={cx(\"@container relative select-none outline-none focus-visible:ring-2 focus-visible:ring-ring\", height === \"fill\" && \"flex min-h-0 flex-1 flex-col\", className)}\n aria-label={label}\n role=\"group\"\n tabIndex={keys ? 0 : undefined}\n aria-busy={loading || undefined}\n onKeyDown={onRootKeyDown}\n >\n {above ? <div className=\"mb-2\">{above}</div> : null}\n\n {showKeepAll ? (\n <div className=\"grid items-center gap-2 border-b border-border px-2 py-2 @min-[36rem]:grid-cols-[minmax(0,1fr)_auto]\">\n <p className=\"m-0 min-w-0 text-xs text-muted-foreground\" id={keepWordsId}>\n {plan.words}\n </p>\n <Button\n size=\"sm\"\n variant={keepAll.primary ? \"primary\" : \"secondary\"}\n loading={keepingAll}\n disabled={!plan.keep.length || !onKeepAll}\n onClick={runKeepAll}\n aria-describedby={keepWordsId}\n >\n {`${keepAll.label ?? words.keepAll} (${plan.keep.length})`}\n </Button>\n </div>\n ) : null}\n\n {problem ? (\n <Notice\n tone=\"danger\"\n className=\"m-2\"\n onDismiss={() => setProblem(null)}\n action={problem.retry ? <Button size=\"sm\" onClick={problem.retry}>{words.retry}</Button> : null}\n >\n {problem.message}\n </Notice>\n ) : null}\n\n {/* one day at a time on a narrow window: the strip picks it */}\n <div className={cx(\"grid gap-1 border-b border-border p-2 @min-[36rem]:hidden\", STRIP_COLS[list.length] ?? \"grid-cols-5\")}>\n {list.map((d, i) => (\n <button\n key={d}\n type=\"button\"\n aria-pressed={d === day}\n onClick={() => setDay(d)}\n className={cx(\"flex min-h-11 flex-col items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\", d === day ? \"bg-accent\" : \"hover:bg-accent/50\")}\n >\n {headerFor(d, i, true)}\n </button>\n ))}\n </div>\n\n <div\n ref={scroller}\n className={cx(\"relative overflow-x-clip\", height && \"overflow-y-auto overscroll-contain\", height === \"fill\" && \"min-h-0 flex-1\")}\n style={height && height !== \"fill\" ? { height } : undefined}\n >\n {/* the date row and the all-day row stay on top while the hours scroll */}\n <div className=\"sticky top-0 z-30 bg-card\">\n <div className={cx(\"hidden @min-[36rem]:grid\", WIDE_COLS[list.length])}>\n <div aria-hidden={corner ? undefined : \"true\"} className=\"flex items-end justify-end border-b border-border px-1.5 pb-1 text-2xs text-muted-foreground\">\n {corner}\n </div>\n {list.map((d, i) => (\n <div key={d} className={cx(\"relative min-w-0 border-l border-border px-2.5 pb-1.5 pt-2\", isToday(d) ? \"border-b-2 border-b-primary bg-primary/5\" : \"border-b border-b-border\")}>\n {headerFor(d, i, false)}\n </div>\n ))}\n </div>\n <div className={cx(\"grid grid-cols-[3rem_minmax(0,1fr)] border-b border-border\", WIDE_COLS[list.length])}>\n <div className=\"flex items-center justify-end pr-1.5 text-2xs text-muted-foreground\">{words.allDay}</div>\n {list.map((d) => (\n <div key={d} className={cx(\"min-h-7 min-w-0 space-y-0.5 border-l border-border p-[3px]\", d !== day && \"hidden @min-[36rem]:block\", isToday(d) && \"bg-primary/5\")}>\n {(byDay.get(d)?.allDay ?? []).map((it) => {\n const look = looks.get(it.id);\n const kind = kindOf(it);\n const waiting = look !== \"ink\" && look !== \"failed\" && look !== \"replaced\" && look !== \"was\";\n return (\n <div key={it.id} className={cx(\"group relative flex min-h-5 min-w-0 items-center rounded-[4px]\", shellOf(look, kind, pencilColor), String(sel) === it.id && \"ring-2 ring-primary\")}>\n <button\n type=\"button\"\n data-block={it.id}\n title={it.title}\n onClick={(e) => {\n select(it.id);\n onOpen?.(it, e.currentTarget);\n }}\n className=\"flex min-h-5 min-w-0 flex-1 items-center gap-1 rounded-[4px] px-1.5 text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name={it.locked || kind.locked ? \"lock\" : (it.icon ?? kind.icon ?? \"calendar\")} size={11} className=\"shrink-0\" />\n <span className=\"min-w-0 truncate\">{it.title}</span>\n </button>\n {waiting && onAction && !readOnly && String(sel) === it.id ? (\n <span className=\"flex shrink-0 gap-0.5 pr-0.5\">\n <button type=\"button\" disabled={busy.has(it.id)} onClick={() => runAction(it, \"keep\")} aria-label={words.keep} title={words.keep} className=\"inline-flex size-5 items-center justify-center rounded-[4px] bg-primary text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <Icon name=\"check\" size={10} />\n </button>\n <button type=\"button\" disabled={busy.has(it.id)} onClick={() => runAction(it, \"dismiss\")} aria-label={words.dismiss} title={words.dismiss} className=\"inline-flex size-5 items-center justify-center rounded-[4px] border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <Icon name=\"x\" size={10} />\n </button>\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n ))}\n </div>\n </div>\n\n <div className={cx(\"relative grid grid-cols-[3rem_minmax(0,1fr)]\", WIDE_COLS[list.length])} style={{ height: bodyHeight }}>\n {/* the hour gutter: the working day's ends in weight */}\n <div aria-hidden=\"true\" className=\"relative\">\n {hours.map((h) =>\n (ws != null && Math.abs(h * 60 - ws) < 30) || (we != null && Math.abs(h * 60 - we) < 30) ? null : (\n <span key={h} data-nums className=\"absolute right-1.5 -translate-y-1/2 font-mono text-2xs text-muted-foreground\" style={{ top: top(h * 60) }}>\n {formatTime(h * 60)}\n </span>\n ),\n )}\n {[ws, we].map((m, i) =>\n m != null && m > lo && m < hi ? (\n <span key={i} data-nums className=\"absolute right-1.5 -translate-y-1/2 font-mono text-2xs font-semibold text-foreground\" style={{ top: top(m) }}>\n {formatTime(m)}\n </span>\n ) : null,\n )}\n </div>\n\n {list.map((d, di) => {\n const slot = byDay.get(d) ?? { timed: [], was: [], lanes: new Map() };\n return (\n <div\n key={d}\n ref={(el) => {\n if (el) cols.current.set(d, el);\n else cols.current.delete(d);\n }}\n data-day={d}\n onPointerDown={(e) => onColumnPointerDown(e, d)}\n onDragOver={onDragOver}\n onDragLeave={onDragLeave}\n onDrop={onDropHere}\n className={cx(\"relative min-w-0 touch-pan-y border-l border-border\", d !== day && \"hidden @min-[36rem]:block\", isToday(d) && \"bg-primary/5\", onCompose && !readOnly && \"cursor-cell\")}\n >\n {/* ruled hours, and the hatched time outside the working day */}\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {ws != null && ws > lo ? <div className={cx(\"absolute inset-x-0 top-0 border-b border-border\", OFF_HATCH)} style={{ height: top(ws) }} /> : null}\n {we != null && we < hi ? <div className={cx(\"absolute inset-x-0 bottom-0 border-t border-border\", OFF_HATCH)} style={{ top: top(we) }} /> : null}\n {hours.map((h) => (\n <div key={h} className=\"absolute inset-x-0 border-t border-border/60\" style={{ top: top(h * 60) }} />\n ))}\n </div>\n\n {loading ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-[3px] top-0 space-y-6 pt-10\">\n {[0, 1, 2].map((n) => (\n <Skeleton key={n} shape=\"rect\" className={cx(\"w-full\", n === 1 ? \"h-16\" : \"h-10\")} />\n ))}\n </div>\n ) : null}\n\n {/* where it used to be: a dotted marker under everything */}\n {slot.was.map((it) => {\n const h = Math.max(14, (it.e - it.s) * ppm - 2);\n return (\n <div key={it.id} aria-hidden=\"true\" className={cx(\"pointer-events-none absolute inset-x-[3px] z-[1] rounded-[4px]\", shellOf(\"was\", {}, pencilColor))} style={{ top: top(it.s) + 1, height: h }}>\n <Face item={it} h={h} kind={{}} look=\"was\" label={it.label ?? words.was} fmt={formatTime} words={words} pencilClassName={pencilClassName} />\n </div>\n );\n })}\n\n {!loading\n ? slot.timed.map((it) => {\n const lane = slot.lanes.get(it.id) ?? { lane: 0, lanes: 1 };\n const h = Math.max(14, (it.e - it.s) * ppm - 2);\n const look = looks.get(it.id);\n const kind = kindOf(it);\n const clashed = clashes.get(it.id);\n const waiting = it.pencil && (look === \"pencil\" || look === \"unsure\");\n const dragging = preview?.id === it.id;\n const movable = editable && it.draggable !== false && !it.locked && !kind.locked && look !== \"failed\" && look !== \"replaced\";\n const selected = String(sel) === it.id;\n const delay = fresh.indexOf(it.id);\n const left = `calc(${(lane.lane / lane.lanes) * 100}% + 3px)`;\n const width = `calc(${100 / lane.lanes}% - 6px)`;\n const tall = h >= 54;\n const showActions = Boolean(onAction) && !readOnly && it.pencil && look !== \"failed\" && look !== \"replaced\" && look !== \"was\";\n const dayName = weekday(it.date, true);\n return (\n <div\n key={it.id}\n data-id={it.id}\n data-look={look}\n onPointerDown={(e) => onBlockPointerDown(e, it)}\n className={cx(\n \"group absolute z-10 overflow-hidden rounded-[4px] outline-none motion-safe:transition-[background-color,border-color,color,box-shadow,opacity] motion-safe:duration-700\",\n shellOf(look, kind, pencilColor),\n it.pencil && \"z-20\",\n movable ? \"cursor-grab touch-none\" : \"cursor-pointer\",\n dragging && \"opacity-30\",\n busy.has(it.id) && \"opacity-70\",\n selected && \"z-30 shadow-md ring-2 ring-primary\",\n )}\n style={{ top: top(it.s) + 1, height: h, left, width, transitionDelay: delay > 0 ? `${delay * 160}ms` : undefined }}\n >\n <button\n type=\"button\"\n data-block={it.id}\n title={`${it.title}, ${span(it.s, it.e, formatTime)}`}\n aria-label={`${it.title}, ${dayName} ${span(it.s, it.e, formatTime)}${waiting ? `, ${words.pencilled}` : \"\"}${it.locked || kind.locked ? `, ${words.locked}` : \"\"}`}\n aria-pressed={it.pencil ? selected : undefined}\n onKeyDown={(e) => onBlockKeyDown(e, it)}\n onClick={(e) => {\n if (suppressClick.current) return;\n select(it.id);\n onOpen?.(it, e.currentTarget);\n }}\n className=\"absolute inset-0 block rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Face item={it} h={h} kind={kind} look={look} label={labelOf(it, look, clashed)} fmt={formatTime} clashed={Boolean(clashed)} words={words} pencilClassName={pencilClassName} />\n </button>\n {showActions && (waiting || it.state === \"kept\") ? (\n <MiniActions item={it} look={look} onAction={runAction} busy={busy.has(it.id)} words={words} tall={tall && look !== \"ink\"} show={selected} />\n ) : null}\n {movable && h >= 22 ? (\n <>\n <span data-edge=\"start\" aria-hidden=\"true\" className=\"absolute inset-x-0 top-0 h-1.5 cursor-ns-resize\" />\n <span data-edge=\"end\" aria-hidden=\"true\" className=\"absolute inset-x-0 bottom-0 flex h-2 cursor-ns-resize items-end justify-center pb-0.5\">\n <span className=\"h-0.5 w-6 rounded-full bg-current opacity-0 transition-opacity group-hover:opacity-40\" />\n </span>\n </>\n ) : null}\n </div>\n );\n })\n : null}\n\n {/* where two things overlap: hatched across the day, before anything is kept */}\n {slot.timed.flatMap((it) =>\n (clashes.get(it.id) ?? []).map((c) => (\n <div\n key={`${it.id}-${c.withId}`}\n data-clash={it.id}\n aria-hidden=\"true\"\n className={cx(\"pointer-events-none absolute inset-x-0 z-[25] border-y border-warning opacity-60\", CLASH_HATCH)}\n style={{ top: top(c.s), height: Math.max(2, (c.e - c.s) * ppm) }}\n />\n )),\n )}\n\n {/* the tag: the words the proposal came from, hanging off its block */}\n {slot.timed.map((it) => {\n if (!it.pencil || !it.tag) return null;\n if (!(tags === \"all\" || (tags === \"selected\" && String(sel) === it.id))) return null;\n const lane = slot.lanes.get(it.id) ?? { lane: 0, lanes: 1 };\n const tg = typeof it.tag === \"string\" ? { quote: it.tag } : it.tag;\n const blockTop = top(it.s) + 1;\n const blockBottom = top(it.e) - 1;\n const place = tagPlacement({ dayIndex: di, dayCount: list.length, bottom: blockBottom, top: blockTop, bodyHeight });\n const style = {};\n if (place.above) style.bottom = bodyHeight - blockTop + 6;\n else style.top = blockBottom + 6;\n if (place.side === \"left\") style.left = `calc(${(lane.lane / lane.lanes) * 100}% + 3px)`;\n else style.right = `calc(${(1 - (lane.lane + 1) / lane.lanes) * 100}% + 3px)`;\n return (\n <div key={`tag-${it.id}`} role=\"note\" className=\"pointer-events-none absolute z-[45] w-60 max-w-[calc(100cqw-4rem)] rounded-md bg-primary px-2 py-1.5 text-xs leading-snug text-primary-foreground shadow-md\" style={style}>\n <span aria-hidden=\"true\" className={cx(\"absolute size-2 rotate-45 bg-primary\", place.above ? \"-bottom-1\" : \"-top-1\", place.side === \"left\" ? \"left-4\" : \"right-4\")} />\n {tg.quote ? <q className=\"font-medium italic\">{tg.quote}</q> : null}\n {tg.source ? <span className=\"mt-0.5 block text-2xs opacity-90\">{tg.source}</span> : null}\n </div>\n );\n })}\n\n {/* the agent still pencilling this day in */}\n {(drafting ?? [])\n .filter((g) => g.date === d)\n .map((g, i) => (\n <div\n key={i}\n aria-hidden=\"true\"\n className={cx(\"pointer-events-none absolute inset-x-[3px] z-10 flex items-center justify-center rounded-[4px] border-[1.5px] border-dashed border-border text-muted-foreground motion-safe:animate-pulse\", HATCH, pencilClassName, \"text-xs italic\")}\n style={{ top: top(toMin(g.start)) + 1, height: Math.max(14, (toMin(g.end) - toMin(g.start)) * ppm - 2) }}\n >\n {g.label ?? words.drafting}\n </div>\n ))}\n\n {/* the block being made, while its editor is open */}\n {draft && draft.date === d ? (\n <div data-draft aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-[3px] z-30 rounded-[4px] shadow-lg ring-2 ring-primary\">\n {(() => {\n const s = toMin(draft.start);\n const e = toMin(draft.end);\n const h = Math.max(14, (e - s) * ppm - 2);\n return (\n <div className={cx(\"absolute inset-x-0 rounded-[4px]\", shellOf(\"ink\", { solid: true }, pencilColor))} style={{ top: top(s) + 1, height: h }}>\n <Face item={{ title: draft.title || words.newBlock, s, e }} h={h} kind={{ solid: true }} look=\"ink\" fmt={formatTime} words={words} />\n </div>\n );\n })()}\n </div>\n ) : null}\n\n {/* the block in the hand: lifted, snapping */}\n {preview && preview.date === d\n ? (() => {\n const src = preview.id ? items.find((x) => x.id === preview.id) : null;\n const h = Math.max(14, (preview.end - preview.start) * ppm - 2);\n const b = src ? { ...src, s: preview.start, e: preview.end } : { title: words.newBlock, s: preview.start, e: preview.end };\n return floating(b, h, src ? looks.get(src.id) : \"ink\", src ? kindOf(src) : { solid: true });\n })()\n : null}\n\n {/* a drop from the list, before it lands */}\n {dropHint && dropHint.date === d ? floating({ title: dropHint.title, s: dropHint.start, e: dropHint.end }, Math.max(14, (dropHint.end - dropHint.start) * ppm - 2), \"pencil\") : null}\n\n {/* dropped and waiting for its row */}\n {pending\n .filter((p) => p.date === d)\n .map((p) => (\n <div key={p.key}>{floating({ title: p.title, s: p.start, e: p.end }, Math.max(14, (p.end - p.start) * ppm - 2), \"ink\", { solid: true })}</div>\n ))}\n\n {/* why this time cannot be used */}\n {note && note.date === d ? (\n <div role=\"note\" className=\"absolute inset-x-1 z-50 rounded-lg bg-foreground px-3 py-2.5 text-sm leading-snug text-background shadow-lg\" style={{ top: Math.min(bodyHeight - 110, Math.max(4, top(note.minute) - 8)) }}>\n {note.node}\n </div>\n ) : null}\n\n {/* now */}\n {isToday(d) && nowMin != null && nowMin >= lo && nowMin <= hi ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 z-[35]\" style={{ top: top(nowMin) }}>\n <div className=\"relative h-0.5 bg-primary\">\n <span className=\"absolute -left-[5px] -top-1 size-2.5 rounded-full bg-primary\" />\n </div>\n </div>\n ) : null}\n </div>\n );\n })}\n\n {!loading && emptyText !== false && !items.length ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center p-6 text-center text-sm text-muted-foreground\">\n <p className=\"m-0 max-w-xs\">{emptyText ?? words.empty}</p>\n </div>\n ) : null}\n </div>\n </div>\n\n <p aria-live=\"polite\" className=\"sr-only\">\n {selectedItem ? `${selectedItem.title}, ${weekday(selectedItem.date, true)} ${span(selectedItem.s ?? 0, selectedItem.e ?? 0, formatTime)}${selectedItem.s == null ? \"\" : `, ${durationWords(selectedItem.e - selectedItem.s)}`}` : \"\"}\n </p>\n </div>\n );\n}\n\n// ---- the goals strip ------------------------------------------------------\n\nconst GOAL_LOOK = {\n ink: \"border-border bg-card\",\n pencil: cx(\"border-dashed border-muted-foreground bg-card\", HATCH),\n done: \"border-success bg-success/10\",\n empty: \"border-dashed border-border bg-transparent text-muted-foreground\",\n};\n\n/**\n * A strip of goals to sit above the days, and a summary cell at its end (a\n * total against a target, red once it is over). Goals are pencil until kept,\n * like the blocks beside them.\n * items [{ id, title, number?, state: \"ink\" | \"pencil\" | \"done\" | \"empty\", meta?, mark? }]\n * summary { label, value, of?, unit?, over?, note? } value / of are numbers or strings\n * empty what to show with no goals\n */\nexport function GoalStrip({ items = [], summary, selectedId, onSelect, empty, label = \"Goals\", className }) {\n if (!items.length && !summary && !empty) return null;\n return (\n <div role=\"group\" aria-label={label} className={cx(\"grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-2\", className)}>\n {items.length ? (\n items.map((g, i) => {\n const state = GOAL_LOOK[g.state] ? g.state : \"ink\";\n const on = selectedId != null && String(selectedId) === String(g.id);\n const body = (\n <>\n <span className=\"flex min-w-0 items-start gap-2\">\n <span data-nums className={cx(\"inline-flex size-5 shrink-0 items-center justify-center rounded-full border text-2xs font-semibold\", state === \"pencil\" || state === \"empty\" ? \"border-dashed border-muted-foreground\" : \"border-border\", state === \"done\" && \"border-success text-success\")}>\n {state === \"done\" ? <Icon name=\"check\" size={10} /> : (g.number ?? i + 1)}\n </span>\n <span className={cx(\"min-w-0 text-xs font-medium leading-snug\", state === \"done\" ? \"text-muted-foreground\" : \"\")}>{cut(g.title)}</span>\n </span>\n {g.meta || g.mark ? (\n <span className=\"mt-1 flex min-w-0 items-center gap-1 pl-7 text-2xs text-muted-foreground\">\n {state === \"pencil\" ? <Icon name=\"pencil\" size={10} className=\"shrink-0\" /> : null}\n <span className=\"truncate\">{g.mark ?? g.meta}</span>\n </span>\n ) : null}\n </>\n );\n const cls = cx(\"block min-w-0 rounded-md border px-2 py-1.5 text-left motion-safe:transition-[background-color,border-color] motion-safe:duration-700\", GOAL_LOOK[state], on && \"ring-2 ring-primary\");\n return onSelect ? (\n <button key={g.id} type=\"button\" aria-pressed={on} onClick={() => onSelect(g.id)} className={cx(cls, \"outline-none focus-visible:ring-2 focus-visible:ring-ring\")}>\n {body}\n </button>\n ) : (\n <div key={g.id} className={cls}>\n {body}\n </div>\n );\n })\n ) : empty ? (\n <div className={cx(\"min-w-0 rounded-md border px-2 py-1.5 text-xs\", GOAL_LOOK.empty)} style={{ gridColumn: \"1 / -1\" }}>\n {empty}\n </div>\n ) : null}\n {summary ? (\n <div className=\"min-w-0 rounded-md border border-border bg-card px-2 py-1.5\" data-summary>\n <span className=\"block text-2xs font-medium uppercase tracking-wide text-muted-foreground\">{summary.label}</span>\n <span data-nums className={cx(\"block text-sm font-semibold\", summary.over ? \"text-destructive\" : \"\")}>\n {summary.value}\n {summary.of != null ? <span className=\"font-normal text-muted-foreground\">{` of ${summary.of}`}</span> : null}\n {summary.unit ? ` ${summary.unit}` : \"\"}\n {summary.over ? <span className=\"ml-1 text-2xs font-semibold\">over</span> : null}\n </span>\n {summary.note ? <span className=\"block truncate text-2xs text-muted-foreground\">{summary.note}</span> : null}\n </div>\n ) : null}\n </div>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/time-grid/grid-model.js",
|
|
26
|
+
"type": "registry:component",
|
|
27
|
+
"target": "@components/makersclaw/blocks/time-grid/grid-model.js",
|
|
28
|
+
"content": "// The time grid's arithmetic: times as minutes, days as 'YYYY-MM-DD', how\n// overlapping blocks share a day's width, which pencilled blocks clash with\n// what is already there, what \"Keep all\" will do (in words, before it does\n// it), and where a dragged block lands. Pure: no React, no DOM, no clock.\n// The component (time-grid.jsx) draws what this returns;\n// test/time-grid.test.mjs pins it.\n\nexport const WEEKDAYS_EN = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\n\n/** '09:30', '9:30', '09:30:00' or '2026-09-24T09:30' → 570; a number passes (rounded); else null. */\nexport function toMin(v) {\n if (typeof v === \"number\") return Number.isFinite(v) ? Math.round(v) : null;\n const m = /(?:^|T|\\s)(\\d{1,2}):(\\d{2})/.exec(String(v ?? \"\"));\n if (!m) return null;\n const h = Number(m[1]);\n const mi = Number(m[2]);\n return h > 24 || mi > 59 ? null : h * 60 + mi;\n}\n\n/** 570 → '09:30' (what a save sends). */\nexport const toHHMM = (min) => `${String(Math.floor(min / 60)).padStart(2, \"0\")}:${String(min % 60).padStart(2, \"0\")}`;\n\n/** 570 → '9:30', 1020 → '17:00': the grid's printed clock (24-hour). */\nexport const clock = (min) => `${Math.floor(min / 60) % 24}:${String(min % 60).padStart(2, \"0\")}`;\n\n/** '9:00–11:30'. */\nexport const span = (s, e, fmt = clock) => `${fmt(s)}–${fmt(e)}`;\n\n/** 90 → '1 h 30 min', 45 → '45 min', 120 → '2 h'. */\nexport function durationWords(min) {\n const m = Math.max(0, Math.round(min));\n const h = Math.floor(m / 60);\n const r = m % 60;\n if (!h) return `${r} min`;\n return r ? `${h} h ${r} min` : `${h} h`;\n}\n\n/** 320 → '5.3' (hours, one decimal, no trailing .0): for a day's total beside its header. */\nexport function hoursOf(min, digits = 1) {\n const f = 10 ** digits;\n const v = Math.round((min / 60) * f) / f;\n return String(v);\n}\n\n/** 0 = Sunday … 6 = Saturday, from 'YYYY-MM-DD'; 0 for anything else. */\nexport function weekdayIndex(iso) {\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(String(iso ?? \"\"));\n return m ? new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).getUTCDay() : 0;\n}\n\n/** The day of the month as a number ('2026-09-24' → 24). */\nexport const dateNum = (iso) => Number(String(iso ?? \"\").slice(8, 10)) || 0;\n\nexport const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\nexport const snapRound = (m, snap) => Math.round(m / snap) * snap;\nexport const snapFloor = (m, snap) => Math.floor(m / snap) * snap;\n\n// ---- reading what the app hands in -------------------------------------\n\n/**\n * An item as the grid draws it: minutes for its times, a day it can be\n * placed on, and nothing else invented. An item with no day in `days` is\n * dropped; a timed item with no length gets one `snap` long; a timed item\n * wholly outside [lo, hi] is dropped, and one that crosses an edge is cut\n * to it (`cutStart` / `cutEnd` say so).\n */\nexport function readItem(raw, { days, lo, hi, snap = 15, pencil = false }) {\n if (!raw || raw.id == null) return null;\n if (!days.includes(raw.date)) return null;\n const base = { ...raw, id: String(raw.id), pencil };\n if (raw.allDay) return { ...base, allDay: true, s: null, e: null };\n let s = toMin(raw.start);\n let e = toMin(raw.end);\n if (s == null) return { ...base, allDay: true, s: null, e: null };\n if (e == null || e <= s) e = s + snap;\n if (e <= lo || s >= hi) return null;\n return { ...base, allDay: false, s: Math.max(s, lo), e: Math.min(e, hi), cutStart: s < lo, cutEnd: e > hi };\n}\n\n/** Overlapping blocks share a day's width, calendar-style: lane and lane count per id. */\nexport function gridLanes(items) {\n const out = new Map();\n const sorted = [...items].sort((a, b) => a.s - b.s || b.e - a.e);\n let group = [];\n let groupEnd = -1;\n const flush = () => {\n const ends = [];\n for (const it of group) {\n let lane = ends.findIndex((end) => end <= it.s);\n if (lane < 0) {\n lane = ends.length;\n ends.push(it.e);\n } else ends[lane] = it.e;\n out.set(it.id, { lane, lanes: 1 });\n }\n for (const it of group) out.get(it.id).lanes = ends.length;\n group = [];\n groupEnd = -1;\n };\n for (const it of sorted) {\n if (group.length && it.s >= groupEnd) flush();\n group.push(it);\n groupEnd = Math.max(groupEnd, it.e);\n }\n if (group.length) flush();\n return out;\n}\n\n// ---- pencil and ink ------------------------------------------------------\n\n/** What a proposal can be. `pencil` is the default. */\nexport const PENCIL_STATES = [\"pencil\", \"kept\", \"failed\", \"replaced\", \"was\"];\n\n/** The look an item is drawn in: ink (an event, or a kept proposal), or one of the pencil looks. */\nexport function lookOf(item) {\n if (!item.pencil) return \"ink\";\n const st = PENCIL_STATES.includes(item.state) ? item.state : \"pencil\";\n if (st === \"kept\") return \"ink\";\n if (st === \"pencil\" && item.unsure) return \"unsure\";\n return st;\n}\n\n/** Is this proposal still waiting for a person? (Failed and replaced ones are not asked again; \"was\" is only a marker.) */\nexport const isWaiting = (item) => item.pencil && lookOf(item) !== \"ink\" && lookOf(item) !== \"failed\" && lookOf(item) !== \"replaced\" && lookOf(item) !== \"was\";\n\n/** The proposals that are waiting, in the order a person reads a week: by day, then by time. */\nexport function waitingOrder(items, days) {\n return items\n .filter(isWaiting)\n .sort((a, b) => days.indexOf(a.date) - days.indexOf(b.date) || (a.s ?? -1) - (b.s ?? -1) || (a.id < b.id ? -1 : 1));\n}\n\n/** J (1) and K (-1): the id of the next waiting proposal; none selected → the first (or the last going back). */\nexport function walkProposal(items, days, currentId, dir = 1) {\n const list = waitingOrder(items, days);\n if (!list.length) return null;\n const at = list.findIndex((i) => i.id === String(currentId));\n if (at < 0) return (dir >= 0 ? list[0] : list[list.length - 1]).id;\n return list[(at + dir + list.length) % list.length].id;\n}\n\n/**\n * The ids whose look changed from a pencil look to ink between two renders:\n * the ones that were just kept (or committed). In the order they sit in\n * the week, so they can ink in one by one. `before` is a Map id → look.\n */\nexport function newlyInked(before, items, days) {\n const out = items.filter((i) => before.get(i.id) && before.get(i.id) !== \"ink\" && lookOf(i) === \"ink\");\n return out\n .sort((a, b) => days.indexOf(a.date) - days.indexOf(b.date) || (a.s ?? -1) - (b.s ?? -1))\n .map((i) => i.id);\n}\n\n// ---- clashes -------------------------------------------------------------\n\n/**\n * Where each waiting proposal overlaps something that is already there\n * (ink events, and kept proposals; never another waiting one unless\n * `among` is true). Returns Map id → [{ withId, title, s, e, at }] where s..e\n * is the overlap, in minutes and `at` is when the other one starts. All-day items never clash.\n */\nexport function clashesOf(items, { among = false } = {}) {\n const out = new Map();\n const timed = items.filter((i) => !i.allDay && i.s != null);\n for (const p of timed) {\n if (!isWaiting(p)) continue;\n for (const o of timed) {\n if (o.id === p.id || o.date !== p.date) continue;\n const solid = lookOf(o) === \"ink\";\n if (!solid && !(among && isWaiting(o))) continue;\n const s = Math.max(p.s, o.s);\n const e = Math.min(p.e, o.e);\n if (e <= s) continue;\n if (!out.has(p.id)) out.set(p.id, []);\n out.get(p.id).push({ withId: o.id, title: o.title ?? \"\", s, e, at: o.s });\n }\n }\n return out;\n}\n\n/** \"Standup 9:30\", or \"Standup 9:30 and Review 11:00\" / \"Standup 9:30 and 2 more\". */\nexport function namesOf(list, fmt = clock, max = 2) {\n const names = list.map((c) => {\n const t = c.at ?? c.s;\n return t != null ? `${c.title} ${fmt(t)}`.trim() : c.title;\n });\n if (names.length <= max) return names.length > 1 ? `${names.slice(0, -1).join(\", \")} and ${names[names.length - 1]}` : (names[0] ?? \"\");\n return `${names.slice(0, max).join(\", \")} and ${names.length - max} more`;\n}\n\n// ---- Keep all: what it will do, said before it does it -------------------\n\n/**\n * What \"Keep all\" will do. `skip` names what stays in pencil: \"clash\" (it\n * overlaps something) and/or \"unsure\" (the item says it is). The rest are\n * kept. Returns\n * { keep: [ids], leave: [{ id, title, reason }], counts: [{ kind, label, n }], total, words }\n * `words` is the sentence the screen shows next to the button.\n */\nexport function planKeepAll(items, { clashes, kinds = {}, skip = [\"clash\"], fmt = clock } = {}) {\n const waiting = items.filter(isWaiting);\n const clash = clashes ?? clashesOf(items);\n const keep = [];\n const leave = [];\n for (const it of waiting) {\n if (skip.includes(\"clash\") && clash.has(it.id)) leave.push({ id: it.id, title: it.title ?? \"\", reason: \"clash\", with: clash.get(it.id) });\n else if (skip.includes(\"unsure\") && it.unsure) leave.push({ id: it.id, title: it.title ?? \"\", reason: \"unsure\" });\n else keep.push(it);\n }\n const byKind = new Map();\n for (const it of keep) {\n const k = it.kind ?? \"\";\n byKind.set(k, (byKind.get(k) ?? 0) + 1);\n }\n const counts = [...byKind].map(([kind, n]) => ({ kind, n, label: kinds[kind]?.label ?? kind }));\n const total = waiting.length;\n let words;\n if (!total) words = \"Nothing is waiting in pencil.\";\n else {\n const head = keep.length\n ? `Keeps ${keep.length === total ? `all ${total}` : `${keep.length} of ${total}`} in ink${counts.length > 1 || (counts[0] && counts[0].label) ? `: ${counts.map((c) => `${c.n} ${c.label}`.trim()).join(\", \")}` : \"\"}.`\n : \"Nothing can be kept as it is.\";\n const clashed = leave.filter((l) => l.reason === \"clash\");\n const unsure = leave.filter((l) => l.reason === \"unsure\");\n const tails = [];\n if (clashed.length)\n tails.push(\n clashed.length === 1\n ? `Leaves ${clashed[0].title ? `“${clashed[0].title}”` : \"1\"} in pencil: it clashes with ${namesOf(clashed[0].with, fmt, 1)}.`\n : `Leaves ${clashed.length} in pencil, they clash: ${clashed\n .slice(0, 2)\n .map((l) => `${l.title ? `“${l.title}”` : \"one\"} with ${namesOf(l.with, fmt, 1)}`)\n .join(\", \")}${clashed.length > 2 ? ` and ${clashed.length - 2} more` : \"\"}.`,\n );\n if (unsure.length) tails.push(`Leaves ${unsure.length} in pencil: ${unsure.length === 1 ? \"it is\" : \"they are\"} not sure.`);\n words = [head, ...tails].join(\" \");\n }\n return { keep: keep.map((i) => i.id), leave, counts, total, words };\n}\n\n// ---- a day's load, for the number beside its header ----------------------\n\n/** Minutes of timed items on `date`, whose look and kind pass `where`. */\nexport function loadOf(items, date, where = () => true) {\n let sum = 0;\n for (const i of items) if (i.date === date && !i.allDay && i.s != null && where(i)) sum += i.e - i.s;\n return sum;\n}\n\n// ---- where a gesture lands ----------------------------------------------\n\n/**\n * The block as it would land. `g` is the gesture ({ kind: \"create\" | \"move\"\n * | \"start\" | \"end\", anchor?, offset?, s0?, e0?, date? }), `at` the pointer\n * as { date, minute } (minute is fractional), `box` the limits\n * { lo, hi, ws, we, snap }. null when the pointer is nowhere.\n */\nexport function previewOf(g, at, box) {\n if (!at) return null;\n const { lo, hi, ws, we, snap } = box;\n if (g.kind === \"create\") {\n const lower = ws ?? lo;\n const upper = we ?? hi;\n const cur = clamp(at.minute, lower, upper);\n let s;\n let e;\n if (cur >= g.anchor) {\n s = g.anchor;\n e = Math.max(g.anchor + snap, Math.ceil(cur / snap) * snap);\n } else {\n s = snapFloor(cur, snap);\n e = g.anchor + snap;\n }\n return { date: g.date, start: clamp(s, lower, upper - snap), end: clamp(e, lower + snap, upper), mode: \"create\" };\n }\n const dur = g.e0 - g.s0;\n if (g.kind === \"move\") {\n const s = clamp(snapRound(at.minute - g.offset, snap), lo, hi - dur);\n return { id: g.id, date: at.date, start: s, end: s + dur, mode: \"move\" };\n }\n if (g.kind === \"end\") {\n const e = clamp(snapRound(at.minute, snap), g.s0 + snap, hi);\n return { id: g.id, date: g.date, start: g.s0, end: e, mode: \"end\" };\n }\n const s = clamp(snapRound(at.minute, snap), lo, g.e0 - snap);\n return { id: g.id, date: g.date, start: s, end: g.e0, mode: \"start\" };\n}\n\n/** An arrow key on a focused block, as the change it asks for (or null). Shift+↑/↓ changes the length. */\nexport function keyChange(key, shift, item, { days, lo, hi, snap }) {\n const dur = item.e - item.s;\n if (key === \"ArrowUp\" && shift) return { date: item.date, start: item.s, end: Math.max(item.s + snap, item.e - snap), mode: \"end\" };\n if (key === \"ArrowDown\" && shift) return { date: item.date, start: item.s, end: Math.min(hi, item.e + snap), mode: \"end\" };\n if (key === \"ArrowUp\") {\n const s = Math.max(lo, item.s - snap);\n return { date: item.date, start: s, end: s + dur, mode: \"move\" };\n }\n if (key === \"ArrowDown\") {\n const s = Math.min(hi - dur, item.s + snap);\n return { date: item.date, start: s, end: s + dur, mode: \"move\" };\n }\n if (key === \"ArrowLeft\" || key === \"ArrowRight\") {\n const i = days.indexOf(item.date) + (key === \"ArrowLeft\" ? -1 : 1);\n if (i < 0 || i >= days.length) return null;\n return { date: days[i], start: item.s, end: item.e, mode: \"move\" };\n }\n return null;\n}\n\n// ---- the tag and where it hangs ------------------------------------------\n\n/**\n * A tag hangs off a block. Under it, left-aligned, unless the block is in\n * the right half of the days (then right-aligned, so it stays inside the\n * grid) or the room under it runs out (then it hangs above).\n */\nexport function tagPlacement({ dayIndex, dayCount, bottom, top, bodyHeight, tagHeight = 64 }) {\n const side = dayCount > 1 && dayIndex >= Math.ceil(dayCount / 2) ? \"right\" : \"left\";\n const above = bottom + 6 + tagHeight > bodyHeight && top - 6 - tagHeight >= 0;\n return { side, above };\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/blocks/time-grid/README.md",
|
|
32
|
+
"type": "registry:file",
|
|
33
|
+
"target": "@components/makersclaw/blocks/time-grid/README.md",
|
|
34
|
+
"content": "# TimeGrid\n\nDays side by side on an hour grid, with **the agent's proposals in pencil**.\n\nWhat is already there is **ink**: an event in its kind's colour, a lock on the\nones another calendar owns. What the agent suggests is **pencil**: a hatched\ncard on a dashed edge, sitting on its hour, with a **tag** hanging off it that\nquotes the words it came from (\"Thursday at 3pm your time\"). A person keeps a\nblock and it inks in: the same block, its colours fading from pencil to ink.\nKeep a few at once and they ink in one by one, in the order of the week.\n\n- **Keep all says what it will do before it does it:** \"Keeps 7 of 8 in ink:\n 5 Focus, 2 Admin. Leaves \"Offer letter\" in pencil: it clashes with Standup\n 9:30.\"\n- **A clash is hatched where two things overlap**, before anything is kept.\n- **A goals strip** (`GoalStrip`) can sit above the days: goals in pencil until\n kept, and a total against a target that turns red once it is over.\n- Blocks are **events, not tasks**: a date, a start, an end. Drag one to move\n it, pull its edge to resize (snapping to 15 minutes), drag across free\n working hours to make one, drop an item in from a list, arrow keys move a\n focused block, **J** and **K** walk the proposals that are waiting.\n- Hours outside the working day are hatched but still there. Today has a now\n line. Under 36rem of its own width it shows one day at a time under a strip\n of days.\n\n```jsx\nimport { TimeGrid, GoalStrip } from \"../components/blocks/time-grid\"; // a Makersclaw app (\"./blocks/time-grid\" from components/)\nimport { TimeGrid, GoalStrip } from \"@/components/makersclaw/blocks/time-grid\"; // a shadcn project\n```\n\nA client component. No other block is laid with it. The helpers (`planKeepAll`,\n`clashesOf`, `loadOf`…) are plain functions and work on the server.\n\n## Which one: TimeGrid or calendar-view\n\n`calendar-view` is the **tasks** calendar: it is built on `task-model`, takes\ntasks with `start` and `due`, and saves task changes. Use it for work that has\ndates and lives in a task list, for a month, for an agenda, and for a booking\npage's open slots. It does not hold events that are not tasks, and it has no\npencil.\n\n`time-grid` is for **a person reviewing what an agent would put on their\nweek**: plain events plus proposals, kept or dropped one at a time or all at\nonce. It reads and saves nothing; every press goes out through a callback.\n\n## Which hook feeds it\n\nNone. The events are the app's rows (a calendar mirrored into a table, blocks\na person made); the proposals are rows the agent wrote. Keep them in the app's\ntables and pass them in. The block never reads or writes: a press goes out\nthrough `onAction`, `onKeepAll` or `onChange`, the app does the write, and\nhands back the changed rows. **A kept proposal keeps its id.** Either give the\nsame row `state: \"kept\"` (it stays a proposal until the week is committed), or\nmove the row into `events` under the same id: the block is the same element\neither way, so it inks in instead of blinking out and back.\n\n## Props\n\n### The week\n\n- `days` — the days shown, in order, `[\"2026-09-21\", …]` (one to seven). An\n item whose `date` is not one of them is not drawn.\n- `today` — \"YYYY-MM-DD\", marks the day's header and column.\n- `now` — \"HH:MM\" (or minutes) for the now line; leave it out and it follows\n the viewer's clock after mount.\n- `startHour` (5) · `endHour` (21) — the hours on the grid. Time outside is\n not drawn; an item that crosses an edge is cut to it.\n- `workStart` · `workEnd` — \"08:30\", \"17:30\": the working day. Hours outside\n it are hatched (still there), and a new block can only be made inside it.\n- `hourHeight` (40) — pixels per hour. `snap` (15) — minutes a drag snaps to.\n- `height` — the hour grid's scrolling height, `\"34rem\"` (never `vh`), or\n `\"fill\"`: the grid fills what is left of a bounded parent (a flex column\n with a height: an `AppLayout` content in `contentMode=\"fill\"`, a bounded\n `Card`) and its hours scroll inside it, so no app measures the box. Leave\n it out and the grid is as tall as its hours. `scrollTo` (\"08:00\") — where\n it opens; default the working day's start.\n- `locale` (\"en-GB\") — weekday names. `formatTime(minutes)` — how the grid\n prints a time (default 24-hour, `9:30`).\n- `corner` — a small label in the top-left (a time zone, \"BST\").\n- `dayNotes` — `{ \"2026-09-21\": { text: \"5.3 of 9 h\", tone: \"danger\" } }`, a\n number beside a day's header. `tone`: `neutral` · `primary` · `info` ·\n `success` · `warning` · `danger`. `loadOf` and `hoursOf` add the minutes up.\n- `renderDayHeader(day, { isToday, index, compact, selected })` — your own\n header.\n- `above` — a node drawn above the days: `<GoalStrip …/>`, or your own.\n\n### What is drawn\n\n- `events` — ink. `[{ id, date, start, end, title, kind?, locked?, done?,\n allDay?, caption?, sub?, icon?, draggable? }]`. `start`/`end` are \"09:30\".\n No `start` (or `allDay`) puts it in the all-day row. `locked` shows a lock\n and the block cannot be moved (`explainBlock` says why). `caption` follows\n the time (\"Maya's calendar\"); `sub` is a line under the title when the block\n is tall; `icon` is a kit icon.\n- `proposals` — pencil. The same fields, and:\n - `state` — `\"pencil\"` (default) · `\"kept\"` (drawn in ink, still a proposal,\n with **Back to pencil**) · `\"failed\"` (\"Didn't add\", solid red edge) ·\n `\"replaced\"` (struck, faint) · `\"was\"` (a dotted marker where it used to\n be, under everything).\n - `unsure: true` — a dotted edge and \"Unsure\".\n - `tag` — `{ quote, source }` (or a string, the quote): hangs off the block\n when it is selected (`tags=\"all\"` shows every one). Under the block from\n the left in the first half of the days, from the right in the second\n half, above it when there is no room below.\n - `label` — the small line over the title; default the kind's label,\n \"Unsure\", \"Didn't add\"… and for a clash \"Clash · Standup 9:30\".\n - `note` — a line at the foot of a tall block (\"pencilled in Mon 06:00\").\n- `kinds` — `{ focus: { label: \"Focus\", tone: \"primary\", solid: true },\n meeting: { label: \"Meeting\", tone: \"neutral\", locked: true }, personal: {\n label: \"Personal\", hue: 5 } }`. `tone` is `neutral` · `primary` · `info` ·\n `success` · `warning` · `danger`; `hue` is `1`–`5` (a categorical colour).\n Ink is the kind's colour on its edge and a faint wash; `solid` fills it with\n the accent. `locked` draws a lock on every block of that kind. `icon` is\n a kit icon.\n- `pencilColor` — `\"neutral\"` (default; pencil has no colour of its own) or\n `\"kind\"` (the dashed edge takes the kind's colour).\n- `pencilClassName` — a class for pencil titles, to set them in the app's\n display face. Layout and type only, never colour.\n- `drafting` — `[{ date, start, end, label? }]`: the agent still pencilling\n this in, as a pulsing outline.\n- `draft` — `{ date, start, end, title }`: the block being made while its\n editor is open.\n- `tags` — `\"selected\"` (default) · `\"all\"` · `\"none\"`.\n- `clashAmong` (false) — also hatch two waiting proposals that overlap each\n other (by default only a proposal against ink or a kept one).\n\n### What a press does\n\n- `selectedId`, `defaultSelectedId`, `onSelect(id | null)` — the selected\n block. Pass `selectedId` to control it (from the address, say); leave it out\n and the block keeps it. A press on empty hours clears it; <kbd>Esc</kbd> too.\n- `onOpen(item, element)` — a block was clicked (or Enter on it): open your\n record. The block is selected as well.\n- `onAction(item, \"keep\" | \"dismiss\" | \"unkeep\")` — `async`, returns `{ ok:\n true }` or `{ ok: false, error? }`. Keep and Dismiss are drawn on every\n waiting proposal (in the block when it is tall, on hover or selection when it\n is short; the all-day row shows them on the selected one), **Back to\n pencil** on a kept one. A failure puts a notice over the grid with Try\n again. **Without `onAction` no buttons are drawn.** The buttons are small\n because a block is: put the same two in your side panel, full size.\n- `keepAll` — `true`, or `{ label?, skip?, primary? }`. Draws the bar over\n the grid: the sentence from `planKeepAll` and a **Keep all (7)** button.\n `skip` is what stays in pencil: `[\"clash\"]` (default), `[\"clash\", \"unsure\"]`,\n or `[]`.\n- `onKeepAll(plan)` — `async`, same return. `plan.keep` is the ids to keep,\n `plan.leave` the ones left and why, `plan.words` the sentence.\n- `onChange(item, change, { proposal })` — `async`, same return. A block was\n moved or resized: `change` is `{ date, start, end, mode: \"move\" | \"start\" |\n \"end\", from: { date, start, end } }`, times as \"09:30\". It shows at once; a\n failure puts it back and says so. Pencil blocks can be moved too\n (`proposal: true`). A locked block, a failed or replaced proposal, and an\n item with `draggable: false` stay put. Without `onChange` nothing moves.\n- `onCompose({ date, start, end })` — a press, or a drag across free working\n hours, on an empty day. Open your editor (and pass `draft` meanwhile).\n- `onDrop(item, { date, start, end })` — an item from a list was dropped on\n an hour. Make the list item draggable with `{...timeGridDragProps({ id,\n title, minutes: 45 })}`. Same return as `onChange`.\n- `explainBlock(item)` — a node: why a locked block cannot be moved (\"From\n Google Calendar · change it there\"), shown once where it is when someone\n tries. `explainOffHours({ date, minute, before })` — the same for a press\n outside the working day.\n- `readOnly` — to read: no buttons, no drags, no compose, no Keep all.\n- `keys` (true) — <kbd>J</kbd> and <kbd>K</kbd> walk the proposals that are\n waiting (by day, then by time) and move to their day on a narrow container.\n The region takes focus with <kbd>Tab</kbd>. They are left alone while someone\n is typing in a field.\n\n### States and words\n\n- `status` — `ready` (default) · `loading` (grey blocks on the grid) · `failed`\n (a notice with `error`, and Try again if you pass `onRetry`).\n- `emptyText` — what to say over a grid with nothing on it; `false` says\n nothing.\n- `words` — the block's few words, to be changed: `allDay`, `keep`, `dismiss`,\n `unkeep`, `keepAll`, `pencilled`, `unsure`, `failed`, `replaced`, `was`,\n `clash`, `locked`, `drafting`, `newBlock`, `empty`, `retry`, `back`.\n- `label` (\"Week\") — the region's accessible name. `className` — layout:\n width, margin. Never colour.\n\n### GoalStrip\n\n```jsx\n<GoalStrip\n items={[{ id: \"g1\", title: \"Pricing page live\", state: \"pencil\", mark: \"pencilled in\" }, …]}\n summary={{ label: \"Focus\", value: 11, of: 15, unit: \"h\", over: false, note: \"all placed\" }}\n onSelect={(id) => …}\n/>\n```\n\n`items`: `{ id, title, number?, state: \"ink\" | \"pencil\" | \"done\" | \"empty\",\nmeta?, mark? }`. A goal in pencil is hatched with a dashed edge and fades to\nink when its `state` changes. `summary`: a total against a target; `over: true`\nturns it red and says \"over\". `empty`: a node for no goals at all.\n`selectedId` / `onSelect(id)` make the goals buttons.\n\n### Helpers\n\nPlain functions (`import` them from the same folder):\n\n- `planKeepAll(items, { clashes?, kinds?, skip?, fmt? })` — `{ keep, leave,\n counts, total, words }`: what Keep all will do. The block calls it itself;\n call it too when a button of yours (a \"Commit the week\") should say the same.\n- `clashesOf(items, { among? })` — Map id → `[{ withId, title, s, e }]`.\n `items` are what `readItem` returns.\n- `readItem(raw, { days, lo, hi, snap, pencil })` — an item as the grid draws\n it (minutes for `s`/`e`).\n- `loadOf(items, date, where?)` — minutes of timed items on a day;\n `hoursOf(minutes)` → \"5.3\"; `durationWords(minutes)` → \"1 h 30 min\".\n- `lookOf(item)`, `isWaiting(item)`, `waitingOrder`, `walkProposal`,\n `newlyInked`, `previewOf`, `tagPlacement`, `gridLanes`, `namesOf`, `toMin`,\n `toHHMM`, `clock`.\n\n## Example\n\nA week the agent drafted on Monday, kept one block at a time or all at once:\n\n```jsx\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport { updateRow, deleteRow } from \"../lib/actions\";\nimport { GoalStrip, TimeGrid } from \"../components/blocks/time-grid\";\n\nconst KINDS = {\n focus: { label: \"Focus\", tone: \"primary\", solid: true },\n meeting: { label: \"Meeting\", tone: \"neutral\", locked: true },\n admin: { label: \"Admin\", tone: \"success\" },\n};\n\nexport default function Week({ days }) {\n const blocks = useRows({ source: \"blocks\", filter: { week: days[0] } });\n const goals = useRows({ source: \"goals\", filter: { week: days[0] } });\n const rows = blocks.rows ?? [];\n const toItem = (b) => ({ id: b.id, date: b.day, start: b.starts, end: b.ends, title: b.title, kind: b.kind, note: b.reason });\n const keep = (ids) => Promise.all(ids.map((id) => updateRow({ source: \"blocks\", id, patch: { state: \"kept\" } })));\n\n return (\n <TimeGrid\n days={days}\n today={days[3]}\n workStart=\"08:30\"\n workEnd=\"17:30\"\n height=\"36rem\"\n kinds={KINDS}\n status={blocks.loading ? \"loading\" : blocks.error ? \"failed\" : \"ready\"}\n error={blocks.error}\n events={rows.filter((b) => b.state === \"committed\" || b.kind === \"meeting\").map(toItem)}\n proposals={rows.filter((b) => b.state === \"pencil\" || b.state === \"kept\").map((b) => ({ ...toItem(b), state: b.state }))}\n keepAll\n onKeepAll={async (plan) => (await keep(plan.keep), { ok: true })}\n onAction={async (item, action) => {\n if (action === \"dismiss\") return deleteRow({ source: \"blocks\", id: item.id });\n return updateRow({ source: \"blocks\", id: item.id, patch: { state: action === \"keep\" ? \"kept\" : \"pencil\" } });\n }}\n onChange={(item, c) => updateRow({ source: \"blocks\", id: item.id, patch: { day: c.date, starts: c.start, ends: c.end } })}\n above={<GoalStrip items={(goals.rows ?? []).map((g) => ({ id: g.id, title: g.title, state: g.kept ? \"ink\" : \"pencil\" }))} />}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nProposals on the hours they came for, with the sender's words:\n\n```jsx\n<TimeGrid\n days={days}\n pencilColor=\"kind\"\n kinds={{ meeting: { label: \"Meeting\", hue: 1 }, todo: { label: \"To-do\", hue: 4 } }}\n events={calendar}\n proposals={found.map((f) => ({\n id: f.id, date: f.day, start: f.start, end: f.end, title: f.title, kind: f.kind,\n unsure: f.sureness < 0.7,\n tag: { quote: f.phrase, source: `${f.sender} · ${f.subject}` },\n }))}\n selectedId={openId}\n onSelect={setOpenId}\n onAction={(item, action) => decide(item.id, action)}\n/>\n```\n\n## States\n\nDrawn in `demo.jsx`: a planner's week with goals above, locked meetings, focus\nin pencil, Keep, Dismiss, Keep all, a failing drag, a drop from a list;\nproposals with a tag and a hatched clash, a \"was\" marker and a failed add;\n320 px (one day at a time); seven days and five kinds; one event; very long\nwords; empty; loading; failed; read-only.\n\n## What it will not do\n\n- Recurring events, time zones, or blocks that cross midnight: a time is wall\n time on one day.\n- Fetch or save. Rows come in as props, saves go out through the callbacks.\n- Decide what clashes beyond overlap: two blocks that touch end to start do\n not clash, and all-day items never do. A rule such as \"no meetings after 5\"\n is the app's; mark it in the proposal's `label`.\n- Take the place of the record: a click goes out through `onOpen`; the form,\n the Commit and the Undo are the page's.\n- Show a month, an agenda or a booking page's open slots. That is\n `calendar-view`.\n\nA grid it cannot be is your own component in `components/` — not an edit to\nthis block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add time-grid` restores them.\n"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"categories": [
|
|
38
|
+
"calendar",
|
|
39
|
+
"week",
|
|
40
|
+
"day",
|
|
41
|
+
"hour grid",
|
|
42
|
+
"time grid",
|
|
43
|
+
"schedule",
|
|
44
|
+
"planner",
|
|
45
|
+
"pencil",
|
|
46
|
+
"ink",
|
|
47
|
+
"proposal",
|
|
48
|
+
"suggestion",
|
|
49
|
+
"draft",
|
|
50
|
+
"keep",
|
|
51
|
+
"keep all",
|
|
52
|
+
"dismiss",
|
|
53
|
+
"ghost",
|
|
54
|
+
"tag",
|
|
55
|
+
"clash",
|
|
56
|
+
"conflict",
|
|
57
|
+
"overlap",
|
|
58
|
+
"goals",
|
|
59
|
+
"focus time",
|
|
60
|
+
"agent",
|
|
61
|
+
"inbox to calendar",
|
|
62
|
+
"weekly planner",
|
|
63
|
+
"drag",
|
|
64
|
+
"resize"
|
|
65
|
+
],
|
|
66
|
+
"docs": "Installed to components/makersclaw/blocks/time-grid. Import: import { TimeGrid, GoalStrip, TIME_GRID_MIME, timeGridDragProps, PENCIL_STATES, clashesOf, clock, durationWords, gridLanes, hoursOf, isWaiting, loadOf, lookOf, namesOf, newlyInked, planKeepAll, previewOf, readItem, tagPlacement, toHHMM, toMin, walkProposal, waitingOrder } from \"@/components/makersclaw/blocks/time-grid\". Props and an example: components/makersclaw/blocks/time-grid/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
67
|
+
"meta": {
|
|
68
|
+
"makersclaw": {
|
|
69
|
+
"version": "0.8.1",
|
|
70
|
+
"useWhen": "A screen where a person reviews what an agent would put on their week: a weekly planner whose focus blocks are drafted in pencil and committed in ink, an inbox that finds meetings and deadlines in mail and shows each on its hour next to the real calendar with the sender's own words, a shift or room plan an assistant drafts, a day plan with proposals. Events are plain items with a date, a start and an end (not tasks). Not for tasks with start and due dates that live in a task list (calendar-view), for a month, for long timelines (gantt), or for a booking page's open slots (calendar-view)."
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|