makersclaw 0.4.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +1 -1
  2. package/r/annotate.json +3 -3
  3. package/r/approval-request.json +4 -4
  4. package/r/audio-player.json +2 -2
  5. package/r/auth-form.json +2 -2
  6. package/r/board.json +2 -2
  7. package/r/calendar-view.json +9 -9
  8. package/r/cart.json +2 -2
  9. package/r/chart.json +7 -7
  10. package/r/chat.json +3 -3
  11. package/r/code-editor.json +2 -2
  12. package/r/comment-box.json +5 -5
  13. package/r/contact-section.json +2 -2
  14. package/r/core.json +5 -5
  15. package/r/csv-import.json +3 -3
  16. package/r/data-table.json +4 -4
  17. package/r/diagram.json +2 -2
  18. package/r/doc-editor.json +2 -2
  19. package/r/doc-page.json +3 -3
  20. package/r/email-render.json +2 -2
  21. package/r/faq.json +2 -2
  22. package/r/file-upload.json +2 -2
  23. package/r/flow-canvas.json +4 -4
  24. package/r/footer.json +2 -2
  25. package/r/gantt.json +3 -3
  26. package/r/job-panel.json +2 -2
  27. package/r/json-editor.json +3 -3
  28. package/r/map.json +2 -2
  29. package/r/menubar.json +2 -2
  30. package/r/mind-map.json +6 -6
  31. package/r/navigation-menu.json +2 -2
  32. package/r/order-detail.json +2 -2
  33. package/r/org-chart.json +4 -4
  34. package/r/page-builder.json +2 -2
  35. package/r/payment-methods.json +2 -2
  36. package/r/pdf-document.json +2 -2
  37. package/r/pdf-viewer.json +3 -3
  38. package/r/product-detail.json +2 -2
  39. package/r/product-grid.json +3 -3
  40. package/r/record-form.json +2 -2
  41. package/r/record-notes.json +6 -6
  42. package/r/registry.json +121 -121
  43. package/r/search-dialog.json +2 -2
  44. package/r/settings-list.json +2 -2
  45. package/r/setup-checklist.json +2 -2
  46. package/r/share-dialog.json +2 -2
  47. package/r/sidebar.json +4 -4
  48. package/r/signature-pad.json +3 -3
  49. package/r/sketch-canvas.json +2 -2
  50. package/r/spreadsheet.json +3 -3
  51. package/r/stat-cards.json +3 -3
  52. package/r/task-list.json +3 -3
  53. package/r/task-model.json +2 -2
  54. package/r/testimonials.json +2 -2
  55. package/r/transaction-list.json +2 -2
  56. package/r/whiteboard.json +3 -3
  57. package/registry/blocks/approval-request/README.md +2 -1
  58. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  59. package/registry/blocks/calendar-view/README.md +8 -0
  60. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  61. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  62. package/registry/blocks/calendar-view/demo.jsx +2 -0
  63. package/registry/blocks/calendar-view/month.jsx +6 -5
  64. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  65. package/registry/blocks/calendar-view/tray.jsx +5 -4
  66. package/registry/blocks/chart/README.md +9 -4
  67. package/registry/blocks/chart/cartesian.jsx +31 -24
  68. package/registry/blocks/chart/core.jsx +21 -0
  69. package/registry/blocks/chart/pie.jsx +15 -10
  70. package/registry/blocks/chart/sparkline.jsx +32 -14
  71. package/registry/blocks/chat/message.jsx +4 -4
  72. package/registry/blocks/comment-box/README.md +1 -1
  73. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  74. package/registry/blocks/data-table/README.md +4 -0
  75. package/registry/blocks/data-table/data-table.jsx +20 -6
  76. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  77. package/registry/blocks/flow-canvas/theme.js +59 -48
  78. package/registry/blocks/mind-map/README.md +9 -2
  79. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  80. package/registry/blocks/mind-map/tree.js +33 -0
  81. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  82. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  83. package/registry/blocks/record-notes/README.md +1 -1
  84. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  85. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  86. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  87. package/registry/blocks/signature-pad/signature.js +8 -1
  88. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  89. package/templates/next/files/README.md +77 -19
  90. package/templates/next/files/app/auto-refresh.jsx +39 -74
  91. package/templates/next/files/app/frame.js +197 -6
  92. package/templates/next/files/check/run.mjs +96 -11
  93. package/templates/next/files/components/ui/calendar.jsx +24 -0
  94. package/templates/next/files/components/ui/field.jsx +12 -3
  95. package/templates/next/files/components/ui/index.js +1 -0
  96. package/templates/next/files/components/ui/sheet.jsx +38 -2
  97. package/templates/next/files/components/ui/toggle.jsx +20 -2
  98. package/templates/next/files/lib/actions.js +63 -5
  99. package/templates/next/files/lib/data.js +4 -1
  100. package/templates/next/files/lib/db.js +18 -1
  101. package/templates/next/files/lib/hooks.js +52 -17
  102. package/templates/next/template.json +2 -2
@@ -5,8 +5,8 @@
5
5
  "title": "CommentBox",
6
6
  "description": "A minimal rich comment field (DocEditor in minimal mode): bold, italic, links and lists from the selection toolbar or markdown shortcuts, @mentions as chips, Cmd/Ctrl+Enter to post. It posts through an async onPost(doc) that answers { ok }, keeps the draft and says why when it fails, and clears when it lands. With `comments` it draws the thread above it: avatar, name, relative time, the rich body. Also exports the composer alone, the read-only body renderer and the timestamp.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/doc-editor.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/doc-editor.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -32,13 +32,13 @@
32
32
  "path": "registry/blocks/comment-box/comment-time.jsx",
33
33
  "type": "registry:component",
34
34
  "target": "@components/makersclaw/blocks/comment-box/comment-time.jsx",
35
- "content": "\"use client\";\n\n// CommentTime — when a comment was written, as a <time>.\n//\n// Relative words (\"5 min ago\") need the clock, and the server's clock and\n// time zone are not the reader's. So the server and the first paint show\n// the absolute date; after mount it turns relative, and one shared minute\n// tick (not a timer per comment) keeps every label current. The full date\n// is always in the title.\nimport { useEffect, useState } from \"react\";\nimport { cx } from \"../../ui\";\n\nconst listeners = new Set();\nlet timer = null;\nfunction subscribe(fn) {\n listeners.add(fn);\n if (!timer) timer = setInterval(() => listeners.forEach((l) => l(Date.now())), 60_000);\n return () => {\n listeners.delete(fn);\n if (!listeners.size && timer) {\n clearInterval(timer);\n timer = null;\n }\n };\n}\n\n/** Date.now(), refreshed each minute — null until mounted. */\nexport function useNow() {\n const [now, setNow] = useState(null);\n useEffect(() => {\n setNow(Date.now());\n return subscribe(setNow);\n }, []);\n return now;\n}\n\nexport const toDate = (v) => {\n if (v === undefined || v === null || v === \"\") return null;\n const d = v instanceof Date ? v : new Date(v);\n return Number.isNaN(d.getTime()) ? null : d;\n};\n\nconst fmt = (opts) => new Intl.DateTimeFormat(undefined, opts);\nconst TIME = { hour: \"numeric\", minute: \"2-digit\" };\nconst startOfDay = (ms) => {\n const d = new Date(ms);\n d.setHours(0, 0, 0, 0);\n return d.getTime();\n};\n\n/** \"12 Sep 2026, 14:05\" — the title, and the label before mount. */\nexport function fullDate(d) {\n return fmt({ day: \"numeric\", month: \"short\", year: \"numeric\", ...TIME }).format(d);\n}\n\n/** \"just now\" · \"5 min ago\" · \"3 h ago\" (today) · \"Yesterday, 14:05\" · \"12 Sep, 14:05\" · \"12 Sep 2025\". */\nexport function relativeDate(d, now) {\n const diff = now - d.getTime();\n if (diff < 45_000 && diff > -60_000) return \"just now\";\n const today = startOfDay(now);\n if (diff > 0 && diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;\n if (d.getTime() >= today && diff > 0) return `${Math.floor(diff / 3_600_000)} h ago`;\n if (d.getTime() >= today - 86_400_000 && d.getTime() < today) return `Yesterday, ${fmt(TIME).format(d)}`;\n if (new Date(d).getFullYear() === new Date(now).getFullYear()) return fmt({ day: \"numeric\", month: \"short\", ...TIME }).format(d);\n return fmt({ day: \"numeric\", month: \"short\", year: \"numeric\" }).format(d);\n}\n\nexport function CommentTime({ date, className }) {\n const now = useNow();\n const d = toDate(date);\n if (!d) return null;\n return (\n <time dateTime={d.toISOString()} title={fullDate(d)} suppressHydrationWarning className={cx(\"text-xs text-muted-foreground\", className)}>\n {now === null ? fullDate(d) : relativeDate(d, now)}\n </time>\n );\n}\n"
35
+ "content": "\"use client\";\n\n// CommentTime — when a comment was written, as a <time>.\n//\n// Relative words (\"5 min ago\") need the clock, and the server's clock and\n// time zone are not the reader's. So the server and the first paint show\n// the absolute date; after mount it turns relative, and one shared minute\n// tick (not a timer per comment) keeps every label current. The full date\n// is always in the title.\nimport { useEffect, useState } from \"react\";\nimport { cx, toLocalDate } from \"../../ui\";\n\nconst listeners = new Set();\nlet timer = null;\nfunction subscribe(fn) {\n listeners.add(fn);\n if (!timer) timer = setInterval(() => listeners.forEach((l) => l(Date.now())), 60_000);\n return () => {\n listeners.delete(fn);\n if (!listeners.size && timer) {\n clearInterval(timer);\n timer = null;\n }\n };\n}\n\n/** Date.now(), refreshed each minute — null until mounted. */\nexport function useNow() {\n const [now, setNow] = useState(null);\n useEffect(() => {\n setNow(Date.now());\n return subscribe(setNow);\n }, []);\n return now;\n}\n\n/** A Date, an ISO time or a day alone (\"2026-09-28\", a `date` column: that\n * day's local midnight, never UTC's) → a Date; null when it is none. */\nexport const toDate = (v) => toLocalDate(v);\n\nconst fmt = (opts) => new Intl.DateTimeFormat(undefined, opts);\nconst TIME = { hour: \"numeric\", minute: \"2-digit\" };\nconst startOfDay = (ms) => {\n const d = new Date(ms);\n d.setHours(0, 0, 0, 0);\n return d.getTime();\n};\n\n/** \"12 Sep 2026, 14:05\" — the title, and the label before mount. */\nexport function fullDate(d) {\n return fmt({ day: \"numeric\", month: \"short\", year: \"numeric\", ...TIME }).format(d);\n}\n\n/** \"just now\" · \"5 min ago\" · \"3 h ago\" (today) · \"Yesterday, 14:05\" · \"12 Sep, 14:05\" · \"12 Sep 2025\". */\nexport function relativeDate(d, now) {\n const diff = now - d.getTime();\n if (diff < 45_000 && diff > -60_000) return \"just now\";\n const today = startOfDay(now);\n if (diff > 0 && diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;\n if (d.getTime() >= today && diff > 0) return `${Math.floor(diff / 3_600_000)} h ago`;\n if (d.getTime() >= today - 86_400_000 && d.getTime() < today) return `Yesterday, ${fmt(TIME).format(d)}`;\n if (new Date(d).getFullYear() === new Date(now).getFullYear()) return fmt({ day: \"numeric\", month: \"short\", ...TIME }).format(d);\n return fmt({ day: \"numeric\", month: \"short\", year: \"numeric\" }).format(d);\n}\n\nexport function CommentTime({ date, className }) {\n const now = useNow();\n const d = toDate(date);\n if (!d) return null;\n return (\n <time dateTime={d.toISOString()} title={fullDate(d)} suppressHydrationWarning className={cx(\"text-xs text-muted-foreground\", className)}>\n {now === null ? fullDate(d) : relativeDate(d, now)}\n </time>\n );\n}\n"
36
36
  },
