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,7 +5,7 @@
5
5
  "title": "SearchDialog",
6
6
  "description": "One ⌘K search over every kind of record — chats, documents, images, projects, people. Before typing it shows your sections (Last opened, Recent); while typing, ranked matches with the typed text marked, a date, image thumbnails, and kind chips (All, Chats, Images…) to narrow them. Searches your list in the browser, or your server through an async onSearch. Built on the kit's Command; SearchPanel is the same box inline.",
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": [
@@ -44,7 +44,7 @@
44
44
  "docs": "Installed to components/makersclaw/blocks/search-dialog. Import: import { SearchDialog, SearchPanel } from \"@/components/makersclaw/blocks/search-dialog\". Props and an example: components/makersclaw/blocks/search-dialog/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
45
45
  "meta": {
46
46
  "makersclaw": {
47
- "version": "0.4.0",
47
+ "version": "0.5.2",
48
48
  "useWhen": "An app has more than one kind of record and wants one global search box or ⌘K palette to find any of them, or a search page with type filters."
49
49
  }
50
50
  }
@@ -5,7 +5,7 @@
5
5
  "title": "SettingsList",
6
6
  "description": "A card of settings, one row each — icon, label, description, meta lines, a badge, and its control: a Switch, a Select, a Button, a remove button or a menu of actions, with optional details that open under the row. Every change saves on its own through an async callback; the row shows the new value at once, a spinner while it saves, and on failure the old value back with the reason and Try again. variant=\"matrix\" is the notification grid: events by group down the side, channels (email, push, SMS) across, a switch per cell, stacked on a phone.",
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": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/settings-list. Import: import { SettingsList } from \"@/components/makersclaw/blocks/settings-list\". Props and an example: components/makersclaw/blocks/settings-list/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.4.0",
62
+ "version": "0.5.2",
63
63
  "useWhen": "An account or workspace settings screen where each setting applies the moment it is changed: preferences, two-step sign-in methods, signed-in devices, connected integrations, billing profiles, notification preferences per channel. Not for a form of fields saved together with one button (record-form)."
64
64
  }
65
65
  }
@@ -5,7 +5,7 @@
5
5
  "title": "SetupChecklist",
6
6
  "description": "The steps to get set up: which are done, how far along the whole is (a ring, a bar, or \"Step 2 of 4\"), and the button for the next one, sent out through an async onAction that can fail on the step. Four shapes: folding rows in a card with a dismiss menu, numbered step cards, a plain step list with a help panel, and bordered accordion rows that can be locked until the step before is done.",
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": [
@@ -44,7 +44,7 @@
44
44
  "docs": "Installed to components/makersclaw/blocks/setup-checklist. Import: import { SetupChecklist } from \"@/components/makersclaw/blocks/setup-checklist\". Props and an example: components/makersclaw/blocks/setup-checklist/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
45
45
  "meta": {
46
46
  "makersclaw": {
47
- "version": "0.4.0",
47
+ "version": "0.5.2",
48
48
  "useWhen": "A new account, workspace or shop has a few things to do before it is useful (complete the profile, invite the team, connect a tool, launch) and the founder should see what is left. Not for a running job's stages (kit Steps or job-panel), and not for a multi-page form wizard."
49
49
  }
50
50
  }
@@ -5,7 +5,7 @@
5
5
  "title": "ShareDialog",
6
6
  "description": "Who can see a record and how to let more people in: invite by email (comma separated, with an access level), each person with their access in a menu (can view, can edit, remove), sharing switches, and a link with a copy button that turns to a check. A dialog, or the same body inline (SharePanel). Every change is an async callback; a failure shows and the old value comes back.",
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": [
@@ -45,7 +45,7 @@
45
45
  "docs": "Installed to components/makersclaw/blocks/share-dialog. Import: import { ShareDialog, SharePanel } from \"@/components/makersclaw/blocks/share-dialog\". Props and an example: components/makersclaw/blocks/share-dialog/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
46
  "meta": {
47
47
  "makersclaw": {
48
- "version": "0.4.0",
48
+ "version": "0.5.2",
49
49
  "useWhen": "A record, project, document or workspace can be shared: an \"Invite\" or \"Share\" button, a members list with roles, a public link to copy."
50
50
  }
51
51
  }
package/r/sidebar.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Sidebar",
6
6
  "description": "An app's navigation down the left edge: app name or workspace switcher, groups of items with icons, counts and nested items, the signed-in person with their menu; collapses to icons (remembered) and becomes a Sheet behind a trigger on a phone. Optional: inset or floating look, a ⌘K search over every item, folding groups with colour dots, a second column (a mail list, or a parent's children to drill into), a \"New\" button and a \"…\" menu per item (a chat history).",
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": [
@@ -19,13 +19,13 @@
19
19
  "path": "registry/blocks/sidebar/sidebar.jsx",
20
20
  "type": "registry:component",
21
21
  "target": "@components/makersclaw/blocks/sidebar/sidebar.jsx",
