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
package/r/org-chart.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "OrgChart",
6
6
  "description": "An org chart on flow-canvas: people laid out top down from a flat list (each names a manager), managers centred over their teams, a team with no managers in it as a compact column, every line drawn for you. Teams fold past the first levels behind a chip that says how many are hidden; search finds a person, unfolds the way to them, centres them and lights their chain of managers; a card opens a details sheet (reports to, direct reports, team size). Colour strips and a legend by department. Optional: drag a card onto another to change someone's manager (async { ok }, optimistic, with Undo). Keyboard throughout.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/flow-canvas.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/flow-canvas.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -20,7 +20,7 @@
20
20
  "path": "registry/blocks/org-chart/org-chart.jsx",
21
21
  "type": "registry:component",
22
22
  "target": "@components/makersclaw/blocks/org-chart/org-chart.jsx",
23
- "content": "\"use client\";\n\n// OrgChart — the people tree, top down, on FlowCanvas. Managers over their\n// reports, a team with no managers in it as a compact column, teams folded\n// past the first levels with a chip that says how big they are. Find a\n// person from the search and the chart unfolds down to them, centres them\n// and lights their chain of managers. A person's card opens their details\n// (reports to, direct reports); with `onChangeManager`, drag a card onto\n// another to move someone to a new manager.\n//\n// People come in flat (each names a manager); org.js builds the tree and\n// lays it out, and the canvas — read-only, its own tools unused — draws,\n// pans and zooms. Pointer and keys come through this component.\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Avatar, Badge, Button, Combobox, Empty, Item, ItemGroup, Kbd, Legend, Sheet, Tooltip, cx, toast } from \"../../ui\";\nimport { FlowCanvas, useFlowCanvas } from \"../flow-canvas\";\nimport { OrgContext, NODE_TYPES } from \"./person\";\nimport { CARD, STACK_FROM, buildOrg, chainOf, groupHues, layoutOrg } from \"./org\";\n\nconst CONTROL = 'input,textarea,select,[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"],[role=\"dialog\"],[role=\"menu\"],[popover],button,a[href]';\n\nfunction useCoarsePointer() {\n const [coarse, setCoarse] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(pointer: coarse)\");\n const on = () => setCoarse(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return coarse;\n}\n\nasync function settle(fn, ...args) {\n try {\n const res = await fn(...args);\n return res && res.ok === false ? { ok: false, error: res.error } : { ok: true };\n } catch (e) {\n return { ok: false, error: e?.message };\n }\n}\n\nconst nameOf = (p) => p?.name || \"Unnamed\";\n\nexport function OrgChart({\n people,\n managerKey = \"managerId\",\n groupBy = \"department\",\n defaultDepth = 3,\n onChangeManager,\n onOpenPerson,\n renderDetails,\n onSelectionChange,\n height = 640,\n label = \"Org chart\",\n className,\n}) {\n const canvas = useFlowCanvas();\n const boxRef = useRef(null);\n const coarse = useCoarsePointer();\n const cbs = useRef({});\n cbs.current = { onChangeManager, onOpenPerson, onSelectionChange };\n\n // ── moves not yet in `people` (optimistic) ────────────────────────────\n const [moves, setMoves] = useState(() => new Map());\n useEffect(() => {\n // What the page has taken in needs no override any more.\n setMoves((m) => {\n if (!m.size) return m;\n const next = new Map(m);\n for (const p of people ?? []) if (next.has(String(p.id)) && String(p[managerKey] ?? \"\") === String(next.get(String(p.id)) ?? \"\")) next.delete(String(p.id));\n return next.size === m.size ? m : next;\n });\n }, [people, managerKey]);\n const everyone = useMemo(\n () => (people ?? []).filter((p) => p && p.id != null).map((p) => (moves.has(String(p.id)) ? { ...p, [managerKey]: moves.get(String(p.id)) } : p)),\n [people, moves, managerKey],\n );\n\n const org = useMemo(() => buildOrg(everyone, { managerKey }), [everyone, managerKey]);\n const hues = useMemo(() => groupHues(everyone, groupBy), [everyone, groupBy]);\n const groupOf = useCallback((p) => (groupBy ? (typeof groupBy === \"function\" ? groupBy(p) : p?.[groupBy]) : null), [groupBy]);\n\n // ── folding ───────────────────────────────────────────────────────────\n const [folds, setFolds] = useState(() => new Map()); // id → folded?\n const isFolded = useCallback((n) => (folds.has(n.id) ? folds.get(n.id) : n.depth >= defaultDepth - 1), [folds, defaultDepth]);\n const layout = useMemo(() => layoutOrg(org.roots, { collapsed: (n) => n.children.length > 0 && isFolded(n) }), [org, isFolded]);\n const itemsById = useMemo(() => new Map(layout.items.map((it) => [it.id, it])), [layout]);\n\n const board = useMemo(\n () => ({\n nodes: layout.items.map((it) => {\n const p = it.node.person;\n const g = groupOf(p);\n return {\n id: it.id,\n type: \"person\",\n position: { x: Math.round(it.x), y: Math.round(it.y) },\n width: it.width,\n height: it.height,\n draggable: false,\n connectable: false,\n data: {\n name: p.name,\n title: p.title,\n avatar: p.avatar,\n group: g ?? undefined,\n hue: g != null ? hues.get(g) : undefined,\n direct: it.node.children.length,\n total: it.node.total,\n folded: it.folded,\n parentId: it.parentId,\n link: it.link.length ? it.link.map((pt) => ({ x: pt.x - it.x, y: pt.y - it.y })) : null,\n },\n };\n }),\n edges: [],\n }),\n [layout, hues, groupOf],\n );\n\n // ── selection ─────────────────────────────────────────────────────────\n const [selectedId, setSelectedId] = useState(null);\n const selectedRef = useRef(null);\n const select = useCallback((id) => {\n selectedRef.current = id;\n setSelectedId(id);\n }, []);\n useEffect(() => {\n if (selectedId && !org.byId.has(selectedId)) select(null);\n }, [org, selectedId, select]);\n const chain = useMemo(() => new Set(selectedId ? chainOf(org.byId, selectedId).map((n) => n.id) : []), [org, selectedId]);\n\n // The canvas pans with a plain drag (its hand tool).\n const hand = useCallback(() => {\n const api = canvas.current;\n if (api && api.getTool?.() !== \"hand\") api.setTool(\"hand\");\n }, [canvas]);\n useEffect(() => {\n let raf = 0;\n let tries = 0;\n const wait = () => {\n if (canvas.current) return hand();\n if (tries++ < 120) raf = requestAnimationFrame(wait);\n };\n wait();\n return () => cancelAnimationFrame(raf);\n }, [canvas, hand]);\n\n /** Centre a person when they are off screen (or always, with `force`),\n * at the zoom there is — but close enough to read. */\n const reveal = useCallback(\n (id, { force = false, tries = 4 } = {}) => {\n const host = boxRef.current;\n const el = host?.querySelector(`[data-person=\"${CSS.escape(id)}\"]`);\n if (!el) {\n if (tries > 0) setTimeout(() => reveal(id, { force, tries: tries - 1 }), 60);\n return;\n }\n const r = el.getBoundingClientRect();\n const c = host.getBoundingClientRect();\n const pad = 24;\n const inView = r.left >= c.left + pad && r.right <= c.right - pad && r.top >= c.top + pad && r.bottom <= c.bottom - pad - 40;\n if (inView && !force) return;\n const now = r.width / CARD.width;\n const zoom = force ? Math.max(now, Math.min(1, (c.width - 48) / CARD.width)) : now;\n canvas.current?.fitView({ nodes: [{ id }], minZoom: zoom, maxZoom: zoom, padding: 0.6 });\n },\n [canvas],\n );\n\n const [announce, setAnnounce] = useState(\"\");\n const say = (m) => setAnnounce((x) => (x === m ? `${m} ` : m));\n\n useEffect(() => {\n const n = selectedId ? org.byId.get(selectedId) : null;\n cbs.current.onSelectionChange?.(n?.person ?? null);\n if (!n) return;\n const p = n.person;\n const boss = n.parentId ? org.byId.get(n.parentId)?.person : null;\n say(`${nameOf(p)}${p.title ? `, ${p.title}` : \"\"}${boss ? `, reports to ${nameOf(boss)}` : \"\"}${n.children.length ? `, ${n.children.length} direct report${n.children.length === 1 ? \"\" : \"s\"}` : \"\"}`);\n const raf = requestAnimationFrame(() => {\n const host = boxRef.current;\n const el = host?.querySelector(`.react-flow__node[data-id=\"${CSS.escape(selectedId)}\"]`);\n if (el && host.contains(document.activeElement) && document.activeElement !== el) el.focus({ preventScroll: true });\n reveal(selectedId);\n });\n return () => cancelAnimationFrame(raf);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selectedId]);\n\n const setFold = useCallback(\n (id, folded) => {\n const n = org.byId.get(id);\n if (!n || !n.children.length) return;\n setFolds((f) => new Map(f).set(id, folded ?? !isFolded(n)));\n say(`${nameOf(n.person)}'s team ${folded ?? !isFolded(n) ? \"hidden\" : \"shown\"}`);\n },\n [org, isFolded],\n );\n const foldAll = (folded) => {\n const next = new Map();\n for (const n of org.byId.values()) if (n.children.length) next.set(n.id, folded && n.depth > 0);\n setFolds(next);\n setTimeout(() => canvas.current?.fitView(), 80);\n };\n\n /** Unfold the way down to someone, select them, centre them. */\n const focusPerson = useCallback(\n (id) => {\n const path = chainOf(org.byId, id);\n if (!path.length) return;\n setFolds((f) => {\n const next = new Map(f);\n for (const n of path.slice(0, -1)) next.set(n.id, false);\n return next;\n });\n select(id);\n setTimeout(() => reveal(id, { force: true }), 30);\n },\n [org, select, reveal],\n );\n\n // ── details ───────────────────────────────────────────────────────────\n const [detailsId, setDetailsId] = useState(null);\n const open = useCallback(\n (id) => {\n const n = org.byId.get(id);\n if (!n) return;\n select(id);\n if (cbs.current.onOpenPerson) return cbs.current.onOpenPerson(n.person);\n setDetailsId(id);\n },\n [org, select],\n );\n\n // ── moving someone to a new manager ──────────────────────────────────\n const canMove = Boolean(onChangeManager);\n const reassign = async (id, to, { quiet = false } = {}) => {\n const n = org.byId.get(id);\n if (!n) return;\n const from = n.person[managerKey] ?? null;\n setMoves((m) => new Map(m).set(id, to));\n if (to) setFolds((f) => new Map(f).set(to, false));\n const boss = to ? org.byId.get(to)?.person : null;\n const res = await settle(cbs.current.onChangeManager, n.person, to);\n if (!res.ok) {\n setMoves((m) => {\n const next = new Map(m);\n if (String(from ?? \"\") === String((people ?? []).find((p) => String(p.id) === id)?.[managerKey] ?? \"\")) next.delete(id);\n else next.set(id, from);\n return next;\n });\n toast.error(`Couldn't move ${nameOf(n.person)}`, { description: res.error || \"Nothing changed. Try again.\" });\n return;\n }\n if (!quiet) {\n toast(`${nameOf(n.person)} now reports to ${boss ? nameOf(boss) : \"no one\"}`, {\n action: { label: \"Undo\", onClick: () => reassign(id, from, { quiet: true }) },\n });\n }\n };\n\n const [drag, setDrag] = useState(null); // { id, name, x, y }\n const [dropId, setDropId] = useState(null);\n const onCardPointerDown = (e, id) => {\n const wasSelected = selectedRef.current === id;\n select(id);\n const start = { x: e.clientX, y: e.clientY };\n let active = false;\n let over = null;\n const n = org.byId.get(id);\n const allowDrag = canMove && e.pointerType === \"mouse\";\n const move = (ev) => {\n if (!allowDrag) return;\n if (!active && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < 5) return;\n active = true;\n const host = boxRef.current?.getBoundingClientRect();\n if (!host) return;\n const hit = document\n .elementsFromPoint(ev.clientX, ev.clientY)\n .map((el) => el.closest?.(\"[data-person]\"))\n .find((el) => el && el.dataset.person !== id);\n const tid = hit?.dataset.person ?? null;\n const inside = tid && chainOf(org.byId, tid).some((x) => x.id === id);\n over = tid && !inside && n?.parentId !== tid ? tid : null;\n setDropId(over);\n setDrag({ id, name: nameOf(n?.person), x: ev.clientX - host.left, y: ev.clientY - host.top });\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n if (!active) {\n // A second click on the selected card opens it (a tap, on a phone).\n if (wasSelected) open(id);\n return;\n }\n setDrag(null);\n setDropId(null);\n if (over) reassign(id, over);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n };\n\n // A click on the empty canvas (not a pan) clears the selection.\n const paneDown = useRef(null);\n const onPointerDownCapture = (e) => {\n paneDown.current = e.target?.classList?.contains(\"react-flow__pane\") ? { x: e.clientX, y: e.clientY } : null;\n };\n const onPointerUpCapture = (e) => {\n const p = paneDown.current;\n paneDown.current = null;\n if (p && Math.hypot(e.clientX - p.x, e.clientY - p.y) <= 4) select(null);\n };\n\n // ── keys ──────────────────────────────────────────────────────────────\n const [searchOpen, setSearchOpen] = useState(false);\n const onKeyDownCapture = (e) => {\n const t = e.target;\n if (t?.closest?.(CONTROL)) return;\n const k = e.key;\n const mod = e.metaKey || e.ctrlKey;\n const done = () => {\n e.preventDefault();\n e.stopPropagation();\n };\n if (mod || e.altKey) return;\n if (k === \"/\") return done(), setSearchOpen(true);\n const sel = selectedRef.current && org.byId.has(selectedRef.current) ? selectedRef.current : null;\n if (k === \"Escape\") {\n if (sel) return done(), select(null);\n setTimeout(hand, 0);\n return;\n }\n const first = org.roots[0]?.id;\n if (!sel) {\n if ((k.startsWith(\"Arrow\") || k === \"Enter\" || k === \"Home\") && first) return done(), select(first);\n return;\n }\n const n = org.byId.get(sel);\n const parent = n.parentId ? org.byId.get(n.parentId) : null;\n const siblings = parent ? parent.children : org.roots;\n const i = siblings.findIndex((x) => x.id === sel);\n const stacked = parent && itemsById.get(siblings[0]?.id)?.link?.length === 3;\n if (k === \"Home\" && first) return done(), select(first);\n if (k === \"Enter\") return done(), open(sel);\n if (k === \" \") return done(), setFold(sel);\n if (k === \"ArrowUp\") {\n done();\n if (stacked && i > 0) return select(siblings[i - 1].id);\n return parent && select(parent.id);\n }\n if (k === \"ArrowDown\") {\n done();\n if (n.children.length) {\n if (isFolded(n)) setFold(sel, false);\n const kids = n.children;\n // Into a column: its top. Into a row: its middle, under the manager.\n const column = kids.length >= STACK_FROM && kids.every((c) => !c.children.length);\n return select(kids[column ? 0 : Math.floor((kids.length - 1) / 2)].id);\n }\n if (stacked && i < siblings.length - 1) return select(siblings[i + 1].id);\n return;\n }\n if (k === \"ArrowLeft\" || k === \"ArrowRight\") {\n done();\n const j = i + (k === \"ArrowLeft\" ? -1 : 1);\n if (siblings[j]) return select(siblings[j].id);\n // Past the end of the team: the nearest card across, in the same row.\n const me = itemsById.get(sel);\n if (!me) return;\n const across = layout.items\n .filter((it) => Math.abs(it.y - me.y) < 4 && (k === \"ArrowLeft\" ? it.x < me.x : it.x > me.x))\n .sort((a, b) => Math.abs(a.x - me.x) - Math.abs(b.x - me.x))[0];\n if (across) select(across.id);\n }\n };\n\n // ── the context the cards read ────────────────────────────────────────\n const ctx = useMemo(\n () => ({\n selectedId,\n chain,\n dropId,\n dragId: drag?.id ?? null,\n coarse,\n draggable: canMove && !coarse,\n onCardPointerDown,\n open,\n toggle: (id) => setFold(id),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [selectedId, chain, dropId, drag, coarse, canMove, open, setFold, org],\n );\n\n // ── chrome ────────────────────────────────────────────────────────────\n const options = useMemo(\n () =>\n everyone.map((p) => ({\n value: String(p.id),\n label: nameOf(p),\n description: [p.title, groupOf(p)].filter(Boolean).join(\" · \") || undefined,\n icon: <Avatar name={p.name} src={p.avatar} size=\"xs\" />,\n keywords: [p.title, groupOf(p), p.email, p.location].filter(Boolean),\n })),\n [everyone, groupOf],\n );\n const legend = useMemo(() => {\n if (!groupBy || !hues.size) return [];\n const count = new Map();\n for (const p of everyone) {\n const g = groupOf(p);\n if (g != null && g !== \"\") count.set(g, (count.get(g) ?? 0) + 1);\n }\n return [...hues.entries()].map(([g, hue]) => ({ key: String(g), hue, label: String(g), count: count.get(g) }));\n }, [everyone, hues, groupBy, groupOf]);\n const managers = useMemo(() => [...org.byId.values()].filter((n) => n.children.length).length, [org]);\n const sized = /(^|\\s)(h-|min-h-|size-|flex-1|grow)/.test(className ?? \"\");\n const details = detailsId ? org.byId.get(detailsId) : null;\n\n if (!everyone.length) {\n return (\n <section aria-label={label} className={cx(\"grid place-items-center rounded-lg border border-border bg-background p-6\", className)} style={sized ? undefined : { minHeight: Math.min(Number(height) || 320, 320) }}>\n <Empty icon=\"users\" title=\"No one on the chart yet\" body=\"People appear here once they're added, each with the person they report to.\" />\n </section>\n );\n }\n\n return (\n <section aria-label={label} className={cx(\"flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground\", className)} style={sized ? undefined : { height }}>\n <header className=\"grid shrink-0 gap-2 border-b border-border bg-card px-3 py-2 text-card-foreground\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Combobox\n label=\"Find a person\"\n options={options}\n value={null}\n onValueChange={(v) => v && focusPerson(String(v))}\n open={searchOpen}\n onOpenChange={setSearchOpen}\n placeholder=\"Find a person…\"\n searchPlaceholder=\"Name, title or team\"\n empty=\"No one by that name.\"\n className=\"w-full sm:w-72\"\n />\n <p className=\"text-sm text-muted-foreground tabular-nums\">\n {everyone.length} {everyone.length === 1 ? \"person\" : \"people\"}\n <span className=\"max-sm:hidden\">\n {\" \"}\n · {managers} {managers === 1 ? \"manager\" : \"managers\"}\n </span>\n </p>\n <div className=\"flex-1\" />\n <div className=\"flex items-center gap-1\">\n <Tooltip label=\"Show every team\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"chevron-down\" onClick={() => foldAll(false)}>\n Expand all\n </Button>\n </Tooltip>\n <Tooltip label=\"Fold every team under the top\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"chevron-up\" onClick={() => foldAll(true)}>\n Collapse\n </Button>\n </Tooltip>\n </div>\n </div>\n {legend.length > 1 ? <Legend size=\"sm\" items={legend} label={typeof groupBy === \"string\" ? `People by ${groupBy}` : \"People by group\"} /> : null}\n </header>\n\n <div ref={boxRef} className=\"relative min-h-0 flex-1\" onKeyDownCapture={onKeyDownCapture} onPointerDownCapture={onPointerDownCapture} onPointerUpCapture={onPointerUpCapture}>\n <p className=\"sr-only\" aria-live=\"polite\">\n {announce}\n </p>\n <OrgContext.Provider value={ctx}>\n <FlowCanvas\n value={board}\n readOnly\n nodeTypes={NODE_TYPES}\n canvasRef={canvas}\n label={label}\n height=\"100%\"\n bare\n emptyState={false}\n templates={[]}\n describeNode={describePerson}\n className=\"[&_.react-flow__node]:transition-transform [&_.react-flow__node]:duration-200 [&_.react-flow__node]:ease-out motion-reduce:[&_.react-flow__node]:transition-none\"\n />\n </OrgContext.Provider>\n\n {drag ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute z-20 grid -translate-x-1/2 -translate-y-[calc(100%+10px)] justify-items-center gap-1\" style={{ left: drag.x, top: drag.y }}>\n <span className=\"max-w-60 truncate rounded-lg border border-border bg-popover px-3 py-1.5 text-sm font-medium text-popover-foreground shadow-lg\">{drag.name}</span>\n <span className=\"rounded-md bg-foreground px-2 py-0.5 text-xs text-background\">{dropId ? `Reports to ${nameOf(org.byId.get(dropId)?.person)}` : \"Drop on their new manager\"}</span>\n </div>\n ) : null}\n\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute bottom-3 left-3 z-10 hidden items-center gap-3 rounded-lg border border-border bg-card/90 px-2.5 py-1.5 text-xs text-muted-foreground shadow-xs backdrop-blur-sm md:flex\">\n <span className=\"flex items-center gap-1.5\">\n <Kbd>/</Kbd>find\n </span>\n <span className=\"flex items-center gap-1.5\">\n <Kbd>←↑→↓</Kbd>move\n </span>\n <span className=\"flex items-center gap-1.5\">\n <Kbd>Space</Kbd>team\n </span>\n <span className=\"flex items-center gap-1.5\">\n <Kbd>Enter</Kbd>details\n </span>\n </div>\n </div>\n\n <Sheet\n open={Boolean(details)}\n onClose={() => setDetailsId(null)}\n title={details ? nameOf(details.person) : \"\"}\n description={details?.person.title}\n footer={\n details ? (\n <Button\n size=\"sm\"\n icon=\"target\"\n onClick={() => {\n const id = details.id;\n setDetailsId(null);\n focusPerson(id);\n }}\n >\n Show on the chart\n </Button>\n ) : null\n }\n >\n {details ? (\n <PersonDetails\n node={details}\n byId={org.byId}\n group={groupOf(details.person)}\n hue={hues.get(groupOf(details.person))}\n extra={renderDetails?.(details.person)}\n onPick={(id) => {\n setDetailsId(id);\n focusPerson(id);\n }}\n />\n ) : null}\n </Sheet>\n </section>\n );\n}\n\nfunction describePerson(n) {\n if (n.type !== \"person\") return undefined;\n const d = n.data ?? {};\n return `${d.name || \"Unnamed\"}${d.title ? `, ${d.title}` : \"\"}${d.direct ? ` — ${d.direct} direct report${d.direct === 1 ? \"\" : \"s\"}` : \"\"}`;\n}\n\nfunction PersonDetails({ node, byId, group, hue, extra, onPick }) {\n const p = node.person;\n const boss = node.parentId ? byId.get(node.parentId) : null;\n const facts = [p.location].filter(Boolean);\n return (\n <div className=\"grid gap-6\">\n <div className=\"flex items-center gap-4\">\n <Avatar name={p.name} src={p.avatar} size=\"lg\" />\n <div className=\"grid min-w-0 gap-1\">\n <p className=\"truncate text-base font-semibold\">{nameOf(p)}</p>\n {p.title ? <p className=\"truncate text-sm text-muted-foreground\">{p.title}</p> : null}\n <div className=\"flex flex-wrap items-center gap-2 pt-1\">\n {group != null && group !== \"\" ? (\n <Badge variant=\"soft\" hue={hue}>\n {String(group)}\n </Badge>\n ) : null}\n {facts.map((f) => (\n <span key={f} className=\"text-xs text-muted-foreground\">\n {f}\n </span>\n ))}\n </div>\n </div>\n </div>\n\n {p.email ? (\n <a href={`mailto:${p.email}`} className=\"-mt-2 w-fit truncate text-sm text-primary underline-offset-4 hover:underline\">\n {p.email}\n </a>\n ) : null}\n\n <section className=\"grid gap-2\">\n <h3 className=\"text-sm font-medium\">Reports to</h3>\n {boss ? (\n <Item size=\"sm\" variant=\"outline\" media={<Avatar name={boss.person.name} src={boss.person.avatar} size=\"sm\" />} title={nameOf(boss.person)} description={boss.person.title} onClick={() => onPick(boss.id)} />\n ) : (\n <p className=\"text-sm text-muted-foreground\">No one — the top of the chart.</p>\n )}\n </section>\n\n <section className=\"grid gap-2\">\n <h3 className=\"flex items-baseline gap-2 text-sm font-medium\">\n Direct reports\n <span className=\"text-xs font-normal text-muted-foreground tabular-nums\">\n {node.children.length}\n {node.total > node.children.length ? ` · ${node.total} in the team` : \"\"}\n </span>\n </h3>\n {node.children.length ? (\n <ItemGroup>\n {node.children.map((c) => (\n <Item\n key={c.id}\n size=\"sm\"\n media={<Avatar name={c.person.name} src={c.person.avatar} size=\"sm\" />}\n title={nameOf(c.person)}\n description={c.person.title}\n meta={c.total ? <span className=\"tabular-nums\">{c.total}</span> : null}\n onClick={() => onPick(c.id)}\n />\n ))}\n </ItemGroup>\n ) : (\n <p className=\"text-sm text-muted-foreground\">No one reports to {nameOf(p).split(\" \")[0]}.</p>\n )}\n </section>\n\n {extra ? <section className=\"grid gap-2\">{extra}</section> : null}\n </div>\n );\n}\n"
23
+ "content": "\"use client\";\n\n// OrgChart — the people tree, top down, on FlowCanvas. Managers over their\n// reports, a team with no managers in it as a compact column, teams folded\n// past the first levels with a chip that says how big they are. Find a\n// person from the search and the chart unfolds down to them, centres them\n// and lights their chain of managers. A person's card opens their details\n// (reports to, direct reports); with `onChangeManager`, drag a card onto\n// another to move someone to a new manager.\n//\n// People come in flat (each names a manager); org.js builds the tree and\n// lays it out, and the canvas — read-only, its own tools unused — draws,\n// pans and zooms. Pointer and keys come through this component.\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Avatar, Badge, Button, Combobox, Empty, Item, ItemGroup, Kbd, Legend, Sheet, Tooltip, cx, toast } from \"../../ui\";\nimport { FlowCanvas, useFlowCanvas } from \"../flow-canvas\";\nimport { OrgContext, NODE_TYPES } from \"./person\";\nimport { CARD, STACK_FROM, buildOrg, chainOf, groupHues, layoutOrg } from \"./org\";\n\nconst CONTROL = 'input,textarea,select,[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"],[role=\"dialog\"],[role=\"menu\"],[popover],button,a[href]';\n\nfunction useCoarsePointer() {\n const [coarse, setCoarse] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(pointer: coarse)\");\n const on = () => setCoarse(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return coarse;\n}\n\nasync function settle(fn, ...args) {\n try {\n const res = await fn(...args);\n return res && res.ok === false ? { ok: false, error: res.error } : { ok: true };\n } catch (e) {\n return { ok: false, error: e?.message };\n }\n}\n\nconst nameOf = (p) => p?.name || \"Unnamed\";\n\nexport function OrgChart({\n people,\n managerKey = \"managerId\",\n groupBy = \"department\",\n defaultDepth = 3,\n onChangeManager,\n onOpenPerson,\n renderDetails,\n onSelectionChange,\n height = 640,\n label = \"Org chart\",\n className,\n}) {\n const canvas = useFlowCanvas();\n const boxRef = useRef(null);\n const coarse = useCoarsePointer();\n const cbs = useRef({});\n cbs.current = { onChangeManager, onOpenPerson, onSelectionChange };\n\n // ── moves not yet in `people` (optimistic) ────────────────────────────\n const [moves, setMoves] = useState(() => new Map());\n useEffect(() => {\n // What the page has taken in needs no override any more.\n setMoves((m) => {\n if (!m.size) return m;\n const next = new Map(m);\n for (const p of people ?? []) if (next.has(String(p.id)) && String(p[managerKey] ?? \"\") === String(next.get(String(p.id)) ?? \"\")) next.delete(String(p.id));\n return next.size === m.size ? m : next;\n });\n }, [people, managerKey]);\n const everyone = useMemo(\n () => (people ?? []).filter((p) => p && p.id != null).map((p) => (moves.has(String(p.id)) ? { ...p, [managerKey]: moves.get(String(p.id)) } : p)),\n [people, moves, managerKey],\n );\n\n const org = useMemo(() => buildOrg(everyone, { managerKey }), [everyone, managerKey]);\n const hues = useMemo(() => groupHues(everyone, groupBy), [everyone, groupBy]);\n const groupOf = useCallback((p) => (groupBy ? (typeof groupBy === \"function\" ? groupBy(p) : p?.[groupBy]) : null), [groupBy]);\n\n // ── folding ───────────────────────────────────────────────────────────\n const [folds, setFolds] = useState(() => new Map()); // id → folded?\n const isFolded = useCallback((n) => (folds.has(n.id) ? folds.get(n.id) : n.depth >= defaultDepth - 1), [folds, defaultDepth]);\n const layout = useMemo(() => layoutOrg(org.roots, { collapsed: (n) => n.children.length > 0 && isFolded(n) }), [org, isFolded]);\n const itemsById = useMemo(() => new Map(layout.items.map((it) => [it.id, it])), [layout]);\n\n const board = useMemo(\n () => ({\n nodes: layout.items.map((it) => {\n const p = it.node.person;\n const g = groupOf(p);\n return {\n id: it.id,\n type: \"person\",\n position: { x: Math.round(it.x), y: Math.round(it.y) },\n width: it.width,\n height: it.height,\n draggable: false,\n connectable: false,\n data: {\n name: p.name,\n title: p.title,\n avatar: p.avatar,\n group: g ?? undefined,\n hue: g != null ? hues.get(g) : undefined,\n direct: it.node.children.length,\n total: it.node.total,\n folded: it.folded,\n parentId: it.parentId,\n link: it.link.length ? it.link.map((pt) => ({ x: pt.x - it.x, y: pt.y - it.y })) : null,\n },\n };\n }),\n edges: [],\n }),\n [layout, hues, groupOf],\n );\n\n // ── selection ─────────────────────────────────────────────────────────\n const [selectedId, setSelectedId] = useState(null);\n const selectedRef = useRef(null);\n const select = useCallback((id) => {\n selectedRef.current = id;\n setSelectedId(id);\n }, []);\n useEffect(() => {\n if (selectedId && !org.byId.has(selectedId)) select(null);\n }, [org, selectedId, select]);\n const chain = useMemo(() => new Set(selectedId ? chainOf(org.byId, selectedId).map((n) => n.id) : []), [org, selectedId]);\n\n // The canvas pans with a plain drag (its hand tool).\n const hand = useCallback(() => {\n const api = canvas.current;\n if (api && api.getTool?.() !== \"hand\") api.setTool(\"hand\");\n }, [canvas]);\n useEffect(() => {\n let raf = 0;\n let tries = 0;\n const wait = () => {\n if (canvas.current) return hand();\n if (tries++ < 120) raf = requestAnimationFrame(wait);\n };\n wait();\n return () => cancelAnimationFrame(raf);\n }, [canvas, hand]);\n\n /** Centre a person when they are off screen (or always, with `force`),\n * at the zoom there is — but close enough to read. */\n const reveal = useCallback(\n (id, { force = false, tries = 4 } = {}) => {\n const host = boxRef.current;\n const el = host?.querySelector(`[data-person=\"${CSS.escape(id)}\"]`);\n if (!el) {\n if (tries > 0) setTimeout(() => reveal(id, { force, tries: tries - 1 }), 60);\n return;\n }\n const r = el.getBoundingClientRect();\n const c = host.getBoundingClientRect();\n const pad = 24;\n const inView = r.left >= c.left + pad && r.right <= c.right - pad && r.top >= c.top + pad && r.bottom <= c.bottom - pad - 40;\n if (inView && !force) return;\n const now = r.width / CARD.width;\n const zoom = force ? Math.max(now, Math.min(1, (c.width - 48) / CARD.width)) : now;\n canvas.current?.fitView({ nodes: [{ id }], minZoom: zoom, maxZoom: zoom, padding: 0.6 });\n },\n [canvas],\n );\n\n const [announce, setAnnounce] = useState(\"\");\n const say = (m) => setAnnounce((x) => (x === m ? `${m} ` : m));\n\n useEffect(() => {\n const n = selectedId ? org.byId.get(selectedId) : null;\n cbs.current.onSelectionChange?.(n?.person ?? null);\n if (!n) return;\n const p = n.person;\n const boss = n.parentId ? org.byId.get(n.parentId)?.person : null;\n say(`${nameOf(p)}${p.title ? `, ${p.title}` : \"\"}${boss ? `, reports to ${nameOf(boss)}` : \"\"}${n.children.length ? `, ${n.children.length} direct report${n.children.length === 1 ? \"\" : \"s\"}` : \"\"}`);\n const raf = requestAnimationFrame(() => {\n const host = boxRef.current;\n const el = host?.querySelector(`.react-flow__node[data-id=\"${CSS.escape(selectedId)}\"]`);\n if (el && host.contains(document.activeElement) && document.activeElement !== el) el.focus({ preventScroll: true });\n reveal(selectedId);\n });\n return () => cancelAnimationFrame(raf);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selectedId]);\n\n const setFold = useCallback(\n (id, folded) => {\n const n = org.byId.get(id);\n if (!n || !n.children.length) return;\n setFolds((f) => new Map(f).set(id, folded ?? !isFolded(n)));\n say(`${nameOf(n.person)}'s team ${folded ?? !isFolded(n) ? \"hidden\" : \"shown\"}`);\n },\n [org, isFolded],\n );\n const foldAll = (folded) => {\n const next = new Map();\n for (const n of org.byId.values()) if (n.children.length) next.set(n.id, folded && n.depth > 0);\n setFolds(next);\n setTimeout(() => canvas.current?.fitView(), 80);\n };\n\n /** Unfold the way down to someone, select them, centre them. */\n const focusPerson = useCallback(\n (id) => {\n const path = chainOf(org.byId, id);\n if (!path.length) return;\n setFolds((f) => {\n const next = new Map(f);\n for (const n of path.slice(0, -1)) next.set(n.id, false);\n return next;\n });\n select(id);\n setTimeout(() => reveal(id, { force: true }), 30);\n },\n [org, select, reveal],\n );\n\n // ── details ───────────────────────────────────────────────────────────\n const [detailsId, setDetailsId] = useState(null);\n const open = useCallback(\n (id) => {\n const n = org.byId.get(id);\n if (!n) return;\n select(id);\n if (cbs.current.onOpenPerson) return cbs.current.onOpenPerson(n.person);\n setDetailsId(id);\n },\n [org, select],\n );\n\n // ── moving someone to a new manager ──────────────────────────────────\n const canMove = Boolean(onChangeManager);\n const reassign = async (id, to, { quiet = false } = {}) => {\n const n = org.byId.get(id);\n if (!n) return;\n const from = n.person[managerKey] ?? null;\n setMoves((m) => new Map(m).set(id, to));\n if (to) setFolds((f) => new Map(f).set(to, false));\n const boss = to ? org.byId.get(to)?.person : null;\n const res = await settle(cbs.current.onChangeManager, n.person, to);\n if (!res.ok) {\n setMoves((m) => {\n const next = new Map(m);\n if (String(from ?? \"\") === String((people ?? []).find((p) => String(p.id) === id)?.[managerKey] ?? \"\")) next.delete(id);\n else next.set(id, from);\n return next;\n });\n toast.error(`Couldn't move ${nameOf(n.person)}`, { description: res.error || \"Nothing changed. Try again.\" });\n return;\n }\n if (!quiet) {\n toast(`${nameOf(n.person)} now reports to ${boss ? nameOf(boss) : \"no one\"}`, {\n action: { label: \"Undo\", onClick: () => reassign(id, from, { quiet: true }) },\n });\n }\n };\n\n const [drag, setDrag] = useState(null); // { id, name, x, y }\n const [dropId, setDropId] = useState(null);\n const onCardPointerDown = (e, id) => {\n const wasSelected = selectedRef.current === id;\n select(id);\n const start = { x: e.clientX, y: e.clientY };\n let active = false;\n let over = null;\n const n = org.byId.get(id);\n const allowDrag = canMove && e.pointerType === \"mouse\";\n const move = (ev) => {\n if (!allowDrag) return;\n if (!active && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < 5) return;\n active = true;\n const host = boxRef.current?.getBoundingClientRect();\n if (!host) return;\n const hit = document\n .elementsFromPoint(ev.clientX, ev.clientY)\n .map((el) => el.closest?.(\"[data-person]\"))\n .find((el) => el && el.dataset.person !== id);\n const tid = hit?.dataset.person ?? null;\n const inside = tid && chainOf(org.byId, tid).some((x) => x.id === id);\n over = tid && !inside && n?.parentId !== tid ? tid : null;\n setDropId(over);\n setDrag({ id, name: nameOf(n?.person), x: ev.clientX - host.left, y: ev.clientY - host.top });\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n if (!active) {\n // A second click on the selected card opens it (a tap, on a phone).\n if (wasSelected) open(id);\n return;\n }\n setDrag(null);\n setDropId(null);\n if (over) reassign(id, over);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n };\n\n // A click on the empty canvas (not a pan) clears the selection.\n const paneDown = useRef(null);\n const onPointerDownCapture = (e) => {\n paneDown.current = e.target?.classList?.contains(\"react-flow__pane\") ? { x: e.clientX, y: e.clientY } : null;\n };\n const onPointerUpCapture = (e) => {\n const p = paneDown.current;\n paneDown.current = null;\n if (p && Math.hypot(e.clientX - p.x, e.clientY - p.y) <= 4) select(null);\n };\n\n // ── keys ──────────────────────────────────────────────────────────────\n const [searchOpen, setSearchOpen] = useState(false);\n const onKeyDownCapture = (e) => {\n const t = e.target;\n if (t?.closest?.(CONTROL)) return;\n const k = e.key;\n const mod = e.metaKey || e.ctrlKey;\n const done = () => {\n e.preventDefault();\n e.stopPropagation();\n };\n if (mod || e.altKey) return;\n if (k === \"/\") return done(), setSearchOpen(true);\n const sel = selectedRef.current && org.byId.has(selectedRef.current) ? selectedRef.current : null;\n if (k === \"Escape\") {\n if (sel) return done(), select(null);\n setTimeout(hand, 0);\n return;\n }\n const first = org.roots[0]?.id;\n if (!sel) {\n if ((k.startsWith(\"Arrow\") || k === \"Enter\" || k === \"Home\") && first) return done(), select(first);\n return;\n }\n const n = org.byId.get(sel);\n const parent = n.parentId ? org.byId.get(n.parentId) : null;\n const siblings = parent ? parent.children : org.roots;\n const i = siblings.findIndex((x) => x.id === sel);\n const stacked = parent && itemsById.get(siblings[0]?.id)?.link?.length === 3;\n if (k === \"Home\" && first) return done(), select(first);\n if (k === \"Enter\") return done(), open(sel);\n if (k === \" \") return done(), setFold(sel);\n if (k === \"ArrowUp\") {\n done();\n if (stacked && i > 0) return select(siblings[i - 1].id);\n return parent && select(parent.id);\n }\n if (k === \"ArrowDown\") {\n done();\n if (n.children.length) {\n if (isFolded(n)) setFold(sel, false);\n const kids = n.children;\n // Into a column: its top. Into a row: its middle, under the manager.\n const column = kids.length >= STACK_FROM && kids.every((c) => !c.children.length);\n return select(kids[column ? 0 : Math.floor((kids.length - 1) / 2)].id);\n }\n if (stacked && i < siblings.length - 1) return select(siblings[i + 1].id);\n return;\n }\n if (k === \"ArrowLeft\" || k === \"ArrowRight\") {\n done();\n const j = i + (k === \"ArrowLeft\" ? -1 : 1);\n if (siblings[j]) return select(siblings[j].id);\n // Past the end of the team: the nearest card across, in the same row.\n const me = itemsById.get(sel);\n if (!me) return;\n const across = layout.items\n .filter((it) => Math.abs(it.y - me.y) < 4 && (k === \"ArrowLeft\" ? it.x < me.x : it.x > me.x))\n .sort((a, b) => Math.abs(a.x - me.x) - Math.abs(b.x - me.x))[0];\n if (across) select(across.id);\n }\n };\n\n // ── the context the cards read ────────────────────────────────────────\n const ctx = useMemo(\n () => ({\n selectedId,\n chain,\n dropId,\n dragId: drag?.id ?? null,\n coarse,\n draggable: canMove && !coarse,\n onCardPointerDown,\n open,\n toggle: (id) => setFold(id),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [selectedId, chain, dropId, drag, coarse, canMove, open, setFold, org],\n );\n\n // ── chrome ────────────────────────────────────────────────────────────\n const options = useMemo(\n () =>\n everyone.map((p) => ({\n value: String(p.id),\n label: nameOf(p),\n description: [p.title, groupOf(p)].filter(Boolean).join(\" · \") || undefined,\n icon: <Avatar name={p.name} src={p.avatar} size=\"xs\" />,\n keywords: [p.title, groupOf(p), p.email, p.location].filter(Boolean),\n })),\n [everyone, groupOf],\n );\n const legend = useMemo(() => {\n if (!groupBy || !hues.size) return [];\n const count = new Map();\n for (const p of everyone) {\n const g = groupOf(p);\n if (g != null && g !== \"\") count.set(g, (count.get(g) ?? 0) + 1);\n }\n return [...hues.entries()].map(([g, hue]) => ({ key: String(g), hue, label: String(g), count: count.get(g) }));\n }, [everyone, hues, groupBy, groupOf]);\n const managers = useMemo(() => [...org.byId.values()].filter((n) => n.children.length).length, [org]);\n const sized = /(^|\\s)(h-|min-h-|size-|flex-1|grow)/.test(className ?? \"\");\n const details = detailsId ? org.byId.get(detailsId) : null;\n\n if (!everyone.length) {\n return (\n <section aria-label={label} className={cx(\"grid place-items-center rounded-lg border border-border bg-background p-6\", className)} style={sized ? undefined : { minHeight: Math.min(Number(height) || 320, 320) }}>\n <Empty icon=\"users\" title=\"No one on the chart yet\" body=\"People appear here once they're added, each with the person they report to.\" />\n </section>\n );\n }\n\n return (\n <section aria-label={label} className={cx(\"flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground\", className)} style={sized ? undefined : { height }}>\n <header className=\"grid shrink-0 gap-2 border-b border-border bg-card px-3 py-2 text-card-foreground\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Combobox\n label=\"Find a person\"\n options={options}\n value={null}\n onValueChange={(v) => v && focusPerson(String(v))}\n open={searchOpen}\n onOpenChange={setSearchOpen}\n placeholder=\"Find a person…\"\n searchPlaceholder=\"Name, title or team\"\n empty=\"No one by that name.\"\n className=\"w-full sm:w-72\"\n />\n <p className=\"text-sm text-muted-foreground tabular-nums\">\n {everyone.length} {everyone.length === 1 ? \"person\" : \"people\"}\n <span className=\"max-sm:hidden\">\n {\" \"}\n · {managers} {managers === 1 ? \"manager\" : \"managers\"}\n </span>\n </p>\n <div className=\"flex-1\" />\n <div className=\"flex items-center gap-1\">\n <Tooltip label=\"Show every team\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"chevron-down\" onClick={() => foldAll(false)}>\n Expand all\n </Button>\n </Tooltip>\n <Tooltip label=\"Fold every team under the top\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"chevron-up\" onClick={() => foldAll(true)}>\n Collapse\n </Button>\n </Tooltip>\n </div>\n </div>\n {legend.length > 1 ? <Legend size=\"sm\" items={legend} label={typeof groupBy === \"string\" ? `People by ${groupBy}` : \"People by group\"} /> : null}\n </header>\n\n <div ref={boxRef} className=\"relative min-h-0 flex-1\" onKeyDownCapture={onKeyDownCapture} onPointerDownCapture={onPointerDownCapture} onPointerUpCapture={onPointerUpCapture}>\n <p className=\"sr-only\" aria-live=\"polite\">\n {announce}\n </p>\n <OrgContext.Provider value={ctx}>\n <FlowCanvas\n value={board}\n readOnly\n nodeTypes={NODE_TYPES}\n canvasRef={canvas}\n label={label}\n height=\"100%\"\n bare\n emptyState={false}\n templates={[]}\n describeNode={describePerson}\n className={String.raw`[&_.react-flow\\_\\_node]:transition-transform [&_.react-flow\\_\\_node]:duration-200 [&_.react-flow\\_\\_node]:ease-out motion-reduce:[&_.react-flow\\_\\_node]:transition-none`}\n />\n </OrgContext.Provider>\n\n {drag ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute z-20 grid -translate-x-1/2 -translate-y-[calc(100%+10px)] justify-items-center gap-1\" style={{ left: drag.x, top: drag.y }}>\n <span className=\"max-w-60 truncate rounded-lg border border-border bg-popover px-3 py-1.5 text-sm font-medium text-popover-foreground shadow-lg\">{drag.name}</span>\n <span className=\"rounded-md bg-foreground px-2 py-0.5 text-xs text-background\">{dropId ? `Reports to ${nameOf(org.byId.get(dropId)?.person)}` : \"Drop on their new manager\"}</span>\n </div>\n ) : null}\n\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute bottom-3 left-3 z-10 hidden items-center gap-3 rounded-lg border border-border bg-card/90 px-2.5 py-1.5 text-xs text-muted-foreground shadow-xs backdrop-blur-sm md:flex\">\n <span className=\"flex items-center gap-1.5\">\n <Kbd>/</Kbd>find\n </span>\n <span className=\"flex items-center gap-1.5\">\n <Kbd>←↑→↓</Kbd>move\n </span>\n <span className=\"flex items-center gap-1.5\">\n <Kbd>Space</Kbd>team\n </span>\n <span className=\"flex items-center gap-1.5\">\n <Kbd>Enter</Kbd>details\n </span>\n </div>\n </div>\n\n <Sheet\n open={Boolean(details)}\n onClose={() => setDetailsId(null)}\n title={details ? nameOf(details.person) : \"\"}\n description={details?.person.title}\n footer={\n details ? (\n <Button\n size=\"sm\"\n icon=\"target\"\n onClick={() => {\n const id = details.id;\n setDetailsId(null);\n focusPerson(id);\n }}\n >\n Show on the chart\n </Button>\n ) : null\n }\n >\n {details ? (\n <PersonDetails\n node={details}\n byId={org.byId}\n group={groupOf(details.person)}\n hue={hues.get(groupOf(details.person))}\n extra={renderDetails?.(details.person)}\n onPick={(id) => {\n setDetailsId(id);\n focusPerson(id);\n }}\n />\n ) : null}\n </Sheet>\n </section>\n );\n}\n\nfunction describePerson(n) {\n if (n.type !== \"person\") return undefined;\n const d = n.data ?? {};\n return `${d.name || \"Unnamed\"}${d.title ? `, ${d.title}` : \"\"}${d.direct ? ` — ${d.direct} direct report${d.direct === 1 ? \"\" : \"s\"}` : \"\"}`;\n}\n\nfunction PersonDetails({ node, byId, group, hue, extra, onPick }) {\n const p = node.person;\n const boss = node.parentId ? byId.get(node.parentId) : null;\n const facts = [p.location].filter(Boolean);\n return (\n <div className=\"grid gap-6\">\n <div className=\"flex items-center gap-4\">\n <Avatar name={p.name} src={p.avatar} size=\"lg\" />\n <div className=\"grid min-w-0 gap-1\">\n <p className=\"truncate text-base font-semibold\">{nameOf(p)}</p>\n {p.title ? <p className=\"truncate text-sm text-muted-foreground\">{p.title}</p> : null}\n <div className=\"flex flex-wrap items-center gap-2 pt-1\">\n {group != null && group !== \"\" ? (\n <Badge variant=\"soft\" hue={hue}>\n {String(group)}\n </Badge>\n ) : null}\n {facts.map((f) => (\n <span key={f} className=\"text-xs text-muted-foreground\">\n {f}\n </span>\n ))}\n </div>\n </div>\n </div>\n\n {p.email ? (\n <a href={`mailto:${p.email}`} className=\"-mt-2 w-fit truncate text-sm text-primary underline-offset-4 hover:underline\">\n {p.email}\n </a>\n ) : null}\n\n <section className=\"grid gap-2\">\n <h3 className=\"text-sm font-medium\">Reports to</h3>\n {boss ? (\n <Item size=\"sm\" variant=\"outline\" media={<Avatar name={boss.person.name} src={boss.person.avatar} size=\"sm\" />} title={nameOf(boss.person)} description={boss.person.title} onClick={() => onPick(boss.id)} />\n ) : (\n <p className=\"text-sm text-muted-foreground\">No one — the top of the chart.</p>\n )}\n </section>\n\n <section className=\"grid gap-2\">\n <h3 className=\"flex items-baseline gap-2 text-sm font-medium\">\n Direct reports\n <span className=\"text-xs font-normal text-muted-foreground tabular-nums\">\n {node.children.length}\n {node.total > node.children.length ? ` · ${node.total} in the team` : \"\"}\n </span>\n </h3>\n {node.children.length ? (\n <ItemGroup>\n {node.children.map((c) => (\n <Item\n key={c.id}\n size=\"sm\"\n media={<Avatar name={c.person.name} src={c.person.avatar} size=\"sm\" />}\n title={nameOf(c.person)}\n description={c.person.title}\n meta={c.total ? <span className=\"tabular-nums\">{c.total}</span> : null}\n onClick={() => onPick(c.id)}\n />\n ))}\n </ItemGroup>\n ) : (\n <p className=\"text-sm text-muted-foreground\">No one reports to {nameOf(p).split(\" \")[0]}.</p>\n )}\n </section>\n\n {extra ? <section className=\"grid gap-2\">{extra}</section> : null}\n </div>\n );\n}\n"
24
24
  },