37
37
  {
38
38
  "path": "registry/blocks/comment-box/README.md",
39
39
  "type": "registry:file",
40
40
  "target": "@components/makersclaw/blocks/comment-box/README.md",
41
- "content": "# CommentBox\n\nA comment field that takes rich text: select a word for bold, italic or a\nlink, start a line with `-` for a list, type `@` to mention a teammate\n(they go in as a chip). Cmd/Ctrl+Enter posts, as does the Post button.\nWhile a post is on its way the box is locked; when it fails the words\nstay and the box says why; when it lands the box clears. Give it\n`comments` and it draws the thread above: avatar, name, \"5 min ago\", the\nbody with its formatting and mentions.\n\nIt is `doc-editor` in its minimal mode (no `/` menu, no drag handles), so\na comment is BlockNote JSON — the same doc every text block uses.\n\n```jsx\nimport { CommentBox } from \"../components/blocks/comment-box\"; // a Makersclaw app (\"./blocks/comment-box\" from components/)\nimport { CommentBox } from \"@/components/makersclaw/blocks/comment-box\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the thread in the server surface (README §4). `makersclaw add\ncomment-box` adds `doc-editor` with it (and its npm packages: run\n`npm install` after).\n\n## The data\n\nA comment in `comments`:\n\n```js\n{\n id: \"c-81\",\n author: { id: \"u-lena\", name: \"Lena Fischer\", src: \"https://…/lena.jpg\" }, // or just \"Lena Fischer\"\n body: [ /* the doc onPost gave you */ ], // or a plain string\n createdAt: \"2026-09-23T09:12:00Z\", // ISO string, Date or ms\n edited: false, // true adds \"(edited)\"\n}\n```\n\nStore `body` as it came out of `onPost` (a `jsonb` column). Keep a plain\ncopy beside it for search and notifications: `docToText(doc)` from\n`doc-editor`. The people mentioned are `docMentions(doc)` — notify them.\n\n## Props\n\n- `onPost(doc)` — required. `async`, returns `{ ok: true }` or\n `{ ok: false, error? }` (`error` is shown under the box; without it,\n \"Couldn't post…\"). A throw counts as a failure. Only an ok clears the\n box. An empty box never posts.\n- `comments` — the thread, oldest first, drawn above the box. Leave it out\n for the field alone; `[]` shows `emptyText`.\n- `author` — `{ name, src? }`, who is writing: their avatar sits beside\n the box from 640px.\n- `mentions` — `async (query) => [{ id, label, description? }]`: turns on\n `@` (doc-editor's `mentions`).\n- `placeholder` — `\"Write a comment…\"`.\n- `postLabel` — the button, `\"Post\"` (\"Reply\", \"Send\").\n- `label` — the field's accessible name, `\"Comment\"`.\n- `threadLabel` — the list's accessible name, `\"Comments\"`.\n- `emptyText` — `\"No comments yet.\"`.\n- `autoFocus` — `false`.\n- `disabled` — `false`; `true` locks the box (no edit, no post).\n- `className` — layout around it.\n\nThe other exports, for a thread of your own:\n\n- `CommentComposer` — the field and its buttons: `onSubmit(doc)` (same\n answer as `onPost`), `author`, `mentions`, `defaultValue` (a doc to\n start from — editing a comment), `placeholder`, `submitLabel`\n (`\"Post\"`), `submitIcon` (a kit icon name, `\"send\"`; `null` for none),\n `busyLabel` (shown while it runs), `failedText`,\n `onCancel` (adds a Cancel button), `cancelLabel`, `label`,\n `autoFocus`, `disabled`, `clearOnSubmit` (`true`; `false` when the\n composer goes away after a save), `className`.\n- `CommentBody` — `{ doc, className }`: a doc drawn read-only as plain\n HTML (server too): paragraphs, headings, lists, checklists, quotes,\n code, tables, links, image links, mentions and fields as chips.\n- `CommentTime` — `{ date, className }`: a `<time>`; the absolute date on\n the server, \"5 min ago\" once mounted, kept current each minute.\n\n## Example\n\n```jsx\n// components/TaskComments.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow } from \"../lib/actions\";\nimport { CommentBox } from \"./blocks/comment-box\";\nimport { docToText } from \"./blocks/doc-editor\";\n\nexport default function TaskComments({ task, me, team, initial }) {\n const [comments, setComments] = useState(initial);\n const findPeople = async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }));\n\n async function post(doc) {\n const res = await insertRow({\n source: \"public.comments\",\n values: { task_id: task.id, author_id: me.id, body: doc, body_text: docToText(doc) },\n });\n if (!res.ok) return { ok: false, error: res.error };\n setComments((c) => [...c, { id: res.id, author: me, body: doc, createdAt: new Date() }]);\n return { ok: true };\n }\n\n return <CommentBox comments={comments} onPost={post} author={me} mentions={findPeople} />;\n}\n```\n\n## What it will not do\n\n- Edit or delete a posted comment, react, or reply in a nested thread: the\n thread is read-only. Notes you keep, edit and delete on a record are\n `record-notes`; build a richer thread from `CommentComposer` +\n `CommentBody` in your own component.\n- Attach files or images, or offer a `/` menu: it is a comment, not a\n document (`doc-editor` for that).\n- Notify anyone: read the mentions off the doc (`docMentions`) and send\n them yourself.\n- Load older comments or scroll the thread: it draws what it is given;\n put it in a scrolling box and page it yourself.\n\nAn editor it cannot be is your own component in `components/` — not an\nedit to this block. Its files are fixed: `npm run check` refuses a\nchange, and `makersclaw add comment-box` restores them.\n"
41
+ "content": "# CommentBox\n\nA comment field that takes rich text: select a word for bold, italic or a\nlink, start a line with `-` for a list, type `@` to mention a teammate\n(they go in as a chip). Cmd/Ctrl+Enter posts, as does the Post button.\nWhile a post is on its way the box is locked; when it fails the words\nstay and the box says why; when it lands the box clears. Give it\n`comments` and it draws the thread above: avatar, name, \"5 min ago\", the\nbody with its formatting and mentions.\n\nIt is `doc-editor` in its minimal mode (no `/` menu, no drag handles), so\na comment is BlockNote JSON — the same doc every text block uses.\n\n```jsx\nimport { CommentBox } from \"../components/blocks/comment-box\"; // a Makersclaw app (\"./blocks/comment-box\" from components/)\nimport { CommentBox } from \"@/components/makersclaw/blocks/comment-box\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the thread in the server surface (README §4). `makersclaw add\ncomment-box` adds `doc-editor` with it (and its npm packages: run\n`npm install` after).\n\n## The data\n\nA comment in `comments`:\n\n```js\n{\n id: \"c-81\",\n author: { id: \"u-lena\", name: \"Lena Fischer\", src: \"https://…/lena.jpg\" }, // or just \"Lena Fischer\"\n body: [ /* the doc onPost gave you */ ], // or a plain string\n createdAt: \"2026-09-23T09:12:00Z\", // ISO string, Date, ms, or a day (\"2026-09-23\": that day, never UTC's)\n edited: false, // true adds \"(edited)\"\n}\n```\n\nStore `body` as it came out of `onPost` (a `jsonb` column). Keep a plain\ncopy beside it for search and notifications: `docToText(doc)` from\n`doc-editor`. The people mentioned are `docMentions(doc)` — notify them.\n\n## Props\n\n- `onPost(doc)` — required. `async`, returns `{ ok: true }` or\n `{ ok: false, error? }` (`error` is shown under the box; without it,\n \"Couldn't post…\"). A throw counts as a failure. Only an ok clears the\n box. An empty box never posts.\n- `comments` — the thread, oldest first, drawn above the box. Leave it out\n for the field alone; `[]` shows `emptyText`.\n- `author` — `{ name, src? }`, who is writing: their avatar sits beside\n the box from 640px.\n- `mentions` — `async (query) => [{ id, label, description? }]`: turns on\n `@` (doc-editor's `mentions`).\n- `placeholder` — `\"Write a comment…\"`.\n- `postLabel` — the button, `\"Post\"` (\"Reply\", \"Send\").\n- `label` — the field's accessible name, `\"Comment\"`.\n- `threadLabel` — the list's accessible name, `\"Comments\"`.\n- `emptyText` — `\"No comments yet.\"`.\n- `autoFocus` — `false`.\n- `disabled` — `false`; `true` locks the box (no edit, no post).\n- `className` — layout around it.\n\nThe other exports, for a thread of your own:\n\n- `CommentComposer` — the field and its buttons: `onSubmit(doc)` (same\n answer as `onPost`), `author`, `mentions`, `defaultValue` (a doc to\n start from — editing a comment), `placeholder`, `submitLabel`\n (`\"Post\"`), `submitIcon` (a kit icon name, `\"send\"`; `null` for none),\n `busyLabel` (shown while it runs), `failedText`,\n `onCancel` (adds a Cancel button), `cancelLabel`, `label`,\n `autoFocus`, `disabled`, `clearOnSubmit` (`true`; `false` when the\n composer goes away after a save), `className`.\n- `CommentBody` — `{ doc, className }`: a doc drawn read-only as plain\n HTML (server too): paragraphs, headings, lists, checklists, quotes,\n code, tables, links, image links, mentions and fields as chips.\n- `CommentTime` — `{ date, className }`: a `<time>`; the absolute date on\n the server, \"5 min ago\" once mounted, kept current each minute.\n\n## Example\n\n```jsx\n// components/TaskComments.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow } from \"../lib/actions\";\nimport { CommentBox } from \"./blocks/comment-box\";\nimport { docToText } from \"./blocks/doc-editor\";\n\nexport default function TaskComments({ task, me, team, initial }) {\n const [comments, setComments] = useState(initial);\n const findPeople = async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }));\n\n async function post(doc) {\n const res = await insertRow({\n source: \"public.comments\",\n values: { task_id: task.id, author_id: me.id, body: doc, body_text: docToText(doc) },\n });\n if (!res.ok) return { ok: false, error: res.error };\n setComments((c) => [...c, { id: res.id, author: me, body: doc, createdAt: new Date() }]);\n return { ok: true };\n }\n\n return <CommentBox comments={comments} onPost={post} author={me} mentions={findPeople} />;\n}\n```\n\n## What it will not do\n\n- Edit or delete a posted comment, react, or reply in a nested thread: the\n thread is read-only. Notes you keep, edit and delete on a record are\n `record-notes`; build a richer thread from `CommentComposer` +\n `CommentBody` in your own component.\n- Attach files or images, or offer a `/` menu: it is a comment, not a\n document (`doc-editor` for that).\n- Notify anyone: read the mentions off the doc (`docMentions`) and send\n them yourself.\n- Load older comments or scroll the thread: it draws what it is given;\n put it in a scrolling box and page it yourself.\n\nAn editor it cannot be is your own component in `components/` — not an\nedit to this block. Its files are fixed: `npm run check` refuses a\nchange, and `makersclaw add comment-box` restores them.\n"
42
42
  }
43
43
  ],