22
- "content": "\"use client\";\n\nimport { createContext, isValidElement, useContext, useEffect, useId, useState } from \"react\";\nimport {\n Avatar,\n Badge,\n Button,\n Collapsible,\n CommandDialog,\n Icon,\n IconButton,\n KbdGroup,\n Menu,\n ScrollArea,\n Separator,\n Sheet,\n StatusDot,\n Tooltip,\n cx,\n} from \"../../ui\";\nimport { PanelBody, guard, run } from \"./sidebar-panel\";\n\n// Sidebar — an app's navigation down the left edge: the app's name (and a\n// workspace switcher when there is more than one), groups of items with\n// icons, counts and nested items, a custom slot, and the signed-in person\n// at the foot with their menu. The block owns what a hand-drawn sidebar\n// gets wrong: the active item said to a screen reader (aria-current), a\n// collapse to icons that keeps every label (as a tooltip) and is\n// remembered, and a phone layout where the sidebar is a Sheet behind a\n// trigger instead of a column squeezing the page. The app owns the items\n// (data in) and what choosing one does (href or onSelect).\n//\n// <SidebarLayout storageKey=\"crm.sidebar\" sidebar={<Sidebar title=\"Harbor CRM\" groups={NAV} user={me} />}>\n// <header><SidebarTrigger /> …</header>\n// …the page…\n// </SidebarLayout>\n//\n// Beyond the column: inset and floating looks, a ⌘K search over every\n// item, folding groups, a second column (the app's panel, or a parent's\n// children to drill into), a primary button, and a \"…\" menu per item.\n//\n// README.md beside this file lists every prop.\n\nconst WIDE = \"(min-width: 768px)\";\n\nconst SidebarContext = createContext(null);\n\n/** The layout's state, for a control of your own: { collapsed,\n * setCollapsed, toggle, mobileOpen, setMobileOpen }. Null outside a\n * SidebarLayout. */\nexport function useSidebar() {\n return useContext(SidebarContext);\n}\n\nfunction readStored(key) {\n try {\n const v = window.localStorage.getItem(key);\n return v === \"1\" ? true : v === \"0\" ? false : null;\n } catch {\n return null;\n }\n}\n\nfunction writeStored(key, value) {\n try {\n window.localStorage.setItem(key, value ? \"1\" : \"0\");\n } catch {\n // private window, blocked storage: the choice lasts this visit only\n }\n}\n\nexport function SidebarLayout({\n sidebar,\n children,\n collapsed,\n defaultCollapsed = false,\n onCollapsedChange,\n storageKey,\n shortcut = \"b\",\n variant,\n className,\n}) {\n // The look is the Sidebar's `variant` unless the layout is given one:\n // inset needs the page drawn as a card, so the frame has to know.\n const look = variant ?? (isValidElement(sidebar) ? sidebar.props?.variant : undefined);\n const controlled = collapsed !== undefined;\n const [own, setOwn] = useState(defaultCollapsed);\n const [mobileOpen, setMobileOpen] = useState(false);\n const isCollapsed = controlled ? collapsed : own;\n\n // Stored after mount: the server has no storage, so the first paint is\n // the default and the remembered width follows at once.\n useEffect(() => {\n if (!storageKey || controlled) return;\n const stored = readStored(storageKey);\n if (stored !== null) setOwn(stored);\n }, [storageKey, controlled]);\n\n // Widening past the phone layout closes the sheet: the column is back.\n useEffect(() => {\n const mq = window.matchMedia(WIDE);\n const onChange = () => mq.matches && setMobileOpen(false);\n mq.addEventListener(\"change\", onChange);\n return () => mq.removeEventListener(\"change\", onChange);\n }, []);\n\n function setCollapsed(next) {\n const value = typeof next === \"function\" ? next(isCollapsed) : Boolean(next);\n if (!controlled) setOwn(value);\n if (storageKey) writeStored(storageKey, value);\n onCollapsedChange?.(value);\n }\n\n function toggle() {\n if (window.matchMedia(WIDE).matches) setCollapsed(!isCollapsed);\n else setMobileOpen((o) => !o);\n }\n\n // Cmd/Ctrl + B, as in most editors — never while typing in a field.\n useEffect(() => {\n if (!shortcut) return undefined;\n const onKey = (e) => {\n if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey || e.key.toLowerCase() !== shortcut) return;\n if (e.target.closest?.(\"input, textarea, select, [contenteditable='true']\")) return;\n e.preventDefault();\n toggle();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n });\n\n const value = { collapsed: isCollapsed, setCollapsed, toggle, mobileOpen, setMobileOpen };\n return (\n <SidebarContext.Provider value={value}>\n <div className={cx(\"flex w-full text-foreground\", look === \"inset\" ? \"bg-background md:bg-muted/40\" : \"bg-background\", className)}>\n {sidebar}\n <div\n className={cx(\n \"flex min-w-0 flex-1 flex-col\",\n look === \"inset\" && \"bg-background md:my-2 md:mr-2 md:rounded-xl md:border md:border-border md:shadow-sm\",\n )}\n >\n {children}\n </div>\n </div>\n </SidebarContext.Provider>\n );\n}\n\n/** The one button that opens the sidebar on a phone and collapses it to\n * icons on a wider window. Put it in the page's header. */\nexport function SidebarTrigger({ className }) {\n const ctx = useSidebar();\n if (!ctx) return null;\n return (\n <>\n <IconButton\n icon=\"menu\"\n label=\"Open navigation\"\n aria-expanded={ctx.mobileOpen}\n onClick={() => ctx.setMobileOpen(true)}\n className={cx(\"md:hidden\", className)}\n />\n <IconButton\n icon=\"panel-left\"\n label={ctx.collapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n aria-expanded={!ctx.collapsed}\n onClick={() => ctx.setCollapsed(!ctx.collapsed)}\n className={cx(\"max-md:hidden\", className)}\n />\n </>\n );\n}\n\n\nconst keyOf = (item) => String(item.key ?? item.href ?? item.label);\nconst hasActive = (item) => Boolean(item.active || item.items?.some(hasActive));\nconst shows = (badge) => badge != null && badge !== \"\";\n\n// The icon rail's active look rides on aria-current, so the state a reader\n// hears is the state the eye sees. (A wide row is a nav Button, which\n// paints its own aria-current; an icon button is square, not a nav row.)\nconst CURRENT = \"aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground aria-[current=page]:font-semibold\";\n\n// The column's frame, per variant: a bordered column (today), a column on\n// the frame's own ground beside a page drawn as a card (inset), or a\n// rounded card held off the window's edge (floating).\nconst SHELLS = {\n sidebar: \"sticky top-0 max-h-dvh self-stretch border-r border-border bg-muted/40\",\n inset: \"sticky top-0 max-h-dvh self-stretch\",\n floating: \"sticky top-2 m-2 max-h-[calc(100dvh-1rem)] self-stretch overflow-hidden rounded-xl border border-border bg-card shadow-sm\",\n};\n\n// A row's \"…\" shows on hover or focus on a wide window, always where\n// there is no hover (a touch screen), and stays while its menu is open.\nconst REVEAL =\n \"opacity-0 transition-opacity motion-reduce:transition-none group-hover/row:opacity-100 group-focus-within/row:opacity-100 \" +\n \"has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100\";\n\n// An href-only item chosen from the ⌘K palette: a full page load, with\n// the Makersclaw app's base path when there is one.\nfunction follow(href) {\n if (typeof window === \"undefined\" || !href) return;\n const base = /^[a-z]+:|^\\/\\//i.test(href) ? \"\" : document.documentElement.dataset.clawBasePath ?? \"\";\n window.location.assign(base + href);\n}\n\nfunction focusSoon(id) {\n requestAnimationFrame(() => document.getElementById(id)?.focus());\n}\n\nexport function Sidebar({\n groups,\n items,\n title,\n subtitle,\n logo,\n switcher,\n switcherLabel = \"Switch workspace\",\n switcherPlacement = \"header\",\n user,\n userMenu,\n children,\n label = \"Main\",\n variant = \"sidebar\",\n headerActions,\n search,\n primaryAction,\n footerActions,\n footerActionsLabel = \"Create\",\n drilldown = \"inline\",\n panel,\n className,\n}) {\n const ctx = useSidebar();\n const uid = useId();\n const collapsed = Boolean(ctx?.collapsed);\n const list = (groups ?? (items ? [{ items }] : [])).filter((g) => g && g.items?.length);\n const drillMode = drilldown === \"panel\" || drilldown === \"replace\" ? drilldown : \"inline\";\n const shell = SHELLS[variant] ?? SHELLS.sidebar;\n // `panel` is a node of the app's own, or { title, search, items, … }.\n const panelIsNode = Boolean(panel) && (isValidElement(panel) || typeof panel !== \"object\" || Array.isArray(panel));\n\n // Open parents are held here so a click on a collapsed parent's icon can\n // widen the sidebar with that parent open. A parent holding the active\n // item starts open.\n const [open, setOpen] = useState(() => {\n const out = new Set();\n for (const g of list) for (const it of g.items) if (it.items?.length && hasActive(it)) out.add(keyOf(it));\n return out;\n });\n const setParent = (key, next) =>\n setOpen((s) => {\n const out = new Set(s);\n if (next) out.add(key);\n else out.delete(key);\n return out;\n });\n\n // Folding groups (group.collapsible): the ones closed, by group key.\n const groupKey = (group, gi) => String(group.key ?? group.label ?? gi);\n const [folded, setFolded] = useState(() => new Set(list.flatMap((g, gi) => (g.collapsible && g.defaultOpen === false ? [groupKey(g, gi)] : []))));\n const setGroup = (key, isOpen) =>\n setFolded((s) => {\n const out = new Set(s);\n if (isOpen) out.delete(key);\n else out.add(key);\n return out;\n });\n\n // Drill-in (drilldown=\"panel\" | \"replace\"): the parent whose children\n // are shown. Starts at the one holding the active item.\n const [drill, setDrill] = useState(() => {\n if (drillMode === \"inline\") return null;\n for (const g of list) for (const it of g.items) if (it.items?.length && hasActive(it)) return keyOf(it);\n return null;\n });\n const drillItem = drill == null ? null : list.flatMap((g) => g.items).find((it) => it.items?.length && keyOf(it) === drill) ?? null;\n\n // The phone sheet's second screen: the panel.\n const [sheetPanel, setSheetPanel] = useState(false);\n useEffect(() => {\n if (!ctx?.mobileOpen) setSheetPanel(false);\n }, [ctx?.mobileOpen]);\n\n // ⌘K search over every item.\n const [searching, setSearching] = useState(false);\n const searchOpts = search ? (typeof search === \"object\" ? search : {}) : null;\n const hotkey = searchOpts ? (searchOpts.hotkey === undefined ? \"k\" : searchOpts.hotkey) : null;\n const searchLabel = searchOpts?.label ?? \"Search\";\n\n function choose(item, { sheet } = {}) {\n item.onSelect?.(item);\n // With a panel, choosing on the phone shows the panel (the mails in\n // the folder just chosen) instead of closing the sheet.\n if (sheet && panel) setSheetPanel(true);\n else ctx?.setMobileOpen(false);\n }\n\n // A choice in the user menu also closes the phone sheet — through\n // groups and submenus too.\n const closingSheet = (entries) =>\n (entries ?? []).filter(Boolean).map((item) =>\n Array.isArray(item.items)\n ? { ...item, items: closingSheet(item.items) }\n : item.onSelect\n ? {\n ...item,\n onSelect: () => {\n ctx?.setMobileOpen(false);\n item.onSelect();\n },\n }\n : item,\n );\n\n const mark = logo ?? <Avatar name={title} shape=\"square\" size=\"sm\" />;\n\n function openDrill(key, where) {\n const next = drillMode === \"panel\" && drill === key ? null : key;\n setDrill(next);\n if (next == null) return;\n // Focus follows: to the back row (replace, or any drill on the phone),\n // or the first child in the second column.\n focusSoon(drillMode === \"replace\" || where === \"s\" ? `${uid}-${where}-back` : `${uid}-drill-first`);\n }\n\n function closeDrill(where) {\n const key = drill;\n setDrill(null);\n if (key != null) focusSoon(`${uid}-${where}-parent-${key}`);\n }\n\n // ---- one item, three ways: the wide column, the icon rail, the sheet --\n\n function leaf(item, { sheet, id }) {\n const current = item.active ? \"page\" : undefined;\n const button = (\n <Button\n id={id}\n variant=\"nav\"\n size={sheet ? \"md\" : \"sm\"}\n href={item.disabled ? undefined : item.href}\n onClick={() => choose(item, { sheet })}\n disabled={item.disabled}\n aria-current={current}\n className={item.description ? \"py-1.5\" : undefined}\n >\n {item.hue ? (\n <span className=\"inline-flex size-4 shrink-0 items-center justify-center\">\n <StatusDot hue={item.hue} size=\"md\" />\n </span>\n ) : item.icon ? (\n <Icon name={item.icon} className=\"shrink-0\" />\n ) : null}\n {item.description ? (\n <span className=\"grid min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate\">{item.label}</span>\n <span className=\"truncate text-xs text-muted-foreground\">{item.description}</span>\n </span>\n ) : (\n <span className=\"min-w-0 flex-1 truncate text-left\">{item.label}</span>\n )}\n {shows(item.badge) ? <Badge className=\"shrink-0\">{item.badge}</Badge> : null}\n </Button>\n );\n if (!item.actions?.length) return button;\n return (\n <div className=\"group/row flex min-w-0 items-center gap-0.5\">\n {button}\n <span className={cx(\"shrink-0\", !sheet && REVEAL)}>\n <Menu items={guard(item.actions, item)} label={`Actions for ${item.label}`} size={sheet ? \"md\" : \"sm\"} />\n </span>\n </div>\n );\n }\n\n // A parent in drill-in mode: a row that opens its children (a second\n // column, or in place of the list) instead of folding them in.\n function drillRow(item, { sheet, where }) {\n const key = keyOf(item);\n const inPanel = drillMode === \"panel\" && !sheet;\n return (\n <Button\n id={`${uid}-${where}-parent-${key}`}\n variant=\"nav\"\n size={sheet ? \"md\" : \"sm\"}\n onClick={() => openDrill(key, where)}\n disabled={item.disabled}\n aria-current={hasActive(item) ? \"true\" : undefined}\n aria-expanded={inPanel ? drill === key : undefined}\n className={inPanel ? \"aria-expanded:bg-accent\" : undefined}\n >\n {item.icon ? <Icon name={item.icon} className=\"shrink-0\" /> : null}\n <span className=\"min-w-0 flex-1 truncate text-left\">{item.label}</span>\n {shows(item.badge) ? <Badge className=\"shrink-0\">{item.badge}</Badge> : null}\n <Icon name=\"chevron-right\" className=\"shrink-0 text-muted-foreground\" />\n </Button>\n );\n }\n\n function wideItem(item, opts) {\n if (!item.items?.length) return leaf(item, opts);\n if (drillMode !== \"inline\") return drillRow(item, opts);\n const key = keyOf(item);\n return (\n <div>\n <Collapsible\n open={open.has(key)}\n onOpenChange={(next) => setParent(key, next)}\n meta={shows(item.badge) ? item.badge : undefined}\n title={\n <span className=\"flex min-w-0 items-center gap-3\">\n {item.icon ? <Icon name={item.icon} className=\"shrink-0\" /> : null}\n <span className=\"truncate\">{item.label}</span>\n </span>\n }\n className={cx(\n \"[&>summary]:mx-0 [&>div]:pb-1 [&>div]:pt-0.5\",\n opts.sheet ? \"[&>summary]:px-4\" : \"[&>summary]:min-h-9 [&>summary]:px-3 [&>summary]:py-1\",\n )}\n >\n <ul className=\"ml-5 grid gap-0.5 border-l border-border pl-2\">\n {item.items.map((child) => (\n <li key={keyOf(child)}>{leaf(child, opts)}</li>\n ))}\n </ul>\n </Collapsible>\n </div>\n );\n }\n\n function iconItem(item) {\n const parent = item.items?.length > 0;\n const count = shows(item.badge) ? item.badge : null;\n const current = item.active || (parent && hasActive(item)) ? \"page\" : undefined;\n return (\n <Tooltip label={count != null ? `${item.label} (${count})` : item.label} side=\"right\">\n <span className=\"relative inline-flex\">\n <IconButton\n icon={item.icon ?? \"file\"}\n label={item.label}\n size=\"sm\"\n href={parent || item.disabled ? undefined : item.href}\n disabled={item.disabled}\n aria-current={current}\n aria-expanded={parent && drillMode === \"panel\" ? drill === keyOf(item) : undefined}\n onClick={() => {\n if (!parent) return choose(item);\n // Drill-in to a column: the rail stays narrow beside it.\n if (drillMode === \"panel\") return openDrill(keyOf(item), \"r\");\n // Otherwise a parent's children need labels: widen, with it open.\n if (drillMode === \"replace\") setDrill(keyOf(item));\n else setParent(keyOf(item), true);\n ctx?.setCollapsed(false);\n }}\n className={cx(CURRENT, drillMode === \"panel\" && \"aria-expanded:bg-accent\")}\n />\n {count != null ? (\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute right-1 top-1 size-1.5 rounded-full bg-primary\" />\n ) : null}\n </span>\n </Tooltip>\n );\n }\n\n // A drilled parent's children in place of the list: a back row, the\n // parent's name, then its children.\n function replaced(item, { sheet, where }) {\n const headingId = `${uid}-${where}-drill`;\n return (\n <div className=\"grid gap-0.5\">\n <Button id={`${uid}-${where}-back`} variant=\"nav\" size={sheet ? \"md\" : \"sm\"} onClick={() => closeDrill(where)}>\n <Icon name=\"arrow-left\" className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">Back</span>\n </Button>\n <h2 id={headingId} className=\"flex h-8 items-center px-3 text-xs font-medium text-muted-foreground\">\n {item.label}\n </h2>\n <ul aria-labelledby={headingId} className=\"grid gap-0.5\">\n {item.items.map((child) => (\n <li key={keyOf(child)}>{leaf(child, { sheet })}</li>\n ))}\n </ul>\n </div>\n );\n }\n\n function primary({ icons, sheet }) {\n if (!primaryAction) return null;\n const act = () => {\n ctx?.setMobileOpen(false);\n return run(primaryAction.onSelect, primaryAction, primaryAction.label);\n };\n if (icons) {\n return (\n <Tooltip label={primaryAction.label} side=\"right\">\n <IconButton icon={primaryAction.icon ?? \"plus\"} label={primaryAction.label} size=\"sm\" variant=\"secondary\" href={primaryAction.href} onClick={act} />\n </Tooltip>\n );\n }\n return (\n <Button variant=\"secondary\" size={sheet ? \"md\" : \"sm\"} align=\"start\" icon={primaryAction.icon ?? \"plus\"} href={primaryAction.href} onClick={act} className=\"w-full\">\n {primaryAction.label}\n </Button>\n );\n }\n\n function nav({ sheet }) {\n const icons = collapsed && !sheet;\n const where = sheet ? \"s\" : \"r\";\n // Replace mode (and any drill-in on the phone): the drilled parent's\n // children stand in for the list.\n const drilledHere = drillItem && !icons && (drillMode === \"replace\" || (sheet && drillMode === \"panel\"));\n return (\n <nav aria-label={label} className={cx(\"grid gap-3\", icons ? \"justify-items-center py-2\" : \"px-2 py-2\")}>\n {primaryAction && !drilledHere ? primary({ icons, sheet }) : null}\n {drilledHere\n ? replaced(drillItem, { sheet, where })\n : list.map((group, gi) => {\n const headingId = `${uid}-${where}-${gi}`;\n const gk = groupKey(group, gi);\n const rows = (\n <ul\n aria-labelledby={group.label && !icons && !group.collapsible ? headingId : undefined}\n aria-label={group.label && !icons && group.collapsible ? group.label : undefined}\n className={cx(\"grid gap-0.5\", icons && \"justify-items-center\")}\n >\n {group.items.map((item) => (\n <li key={keyOf(item)}>{icons ? iconItem(item) : wideItem(item, { sheet, where })}</li>\n ))}\n </ul>\n );\n if (group.collapsible && group.label && !icons) {\n return (\n <Collapsible\n key={gk}\n open={!folded.has(gk)}\n onOpenChange={(next) => setGroup(gk, next)}\n meta={shows(group.badge) ? group.badge : undefined}\n title={<span className=\"truncate text-xs font-medium text-muted-foreground\">{group.label}</span>}\n className={cx(\"[&>summary]:mx-0 [&>div]:pb-0 [&>div]:pt-0.5\", sheet ? \"[&>summary]:px-4\" : \"[&>summary]:min-h-8 [&>summary]:px-3 [&>summary]:py-1\")}\n >\n {rows}\n </Collapsible>\n );\n }\n return (\n <div key={group.key ?? group.label ?? gi} className=\"grid gap-0.5\">\n {group.label && !icons ? (\n <h2 id={headingId} className=\"flex h-8 items-center px-3 text-xs font-medium text-muted-foreground\">\n {group.label}\n </h2>\n ) : icons && gi > 0 ? (\n <Separator className=\"mb-2 w-6\" />\n ) : null}\n {rows}\n </div>\n );\n })}\n {children && !icons && !drilledHere ? <div className=\"px-2\">{children}</div> : null}\n </nav>\n );\n }\n\n function brand({ icons, side = \"bottom\" }) {\n const words = (\n <span className=\"grid min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate text-sm font-semibold\">{title}</span>\n {subtitle ? <span className=\"truncate text-xs font-normal text-muted-foreground\">{subtitle}</span> : null}\n </span>\n );\n if (switcher?.length) {\n return (\n <Menu\n items={switcher}\n label={`${title}, ${switcherLabel}`}\n side={side === \"top\" && icons ? \"right\" : side}\n align={side === \"top\" && icons ? \"end\" : \"start\"}\n className={cx(\"min-h-12\", icons ? \"px-1.5\" : \"w-full px-2\")}\n trigger={\n <>\n {mark}\n {icons ? null : words}\n {icons ? null : <Icon name=\"chevrons-up-down\" className=\"shrink-0 text-muted-foreground\" />}\n </>\n }\n />\n );\n }\n return (\n <div className={cx(\"flex min-h-12 items-center gap-2\", icons ? \"justify-center\" : \"px-2\")}>\n {icons ? <Tooltip label={title} side=\"right\">{mark}</Tooltip> : mark}\n {icons ? null : words}\n </div>\n );\n }\n\n // The top of the column: the brand (or, with the switcher in the foot,\n // only the mark), then headerActions — a bell, a trigger.\n function head({ icons }) {\n const footSwitch = switcherPlacement === \"footer\" && switcher?.length;\n const top = footSwitch ? (\n <div className={cx(\"flex min-h-12 items-center\", icons ? \"justify-center\" : \"px-2\")}>\n {icons ? <Tooltip label={title} side=\"right\">{mark}</Tooltip> : mark}\n </div>\n ) : (\n brand({ icons })\n );\n if (icons || !headerActions) return top;\n return (\n <div className=\"flex items-center gap-1\">\n <div className=\"min-w-0 flex-1\">{top}</div>\n <div className=\"flex shrink-0 items-center gap-1\">{headerActions}</div>\n </div>\n );\n }\n\n function searchRow({ icons, sheet }) {\n if (!searchOpts) return null;\n const openIt = () => {\n ctx?.setMobileOpen(false);\n setSearching(true);\n };\n const keys = hotkey ? `Meta+${String(hotkey).toUpperCase()}` : undefined;\n if (icons) {\n return (\n <Tooltip label={searchLabel} side=\"right\">\n <IconButton icon=\"search\" label={searchLabel} size=\"sm\" onClick={openIt} aria-keyshortcuts={keys} />\n </Tooltip>\n );\n }\n return (\n <Button variant=\"secondary\" size={sheet ? \"md\" : \"sm\"} align=\"start\" onClick={openIt} aria-keyshortcuts={keys} className=\"w-full\">\n <Icon name=\"search\" className=\"shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate text-left font-normal text-muted-foreground\">{searchOpts.placeholder ?? `${searchLabel}…`}</span>\n {hotkey && !sheet ? <KbdGroup keys={[\"⌘\", String(hotkey).toUpperCase()]} /> : null}\n </Button>\n );\n }\n\n // Every item, for the palette: a group's items under its label, a\n // parent's children under the parent's name.\n const searchItems = searchOpts\n ? list.flatMap((group, gi) =>\n group.items.flatMap((item) => {\n const own = (it, parent) => ({\n value: `${gi}:${parent ? `${keyOf(parent)}/` : \"\"}${keyOf(it)}`,\n label: it.label,\n description: parent ? parent.label : it.description,\n icon: it.hue ? <StatusDot hue={it.hue} /> : it.icon,\n group: group.label ?? searchOpts.groupLabel ?? \"Go to\",\n keywords: it.keywords,\n disabled: it.disabled,\n onSelect: () => {\n if (it.onSelect) it.onSelect(it);\n else follow(it.href);\n },\n });\n if (!item.items?.length) return [own(item)];\n return [...(item.onSelect || item.href ? [own(item)] : []), ...item.items.map((child) => own(child, item))];\n }),\n )\n : [];\n\n function person({ icons }) {\n if (!user) return null;\n const face = <Avatar name={user.name} src={user.src} size=\"sm\" />;\n const words = (\n <span className=\"grid min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate text-sm font-medium\">{user.name}</span>\n {user.email ? <span className=\"truncate text-xs font-normal text-muted-foreground\">{user.email}</span> : null}\n </span>\n );\n if (!userMenu?.length) {\n return <div className={cx(\"flex min-h-12 items-center gap-2\", icons ? \"justify-center\" : \"px-2\")}>{face}{icons ? null : words}</div>;\n }\n // Opens upward from the foot of the window (beside the rail when it is\n // icons), and flips where there is no room.\n return (\n <Menu\n side={icons ? \"right\" : \"top\"}\n align={icons ? \"end\" : \"start\"}\n label={`${user.name}, account menu`}\n items={closingSheet(userMenu)}\n className={cx(\"min-h-12\", icons ? \"px-1.5\" : \"w-full px-2\")}\n trigger={\n <>\n {face}\n {icons ? null : words}\n {icons ? null : <Icon name=\"chevrons-up-down\" className=\"shrink-0 text-muted-foreground\" />}\n </>\n }\n />\n );\n }\n\n // The foot: the switcher (when it lives here), the person, and the \"+\"\n // menu of footerActions beside them.\n function foot({ icons, sheet }) {\n const footSwitch = switcherPlacement === \"footer\" && switcher?.length && !sheet;\n const plus = footerActions?.length ? (\n <Menu\n items={closingSheet(guard(footerActions))}\n label={footerActionsLabel}\n side={icons ? \"right\" : \"top\"}\n align=\"end\"\n size={sheet ? \"md\" : \"sm\"}\n trigger={<Icon name=\"plus\" />}\n />\n ) : null;\n if (!user && !plus && !footSwitch) return null;\n return (\n <div className=\"grid gap-1\">\n {footSwitch ? brand({ icons, side: \"top\" }) : null}\n {user || plus ? (\n <div className={cx(\"flex items-center gap-1\", icons && \"flex-col\")}>\n {user ? <div className={cx(\"min-w-0\", !icons && \"flex-1\")}>{person({ icons })}</div> : null}\n {plus}\n </div>\n ) : null}\n </div>\n );\n }\n\n // The second column from 768px: a drilled parent's children\n // (drilldown=\"panel\"), else the panel.\n function secondColumn() {\n if (drillMode === \"panel\" && drillItem) {\n const headingId = `${uid}-drill-title`;\n return (\n <div\n className=\"flex min-h-0 flex-col\"\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.stopPropagation();\n closeDrill(\"r\");\n }\n }}\n >\n <div className=\"flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3\">\n <h2 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {drillItem.label}\n </h2>\n <IconButton icon=\"x\" label={`Close ${drillItem.label}`} size=\"sm\" onClick={() => closeDrill(\"r\")} />\n </div>\n <ScrollArea label={drillItem.label} className=\"min-h-0 flex-1\" fade>\n <ul aria-labelledby={headingId} className=\"grid gap-0.5 p-2\">\n {drillItem.items.map((child, i) => (\n <li key={keyOf(child)}>{leaf(child, { sheet: false, id: i === 0 ? `${uid}-drill-first` : undefined })}</li>\n ))}\n </ul>\n </ScrollArea>\n </div>\n );\n }\n if (!panel) return null;\n if (panelIsNode) return panel;\n return <PanelBody panel={panel} onChoose={() => ctx?.setMobileOpen(false)} />;\n }\n\n const inLayout = Boolean(ctx);\n const second = secondColumn();\n const hasFoot = Boolean(user || footerActions?.length || (switcherPlacement === \"footer\" && switcher?.length));\n return (\n <>\n <div\n data-sidebar=\"\"\n data-collapsed={collapsed ? \"\" : undefined}\n data-variant={variant !== \"sidebar\" ? variant : undefined}\n className={cx(inLayout ? \"hidden md:flex\" : \"flex\", \"shrink-0\", shell, className)}\n >\n <div\n className={cx(\n \"flex min-h-0 shrink-0 flex-col transition-[width] duration-200 ease-out motion-reduce:transition-none\",\n collapsed ? \"w-14\" : \"w-64\",\n )}\n >\n <div className={cx(\"shrink-0 py-2\", collapsed ? \"flex justify-center\" : \"px-2\")}>{head({ icons: collapsed })}</div>\n {searchOpts ? <div className={cx(\"shrink-0 pb-1\", collapsed ? \"flex justify-center\" : \"px-2\")}>{searchRow({ icons: collapsed })}</div> : null}\n <ScrollArea label={`${label} navigation`} className=\"flex-1\" fade>\n {nav({ sheet: false })}\n </ScrollArea>\n {hasFoot ? (\n <div className={cx(\"shrink-0 border-t border-border py-2\", collapsed ? \"flex justify-center\" : \"px-2\")}>\n {foot({ icons: collapsed })}\n </div>\n ) : null}\n </div>\n {second ? (\n <div className={cx(\"flex w-80 min-h-0 shrink-0 flex-col border-l border-border\", variant === \"sidebar\" && \"bg-background\")}>{second}</div>\n ) : null}\n </div>\n {searchOpts ? (\n <CommandDialog\n open={searching}\n onOpenChange={setSearching}\n hotkey={hotkey || undefined}\n label={searchOpts.label ?? `Search ${title ?? label}`}\n placeholder={searchOpts.placeholder ?? \"Go to…\"}\n empty={searchOpts.empty ?? \"Nothing by that name.\"}\n items={searchItems}\n />\n ) : null}\n {inLayout ? (\n <Sheet\n side=\"start\"\n open={ctx.mobileOpen}\n onClose={() => ctx.setMobileOpen(false)}\n title={title}\n description={subtitle}\n footer={user || footerActions?.length ? <div className=\"w-full\">{foot({ icons: false, sheet: true })}</div> : null}\n >\n {ctx.mobileOpen ? (\n sheetPanel && panel ? (\n <div className=\"-mx-2 -my-2 grid gap-2\">\n <div className=\"px-2 pt-2\">\n <Button variant=\"nav\" size=\"md\" onClick={() => setSheetPanel(false)}>\n <Icon name=\"arrow-left\" className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">Back</span>\n </Button>\n </div>\n {panelIsNode ? panel : <PanelBody panel={panel} sheet onChoose={() => ctx.setMobileOpen(false)} />}\n </div>\n ) : (\n <div className=\"-mx-2 -my-2 grid gap-2\">\n {headerActions ? <div className=\"flex flex-wrap items-center gap-1 px-2\">{headerActions}</div> : null}\n {searchOpts ? <div className=\"px-2\">{searchRow({ icons: false, sheet: true })}</div> : null}\n {switcher?.length ? (\n // The sheet's header already names the workspace: here the\n // switcher is just the way to change it.\n <div className=\"px-2\">\n <Menu\n items={switcher}\n label={switcherLabel}\n align=\"start\"\n className=\"min-h-11 w-full px-2\"\n trigger={\n <>\n <Icon name=\"chevrons-up-down\" className=\"shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{switcherLabel}</span>\n </>\n }\n />\n </div>\n ) : null}\n {panel ? (\n <div className=\"px-2\">\n <Button variant=\"nav\" size=\"md\" onClick={() => setSheetPanel(true)}>\n <Icon name=\"list\" className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{(panelIsNode ? null : panel.title) ?? \"Open list\"}</span>\n <Icon name=\"chevron-right\" className=\"shrink-0 text-muted-foreground\" />\n </Button>\n </div>\n ) : null}\n {nav({ sheet: true })}\n </div>\n )\n ) : null}\n </Sheet>\n ) : null}\n </>\n );\n}\n"
22
+ "content": "\"use client\";\n\nimport { createContext, isValidElement, useContext, useEffect, useId, useState } from \"react\";\nimport {\n Avatar,\n Badge,\n Button,\n Collapsible,\n CommandDialog,\n Icon,\n IconButton,\n KbdGroup,\n Menu,\n ScrollArea,\n Separator,\n Sheet,\n StatusDot,\n Tooltip,\n cx,\n} from \"../../ui\";\nimport { PanelBody, guard, run } from \"./sidebar-panel\";\n\n// Sidebar — an app's navigation down the left edge: the app's name (and a\n// workspace switcher when there is more than one), groups of items with\n// icons, counts and nested items, a custom slot, and the signed-in person\n// at the foot with their menu. The block owns what a hand-drawn sidebar\n// gets wrong: the active item said to a screen reader (aria-current), a\n// collapse to icons that keeps every label (as a tooltip) and is\n// remembered, and a phone layout where the sidebar is a Sheet behind a\n// trigger instead of a column squeezing the page. The app owns the items\n// (data in) and what choosing one does (href or onSelect).\n//\n// <SidebarLayout storageKey=\"crm.sidebar\" sidebar={<Sidebar title=\"Harbor CRM\" groups={NAV} user={me} />}>\n// <header><SidebarTrigger /> …</header>\n// …the page…\n// </SidebarLayout>\n//\n// Beyond the column: inset and floating looks, a ⌘K search over every\n// item, folding groups, a second column (the app's panel, or a parent's\n// children to drill into), a primary button, and a \"…\" menu per item.\n//\n// README.md beside this file lists every prop.\n\nconst WIDE = \"(min-width: 768px)\";\n\nconst SidebarContext = createContext(null);\n\n/** The layout's state, for a control of your own: { collapsed,\n * setCollapsed, toggle, mobileOpen, setMobileOpen }. Null outside a\n * SidebarLayout. */\nexport function useSidebar() {\n return useContext(SidebarContext);\n}\n\nfunction readStored(key) {\n try {\n const v = window.localStorage.getItem(key);\n return v === \"1\" ? true : v === \"0\" ? false : null;\n } catch {\n return null;\n }\n}\n\nfunction writeStored(key, value) {\n try {\n window.localStorage.setItem(key, value ? \"1\" : \"0\");\n } catch {\n // private window, blocked storage: the choice lasts this visit only\n }\n}\n\nexport function SidebarLayout({\n sidebar,\n children,\n collapsed,\n defaultCollapsed = false,\n onCollapsedChange,\n storageKey,\n shortcut = \"b\",\n variant,\n className,\n}) {\n // The look is the Sidebar's `variant` unless the layout is given one:\n // inset needs the page drawn as a card, so the frame has to know.\n const look = variant ?? (isValidElement(sidebar) ? sidebar.props?.variant : undefined);\n const controlled = collapsed !== undefined;\n const [own, setOwn] = useState(defaultCollapsed);\n const [mobileOpen, setMobileOpen] = useState(false);\n const isCollapsed = controlled ? collapsed : own;\n\n // Stored after mount: the server has no storage, so the first paint is\n // the default and the remembered width follows at once.\n useEffect(() => {\n if (!storageKey || controlled) return;\n const stored = readStored(storageKey);\n if (stored !== null) setOwn(stored);\n }, [storageKey, controlled]);\n\n // Widening past the phone layout closes the sheet: the column is back.\n useEffect(() => {\n const mq = window.matchMedia(WIDE);\n const onChange = () => mq.matches && setMobileOpen(false);\n mq.addEventListener(\"change\", onChange);\n return () => mq.removeEventListener(\"change\", onChange);\n }, []);\n\n function setCollapsed(next) {\n const value = typeof next === \"function\" ? next(isCollapsed) : Boolean(next);\n if (!controlled) setOwn(value);\n if (storageKey) writeStored(storageKey, value);\n onCollapsedChange?.(value);\n }\n\n function toggle() {\n if (window.matchMedia(WIDE).matches) setCollapsed(!isCollapsed);\n else setMobileOpen((o) => !o);\n }\n\n // Cmd/Ctrl + B, as in most editors — never while typing in a field.\n useEffect(() => {\n if (!shortcut) return undefined;\n const onKey = (e) => {\n if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey || e.key.toLowerCase() !== shortcut) return;\n if (e.target.closest?.(\"input, textarea, select, [contenteditable='true']\")) return;\n e.preventDefault();\n toggle();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n });\n\n const value = { collapsed: isCollapsed, setCollapsed, toggle, mobileOpen, setMobileOpen };\n return (\n <SidebarContext.Provider value={value}>\n <div className={cx(\"flex w-full text-foreground\", look === \"inset\" ? \"bg-background md:bg-muted/40\" : \"bg-background\", className)}>\n {sidebar}\n <div\n className={cx(\n \"flex min-w-0 flex-1 flex-col\",\n look === \"inset\" && \"bg-background md:my-2 md:mr-2 md:rounded-xl md:border md:border-border md:shadow-sm\",\n )}\n >\n {children}\n </div>\n </div>\n </SidebarContext.Provider>\n );\n}\n\n/** The one button that opens the sidebar on a phone and collapses it to\n * icons on a wider window. Put it in the page's header. */\nexport function SidebarTrigger({ className }) {\n const ctx = useSidebar();\n if (!ctx) return null;\n return (\n <>\n <IconButton\n icon=\"menu\"\n label=\"Open navigation\"\n aria-expanded={ctx.mobileOpen}\n onClick={() => ctx.setMobileOpen(true)}\n className={cx(\"md:hidden\", className)}\n />\n <IconButton\n icon=\"panel-left\"\n label={ctx.collapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n aria-expanded={!ctx.collapsed}\n onClick={() => ctx.setCollapsed(!ctx.collapsed)}\n className={cx(\"max-md:hidden\", className)}\n />\n </>\n );\n}\n\n\nconst keyOf = (item) => String(item.key ?? item.href ?? item.label);\nconst hasActive = (item) => Boolean(item.active || item.items?.some(hasActive));\nconst shows = (badge) => badge != null && badge !== \"\";\n\n// The icon rail's active look rides on aria-current, so the state a reader\n// hears is the state the eye sees. (A wide row is a nav Button, which\n// paints its own aria-current; an icon button is square, not a nav row.)\nconst CURRENT = \"aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground aria-[current=page]:font-semibold\";\n\n// The column's frame, per variant: a bordered column (today), a column on\n// the frame's own ground beside a page drawn as a card (inset), or a\n// rounded card held off the window's edge (floating).\nconst SHELLS = {\n sidebar: \"sticky top-0 max-h-dvh self-stretch border-r border-border bg-muted/40\",\n inset: \"sticky top-0 max-h-dvh self-stretch\",\n floating: \"sticky top-2 m-2 max-h-[calc(100dvh-1rem)] self-stretch overflow-hidden rounded-xl border border-border bg-card shadow-sm\",\n};\n\n// A row's \"…\" shows on hover or focus on a wide window, always where\n// there is no hover (a touch screen), and stays while its menu is open.\nconst REVEAL =\n \"opacity-0 transition-opacity motion-reduce:transition-none group-hover/row:opacity-100 group-focus-within/row:opacity-100 \" +\n \"has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100\";\n\n// An href-only item chosen from the ⌘K palette: a full page load, with\n// the Makersclaw app's base path when there is one.\nfunction follow(href) {\n if (typeof window === \"undefined\" || !href) return;\n const base = /^[a-z]+:|^\\/\\//i.test(href) ? \"\" : document.documentElement.dataset.clawBasePath ?? \"\";\n window.location.assign(base + href);\n}\n\nfunction focusSoon(id) {\n requestAnimationFrame(() => document.getElementById(id)?.focus());\n}\n\nexport function Sidebar({\n groups,\n items,\n title,\n subtitle,\n logo,\n switcher,\n switcherLabel = \"Switch workspace\",\n switcherPlacement = \"header\",\n user,\n userMenu,\n children,\n label = \"Main\",\n variant = \"sidebar\",\n headerActions,\n search,\n primaryAction,\n footerActions,\n footerActionsLabel = \"Create\",\n drilldown = \"inline\",\n panel,\n className,\n}) {\n const ctx = useSidebar();\n const uid = useId();\n const collapsed = Boolean(ctx?.collapsed);\n const list = (groups ?? (items ? [{ items }] : [])).filter((g) => g && g.items?.length);\n const drillMode = drilldown === \"panel\" || drilldown === \"replace\" ? drilldown : \"inline\";\n const shell = SHELLS[variant] ?? SHELLS.sidebar;\n // `panel` is a node of the app's own, or { title, search, items, … }.\n const panelIsNode = Boolean(panel) && (isValidElement(panel) || typeof panel !== \"object\" || Array.isArray(panel));\n\n // Open parents are held here so a click on a collapsed parent's icon can\n // widen the sidebar with that parent open. A parent holding the active\n // item starts open.\n const [open, setOpen] = useState(() => {\n const out = new Set();\n for (const g of list) for (const it of g.items) if (it.items?.length && hasActive(it)) out.add(keyOf(it));\n return out;\n });\n const setParent = (key, next) =>\n setOpen((s) => {\n const out = new Set(s);\n if (next) out.add(key);\n else out.delete(key);\n return out;\n });\n\n // Folding groups (group.collapsible): the ones closed, by group key.\n const groupKey = (group, gi) => String(group.key ?? group.label ?? gi);\n const [folded, setFolded] = useState(() => new Set(list.flatMap((g, gi) => (g.collapsible && g.defaultOpen === false ? [groupKey(g, gi)] : []))));\n const setGroup = (key, isOpen) =>\n setFolded((s) => {\n const out = new Set(s);\n if (isOpen) out.delete(key);\n else out.add(key);\n return out;\n });\n\n // Drill-in (drilldown=\"panel\" | \"replace\"): the parent whose children\n // are shown. Starts at the one holding the active item.\n const [drill, setDrill] = useState(() => {\n if (drillMode === \"inline\") return null;\n for (const g of list) for (const it of g.items) if (it.items?.length && hasActive(it)) return keyOf(it);\n return null;\n });\n const drillItem = drill == null ? null : list.flatMap((g) => g.items).find((it) => it.items?.length && keyOf(it) === drill) ?? null;\n\n // The phone sheet's second screen: the panel.\n const [sheetPanel, setSheetPanel] = useState(false);\n useEffect(() => {\n if (!ctx?.mobileOpen) setSheetPanel(false);\n }, [ctx?.mobileOpen]);\n\n // ⌘K search over every item.\n const [searching, setSearching] = useState(false);\n const searchOpts = search ? (typeof search === \"object\" ? search : {}) : null;\n const hotkey = searchOpts ? (searchOpts.hotkey === undefined ? \"k\" : searchOpts.hotkey) : null;\n const searchLabel = searchOpts?.label ?? \"Search\";\n\n function choose(item, { sheet } = {}) {\n item.onSelect?.(item);\n // With a panel, choosing on the phone shows the panel (the mails in\n // the folder just chosen) instead of closing the sheet.\n if (sheet && panel) setSheetPanel(true);\n else ctx?.setMobileOpen(false);\n }\n\n // A choice in the user menu also closes the phone sheet — through\n // groups and submenus too.\n const closingSheet = (entries) =>\n (entries ?? []).filter(Boolean).map((item) =>\n Array.isArray(item.items)\n ? { ...item, items: closingSheet(item.items) }\n : item.onSelect\n ? {\n ...item,\n onSelect: () => {\n ctx?.setMobileOpen(false);\n item.onSelect();\n },\n }\n : item,\n );\n\n const mark = logo ?? <Avatar name={title} shape=\"square\" size=\"sm\" />;\n\n function openDrill(key, where) {\n const next = drillMode === \"panel\" && drill === key ? null : key;\n setDrill(next);\n if (next == null) return;\n // Focus follows: to the back row (replace, or any drill on the phone),\n // or the first child in the second column.\n focusSoon(drillMode === \"replace\" || where === \"s\" ? `${uid}-${where}-back` : `${uid}-drill-first`);\n }\n\n function closeDrill(where) {\n const key = drill;\n setDrill(null);\n if (key != null) focusSoon(`${uid}-${where}-parent-${key}`);\n }\n\n // ---- one item, three ways: the wide column, the icon rail, the sheet --\n\n function leaf(item, { sheet, id }) {\n const current = item.active ? \"page\" : undefined;\n const button = (\n <Button\n id={id}\n variant=\"nav\"\n size={sheet ? \"md\" : \"sm\"}\n href={item.disabled ? undefined : item.href}\n onClick={() => choose(item, { sheet })}\n disabled={item.disabled}\n aria-current={current}\n className={item.description ? \"py-1.5\" : undefined}\n >\n {item.hue ? (\n <span className=\"inline-flex size-4 shrink-0 items-center justify-center\">\n <StatusDot hue={item.hue} size=\"md\" />\n </span>\n ) : item.icon ? (\n <Icon name={item.icon} className=\"shrink-0\" />\n ) : null}\n {item.description ? (\n <span className=\"grid grid-cols-1 min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate\">{item.label}</span>\n <span className=\"truncate text-xs text-muted-foreground\">{item.description}</span>\n </span>\n ) : (\n <span className=\"min-w-0 flex-1 truncate text-left\">{item.label}</span>\n )}\n {shows(item.badge) ? <Badge className=\"shrink-0\">{item.badge}</Badge> : null}\n </Button>\n );\n if (!item.actions?.length) return button;\n return (\n <div className=\"group/row flex min-w-0 items-center gap-0.5\">\n {button}\n <span className={cx(\"shrink-0\", !sheet && REVEAL)}>\n <Menu items={guard(item.actions, item)} label={`Actions for ${item.label}`} size={sheet ? \"md\" : \"sm\"} />\n </span>\n </div>\n );\n }\n\n // A parent in drill-in mode: a row that opens its children (a second\n // column, or in place of the list) instead of folding them in.\n function drillRow(item, { sheet, where }) {\n const key = keyOf(item);\n const inPanel = drillMode === \"panel\" && !sheet;\n return (\n <Button\n id={`${uid}-${where}-parent-${key}`}\n variant=\"nav\"\n size={sheet ? \"md\" : \"sm\"}\n onClick={() => openDrill(key, where)}\n disabled={item.disabled}\n aria-current={hasActive(item) ? \"true\" : undefined}\n aria-expanded={inPanel ? drill === key : undefined}\n className={inPanel ? \"aria-expanded:bg-accent\" : undefined}\n >\n {item.icon ? <Icon name={item.icon} className=\"shrink-0\" /> : null}\n <span className=\"min-w-0 flex-1 truncate text-left\">{item.label}</span>\n {shows(item.badge) ? <Badge className=\"shrink-0\">{item.badge}</Badge> : null}\n <Icon name=\"chevron-right\" className=\"shrink-0 text-muted-foreground\" />\n </Button>\n );\n }\n\n function wideItem(item, opts) {\n if (!item.items?.length) return leaf(item, opts);\n if (drillMode !== \"inline\") return drillRow(item, opts);\n const key = keyOf(item);\n return (\n <div>\n <Collapsible\n open={open.has(key)}\n onOpenChange={(next) => setParent(key, next)}\n meta={shows(item.badge) ? item.badge : undefined}\n title={\n <span className=\"flex min-w-0 items-center gap-3\">\n {item.icon ? <Icon name={item.icon} className=\"shrink-0\" /> : null}\n <span className=\"truncate\">{item.label}</span>\n </span>\n }\n className={cx(\n \"[&>summary]:mx-0 [&>div]:pb-1 [&>div]:pt-0.5\",\n opts.sheet ? \"[&>summary]:px-4\" : \"[&>summary]:min-h-9 [&>summary]:px-3 [&>summary]:py-1\",\n )}\n >\n <ul className=\"ml-5 grid grid-cols-1 gap-0.5 border-l border-border pl-2\">\n {item.items.map((child) => (\n <li key={keyOf(child)}>{leaf(child, opts)}</li>\n ))}\n </ul>\n </Collapsible>\n </div>\n );\n }\n\n function iconItem(item) {\n const parent = item.items?.length > 0;\n const count = shows(item.badge) ? item.badge : null;\n const current = item.active || (parent && hasActive(item)) ? \"page\" : undefined;\n return (\n <Tooltip label={count != null ? `${item.label} (${count})` : item.label} side=\"right\">\n <span className=\"relative inline-flex\">\n <IconButton\n icon={item.icon ?? \"file\"}\n label={item.label}\n size=\"sm\"\n href={parent || item.disabled ? undefined : item.href}\n disabled={item.disabled}\n aria-current={current}\n aria-expanded={parent && drillMode === \"panel\" ? drill === keyOf(item) : undefined}\n onClick={() => {\n if (!parent) return choose(item);\n // Drill-in to a column: the rail stays narrow beside it.\n if (drillMode === \"panel\") return openDrill(keyOf(item), \"r\");\n // Otherwise a parent's children need labels: widen, with it open.\n if (drillMode === \"replace\") setDrill(keyOf(item));\n else setParent(keyOf(item), true);\n ctx?.setCollapsed(false);\n }}\n className={cx(CURRENT, drillMode === \"panel\" && \"aria-expanded:bg-accent\")}\n />\n {count != null ? (\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute right-1 top-1 size-1.5 rounded-full bg-primary\" />\n ) : null}\n </span>\n </Tooltip>\n );\n }\n\n // A drilled parent's children in place of the list: a back row, the\n // parent's name, then its children.\n function replaced(item, { sheet, where }) {\n const headingId = `${uid}-${where}-drill`;\n return (\n <div className=\"grid grid-cols-1 gap-0.5\">\n <Button id={`${uid}-${where}-back`} variant=\"nav\" size={sheet ? \"md\" : \"sm\"} onClick={() => closeDrill(where)}>\n <Icon name=\"arrow-left\" className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">Back</span>\n </Button>\n <h2 id={headingId} className=\"flex h-8 items-center px-3 text-xs font-medium text-muted-foreground\">\n {item.label}\n </h2>\n <ul aria-labelledby={headingId} className=\"grid grid-cols-1 gap-0.5\">\n {item.items.map((child) => (\n <li key={keyOf(child)}>{leaf(child, { sheet })}</li>\n ))}\n </ul>\n </div>\n );\n }\n\n function primary({ icons, sheet }) {\n if (!primaryAction) return null;\n const act = () => {\n ctx?.setMobileOpen(false);\n return run(primaryAction.onSelect, primaryAction, primaryAction.label);\n };\n if (icons) {\n return (\n <Tooltip label={primaryAction.label} side=\"right\">\n <IconButton icon={primaryAction.icon ?? \"plus\"} label={primaryAction.label} size=\"sm\" variant=\"secondary\" href={primaryAction.href} onClick={act} />\n </Tooltip>\n );\n }\n return (\n <Button variant=\"secondary\" size={sheet ? \"md\" : \"sm\"} align=\"start\" icon={primaryAction.icon ?? \"plus\"} href={primaryAction.href} onClick={act} className=\"w-full\">\n {primaryAction.label}\n </Button>\n );\n }\n\n function nav({ sheet }) {\n const icons = collapsed && !sheet;\n const where = sheet ? \"s\" : \"r\";\n // Replace mode (and any drill-in on the phone): the drilled parent's\n // children stand in for the list.\n const drilledHere = drillItem && !icons && (drillMode === \"replace\" || (sheet && drillMode === \"panel\"));\n return (\n <nav aria-label={label} className={cx(\"grid grid-cols-1 gap-3\", icons ? \"justify-items-center py-2\" : \"px-2 py-2\")}>\n {primaryAction && !drilledHere ? primary({ icons, sheet }) : null}\n {drilledHere\n ? replaced(drillItem, { sheet, where })\n : list.map((group, gi) => {\n const headingId = `${uid}-${where}-${gi}`;\n const gk = groupKey(group, gi);\n const rows = (\n <ul\n aria-labelledby={group.label && !icons && !group.collapsible ? headingId : undefined}\n aria-label={group.label && !icons && group.collapsible ? group.label : undefined}\n className={cx(\"grid grid-cols-1 gap-0.5\", icons && \"justify-items-center\")}\n >\n {group.items.map((item) => (\n <li key={keyOf(item)}>{icons ? iconItem(item) : wideItem(item, { sheet, where })}</li>\n ))}\n </ul>\n );\n if (group.collapsible && group.label && !icons) {\n return (\n <Collapsible\n key={gk}\n open={!folded.has(gk)}\n onOpenChange={(next) => setGroup(gk, next)}\n meta={shows(group.badge) ? group.badge : undefined}\n title={<span className=\"truncate text-xs font-medium text-muted-foreground\">{group.label}</span>}\n className={cx(\"[&>summary]:mx-0 [&>div]:pb-0 [&>div]:pt-0.5\", sheet ? \"[&>summary]:px-4\" : \"[&>summary]:min-h-8 [&>summary]:px-3 [&>summary]:py-1\")}\n >\n {rows}\n </Collapsible>\n );\n }\n return (\n <div key={group.key ?? group.label ?? gi} className=\"grid grid-cols-1 gap-0.5\">\n {group.label && !icons ? (\n <h2 id={headingId} className=\"flex h-8 items-center px-3 text-xs font-medium text-muted-foreground\">\n {group.label}\n </h2>\n ) : icons && gi > 0 ? (\n <Separator className=\"mb-2 w-6\" />\n ) : null}\n {rows}\n </div>\n );\n })}\n {children && !icons && !drilledHere ? <div className=\"px-2\">{children}</div> : null}\n </nav>\n );\n }\n\n function brand({ icons, side = \"bottom\" }) {\n const words = (\n <span className=\"grid grid-cols-1 min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate text-sm font-semibold\">{title}</span>\n {subtitle ? <span className=\"truncate text-xs font-normal text-muted-foreground\">{subtitle}</span> : null}\n </span>\n );\n if (switcher?.length) {\n return (\n <Menu\n items={switcher}\n label={`${title}, ${switcherLabel}`}\n side={side === \"top\" && icons ? \"right\" : side}\n align={side === \"top\" && icons ? \"end\" : \"start\"}\n className={cx(\"min-h-12\", icons ? \"px-1.5\" : \"w-full px-2\")}\n trigger={\n <>\n {mark}\n {icons ? null : words}\n {icons ? null : <Icon name=\"chevrons-up-down\" className=\"shrink-0 text-muted-foreground\" />}\n </>\n }\n />\n );\n }\n return (\n <div className={cx(\"flex min-h-12 items-center gap-2\", icons ? \"justify-center\" : \"px-2\")}>\n {icons ? <Tooltip label={title} side=\"right\">{mark}</Tooltip> : mark}\n {icons ? null : words}\n </div>\n );\n }\n\n // The top of the column: the brand (or, with the switcher in the foot,\n // only the mark), then headerActions — a bell, a trigger.\n function head({ icons }) {\n const footSwitch = switcherPlacement === \"footer\" && switcher?.length;\n const top = footSwitch ? (\n <div className={cx(\"flex min-h-12 items-center\", icons ? \"justify-center\" : \"px-2\")}>\n {icons ? <Tooltip label={title} side=\"right\">{mark}</Tooltip> : mark}\n </div>\n ) : (\n brand({ icons })\n );\n if (icons || !headerActions) return top;\n return (\n <div className=\"flex items-center gap-1\">\n <div className=\"min-w-0 flex-1\">{top}</div>\n <div className=\"flex shrink-0 items-center gap-1\">{headerActions}</div>\n </div>\n );\n }\n\n function searchRow({ icons, sheet }) {\n if (!searchOpts) return null;\n const openIt = () => {\n ctx?.setMobileOpen(false);\n setSearching(true);\n };\n const keys = hotkey ? `Meta+${String(hotkey).toUpperCase()}` : undefined;\n if (icons) {\n return (\n <Tooltip label={searchLabel} side=\"right\">\n <IconButton icon=\"search\" label={searchLabel} size=\"sm\" onClick={openIt} aria-keyshortcuts={keys} />\n </Tooltip>\n );\n }\n return (\n <Button variant=\"secondary\" size={sheet ? \"md\" : \"sm\"} align=\"start\" onClick={openIt} aria-keyshortcuts={keys} className=\"w-full\">\n <Icon name=\"search\" className=\"shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate text-left font-normal text-muted-foreground\">{searchOpts.placeholder ?? `${searchLabel}…`}</span>\n {hotkey && !sheet ? <KbdGroup keys={[\"⌘\", String(hotkey).toUpperCase()]} /> : null}\n </Button>\n );\n }\n\n // Every item, for the palette: a group's items under its label, a\n // parent's children under the parent's name.\n const searchItems = searchOpts\n ? list.flatMap((group, gi) =>\n group.items.flatMap((item) => {\n const own = (it, parent) => ({\n value: `${gi}:${parent ? `${keyOf(parent)}/` : \"\"}${keyOf(it)}`,\n label: it.label,\n description: parent ? parent.label : it.description,\n icon: it.hue ? <StatusDot hue={it.hue} /> : it.icon,\n group: group.label ?? searchOpts.groupLabel ?? \"Go to\",\n keywords: it.keywords,\n disabled: it.disabled,\n onSelect: () => {\n if (it.onSelect) it.onSelect(it);\n else follow(it.href);\n },\n });\n if (!item.items?.length) return [own(item)];\n return [...(item.onSelect || item.href ? [own(item)] : []), ...item.items.map((child) => own(child, item))];\n }),\n )\n : [];\n\n function person({ icons }) {\n if (!user) return null;\n const face = <Avatar name={user.name} src={user.src} size=\"sm\" />;\n const words = (\n <span className=\"grid grid-cols-1 min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate text-sm font-medium\">{user.name}</span>\n {user.email ? <span className=\"truncate text-xs font-normal text-muted-foreground\">{user.email}</span> : null}\n </span>\n );\n if (!userMenu?.length) {\n return <div className={cx(\"flex min-h-12 items-center gap-2\", icons ? \"justify-center\" : \"px-2\")}>{face}{icons ? null : words}</div>;\n }\n // Opens upward from the foot of the window (beside the rail when it is\n // icons), and flips where there is no room.\n return (\n <Menu\n side={icons ? \"right\" : \"top\"}\n align={icons ? \"end\" : \"start\"}\n label={`${user.name}, account menu`}\n items={closingSheet(userMenu)}\n className={cx(\"min-h-12\", icons ? \"px-1.5\" : \"w-full px-2\")}\n trigger={\n <>\n {face}\n {icons ? null : words}\n {icons ? null : <Icon name=\"chevrons-up-down\" className=\"shrink-0 text-muted-foreground\" />}\n </>\n }\n />\n );\n }\n\n // The foot: the switcher (when it lives here), the person, and the \"+\"\n // menu of footerActions beside them.\n function foot({ icons, sheet }) {\n const footSwitch = switcherPlacement === \"footer\" && switcher?.length && !sheet;\n const plus = footerActions?.length ? (\n <Menu\n items={closingSheet(guard(footerActions))}\n label={footerActionsLabel}\n side={icons ? \"right\" : \"top\"}\n align=\"end\"\n size={sheet ? \"md\" : \"sm\"}\n trigger={<Icon name=\"plus\" />}\n />\n ) : null;\n if (!user && !plus && !footSwitch) return null;\n return (\n <div className=\"grid grid-cols-1 gap-1\">\n {footSwitch ? brand({ icons, side: \"top\" }) : null}\n {user || plus ? (\n <div className={cx(\"flex items-center gap-1\", icons && \"flex-col\")}>\n {user ? <div className={cx(\"min-w-0\", !icons && \"flex-1\")}>{person({ icons })}</div> : null}\n {plus}\n </div>\n ) : null}\n </div>\n );\n }\n\n // The second column from 768px: a drilled parent's children\n // (drilldown=\"panel\"), else the panel.\n function secondColumn() {\n if (drillMode === \"panel\" && drillItem) {\n const headingId = `${uid}-drill-title`;\n return (\n <div\n className=\"flex min-h-0 flex-col\"\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.stopPropagation();\n closeDrill(\"r\");\n }\n }}\n >\n <div className=\"flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3\">\n <h2 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {drillItem.label}\n </h2>\n <IconButton icon=\"x\" label={`Close ${drillItem.label}`} size=\"sm\" onClick={() => closeDrill(\"r\")} />\n </div>\n <ScrollArea label={drillItem.label} className=\"min-h-0 flex-1\" fade>\n <ul aria-labelledby={headingId} className=\"grid grid-cols-1 gap-0.5 p-2\">\n {drillItem.items.map((child, i) => (\n <li key={keyOf(child)}>{leaf(child, { sheet: false, id: i === 0 ? `${uid}-drill-first` : undefined })}</li>\n ))}\n </ul>\n </ScrollArea>\n </div>\n );\n }\n if (!panel) return null;\n if (panelIsNode) return panel;\n return <PanelBody panel={panel} onChoose={() => ctx?.setMobileOpen(false)} />;\n }\n\n const inLayout = Boolean(ctx);\n const second = secondColumn();\n const hasFoot = Boolean(user || footerActions?.length || (switcherPlacement === \"footer\" && switcher?.length));\n return (\n <>\n <div\n data-sidebar=\"\"\n data-collapsed={collapsed ? \"\" : undefined}\n data-variant={variant !== \"sidebar\" ? variant : undefined}\n className={cx(inLayout ? \"hidden md:flex\" : \"flex\", \"shrink-0\", shell, className)}\n >\n <div\n className={cx(\n \"flex min-h-0 shrink-0 flex-col transition-[width] duration-200 ease-out motion-reduce:transition-none\",\n collapsed ? \"w-14\" : \"w-64\",\n )}\n >\n <div className={cx(\"shrink-0 py-2\", collapsed ? \"flex justify-center\" : \"px-2\")}>{head({ icons: collapsed })}</div>\n {searchOpts ? <div className={cx(\"shrink-0 pb-1\", collapsed ? \"flex justify-center\" : \"px-2\")}>{searchRow({ icons: collapsed })}</div> : null}\n <ScrollArea label={`${label} navigation`} className=\"flex-1\" fade>\n {nav({ sheet: false })}\n </ScrollArea>\n {hasFoot ? (\n <div className={cx(\"shrink-0 border-t border-border py-2\", collapsed ? \"flex justify-center\" : \"px-2\")}>\n {foot({ icons: collapsed })}\n </div>\n ) : null}\n </div>\n {second ? (\n <div className={cx(\"flex w-80 min-h-0 shrink-0 flex-col border-l border-border\", variant === \"sidebar\" && \"bg-background\")}>{second}</div>\n ) : null}\n </div>\n {searchOpts ? (\n <CommandDialog\n open={searching}\n onOpenChange={setSearching}\n hotkey={hotkey || undefined}\n label={searchOpts.label ?? `Search ${title ?? label}`}\n placeholder={searchOpts.placeholder ?? \"Go to…\"}\n empty={searchOpts.empty ?? \"Nothing by that name.\"}\n items={searchItems}\n />\n ) : null}\n {inLayout ? (\n <Sheet\n side=\"start\"\n open={ctx.mobileOpen}\n onClose={() => ctx.setMobileOpen(false)}\n title={title}\n description={subtitle}\n footer={user || footerActions?.length ? <div className=\"w-full\">{foot({ icons: false, sheet: true })}</div> : null}\n >\n {ctx.mobileOpen ? (\n sheetPanel && panel ? (\n <div className=\"-mx-2 -my-2 grid grid-cols-1 gap-2\">\n <div className=\"px-2 pt-2\">\n <Button variant=\"nav\" size=\"md\" onClick={() => setSheetPanel(false)}>\n <Icon name=\"arrow-left\" className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">Back</span>\n </Button>\n </div>\n {panelIsNode ? panel : <PanelBody panel={panel} sheet onChoose={() => ctx.setMobileOpen(false)} />}\n </div>\n ) : (\n <div className=\"-mx-2 -my-2 grid grid-cols-1 gap-2\">\n {headerActions ? <div className=\"flex flex-wrap items-center gap-1 px-2\">{headerActions}</div> : null}\n {searchOpts ? <div className=\"px-2\">{searchRow({ icons: false, sheet: true })}</div> : null}\n {switcher?.length ? (\n // The sheet's header already names the workspace: here the\n // switcher is just the way to change it.\n <div className=\"px-2\">\n <Menu\n items={switcher}\n label={switcherLabel}\n align=\"start\"\n className=\"min-h-11 w-full px-2\"\n trigger={\n <>\n <Icon name=\"chevrons-up-down\" className=\"shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{switcherLabel}</span>\n </>\n }\n />\n </div>\n ) : null}\n {panel ? (\n <div className=\"px-2\">\n <Button variant=\"nav\" size=\"md\" onClick={() => setSheetPanel(true)}>\n <Icon name=\"list\" className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{(panelIsNode ? null : panel.title) ?? \"Open list\"}</span>\n <Icon name=\"chevron-right\" className=\"shrink-0 text-muted-foreground\" />\n </Button>\n </div>\n ) : null}\n {nav({ sheet: true })}\n </div>\n )\n ) : null}\n </Sheet>\n ) : null}\n </>\n );\n}\n"
23
23
  },