25
25
  {
26
26
  "path": "registry/blocks/org-chart/person.jsx",
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/org-chart. Import: import { OrgChart, buildOrg, layoutOrg, chainOf, orgToText, groupHues } from \"@/components/makersclaw/blocks/org-chart\". Props and an example: components/makersclaw/blocks/org-chart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.4.0",
62
+ "version": "0.5.2",
63
63
  "useWhen": "A screen that shows who reports to whom: a team or company directory's chart, an account's buying committee as a hierarchy, a reporting line to check before a reorg. Not for a free-form map of people and notes (account-map, flow-canvas), a plain list of people (data-table), or a tree of ideas (mind-map)."
64
64
  }
65
65
  }
@@ -5,7 +5,7 @@
5
5
  "title": "PageBuilder",
6
6
  "description": "A visual page editor (GrapesJS core) in the workspace theme: our own clean panels instead of GrapesJS's chrome — an Add panel of section and basic blocks (click or drag), a Layers tree, a Design panel limited to theme-token choices (colours, type, spacing, width, corners, borders), a device toggle (desktop, tablet, phone), undo/redo, preview, and a friendly empty state with starter templates. Pages style with CSS variables, so a saved page repaints with the theme. Saves out as { html, css, project }; PageView shows a saved page in an app.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.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
  "grapesjs@0.22.16",