44
44
  "categories": [
@@ -57,7 +57,7 @@
57
57
  "docs": "Installed to components/makersclaw/blocks/comment-box. Import: import { CommentBox, CommentComposer, CommentBody, CommentTime } from \"@/components/makersclaw/blocks/comment-box\". Props and an example: components/makersclaw/blocks/comment-box/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
58
58
  "meta": {
59
59
  "makersclaw": {
60
- "version": "0.4.0",
60
+ "version": "0.5.2",
61
61
  "useWhen": "People comment on something in the app: a task, a design, a document, an approval, a support ticket; a reply field with @mentions under a thread. Not dated notes kept on a record with edit and delete (record-notes), not a conversation with an assistant (chat), not a long document (doc-editor, doc-page)."
62
62
  }
63
63
  }
@@ -5,7 +5,7 @@
5
5
  "title": "Contact Section",
6
6
  "description": "A public \"contact us\" section: a form (name, email, message; optional phone, company, subject, a topic as chips or radios, a consent box) and/or the ways to reach you (email, phone, address, hours, offices). The form alone, beside an info panel (plain or filled), or with info cards above or beside it; an optional picture beside or behind the form. Validates in place, submits through an async onSubmit that returns { ok }, and shows the failure or a sent state.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -46,7 +46,7 @@
46
46
  "docs": "Installed to components/makersclaw/blocks/contact-section. Import: import { ContactSection } from \"@/components/makersclaw/blocks/contact-section\". Props and an example: components/makersclaw/blocks/contact-section/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
47
47
  "meta": {
48
48
  "makersclaw": {
49
- "version": "0.4.0",
49
+ "version": "0.5.2",
50
50
  "useWhen": "The contact page or contact section of a public site: sales enquiries, support requests, a studio's \"start a project\" form. Not for a signed-in user's support inbox or ticket thread, or a general-purpose record form (use a form of your own from the kit)."
51
51
  }
52
52
  }
package/r/core.json CHANGED
@@ -63,7 +63,7 @@
63
63
  "path": "templates/next/files/components/ui/calendar.jsx",
64
64
  "type": "registry:ui",
65
65
  "target": "@components/makersclaw/ui/calendar.jsx",
66
- "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\nimport { Select } from \"./field\";\n\n// Calendar — a month grid to pick a day, several days, or a span of days.\n// It is the WAI-ARIA date grid: one Tab stop (the focused day), arrows\n// move a day or a week, Page Up/Down a month (with Shift, a year), Home\n// and End the week's edges, Enter or Space picks. Names, week start and\n// digits come from Intl for the reader's locale. No date library.\n//\n// mode single (value \"2026-03-12\" | null) · multiple (value\n// [\"2026-03-12\", …], kept sorted) · range (value { from, to }\n// | null; `to` is null until the second click; a second click\n// before `from` swaps them)\n// value, onChange(value) — controlled; or `defaultValue`\n// required single: clicking the chosen day again keeps it (default:\n// it clears)\n// month the month shown, \"2026-03\" (controlled, with onMonthChange);\n// or `defaultMonth`; else the first chosen day's, else today's\n// numberOfMonths side by side from 640px, stacked on a phone\n// min, max \"YYYY-MM-DD\", inclusive; days past them are disabled and\n// the arrows stop there\n// disabled (date) → true for a day that cannot be picked (weekends,\n// booked days); true disables every day\n// captionLayout label (default) · dropdown (month and year Selects for\n// far jumps; the arrows stay) · none (no caption and no\n// arrows: the parent shows the month and moves it, passing a\n// controlled `month`; a screen reader still hears the month)\n// fromYear, toYear the dropdown's years (default: min/max, else today\n// −100 … +10)\n// showOutsideDays the neighbouring months' days, muted (true)\n// showWeekNumbers ISO 8601 week numbers down the side\n// marked days that hold something (posts, bookings): a list of\n// \"YYYY-MM-DD\", or { \"2026-03-04\": { tone, label } } — a dot\n// under the number in `tone` (primary, the default · neutral ·\n// success · warning · danger · info); `label` joins the day's\n// name for a screen reader (\"Wednesday, March 4, 2026, 3\n// posts\"). Selection is unchanged: open the day's items from\n// onChange\n// emphasizeMarked marked days drawn bold, the rest muted — a calendar\n// that exists to show where the posts are (false)\n// onlyMarked unmarked days cannot be picked, with no strike-through: they\n// are empty, not forbidden (that stays `disabled`'s look).\n// Keys still walk them (false)\n// weekStartsOn 0 (Sunday) … 6; default: the locale's\n// locale a BCP 47 tag; default: the browser's\n// today \"YYYY-MM-DD\", drawn ringed; default: the browser's today.\n// Pass it where a screen must not change from day to day (a\n// demo, a test)\n// size md (44px days) · sm (36px days from 640px, 44px on a phone)\n// autoFocus when it turns true, focus moves to the grid (a picker\n// passes its open state)\n// label what a screen reader calls the whole calendar (\"Calendar\")\n//\n// Dates are plain \"YYYY-MM-DD\" strings in and out, never Date objects. A\n// date is a day on a wall calendar, not an instant: a Date is an instant,\n// and \"2026-03-12\" parsed as one is midnight UTC — the 11th in New York.\n// Strings compare in order (\"2026-03-02\" < \"2026-03-12\"), survive JSON\n// and a database's `date` column unchanged, and mean the same day in\n// every timezone. Inside, arithmetic runs on UTC midnights, where no\n// daylight-saving shift can add or lose a day. The helpers below are\n// exported for the same work in a surface.\n\n// ── Dates as strings ────────────────────────────────────────────────────\n\nconst ISO = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\nconst DAY = 86400000;\nconst pad = (n, w = 2) => String(n).padStart(w, \"0\");\n\n/** \"YYYY-MM-DD\" → a Date at that day's UTC midnight; null when it is not\n * a real day (\"2026-02-30\", \"\", undefined). */\nexport function parseDate(s) {\n const m = typeof s === \"string\" ? ISO.exec(s) : null;\n if (!m) return null;\n const d = new Date(0);\n d.setUTCFullYear(+m[1], +m[2] - 1, +m[3]);\n return d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] ? d : null;\n}\n\nconst iso = (d) => `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;\n\n/** True for a real \"YYYY-MM-DD\" day. */\nexport const isDateString = (s) => parseDate(s) !== null;\n\n/** A JS Date → the \"YYYY-MM-DD\" it is on the reader's own calendar\n * (local time: new Date() at 23:30 in Tokyo is Tokyo's day). */\nexport function toDateString(date = new Date()) {\n return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;\n}\n\n/** Today on the reader's calendar, \"YYYY-MM-DD\". */\nexport const todayString = () => toDateString(new Date());\n\n/** The day `n` days on (negative: back). */\nexport function addDays(s, n) {\n const d = parseDate(s);\n return d ? iso(new Date(d.getTime() + n * DAY)) : null;\n}\n\n/** The same day `n` months on, clamped to the month's end (Jan 31 + 1 →\n * Feb 28). */\nexport function addMonths(s, n) {\n const d = parseDate(s);\n if (!d) return null;\n const y = d.getUTCFullYear();\n const m = d.getUTCMonth() + n;\n const last = new Date(Date.UTC(2000, 0, 1));\n last.setUTCFullYear(y, m + 1, 0);\n const out = new Date(0);\n out.setUTCFullYear(y, m, Math.min(d.getUTCDate(), last.getUTCDate()));\n return iso(out);\n}\n\n/** The first day of `s`'s month; \"2026-03\" works as well as a full date. */\nexport const startOfMonth = (s) => (typeof s === \"string\" && /^\\d{4}-\\d{2}/.test(s) ? `${s.slice(0, 7)}-01` : null);\n\n/** The last day of `s`'s month. */\nexport const endOfMonth = (s) => {\n const first = startOfMonth(s);\n return first ? addDays(addMonths(first, 1), -1) : null;\n};\n\n/** The day of the week, 0 (Sunday) … 6. */\nexport const dayOfWeek = (s) => parseDate(s)?.getUTCDay() ?? null;\n\n// Intl's spacing differs between ICU builds (Node's thin and no-break\n// spaces around a range's dash, a browser's plain ones): the server's\n// HTML and the browser's first render must be the same text, so every\n// formatted string is written with plain spaces.\nexport const plainSpaces = (s) => s.replace(/[\\u00a0\\u2009\\u202f]/g, \" \");\n\n/** A day in words for the locale: formatDate(\"2026-03-12\") → \"Mar 12,\n * 2026\"; `options` are Intl.DateTimeFormat's ({ dateStyle: \"full\" }). */\nexport function formatDate(s, options = { dateStyle: \"medium\" }, locale) {\n const d = parseDate(s);\n return d ? plainSpaces(new Intl.DateTimeFormat(locale, { ...options, timeZone: \"UTC\" }).format(d)) : \"\";\n}\n\n// The week's first day for a locale: Intl's own answer where the browser\n// has it (getWeekInfo), else CLDR's regions that start on Sunday or\n// Saturday; everyone else starts on Monday.\nconst SUNDAY = new Set(\"AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW\".split(\" \"));\nconst SATURDAY = new Set(\"AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY\".split(\" \"));\n\n/** 0 (Sunday) … 6: the day a week starts on in `locale`. */\nexport function firstDayOfWeek(locale) {\n try {\n const loc = new Intl.Locale(locale ?? Intl.DateTimeFormat().resolvedOptions().locale);\n const info = loc.getWeekInfo?.() ?? loc.weekInfo;\n if (info?.firstDay) return info.firstDay % 7;\n const region = loc.maximize().region;\n return SUNDAY.has(region) ? 0 : SATURDAY.has(region) ? 6 : 1;\n } catch {\n return 1;\n }\n}\n\n// ISO 8601 week number of the week holding `thursday` (a Thursday's\n// week number is its day of the year, in sevens, rounded up).\nfunction isoWeek(thursday) {\n const d = parseDate(thursday);\n const jan1 = Date.UTC(d.getUTCFullYear(), 0, 1);\n return Math.floor((d.getTime() - jan1) / DAY / 7) + 1;\n}\n\n// ── The reader's locale and today, without a hydration mismatch ────────\n// The server does not know either. useSyncExternalStore renders the\n// server's answer (null) while hydrating, then the browser's — so the\n// first paint matches the HTML and nothing warns.\n\nconst never = () => () => {};\nfunction useBrowser(get) {\n return useSyncExternalStore(never, get, () => null);\n}\n\n/** The locale to format in: `locale`, else the browser's (after\n * hydration; \"en-US\" in the server's HTML). */\nexport function useLocale(locale) {\n const browser = useBrowser(() => navigator.language);\n return locale ?? browser ?? \"en-US\";\n}\n\n/** `today`, else the browser's today once hydrated (null before). */\nexport function useToday(today) {\n const browser = useBrowser(todayString);\n return today ?? browser;\n}\n\n// ── The grid ────────────────────────────────────────────────────────────\n\n// A marked day's dot. On a picked day (the primary fill) the primary and\n// neutral dots turn to the fill's own foreground so they still show.\nconst DOT = {\n primary: \"bg-primary\",\n neutral: \"bg-muted-foreground\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n info: \"bg-info\",\n};\n\nconst SIZES = {\n md: { cell: \"size-11\", caption: \"h-11\", nav: \"md\" },\n sm: { cell: \"size-11 sm:size-9\", caption: \"h-11 sm:h-9\", nav: \"sm\" },\n};\n\nfunction useControlled(value, fallback) {\n const [own, setOwn] = useState(fallback);\n return value !== undefined ? [value, false] : [own, setOwn];\n}\n\nfunction listOf(mode, value) {\n if (!value) return [];\n if (mode === \"multiple\") return Array.isArray(value) ? value : [];\n if (mode === \"range\") return [value.from, value.to].filter(Boolean);\n return [value];\n}\n\nexport function Calendar({\n mode = \"single\",\n value: valueProp,\n defaultValue,\n onChange,\n required = false,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n numberOfMonths = 1,\n min,\n max,\n disabled,\n captionLayout = \"label\",\n fromYear,\n toYear,\n showOutsideDays = true,\n showWeekNumbers = false,\n marked,\n emphasizeMarked = false,\n onlyMarked = false,\n weekStartsOn,\n locale: localeProp,\n today: todayProp,\n size = \"md\",\n autoFocus = false,\n label = \"Calendar\",\n className,\n}) {\n const uid = useId();\n const root = useRef(null);\n const pending = useRef(false);\n const locale = useLocale(localeProp);\n const today = useToday(todayProp);\n const s = SIZES[size] ?? SIZES.md;\n const n = Math.max(1, numberOfMonths | 0);\n\n const [own, setOwnValue] = useControlled(valueProp, defaultValue ?? (mode === \"multiple\" ? [] : null));\n const value = valueProp !== undefined ? valueProp : own;\n const chosen = listOf(mode, value);\n\n const [ownMonth, setOwnMonth] = useState(() => startOfMonth(defaultMonth) ?? startOfMonth(chosen[0]));\n const first = startOfMonth(monthProp) ?? ownMonth ?? startOfMonth(today);\n const last = first ? addMonths(first, n - 1) : null;\n const [focus, setFocus] = useState(null);\n const [hover, setHover] = useState(null);\n\n const marks = useMemo(() => {\n if (Array.isArray(marked)) return Object.fromEntries(marked.map((d) => [d, {}]));\n return marked && typeof marked === \"object\" ? marked : {};\n }, [marked]);\n\n const ws = weekStartsOn ?? firstDayOfWeek(locale);\n const fmt = useMemo(() => {\n const f = (o) => {\n const intl = new Intl.DateTimeFormat(locale, { ...o, timeZone: \"UTC\" });\n return { format: (d) => plainSpaces(intl.format(d)) };\n };\n return {\n caption: f({ month: \"long\", year: \"numeric\" }),\n day: f({ day: \"numeric\" }),\n full: f({ dateStyle: \"full\" }),\n monthShort: f({ month: \"short\" }),\n year: f({ year: \"numeric\" }),\n weekShort: f({ weekday: \"short\" }),\n weekLong: f({ weekday: \"long\" }),\n };\n }, [locale]);\n // 2023-01-01 was a Sunday: the seven names from the week's first day.\n const weekdays = useMemo(\n () =>\n Array.from({ length: 7 }, (_, i) => {\n const d = parseDate(addDays(\"2023-01-01\", (ws + i) % 7));\n return { short: fmt.weekShort.format(d), long: fmt.weekLong.format(d) };\n }),\n [fmt, ws],\n );\n\n // Focus follows the roving day after the render that shows it.\n useEffect(() => {\n if (!pending.current) return;\n pending.current = false;\n root.current?.querySelector('button[data-day][tabindex=\"0\"]')?.focus();\n });\n useEffect(() => {\n if (!autoFocus) return;\n const go = () => root.current?.querySelector('button[data-day][tabindex=\"0\"]')?.focus({ preventScroll: true });\n // Inside a popover not shown yet (one open on load), wait for it: the\n // Popover focuses its first control on toggle, and the day wins after.\n const pop = root.current?.closest(\"[popover]\");\n if (pop && !pop.matches(\":popover-open\")) {\n let t;\n const on = () => (t = setTimeout(go));\n pop.addEventListener(\"toggle\", on, { once: true });\n return () => {\n pop.removeEventListener(\"toggle\", on);\n clearTimeout(t);\n };\n }\n go();\n }, [autoFocus]);\n\n function setMonth(m) {\n if (monthProp === undefined) setOwnMonth(m);\n onMonthChange?.(m.slice(0, 7));\n }\n\n function setValue(v) {\n if (setOwnValue) setOwnValue(v);\n onChange?.(v);\n }\n\n const isDisabled = (d) =>\n disabled === true || (min && d < min) || (max && d > max) || (typeof disabled === \"function\" && !!disabled(d));\n const inView = (d) => !!(d && first && startOfMonth(d) >= first && startOfMonth(d) <= last);\n\n const isEmpty = (d) => onlyMarked && !marks[d];\n\n function pick(d) {\n if (isDisabled(d) || isEmpty(d)) return;\n if (mode === \"multiple\") {\n setValue(chosen.includes(d) ? chosen.filter((x) => x !== d) : [...chosen, d].sort());\n } else if (mode === \"range\") {\n const { from, to } = value ?? {};\n if (!from || to) setValue({ from: d, to: null });\n else if (d < from) setValue({ from: d, to: from });\n else setValue({ from, to: d });\n } else {\n setValue(value === d && !required ? null : d);\n }\n }\n\n function moveTo(d) {\n if (min && d < min) d = min;\n if (max && d > max) d = max;\n const m = startOfMonth(d);\n if (m < first) setMonth(m);\n else if (m > last) setMonth(addMonths(m, -(n - 1)));\n setFocus(d);\n pending.current = true;\n }\n\n function onKeyDown(e) {\n const day = e.target.dataset?.day;\n if (!day || e.altKey || e.ctrlKey || e.metaKey) return;\n const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n const back = (dayOfWeek(day) - ws + 7) % 7;\n const step = {\n ArrowLeft: () => addDays(day, rtl ? 1 : -1),\n ArrowRight: () => addDays(day, rtl ? -1 : 1),\n ArrowUp: () => addDays(day, -7),\n ArrowDown: () => addDays(day, 7),\n PageUp: () => addMonths(day, e.shiftKey ? -12 : -1),\n PageDown: () => addMonths(day, e.shiftKey ? 12 : 1),\n Home: () => addDays(day, -back),\n End: () => addDays(day, 6 - back),\n }[e.key];\n if (!step) return;\n e.preventDefault();\n moveTo(step());\n }\n\n // Range display: the committed span, or — between the two clicks —\n // a preview from `from` to the day under the pointer or focus.\n const range = mode === \"range\" ? value ?? {} : {};\n const previewing = !!(range.from && !range.to && hover);\n const ends = range.from ? [range.from, range.to ?? (previewing ? hover : range.from)].sort() : null;\n\n const tabbable = [focus, ...chosen, today].find((d) => inView(d) && !(min && d < min) && !(max && d > max)) ?? (inView(min) ? min : first);\n\n if (!first) {\n // Hydrating with no month to show yet: hold the space.\n return <div ref={root} role=\"group\" aria-label={label} aria-busy=\"true\" className={cx(\"min-h-80 min-w-72\", className)} />;\n }\n\n const months = Array.from({ length: n }, (_, i) => addMonths(first, i));\n const prevOff = !!(min && startOfMonth(min) >= first);\n const nextOff = !!(max && startOfMonth(max) <= last);\n\n function Caption({ m, i }) {\n const id = `${uid}-cap-${i}`;\n if (captionLayout === \"none\") {\n return (\n <span id={id} className=\"sr-only\" aria-live={i === 0 ? \"polite\" : undefined}>\n {fmt.caption.format(parseDate(m))}\n </span>\n );\n }\n if (captionLayout === \"dropdown\" && i === 0) {\n const y = +m.slice(0, 4);\n const ty = +(today ?? m).slice(0, 4);\n const lo = Math.min(fromYear ?? (min ? +min.slice(0, 4) : ty - 100), y);\n const hi = Math.max(toYear ?? (max ? +max.slice(0, 4) : ty + 10), y);\n const monthOff = (k) => {\n const mm = `${pad(y, 4)}-${pad(k + 1)}-01`;\n return (min && endOfMonth(mm) < min) || (max && mm > max);\n };\n return (\n <div className={cx(\"flex items-center justify-center gap-1 px-12\", s.caption)}>\n <span id={id} className=\"sr-only\" aria-live=\"polite\">\n {fmt.caption.format(parseDate(m))}\n </span>\n <span className=\"min-w-0 flex-[3]\">\n <Select aria-label=\"Month\" value={String(+m.slice(5, 7))} onChange={(e) => setMonth(`${pad(y, 4)}-${pad(+e.target.value)}-01`)}>\n {Array.from({ length: 12 }, (_, k) => (\n <option key={k} value={k + 1} disabled={monthOff(k)}>\n {fmt.monthShort.format(parseDate(`2023-${pad(k + 1)}-01`))}\n </option>\n ))}\n </Select>\n </span>\n <span className=\"min-w-0 flex-[2]\">\n <Select aria-label=\"Year\" value={String(y)} onChange={(e) => setMonth(`${pad(+e.target.value, 4)}${m.slice(4)}`)}>\n {Array.from({ length: hi - lo + 1 }, (_, k) => (\n <option key={k} value={lo + k}>\n {fmt.year.format(parseDate(`${pad(lo + k, 4)}-06-15`))}\n </option>\n ))}\n </Select>\n </span>\n </div>\n );\n }\n return (\n <div id={id} aria-live={i === 0 ? \"polite\" : undefined} className={cx(\"flex items-center justify-center px-12 font-medium\", s.caption)}>\n {fmt.caption.format(parseDate(m))}\n </div>\n );\n }\n\n function Month({ m, i }) {\n const lead = (dayOfWeek(m) - ws + 7) % 7;\n const start = addDays(m, -lead);\n const dim = +endOfMonth(m).slice(8);\n const rows = Math.ceil((lead + dim) / 7);\n return (\n <div className=\"grid content-start gap-2\">\n {Caption({ m, i })}\n <table role=\"grid\" aria-labelledby={`${uid}-cap-${i}`} className=\"border-collapse\" onKeyDown={onKeyDown}>\n <thead>\n <tr>\n {showWeekNumbers ? (\n <th scope=\"col\" className=\"w-8 text-xs font-normal text-muted-foreground\">\n <span className=\"sr-only\">Week</span>\n </th>\n ) : null}\n {weekdays.map((w) => (\n <th key={w.long} scope=\"col\" abbr={w.long} className=\"h-8 p-0 text-xs font-normal text-muted-foreground\">\n <span aria-hidden=\"true\">{w.short}</span>\n <span className=\"sr-only\">{w.long}</span>\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {Array.from({ length: rows }, (_, r) => {\n const rowStart = addDays(start, r * 7);\n return (\n <tr key={rowStart}>\n {showWeekNumbers ? (\n <th scope=\"row\" className=\"w-8 p-0 text-center text-xs font-normal tabular-nums text-muted-foreground\">\n {isoWeek(addDays(rowStart, (4 - dayOfWeek(rowStart) + 7) % 7))}\n </th>\n ) : null}\n {Array.from({ length: 7 }, (_, c) => Day({ d: addDays(rowStart, c), m }))}\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n );\n }\n\n function Day({ d, m }) {\n const outside = startOfMonth(d) !== m;\n if (outside && !showOutsideDays) return <td key={d} className=\"p-0\" />;\n const off = isDisabled(d);\n const empty = !off && isEmpty(d);\n const picked = mode === \"range\" ? d === range.from || d === range.to : chosen.includes(d);\n // Beside another month, an outside day repeats a day shown there: only\n // the real one carries the band.\n const band = ends && ends[0] < ends[1] && d >= ends[0] && d <= ends[1] && !(outside && n > 1);\n const isToday = d === today;\n const mark = marks[d];\n const tone = mark ? (DOT[mark.tone] ? mark.tone : \"primary\") : null;\n // cx does not merge: one text colour and one weight per day.\n const quiet = !picked && !band && !outside && ((emphasizeMarked && !mark) || empty);\n return (\n <td\n key={d}\n aria-selected={picked || (band && !previewing) || undefined}\n className={cx(\n \"p-0 text-center\",\n band && (previewing ? \"bg-accent/50\" : \"bg-accent\"),\n band && d === ends[0] && \"rounded-l-md\",\n band && d === ends[1] && \"rounded-r-md\",\n )}\n >\n <button\n type=\"button\"\n data-day={d}\n data-outside={outside || undefined}\n tabIndex={!outside && d === tabbable ? 0 : -1}\n aria-label={[fmt.full.format(parseDate(d)), isToday && \"today\", mark?.label].filter(Boolean).join(\", \")}\n aria-current={isToday ? \"date\" : undefined}\n aria-disabled={off || empty || undefined}\n onClick={() => {\n if (off || empty) return;\n pick(d);\n setFocus(d);\n if (!inView(d)) setMonth(startOfMonth(d) < first ? startOfMonth(d) : addMonths(startOfMonth(d), -(n - 1)));\n }}\n onMouseEnter={() => mode === \"range\" && setHover(d)}\n onFocus={() => mode === \"range\" && setHover(d)}\n className={cx(\n \"relative inline-flex items-center justify-center rounded-md text-sm tabular-nums outline-none\",\n \"focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring\",\n s.cell,\n picked\n ? \"bg-primary font-medium text-primary-foreground\"\n : band\n ? \"text-accent-foreground hover:bg-accent\"\n : quiet\n ? \"text-muted-foreground\"\n : \"text-foreground\",\n !picked && !band && !empty && \"hover:bg-accent\",\n emphasizeMarked && mark && !picked && !isToday && \"font-semibold\",\n outside && !picked && \"text-muted-foreground opacity-60\",\n outside && picked && \"opacity-70\",\n off && \"cursor-not-allowed line-through opacity-40 hover:bg-transparent\",\n empty && \"cursor-default\",\n // Today: a ring and a heavier number — never colour alone.\n isToday && \"font-semibold ring-1 ring-inset ring-muted-foreground/60\",\n )}\n >\n {fmt.day.format(parseDate(d))}\n {tone ? (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"absolute bottom-1 left-1/2 size-1.5 -translate-x-1/2 rounded-full\",\n picked && (tone === \"primary\" || tone === \"neutral\") ? \"bg-primary-foreground\" : DOT[tone],\n )}\n />\n ) : null}\n </button>\n </td>\n );\n }\n\n return (\n <div\n ref={root}\n role=\"group\"\n aria-label={label}\n onMouseLeave={() => setHover(null)}\n onBlur={(e) => !root.current?.contains(e.relatedTarget) && setHover(null)}\n className={cx(\"relative inline-block max-w-full text-sm\", className)}\n >\n {captionLayout === \"none\" ? null : (\n <>\n <IconButton\n icon=\"chevron-left\"\n label=\"Previous month\"\n size={s.nav}\n disabled={prevOff}\n onClick={() => setMonth(addMonths(first, -1))}\n className=\"absolute left-0 top-0 z-10\"\n />\n <IconButton\n icon=\"chevron-right\"\n label=\"Next month\"\n size={s.nav}\n disabled={nextOff}\n onClick={() => setMonth(addMonths(first, 1))}\n className=\"absolute right-0 top-0 z-10\"\n />\n </>\n )}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:gap-6\">\n {months.map((m, i) => (\n <div key={m}>{Month({ m, i })}</div>\n ))}\n </div>\n </div>\n );\n}\n"
66
+ "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cx } from \"./cx\";\nimport { IconButton } from \"./button\";\nimport { Select } from \"./field\";\n\n// Calendar — a month grid to pick a day, several days, or a span of days.\n// It is the WAI-ARIA date grid: one Tab stop (the focused day), arrows\n// move a day or a week, Page Up/Down a month (with Shift, a year), Home\n// and End the week's edges, Enter or Space picks. Names, week start and\n// digits come from Intl for the reader's locale. No date library.\n//\n// mode single (value \"2026-03-12\" | null) · multiple (value\n// [\"2026-03-12\", …], kept sorted) · range (value { from, to }\n// | null; `to` is null until the second click; a second click\n// before `from` swaps them)\n// value, onChange(value) — controlled; or `defaultValue`\n// required single: clicking the chosen day again keeps it (default:\n// it clears)\n// month the month shown, \"2026-03\" (controlled, with onMonthChange);\n// or `defaultMonth`; else the first chosen day's, else today's\n// numberOfMonths side by side from 640px, stacked on a phone\n// min, max \"YYYY-MM-DD\", inclusive; days past them are disabled and\n// the arrows stop there\n// disabled (date) → true for a day that cannot be picked (weekends,\n// booked days); true disables every day\n// captionLayout label (default) · dropdown (month and year Selects for\n// far jumps; the arrows stay) · none (no caption and no\n// arrows: the parent shows the month and moves it, passing a\n// controlled `month`; a screen reader still hears the month)\n// fromYear, toYear the dropdown's years (default: min/max, else today\n// −100 … +10)\n// showOutsideDays the neighbouring months' days, muted (true)\n// showWeekNumbers ISO 8601 week numbers down the side\n// marked days that hold something (posts, bookings): a list of\n// \"YYYY-MM-DD\", or { \"2026-03-04\": { tone, label } } — a dot\n// under the number in `tone` (primary, the default · neutral ·\n// success · warning · danger · info); `label` joins the day's\n// name for a screen reader (\"Wednesday, March 4, 2026, 3\n// posts\"). Selection is unchanged: open the day's items from\n// onChange\n// emphasizeMarked marked days drawn bold, the rest muted — a calendar\n// that exists to show where the posts are (false)\n// onlyMarked unmarked days cannot be picked, with no strike-through: they\n// are empty, not forbidden (that stays `disabled`'s look).\n// Keys still walk them (false)\n// weekStartsOn 0 (Sunday) … 6; default: the locale's\n// locale a BCP 47 tag; default: the browser's\n// today \"YYYY-MM-DD\", drawn ringed; default: the browser's today.\n// Pass it where a screen must not change from day to day (a\n// demo, a test)\n// size md (44px days) · sm (36px days from 640px, 44px on a phone)\n// autoFocus when it turns true, focus moves to the grid (a picker\n// passes its open state)\n// label what a screen reader calls the whole calendar (\"Calendar\")\n//\n// Dates are plain \"YYYY-MM-DD\" strings in and out, never Date objects. A\n// date is a day on a wall calendar, not an instant: a Date is an instant,\n// and \"2026-03-12\" parsed as one is midnight UTC — the 11th in New York.\n// Strings compare in order (\"2026-03-02\" < \"2026-03-12\"), survive JSON\n// and a database's `date` column unchanged, and mean the same day in\n// every timezone. Inside, arithmetic runs on UTC midnights, where no\n// daylight-saving shift can add or lose a day. The helpers below are\n// exported for the same work in a surface.\n\n// ── Dates as strings ────────────────────────────────────────────────────\n\nconst ISO = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\nconst DAY = 86400000;\nconst pad = (n, w = 2) => String(n).padStart(w, \"0\");\n\n/** \"YYYY-MM-DD\" → a Date at that day's UTC midnight; null when it is not\n * a real day (\"2026-02-30\", \"\", undefined). */\nexport function parseDate(s) {\n const m = typeof s === \"string\" ? ISO.exec(s) : null;\n if (!m) return null;\n const d = new Date(0);\n d.setUTCFullYear(+m[1], +m[2] - 1, +m[3]);\n return d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] ? d : null;\n}\n\nconst iso = (d) => `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;\n\n/** True for a real \"YYYY-MM-DD\" day. */\nexport const isDateString = (s) => parseDate(s) !== null;\n\n/** A JS Date → the \"YYYY-MM-DD\" it is on the reader's own calendar\n * (local time: new Date() at 23:30 in Tokyo is Tokyo's day). */\nexport function toDateString(date = new Date()) {\n return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;\n}\n\n/** Today on the reader's calendar, \"YYYY-MM-DD\". */\nexport const todayString = () => toDateString(new Date());\n\n/** The day `n` days on (negative: back). */\nexport function addDays(s, n) {\n const d = parseDate(s);\n return d ? iso(new Date(d.getTime() + n * DAY)) : null;\n}\n\n/** The same day `n` months on, clamped to the month's end (Jan 31 + 1 →\n * Feb 28). */\nexport function addMonths(s, n) {\n const d = parseDate(s);\n if (!d) return null;\n const y = d.getUTCFullYear();\n const m = d.getUTCMonth() + n;\n const last = new Date(Date.UTC(2000, 0, 1));\n last.setUTCFullYear(y, m + 1, 0);\n const out = new Date(0);\n out.setUTCFullYear(y, m, Math.min(d.getUTCDate(), last.getUTCDate()));\n return iso(out);\n}\n\n/** The first day of `s`'s month; \"2026-03\" works as well as a full date. */\nexport const startOfMonth = (s) => (typeof s === \"string\" && /^\\d{4}-\\d{2}/.test(s) ? `${s.slice(0, 7)}-01` : null);\n\n/** The last day of `s`'s month. */\nexport const endOfMonth = (s) => {\n const first = startOfMonth(s);\n return first ? addDays(addMonths(first, 1), -1) : null;\n};\n\n/** The day of the week, 0 (Sunday) … 6. */\nexport const dayOfWeek = (s) => parseDate(s)?.getUTCDay() ?? null;\n\n// Intl's spacing differs between ICU builds (Node's thin and no-break\n// spaces around a range's dash, a browser's plain ones): the server's\n// HTML and the browser's first render must be the same text, so every\n// formatted string is written with plain spaces.\nexport const plainSpaces = (s) => s.replace(/[\\u00a0\\u2009\\u202f]/g, \" \");\n\n/** A day in words for the locale: formatDate(\"2026-03-12\") → \"Mar 12,\n * 2026\"; `options` are Intl.DateTimeFormat's ({ dateStyle: \"full\" }). */\nexport function formatDate(s, options = { dateStyle: \"medium\" }, locale) {\n const d = parseDate(s);\n return d ? plainSpaces(new Intl.DateTimeFormat(locale, { ...options, timeZone: \"UTC\" }).format(d)) : \"\";\n}\n\n/** Any date a row holds → a Date to show in the reader's own time; null\n * when it is none. A day (\"2026-09-28\", what a `date` column reads as)\n * is that day's LOCAL midnight: new Date(\"2026-09-28\") is UTC's\n * midnight, the 27th everywhere west of Greenwich, and a screen that\n * shows it, then saves what it shows, moves the day back by one. A Date\n * stays as it is; an ISO timestamp or epoch milliseconds go through\n * new Date(). For a day alone, formatDate and the helpers above need no\n * Date at all; this is for code that mixes days with instants (a\n * comment's time, \"due\" beside \"sent at\"). */\nexport function toLocalDate(value) {\n if (value === null || value === undefined || value === \"\") return null;\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;\n const day = typeof value === \"string\" ? parseDate(value) : null;\n if (day) {\n // setFullYear, not new Date(y, m, d): that one reads years 0–99 as 19xx.\n const local = new Date(0);\n local.setFullYear(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());\n local.setHours(0, 0, 0, 0);\n return local;\n }\n const d = new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\n}\n\n// The week's first day for a locale: Intl's own answer where the browser\n// has it (getWeekInfo), else CLDR's regions that start on Sunday or\n// Saturday; everyone else starts on Monday.\nconst SUNDAY = new Set(\"AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW\".split(\" \"));\nconst SATURDAY = new Set(\"AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY\".split(\" \"));\n\n/** 0 (Sunday) … 6: the day a week starts on in `locale`. */\nexport function firstDayOfWeek(locale) {\n try {\n const loc = new Intl.Locale(locale ?? Intl.DateTimeFormat().resolvedOptions().locale);\n const info = loc.getWeekInfo?.() ?? loc.weekInfo;\n if (info?.firstDay) return info.firstDay % 7;\n const region = loc.maximize().region;\n return SUNDAY.has(region) ? 0 : SATURDAY.has(region) ? 6 : 1;\n } catch {\n return 1;\n }\n}\n\n// ISO 8601 week number of the week holding `thursday` (a Thursday's\n// week number is its day of the year, in sevens, rounded up).\nfunction isoWeek(thursday) {\n const d = parseDate(thursday);\n const jan1 = Date.UTC(d.getUTCFullYear(), 0, 1);\n return Math.floor((d.getTime() - jan1) / DAY / 7) + 1;\n}\n\n// ── The reader's locale and today, without a hydration mismatch ────────\n// The server does not know either. useSyncExternalStore renders the\n// server's answer (null) while hydrating, then the browser's — so the\n// first paint matches the HTML and nothing warns.\n\nconst never = () => () => {};\nfunction useBrowser(get) {\n return useSyncExternalStore(never, get, () => null);\n}\n\n/** The locale to format in: `locale`, else the browser's (after\n * hydration; \"en-US\" in the server's HTML). */\nexport function useLocale(locale) {\n const browser = useBrowser(() => navigator.language);\n return locale ?? browser ?? \"en-US\";\n}\n\n/** `today`, else the browser's today once hydrated (null before). */\nexport function useToday(today) {\n const browser = useBrowser(todayString);\n return today ?? browser;\n}\n\n// ── The grid ────────────────────────────────────────────────────────────\n\n// A marked day's dot. On a picked day (the primary fill) the primary and\n// neutral dots turn to the fill's own foreground so they still show.\nconst DOT = {\n primary: \"bg-primary\",\n neutral: \"bg-muted-foreground\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n info: \"bg-info\",\n};\n\nconst SIZES = {\n md: { cell: \"size-11\", caption: \"h-11\", nav: \"md\" },\n sm: { cell: \"size-11 sm:size-9\", caption: \"h-11 sm:h-9\", nav: \"sm\" },\n};\n\nfunction useControlled(value, fallback) {\n const [own, setOwn] = useState(fallback);\n return value !== undefined ? [value, false] : [own, setOwn];\n}\n\nfunction listOf(mode, value) {\n if (!value) return [];\n if (mode === \"multiple\") return Array.isArray(value) ? value : [];\n if (mode === \"range\") return [value.from, value.to].filter(Boolean);\n return [value];\n}\n\nexport function Calendar({\n mode = \"single\",\n value: valueProp,\n defaultValue,\n onChange,\n required = false,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n numberOfMonths = 1,\n min,\n max,\n disabled,\n captionLayout = \"label\",\n fromYear,\n toYear,\n showOutsideDays = true,\n showWeekNumbers = false,\n marked,\n emphasizeMarked = false,\n onlyMarked = false,\n weekStartsOn,\n locale: localeProp,\n today: todayProp,\n size = \"md\",\n autoFocus = false,\n label = \"Calendar\",\n className,\n}) {\n const uid = useId();\n const root = useRef(null);\n const pending = useRef(false);\n const locale = useLocale(localeProp);\n const today = useToday(todayProp);\n const s = SIZES[size] ?? SIZES.md;\n const n = Math.max(1, numberOfMonths | 0);\n\n const [own, setOwnValue] = useControlled(valueProp, defaultValue ?? (mode === \"multiple\" ? [] : null));\n const value = valueProp !== undefined ? valueProp : own;\n const chosen = listOf(mode, value);\n\n const [ownMonth, setOwnMonth] = useState(() => startOfMonth(defaultMonth) ?? startOfMonth(chosen[0]));\n const first = startOfMonth(monthProp) ?? ownMonth ?? startOfMonth(today);\n const last = first ? addMonths(first, n - 1) : null;\n const [focus, setFocus] = useState(null);\n const [hover, setHover] = useState(null);\n\n const marks = useMemo(() => {\n if (Array.isArray(marked)) return Object.fromEntries(marked.map((d) => [d, {}]));\n return marked && typeof marked === \"object\" ? marked : {};\n }, [marked]);\n\n const ws = weekStartsOn ?? firstDayOfWeek(locale);\n const fmt = useMemo(() => {\n const f = (o) => {\n const intl = new Intl.DateTimeFormat(locale, { ...o, timeZone: \"UTC\" });\n return { format: (d) => plainSpaces(intl.format(d)) };\n };\n return {\n caption: f({ month: \"long\", year: \"numeric\" }),\n day: f({ day: \"numeric\" }),\n full: f({ dateStyle: \"full\" }),\n monthShort: f({ month: \"short\" }),\n year: f({ year: \"numeric\" }),\n weekShort: f({ weekday: \"short\" }),\n weekLong: f({ weekday: \"long\" }),\n };\n }, [locale]);\n // 2023-01-01 was a Sunday: the seven names from the week's first day.\n const weekdays = useMemo(\n () =>\n Array.from({ length: 7 }, (_, i) => {\n const d = parseDate(addDays(\"2023-01-01\", (ws + i) % 7));\n return { short: fmt.weekShort.format(d), long: fmt.weekLong.format(d) };\n }),\n [fmt, ws],\n );\n\n // Focus follows the roving day after the render that shows it.\n useEffect(() => {\n if (!pending.current) return;\n pending.current = false;\n root.current?.querySelector('button[data-day][tabindex=\"0\"]')?.focus();\n });\n useEffect(() => {\n if (!autoFocus) return;\n const go = () => root.current?.querySelector('button[data-day][tabindex=\"0\"]')?.focus({ preventScroll: true });\n // Inside a popover not shown yet (one open on load), wait for it: the\n // Popover focuses its first control on toggle, and the day wins after.\n const pop = root.current?.closest(\"[popover]\");\n if (pop && !pop.matches(\":popover-open\")) {\n let t;\n const on = () => (t = setTimeout(go));\n pop.addEventListener(\"toggle\", on, { once: true });\n return () => {\n pop.removeEventListener(\"toggle\", on);\n clearTimeout(t);\n };\n }\n go();\n }, [autoFocus]);\n\n function setMonth(m) {\n if (monthProp === undefined) setOwnMonth(m);\n onMonthChange?.(m.slice(0, 7));\n }\n\n function setValue(v) {\n if (setOwnValue) setOwnValue(v);\n onChange?.(v);\n }\n\n const isDisabled = (d) =>\n disabled === true || (min && d < min) || (max && d > max) || (typeof disabled === \"function\" && !!disabled(d));\n const inView = (d) => !!(d && first && startOfMonth(d) >= first && startOfMonth(d) <= last);\n\n const isEmpty = (d) => onlyMarked && !marks[d];\n\n function pick(d) {\n if (isDisabled(d) || isEmpty(d)) return;\n if (mode === \"multiple\") {\n setValue(chosen.includes(d) ? chosen.filter((x) => x !== d) : [...chosen, d].sort());\n } else if (mode === \"range\") {\n const { from, to } = value ?? {};\n if (!from || to) setValue({ from: d, to: null });\n else if (d < from) setValue({ from: d, to: from });\n else setValue({ from, to: d });\n } else {\n setValue(value === d && !required ? null : d);\n }\n }\n\n function moveTo(d) {\n if (min && d < min) d = min;\n if (max && d > max) d = max;\n const m = startOfMonth(d);\n if (m < first) setMonth(m);\n else if (m > last) setMonth(addMonths(m, -(n - 1)));\n setFocus(d);\n pending.current = true;\n }\n\n function onKeyDown(e) {\n const day = e.target.dataset?.day;\n if (!day || e.altKey || e.ctrlKey || e.metaKey) return;\n const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n const back = (dayOfWeek(day) - ws + 7) % 7;\n const step = {\n ArrowLeft: () => addDays(day, rtl ? 1 : -1),\n ArrowRight: () => addDays(day, rtl ? -1 : 1),\n ArrowUp: () => addDays(day, -7),\n ArrowDown: () => addDays(day, 7),\n PageUp: () => addMonths(day, e.shiftKey ? -12 : -1),\n PageDown: () => addMonths(day, e.shiftKey ? 12 : 1),\n Home: () => addDays(day, -back),\n End: () => addDays(day, 6 - back),\n }[e.key];\n if (!step) return;\n e.preventDefault();\n moveTo(step());\n }\n\n // Range display: the committed span, or — between the two clicks —\n // a preview from `from` to the day under the pointer or focus.\n const range = mode === \"range\" ? value ?? {} : {};\n const previewing = !!(range.from && !range.to && hover);\n const ends = range.from ? [range.from, range.to ?? (previewing ? hover : range.from)].sort() : null;\n\n const tabbable = [focus, ...chosen, today].find((d) => inView(d) && !(min && d < min) && !(max && d > max)) ?? (inView(min) ? min : first);\n\n if (!first) {\n // Hydrating with no month to show yet: hold the space.\n return <div ref={root} role=\"group\" aria-label={label} aria-busy=\"true\" className={cx(\"min-h-80 min-w-72\", className)} />;\n }\n\n const months = Array.from({ length: n }, (_, i) => addMonths(first, i));\n const prevOff = !!(min && startOfMonth(min) >= first);\n const nextOff = !!(max && startOfMonth(max) <= last);\n\n function Caption({ m, i }) {\n const id = `${uid}-cap-${i}`;\n if (captionLayout === \"none\") {\n return (\n <span id={id} className=\"sr-only\" aria-live={i === 0 ? \"polite\" : undefined}>\n {fmt.caption.format(parseDate(m))}\n </span>\n );\n }\n if (captionLayout === \"dropdown\" && i === 0) {\n const y = +m.slice(0, 4);\n const ty = +(today ?? m).slice(0, 4);\n const lo = Math.min(fromYear ?? (min ? +min.slice(0, 4) : ty - 100), y);\n const hi = Math.max(toYear ?? (max ? +max.slice(0, 4) : ty + 10), y);\n const monthOff = (k) => {\n const mm = `${pad(y, 4)}-${pad(k + 1)}-01`;\n return (min && endOfMonth(mm) < min) || (max && mm > max);\n };\n return (\n <div className={cx(\"flex items-center justify-center gap-1 px-12\", s.caption)}>\n <span id={id} className=\"sr-only\" aria-live=\"polite\">\n {fmt.caption.format(parseDate(m))}\n </span>\n <span className=\"min-w-0 flex-[3]\">\n <Select aria-label=\"Month\" value={String(+m.slice(5, 7))} onChange={(e) => setMonth(`${pad(y, 4)}-${pad(+e.target.value)}-01`)}>\n {Array.from({ length: 12 }, (_, k) => (\n <option key={k} value={k + 1} disabled={monthOff(k)}>\n {fmt.monthShort.format(parseDate(`2023-${pad(k + 1)}-01`))}\n </option>\n ))}\n </Select>\n </span>\n <span className=\"min-w-0 flex-[2]\">\n <Select aria-label=\"Year\" value={String(y)} onChange={(e) => setMonth(`${pad(+e.target.value, 4)}${m.slice(4)}`)}>\n {Array.from({ length: hi - lo + 1 }, (_, k) => (\n <option key={k} value={lo + k}>\n {fmt.year.format(parseDate(`${pad(lo + k, 4)}-06-15`))}\n </option>\n ))}\n </Select>\n </span>\n </div>\n );\n }\n return (\n <div id={id} aria-live={i === 0 ? \"polite\" : undefined} className={cx(\"flex items-center justify-center px-12 font-medium\", s.caption)}>\n {fmt.caption.format(parseDate(m))}\n </div>\n );\n }\n\n function Month({ m, i }) {\n const lead = (dayOfWeek(m) - ws + 7) % 7;\n const start = addDays(m, -lead);\n const dim = +endOfMonth(m).slice(8);\n const rows = Math.ceil((lead + dim) / 7);\n return (\n <div className=\"grid content-start gap-2\">\n {Caption({ m, i })}\n <table role=\"grid\" aria-labelledby={`${uid}-cap-${i}`} className=\"border-collapse\" onKeyDown={onKeyDown}>\n <thead>\n <tr>\n {showWeekNumbers ? (\n <th scope=\"col\" className=\"w-8 text-xs font-normal text-muted-foreground\">\n <span className=\"sr-only\">Week</span>\n </th>\n ) : null}\n {weekdays.map((w) => (\n <th key={w.long} scope=\"col\" abbr={w.long} className=\"h-8 p-0 text-xs font-normal text-muted-foreground\">\n <span aria-hidden=\"true\">{w.short}</span>\n <span className=\"sr-only\">{w.long}</span>\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {Array.from({ length: rows }, (_, r) => {\n const rowStart = addDays(start, r * 7);\n return (\n <tr key={rowStart}>\n {showWeekNumbers ? (\n <th scope=\"row\" className=\"w-8 p-0 text-center text-xs font-normal tabular-nums text-muted-foreground\">\n {isoWeek(addDays(rowStart, (4 - dayOfWeek(rowStart) + 7) % 7))}\n </th>\n ) : null}\n {Array.from({ length: 7 }, (_, c) => Day({ d: addDays(rowStart, c), m }))}\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n );\n }\n\n function Day({ d, m }) {\n const outside = startOfMonth(d) !== m;\n if (outside && !showOutsideDays) return <td key={d} className=\"p-0\" />;\n const off = isDisabled(d);\n const empty = !off && isEmpty(d);\n const picked = mode === \"range\" ? d === range.from || d === range.to : chosen.includes(d);\n // Beside another month, an outside day repeats a day shown there: only\n // the real one carries the band.\n const band = ends && ends[0] < ends[1] && d >= ends[0] && d <= ends[1] && !(outside && n > 1);\n const isToday = d === today;\n const mark = marks[d];\n const tone = mark ? (DOT[mark.tone] ? mark.tone : \"primary\") : null;\n // cx does not merge: one text colour and one weight per day.\n const quiet = !picked && !band && !outside && ((emphasizeMarked && !mark) || empty);\n return (\n <td\n key={d}\n aria-selected={picked || (band && !previewing) || undefined}\n className={cx(\n \"p-0 text-center\",\n band && (previewing ? \"bg-accent/50\" : \"bg-accent\"),\n band && d === ends[0] && \"rounded-l-md\",\n band && d === ends[1] && \"rounded-r-md\",\n )}\n >\n <button\n type=\"button\"\n data-day={d}\n data-outside={outside || undefined}\n tabIndex={!outside && d === tabbable ? 0 : -1}\n aria-label={[fmt.full.format(parseDate(d)), isToday && \"today\", mark?.label].filter(Boolean).join(\", \")}\n aria-current={isToday ? \"date\" : undefined}\n aria-disabled={off || empty || undefined}\n onClick={() => {\n if (off || empty) return;\n pick(d);\n setFocus(d);\n if (!inView(d)) setMonth(startOfMonth(d) < first ? startOfMonth(d) : addMonths(startOfMonth(d), -(n - 1)));\n }}\n onMouseEnter={() => mode === \"range\" && setHover(d)}\n onFocus={() => mode === \"range\" && setHover(d)}\n className={cx(\n \"relative inline-flex items-center justify-center rounded-md text-sm tabular-nums outline-none\",\n \"focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring\",\n s.cell,\n picked\n ? \"bg-primary font-medium text-primary-foreground\"\n : band\n ? \"text-accent-foreground hover:bg-accent\"\n : quiet\n ? \"text-muted-foreground\"\n : \"text-foreground\",\n !picked && !band && !empty && \"hover:bg-accent\",\n emphasizeMarked && mark && !picked && !isToday && \"font-semibold\",\n outside && !picked && \"text-muted-foreground opacity-60\",\n outside && picked && \"opacity-70\",\n off && \"cursor-not-allowed line-through opacity-40 hover:bg-transparent\",\n empty && \"cursor-default\",\n // Today: a ring and a heavier number — never colour alone.\n isToday && \"font-semibold ring-1 ring-inset ring-muted-foreground/60\",\n )}\n >\n {fmt.day.format(parseDate(d))}\n {tone ? (\n <span\n aria-hidden=\"true\"\n className={cx(\n \"absolute bottom-1 left-1/2 size-1.5 -translate-x-1/2 rounded-full\",\n picked && (tone === \"primary\" || tone === \"neutral\") ? \"bg-primary-foreground\" : DOT[tone],\n )}\n />\n ) : null}\n </button>\n </td>\n );\n }\n\n return (\n <div\n ref={root}\n role=\"group\"\n aria-label={label}\n onMouseLeave={() => setHover(null)}\n onBlur={(e) => !root.current?.contains(e.relatedTarget) && setHover(null)}\n className={cx(\"relative inline-block max-w-full text-sm\", className)}\n >\n {captionLayout === \"none\" ? null : (\n <>\n <IconButton\n icon=\"chevron-left\"\n label=\"Previous month\"\n size={s.nav}\n disabled={prevOff}\n onClick={() => setMonth(addMonths(first, -1))}\n className=\"absolute left-0 top-0 z-10\"\n />\n <IconButton\n icon=\"chevron-right\"\n label=\"Next month\"\n size={s.nav}\n disabled={nextOff}\n onClick={() => setMonth(addMonths(first, 1))}\n className=\"absolute right-0 top-0 z-10\"\n />\n </>\n )}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:gap-6\">\n {months.map((m, i) => (\n <div key={m}>{Month({ m, i })}</div>\n ))}\n </div>\n </div>\n );\n}\n"
67
67
  },