24
24
  {
25
25
  "path": "registry/blocks/sidebar/sidebar-panel.jsx",
26
26
  "type": "registry:component",
27
27
  "target": "@components/makersclaw/blocks/sidebar/sidebar-panel.jsx",
28
- "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button, Empty, InputGroup, Item, Notice, ScrollArea, Skeleton, commandScore, toast } from \"../../ui\";\n\n// The Sidebar's second column (README \"panel\"): a title, an optional\n// search field and a list — the mails in a folder, the threads in a\n// channel — or any node of the app's own. Drawn beside the nav from\n// 768px and as the sheet's second screen on a phone (sidebar.jsx decides\n// where). Also the helper that turns an action's `{ ok: false }` into a\n// toast, shared by item menus, the foot's \"+\" menu and primaryAction.\n\n/** Menu items whose onSelect may be async and return `{ ok, error? }`:\n * a refusal or a throw becomes an error toast, so a failed \"Archive\"\n * never looks done. `arg` is what each onSelect is called with. */\nexport function guard(entries, arg) {\n return (entries ?? []).filter(Boolean).map((entry) => {\n if (Array.isArray(entry.items)) return { ...entry, items: guard(entry.items, arg) };\n if (!entry.onSelect) return entry;\n return { ...entry, onSelect: () => run(entry.onSelect, arg, entry.label) };\n });\n}\n\n/** Runs an action and says so when it fails. */\nexport async function run(fn, arg, label) {\n try {\n const result = await fn?.(arg);\n if (result && result.ok === false) toast.error(result.error || `${label ?? \"That\"} didn't go through`);\n return result;\n } catch (err) {\n toast.error(`${label ?? \"That\"} didn't go through`, { description: err?.message });\n return { ok: false };\n }\n}\n\nconst textOf = (it) => [it.title, it.subtitle, it.description, ...(it.keywords ?? [])].filter((x) => typeof x === \"string\").join(\" \");\n\n/** The panel's body: header (title, actions, search), then the list or its\n * loading · error · empty state. `onChoose(item)` runs after the item's\n * own onSelect (the Sidebar closes the phone sheet there). */\nexport function PanelBody({ panel, onChoose, sheet = false }) {\n const [own, setOwn] = useState(\"\");\n const query = panel.query ?? own;\n const setQuery = (q) => {\n if (panel.query === undefined) setOwn(q);\n panel.onQueryChange?.(q);\n };\n const searchOn = Boolean(panel.search);\n const placeholder = (typeof panel.search === \"object\" && panel.search.placeholder) || \"Search\";\n const items = (panel.items ?? []).filter(Boolean);\n const shown = !searchOn || panel.filter === false || !query.trim() ? items : items.filter((it) => commandScore(textOf(it), query) > 0);\n\n function choose(item) {\n item.onSelect?.(item);\n panel.onSelect?.(item);\n onChoose?.(item);\n }\n\n let body;\n if (panel.error) {\n body = (\n <div className=\"p-3\">\n <Notice\n tone=\"danger\"\n title={typeof panel.error === \"string\" ? panel.error : \"This list didn't load\"}\n action={panel.onRetry ? <Button size=\"sm\" onClick={panel.onRetry}>Try again</Button> : undefined}\n />\n </div>\n );\n } else if (panel.loading) {\n body = (\n <ul aria-busy=\"true\" aria-label={`Loading ${panel.title ?? \"list\"}`} className=\"grid\">\n {[0, 1, 2, 3, 4].map((i) => (\n <li key={i} className=\"grid gap-2 border-b border-border px-4 py-3\">\n <Skeleton className=\"w-1/2\" />\n <Skeleton className=\"w-3/4\" />\n <Skeleton className=\"w-full\" />\n </li>\n ))}\n </ul>\n );\n } else if (!shown.length) {\n body = (\n <div className=\"p-4\">\n <Empty\n icon={query.trim() ? \"search\" : \"inbox\"}\n title={query.trim() ? `Nothing matches \"${query.trim()}\"` : panel.empty ?? \"Nothing here yet\"}\n />\n </div>\n );\n } else {\n body = (\n <ul aria-label={panel.title ?? \"List\"} className=\"grid\">\n {shown.map((it, i) => (\n <li key={it.key ?? it.href ?? `${it.title}-${i}`} className=\"border-b border-border\">\n <Item\n title={it.title}\n meta={it.meta}\n media={it.media}\n icon={it.icon}\n href={it.disabled ? undefined : it.href}\n onClick={it.disabled ? undefined : () => choose(it)}\n disabled={it.disabled}\n aria-current={it.active ? \"true\" : undefined}\n description={\n it.subtitle || it.description ? (\n <>\n {it.subtitle ? <span className=\"block truncate font-medium text-foreground\">{it.subtitle}</span> : null}\n {it.description ? <span className=\"line-clamp-2\">{it.description}</span> : null}\n </>\n ) : undefined\n }\n />\n </li>\n ))}\n </ul>\n );\n }\n\n return (\n <div className=\"flex min-h-0 flex-1 flex-col\">\n {panel.title || panel.actions || searchOn ? (\n <div className=\"grid shrink-0 gap-3 border-b border-border p-3\">\n {panel.title || panel.actions ? (\n <div className=\"flex min-h-9 items-center gap-2\">\n {panel.title ? <h2 className=\"min-w-0 flex-1 truncate text-base font-semibold\">{panel.title}</h2> : null}\n {panel.actions ? <div className=\"flex shrink-0 items-center gap-1\">{panel.actions}</div> : null}\n </div>\n ) : null}\n {searchOn ? (\n <InputGroup\n icon=\"search\"\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={placeholder}\n aria-label={`Search ${panel.title ?? \"the list\"}`}\n />\n ) : null}\n </div>\n ) : null}\n {sheet ? <div className=\"min-h-0 flex-1\">{body}</div> : (\n <ScrollArea label={panel.title ?? \"List\"} className=\"min-h-0 flex-1\" fade>\n {body}\n </ScrollArea>\n )}\n </div>\n );\n}\n"
28
+ "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button, Empty, InputGroup, Item, Notice, ScrollArea, Skeleton, commandScore, toast } from \"../../ui\";\n\n// The Sidebar's second column (README \"panel\"): a title, an optional\n// search field and a list — the mails in a folder, the threads in a\n// channel — or any node of the app's own. Drawn beside the nav from\n// 768px and as the sheet's second screen on a phone (sidebar.jsx decides\n// where). Also the helper that turns an action's `{ ok: false }` into a\n// toast, shared by item menus, the foot's \"+\" menu and primaryAction.\n\n/** Menu items whose onSelect may be async and return `{ ok, error? }`:\n * a refusal or a throw becomes an error toast, so a failed \"Archive\"\n * never looks done. `arg` is what each onSelect is called with. */\nexport function guard(entries, arg) {\n return (entries ?? []).filter(Boolean).map((entry) => {\n if (Array.isArray(entry.items)) return { ...entry, items: guard(entry.items, arg) };\n if (!entry.onSelect) return entry;\n return { ...entry, onSelect: () => run(entry.onSelect, arg, entry.label) };\n });\n}\n\n/** Runs an action and says so when it fails. */\nexport async function run(fn, arg, label) {\n try {\n const result = await fn?.(arg);\n if (result && result.ok === false) toast.error(result.error || `${label ?? \"That\"} didn't go through`);\n return result;\n } catch (err) {\n toast.error(`${label ?? \"That\"} didn't go through`, { description: err?.message });\n return { ok: false };\n }\n}\n\nconst textOf = (it) => [it.title, it.subtitle, it.description, ...(it.keywords ?? [])].filter((x) => typeof x === \"string\").join(\" \");\n\n/** The panel's body: header (title, actions, search), then the list or its\n * loading · error · empty state. `onChoose(item)` runs after the item's\n * own onSelect (the Sidebar closes the phone sheet there). */\nexport function PanelBody({ panel, onChoose, sheet = false }) {\n const [own, setOwn] = useState(\"\");\n const query = panel.query ?? own;\n const setQuery = (q) => {\n if (panel.query === undefined) setOwn(q);\n panel.onQueryChange?.(q);\n };\n const searchOn = Boolean(panel.search);\n const placeholder = (typeof panel.search === \"object\" && panel.search.placeholder) || \"Search\";\n const items = (panel.items ?? []).filter(Boolean);\n const shown = !searchOn || panel.filter === false || !query.trim() ? items : items.filter((it) => commandScore(textOf(it), query) > 0);\n\n function choose(item) {\n item.onSelect?.(item);\n panel.onSelect?.(item);\n onChoose?.(item);\n }\n\n let body;\n if (panel.error) {\n body = (\n <div className=\"p-3\">\n <Notice\n tone=\"danger\"\n title={typeof panel.error === \"string\" ? panel.error : \"This list didn't load\"}\n action={panel.onRetry ? <Button size=\"sm\" onClick={panel.onRetry}>Try again</Button> : undefined}\n />\n </div>\n );\n } else if (panel.loading) {\n body = (\n <ul aria-busy=\"true\" aria-label={`Loading ${panel.title ?? \"list\"}`} className=\"grid grid-cols-1\">\n {[0, 1, 2, 3, 4].map((i) => (\n <li key={i} className=\"grid grid-cols-1 gap-2 border-b border-border px-4 py-3\">\n <Skeleton className=\"w-1/2\" />\n <Skeleton className=\"w-3/4\" />\n <Skeleton className=\"w-full\" />\n </li>\n ))}\n </ul>\n );\n } else if (!shown.length) {\n body = (\n <div className=\"p-4\">\n <Empty\n icon={query.trim() ? \"search\" : \"inbox\"}\n title={query.trim() ? `Nothing matches \"${query.trim()}\"` : panel.empty ?? \"Nothing here yet\"}\n />\n </div>\n );\n } else {\n body = (\n <ul aria-label={panel.title ?? \"List\"} className=\"grid grid-cols-1\">\n {shown.map((it, i) => (\n <li key={it.key ?? it.href ?? `${it.title}-${i}`} className=\"border-b border-border\">\n <Item\n title={it.title}\n meta={it.meta}\n media={it.media}\n icon={it.icon}\n href={it.disabled ? undefined : it.href}\n onClick={it.disabled ? undefined : () => choose(it)}\n disabled={it.disabled}\n aria-current={it.active ? \"true\" : undefined}\n description={\n it.subtitle || it.description ? (\n <>\n {it.subtitle ? <span className=\"block truncate font-medium text-foreground\">{it.subtitle}</span> : null}\n {it.description ? <span className=\"line-clamp-2\">{it.description}</span> : null}\n </>\n ) : undefined\n }\n />\n </li>\n ))}\n </ul>\n );\n }\n\n return (\n <div className=\"flex min-h-0 flex-1 flex-col\">\n {panel.title || panel.actions || searchOn ? (\n <div className=\"grid grid-cols-1 shrink-0 gap-3 border-b border-border p-3\">\n {panel.title || panel.actions ? (\n <div className=\"flex min-h-9 items-center gap-2\">\n {panel.title ? <h2 className=\"min-w-0 flex-1 truncate text-base font-semibold\">{panel.title}</h2> : null}\n {panel.actions ? <div className=\"flex shrink-0 items-center gap-1\">{panel.actions}</div> : null}\n </div>\n ) : null}\n {searchOn ? (\n <InputGroup\n icon=\"search\"\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={placeholder}\n aria-label={`Search ${panel.title ?? \"the list\"}`}\n />\n ) : null}\n </div>\n ) : null}\n {sheet ? <div className=\"min-h-0 flex-1\">{body}</div> : (\n <ScrollArea label={panel.title ?? \"List\"} className=\"min-h-0 flex-1\" fade>\n {body}\n </ScrollArea>\n )}\n </div>\n );\n}\n"
29
29
  },