@@ -98,7 +98,7 @@
98
98
  "docs": "Installed to components/makersclaw/blocks/page-builder. Import: import { PageBuilder, usePageBuilder, readPage, DEVICES, PageView, standaloneDocument, BlockThumb, PageThumb, THUMB_NAMES, BASE_CSS, SECTION_BLOCKS, BASIC_BLOCKS, STARTER_TEMPLATES, STYLE_GROUPS, COLOR_TOKENS, groupsFor, currentOption, patchFor, tokenCss, fontFaceCss, fontLinks, PAGE_BASE_CSS, emptyPage, isPageEmpty, cleanHtml, cleanCss, normalizeBlock, templatePage, pageDocument, projectSections } from \"@/components/makersclaw/blocks/page-builder\". Props and an example: components/makersclaw/blocks/page-builder/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
99
99
  "meta": {
100
100
  "makersclaw": {
101
- "version": "0.4.0",
101
+ "version": "0.5.2",
102
102
  "useWhen": "A screen where someone builds or edits a web page visually: a landing page, a waitlist or launch page, a simple site section, an announcement page. landing-builder is built on it (section templates, publishing). Not for rich text in a document (doc-editor), an email body (newsletter-builder), a form of fields (record-form) or a freeform drawing (sketch-canvas)."
103
103
  }
104
104
  }