68
68
  {
69
69
  "path": "templates/next/files/components/ui/carousel.jsx",
@@ -129,7 +129,7 @@
129
129
  "path": "templates/next/files/components/ui/field.jsx",
130
130
  "type": "registry:ui",
131
131
  "target": "@components/makersclaw/ui/field.jsx",
132
- "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef } from \"react\";\nimport { cx } from \"./cx\";\n\n// Form parts. Each is the native element with the workspace's look and a\n// 44px target; the browser supplies the behaviour (focus, keyboard,\n// validation, autofill). Wrap one in <Field> for its label, hint and\n// error — never a bare <input> with a <div> label beside it.\n\nconst CONTROL =\n \"w-full min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground \" +\n \"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring \" +\n \"disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive\";\n\n/** Label, control, hint and error in one stack. Pass `id`; the label\n * points at it. `error` (a string) marks the control invalid. */\nexport function Field({ id, label, hint, error, required, className, children }) {\n return (\n <div className={cx(\"grid gap-1.5\", className)}>\n {label ? (\n <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n {label}\n {required ? <span aria-hidden=\"true\" className=\"text-destructive\"> *</span> : null}\n </label>\n ) : null}\n {children}\n {error ? (\n <p id={id ? `${id}-error` : undefined} role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : hint ? (\n <p id={id ? `${id}-hint` : undefined} className=\"text-xs text-muted-foreground\">\n {hint}\n </p>\n ) : null}\n </div>\n );\n}\n\nexport function Input({ className, invalid, ...rest }) {\n return <input className={cx(CONTROL, className)} aria-invalid={invalid ? true : undefined} {...rest} />;\n}\n\n/**\n * A time of day: the native <input type=\"time\"> (the browser's segments,\n * keyboard and picker; `value` is \"HH:MM\", or \"HH:MM:SS\" with `step` under\n * 60), in the workspace's look. The browser's own clock button is drawn\n * by the engine in its own grey and ignores the theme, so it is laid\n * invisibly over the kit's clock glyph: the glyph is what shows, the\n * native button is what a press opens. min, max, step, required and every\n * other prop go to the input.\n */\nexport function TimeInput({ className, invalid, ...rest }) {\n return (\n <span className=\"relative block\">\n <input\n type=\"time\"\n className={cx(\n CONTROL,\n \"appearance-none pr-10 tabular-nums [&::-webkit-date-and-time-value]:text-left\",\n \"[&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:right-3 [&::-webkit-calendar-picker-indicator]:top-1/2\",\n \"[&::-webkit-calendar-picker-indicator]:size-5 [&::-webkit-calendar-picker-indicator]:-translate-y-1/2 [&::-webkit-calendar-picker-indicator]:m-0\",\n \"[&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0\",\n className,\n )}\n aria-invalid={invalid ? true : undefined}\n {...rest}\n />\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5l3 3\" />\n </svg>\n </span>\n );\n}\n\n/**\n * Keeps a <textarea> as tall as its text, between `minRows` and `maxRows`\n * lines; past `maxRows` it scrolls. Returns the ref callback to put on the\n * textarea (it also sets `outer`, a ref of the caller's). It measures on\n * every value change, typed or set (a composer clearing after send), and\n * when the width changes the wrapping. Textarea autoGrow and InputGroup\n * multiline use it; it is not a surface's tool.\n */\nexport function useAutoGrow({ enabled = true, minRows = 1, maxRows = 8, value, outer }) {\n const node = useRef(null);\n\n const fit = useCallback(() => {\n const el = node.current;\n if (!el || !enabled) return;\n const cs = getComputedStyle(el);\n const line = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5 || 20;\n const frame =\n parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) +\n (cs.boxSizing === \"border-box\" ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) : 0);\n const low = line * Math.max(1, minRows) + frame;\n const high = Number.isFinite(maxRows) && maxRows > 0 ? line * Math.max(minRows, maxRows) + frame : Infinity;\n el.style.height = \"auto\";\n const content = el.scrollHeight + (cs.boxSizing === \"border-box\" ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) : 0);\n el.style.height = `${Math.min(Math.max(content, low), high)}px`;\n el.style.overflowY = content > high ? \"auto\" : \"hidden\";\n }, [enabled, minRows, maxRows]);\n\n useLayoutEffect(fit, [fit, value]);\n\n // A width change re-wraps the text; a height change is our own doing.\n useLayoutEffect(() => {\n const el = node.current;\n if (!el || !enabled || typeof ResizeObserver === \"undefined\") return;\n let width = el.clientWidth;\n const ro = new ResizeObserver(() => {\n if (el.clientWidth === width) return;\n width = el.clientWidth;\n fit();\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, [enabled, fit]);\n\n const ref = useCallback(\n (el) => {\n node.current = el;\n if (typeof outer === \"function\") return outer(el);\n if (outer) outer.current = el;\n },\n [outer],\n );\n return { ref, fit };\n}\n\n/**\n * The native <textarea>. `rows` sets its height (default 4) and it\n * resizes by its corner. `autoGrow` makes it as tall as its text instead:\n * from `minRows` (default 1) to `maxRows` (default 8) lines, then it\n * scrolls; no resize corner.\n */\nexport function Textarea({ className, invalid, rows = 4, autoGrow = false, minRows = 1, maxRows = 8, onInput, ref, ...rest }) {\n const grow = useAutoGrow({ enabled: autoGrow, minRows, maxRows, value: rest.value, outer: ref });\n return (\n <textarea\n ref={grow.ref}\n rows={autoGrow ? minRows : rows}\n className={cx(CONTROL, autoGrow ? \"resize-none py-2.5 leading-normal\" : \"min-h-24 resize-y py-2.5 leading-normal\", className)}\n aria-invalid={invalid ? true : undefined}\n onInput={(e) => {\n if (autoGrow) grow.fit();\n onInput?.(e);\n }}\n {...rest}\n />\n );\n}\n\n/**\n * The native <select>, styled. `options` is [{ value, label }]; children\n * work too. A select with nothing to choose from is not a control — when\n * `options` is empty it renders `empty` (a short muted line, or nothing\n * when `empty` is null) rather than a disabled dropdown. For \"pick a\n * saved record or make a new one\" use RecordSwitcher (components/parts).\n */\nexport function Select({ options, empty = \"Nothing to choose yet.\", className, invalid, children, ...rest }) {\n if (options && options.length === 0 && !children) {\n return empty ? <p className=\"text-sm text-muted-foreground\">{empty}</p> : null;\n }\n return (\n <span className=\"relative block\">\n <select\n className={cx(CONTROL, \"appearance-none pr-9\", className)}\n aria-invalid={invalid ? true : undefined}\n {...rest}\n >\n {options ? options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>) : children}\n </select>\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </span>\n );\n}\n\n/** An on/off. A checkbox underneath (role=switch), so forms, labels and\n * keyboards work; the track is drawn from tokens. */\nexport function Switch({ id, checked, onChange, label, disabled, className }) {\n return (\n <label htmlFor={id} className={cx(\"inline-flex min-h-11 cursor-pointer items-center gap-3 text-sm\", disabled && \"cursor-not-allowed opacity-50\", className)}>\n <span className=\"relative inline-flex h-6 w-10 shrink-0\">\n <input\n id={id}\n type=\"checkbox\"\n role=\"switch\"\n className=\"peer sr-only\"\n checked={checked}\n onChange={onChange}\n disabled={disabled}\n />\n <span className=\"absolute inset-0 rounded-full border border-input bg-muted transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring\" />\n <span className=\"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-foreground transition-transform peer-checked:translate-x-4 peer-checked:bg-primary-foreground\" />\n </span>\n {label ? <span>{label}</span> : null}\n </label>\n );\n}\n\nexport function Checkbox({ id, label, className, ...rest }) {\n return (\n <label htmlFor={id} className={cx(\"inline-flex min-h-11 cursor-pointer items-center gap-2 text-sm\", className)}>\n <input\n id={id}\n type=\"checkbox\"\n className=\"h-4 w-4 rounded-sm border-input accent-[var(--color-primary)] outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n {...rest}\n />\n {label ? <span>{label}</span> : null}\n </label>\n );\n}\n"
132
+ "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef } from \"react\";\nimport { cx } from \"./cx\";\n\n// Form parts. Each is the native element with the workspace's look and a\n// 44px target; the browser supplies the behaviour (focus, keyboard,\n// validation, autofill). Wrap one in <Field> for its label, hint and\n// error — never a bare <input> with a <div> label beside it.\n\nconst BOX =\n \"min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground \" +\n \"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring \" +\n \"disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive\";\nconst CONTROL = `w-full ${BOX}`;\n\n// An Input is as wide as its column unless the caller's className gives\n// it a width (w-24, w-auto, w-[12rem], size-10), which then stands in\n// for w-full. cx does not merge classes: with both, the rule later in the\n// stylesheet wins, and Tailwind writes w-full after w-24, w-auto, w-fit\n// and w-[…] — until 0.5.1 those did nothing on an Input. A width under a\n// breakpoint (sm:w-40) keeps w-full below it.\nconst ownWidth = (className) => /(?:^|\\s)!?(?:w|size)-/.test(className ?? \"\");\n\n/** Label, control, hint and error in one stack. Pass `id`; the label\n * points at it. `error` (a string) marks the control invalid. */\nexport function Field({ id, label, hint, error, required, className, children }) {\n return (\n <div className={cx(\"grid gap-1.5\", className)}>\n {label ? (\n <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n {label}\n {required ? <span aria-hidden=\"true\" className=\"text-destructive\"> *</span> : null}\n </label>\n ) : null}\n {children}\n {error ? (\n <p id={id ? `${id}-error` : undefined} role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : hint ? (\n <p id={id ? `${id}-hint` : undefined} className=\"text-xs text-muted-foreground\">\n {hint}\n </p>\n ) : null}\n </div>\n );\n}\n\nexport function Input({ className, invalid, ...rest }) {\n return <input className={cx(!ownWidth(className) && \"w-full\", BOX, className)} aria-invalid={invalid ? true : undefined} {...rest} />;\n}\n\n/**\n * A time of day: the native <input type=\"time\"> (the browser's segments,\n * keyboard and picker; `value` is \"HH:MM\", or \"HH:MM:SS\" with `step` under\n * 60), in the workspace's look. The browser's own clock button is drawn\n * by the engine in its own grey and ignores the theme, so it is laid\n * invisibly over the kit's clock glyph: the glyph is what shows, the\n * native button is what a press opens. min, max, step, required and every\n * other prop go to the input.\n */\nexport function TimeInput({ className, invalid, ...rest }) {\n return (\n <span className=\"relative block\">\n <input\n type=\"time\"\n className={cx(\n CONTROL,\n \"appearance-none pr-10 tabular-nums [&::-webkit-date-and-time-value]:text-left\",\n \"[&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:right-3 [&::-webkit-calendar-picker-indicator]:top-1/2\",\n \"[&::-webkit-calendar-picker-indicator]:size-5 [&::-webkit-calendar-picker-indicator]:-translate-y-1/2 [&::-webkit-calendar-picker-indicator]:m-0\",\n \"[&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0\",\n className,\n )}\n aria-invalid={invalid ? true : undefined}\n {...rest}\n />\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5l3 3\" />\n </svg>\n </span>\n );\n}\n\n/**\n * Keeps a <textarea> as tall as its text, between `minRows` and `maxRows`\n * lines; past `maxRows` it scrolls. Returns the ref callback to put on the\n * textarea (it also sets `outer`, a ref of the caller's). It measures on\n * every value change, typed or set (a composer clearing after send), and\n * when the width changes the wrapping. Textarea autoGrow and InputGroup\n * multiline use it; it is not a surface's tool.\n */\nexport function useAutoGrow({ enabled = true, minRows = 1, maxRows = 8, value, outer }) {\n const node = useRef(null);\n\n const fit = useCallback(() => {\n const el = node.current;\n if (!el || !enabled) return;\n const cs = getComputedStyle(el);\n const line = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5 || 20;\n const frame =\n parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) +\n (cs.boxSizing === \"border-box\" ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) : 0);\n const low = line * Math.max(1, minRows) + frame;\n const high = Number.isFinite(maxRows) && maxRows > 0 ? line * Math.max(minRows, maxRows) + frame : Infinity;\n el.style.height = \"auto\";\n const content = el.scrollHeight + (cs.boxSizing === \"border-box\" ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) : 0);\n el.style.height = `${Math.min(Math.max(content, low), high)}px`;\n el.style.overflowY = content > high ? \"auto\" : \"hidden\";\n }, [enabled, minRows, maxRows]);\n\n useLayoutEffect(fit, [fit, value]);\n\n // A width change re-wraps the text; a height change is our own doing.\n useLayoutEffect(() => {\n const el = node.current;\n if (!el || !enabled || typeof ResizeObserver === \"undefined\") return;\n let width = el.clientWidth;\n const ro = new ResizeObserver(() => {\n if (el.clientWidth === width) return;\n width = el.clientWidth;\n fit();\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, [enabled, fit]);\n\n const ref = useCallback(\n (el) => {\n node.current = el;\n if (typeof outer === \"function\") return outer(el);\n if (outer) outer.current = el;\n },\n [outer],\n );\n return { ref, fit };\n}\n\n/**\n * The native <textarea>. `rows` sets its height (default 4) and it\n * resizes by its corner. `autoGrow` makes it as tall as its text instead:\n * from `minRows` (default 1) to `maxRows` (default 8) lines, then it\n * scrolls; no resize corner.\n */\nexport function Textarea({ className, invalid, rows = 4, autoGrow = false, minRows = 1, maxRows = 8, onInput, ref, ...rest }) {\n const grow = useAutoGrow({ enabled: autoGrow, minRows, maxRows, value: rest.value, outer: ref });\n return (\n <textarea\n ref={grow.ref}\n rows={autoGrow ? minRows : rows}\n className={cx(CONTROL, autoGrow ? \"resize-none py-2.5 leading-normal\" : \"min-h-24 resize-y py-2.5 leading-normal\", className)}\n aria-invalid={invalid ? true : undefined}\n onInput={(e) => {\n if (autoGrow) grow.fit();\n onInput?.(e);\n }}\n {...rest}\n />\n );\n}\n\n/**\n * The native <select>, styled. `options` is [{ value, label }]; children\n * work too. A select with nothing to choose from is not a control — when\n * `options` is empty it renders `empty` (a short muted line, or nothing\n * when `empty` is null) rather than a disabled dropdown. For \"pick a\n * saved record or make a new one\" use RecordSwitcher (components/parts).\n */\nexport function Select({ options, empty = \"Nothing to choose yet.\", className, invalid, children, ...rest }) {\n if (options && options.length === 0 && !children) {\n return empty ? <p className=\"text-sm text-muted-foreground\">{empty}</p> : null;\n }\n return (\n <span className=\"relative block\">\n <select\n className={cx(CONTROL, \"appearance-none pr-9\", className)}\n aria-invalid={invalid ? true : undefined}\n {...rest}\n >\n {options ? options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>) : children}\n </select>\n <svg\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.8\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </span>\n );\n}\n\n/** An on/off. A checkbox underneath (role=switch), so forms, labels and\n * keyboards work; the track is drawn from tokens. */\nexport function Switch({ id, checked, onChange, label, disabled, className }) {\n return (\n <label htmlFor={id} className={cx(\"inline-flex min-h-11 cursor-pointer items-center gap-3 text-sm\", disabled && \"cursor-not-allowed opacity-50\", className)}>\n <span className=\"relative inline-flex h-6 w-10 shrink-0\">\n <input\n id={id}\n type=\"checkbox\"\n role=\"switch\"\n className=\"peer sr-only\"\n checked={checked}\n onChange={onChange}\n disabled={disabled}\n />\n <span className=\"absolute inset-0 rounded-full border border-input bg-muted transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring\" />\n <span className=\"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-foreground transition-transform peer-checked:translate-x-4 peer-checked:bg-primary-foreground\" />\n </span>\n {label ? <span>{label}</span> : null}\n </label>\n );\n}\n\nexport function Checkbox({ id, label, className, ...rest }) {\n return (\n <label htmlFor={id} className={cx(\"inline-flex min-h-11 cursor-pointer items-center gap-2 text-sm\", className)}>\n <input\n id={id}\n type=\"checkbox\"\n className=\"h-4 w-4 rounded-sm border-input accent-[var(--color-primary)] outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n {...rest}\n />\n {label ? <span>{label}</span> : null}\n </label>\n );\n}\n"
133
133
  },