30
30
  {
31
31
  "path": "registry/blocks/sidebar/README.md",
@@ -62,7 +62,7 @@
62
62
  "docs": "Installed to components/makersclaw/blocks/sidebar. Import: import { Sidebar, SidebarLayout, SidebarTrigger, useSidebar } from \"@/components/makersclaw/blocks/sidebar\". Props and an example: components/makersclaw/blocks/sidebar/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
63
63
  "meta": {
64
64
  "makersclaw": {
65
- "version": "0.4.0",
65
+ "version": "0.5.2",
66
66
  "useWhen": "An app with more than a handful of places to go (inbox, deals, contacts, reports, settings, projects) that wants a real app shell: compose it in app/shell.jsx around the surfaces. Not for in-page tabs (Tabs) or a short strip of links."
67
67
  }
68
68
  }
@@ -5,7 +5,7 @@
5
5
  "title": "SignaturePad",
6
6
  "description": "A signature field (signature_pad) in the workspace theme: a full-name field, then draw on a paper box (smooth pen strokes, undo the last stroke, clear with Undo) or type the name set as a signature. Sign returns a record: a cropped transparent PNG in dark ink, the typed name, the mode and the time; the signed view shows it with Sign again. Works as a form field too, through a handle (toPNG, getSignature).",
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
  "signature_pad@5.1.4"
@@ -39,7 +39,7 @@
39
39
  "path": "registry/blocks/signature-pad/signature.js",
40
40
  "type": "registry:component",
41
41
  "target": "@components/makersclaw/blocks/signature-pad/signature.js",
42
- "content": "// Plain helpers for a signature: its strokes, what is missing before it\n// can be signed, the record it becomes. No DOM, so they run anywhere.\n//\n// Strokes are signature_pad's point groups, in CSS pixels of the pad:\n// [{ points: [{ x, y, pressure, time }], penColor, minWidth, maxWidth, … }]\n\n/** The box around every point, grown by `pad` px on each side (a stroke\n * has width); clipped to 0,0 and, when given, to the pad's size. Null\n * when there is nothing drawn. */\nexport function strokeBounds(groups, pad = 6, size) {\n let x0 = Infinity;\n let y0 = Infinity;\n let x1 = -Infinity;\n let y1 = -Infinity;\n for (const g of groups ?? []) {\n for (const p of g?.points ?? []) {\n if (p.x < x0) x0 = p.x;\n if (p.y < y0) y0 = p.y;\n if (p.x > x1) x1 = p.x;\n if (p.y > y1) y1 = p.y;\n }\n }\n if (!Number.isFinite(x0)) return null;\n const maxX = size?.width ?? Infinity;\n const maxY = size?.height ?? Infinity;\n const x = Math.max(0, Math.floor(x0 - pad));\n const y = Math.max(0, Math.floor(y0 - pad));\n const right = Math.min(maxX, Math.ceil(x1 + pad));\n const bottom = Math.min(maxY, Math.ceil(y1 + pad));\n return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };\n}\n\n/** Every stroke inked in `colour` (a theme change repaints the pad). */\nexport const withInk = (groups, colour) => (colour ? (groups ?? []).map((g) => ({ ...g, penColor: colour })) : groups ?? []);\n\n/** The strokes without the last one. */\nexport const withoutLast = (groups) => (groups ?? []).slice(0, -1);\n\n/** How many points were drawn: a tap is 1, a real signature dozens. */\nexport const pointCount = (groups) => (groups ?? []).reduce((n, g) => n + (g?.points?.length ?? 0), 0);\n\n/** What stops this from being signed, as the words to show, or null.\n * mode \"draw\" | \"type\"\n * drawn how many points are on the pad\n * name the typed full name\n * requireName a name is needed in draw mode too */\nexport function missing({ mode, drawn, name, requireName = true }) {\n const hasName = Boolean(String(name ?? \"\").trim());\n if (mode === \"type\") return hasName ? null : { name: \"Type your full name to sign.\" };\n const out = {};\n if (requireName && !hasName) out.name = \"Add your full name.\";\n if (!drawn) out.pad = \"Draw your signature in the box.\";\n else if (drawn < 4) out.pad = \"That looks like a dot — draw your signature in the box.\";\n return Object.keys(out).length ? out : null;\n}\n\n/** The record a signature becomes (what onSubmit receives, what `value`\n * shows): the picture, the name, how it was made, when. */\nexport function signatureRecord({ png, name, mode, signedAt }) {\n return { png, name: String(name ?? \"\").trim(), mode, signedAt: signedAt ?? new Date().toISOString() };\n}\n\n/** \"Sep 23, 2026, 2:04 PM\" in the reader's own locale. */\nexport function formatSignedAt(iso, locale) {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return \"\";\n return d.toLocaleString(locale, { month: \"short\", day: \"numeric\", year: \"numeric\", hour: \"numeric\", minute: \"2-digit\" });\n}\n"
42
+ "content": "// Plain helpers for a signature: its strokes, what is missing before it\n// can be signed, the record it becomes. No DOM, so they run anywhere.\n//\n// Strokes are signature_pad's point groups, in CSS pixels of the pad:\n// [{ points: [{ x, y, pressure, time }], penColor, minWidth, maxWidth, … }]\n\n/** The box around every point, grown by `pad` px on each side (a stroke\n * has width); clipped to 0,0 and, when given, to the pad's size. Null\n * when there is nothing drawn. */\nexport function strokeBounds(groups, pad = 6, size) {\n let x0 = Infinity;\n let y0 = Infinity;\n let x1 = -Infinity;\n let y1 = -Infinity;\n for (const g of groups ?? []) {\n for (const p of g?.points ?? []) {\n if (p.x < x0) x0 = p.x;\n if (p.y < y0) y0 = p.y;\n if (p.x > x1) x1 = p.x;\n if (p.y > y1) y1 = p.y;\n }\n }\n if (!Number.isFinite(x0)) return null;\n const maxX = size?.width ?? Infinity;\n const maxY = size?.height ?? Infinity;\n const x = Math.max(0, Math.floor(x0 - pad));\n const y = Math.max(0, Math.floor(y0 - pad));\n const right = Math.min(maxX, Math.ceil(x1 + pad));\n const bottom = Math.min(maxY, Math.ceil(y1 + pad));\n return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };\n}\n\n/** Every stroke inked in `colour` (a theme change repaints the pad). */\nexport const withInk = (groups, colour) => (colour ? (groups ?? []).map((g) => ({ ...g, penColor: colour })) : groups ?? []);\n\n/** The strokes without the last one. */\nexport const withoutLast = (groups) => (groups ?? []).slice(0, -1);\n\n/** How many points were drawn: a tap is 1, a real signature dozens. */\nexport const pointCount = (groups) => (groups ?? []).reduce((n, g) => n + (g?.points?.length ?? 0), 0);\n\n/** What stops this from being signed, as the words to show, or null.\n * mode \"draw\" | \"type\"\n * drawn how many points are on the pad\n * name the typed full name\n * requireName a name is needed in draw mode too */\nexport function missing({ mode, drawn, name, requireName = true }) {\n const hasName = Boolean(String(name ?? \"\").trim());\n if (mode === \"type\") return hasName ? null : { name: \"Type your full name to sign.\" };\n const out = {};\n if (requireName && !hasName) out.name = \"Add your full name.\";\n if (!drawn) out.pad = \"Draw your signature in the box.\";\n else if (drawn < 4) out.pad = \"That looks like a dot — draw your signature in the box.\";\n return Object.keys(out).length ? out : null;\n}\n\n/** The record a signature becomes (what onSubmit receives, what `value`\n * shows): the picture, the name, how it was made, when. */\nexport function signatureRecord({ png, name, mode, signedAt }) {\n return { png, name: String(name ?? \"\").trim(), mode, signedAt: signedAt ?? new Date().toISOString() };\n}\n\n// A day alone (\"2026-09-28\", from a `date` column) is that day's local\n// midnight: new Date() would read it as UTC's, the day before west of\n// Greenwich. Written out here, not the kit's toLocalDate: these helpers\n// import nothing, so they run anywhere.\nconst DAY = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\n\n/** \"Sep 23, 2026, 2:04 PM\" in the reader's own locale. */\nexport function formatSignedAt(iso, locale) {\n const day = typeof iso === \"string\" ? DAY.exec(iso) : null;\n const d = day ? new Date(+day[1], +day[2] - 1, +day[3]) : new Date(iso);\n if (Number.isNaN(d.getTime())) return \"\";\n return d.toLocaleString(locale, { month: \"short\", day: \"numeric\", year: \"numeric\", hour: \"numeric\", minute: \"2-digit\" });\n}\n"
43
43
  },