@@ -5,7 +5,7 @@
5
5
  "title": "PaymentMethods",
6
6
  "description": "A customer's SAVED payment methods as cards: brand, last four digits, expiry (expired and expiring soon flagged) and a Default badge; Make default, Remove behind a Confirm, and Add, which hands off to the payment provider (Paddle). Never a card-number field. Empty, loading and error states built in.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.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/payment-methods. Import: import { PaymentMethods } from \"@/components/makersclaw/blocks/payment-methods\". Props and an example: components/makersclaw/blocks/payment-methods/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
45
45
  "meta": {
46
46
  "makersclaw": {
47
- "version": "0.4.0",
47
+ "version": "0.5.2",
48
48
  "useWhen": "A billing or account page where a customer manages the cards and wallets on file. Not for taking a payment (cart's checkout step hands off to Paddle) or for a list of charges (transaction-list)."
49
49
  }
50
50
  }
@@ -5,7 +5,7 @@
5
5
  "title": "PdfDocument",
6
6
  "description": "Make PDFs in the workspace theme (@react-pdf/renderer): a small set of document primitives (headings, text, label/value fields, tables with repeating headers, totals, callouts, badges, rows and columns) and a clean ready-made quote / estimate / invoice layout, rendered in the browser to a Blob with renderPdf(doc). The theme's colours are read from the page when the PDF is made (a PDF cannot read CSS variables): white paper, near-black ink and the theme's primary as the one accent. Also a live-preview hook (usePdf), a Download PDF button, and the quote sums and money formatting as plain functions.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.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
  "@react-pdf/renderer@4.9.0"