134
134
  {
135
135
  "path": "templates/next/files/components/ui/file-input.jsx",
@@ -153,7 +153,7 @@
153
153
  "path": "templates/next/files/components/ui/index.js",
154
154
  "type": "registry:ui",
155
155
  "target": "@components/makersclaw/ui/index.js",
156
- "content": "// The kit. Import from \"../components/ui\" (a surface) or \"./ui\" (a\n// component). Every part is the workspace's look over the browser's own\n// behaviour; none of them takes a colour. README §3b.\nexport { cx } from \"./cx\";\nexport { Icon, ICON_NAMES } from \"./icon\";\nexport { Button, IconButton } from \"./button\";\nexport { Field, Input, TimeInput, Textarea, Select, Switch, Checkbox } from \"./field\";\nexport { Card, Notice, Badge, Kicker, Empty } from \"./notice\";\nexport { Sheet, Confirm } from \"./sheet\";\nexport { Tabs, TabPanel } from \"./tabs\";\nexport { Menu, MenuPanel } from \"./menu\";\nexport { ContextMenu } from \"./context-menu\";\nexport { Popover, usePopoverPosition, panelClass } from \"./popover\";\nexport {\n Calendar,\n parseDate,\n isDateString,\n toDateString,\n todayString,\n addDays,\n addMonths,\n startOfMonth,\n endOfMonth,\n dayOfWeek,\n formatDate,\n firstDayOfWeek,\n} from \"./calendar\";\nexport { DatePicker, DateInput, DATE_PRESETS } from \"./date-picker\";\nexport { Tooltip, HoverCard } from \"./tooltip\";\nexport { Depiction } from \"./depiction\";\nexport { toast, Toaster } from \"./toast\";\nexport { Carousel } from \"./carousel\";\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle } from \"./resizable\";\nexport { Avatar, AvatarGroup } from \"./avatar\";\nexport { Breadcrumb } from \"./breadcrumb\";\nexport { Pagination } from \"./pagination\";\nexport { RadioGroup, Radio } from \"./radio\";\nexport { Toggle, ToggleGroup } from \"./toggle\";\nexport { ButtonGroup, ButtonGroupSeparator } from \"./button-group\";\nexport { Collapsible, Accordion } from \"./collapsible\";\nexport { Item, ItemGroup } from \"./item\";\nexport { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from \"./table\";\nexport { Spinner, Skeleton, Progress } from \"./feedback\";\nexport { Distribution } from \"./distribution\";\nexport { Swatch } from \"./swatch\";\nexport { Stat, StatGroup, Delta, StatusDot, Legend, Meter } from \"./stat\";\nexport { Steps } from \"./steps\";\nexport { Separator, Kbd, KbdGroup, AspectRatio } from \"./separator\";\nexport { ScrollArea } from \"./scroll-area\";\nexport { Slider } from \"./slider\";\nexport { InputGroup, PasswordInput } from \"./input-group\";\nexport { Image } from \"./image\";\nexport { FileInput, FileDrop, fileMatches } from \"./file-input\";\nexport { InputOTP } from \"./input-otp\";\nexport { Command, CommandDialog, commandScore, commandFilter } from \"./command\";\nexport { Combobox } from \"./combobox\";\nexport { Rating } from \"./rating\";\n"
156
+ "content": "// The kit. Import from \"../components/ui\" (a surface) or \"./ui\" (a\n// component). Every part is the workspace's look over the browser's own\n// behaviour; none of them takes a colour. README §3b.\nexport { cx } from \"./cx\";\nexport { Icon, ICON_NAMES } from \"./icon\";\nexport { Button, IconButton } from \"./button\";\nexport { Field, Input, TimeInput, Textarea, Select, Switch, Checkbox } from \"./field\";\nexport { Card, Notice, Badge, Kicker, Empty } from \"./notice\";\nexport { Sheet, Confirm } from \"./sheet\";\nexport { Tabs, TabPanel } from \"./tabs\";\nexport { Menu, MenuPanel } from \"./menu\";\nexport { ContextMenu } from \"./context-menu\";\nexport { Popover, usePopoverPosition, panelClass } from \"./popover\";\nexport {\n Calendar,\n parseDate,\n isDateString,\n toDateString,\n todayString,\n addDays,\n addMonths,\n startOfMonth,\n endOfMonth,\n dayOfWeek,\n formatDate,\n toLocalDate,\n firstDayOfWeek,\n} from \"./calendar\";\nexport { DatePicker, DateInput, DATE_PRESETS } from \"./date-picker\";\nexport { Tooltip, HoverCard } from \"./tooltip\";\nexport { Depiction } from \"./depiction\";\nexport { toast, Toaster } from \"./toast\";\nexport { Carousel } from \"./carousel\";\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle } from \"./resizable\";\nexport { Avatar, AvatarGroup } from \"./avatar\";\nexport { Breadcrumb } from \"./breadcrumb\";\nexport { Pagination } from \"./pagination\";\nexport { RadioGroup, Radio } from \"./radio\";\nexport { Toggle, ToggleGroup } from \"./toggle\";\nexport { ButtonGroup, ButtonGroupSeparator } from \"./button-group\";\nexport { Collapsible, Accordion } from \"./collapsible\";\nexport { Item, ItemGroup } from \"./item\";\nexport { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from \"./table\";\nexport { Spinner, Skeleton, Progress } from \"./feedback\";\nexport { Distribution } from \"./distribution\";\nexport { Swatch } from \"./swatch\";\nexport { Stat, StatGroup, Delta, StatusDot, Legend, Meter } from \"./stat\";\nexport { Steps } from \"./steps\";\nexport { Separator, Kbd, KbdGroup, AspectRatio } from \"./separator\";\nexport { ScrollArea } from \"./scroll-area\";\nexport { Slider } from \"./slider\";\nexport { InputGroup, PasswordInput } from \"./input-group\";\nexport { Image } from \"./image\";\nexport { FileInput, FileDrop, fileMatches } from \"./file-input\";\nexport { InputOTP } from \"./input-otp\";\nexport { Command, CommandDialog, commandScore, commandFilter } from \"./command\";\nexport { Combobox } from \"./combobox\";\nexport { Rating } from \"./rating\";\n"
157
157
  },