44
44
  {
45
45
  "path": "registry/blocks/signature-pad/README.md",
@@ -65,7 +65,7 @@
65
65
  "docs": "Installed to components/makersclaw/blocks/signature-pad. Import: import { SignaturePad, useSignaturePad, signatureRecord, formatSignedAt } from \"@/components/makersclaw/blocks/signature-pad\". Props and an example: components/makersclaw/blocks/signature-pad/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
66
66
  "meta": {
67
67
  "makersclaw": {
68
- "version": "0.4.0",
68
+ "version": "0.5.2",
69
69
  "useWhen": "A screen needs someone to sign: accepting a proposal or a quote, a delivery or work-done sign-off, a waiver, a consent form, an approval on a record. The proposal-accept block is built on it. It records an acceptance with a picture of the signature — it is not a certified legal e-signature (no identity check, no audit trail beyond what you store)."
70
70
  }
71
71
  }
@@ -5,7 +5,7 @@
5
5
  "title": "SketchCanvas",
6
6
  "description": "A freeform drawing canvas (Excalidraw) in the workspace theme: a Miro-style tool rail (select, pan, draw, shapes, arrows, text, image, eraser, frames), a floating toolbar over the selection with colours from the theme's tokens, zoom and undo controls, a friendly empty state, and stamp tools that place ready-made drawings (UI wireframe pieces built in). Optional locked background image for annotating a screenshot. The sketch is plain JSON; helpers read its words for an agent, and the handle exports a PNG.",
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
  "@excalidraw/excalidraw@0.18.1"
@@ -92,7 +92,7 @@
92
92
  "docs": "Installed to components/makersclaw/blocks/sketch-canvas. Import: import { SketchCanvas, useSketchCanvas, SKETCH_TOOLS, WIREFRAME_STAMPS, STROKES, FILLS, emptySketch, isSketchEmpty, sketchToText, sketchItems, toSketch } from \"@/components/makersclaw/blocks/sketch-canvas\". Props and an example: components/makersclaw/blocks/sketch-canvas/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
93
93
  "meta": {
94
94
  "makersclaw": {
95
- "version": "0.4.0",
95
+ "version": "0.5.2",
96
96
  "useWhen": "A screen needs freehand drawing: a whiteboard, marking up a screenshot, sketching a UI wireframe, a diagram drawn by hand. The whiteboard, annotate and sketch-to-app blocks are built on it. Not for structured boards of cards and connectors (flow-canvas), charts (chart) or a kanban (board)."
97
97
  }
98
98
  }
@@ -5,7 +5,7 @@
5
5
  "title": "Spreadsheet",
6
6
  "description": "A table you edit like Google Sheets or Airtable, virtualised for 10,000+ rows: typed columns (text, number, currency, date, single and multiple select, person, checkbox, link, rating) each with its own in-cell editor, a cell cursor and range selection with the mouse and arrow keys, typing to edit, copy and paste ranges as TSV (to and from Sheets and Excel), drag-to-fill and ⌘D fill down, undo/redo, column resize, drag to reorder, hide, freeze, rename, change type and add fields, row insert and delete with Undo, sort and filter panels, search, a sum/average of the selection, validation marks, and a phone layout. Edits go out as async callbacks answering { ok }, applied at once and rolled back on failure. A column-type registry and an imperative handle for blocks built on it.",
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
  "@tanstack/react-virtual@3.14.13"
@@ -27,7 +27,7 @@
27
27
  "path": "registry/blocks/spreadsheet/cells.jsx",
28
28
  "type": "registry:component",
29
29
  "target": "@components/makersclaw/blocks/spreadsheet/cells.jsx",
30
- "content": "\"use client\";\n\n// How each column type is DRAWN in a cell and EDITED in place. The logic\n// (parse, format, sort, filter) is types.js; this file is keyed by a\n// type's `editor` name. A custom type (the `types` prop) may bring its own\n// `render(value, column, ctx)` and `Editor` component.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, usePopoverPosition } from \"../../ui\";\nimport { optionOf, optionValue } from \"./types\";\n\nconst asList = (v) => (Array.isArray(v) ? v : v == null || v === \"\" ? [] : [v]);\n\n// ── display ─────────────────────────────────────────────────────────────\n\nexport function OptionBadge({ option, fallback }) {\n return (\n <Badge variant=\"soft\" hue={option?.hue ?? 1} className=\"max-w-full shrink-0 truncate\">\n {option?.label ?? fallback}\n </Badge>\n );\n}\n\n/** A cell's content, not editing. `active` is the selected cell (a rating\n * is an input there). */\nexport function CellValue({ value, column, type, ctx, active, readOnly, onSet }) {\n if (type.render) return type.render(value, column, ctx);\n switch (type.editor) {\n case \"number\":\n return <span className=\"block min-w-0 flex-1 truncate text-right tabular-nums\">{type.format(value, column, ctx)}</span>;\n case \"select\": {\n const list = asList(value);\n if (!list.length) return null;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-1 overflow-hidden\">\n {list.map((v) => (\n <OptionBadge key={v} option={optionOf(column, v)} fallback={v} />\n ))}\n </span>\n );\n }\n case \"person\": {\n const people = asList(value).map((id) => (ctx.people ?? []).find((p) => p.id === id) ?? { id, name: String(id) });\n if (!people.length) return null;\n if (people.length > 1) return <AvatarGroup people={people} size=\"xs\" max={3} />;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Avatar name={people[0].name} src={people[0].src} size=\"xs\" />\n <span className=\"truncate\">{people[0].name}</span>\n </span>\n );\n }\n case \"toggle\":\n return (\n <span className=\"flex flex-1 justify-center\">\n <span\n aria-hidden=\"true\"\n data-toggle=\"\"\n className={cx(\n \"grid size-4 place-items-center rounded-sm border transition-colors duration-150\",\n value ? \"border-primary bg-primary text-primary-foreground\" : \"border-input bg-background\",\n !readOnly && \"cursor-pointer\",\n )}\n >\n {value ? <Icon name=\"check\" size={12} /> : null}\n </span>\n </span>\n );\n case \"rating\": {\n const max = column.max ?? 5;\n if (active && !readOnly) {\n return <Rating value={value || 0} max={max} size=\"sm\" label={column.name} onChange={(n) => onSet?.(n === value ? 0 : n)} />;\n }\n return value ? <Rating value={value} max={max} size=\"sm\" label={column.name} /> : null;\n }\n case \"url\": {\n if (!value) return null;\n const href = /^[a-z]+:/i.test(value) ? value : `https://${value}`;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-1\">\n <span className=\"truncate text-primary\">{type.format(value, column, ctx)}</span>\n {/^https?:/i.test(href) ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n tabIndex={-1}\n aria-label={`Open ${type.format(value, column, ctx)}`}\n onPointerDown={(e) => e.stopPropagation()}\n className={cx(\"ml-auto grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground\", active ? \"opacity-100\" : \"opacity-0 group-hover/row:opacity-100\")}\n >\n <Icon name=\"external\" size={14} />\n </a>\n ) : null}\n </span>\n );\n }\n default:\n return <span className=\"block min-w-0 flex-1 truncate\">{type.format(value, column, ctx)}</span>;\n }\n}\n\n// ── the floating panel an editor opens beside its cell ──────────────────\n\n/** A top-layer panel under `anchorRef` (it floats above the grid's scroll\n * box, which would clip it). Dismissed by a press outside it and the cell. */\nexport function CellPanel({ anchorRef, onDismiss, width = \"sm\", className, label, children }) {\n const ref = useRef(null);\n const dismiss = useRef(onDismiss);\n dismiss.current = onDismiss;\n usePopoverPosition(anchorRef, ref, { side: \"bottom\", align: \"start\", offset: 4 });\n useLayoutEffect(() => {\n const p = ref.current;\n if (p && !p.matches(\":popover-open\")) p.showPopover?.();\n return () => {\n if (p?.matches(\":popover-open\")) p.hidePopover?.();\n };\n }, []);\n useEffect(() => {\n function down(e) {\n const inside = ref.current?.contains(e.target) || anchorRef.current?.parentElement?.contains(e.target);\n if (!inside) dismiss.current?.();\n }\n document.addEventListener(\"pointerdown\", down, true);\n return () => document.removeEventListener(\"pointerdown\", down, true);\n }, [anchorRef]);\n return (\n <div ref={ref} popover=\"manual\" role=\"dialog\" aria-label={label} className={panelClass(width, cx(\"overflow-hidden p-0 text-sm\", className))}>\n {children}\n </div>\n );\n}\n\n// ── editors ─────────────────────────────────────────────────────────────\n// Every editor gets { value, column, type, ctx, initial, today, onCommit,\n// onCancel }. onCommit(value, move, extra) — move: \"down\" · \"up\" · \"right\"\n// · \"left\" · null (stay); extra.create: new options for a select column.\n\nconst TAB_MOVE = (e) => (e.shiftKey ? \"left\" : \"right\");\n\n/** Text, number, currency, link: an input laid over the cell. */\nfunction TextEditor({ value, column, type, ctx, initial, onCommit, onCancel }) {\n const [text, setText] = useState(() => (initial != null ? initial : type.toText(value, column, ctx)));\n const [error, setError] = useState(null);\n const input = useRef(null);\n const anchor = useRef(null);\n const done = useRef(false);\n\n useLayoutEffect(() => {\n const el = input.current;\n if (!el) return;\n el.focus({ preventScroll: true });\n const end = el.value.length;\n el.setSelectionRange?.(end, end);\n }, []);\n\n function commit(move) {\n const res = type.parse(text, column, ctx);\n if (\"error\" in res) {\n setError(res.error);\n input.current?.focus({ preventScroll: true });\n return false;\n }\n done.current = true;\n onCommit(res.value, move, res.create ? { create: res.create } : undefined);\n return true;\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n {/* raw: an input the size of the cell with no chrome of its own; the kit's Input is a 44px field with a border. */}\n <input\n ref={input}\n value={text}\n aria-label={column.name}\n aria-invalid={error ? true : undefined}\n inputMode={type.editor === \"number\" ? \"decimal\" : type.editor === \"url\" ? \"url\" : undefined}\n spellCheck={type.editor === \"text\"}\n onChange={(e) => {\n setText(e.target.value);\n if (error) setError(null);\n }}\n onKeyDown={(e) => {\n if (e.nativeEvent.isComposing) return;\n if (e.key === \"Enter\") {\n e.preventDefault();\n commit(e.shiftKey ? \"up\" : \"down\");\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n commit(TAB_MOVE(e));\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n done.current = true;\n onCancel();\n }\n e.stopPropagation();\n }}\n onBlur={() => {\n if (done.current) return;\n // Leaving with text that does not parse keeps the old value.\n const res = type.parse(text, column, ctx);\n done.current = true;\n if (\"error\" in res) onCancel();\n else onCommit(res.value, null, res.create ? { create: res.create } : undefined);\n }}\n className={cx(\n \"absolute inset-0 z-10 h-full w-full bg-background px-2 text-sm text-foreground outline-none\",\n \"rounded-none shadow-[inset_0_0_0_2px_var(--color-primary)]\",\n type.editor === \"number\" && \"text-right tabular-nums\",\n error && \"shadow-[inset_0_0_0_2px_var(--color-destructive)]\",\n )}\n />\n {error ? (\n <CellPanel anchorRef={anchor} onDismiss={() => {}} width=\"auto\" label=\"Problem\">\n <p role=\"alert\" className=\"flex items-center gap-2 px-3 py-2 text-xs text-destructive\">\n <Icon name=\"warning\" size={14} />\n {error}\n </p>\n </CellPanel>\n ) : null}\n </>\n );\n}\n\n/** A date: type it, or pick it on the calendar. */\nfunction DateEditor({ value, column, type, ctx, initial, today, onCommit, onCancel }) {\n const anchor = useRef(null);\n const input = useRef(null);\n const [text, setText] = useState(() => (initial != null ? initial : value ? type.format(value, column, ctx) : \"\"));\n const [error, setError] = useState(null);\n const typed = type.parse(text, column, ctx);\n const shown = !(\"error\" in typed) && typed.value ? typed.value : value || null;\n\n useLayoutEffect(() => {\n input.current?.focus({ preventScroll: true });\n input.current?.select?.();\n }, []);\n\n function commitText(move) {\n if (\"error\" in typed) {\n setError(typed.error);\n return;\n }\n onCommit(typed.value, move);\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 shadow-[inset_0_0_0_2px_var(--color-primary)]\" />\n <CellPanel anchorRef={anchor} onDismiss={onCancel} width=\"auto\" label={column.name}>\n <div\n className=\"grid gap-2 p-2\"\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n onCancel();\n }\n e.stopPropagation();\n }}\n >\n {/* raw: a compact date text box inside a 36px-row picker; the kit's Input is a 44px field. */}\n <input\n ref={input}\n value={text}\n placeholder=\"Type a date, or pick one\"\n aria-label={`${column.name}, as text`}\n aria-invalid={error ? true : undefined}\n onChange={(e) => {\n setText(e.target.value);\n setError(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n commitText(\"down\");\n } else if (e.key === \"Tab\" && !e.shiftKey && !text) {\n // Tab with nothing typed walks into the calendar.\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n commitText(TAB_MOVE(e));\n }\n }}\n className={cx(\n \"h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n error && \"border-destructive\",\n )}\n />\n {error ? (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n <Calendar\n mode=\"single\"\n size=\"sm\"\n value={shown}\n defaultMonth={shown ? shown.slice(0, 7) : undefined}\n today={today}\n onChange={(d) => onCommit(d ?? null, null)}\n />\n <div className=\"flex items-center justify-between border-t border-border pt-2\">\n {/* raw: a text-size link-button in a dense picker footer; kit Button's link variant is text-sm with no hit area of its own. */}\n <button\n type=\"button\"\n className=\"rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n onClick={() => onCommit(today ?? new Date().toISOString().slice(0, 10), null)}\n >\n Today\n </button>\n {value ? (\n // raw: the same dense footer link.\n <button\n type=\"button\"\n className=\"rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n onClick={() => onCommit(null, null)}\n >\n Clear\n </button>\n ) : null}\n </div>\n </div>\n </CellPanel>\n </>\n );\n}\n\n/** Single and multiple select, and people: a searchable list. A single\n * pick commits; a multiple one stays open and commits on close. */\nfunction PickEditor({ value, column, type, ctx, initial, canCreate, onCommit, onCancel }) {\n const anchor = useRef(null);\n const person = type.editor === \"person\";\n const multiple = person ? Boolean(column.multiple) : Boolean(type.multiple);\n const [query, setQuery] = useState(initial ?? \"\");\n const [draft, setDraft] = useState(() => asList(value));\n const [created, setCreated] = useState([]);\n const draftRef = useRef(draft);\n draftRef.current = draft;\n const options = person\n ? (ctx.people ?? []).map((p) => ({ value: p.id, label: p.name, description: p.email, icon: <Avatar name={p.name} src={p.src} size=\"xs\" /> }))\n : [...(column.options ?? []), ...created].map((o) => ({ value: o.value, label: o.label, icon: <span aria-hidden=\"true\" className={cx(\"size-2 rounded-full\", HUE_DOT[o.hue ?? 1])} /> }));\n\n const exact = options.some((o) => o.label.toLowerCase() === query.trim().toLowerCase());\n const items = options.map((o) => ({\n ...o,\n icon: multiple ? (\n <span className={cx(\"grid size-4 place-items-center rounded-sm border\", draft.includes(o.value) ? \"border-primary bg-primary text-primary-foreground\" : \"border-input\")}>\n {draft.includes(o.value) ? <Icon name=\"check\" size={12} /> : null}\n </span>\n ) : (\n o.icon\n ),\n description: !multiple && draft[0] === o.value ? \"Selected\" : o.description,\n }));\n if (!person && canCreate && column.allowNew !== false && query.trim() && !exact) {\n items.push({ value: \"__create\", label: `Create “${query.trim()}”`, icon: \"plus\", keywords: [query] });\n }\n if (!multiple && draft.length) items.push({ value: \"__clear\", label: \"Clear\", icon: \"x\" });\n\n function finish(move = null) {\n const list = draftRef.current;\n const next = multiple ? list : list[0] ?? null;\n onCommit(next, move, created.length ? { create: created } : undefined);\n }\n\n function pick(item) {\n if (item.value === \"__clear\") return onCommit(null, null);\n if (item.value === \"__create\") {\n const label = query.trim();\n const o = { value: optionValue(label, [...(column.options ?? []), ...created].map((x) => x.value)), label, hue: (((column.options?.length ?? 0) + created.length) % 5) + 1 };\n setCreated((c) => [...c, o]);\n setQuery(\"\");\n if (multiple) setDraft((d) => [...d, o.value]);\n else onCommit(o.value, null, { create: [...created, o] });\n return;\n }\n if (multiple) {\n setDraft((d) => (d.includes(item.value) ? d.filter((x) => x !== item.value) : [...d, item.value]));\n setQuery(\"\");\n } else onCommit(item.value, null, created.length ? { create: created } : undefined);\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 shadow-[inset_0_0_0_2px_var(--color-primary)]\" />\n <CellPanel anchorRef={anchor} onDismiss={() => (multiple ? finish() : onCancel())} width=\"sm\" label={column.name}>\n <div\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n if (multiple) finish();\n else onCancel();\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n if (multiple) finish(TAB_MOVE(e));\n else onCancel();\n } else if (e.key === \"Backspace\" && multiple && !query && draft.length) {\n setDraft((d) => d.slice(0, -1));\n }\n e.stopPropagation();\n }}\n >\n {multiple && draft.length ? (\n <div className=\"flex flex-wrap gap-1 border-b border-border p-2\">\n {draft.map((v) =>\n person ? (\n <Badge key={v} variant=\"soft\">\n {(ctx.people ?? []).find((p) => p.id === v)?.name ?? v}\n </Badge>\n ) : (\n <OptionBadge key={v} option={optionOf({ options: [...(column.options ?? []), ...created] }, v)} fallback={v} />\n ),\n )}\n </div>\n ) : null}\n <Command\n bare\n autoFocus\n items={items}\n query={query}\n onQueryChange={setQuery}\n onSelect={pick}\n label={column.name}\n placeholder={person ? \"Find a person\" : canCreate && column.allowNew !== false ? \"Find or create an option\" : \"Find an option\"}\n empty={person ? \"No one by that name.\" : \"No options yet — type one to create it.\"}\n listClassName=\"max-h-64\"\n />\n {multiple ? (\n <p className=\"border-t border-border px-3 py-2 text-2xs text-muted-foreground\">Enter to toggle · Esc when done</p>\n ) : null}\n </div>\n </CellPanel>\n </>\n );\n}\n\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\n/** The editor for a type, or null when it edits in place (a checkbox, a\n * rating) or not at all. */\nexport function editorFor(type) {\n if (type.Editor) return type.Editor;\n switch (type.editor) {\n case \"text\":\n case \"number\":\n case \"url\":\n return TextEditor;\n case \"date\":\n return DateEditor;\n case \"select\":\n case \"person\":\n return PickEditor;\n default:\n return null;\n }\n}\n"
30
+ "content": "\"use client\";\n\n// How each column type is DRAWN in a cell and EDITED in place. The logic\n// (parse, format, sort, filter) is types.js; this file is keyed by a\n// type's `editor` name. A custom type (the `types` prop) may bring its own\n// `render(value, column, ctx)` and `Editor` component.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, todayString, usePopoverPosition } from \"../../ui\";\nimport { optionOf, optionValue } from \"./types\";\n\nconst asList = (v) => (Array.isArray(v) ? v : v == null || v === \"\" ? [] : [v]);\n\n// ── display ─────────────────────────────────────────────────────────────\n\nexport function OptionBadge({ option, fallback }) {\n return (\n <Badge variant=\"soft\" hue={option?.hue ?? 1} className=\"max-w-full shrink-0 truncate\">\n {option?.label ?? fallback}\n </Badge>\n );\n}\n\n/** A cell's content, not editing. `active` is the selected cell (a rating\n * is an input there). */\nexport function CellValue({ value, column, type, ctx, active, readOnly, onSet }) {\n if (type.render) return type.render(value, column, ctx);\n switch (type.editor) {\n case \"number\":\n return <span className=\"block min-w-0 flex-1 truncate text-right tabular-nums\">{type.format(value, column, ctx)}</span>;\n case \"select\": {\n const list = asList(value);\n if (!list.length) return null;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-1 overflow-hidden\">\n {list.map((v) => (\n <OptionBadge key={v} option={optionOf(column, v)} fallback={v} />\n ))}\n </span>\n );\n }\n case \"person\": {\n const people = asList(value).map((id) => (ctx.people ?? []).find((p) => p.id === id) ?? { id, name: String(id) });\n if (!people.length) return null;\n if (people.length > 1) return <AvatarGroup people={people} size=\"xs\" max={3} />;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Avatar name={people[0].name} src={people[0].src} size=\"xs\" />\n <span className=\"truncate\">{people[0].name}</span>\n </span>\n );\n }\n case \"toggle\":\n return (\n <span className=\"flex flex-1 justify-center\">\n <span\n aria-hidden=\"true\"\n data-toggle=\"\"\n className={cx(\n \"grid size-4 place-items-center rounded-sm border transition-colors duration-150\",\n value ? \"border-primary bg-primary text-primary-foreground\" : \"border-input bg-background\",\n !readOnly && \"cursor-pointer\",\n )}\n >\n {value ? <Icon name=\"check\" size={12} /> : null}\n </span>\n </span>\n );\n case \"rating\": {\n const max = column.max ?? 5;\n if (active && !readOnly) {\n return <Rating value={value || 0} max={max} size=\"sm\" label={column.name} onChange={(n) => onSet?.(n === value ? 0 : n)} />;\n }\n return value ? <Rating value={value} max={max} size=\"sm\" label={column.name} /> : null;\n }\n case \"url\": {\n if (!value) return null;\n const href = /^[a-z]+:/i.test(value) ? value : `https://${value}`;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-1\">\n <span className=\"truncate text-primary\">{type.format(value, column, ctx)}</span>\n {/^https?:/i.test(href) ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n tabIndex={-1}\n aria-label={`Open ${type.format(value, column, ctx)}`}\n onPointerDown={(e) => e.stopPropagation()}\n className={cx(\"ml-auto grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground\", active ? \"opacity-100\" : \"opacity-0 group-hover/row:opacity-100\")}\n >\n <Icon name=\"external\" size={14} />\n </a>\n ) : null}\n </span>\n );\n }\n default:\n return <span className=\"block min-w-0 flex-1 truncate\">{type.format(value, column, ctx)}</span>;\n }\n}\n\n// ── the floating panel an editor opens beside its cell ──────────────────\n\n/** A top-layer panel under `anchorRef` (it floats above the grid's scroll\n * box, which would clip it). Dismissed by a press outside it and the cell. */\nexport function CellPanel({ anchorRef, onDismiss, width = \"sm\", className, label, children }) {\n const ref = useRef(null);\n const dismiss = useRef(onDismiss);\n dismiss.current = onDismiss;\n usePopoverPosition(anchorRef, ref, { side: \"bottom\", align: \"start\", offset: 4 });\n useLayoutEffect(() => {\n const p = ref.current;\n if (p && !p.matches(\":popover-open\")) p.showPopover?.();\n return () => {\n if (p?.matches(\":popover-open\")) p.hidePopover?.();\n };\n }, []);\n useEffect(() => {\n function down(e) {\n const inside = ref.current?.contains(e.target) || anchorRef.current?.parentElement?.contains(e.target);\n if (!inside) dismiss.current?.();\n }\n document.addEventListener(\"pointerdown\", down, true);\n return () => document.removeEventListener(\"pointerdown\", down, true);\n }, [anchorRef]);\n return (\n <div ref={ref} popover=\"manual\" role=\"dialog\" aria-label={label} className={panelClass(width, cx(\"overflow-hidden p-0 text-sm\", className))}>\n {children}\n </div>\n );\n}\n\n// ── editors ─────────────────────────────────────────────────────────────\n// Every editor gets { value, column, type, ctx, initial, today, onCommit,\n// onCancel }. onCommit(value, move, extra) — move: \"down\" · \"up\" · \"right\"\n// · \"left\" · null (stay); extra.create: new options for a select column.\n\nconst TAB_MOVE = (e) => (e.shiftKey ? \"left\" : \"right\");\n\n/** Text, number, currency, link: an input laid over the cell. */\nfunction TextEditor({ value, column, type, ctx, initial, onCommit, onCancel }) {\n const [text, setText] = useState(() => (initial != null ? initial : type.toText(value, column, ctx)));\n const [error, setError] = useState(null);\n const input = useRef(null);\n const anchor = useRef(null);\n const done = useRef(false);\n\n useLayoutEffect(() => {\n const el = input.current;\n if (!el) return;\n el.focus({ preventScroll: true });\n const end = el.value.length;\n el.setSelectionRange?.(end, end);\n }, []);\n\n function commit(move) {\n const res = type.parse(text, column, ctx);\n if (\"error\" in res) {\n setError(res.error);\n input.current?.focus({ preventScroll: true });\n return false;\n }\n done.current = true;\n onCommit(res.value, move, res.create ? { create: res.create } : undefined);\n return true;\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n {/* raw: an input the size of the cell with no chrome of its own; the kit's Input is a 44px field with a border. */}\n <input\n ref={input}\n value={text}\n aria-label={column.name}\n aria-invalid={error ? true : undefined}\n inputMode={type.editor === \"number\" ? \"decimal\" : type.editor === \"url\" ? \"url\" : undefined}\n spellCheck={type.editor === \"text\"}\n onChange={(e) => {\n setText(e.target.value);\n if (error) setError(null);\n }}\n onKeyDown={(e) => {\n if (e.nativeEvent.isComposing) return;\n if (e.key === \"Enter\") {\n e.preventDefault();\n commit(e.shiftKey ? \"up\" : \"down\");\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n commit(TAB_MOVE(e));\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n done.current = true;\n onCancel();\n }\n e.stopPropagation();\n }}\n onBlur={() => {\n if (done.current) return;\n // Leaving with text that does not parse keeps the old value.\n const res = type.parse(text, column, ctx);\n done.current = true;\n if (\"error\" in res) onCancel();\n else onCommit(res.value, null, res.create ? { create: res.create } : undefined);\n }}\n className={cx(\n \"absolute inset-0 z-10 h-full w-full bg-background px-2 text-sm text-foreground outline-none\",\n \"rounded-none shadow-[inset_0_0_0_2px_var(--color-primary)]\",\n type.editor === \"number\" && \"text-right tabular-nums\",\n error && \"shadow-[inset_0_0_0_2px_var(--color-destructive)]\",\n )}\n />\n {error ? (\n <CellPanel anchorRef={anchor} onDismiss={() => {}} width=\"auto\" label=\"Problem\">\n <p role=\"alert\" className=\"flex items-center gap-2 px-3 py-2 text-xs text-destructive\">\n <Icon name=\"warning\" size={14} />\n {error}\n </p>\n </CellPanel>\n ) : null}\n </>\n );\n}\n\n/** A date: type it, or pick it on the calendar. */\nfunction DateEditor({ value, column, type, ctx, initial, today, onCommit, onCancel }) {\n const anchor = useRef(null);\n const input = useRef(null);\n const [text, setText] = useState(() => (initial != null ? initial : value ? type.format(value, column, ctx) : \"\"));\n const [error, setError] = useState(null);\n const typed = type.parse(text, column, ctx);\n const shown = !(\"error\" in typed) && typed.value ? typed.value : value || null;\n\n useLayoutEffect(() => {\n input.current?.focus({ preventScroll: true });\n input.current?.select?.();\n }, []);\n\n function commitText(move) {\n if (\"error\" in typed) {\n setError(typed.error);\n return;\n }\n onCommit(typed.value, move);\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 shadow-[inset_0_0_0_2px_var(--color-primary)]\" />\n <CellPanel anchorRef={anchor} onDismiss={onCancel} width=\"auto\" label={column.name}>\n <div\n className=\"grid gap-2 p-2\"\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n onCancel();\n }\n e.stopPropagation();\n }}\n >\n {/* raw: a compact date text box inside a 36px-row picker; the kit's Input is a 44px field. */}\n <input\n ref={input}\n value={text}\n placeholder=\"Type a date, or pick one\"\n aria-label={`${column.name}, as text`}\n aria-invalid={error ? true : undefined}\n onChange={(e) => {\n setText(e.target.value);\n setError(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n commitText(\"down\");\n } else if (e.key === \"Tab\" && !e.shiftKey && !text) {\n // Tab with nothing typed walks into the calendar.\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n commitText(TAB_MOVE(e));\n }\n }}\n className={cx(\n \"h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n error && \"border-destructive\",\n )}\n />\n {error ? (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n <Calendar\n mode=\"single\"\n size=\"sm\"\n value={shown}\n defaultMonth={shown ? shown.slice(0, 7) : undefined}\n today={today}\n onChange={(d) => onCommit(d ?? null, null)}\n />\n <div className=\"flex items-center justify-between border-t border-border pt-2\">\n {/* raw: a text-size link-button in a dense picker footer; kit Button's link variant is text-sm with no hit area of its own. */}\n <button\n type=\"button\"\n className=\"rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n onClick={() => onCommit(today ?? todayString(), null)}\n >\n Today\n </button>\n {value ? (\n // raw: the same dense footer link.\n <button\n type=\"button\"\n className=\"rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n onClick={() => onCommit(null, null)}\n >\n Clear\n </button>\n ) : null}\n </div>\n </div>\n </CellPanel>\n </>\n );\n}\n\n/** Single and multiple select, and people: a searchable list. A single\n * pick commits; a multiple one stays open and commits on close. */\nfunction PickEditor({ value, column, type, ctx, initial, canCreate, onCommit, onCancel }) {\n const anchor = useRef(null);\n const person = type.editor === \"person\";\n const multiple = person ? Boolean(column.multiple) : Boolean(type.multiple);\n const [query, setQuery] = useState(initial ?? \"\");\n const [draft, setDraft] = useState(() => asList(value));\n const [created, setCreated] = useState([]);\n const draftRef = useRef(draft);\n draftRef.current = draft;\n const options = person\n ? (ctx.people ?? []).map((p) => ({ value: p.id, label: p.name, description: p.email, icon: <Avatar name={p.name} src={p.src} size=\"xs\" /> }))\n : [...(column.options ?? []), ...created].map((o) => ({ value: o.value, label: o.label, icon: <span aria-hidden=\"true\" className={cx(\"size-2 rounded-full\", HUE_DOT[o.hue ?? 1])} /> }));\n\n const exact = options.some((o) => o.label.toLowerCase() === query.trim().toLowerCase());\n const items = options.map((o) => ({\n ...o,\n icon: multiple ? (\n <span className={cx(\"grid size-4 place-items-center rounded-sm border\", draft.includes(o.value) ? \"border-primary bg-primary text-primary-foreground\" : \"border-input\")}>\n {draft.includes(o.value) ? <Icon name=\"check\" size={12} /> : null}\n </span>\n ) : (\n o.icon\n ),\n description: !multiple && draft[0] === o.value ? \"Selected\" : o.description,\n }));\n if (!person && canCreate && column.allowNew !== false && query.trim() && !exact) {\n items.push({ value: \"__create\", label: `Create “${query.trim()}”`, icon: \"plus\", keywords: [query] });\n }\n if (!multiple && draft.length) items.push({ value: \"__clear\", label: \"Clear\", icon: \"x\" });\n\n function finish(move = null) {\n const list = draftRef.current;\n const next = multiple ? list : list[0] ?? null;\n onCommit(next, move, created.length ? { create: created } : undefined);\n }\n\n function pick(item) {\n if (item.value === \"__clear\") return onCommit(null, null);\n if (item.value === \"__create\") {\n const label = query.trim();\n const o = { value: optionValue(label, [...(column.options ?? []), ...created].map((x) => x.value)), label, hue: (((column.options?.length ?? 0) + created.length) % 5) + 1 };\n setCreated((c) => [...c, o]);\n setQuery(\"\");\n if (multiple) setDraft((d) => [...d, o.value]);\n else onCommit(o.value, null, { create: [...created, o] });\n return;\n }\n if (multiple) {\n setDraft((d) => (d.includes(item.value) ? d.filter((x) => x !== item.value) : [...d, item.value]));\n setQuery(\"\");\n } else onCommit(item.value, null, created.length ? { create: created } : undefined);\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 shadow-[inset_0_0_0_2px_var(--color-primary)]\" />\n <CellPanel anchorRef={anchor} onDismiss={() => (multiple ? finish() : onCancel())} width=\"sm\" label={column.name}>\n <div\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n if (multiple) finish();\n else onCancel();\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n if (multiple) finish(TAB_MOVE(e));\n else onCancel();\n } else if (e.key === \"Backspace\" && multiple && !query && draft.length) {\n setDraft((d) => d.slice(0, -1));\n }\n e.stopPropagation();\n }}\n >\n {multiple && draft.length ? (\n <div className=\"flex flex-wrap gap-1 border-b border-border p-2\">\n {draft.map((v) =>\n person ? (\n <Badge key={v} variant=\"soft\">\n {(ctx.people ?? []).find((p) => p.id === v)?.name ?? v}\n </Badge>\n ) : (\n <OptionBadge key={v} option={optionOf({ options: [...(column.options ?? []), ...created] }, v)} fallback={v} />\n ),\n )}\n </div>\n ) : null}\n <Command\n bare\n autoFocus\n items={items}\n query={query}\n onQueryChange={setQuery}\n onSelect={pick}\n label={column.name}\n placeholder={person ? \"Find a person\" : canCreate && column.allowNew !== false ? \"Find or create an option\" : \"Find an option\"}\n empty={person ? \"No one by that name.\" : \"No options yet — type one to create it.\"}\n listClassName=\"max-h-64\"\n />\n {multiple ? (\n <p className=\"border-t border-border px-3 py-2 text-2xs text-muted-foreground\">Enter to toggle · Esc when done</p>\n ) : null}\n </div>\n </CellPanel>\n </>\n );\n}\n\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\n/** The editor for a type, or null when it edits in place (a checkbox, a\n * rating) or not at all. */\nexport function editorFor(type) {\n if (type.Editor) return type.Editor;\n switch (type.editor) {\n case \"text\":\n case \"number\":\n case \"url\":\n return TextEditor;\n case \"date\":\n return DateEditor;\n case \"select\":\n case \"person\":\n return PickEditor;\n default:\n return null;\n }\n}\n"
31
31
  },