@@ -72,7 +72,7 @@
72
72
  "docs": "Installed to components/makersclaw/blocks/pdf-document. Import: import { renderPdf, usePdf, PdfDownloadButton, downloadBlob, registerPdfFont, PdfDocument, PdfStack, PdfRow, PdfCol, PdfText, PdfHeading, PdfLabel, PdfField, PdfDivider, PdfCallout, PdfBadge, PdfTable, PdfTotals, PdfLines, PdfView, PdfRawText, PdfImage, PdfLink, PdfThemeProvider, usePdfTheme, PDF_TYPE, QuoteDocument, quoteTotals, lineAmount, round2, formatMoney, formatQuantity, formatPdfDate, resolvePdfTheme, buildPdfTheme, DEFAULT_PDF_THEME } from \"@/components/makersclaw/blocks/pdf-document\". Props and an example: components/makersclaw/blocks/pdf-document/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
73
73
  "meta": {
74
74
  "makersclaw": {
75
- "version": "0.4.0",
75
+ "version": "0.5.2",
76
76
  "useWhen": "A screen produces a document someone downloads, prints, emails or signs: a quote, an invoice, an estimate, a receipt, a statement, a certificate, a one-page report. The quote-builder and proposal blocks print through it. Not for showing an existing PDF on screen (pdf-viewer) or for a web page people read in the app (doc-page)."
77
77
  }
78
78
  }
package/r/pdf-viewer.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "PdfViewer",
6
6
  "description": "A PDF on screen, in the workspace theme (react-pdf + pdf.js): pages in one smooth scrolling column (only the pages near the window are drawn, so long files stay fast), a thumbnail rail, a page box, zoom by steps, fit width, fit page and Ctrl/⌘ + wheel or pinch, find in the text with every match marked, and highlights with notes: select text, choose Highlight or Add note, and it saves through onHighlight; notes are edited in a side list, colours are theme tokens, delete has Undo. Opens a URL, a Blob or bytes; password-protected files ask for the password; a narrow viewer turns its rails into sheets. The pdf.js worker ships with the app (no public/ file, no CDN).",
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
  "react-pdf@11.0.0",
@@ -28,7 +28,7 @@
28
28
  "path": "registry/blocks/pdf-viewer/notes.jsx",
29
29
  "type": "registry:component",
30
30
  "target": "@components/makersclaw/blocks/pdf-viewer/notes.jsx",
31
- "content": "\"use client\";\n\n// The highlights list: every highlight in reading order, its quote, its\n// note, and what can be done with it. A rail beside the pages on a wide\n// viewer, a sheet on a narrow one (pdf-viewer.jsx decides). A click on an\n// entry takes the pages to it; its note is edited in place.\nimport { useEffect, useRef, useState } from \"react\";\nimport { Button, Icon, Menu, Textarea, cx } from \"../../ui\";\nimport { sortHighlights } from \"./helpers\";\n\n/** The colours a highlight can take, as token names (never a colour). */\nexport const HIGHLIGHT_TONES = [\n { value: \"warning\", label: \"Amber\" },\n { value: \"info\", label: \"Blue\" },\n { value: \"success\", label: \"Green\" },\n { value: \"destructive\", label: \"Red\" },\n { value: \"primary\", label: \"Accent\" },\n];\n\n// Literal classes so Tailwind sees every one. On the page a highlight is\n// a marker stroke: the tone, translucent, multiplied into the paper so\n// the ink shows through. In the list it is a bar at the quote's edge.\nexport const TONE_FILL = {\n warning: \"bg-warning/45\",\n info: \"bg-info/30\",\n success: \"bg-success/30\",\n destructive: \"bg-destructive/25\",\n primary: \"bg-primary/30\",\n};\nexport const TONE_RING = {\n warning: \"outline-warning\",\n info: \"outline-info\",\n success: \"outline-success\",\n destructive: \"outline-destructive\",\n primary: \"outline-primary\",\n};\nconst TONE_BAR = {\n warning: \"border-warning\",\n info: \"border-info\",\n success: \"border-success\",\n destructive: \"border-destructive\",\n primary: \"border-primary\",\n};\nexport const toneOf = (h) => (TONE_FILL[h?.tone] ? h.tone : \"warning\");\n\nexport function NotesPanel({ highlights, activeId, editingId, readOnly, onJump, onEdit, onSaveNote, onTone, onDelete, onCopy, className }) {\n const list = sortHighlights(highlights);\n const activeRef = useRef(null);\n\n useEffect(() => {\n activeRef.current?.scrollIntoView?.({ block: \"nearest\", behavior: \"smooth\" });\n }, [activeId]);\n\n if (!list.length) {\n return (\n <div className={cx(\"grid place-items-center px-6 py-10 text-center\", className)}>\n <div className=\"grid max-w-60 justify-items-center gap-2\">\n <span className=\"grid size-10 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name=\"highlighter\" size={18} />\n </span>\n <p className=\"text-sm font-medium\">No highlights yet</p>\n <p className=\"text-sm text-muted-foreground\">\n {readOnly ? \"Highlights added to this document show here.\" : \"Select text on a page, then choose Highlight or Add note.\"}\n </p>\n </div>\n </div>\n );\n }\n\n return (\n <ol className={cx(\"grid gap-1 p-2\", className)} aria-label=\"Highlights\">\n {list.map((h, i) => {\n const prevPage = list[i - 1]?.page;\n return (\n <li key={h.id} ref={h.id === activeId ? activeRef : undefined}>\n {h.page !== prevPage ? <p className={cx(\"px-2 pb-1 text-xs text-muted-foreground tabular-nums\", i > 0 && \"pt-3\")}>{h.page ? `Page ${h.page}` : \"Not found in the text\"}</p> : null}\n <NoteItem\n h={h}\n active={h.id === activeId}\n editing={h.id === editingId}\n readOnly={readOnly}\n onJump={onJump}\n onEdit={onEdit}\n onSaveNote={onSaveNote}\n onTone={onTone}\n onDelete={onDelete}\n onCopy={onCopy}\n />\n </li>\n );\n })}\n </ol>\n );\n}\n\nfunction NoteItem({ h, active, editing, readOnly, onJump, onEdit, onSaveNote, onTone, onDelete, onCopy }) {\n const tone = toneOf(h);\n const [draft, setDraft] = useState(h.note ?? \"\");\n const area = useRef(null);\n\n useEffect(() => {\n if (editing) {\n setDraft(h.note ?? \"\");\n requestAnimationFrame(() => {\n area.current?.focus();\n const n = area.current?.value.length ?? 0;\n area.current?.setSelectionRange?.(n, n);\n });\n }\n }, [editing, h.note]);\n\n const save = () => {\n onSaveNote(h, draft.trim());\n onEdit(null);\n };\n\n return (\n <div className={cx(\"group relative rounded-md transition-colors duration-150 motion-reduce:transition-none\", active ? \"bg-accent\" : \"hover:bg-accent/60\")}>\n {/* raw: the whole quote is the button that takes the pages to it; a\n kit Button is one line, this is a block of text with a tone bar. */}\n <button\n type=\"button\"\n onClick={() => onJump(h)}\n aria-current={active ? \"true\" : undefined}\n className=\"block w-full rounded-md px-2 py-2 pr-10 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className={cx(\"line-clamp-3 border-l-2 pl-2.5 text-sm leading-snug text-foreground\", TONE_BAR[tone], h.pending && \"opacity-60\")}>{h.text || \"Highlighted area\"}</span>\n {h.note && !editing ? <span className=\"mt-1.5 block whitespace-pre-wrap pl-3 text-sm text-muted-foreground\">{h.note}</span> : null}\n {h.author || h.createdAt ? (\n <span className=\"mt-1.5 block truncate pl-3 text-xs text-muted-foreground\">{[h.author, h.createdAt ? when(h.createdAt) : null].filter(Boolean).join(\" · \")}</span>\n ) : null}\n </button>\n <div className={cx(\"absolute right-1 top-1 transition-opacity duration-150\", active ? \"opacity-100\" : \"opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100\")}>\n <Menu\n label={`Highlight${h.page ? ` on page ${h.page}` : \"\"}, actions`}\n items={[\n !readOnly && { label: h.note ? \"Edit note\" : \"Add note\", icon: \"pencil\", onSelect: () => onEdit(h.id) },\n { label: \"Copy text\", icon: \"copy\", onSelect: () => onCopy(h) },\n !readOnly && {\n label: \"Colour\",\n icon: \"palette\",\n items: [{ type: \"radio\", value: tone, onValueChange: (v) => onTone(h, v), options: HIGHLIGHT_TONES }],\n },\n !readOnly && { type: \"separator\" },\n !readOnly && { label: \"Delete highlight\", icon: \"trash\", tone: \"danger\", onSelect: () => onDelete(h) },\n ]}\n />\n </div>\n {editing ? (\n <div className=\"grid gap-2 px-2 pb-2 pl-5\">\n <Textarea\n ref={area}\n autoGrow\n minRows={2}\n maxRows={8}\n value={draft}\n placeholder=\"Write a note…\"\n aria-label={`Note on page ${h.page}`}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n e.preventDefault();\n save();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n onEdit(null);\n }\n }}\n />\n <div className=\"flex items-center justify-end gap-1\">\n <Button size=\"sm\" variant=\"quiet\" onClick={() => onEdit(null)}>\n Cancel\n </Button>\n <Button size=\"sm\" variant=\"primary\" onClick={save}>\n Save note\n </Button>\n </div>\n </div>\n ) : !h.note && !readOnly && active ? (\n <div className=\"px-2 pb-2 pl-4\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"message-square\" onClick={() => onEdit(h.id)}>\n Add note\n </Button>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction when(v) {\n const d = new Date(v);\n if (Number.isNaN(d.getTime())) return String(v);\n return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" }).format(d);\n}\n"
31
+ "content": "\"use client\";\n\n// The highlights list: every highlight in reading order, its quote, its\n// note, and what can be done with it. A rail beside the pages on a wide\n// viewer, a sheet on a narrow one (pdf-viewer.jsx decides). A click on an\n// entry takes the pages to it; its note is edited in place.\nimport { useEffect, useRef, useState } from \"react\";\nimport { Button, Icon, Menu, Textarea, cx, toLocalDate } from \"../../ui\";\nimport { sortHighlights } from \"./helpers\";\n\n/** The colours a highlight can take, as token names (never a colour). */\nexport const HIGHLIGHT_TONES = [\n { value: \"warning\", label: \"Amber\" },\n { value: \"info\", label: \"Blue\" },\n { value: \"success\", label: \"Green\" },\n { value: \"destructive\", label: \"Red\" },\n { value: \"primary\", label: \"Accent\" },\n];\n\n// Literal classes so Tailwind sees every one. On the page a highlight is\n// a marker stroke: the tone, translucent, multiplied into the paper so\n// the ink shows through. In the list it is a bar at the quote's edge.\nexport const TONE_FILL = {\n warning: \"bg-warning/45\",\n info: \"bg-info/30\",\n success: \"bg-success/30\",\n destructive: \"bg-destructive/25\",\n primary: \"bg-primary/30\",\n};\nexport const TONE_RING = {\n warning: \"outline-warning\",\n info: \"outline-info\",\n success: \"outline-success\",\n destructive: \"outline-destructive\",\n primary: \"outline-primary\",\n};\nconst TONE_BAR = {\n warning: \"border-warning\",\n info: \"border-info\",\n success: \"border-success\",\n destructive: \"border-destructive\",\n primary: \"border-primary\",\n};\nexport const toneOf = (h) => (TONE_FILL[h?.tone] ? h.tone : \"warning\");\n\nexport function NotesPanel({ highlights, activeId, editingId, readOnly, onJump, onEdit, onSaveNote, onTone, onDelete, onCopy, className }) {\n const list = sortHighlights(highlights);\n const activeRef = useRef(null);\n\n useEffect(() => {\n activeRef.current?.scrollIntoView?.({ block: \"nearest\", behavior: \"smooth\" });\n }, [activeId]);\n\n if (!list.length) {\n return (\n <div className={cx(\"grid place-items-center px-6 py-10 text-center\", className)}>\n <div className=\"grid max-w-60 justify-items-center gap-2\">\n <span className=\"grid size-10 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name=\"highlighter\" size={18} />\n </span>\n <p className=\"text-sm font-medium\">No highlights yet</p>\n <p className=\"text-sm text-muted-foreground\">\n {readOnly ? \"Highlights added to this document show here.\" : \"Select text on a page, then choose Highlight or Add note.\"}\n </p>\n </div>\n </div>\n );\n }\n\n return (\n <ol className={cx(\"grid gap-1 p-2\", className)} aria-label=\"Highlights\">\n {list.map((h, i) => {\n const prevPage = list[i - 1]?.page;\n return (\n <li key={h.id} ref={h.id === activeId ? activeRef : undefined}>\n {h.page !== prevPage ? <p className={cx(\"px-2 pb-1 text-xs text-muted-foreground tabular-nums\", i > 0 && \"pt-3\")}>{h.page ? `Page ${h.page}` : \"Not found in the text\"}</p> : null}\n <NoteItem\n h={h}\n active={h.id === activeId}\n editing={h.id === editingId}\n readOnly={readOnly}\n onJump={onJump}\n onEdit={onEdit}\n onSaveNote={onSaveNote}\n onTone={onTone}\n onDelete={onDelete}\n onCopy={onCopy}\n />\n </li>\n );\n })}\n </ol>\n );\n}\n\nfunction NoteItem({ h, active, editing, readOnly, onJump, onEdit, onSaveNote, onTone, onDelete, onCopy }) {\n const tone = toneOf(h);\n const [draft, setDraft] = useState(h.note ?? \"\");\n const area = useRef(null);\n\n useEffect(() => {\n if (editing) {\n setDraft(h.note ?? \"\");\n requestAnimationFrame(() => {\n area.current?.focus();\n const n = area.current?.value.length ?? 0;\n area.current?.setSelectionRange?.(n, n);\n });\n }\n }, [editing, h.note]);\n\n const save = () => {\n onSaveNote(h, draft.trim());\n onEdit(null);\n };\n\n return (\n <div className={cx(\"group relative rounded-md transition-colors duration-150 motion-reduce:transition-none\", active ? \"bg-accent\" : \"hover:bg-accent/60\")}>\n {/* raw: the whole quote is the button that takes the pages to it; a\n kit Button is one line, this is a block of text with a tone bar. */}\n <button\n type=\"button\"\n onClick={() => onJump(h)}\n aria-current={active ? \"true\" : undefined}\n className=\"block w-full rounded-md px-2 py-2 pr-10 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className={cx(\"line-clamp-3 border-l-2 pl-2.5 text-sm leading-snug text-foreground\", TONE_BAR[tone], h.pending && \"opacity-60\")}>{h.text || \"Highlighted area\"}</span>\n {h.note && !editing ? <span className=\"mt-1.5 block whitespace-pre-wrap pl-3 text-sm text-muted-foreground\">{h.note}</span> : null}\n {h.author || h.createdAt ? (\n <span className=\"mt-1.5 block truncate pl-3 text-xs text-muted-foreground\">{[h.author, h.createdAt ? when(h.createdAt) : null].filter(Boolean).join(\" · \")}</span>\n ) : null}\n </button>\n <div className={cx(\"absolute right-1 top-1 transition-opacity duration-150\", active ? \"opacity-100\" : \"opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100\")}>\n <Menu\n label={`Highlight${h.page ? ` on page ${h.page}` : \"\"}, actions`}\n items={[\n !readOnly && { label: h.note ? \"Edit note\" : \"Add note\", icon: \"pencil\", onSelect: () => onEdit(h.id) },\n { label: \"Copy text\", icon: \"copy\", onSelect: () => onCopy(h) },\n !readOnly && {\n label: \"Colour\",\n icon: \"palette\",\n items: [{ type: \"radio\", value: tone, onValueChange: (v) => onTone(h, v), options: HIGHLIGHT_TONES }],\n },\n !readOnly && { type: \"separator\" },\n !readOnly && { label: \"Delete highlight\", icon: \"trash\", tone: \"danger\", onSelect: () => onDelete(h) },\n ]}\n />\n </div>\n {editing ? (\n <div className=\"grid gap-2 px-2 pb-2 pl-5\">\n <Textarea\n ref={area}\n autoGrow\n minRows={2}\n maxRows={8}\n value={draft}\n placeholder=\"Write a note…\"\n aria-label={`Note on page ${h.page}`}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n e.preventDefault();\n save();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n onEdit(null);\n }\n }}\n />\n <div className=\"flex items-center justify-end gap-1\">\n <Button size=\"sm\" variant=\"quiet\" onClick={() => onEdit(null)}>\n Cancel\n </Button>\n <Button size=\"sm\" variant=\"primary\" onClick={save}>\n Save note\n </Button>\n </div>\n </div>\n ) : !h.note && !readOnly && active ? (\n <div className=\"px-2 pb-2 pl-4\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"message-square\" onClick={() => onEdit(h.id)}>\n Add note\n </Button>\n </div>\n ) : null}\n </div>\n );\n}\n\n// A day alone (\"2026-09-28\") is that day in the reader's zone, not UTC's.\nfunction when(v) {\n const d = toLocalDate(v);\n if (!d) return String(v);\n return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" }).format(d);\n}\n"
32
32
  },