158
158
  {
159
159
  "path": "templates/next/files/components/ui/input-group.jsx",
@@ -231,7 +231,7 @@
231
231
  "path": "templates/next/files/components/ui/sheet.jsx",
232
232
  "type": "registry:ui",
233
233
  "target": "@components/makersclaw/ui/sheet.jsx",
234
- "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Checkbox, Input } from \"./field\";\n\n// Sheet — the one overlay. A native <dialog> (focus trap, Esc, scroll\n// lock and the top layer come from the browser), docked to the side the\n// surface asks for. Mobile first: on a narrow window every side becomes\n// a bottom sheet the thumb can reach. Controlled: `open` + `onClose`.\n//\n// side end (a rail beside the work; the default) · start (a drawer\n// from the leading edge, as navigation opens) · center (a\n// dialog) · bottom\n// title, description the header; the title names the dialog\n// footer a row of buttons under the body\n// bare true: no header and a flush body (no padding) — for a panel\n// that draws its own top, as CommandDialog does. Esc and a\n// press outside still close it. Give it `label`.\n// label the dialog's name for a screen reader when no `title` shows\n//\n// Confirm below is the \"delete this?\" case — never window.confirm().\n\nconst SIDES = {\n end:\n \"inset-y-0 right-0 left-auto h-dvh max-h-none w-full max-w-md rounded-none border-l \" +\n \"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-l-0 max-sm:border-t\",\n start:\n \"inset-y-0 left-0 right-auto h-dvh max-h-none w-full max-w-md rounded-none border-r \" +\n \"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-r-0 max-sm:border-t\",\n center: \"m-auto w-[calc(100%-2rem)] max-w-lg rounded-lg border max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:rounded-t-xl\",\n bottom: \"inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t\",\n};\n\nexport function Sheet({ open, onClose, title, description, side = \"end\", footer, bare = false, label, className, children }) {\n const ref = useRef(null);\n const titleId = useId();\n const named = Boolean(title) && !bare;\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n if (open && !el.open) el.showModal();\n else if (!open && el.open) el.close();\n }, [open]);\n\n return (\n <dialog\n ref={ref}\n onClose={() => onClose?.()}\n onClick={(e) => {\n if (e.target === ref.current) ref.current.close();\n }}\n className={cx(\n \"fixed m-0 flex-col bg-popover p-0 text-popover-foreground shadow-xl border-border open:flex\",\n SIDES[side] ?? SIDES.end,\n className,\n )}\n aria-labelledby={named ? titleId : undefined}\n aria-label={named ? undefined : label}\n >\n <div className=\"flex min-h-0 flex-1 flex-col\">\n {!bare && (title || onClose) ? (\n <header className=\"flex items-start justify-between gap-3 border-b border-border px-4 py-3\">\n <div className=\"min-w-0\">\n {title ? <h2 id={titleId} className=\"text-base font-semibold leading-tight\">{title}</h2> : null}\n {description ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n <IconButton icon=\"x\" label=\"Close\" size=\"sm\" onClick={() => ref.current?.close()} className=\"-mr-2\" />\n </header>\n ) : null}\n <div className={cx(\"min-h-0 flex-1 overflow-y-auto\", !bare && \"px-4 py-4\")}>{children}</div>\n {footer ? <footer className=\"flex flex-wrap justify-end gap-2 border-t border-border px-4 py-3\">{footer}</footer> : null}\n </div>\n </dialog>\n );\n}\n\n/** A yes/no with the consequence spelled out. `tone=\"danger\"` for a\n * destructive confirm. Resolves through `onConfirm`; closes either way.\n *\n * consequences lines that will happen (\"14 contacts are deleted\"), a\n * muted bullet list under the body\n * requireText the exact text to type (\"acme-corp\") before Confirm\n * unlocks — for the one delete that cannot come back\n * acknowledge a checkbox label that must be ticked first (\"I have\n * exported what I need\")\n *\n * Confirm stays disabled until each gate given is met; both reset when\n * the dialog opens again. Without them it is the plain yes/no. */\nexport function Confirm({\n open,\n onClose,\n onConfirm,\n title,\n body,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"neutral\",\n busy,\n consequences,\n requireText,\n acknowledge,\n}) {\n const [typed, setTyped] = useState(\"\");\n const [ticked, setTicked] = useState(false);\n const typeId = useId();\n const tickId = useId();\n\n useEffect(() => {\n if (!open) return;\n setTyped(\"\");\n setTicked(false);\n }, [open]);\n\n const lines = Array.isArray(consequences) ? consequences.filter(Boolean) : [];\n const held = (requireText ? typed !== requireText : false) || (acknowledge ? !ticked : false);\n\n return (\n <Sheet\n open={open}\n onClose={onClose}\n side=\"center\"\n title={title}\n footer={\n <>\n <Button variant=\"quiet\" onClick={onClose} disabled={busy}>\n {cancelLabel}\n </Button>\n <Button variant={tone === \"danger\" ? \"danger\" : \"primary\"} onClick={onConfirm} disabled={busy || held}>\n {busy ? \"Working…\" : confirmLabel}\n </Button>\n </>\n }\n >\n {body ? <p className=\"text-sm text-muted-foreground\">{body}</p> : null}\n {lines.length ? (\n <ul className={cx(\"list-disc space-y-1 ps-5 text-sm text-muted-foreground marker:text-muted-foreground\", body && \"mt-3\")}>\n {lines.map((line, i) => (\n <li key={i}>{line}</li>\n ))}\n </ul>\n ) : null}\n {requireText ? (\n <div className={cx(\"grid gap-1.5\", (body || lines.length) && \"mt-4\")}>\n <label htmlFor={typeId} className=\"text-sm text-foreground\">\n Type <span className=\"break-all font-mono font-medium\">{requireText}</span> to confirm\n </label>\n <Input\n id={typeId}\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n autoComplete=\"off\"\n autoCapitalize=\"off\"\n autoCorrect=\"off\"\n spellCheck={false}\n disabled={busy}\n />\n </div>\n ) : null}\n {acknowledge ? (\n <Checkbox\n id={tickId}\n checked={ticked}\n onChange={(e) => setTicked(e.target.checked)}\n label={acknowledge}\n disabled={busy}\n className={cx((body || lines.length || requireText) && \"mt-2\")}\n />\n ) : null}\n </Sheet>\n );\n}\n"
234
+ "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Button, IconButton } from \"./button\";\nimport { Checkbox, Input } from \"./field\";\n\n// Sheet — the one overlay. A native <dialog> (focus trap, Esc, scroll\n// lock and the top layer come from the browser), docked to the side the\n// surface asks for. Mobile first: on a narrow window every side becomes\n// a bottom sheet the thumb can reach. Controlled: `open` + `onClose`.\n//\n// side end (a rail beside the work; the default) · start (a drawer\n// from the leading edge, as navigation opens) · center (a\n// dialog) · bottom\n// title, description the header; the title names the dialog\n// footer a row of buttons under the body\n// bare true: no header and a flush body (no padding) — for a panel\n// that draws its own top, as CommandDialog does. Esc and a\n// press outside still close it. Give it `label`.\n// label the dialog's name for a screen reader when no `title` shows\n//\n// A field with autoFocus inside it takes focus once the sheet is open\n// (otherwise the browser gives it to the first control: Close).\n//\n// Confirm below is the \"delete this?\" case — never window.confirm().\n\nconst SIDES = {\n end:\n \"inset-y-0 right-0 left-auto h-dvh max-h-none w-full max-w-md rounded-none border-l \" +\n \"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-l-0 max-sm:border-t\",\n start:\n \"inset-y-0 left-0 right-auto h-dvh max-h-none w-full max-w-md rounded-none border-r \" +\n \"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-r-0 max-sm:border-t\",\n center: \"m-auto w-[calc(100%-2rem)] max-w-lg rounded-lg border max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:rounded-t-xl\",\n bottom: \"inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t\",\n};\n\n// autoFocus inside a Sheet. React focuses an autoFocus field as the field\n// mounts, and the dialog opens after that, in the effect below: a field\n// mounted with the sheet, or long before it (a sheet's body is rendered\n// while it is closed), was focused inside a closed dialog, where focus\n// goes nowhere. showModal() then focused the first thing it could, the\n// header's Close button, and typing in \"New piece\" went nowhere\n// (weekly-content-report, 2026-09-23; CLAW-FIXES #18). So once it is\n// open, the Sheet puts focus where a field asked for it.\n//\n// Which field: React renders no autofocus attribute in the browser. It\n// keeps a host element's props on the element, under \"__reactProps$…\"\n// (react-dom's own key since React 17; test:app holds the installed\n// react-dom to that name), so that is read, for the elements React\n// autofocuses. The attribute counts too: a server-rendered field has it.\nconst AUTOFOCUSABLE = \"input, textarea, select, button, [autofocus]\";\n\nfunction reactProps(node) {\n for (const key of Object.keys(node)) if (key.startsWith(\"__reactProps$\")) return node[key];\n return null;\n}\n\n/** The field in `dialog` that asked for focus (autoFocus), or null — never\n * one inside a sheet nested in this one, never a disabled one. */\nexport function autoFocusTarget(dialog) {\n for (const node of dialog.querySelectorAll(AUTOFOCUSABLE)) {\n if (node.disabled || node.closest(\"dialog\") !== dialog) continue;\n if (node.hasAttribute(\"autofocus\") || reactProps(node)?.autoFocus) return node;\n }\n return null;\n}\n\nexport function Sheet({ open, onClose, title, description, side = \"end\", footer, bare = false, label, className, children }) {\n const ref = useRef(null);\n const titleId = useId();\n const named = Boolean(title) && !bare;\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n if (open && !el.open) {\n el.showModal();\n autoFocusTarget(el)?.focus();\n } else if (!open && el.open) el.close();\n }, [open]);\n\n return (\n <dialog\n ref={ref}\n onClose={() => onClose?.()}\n onClick={(e) => {\n if (e.target === ref.current) ref.current.close();\n }}\n className={cx(\n \"fixed m-0 flex-col bg-popover p-0 text-popover-foreground shadow-xl border-border open:flex\",\n SIDES[side] ?? SIDES.end,\n className,\n )}\n aria-labelledby={named ? titleId : undefined}\n aria-label={named ? undefined : label}\n >\n <div className=\"flex min-h-0 flex-1 flex-col\">\n {!bare && (title || onClose) ? (\n <header className=\"flex items-start justify-between gap-3 border-b border-border px-4 py-3\">\n <div className=\"min-w-0\">\n {title ? <h2 id={titleId} className=\"text-base font-semibold leading-tight\">{title}</h2> : null}\n {description ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n <IconButton icon=\"x\" label=\"Close\" size=\"sm\" onClick={() => ref.current?.close()} className=\"-mr-2\" />\n </header>\n ) : null}\n <div className={cx(\"min-h-0 flex-1 overflow-y-auto\", !bare && \"px-4 py-4\")}>{children}</div>\n {footer ? <footer className=\"flex flex-wrap justify-end gap-2 border-t border-border px-4 py-3\">{footer}</footer> : null}\n </div>\n </dialog>\n );\n}\n\n/** A yes/no with the consequence spelled out. `tone=\"danger\"` for a\n * destructive confirm. Resolves through `onConfirm`; closes either way.\n *\n * consequences lines that will happen (\"14 contacts are deleted\"), a\n * muted bullet list under the body\n * requireText the exact text to type (\"acme-corp\") before Confirm\n * unlocks — for the one delete that cannot come back\n * acknowledge a checkbox label that must be ticked first (\"I have\n * exported what I need\")\n *\n * Confirm stays disabled until each gate given is met; both reset when\n * the dialog opens again. Without them it is the plain yes/no. */\nexport function Confirm({\n open,\n onClose,\n onConfirm,\n title,\n body,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"neutral\",\n busy,\n consequences,\n requireText,\n acknowledge,\n}) {\n const [typed, setTyped] = useState(\"\");\n const [ticked, setTicked] = useState(false);\n const typeId = useId();\n const tickId = useId();\n\n useEffect(() => {\n if (!open) return;\n setTyped(\"\");\n setTicked(false);\n }, [open]);\n\n const lines = Array.isArray(consequences) ? consequences.filter(Boolean) : [];\n const held = (requireText ? typed !== requireText : false) || (acknowledge ? !ticked : false);\n\n return (\n <Sheet\n open={open}\n onClose={onClose}\n side=\"center\"\n title={title}\n footer={\n <>\n <Button variant=\"quiet\" onClick={onClose} disabled={busy}>\n {cancelLabel}\n </Button>\n <Button variant={tone === \"danger\" ? \"danger\" : \"primary\"} onClick={onConfirm} disabled={busy || held}>\n {busy ? \"Working…\" : confirmLabel}\n </Button>\n </>\n }\n >\n {body ? <p className=\"text-sm text-muted-foreground\">{body}</p> : null}\n {lines.length ? (\n <ul className={cx(\"list-disc space-y-1 ps-5 text-sm text-muted-foreground marker:text-muted-foreground\", body && \"mt-3\")}>\n {lines.map((line, i) => (\n <li key={i}>{line}</li>\n ))}\n </ul>\n ) : null}\n {requireText ? (\n <div className={cx(\"grid gap-1.5\", (body || lines.length) && \"mt-4\")}>\n <label htmlFor={typeId} className=\"text-sm text-foreground\">\n Type <span className=\"break-all font-mono font-medium\">{requireText}</span> to confirm\n </label>\n <Input\n id={typeId}\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n autoComplete=\"off\"\n autoCapitalize=\"off\"\n autoCorrect=\"off\"\n spellCheck={false}\n disabled={busy}\n />\n </div>\n ) : null}\n {acknowledge ? (\n <Checkbox\n id={tickId}\n checked={ticked}\n onChange={(e) => setTicked(e.target.checked)}\n label={acknowledge}\n disabled={busy}\n className={cx((body || lines.length || requireText) && \"mt-2\")}\n />\n ) : null}\n </Sheet>\n );\n}\n"
235
235
  },