32
32
  {
33
33
  "path": "registry/blocks/spreadsheet/chrome.jsx",
@@ -83,7 +83,7 @@
83
83
  "docs": "Installed to components/makersclaw/blocks/spreadsheet. Import: import { Spreadsheet, CellValue, CellPanel, OptionBadge, editorFor, TYPE_ORDER, sortWords, COLUMN_TYPES, typeOf, parseCell, convertValue, guessType, parseNumberText, parseDateText, optionValue, isBlank, emptyView, normalizeView, orderedColumns, visibleColumns, processRows, passesFilters, parseTSV, toTSV, pasteChanges, fillChanges, clearChanges, applyChanges, invertChanges } from \"@/components/makersclaw/blocks/spreadsheet\". Props and an example: components/makersclaw/blocks/spreadsheet/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
84
84
  "meta": {
85
85
  "makersclaw": {
86
- "version": "0.4.0",
86
+ "version": "0.5.2",
87
87
  "useWhen": "People need to enter or clean up many records in a grid, fast, with the keyboard: a lead list, an inventory, a budget, a content calendar, a table view over any collection, the grid under an Airtable-like base. Not for a read-mostly list to scan and act on row by row (data-table), records moving through stages (board), or a single record (record-form)."
88
88
  }
89
89
  }
package/r/stat-cards.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Stat cards",
6
6
  "description": "A row of key numbers, each with its name and its change: one bordered strip, or a card each with a change in the corner or after the value, a \"from\" value, a status dot and a goal meter, a sparkline, a \"View more\" footer; or a usage tile per amount against its limit (a bar or a ring, a split by kind with what is free, a warning line near the cap, an action and a limit the founder can change); or a plain list of values with their change. Loading, empty and error states.",
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/chart.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/chart.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/stat-cards. Import: import { StatCards } from \"@/components/makersclaw/blocks/stat-cards\". Props and an example: components/makersclaw/blocks/stat-cards/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.4.0",
62
+ "version": "0.5.2",
63
63
  "useWhen": "The few numbers at the top of a dashboard or a report: revenue, users, conversion, spend against budget, seats and storage against a plan. Not for a trend worth reading on its own (a chart), many rows of records (a table), or one subject scored against thresholds (a scorecard)."
64
64
  }