33
33
  {
34
34
  "path": "registry/blocks/pdf-viewer/helpers.js",
@@ -81,7 +81,7 @@
81
81
  "docs": "Installed to components/makersclaw/blocks/pdf-viewer. Import: import { PdfViewer, usePdfViewer, HIGHLIGHT_TONES, findMatches, toPageRects, mergeRects, sortHighlights, highlightAt, fitScale, ZOOM_STEPS } from \"@/components/makersclaw/blocks/pdf-viewer\". Props and an example: components/makersclaw/blocks/pdf-viewer/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
82
82
  "meta": {
83
83
  "makersclaw": {
84
- "version": "0.4.0",
84
+ "version": "0.5.2",
85
85
  "useWhen": "A screen shows an existing PDF: a contract or proposal to review, a signed agreement, an uploaded invoice or statement, a report, a generated quote before it is sent; or people mark up a PDF and leave notes on passages. Pair it with pdf-document to preview a PDF you make. Not for making PDFs (pdf-document), for rich text people edit (doc-editor), or for marking up a screenshot (annotate)."
86
86
  }
87
87
  }
@@ -5,7 +5,7 @@
5
5
  "title": "Product detail",
6
6
  "description": "One product on its own page: pictures (thumbnails, a side rail, a carousel, a tall stack, or one scene with the buy box floating over it), brand, name, price with the old price struck through, star rating and review count, options (sizes as a segmented row, colours as swatches, long lists as a select, plans as cards with their own price), a quantity control, add to cart through an async callback that may fail, buy now, save to favourites, perks, and details in an accordion or tabs. Loading, error, not-found and sold-out states.",
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": [
@@ -69,7 +69,7 @@
69
69
  "docs": "Installed to components/makersclaw/blocks/product-detail. Import: import { ProductDetail, ProductMedia, OptionPicker, QuantityControl, Price, formatPrice, cartLine } from \"@/components/makersclaw/blocks/product-detail\". Props and an example: components/makersclaw/blocks/product-detail/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
70
70
  "meta": {
71
71
  "makersclaw": {
72
- "version": "0.4.0",
72
+ "version": "0.5.2",
73
73
  "useWhen": "Selling one thing a customer looks at before buying: a product page, a template or digital download with plans, a merch item, a booking add-on. Not for many products at once (product-grid), the cart itself (cart), or a plan comparison table."
74
74
  }
75
75
  }
@@ -5,8 +5,8 @@
5
5
  "title": "Product grid",
6
6
  "description": "Products as cards in a grid: picture, badge, favourite heart, brand, name, star rating, price with the old one struck through, and a quick add to cart through an async callback that may fail (\"Choose options\" when the product has sizes or colours). Facet filters as selects that also match a product's option values, a sort, a live count, a lead tile that takes a double cell (a picture or a muted looping video), and a promo card for collection tiles. Loading, error, empty and no-matches states; two columns on a phone.",
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/product-detail.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/product-detail.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -58,7 +58,7 @@
58
58
  "docs": "Installed to components/makersclaw/blocks/product-grid. Import: import { ProductGrid, ProductCard, PromoTile, productMatches } from \"@/components/makersclaw/blocks/product-grid\". Props and an example: components/makersclaw/blocks/product-grid/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
59
59
  "meta": {
60
60
  "makersclaw": {
61
- "version": "0.4.0",
61
+ "version": "0.5.2",
62
62
  "useWhen": "A shop's catalogue, a collection page, related products under a product page, a row of promotions ('New', 'Sale'). Not for records to scan and act on in columns (data-table), one product's page (product-detail), or the cart (cart)."
63
63
  }
64
64
  }
@@ -5,7 +5,7 @@
5
5
  "title": "RecordForm",
6
6
  "description": "Edit or create one record from a list of fields given as data (type, label, required, options, help, validation), grouped into titled sections — text, email, number, password, select, combobox, tags, radio, choice cards with prices, checkboxes, switch, date, time, rating, a photo and a row of images. It checks as you type, saves through an async onSave that answers { ok } or per-field errors shown under each field, and sits in a card, on a page (sections split title-left) or in a dialog.",
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": [
@@ -60,7 +60,7 @@
60
60
  "docs": "Installed to components/makersclaw/blocks/record-form. Import: import { RecordForm } from \"@/components/makersclaw/blocks/record-form\". Props and an example: components/makersclaw/blocks/record-form/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
61
61
  "meta": {
62
62
  "makersclaw": {
63
- "version": "0.4.0",
63
+ "version": "0.5.2",
64
64
  "useWhen": "A screen edits one record's fields: a profile, a product, a team member, account details, a sign-up or application form, a settings page of fields with one Save. Not for a list of records (data-table), for toggles that each save on their own (settings-list), or for sign-in (auth-form)."
65
65
  }
66
66
  }
@@ -5,9 +5,9 @@
5
5
  "title": "RecordNotes",
6
6
  "description": "Dated rich-text notes on one record (a deal, a contact, a company), to sit beside record-form: a composer with @mentions and Cmd/Ctrl+Enter adds a note; the notes follow newest first (or oldest), grouped by day, each with author avatar, name and relative time; your own notes edit in place and delete after a confirm. Every save is an async callback answering { ok }; a failure keeps the words and says why where it happened.",
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/comment-box.json",
10
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/doc-editor.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/comment-box.json",
10
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/doc-editor.json"
11
11
  ],
12
12
  "dependencies": [],