236
236
  {
237
237
  "path": "templates/next/files/components/ui/slider.jsx",
@@ -279,7 +279,7 @@
279
279
  "path": "templates/next/files/components/ui/toggle.jsx",
280
280
  "type": "registry:ui",
281
281
  "target": "@components/makersclaw/ui/toggle.jsx",
282
- "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Toggle — a button that stays down: bold on, pinned, \"show archived\".\n// A real <button aria-pressed>, so Space and Enter work and a screen\n// reader says \"pressed\". Controlled (`pressed` + `onPressedChange(next)`)\n// or not (`defaultPressed`). A setting that saves is a Switch; a toggle\n// changes what THIS view shows or does. Off reads muted, on reads lit\n// (full text, the accent fill, a stronger edge) — never colour alone.\n//\n// variant secondary (bordered; the default) · quiet (no fill until pressed)\n// size md (44px) · sm (36px)\n// icon a name from ./icon; with no children the toggle is square\n// and `label` is required (what a screen reader says)\n//\n// ToggleGroup below is a row of them as one control — the segmented\n// control. Arrow keys move between items (roving focus: the row is one\n// Tab stop), Space or Enter presses.\n\nconst VARIANTS = {\n secondary:\n \"bg-secondary text-muted-foreground border-border hover:bg-accent hover:text-foreground \" +\n \"aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:border-foreground/50\",\n quiet:\n \"bg-transparent text-muted-foreground border-transparent hover:bg-accent hover:text-foreground \" +\n \"aria-pressed:bg-accent aria-pressed:text-accent-foreground\",\n};\n\n// `square` is the icon-only toggle's own box, used INSTEAD of `box` —\n// never a px-0 laid over box's padding: cx does not merge classes, so\n// which of two paddings wins is CSS order, not the order written.\nconst SIZES = {\n md: { box: \"min-h-11 px-3 text-sm\", square: \"size-11 px-0 text-sm\", glyph: 18 },\n sm: { box: \"min-h-9 px-2.5 text-sm\", square: \"size-9 px-0 text-sm\", glyph: 16 },\n};\n\nexport function Toggle({\n pressed,\n defaultPressed = false,\n onPressedChange,\n variant = \"secondary\",\n size = \"md\",\n icon,\n label,\n className,\n children,\n onClick,\n ...rest\n}) {\n const [own, setOwn] = useState(defaultPressed);\n const on = pressed ?? own;\n const s = SIZES[size] ?? SIZES.md;\n const square = icon && !children;\n return (\n <button\n type=\"button\"\n aria-pressed={on}\n aria-label={square ? label : undefined}\n title={square ? label : undefined}\n onClick={(e) => {\n onClick?.(e);\n if (e.defaultPrevented) return;\n if (pressed === undefined) setOwn(!on);\n onPressedChange?.(!on);\n }}\n className={cx(\n \"inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n VARIANTS[variant] ?? VARIANTS.secondary,\n square ? s.square : s.box,\n className,\n )}\n {...rest}\n >\n {icon ? <Icon name={icon} size={square ? s.glyph : 16} /> : null}\n {children}\n </button>\n );\n}\n\n// As SIZES: `square` replaces `item` for an icon-only item.\nconst SEG = {\n md: { track: \"p-1\", item: \"min-h-9 px-3\", square: \"min-h-9 min-w-9 px-0\", glyph: 18 },\n sm: { track: \"p-0.5\", item: \"min-h-8 px-2.5\", square: \"min-h-8 min-w-8 px-0\", glyph: 16 },\n};\n\n/**\n * A row of toggles as one control, the segmented look: a muted track,\n * the pressed items raised. `type` single (exactly one pressed — pressing\n * it again keeps it; a filter that can be off is `multiple`) · multiple.\n * `value` is a string (single) or an array (multiple); `onChange` gets\n * the same shape. md is 44px tall, sm 36px. Stretches when given a width\n * (className=\"w-full\"): the items share it.\n *\n * items [{ value, label, icon?, iconOnly?, disabled? }] — `iconOnly`\n * shows the icon alone; `label` is then what a screen reader says\n */\nexport function ToggleGroup({\n type = \"single\",\n items,\n value,\n defaultValue,\n onChange,\n size = \"md\",\n disabled,\n className,\n \"aria-label\": ariaLabel,\n}) {\n const multiple = type === \"multiple\";\n const [own, setOwn] = useState(defaultValue ?? (multiple ? [] : undefined));\n const current = value !== undefined ? value : own;\n const refs = useRef([]);\n const s = SEG[size] ?? SEG.md;\n const live = (items ?? []).filter(Boolean);\n\n const isOn = (v) => (multiple ? (current ?? []).includes(v) : current === v);\n const usable = (i) => !disabled && !live[i]?.disabled;\n\n function press(v) {\n let next;\n if (multiple) next = isOn(v) ? current.filter((x) => x !== v) : [...(current ?? []), v];\n else if (current === v) return;\n else next = v;\n if (value === undefined) setOwn(next);\n onChange?.(next);\n }\n\n // One Tab stop: the first pressed item, else the first usable one.\n let stop = live.findIndex((it, i) => usable(i) && isOn(it.value));\n if (stop < 0) stop = live.findIndex((_, i) => usable(i));\n\n function onKeyDown(e, index) {\n const step = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0;\n let next = null;\n if (step) {\n for (let k = 1; k <= live.length; k++) {\n const i = (index + step * k + live.length) % live.length;\n if (usable(i)) {\n next = i;\n break;\n }\n }\n } else if (e.key === \"Home\") next = live.findIndex((_, i) => usable(i));\n else if (e.key === \"End\") next = live.findLastIndex((_, i) => usable(i));\n if (next === null || next < 0) return;\n e.preventDefault();\n refs.current[next]?.focus();\n }\n\n if (live.length === 0) return null;\n\n return (\n <div\n role=\"group\"\n aria-label={ariaLabel}\n className={cx(\"inline-flex max-w-full gap-1 rounded-md bg-muted\", s.track, disabled && \"opacity-50\", className)}\n >\n {live.map((it, i) => {\n const on = isOn(it.value);\n const square = it.icon && it.iconOnly;\n return (\n <button\n key={it.value}\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n aria-pressed={on}\n aria-label={square ? it.label : undefined}\n title={square ? it.label : undefined}\n disabled={!usable(i)}\n tabIndex={i === stop ? 0 : -1}\n onClick={() => press(it.value)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cx(\n \"inline-flex flex-1 items-center justify-center gap-2 rounded-sm text-sm font-medium whitespace-nowrap transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n \"disabled:cursor-not-allowed\",\n !disabled && \"disabled:opacity-50\",\n square ? s.square : s.item,\n on ? \"bg-background text-foreground shadow-sm ring-1 ring-border\" : \"text-muted-foreground hover:text-foreground\",\n )}\n >\n {it.icon ? <Icon name={it.icon} size={square ? s.glyph : 16} /> : null}\n {square ? null : it.label}\n </button>\n );\n })}\n </div>\n );\n}\n"
282
+ "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"./cx\";\nimport { Icon } from \"./icon\";\n\n// Toggle — a button that stays down: bold on, pinned, \"show archived\".\n// A real <button aria-pressed>, so Space and Enter work and a screen\n// reader says \"pressed\". Controlled (`pressed` + `onPressedChange(next)`)\n// or not (`defaultPressed`). A setting that saves is a Switch; a toggle\n// changes what THIS view shows or does. Off reads muted, on reads lit\n// (full text, the accent fill, a stronger edge) — never colour alone.\n//\n// variant secondary (bordered; the default) · quiet (no fill until pressed)\n// size md (44px) · sm (36px)\n// icon a name from ./icon; with no children the toggle is square\n// and `label` is required (what a screen reader says)\n//\n// ToggleGroup below is a row of them as one control — the segmented\n// control. Arrow keys move between items (roving focus: the row is one\n// Tab stop), Space or Enter presses.\n\nconst VARIANTS = {\n secondary:\n \"bg-secondary text-muted-foreground border-border hover:bg-accent hover:text-foreground \" +\n \"aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:border-foreground/50\",\n quiet:\n \"bg-transparent text-muted-foreground border-transparent hover:bg-accent hover:text-foreground \" +\n \"aria-pressed:bg-accent aria-pressed:text-accent-foreground\",\n};\n\n// `square` is the icon-only toggle's own box, used INSTEAD of `box` —\n// never a px-0 laid over box's padding: cx does not merge classes, so\n// which of two paddings wins is CSS order, not the order written.\nconst SIZES = {\n md: { box: \"min-h-11 px-3 text-sm\", square: \"size-11 px-0 text-sm\", glyph: 18 },\n sm: { box: \"min-h-9 px-2.5 text-sm\", square: \"size-9 px-0 text-sm\", glyph: 16 },\n};\n\nexport function Toggle({\n pressed,\n defaultPressed = false,\n onPressedChange,\n variant = \"secondary\",\n size = \"md\",\n icon,\n label,\n className,\n children,\n onClick,\n ...rest\n}) {\n const [own, setOwn] = useState(defaultPressed);\n const on = pressed ?? own;\n const s = SIZES[size] ?? SIZES.md;\n const square = icon && !children;\n return (\n <button\n type=\"button\"\n aria-pressed={on}\n aria-label={square ? label : undefined}\n title={square ? label : undefined}\n onClick={(e) => {\n onClick?.(e);\n if (e.defaultPrevented) return;\n if (pressed === undefined) setOwn(!on);\n onPressedChange?.(!on);\n }}\n className={cx(\n \"inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n VARIANTS[variant] ?? VARIANTS.secondary,\n square ? s.square : s.box,\n className,\n )}\n {...rest}\n >\n {icon ? <Icon name={icon} size={square ? s.glyph : 16} /> : null}\n {children}\n </button>\n );\n}\n\n// As SIZES: `square` replaces `item` for an icon-only item.\nconst SEG = {\n md: { track: \"p-1\", item: \"min-h-9 px-3\", square: \"min-h-9 min-w-9 px-0\", glyph: 18 },\n sm: { track: \"p-0.5\", item: \"min-h-8 px-2.5\", square: \"min-h-8 min-w-8 px-0\", glyph: 16 },\n};\n\n// The group is a scroll container, so its row never makes its parent\n// wider (in a plain grid it pushed the page wider on a phone, 0.5.0).\n// That also takes away the minimums a flex item gets from its content,\n// so each comes back unless the className sizes that axis itself:\n// shrink-0 keeps its width in a flex row (its neighbours give way, as\n// before), min-h-min its height in a flex column.\nconst FLEXES = /(?:^|\\s)!?(?:w-|flex-|grow|shrink|basis-)/;\nconst TALL = /(?:^|\\s)!?(?:h-|min-h-|size-)/;\n\n/**\n * A row of toggles as one control, the segmented look: a muted track,\n * the pressed items raised. `type` single (exactly one pressed — pressing\n * it again keeps it; a filter that can be off is `multiple`) · multiple.\n * `value` is a string (single) or an array (multiple); `onChange` gets\n * the same shape. md is 44px tall, sm 36px. Stretches when given a width\n * (className=\"w-full\"): the items share it. Never wider than its\n * container: past that it scrolls sideways inside itself (it does not\n * wrap unless className=\"flex-wrap\").\n *\n * items [{ value, label, icon?, iconOnly?, disabled? }] — `iconOnly`\n * shows the icon alone; `label` is then what a screen reader says\n */\nexport function ToggleGroup({\n type = \"single\",\n items,\n value,\n defaultValue,\n onChange,\n size = \"md\",\n disabled,\n className,\n \"aria-label\": ariaLabel,\n}) {\n const multiple = type === \"multiple\";\n const [own, setOwn] = useState(defaultValue ?? (multiple ? [] : undefined));\n const current = value !== undefined ? value : own;\n const refs = useRef([]);\n const s = SEG[size] ?? SEG.md;\n const live = (items ?? []).filter(Boolean);\n\n const isOn = (v) => (multiple ? (current ?? []).includes(v) : current === v);\n const usable = (i) => !disabled && !live[i]?.disabled;\n\n function press(v) {\n let next;\n if (multiple) next = isOn(v) ? current.filter((x) => x !== v) : [...(current ?? []), v];\n else if (current === v) return;\n else next = v;\n if (value === undefined) setOwn(next);\n onChange?.(next);\n }\n\n // One Tab stop: the first pressed item, else the first usable one.\n let stop = live.findIndex((it, i) => usable(i) && isOn(it.value));\n if (stop < 0) stop = live.findIndex((_, i) => usable(i));\n\n function onKeyDown(e, index) {\n const step = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0;\n let next = null;\n if (step) {\n for (let k = 1; k <= live.length; k++) {\n const i = (index + step * k + live.length) % live.length;\n if (usable(i)) {\n next = i;\n break;\n }\n }\n } else if (e.key === \"Home\") next = live.findIndex((_, i) => usable(i));\n else if (e.key === \"End\") next = live.findLastIndex((_, i) => usable(i));\n if (next === null || next < 0) return;\n e.preventDefault();\n refs.current[next]?.focus();\n }\n\n if (live.length === 0) return null;\n\n return (\n <div\n role=\"group\"\n aria-label={ariaLabel}\n className={cx(\n \"inline-flex max-w-full gap-1 overflow-x-auto rounded-md bg-muted\",\n !FLEXES.test(className ?? \"\") && \"shrink-0\",\n !TALL.test(className ?? \"\") && \"min-h-min\",\n s.track,\n disabled && \"opacity-50\",\n className,\n )}\n >\n {live.map((it, i) => {\n const on = isOn(it.value);\n const square = it.icon && it.iconOnly;\n return (\n <button\n key={it.value}\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n aria-pressed={on}\n aria-label={square ? it.label : undefined}\n title={square ? it.label : undefined}\n disabled={!usable(i)}\n tabIndex={i === stop ? 0 : -1}\n onClick={() => press(it.value)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cx(\n \"inline-flex flex-1 items-center justify-center gap-2 rounded-sm text-sm font-medium whitespace-nowrap transition-colors\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n \"disabled:cursor-not-allowed\",\n !disabled && \"disabled:opacity-50\",\n square ? s.square : s.item,\n on ? \"bg-background text-foreground shadow-sm ring-1 ring-border\" : \"text-muted-foreground hover:text-foreground\",\n )}\n >\n {it.icon ? <Icon name={it.icon} size={square ? s.glyph : 16} /> : null}\n {square ? null : it.label}\n </button>\n );\n })}\n </div>\n );\n}\n"
283
283
  },