65
65
  }
package/r/task-list.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Task list",
6
6
  "description": "The List view of work, like ClickUp's or Linear's: tasks under headings that fold (group by status, assignee, priority, project, due date or any field), a round check to complete each, the title edited in place, subtasks indented under their parent with their own fold, the fields you choose (assignees, due date, priority, tags, project, estimate) as inline pickers, a quick-add row at the end of every group, drag to reorder and to move between groups (saving the new status, assignee or due date), multi-select with a bulk bar (status, assign, due, priority, delete with undo), a toolbar with search, filter chips, group by, sort and fields, and friendly empty states with starter templates. One async onChange saves every change; failures roll back with a notice.",
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/task-model.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/task-model.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -56,7 +56,7 @@
56
56
  "docs": "Installed to components/makersclaw/blocks/task-list. Import: import { TaskList } from \"@/components/makersclaw/blocks/task-list\". Props and an example: components/makersclaw/blocks/task-list/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": "A screen that lists work to get done and moves it along: a team's tasks, a launch checklist, a sprint, a personal to-do list, onboarding steps, content to publish. Not for records that are not work (data-table), for stage columns side by side (board), or for dates on a grid (calendar)."
61
61
  }
62
62
  }
package/r/task-model.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Task model",
6
6
  "description": "The shared work-item model every task view reads: one task shape (status, assignees, start/due, estimate, progress, priority, parent, dependencies, location, tags, project, extra fields) with statuses, people and projects; pure helpers to search, filter, group by any field, sort, nest subtasks, do date math (week and month grids, working days, overlaps, time scales), sum capacity per person, follow dependencies and the critical path, and apply, undo and persist changes; the shared parts (StatusPill, AssigneeStack, DueLabel, PriorityFlag, TaskChip, CompleteToggle), the ViewToolbar (search, filter chips, group by, sort, show fields), drag primitives on @dnd-kit, a hook for optimistic saves with rollback and undo, and adapters that drive the Board and DataTable blocks from tasks.",
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
  "@dnd-kit/core@6.3.1",