13
13
  "files": [
@@ -21,13 +21,13 @@
21
21
  "path": "registry/blocks/record-notes/record-notes.jsx",
22
22
  "type": "registry:component",
23
23
  "target": "@components/makersclaw/blocks/record-notes/record-notes.jsx",
24
- "content": "\"use client\";\n\n// RecordNotes — the dated notes kept on one record (a deal, a contact), to\n// sit beside record-form. A composer adds a note; the notes follow, grouped\n// by day, each with its author and time; your own notes have Edit (in\n// place, the same composer) and Delete (after a Confirm).\n//\n// Every save is an async callback answering { ok }. The block holds no\n// copy of the notes: the page adds, replaces or drops the note in `notes`\n// when its callback succeeds, and the block shows a failure where it\n// happened (under the composer, or on the note).\n//\n// Days (\"Today\", \"Yesterday\") are the reader's, known only in the\n// browser: the server draws the notes as one list, and the day headings\n// appear once mounted — never a mismatch between the two renders.\nimport { useEffect, useRef, useState } from \"react\";\nimport { Avatar, Badge, Card, Confirm, Menu, cx } from \"../../ui\";\nimport { CommentBody, CommentComposer, CommentTime } from \"../comment-box\";\nimport { docToText } from \"../doc-editor\";\n\nconst nameOf = (author) => (typeof author === \"string\" ? author : author?.name) || \"Someone\";\nconst srcOf = (author) => (typeof author === \"object\" && author ? author.src : undefined);\nconst idOf = (author) => (typeof author === \"object\" && author ? author.id : undefined);\n\nconst toMs = (v) => {\n if (v === undefined || v === null || v === \"\") return NaN;\n return (v instanceof Date ? v : new Date(v)).getTime();\n};\n\nfunction useMounted() {\n const [on, setOn] = useState(false);\n useEffect(() => setOn(true), []);\n return on;\n}\n\nfunction dayOf(ms) {\n const d = new Date(ms);\n d.setHours(0, 0, 0, 0);\n return d.getTime();\n}\n\nfunction dayLabel(day, now) {\n const today = dayOf(now);\n if (day === today) return \"Today\";\n if (day === today - 86_400_000) return \"Yesterday\";\n const d = new Date(day);\n const sameYear = d.getFullYear() === new Date(now).getFullYear();\n return new Intl.DateTimeFormat(undefined, { weekday: \"short\", day: \"numeric\", month: \"short\", ...(sameYear ? {} : { year: \"numeric\" }) }).format(d);\n}\n\n/** Split sorted notes into runs of one day each: [{ day, label, notes }]. */\nfunction byDay(notes, now) {\n const out = [];\n for (const n of notes) {\n const ms = toMs(n.createdAt);\n const day = Number.isNaN(ms) ? null : dayOf(ms);\n const last = out[out.length - 1];\n if (last && last.day === day) last.notes.push(n);\n else out.push({ day, label: day === null ? \"Undated\" : dayLabel(day, now), notes: [n] });\n }\n return out;\n}\n\nconst snippet = (doc, max = 120) => {\n const text = docToText(doc).replace(/\\s+/g, \" \").trim();\n return text.length > max ? `${text.slice(0, max - 1)}…` : text;\n};\n\nfunction Note({ note, own, editing, onStartEdit, onStopEdit, onEdit, onAskDelete, error, mentions }) {\n const name = nameOf(note.author);\n const self = useRef(null);\n const was = useRef(editing);\n useEffect(() => {\n // Back from editing: the caret was in the composer, which is gone.\n if (was.current && !editing) self.current?.focus();\n was.current = editing;\n }, [editing]);\n\n return (\n <li>\n <article ref={self} tabIndex={-1} aria-label={`Note by ${name}`} className=\"flex gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <Avatar name={name} src={srcOf(note.author)} size=\"sm\" className=\"mt-0.5 shrink-0\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-start gap-2\">\n <p className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 pt-1 text-sm\">\n <span className=\"font-medium\">{name}</span>\n <CommentTime date={note.createdAt} />\n {note.updatedAt || note.edited ? <span className=\"text-xs text-muted-foreground\">(edited)</span> : null}\n </p>\n {own && !editing ? (\n <Menu\n size=\"md\"\n label={`Note by ${name}, actions`}\n className=\"-me-2 -mt-2 shrink-0\"\n items={[\n onEdit && { label: \"Edit\", icon: \"pencil\", onSelect: onStartEdit },\n onAskDelete && { label: \"Delete\", icon: \"trash\", tone: \"danger\", onSelect: onAskDelete },\n ]}\n />\n ) : null}\n </div>\n {editing ? (\n <CommentComposer\n className=\"mt-2\"\n defaultValue={note.body}\n onSubmit={(doc) => onEdit(note, doc)}\n onCancel={onStopEdit}\n mentions={mentions}\n submitLabel=\"Save\"\n submitIcon={null}\n busyLabel=\"Saving…\"\n failedText=\"Couldn't save the change. Your edit is still here; try again.\"\n label={`Edit note by ${name}`}\n clearOnSubmit={false}\n autoFocus\n />\n ) : (\n <CommentBody doc={note.body} className=\"mt-0.5\" />\n )}\n {error ? (\n <p role=\"alert\" className=\"mt-1.5 text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n </div>\n </article>\n </li>\n );\n}\n\nexport function RecordNotes({\n notes,\n me,\n onAdd,\n onEdit,\n onDelete,\n canEdit,\n mentions,\n title = \"Notes\",\n description,\n placeholder = \"Add a note…\",\n addLabel = \"Add note\",\n emptyText = \"No notes yet. The first one starts the record's history.\",\n order = \"newest\",\n frame = \"card\",\n className,\n}) {\n const mounted = useMounted();\n const [editing, setEditing] = useState(null);\n const [asking, setAsking] = useState(null);\n const [deleting, setDeleting] = useState(false);\n const [errors, setErrors] = useState({});\n const listRef = useRef(null);\n\n const list = (Array.isArray(notes) ? notes : [])\n .map((n, i) => ({ n, i, ms: toMs(n.createdAt) }))\n .sort((a, b) => {\n const x = Number.isNaN(a.ms) ? -Infinity : a.ms;\n const y = Number.isNaN(b.ms) ? -Infinity : b.ms;\n return (order === \"oldest\" ? x - y : y - x) || a.i - b.i;\n })\n .map((x) => x.n);\n\n const keyOf = (n, i) => n.id ?? `note-${i}`;\n const isOwn = (n) => (canEdit ? !!canEdit(n) : n.mine === true || (me?.id !== undefined && idOf(n.author) === me.id));\n const setError = (key, msg) => setErrors((e) => ({ ...e, [key]: msg }));\n\n async function saveEdit(note, doc) {\n let res;\n try {\n res = await onEdit(note, doc);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (res && res.ok === false) return res;\n setEditing(null);\n setError(note.id, null);\n return { ok: true };\n }\n\n async function confirmDelete() {\n const note = asking;\n if (!note) return;\n setDeleting(true);\n let res;\n try {\n res = await onDelete(note);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n setDeleting(false);\n setAsking(null);\n if (res && res.ok === false) {\n setError(note.id, res.error || \"Couldn't delete this note. Try again.\");\n return;\n }\n listRef.current?.focus();\n }\n\n const composer = onAdd ? (\n <CommentComposer onSubmit={onAdd} author={me} mentions={mentions} placeholder={placeholder} submitLabel={addLabel} submitIcon=\"plus\" busyLabel=\"Adding…\" failedText=\"Couldn't add the note. It is still here; try again.\" label=\"New note\" />\n ) : null;\n\n const renderNote = (n, i) => {\n const key = keyOf(n, i);\n return (\n <Note\n key={key}\n note={n}\n own={isOwn(n)}\n editing={editing === key}\n onStartEdit={() => {\n setError(n.id, null);\n setEditing(key);\n }}\n onStopEdit={() => setEditing(null)}\n onEdit={onEdit ? saveEdit : null}\n onAskDelete={onDelete ? () => setAsking(n) : null}\n error={errors[n.id]}\n mentions={mentions}\n />\n );\n };\n\n const groups = mounted ? byDay(list, Date.now()) : [{ day: \"all\", label: null, notes: list }];\n const body = (\n <div className=\"grid gap-5\">\n {order !== \"oldest\" ? composer : null}\n {list.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">{emptyText}</p>\n ) : (\n <div ref={listRef} tabIndex={-1} aria-label={`${title}, ${list.length}`} role=\"region\" className=\"grid gap-5 outline-none\">\n {groups.map((g) => (\n <section key={g.day ?? \"undated\"} aria-label={g.label ?? undefined} className=\"grid gap-3\">\n {g.label ? (\n <h3 className=\"flex items-center gap-3 text-xs font-medium text-muted-foreground\">\n <span className=\"shrink-0\">{g.label}</span>\n <span aria-hidden=\"true\" className=\"h-px flex-1 bg-border\" />\n </h3>\n ) : null}\n <ul className=\"grid gap-4\">{g.notes.map((n) => renderNote(n, list.indexOf(n)))}</ul>\n </section>\n ))}\n </div>\n )}\n {order === \"oldest\" ? composer : null}\n <Confirm\n open={!!asking}\n onClose={() => (deleting ? null : setAsking(null))}\n onConfirm={confirmDelete}\n title=\"Delete this note?\"\n body={asking ? `“${snippet(asking.body) || \"Empty note\"}” will be gone for everyone on this record.` : null}\n confirmLabel=\"Delete note\"\n tone=\"danger\"\n busy={deleting}\n />\n </div>\n );\n\n if (frame === \"none\") return <div className={className}>{body}</div>;\n return (\n <Card title={title} description={description} actions={list.length ? <Badge variant=\"soft\">{list.length}</Badge> : null} className={cx(\"min-w-0\", className)}>\n {body}\n </Card>\n );\n}\n"
24
+ "content": "\"use client\";\n\n// RecordNotes — the dated notes kept on one record (a deal, a contact), to\n// sit beside record-form. A composer adds a note; the notes follow, grouped\n// by day, each with its author and time; your own notes have Edit (in\n// place, the same composer) and Delete (after a Confirm).\n//\n// Every save is an async callback answering { ok }. The block holds no\n// copy of the notes: the page adds, replaces or drops the note in `notes`\n// when its callback succeeds, and the block shows a failure where it\n// happened (under the composer, or on the note).\n//\n// Days (\"Today\", \"Yesterday\") are the reader's, known only in the\n// browser: the server draws the notes as one list, and the day headings\n// appear once mounted — never a mismatch between the two renders.\nimport { useEffect, useRef, useState } from \"react\";\nimport { Avatar, Badge, Card, Confirm, Menu, cx, toLocalDate } from \"../../ui\";\nimport { CommentBody, CommentComposer, CommentTime } from \"../comment-box\";\nimport { docToText } from \"../doc-editor\";\n\nconst nameOf = (author) => (typeof author === \"string\" ? author : author?.name) || \"Someone\";\nconst srcOf = (author) => (typeof author === \"object\" && author ? author.src : undefined);\nconst idOf = (author) => (typeof author === \"object\" && author ? author.id : undefined);\n\n// A note dated with a day alone (\"2026-09-28\", a `date` column) files\n// under that day wherever the reader is: local midnight, never UTC's.\nconst toMs = (v) => toLocalDate(v)?.getTime() ?? NaN;\n\nfunction useMounted() {\n const [on, setOn] = useState(false);\n useEffect(() => setOn(true), []);\n return on;\n}\n\nfunction dayOf(ms) {\n const d = new Date(ms);\n d.setHours(0, 0, 0, 0);\n return d.getTime();\n}\n\nfunction dayLabel(day, now) {\n const today = dayOf(now);\n if (day === today) return \"Today\";\n if (day === today - 86_400_000) return \"Yesterday\";\n const d = new Date(day);\n const sameYear = d.getFullYear() === new Date(now).getFullYear();\n return new Intl.DateTimeFormat(undefined, { weekday: \"short\", day: \"numeric\", month: \"short\", ...(sameYear ? {} : { year: \"numeric\" }) }).format(d);\n}\n\n/** Split sorted notes into runs of one day each: [{ day, label, notes }]. */\nfunction byDay(notes, now) {\n const out = [];\n for (const n of notes) {\n const ms = toMs(n.createdAt);\n const day = Number.isNaN(ms) ? null : dayOf(ms);\n const last = out[out.length - 1];\n if (last && last.day === day) last.notes.push(n);\n else out.push({ day, label: day === null ? \"Undated\" : dayLabel(day, now), notes: [n] });\n }\n return out;\n}\n\nconst snippet = (doc, max = 120) => {\n const text = docToText(doc).replace(/\\s+/g, \" \").trim();\n return text.length > max ? `${text.slice(0, max - 1)}…` : text;\n};\n\nfunction Note({ note, own, editing, onStartEdit, onStopEdit, onEdit, onAskDelete, error, mentions }) {\n const name = nameOf(note.author);\n const self = useRef(null);\n const was = useRef(editing);\n useEffect(() => {\n // Back from editing: the caret was in the composer, which is gone.\n if (was.current && !editing) self.current?.focus();\n was.current = editing;\n }, [editing]);\n\n return (\n <li>\n <article ref={self} tabIndex={-1} aria-label={`Note by ${name}`} className=\"flex gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <Avatar name={name} src={srcOf(note.author)} size=\"sm\" className=\"mt-0.5 shrink-0\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-start gap-2\">\n <p className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 pt-1 text-sm\">\n <span className=\"font-medium\">{name}</span>\n <CommentTime date={note.createdAt} />\n {note.updatedAt || note.edited ? <span className=\"text-xs text-muted-foreground\">(edited)</span> : null}\n </p>\n {own && !editing ? (\n <Menu\n size=\"md\"\n label={`Note by ${name}, actions`}\n className=\"-me-2 -mt-2 shrink-0\"\n items={[\n onEdit && { label: \"Edit\", icon: \"pencil\", onSelect: onStartEdit },\n onAskDelete && { label: \"Delete\", icon: \"trash\", tone: \"danger\", onSelect: onAskDelete },\n ]}\n />\n ) : null}\n </div>\n {editing ? (\n <CommentComposer\n className=\"mt-2\"\n defaultValue={note.body}\n onSubmit={(doc) => onEdit(note, doc)}\n onCancel={onStopEdit}\n mentions={mentions}\n submitLabel=\"Save\"\n submitIcon={null}\n busyLabel=\"Saving…\"\n failedText=\"Couldn't save the change. Your edit is still here; try again.\"\n label={`Edit note by ${name}`}\n clearOnSubmit={false}\n autoFocus\n />\n ) : (\n <CommentBody doc={note.body} className=\"mt-0.5\" />\n )}\n {error ? (\n <p role=\"alert\" className=\"mt-1.5 text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n </div>\n </article>\n </li>\n );\n}\n\nexport function RecordNotes({\n notes,\n me,\n onAdd,\n onEdit,\n onDelete,\n canEdit,\n mentions,\n title = \"Notes\",\n description,\n placeholder = \"Add a note…\",\n addLabel = \"Add note\",\n emptyText = \"No notes yet. The first one starts the record's history.\",\n order = \"newest\",\n frame = \"card\",\n className,\n}) {\n const mounted = useMounted();\n const [editing, setEditing] = useState(null);\n const [asking, setAsking] = useState(null);\n const [deleting, setDeleting] = useState(false);\n const [errors, setErrors] = useState({});\n const listRef = useRef(null);\n\n const list = (Array.isArray(notes) ? notes : [])\n .map((n, i) => ({ n, i, ms: toMs(n.createdAt) }))\n .sort((a, b) => {\n const x = Number.isNaN(a.ms) ? -Infinity : a.ms;\n const y = Number.isNaN(b.ms) ? -Infinity : b.ms;\n return (order === \"oldest\" ? x - y : y - x) || a.i - b.i;\n })\n .map((x) => x.n);\n\n const keyOf = (n, i) => n.id ?? `note-${i}`;\n const isOwn = (n) => (canEdit ? !!canEdit(n) : n.mine === true || (me?.id !== undefined && idOf(n.author) === me.id));\n const setError = (key, msg) => setErrors((e) => ({ ...e, [key]: msg }));\n\n async function saveEdit(note, doc) {\n let res;\n try {\n res = await onEdit(note, doc);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (res && res.ok === false) return res;\n setEditing(null);\n setError(note.id, null);\n return { ok: true };\n }\n\n async function confirmDelete() {\n const note = asking;\n if (!note) return;\n setDeleting(true);\n let res;\n try {\n res = await onDelete(note);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n setDeleting(false);\n setAsking(null);\n if (res && res.ok === false) {\n setError(note.id, res.error || \"Couldn't delete this note. Try again.\");\n return;\n }\n listRef.current?.focus();\n }\n\n const composer = onAdd ? (\n <CommentComposer onSubmit={onAdd} author={me} mentions={mentions} placeholder={placeholder} submitLabel={addLabel} submitIcon=\"plus\" busyLabel=\"Adding…\" failedText=\"Couldn't add the note. It is still here; try again.\" label=\"New note\" />\n ) : null;\n\n const renderNote = (n, i) => {\n const key = keyOf(n, i);\n return (\n <Note\n key={key}\n note={n}\n own={isOwn(n)}\n editing={editing === key}\n onStartEdit={() => {\n setError(n.id, null);\n setEditing(key);\n }}\n onStopEdit={() => setEditing(null)}\n onEdit={onEdit ? saveEdit : null}\n onAskDelete={onDelete ? () => setAsking(n) : null}\n error={errors[n.id]}\n mentions={mentions}\n />\n );\n };\n\n const groups = mounted ? byDay(list, Date.now()) : [{ day: \"all\", label: null, notes: list }];\n const body = (\n <div className=\"grid gap-5\">\n {order !== \"oldest\" ? composer : null}\n {list.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">{emptyText}</p>\n ) : (\n <div ref={listRef} tabIndex={-1} aria-label={`${title}, ${list.length}`} role=\"region\" className=\"grid gap-5 outline-none\">\n {groups.map((g) => (\n <section key={g.day ?? \"undated\"} aria-label={g.label ?? undefined} className=\"grid gap-3\">\n {g.label ? (\n <h3 className=\"flex items-center gap-3 text-xs font-medium text-muted-foreground\">\n <span className=\"shrink-0\">{g.label}</span>\n <span aria-hidden=\"true\" className=\"h-px flex-1 bg-border\" />\n </h3>\n ) : null}\n <ul className=\"grid gap-4\">{g.notes.map((n) => renderNote(n, list.indexOf(n)))}</ul>\n </section>\n ))}\n </div>\n )}\n {order === \"oldest\" ? composer : null}\n <Confirm\n open={!!asking}\n onClose={() => (deleting ? null : setAsking(null))}\n onConfirm={confirmDelete}\n title=\"Delete this note?\"\n body={asking ? `“${snippet(asking.body) || \"Empty note\"}” will be gone for everyone on this record.` : null}\n confirmLabel=\"Delete note\"\n tone=\"danger\"\n busy={deleting}\n />\n </div>\n );\n\n if (frame === \"none\") return <div className={className}>{body}</div>;\n return (\n <Card title={title} description={description} actions={list.length ? <Badge variant=\"soft\">{list.length}</Badge> : null} className={cx(\"min-w-0\", className)}>\n {body}\n </Card>\n );\n}\n"
25
25
  },