284
284
  {
285
285
  "path": "templates/next/files/components/ui/tooltip.jsx",
package/r/csv-import.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "CsvImport",
6
6
  "description": "Import a CSV in three calm steps: drop a file (or paste text, or download a template), match each file column to a field with automatic matches and sample values (or, with no fields, name and type a new table's fields from guessed types), then review every row in an editable spreadsheet where cells that do not fit their type are marked red and fixed in place, delete rows or leave the bad ones out, and import through one async onImport that answers { ok }. Quoted commas and newlines, TSV and semicolon files, byte-order marks, 50,000 rows.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/spreadsheet.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/spreadsheet.json"
10
10
  ],
11
11
  "dependencies": [
12
12
  "papaparse@5.7.0"
@@ -56,7 +56,7 @@
56
56
  "docs": "Installed to components/makersclaw/blocks/csv-import. Import: import { CsvImport, parseCsvText, readCsvFile, headersOf, autoMatch, guessColumns, buildRows, cleanRow, templateCsv } from \"@/components/makersclaw/blocks/csv-import\". Props and an example: components/makersclaw/blocks/csv-import/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
57
57
  "meta": {
58
58
  "makersclaw": {
59
- "version": "0.4.0",
59
+ "version": "0.5.2",
60
60
  "useWhen": "People bring records in from a spreadsheet or another tool: contacts, leads, products, a price list, an old CRM export, a first table for a new app. Built on spreadsheet. Not for uploading files to keep (file-upload) or for editing records already in the app (spreadsheet, data-table)."
61
61
  }
62
62
  }