@@ -91,7 +91,7 @@
91
91
  "docs": "Installed to components/makersclaw/blocks/task-model. Import: import { NONE, PRIORITIES, DEFAULT_STATUSES, TASK_FIELDS, DEFAULT_VIEW, normalizeTask, normalizeTasks, createTaskContext, fieldOf, isDone, doneStatusId, openStatusId, toggleDoneChange, newTaskId, draftTask, valuesOf, optionsOf, labelOf, searchText, searchTasks, filterTasks, orderOf, sortTasks, sortOptions, groupTasks, groupOptions, movePatch, applyView, activeFilterCount, rankBetween, placeChange, childrenMap, buildTree, flattenTree, descendantIds, ancestorIds, subtaskCounts, rollup, progressOf, dependencyEdges, blockersOf, wouldCycle, dateConflicts, criticalPath, hoursPerDay, workload, sumOf, applyChange, invertChange, changeWrites, changedIds, deleteChange, toBoard, toTableRows, taskFacets, StatusPill, CompleteToggle, AssigneeStack, PersonLabel, DueLabel, PriorityFlag, TagList, SubtaskCount, ProgressLine, TaskChip, GroupLabel, taskColumns, TONE_TEXT, HUE_EDGE, HUE_BG, TaskDragProvider, SortableGroup, useSortableTask, useDraggableTask, useDropTarget, DragHandle, DropLine, LIFTED, useReducedMotion, arrayMove, isDay, dayNumber, fromDayNumber, dayOf, minutesOf, withTime, todayDay, addDays, diffDays, weekdayOf, startOfWeek, endOfWeek, weekDays, startOfMonth, endOfMonth, addMonths, daysBetween, monthGrid, WEEKEND, isWeekend, isWorkingDay, workingDaysBetween, addWorkingDays, spanOf, spansOverlap, tasksInRange, tasksOnDay, shiftDates, spanPatch, dueBucket, DUE_BUCKETS, dayForBucket, formatDay, formatTime, formatDate, timeScale, rangeOfTasks, isNewId } from \"@/components/makersclaw/blocks/task-model\". Props and an example: components/makersclaw/blocks/task-model/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
92
92
  "meta": {
93
93
  "makersclaw": {
94
- "version": "0.4.0",
94
+ "version": "0.5.2",
95
95
  "useWhen": "You are building a view of work items — a list, calendar, gantt, workload, roadmap or a switcher between them — or driving Board / DataTable from tasks. Views import it; a surface rarely renders it alone. Not for records that are not work (use data-table or board directly)."
96
96
  }
97
97
  }