26
26
  {
27
27
  "path": "registry/blocks/record-notes/README.md",
28
28
  "type": "registry:file",
29
29
  "target": "@components/makersclaw/blocks/record-notes/README.md",
30
- "content": "# RecordNotes\n\nThe notes kept on one record — a deal, a contact, a company — to sit\nbeside its `record-form`. A composer at the top adds a note (rich text,\n`@` to mention a teammate, Cmd/Ctrl+Enter or Add note). The notes follow,\nnewest first, grouped under \"Today\", \"Yesterday\", \"Mon 21 Sep\", each with\nits author, \"2 h ago\" and the body as written. Your own notes have a ⋯\nmenu: Edit opens the same composer in place, Delete asks first.\n\nEvery save goes out through an async callback that answers `{ ok }`. The\nblock keeps no copy of the notes: when a callback succeeds, the page puts\nthe change into `notes`. When one fails, the words stay and the block\nsays why — under the composer, or on the note.\n\n```jsx\nimport { RecordNotes } from \"../components/blocks/record-notes\"; // a Makersclaw app (\"./blocks/record-notes\" from components/)\nimport { RecordNotes } from \"@/components/makersclaw/blocks/record-notes\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the notes in the server surface (README §4). `makersclaw add\nrecord-notes` adds `comment-box` and `doc-editor` with it (and the editor's\nnpm packages: run `npm install` after).\n\n## The data\n\nA note in `notes`:\n\n```js\n{\n id: \"n-204\",\n author: { id: \"u-lena\", name: \"Lena Fischer\", src: \"https://…/lena.jpg\" },\n body: [ /* the doc onAdd gave you (BlockNote JSON) */ ], // or a plain string\n createdAt: \"2026-09-23T09:12:00Z\", // ISO string, Date or ms\n updatedAt: null, // set → \"(edited)\"\n}\n```\n\nOne table, a row per note: `record_id`, `author_id`, `body jsonb`,\n`body_text` (`docToText(doc)` from `doc-editor`, for search), `created_at`,\n`updated_at`. A note is the author's: by default only notes whose\n`author.id` is `me.id` (or that carry `mine: true`) get Edit and Delete.\n\n## Props\n\n- `notes` — required: the record's notes, any order (the block sorts by\n `createdAt`).\n- `me` — `{ id, name, src? }`, who is writing: their avatar is beside the\n composer, and their notes are editable.\n- `onAdd(doc)` — `async`, returns `{ ok: true }` or `{ ok: false, error? }`.\n Add the note to `notes` before answering ok. Without it there is no\n composer (a read-only log).\n- `onEdit(note, doc)` — `async`, `{ ok }`. Replace the note's body (and set\n `updatedAt`). Without it there is no Edit.\n- `onDelete(note)` — `async`, `{ ok }`, after the reader confirms. Drop the\n note from `notes`. Without it there is no Delete.\n- `canEdit(note)` — who may edit and delete which note, when \"the author\"\n is not the rule (an admin may delete any note). Default: the author.\n- `mentions` — `async (query) => [{ id, label, description? }]`: turns on\n `@` in the composer.\n- `title` — `\"Notes\"`; the card's heading and the list's name.\n- `description` — a line under the title.\n- `placeholder` — `\"Add a note…\"`.\n- `addLabel` — the composer's button, `\"Add note\"`.\n- `emptyText` — shown when there are no notes.\n- `order` — `\"newest\"` (default: composer on top, newest first) ·\n `\"oldest\"` (a log read top to bottom, composer at the end).\n- `frame` — `\"card\"` (default: a Card with the title and a count) ·\n `\"none\"` (just the composer and notes, for a tab or a sheet that has its\n own heading).\n- `className` — layout around it.\n\n## Example\n\n```jsx\n// components/DealNotes.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { RecordNotes } from \"./blocks/record-notes\";\nimport { docToText } from \"./blocks/doc-editor\";\n\nexport default function DealNotes({ deal, me, team, initial }) {\n const [notes, setNotes] = useState(initial);\n const findPeople = async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }));\n\n async function add(doc) {\n const res = await insertRow({\n source: \"public.deal_notes\",\n values: { deal_id: deal.id, author_id: me.id, body: doc, body_text: docToText(doc) },\n });\n if (!res.ok) return res;\n setNotes((n) => [{ id: res.id, author: me, body: doc, createdAt: new Date() }, ...n]);\n return { ok: true };\n }\n\n async function edit(note, doc) {\n const updatedAt = new Date();\n const res = await updateRow({ source: \"public.deal_notes\", id: note.id, patch: { body: doc, body_text: docToText(doc), updated_at: updatedAt } });\n if (res.ok) setNotes((n) => n.map((x) => (x.id === note.id ? { ...x, body: doc, updatedAt } : x)));\n return res;\n }\n\n async function remove(note) {\n const res = await deleteRow({ source: \"public.deal_notes\", id: note.id });\n if (res.ok) setNotes((n) => n.filter((x) => x.id !== note.id));\n return res;\n }\n\n return <RecordNotes notes={notes} me={me} onAdd={add} onEdit={edit} onDelete={remove} mentions={findPeople} />;\n}\n```\n\nBeside the form, on a record page:\n\n```jsx\n<div className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]\">\n <RecordForm … />\n <DealNotes … />\n</div>\n```\n\n## What it will not do\n\n- Pin, tag, react to or reply under a note: a note is a dated entry, not\n a thread (`comment-box` for a discussion).\n- Log activity by itself (emails sent, stage changes): pass those in as\n notes if you want them in the list, with no `onEdit`/`onDelete` rights.\n- Page or search the notes: it draws what it is given, sorted. Load the\n latest few dozen and a \"Show older\" of your own.\n- Collaborate live: two people editing one note is last-write-wins.\n- Notify anyone mentioned: read `docMentions(doc)` in `onAdd` and send it\n yourself.\n\nA notes panel it cannot be is your own component in `components/`,\nbuilt from `comment-box`'s `CommentComposer`, `CommentBody` and\n`CommentTime` — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add record-notes` restores them.\n"
30
+ "content": "# RecordNotes\n\nThe notes kept on one record — a deal, a contact, a company — to sit\nbeside its `record-form`. A composer at the top adds a note (rich text,\n`@` to mention a teammate, Cmd/Ctrl+Enter or Add note). The notes follow,\nnewest first, grouped under \"Today\", \"Yesterday\", \"Mon 21 Sep\", each with\nits author, \"2 h ago\" and the body as written. Your own notes have a ⋯\nmenu: Edit opens the same composer in place, Delete asks first.\n\nEvery save goes out through an async callback that answers `{ ok }`. The\nblock keeps no copy of the notes: when a callback succeeds, the page puts\nthe change into `notes`. When one fails, the words stay and the block\nsays why — under the composer, or on the note.\n\n```jsx\nimport { RecordNotes } from \"../components/blocks/record-notes\"; // a Makersclaw app (\"./blocks/record-notes\" from components/)\nimport { RecordNotes } from \"@/components/makersclaw/blocks/record-notes\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the notes in the server surface (README §4). `makersclaw add\nrecord-notes` adds `comment-box` and `doc-editor` with it (and the editor's\nnpm packages: run `npm install` after).\n\n## The data\n\nA note in `notes`:\n\n```js\n{\n id: \"n-204\",\n author: { id: \"u-lena\", name: \"Lena Fischer\", src: \"https://…/lena.jpg\" },\n body: [ /* the doc onAdd gave you (BlockNote JSON) */ ], // or a plain string\n createdAt: \"2026-09-23T09:12:00Z\", // ISO string, Date, ms, or a day (\"2026-09-23\": that day, never UTC's)\n updatedAt: null, // set → \"(edited)\"\n}\n```\n\nOne table, a row per note: `record_id`, `author_id`, `body jsonb`,\n`body_text` (`docToText(doc)` from `doc-editor`, for search), `created_at`,\n`updated_at`. A note is the author's: by default only notes whose\n`author.id` is `me.id` (or that carry `mine: true`) get Edit and Delete.\n\n## Props\n\n- `notes` — required: the record's notes, any order (the block sorts by\n `createdAt`).\n- `me` — `{ id, name, src? }`, who is writing: their avatar is beside the\n composer, and their notes are editable.\n- `onAdd(doc)` — `async`, returns `{ ok: true }` or `{ ok: false, error? }`.\n Add the note to `notes` before answering ok. Without it there is no\n composer (a read-only log).\n- `onEdit(note, doc)` — `async`, `{ ok }`. Replace the note's body (and set\n `updatedAt`). Without it there is no Edit.\n- `onDelete(note)` — `async`, `{ ok }`, after the reader confirms. Drop the\n note from `notes`. Without it there is no Delete.\n- `canEdit(note)` — who may edit and delete which note, when \"the author\"\n is not the rule (an admin may delete any note). Default: the author.\n- `mentions` — `async (query) => [{ id, label, description? }]`: turns on\n `@` in the composer.\n- `title` — `\"Notes\"`; the card's heading and the list's name.\n- `description` — a line under the title.\n- `placeholder` — `\"Add a note…\"`.\n- `addLabel` — the composer's button, `\"Add note\"`.\n- `emptyText` — shown when there are no notes.\n- `order` — `\"newest\"` (default: composer on top, newest first) ·\n `\"oldest\"` (a log read top to bottom, composer at the end).\n- `frame` — `\"card\"` (default: a Card with the title and a count) ·\n `\"none\"` (just the composer and notes, for a tab or a sheet that has its\n own heading).\n- `className` — layout around it.\n\n## Example\n\n```jsx\n// components/DealNotes.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { RecordNotes } from \"./blocks/record-notes\";\nimport { docToText } from \"./blocks/doc-editor\";\n\nexport default function DealNotes({ deal, me, team, initial }) {\n const [notes, setNotes] = useState(initial);\n const findPeople = async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }));\n\n async function add(doc) {\n const res = await insertRow({\n source: \"public.deal_notes\",\n values: { deal_id: deal.id, author_id: me.id, body: doc, body_text: docToText(doc) },\n });\n if (!res.ok) return res;\n setNotes((n) => [{ id: res.id, author: me, body: doc, createdAt: new Date() }, ...n]);\n return { ok: true };\n }\n\n async function edit(note, doc) {\n const updatedAt = new Date();\n const res = await updateRow({ source: \"public.deal_notes\", id: note.id, patch: { body: doc, body_text: docToText(doc), updated_at: updatedAt } });\n if (res.ok) setNotes((n) => n.map((x) => (x.id === note.id ? { ...x, body: doc, updatedAt } : x)));\n return res;\n }\n\n async function remove(note) {\n const res = await deleteRow({ source: \"public.deal_notes\", id: note.id });\n if (res.ok) setNotes((n) => n.filter((x) => x.id !== note.id));\n return res;\n }\n\n return <RecordNotes notes={notes} me={me} onAdd={add} onEdit={edit} onDelete={remove} mentions={findPeople} />;\n}\n```\n\nBeside the form, on a record page:\n\n```jsx\n<div className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]\">\n <RecordForm … />\n <DealNotes … />\n</div>\n```\n\n## What it will not do\n\n- Pin, tag, react to or reply under a note: a note is a dated entry, not\n a thread (`comment-box` for a discussion).\n- Log activity by itself (emails sent, stage changes): pass those in as\n notes if you want them in the list, with no `onEdit`/`onDelete` rights.\n- Page or search the notes: it draws what it is given, sorted. Load the\n latest few dozen and a \"Show older\" of your own.\n- Collaborate live: two people editing one note is last-write-wins.\n- Notify anyone mentioned: read `docMentions(doc)` in `onAdd` and send it\n yourself.\n\nA notes panel it cannot be is your own component in `components/`,\nbuilt from `comment-box`'s `CommentComposer`, `CommentBody` and\n`CommentTime` — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add record-notes` restores them.\n"
31
31
  }
32
32
  ],
33
33
  "categories": [
@@ -48,7 +48,7 @@
48
48
  "docs": "Installed to components/makersclaw/blocks/record-notes. Import: import { RecordNotes } from \"@/components/makersclaw/blocks/record-notes\". Props and an example: components/makersclaw/blocks/record-notes/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
49
49
  "meta": {
50
50
  "makersclaw": {
51
- "version": "0.4.0",
51
+ "version": "0.5.2",
52
52
  "useWhen": "A record page keeps a running log of notes: call notes on a deal, context on a contact, a history on a customer or a candidate. Not a discussion thread without edit and delete (comment-box), not one long document per record (doc-editor or doc-page), not an automatic activity feed."
53
53
  }
54
54
  }