makersclaw 0.1.0

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 (57) hide show
  1. package/README.md +68 -0
  2. package/bin/makersclaw.mjs +10 -0
  3. package/package.json +34 -0
  4. package/registry/blocks/board/README.md +130 -0
  5. package/registry/blocks/board/block.json +10 -0
  6. package/registry/blocks/board/board.jsx +435 -0
  7. package/registry/blocks/board/demo.jsx +50 -0
  8. package/registry/blocks/board/index.js +4 -0
  9. package/registry/blocks/job-panel/README.md +68 -0
  10. package/registry/blocks/job-panel/block.json +10 -0
  11. package/registry/blocks/job-panel/demo.jsx +75 -0
  12. package/registry/blocks/job-panel/index.js +3 -0
  13. package/registry/blocks/job-panel/job-panel.jsx +110 -0
  14. package/src/cli.mjs +301 -0
  15. package/src/fsutil.mjs +76 -0
  16. package/src/install.mjs +457 -0
  17. package/src/registry.mjs +191 -0
  18. package/templates/next/files/README.md +626 -0
  19. package/templates/next/files/app/auto-refresh.jsx +26 -0
  20. package/templates/next/files/app/error-relay.jsx +53 -0
  21. package/templates/next/files/app/error.jsx +44 -0
  22. package/templates/next/files/app/global-error.jsx +36 -0
  23. package/templates/next/files/app/globals.css +178 -0
  24. package/templates/next/files/app/layout.jsx +51 -0
  25. package/templates/next/files/app/not-found.jsx +23 -0
  26. package/templates/next/files/app/page.jsx +22 -0
  27. package/templates/next/files/app/s/[key]/page.jsx +47 -0
  28. package/templates/next/files/app/shell.jsx +64 -0
  29. package/templates/next/files/check/run.mjs +683 -0
  30. package/templates/next/files/components/parts/fixture.jsx +60 -0
  31. package/templates/next/files/components/parts/index.js +5 -0
  32. package/templates/next/files/components/parts/record-switcher.jsx +49 -0
  33. package/templates/next/files/components/ui/button.jsx +81 -0
  34. package/templates/next/files/components/ui/cx.js +4 -0
  35. package/templates/next/files/components/ui/depiction.jsx +66 -0
  36. package/templates/next/files/components/ui/field.jsx +126 -0
  37. package/templates/next/files/components/ui/icon.jsx +87 -0
  38. package/templates/next/files/components/ui/index.js +12 -0
  39. package/templates/next/files/components/ui/menu.jsx +123 -0
  40. package/templates/next/files/components/ui/notice.jsx +86 -0
  41. package/templates/next/files/components/ui/sheet.jsx +89 -0
  42. package/templates/next/files/components/ui/tabs.jsx +81 -0
  43. package/templates/next/files/lib/actions.js +187 -0
  44. package/templates/next/files/lib/claw.js +219 -0
  45. package/templates/next/files/lib/data.js +171 -0
  46. package/templates/next/files/lib/db.js +72 -0
  47. package/templates/next/files/lib/hooks.js +218 -0
  48. package/templates/next/files/lib/platform.js +59 -0
  49. package/templates/next/files/lib/sample.js +12 -0
  50. package/templates/next/files/middleware.js +42 -0
  51. package/templates/next/files/next.config.mjs +41 -0
  52. package/templates/next/files/package.json +19 -0
  53. package/templates/next/files/postcss.config.mjs +3 -0
  54. package/templates/next/files/schema.sql +22 -0
  55. package/templates/next/files/surfaces/README.md +32 -0
  56. package/templates/next/files/surfaces.js +53 -0
  57. package/templates/next/template.json +118 -0
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # makersclaw
2
+
3
+ The Makersclaw app template, its kit and its blocks, as one command.
4
+
5
+ ```sh
6
+ npx makersclaw init my-app # lay the template
7
+ npx makersclaw search "kanban" # find a block by what it does
8
+ npx makersclaw add board # install it
9
+ ```
10
+
11
+ Every app Makersclaw builds starts from this template: Next.js 15 (app
12
+ router, JSX, server actions), Postgres, Tailwind v4 mapped to the
13
+ workspace's theme, the data layer and write actions, the `_claw` platform
14
+ client, and a kit of parts. The app designs its own screens on top.
15
+
16
+ ## Components come with the template; blocks come when a surface needs one
17
+
18
+ - **Components** — the kit (`components/ui`: Button, Field, Select, Sheet,
19
+ Tabs, Menu, Notice, Icon…) and the parts bound to the data layer
20
+ (`components/parts`: RecordSwitcher, useFixture). `init` lays them into
21
+ every app. `add <component>` only restores one.
22
+ - **Blocks** — the bigger pieces a surface is assembled from: a board, a
23
+ job panel, and more to come. No app has one until `add` puts it in
24
+ `components/blocks/<name>/`. `search` finds them by what they do.
25
+
26
+ Everything this CLI lays is **fixed**: pinned by sha256 in
27
+ `check/pins.json`, and `npm run check` refuses a change. An app composes
28
+ the kit and the blocks through their props; what they cannot do is the
29
+ app's own component in `components/`. Fixes reach apps through `update`.
30
+
31
+ ## Commands
32
+
33
+ ```
34
+ npx makersclaw init [dir] [-t next] lay the template: base files, the kit, the check
35
+ npx makersclaw add <name...> install blocks, or restore a kit component
36
+ npx makersclaw search <words...> find blocks and components by what they do
37
+ npx makersclaw view <name> one item: props, data, an example
38
+ npx makersclaw list [--type block] everything in this version
39
+ npx makersclaw remove <block...> uninstall blocks no file imports
40
+ npx makersclaw diff what update would change in this app
41
+ npx makersclaw update [--major] lay this version's fixed files and blocks
42
+ ```
43
+
44
+ `--cwd <dir>` names the app (default: the current folder); `--json` prints
45
+ one object instead of text.
46
+
47
+ An app records what it holds in two files: `makersclaw.json` (the template
48
+ and version, and each block with the version it came from) and
49
+ `check/pins.json` (the hash of every fixed file). `update` moves an app to
50
+ this version's fixed files and blocks and never touches its own files —
51
+ the shell, the surfaces, `components/*.jsx`. Within `0.x` a new minor is a
52
+ new contract: `update` refuses it until `--major`, because screens written
53
+ against the old one may need rebuilding.
54
+
55
+ ## This repository
56
+
57
+ ```
58
+ templates/next/template.json which files are fixed, docs, starters or merged; the kit's entries
59
+ templates/next/files/ the app — what `init` lays
60
+ registry/blocks/<name>/ block.json, the files `add` lays, README.md (what view/add print), demo.jsx
61
+ src/ the CLI (no dependencies)
62
+ test/ npm test · npm run check:regress · npm run test:app
63
+ ```
64
+
65
+ - `npm test` — the CLI against scratch folders.
66
+ - `npm run check:regress` — the template's `npm run check`, case by case.
67
+ - `npm run test:app` — a real app: init, every block added with its demo as
68
+ a surface, `npm install`, `npm run check` (build, start, every surface).
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // npx makersclaw — see src/cli.mjs.
3
+ import { main } from "../src/cli.mjs";
4
+
5
+ try {
6
+ process.exitCode = main(process.argv.slice(2));
7
+ } catch (error) {
8
+ process.stderr.write(`makersclaw: ${error?.stack ?? error}\n`);
9
+ process.exitCode = 2;
10
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "makersclaw",
3
+ "version": "0.1.0",
4
+ "description": "The Makersclaw app template, its kit and its blocks: init an app, add the blocks a surface needs, search the registry.",
5
+ "type": "module",
6
+ "bin": {
7
+ "makersclaw": "bin/makersclaw.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "templates",
13
+ "registry",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs",
21
+ "test:app": "node test/smoke-app.mjs",
22
+ "check:regress": "bash test/check-regress.sh"
23
+ },
24
+ "keywords": [
25
+ "makersclaw",
26
+ "template",
27
+ "nextjs",
28
+ "blocks",
29
+ "components",
30
+ "cli"
31
+ ],
32
+ "homepage": "https://makersclaw.com",
33
+ "license": "UNLICENSED"
34
+ }
@@ -0,0 +1,130 @@
1
+ # Board
2
+
3
+ Records as cards in columns, one column per stage. Drag a card to another
4
+ column — with a mouse from anywhere on the card, on a phone from its grip,
5
+ from the keyboard through the card's menu ("Move to …") — and the stage is
6
+ saved. The move shows at once; when the write fails the card goes back and
7
+ the board says why.
8
+
9
+ ```jsx
10
+ import { Board } from "../components/blocks/board"; // "./blocks/board" from components/
11
+ ```
12
+
13
+ It is a client component: render it from a `"use client"` component, and
14
+ load the rows in the server surface (README §4).
15
+
16
+ ## The data
17
+
18
+ One table, one column that holds the stage (`groupBy`). Each row is a card.
19
+ `columns` is the stages in order — the values that column holds. A row
20
+ whose stage is not in `columns` lands in a last "No stage" column, so no
21
+ record goes missing; it can be dragged out of it, not into it.
22
+
23
+ ## Props
24
+
25
+ - `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the
26
+ example cards move on screen without saving (they are not rows).
27
+ - `groupBy` — the column that holds the stage, e.g. `"stage"`.
28
+ - `columns` — the stages in order: `["lead", "call", "won"]` (labels are
29
+ made from the keys) or `[{ key: "lead", label: "New lead" }, …]`.
30
+ - `source` — the table, e.g. `"public.deals"`: a move then saves itself
31
+ through `moveRow` (lib/actions.js — the activity row included).
32
+ - `onMove(row, to, from)` — instead of `source`, when a move is more than
33
+ one column: return `{ ok: true }` or `{ ok: false, error }`. Without
34
+ `source` or `onMove` the board only shows; nothing can be moved.
35
+ - `idColumn` — default `"id"`.
36
+ - `titleOf(row)` — the card's title, default `row.title ?? row.name`. It
37
+ also names the card in its menu, in announcements and in errors.
38
+ - `onOpen(row)` — the title becomes a button: open your `Sheet` to edit
39
+ the record. The block draws that button; you never style one.
40
+ - `renderDetails(row, { column })` — what the card says under its title:
41
+ the part of the board that is yours. Kit parts and tokens only.
42
+ - `metaOf(row)` — a plain muted line under the title, when that is all
43
+ the card needs (ignored when `renderDetails` is set).
44
+ - `actionsOf(row)` — more menu items for a card, kit `Menu` shape:
45
+ `[{ label, onSelect, icon?, tone?: "danger" }]`. The "Move to" items
46
+ follow them.
47
+ - `onCreate(title, column)` — an "Add" at every column's foot opens an
48
+ inline field; Enter calls this (insert the row with `insertRow`), the
49
+ field stays open for the next one. Return `{ ok: false, error }` to show
50
+ why it failed.
51
+ - `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.
52
+ - `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows
53
+ `4/3` in the header, in the warning tone, when it is exceeded.
54
+ - `label` — what the board is called to a screen reader.
55
+ - `className` — layout around it (width, margin). Never colour.
56
+
57
+ ## Example
58
+
59
+ ```jsx
60
+ // surfaces/pipeline.jsx — server: load, hand over
61
+ import { loadRows } from "../lib/data";
62
+ import Pipeline from "../components/Pipeline";
63
+
64
+ export default async function PipelineSurface() {
65
+ const { rows, error } = await loadRows({ source: "public.deals", orderBy: "updated_at desc" });
66
+ return <Pipeline deals={rows} error={error} />;
67
+ }
68
+ ```
69
+
70
+ ```jsx
71
+ // components/Pipeline.jsx — client: the board, the card, the edit sheet
72
+ "use client";
73
+ import { useState } from "react";
74
+ import { insertRow } from "../lib/actions";
75
+ import { Badge, Notice } from "./ui";
76
+ import { FixtureNotice, useFixture } from "./parts";
77
+ import { Board } from "./blocks/board";
78
+
79
+ const STAGES = [
80
+ { key: "lead", label: "Lead" },
81
+ { key: "call", label: "Call booked" },
82
+ { key: "proposal", label: "Proposal" },
83
+ { key: "won", label: "Won" },
84
+ ];
85
+ const SAMPLE = [
86
+ { name: "Northwind", stage: "call", value: 12000, owner: "Maya" },
87
+ { name: "Acme Ltd", stage: "lead", value: 4000, owner: "Leon" },
88
+ ];
89
+
90
+ export default function Pipeline({ deals, error }) {
91
+ const fixture = useFixture(deals, SAMPLE);
92
+ const [editing, setEditing] = useState(null);
93
+ return (
94
+ <div className="grid gap-4">
95
+ {error ? <Notice tone="danger">{error}</Notice> : null}
96
+ {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}
97
+ <Board
98
+ rows={fixture.rows}
99
+ groupBy="stage"
100
+ columns={STAGES}
101
+ source="public.deals"
102
+ titleOf={(d) => d.name}
103
+ limits={{ proposal: 5 }}
104
+ onCreate={(name, stage) => insertRow({ source: "public.deals", values: { name, stage }, detail: name })}
105
+ onOpen={setEditing}
106
+ renderDetails={(d) => (
107
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
108
+ <span>{d.owner ?? "Unowned"}</span>
109
+ <Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>
110
+ </div>
111
+ )}
112
+ label="Deals by stage"
113
+ />
114
+ {/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}
115
+ </div>
116
+ );
117
+ }
118
+ ```
119
+
120
+ ## What it will not do
121
+
122
+ - Order cards inside a column: they keep the order of `rows` — sort in
123
+ the surface's `loadRows`.
124
+ - Create a stage: `columns` is the surface's; a new stage is a new entry.
125
+ - Colour a column: stages are not status. Put a `Badge` on the card when a
126
+ stage means something the founder must see.
127
+
128
+ A board it cannot be is your own component in `components/`, built from
129
+ the kit — not an edit to this block. Its files are fixed: `npm run check`
130
+ refuses a change, and `makersclaw add board` restores them.
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "board",
3
+ "title": "Board",
4
+ "description": "Records as cards in columns, one per stage; drag a card to another column (mouse, touch, or the card's menu from the keyboard) and the stage is saved.",
5
+ "useWhen": "A process where each record sits in one stage and moves forward: a pipeline, a hiring funnel, a sprint, a content calendar by status, outreach by reply state. Not for records with no stages (a table) or items placed on a timeline.",
6
+ "tags": ["board", "kanban", "pipeline", "stages", "columns", "cards", "drag", "drop", "status", "workflow", "funnel"],
7
+ "exports": ["Board"],
8
+ "files": ["index.js", "board.jsx"],
9
+ "requires": []
10
+ }
@@ -0,0 +1,435 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { moveRow } from "../../../lib/actions";
6
+ import { isSample } from "../../parts";
7
+ import { Button, Icon, Input, Menu, Notice, cx } from "../../ui";
8
+
9
+ // Board — records as cards in columns, one column per stage, moved by
10
+ // dragging or from the card's menu. The block owns the behaviour a board
11
+ // gets wrong when it is drawn by hand: pointer dragging that works with a
12
+ // mouse and on a phone, a keyboard path (the menu), the move shown at
13
+ // once and put back with a reason when the write fails, examples that
14
+ // move on screen and save nothing, the count in every header, and cards
15
+ // no stage claims kept in view instead of lost. The surface owns what a
16
+ // card says under its title: renderDetails.
17
+ //
18
+ // <Board rows={deals} groupBy="stage" columns={STAGES} source="public.deals"
19
+ // titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}
20
+ // onOpen={setEditing} />
21
+ //
22
+ // README.md beside this file lists every prop.
23
+
24
+ const OTHER = "__other";
25
+ const DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };
26
+
27
+ function humanize(key) {
28
+ const text = String(key ?? "").replace(/[_-]+/g, " ").trim();
29
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : "—";
30
+ }
31
+
32
+ function normalizeColumns(columns) {
33
+ return (columns ?? []).map((c) =>
34
+ typeof c === "string" || typeof c === "number"
35
+ ? { key: String(c), label: humanize(c) }
36
+ : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },
37
+ );
38
+ }
39
+
40
+ export function Board({
41
+ rows,
42
+ groupBy,
43
+ columns,
44
+ idColumn = "id",
45
+ source,
46
+ onMove,
47
+ renderDetails,
48
+ titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? ""),
49
+ metaOf,
50
+ onOpen,
51
+ actionsOf,
52
+ onCreate,
53
+ addLabel = "Add",
54
+ createPlaceholder = "Title, then Enter",
55
+ limits,
56
+ label = "Board",
57
+ emptyLabel = "Nothing here yet",
58
+ otherLabel = "No stage",
59
+ className,
60
+ }) {
61
+ const uid = useId();
62
+ const boardRef = useRef(null);
63
+ const dragRef = useRef(null);
64
+ const suppressClick = useRef(false);
65
+ const [overrides, setOverrides] = useState(() => new Map());
66
+ const [drag, setDrag] = useState(null);
67
+ const [error, setError] = useState(null);
68
+ const [announce, setAnnounce] = useState("");
69
+ const [adding, setAdding] = useState(null);
70
+ const [draft, setDraft] = useState("");
71
+ const [busy, setBusy] = useState(false);
72
+ const [portal, setPortal] = useState(null);
73
+
74
+ const movable = Boolean(onMove || source);
75
+ const list = rows ?? [];
76
+ const keyOf = (row) => String(row?.[idColumn]);
77
+ const cols = useMemo(() => normalizeColumns(columns), [columns]);
78
+ const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);
79
+ const columnOf = (row) => {
80
+ const moved = overrides.get(keyOf(row));
81
+ const value = moved ?? (row?.[groupBy] == null ? "" : String(row[groupBy]));
82
+ return known.has(value) ? value : OTHER;
83
+ };
84
+
85
+ useEffect(() => setPortal(document.body), []);
86
+
87
+ // A move stays on screen until the rows the page re-reads carry it; then
88
+ // the override has nothing left to say.
89
+ useEffect(() => {
90
+ setOverrides((current) => {
91
+ if (current.size === 0) return current;
92
+ const next = new Map(current);
93
+ for (const [id, to] of current) {
94
+ const row = list.find((r) => keyOf(r) === id);
95
+ if (!row || String(row[groupBy]) === to) next.delete(id);
96
+ }
97
+ return next.size === current.size ? current : next;
98
+ });
99
+ // `list` changes identity with every read; that is the point.
100
+ }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps
101
+
102
+ const byColumn = useMemo(() => {
103
+ const out = new Map(cols.map((c) => [c.key, []]));
104
+ out.set(OTHER, []);
105
+ for (const row of list) out.get(columnOf(row)).push(row);
106
+ return out;
107
+ }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps
108
+
109
+ const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;
110
+ const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);
111
+
112
+ async function move(row, to) {
113
+ const from = columnOf(row);
114
+ if (!movable || to === from || to === OTHER || !known.has(to)) return;
115
+ const id = keyOf(row);
116
+ const title = titleOf(row);
117
+ setError(null);
118
+ setOverrides((m) => new Map(m).set(id, to));
119
+ setAnnounce(`${title} moved to ${labelOf(to)}`);
120
+ // An example moves on screen and saves nothing: it is not a row.
121
+ if (isSample(row)) return;
122
+ let result;
123
+ try {
124
+ result = onMove
125
+ ? await onMove(row, to, from === OTHER ? null : from)
126
+ : await moveRow({ source, idColumn, id: row[idColumn], stateColumn: groupBy, to });
127
+ } catch (e) {
128
+ result = { ok: false, error: e?.message ?? String(e) };
129
+ }
130
+ if (result && result.ok === false) {
131
+ setOverrides((m) => {
132
+ const next = new Map(m);
133
+ next.delete(id);
134
+ return next;
135
+ });
136
+ setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? "the move was not saved"}`);
137
+ setAnnounce(`${title} could not be moved`);
138
+ }
139
+ }
140
+
141
+ // ---- pointer dragging ---------------------------------------------------
142
+ // A mouse drags a card from anywhere on it; touch and pen drag from the
143
+ // grip, so the column still scrolls under a finger.
144
+
145
+ function onPointerDown(e, row) {
146
+ if (!movable) return;
147
+ if (e.pointerType === "mouse" && e.button !== 0) return;
148
+ const onGrip = Boolean(e.target.closest?.("[data-board-grip]"));
149
+ if (e.pointerType !== "mouse" && !onGrip) return;
150
+ if (!onGrip && e.target.closest?.("input, textarea, select, a, [aria-haspopup], [role=menuitem]")) return;
151
+ // No text selection while a mouse drags; the click still lands.
152
+ if (e.pointerType === "mouse") e.preventDefault();
153
+ const card = e.currentTarget;
154
+ const rect = card.getBoundingClientRect();
155
+ dragRef.current = {
156
+ row,
157
+ id: keyOf(row),
158
+ from: columnOf(row),
159
+ pointerId: e.pointerId,
160
+ type: e.pointerType,
161
+ card,
162
+ startX: e.clientX,
163
+ startY: e.clientY,
164
+ offsetX: e.clientX - rect.left,
165
+ offsetY: e.clientY - rect.top,
166
+ width: rect.width,
167
+ active: false,
168
+ over: null,
169
+ x: e.clientX,
170
+ y: e.clientY,
171
+ };
172
+ }
173
+
174
+ function columnAt(x, y) {
175
+ const el = document.elementFromPoint(x, y);
176
+ const col = el?.closest?.("[data-board-column]");
177
+ return col && boardRef.current?.contains(col) ? col.getAttribute("data-board-column") : null;
178
+ }
179
+
180
+ function onPointerMove(e) {
181
+ const d = dragRef.current;
182
+ if (!d || e.pointerId !== d.pointerId) return;
183
+ if (!d.active) {
184
+ const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);
185
+ if (!far) return;
186
+ d.active = true;
187
+ try {
188
+ d.card.setPointerCapture(e.pointerId);
189
+ } catch {
190
+ // the pointer is already gone; the move below still works
191
+ }
192
+ }
193
+ e.preventDefault();
194
+ d.x = e.clientX;
195
+ d.y = e.clientY;
196
+ d.over = columnAt(e.clientX, e.clientY);
197
+ const board = boardRef.current;
198
+ if (board) {
199
+ const r = board.getBoundingClientRect();
200
+ if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });
201
+ else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });
202
+ }
203
+ setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });
204
+ }
205
+
206
+ function finish(e, drop) {
207
+ const d = dragRef.current;
208
+ if (!d || (e && e.pointerId !== d.pointerId)) return;
209
+ dragRef.current = null;
210
+ if (!d.active) return;
211
+ suppressClick.current = true;
212
+ setTimeout(() => {
213
+ suppressClick.current = false;
214
+ }, 0);
215
+ setDrag(null);
216
+ if (drop && d.over) move(d.row, d.over);
217
+ }
218
+
219
+ useEffect(() => {
220
+ if (!drag) return undefined;
221
+ const onKey = (e) => {
222
+ if (e.key === "Escape") finish(null, false);
223
+ };
224
+ window.addEventListener("keydown", onKey);
225
+ return () => window.removeEventListener("keydown", onKey);
226
+ }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps
227
+
228
+ async function create(e, column) {
229
+ e.preventDefault();
230
+ const title = draft.trim();
231
+ if (!title || busy) return;
232
+ setBusy(true);
233
+ setError(null);
234
+ let result;
235
+ try {
236
+ result = await onCreate(title, column);
237
+ } catch (err) {
238
+ result = { ok: false, error: err?.message ?? String(err) };
239
+ }
240
+ setBusy(false);
241
+ if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? "the write failed"}`);
242
+ else {
243
+ setDraft("");
244
+ setAnnounce(`${title} added to ${labelOf(column)}`);
245
+ }
246
+ }
247
+
248
+ function card(row, { ghost = false } = {}) {
249
+ const title = titleOf(row);
250
+ const body = (
251
+ <>
252
+ {onOpen ? (
253
+ <button
254
+ type="button"
255
+ onClick={() => onOpen(row)}
256
+ className="block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
257
+ >
258
+ {title}
259
+ </button>
260
+ ) : (
261
+ <p className="text-sm font-medium">{title}</p>
262
+ )}
263
+ {renderDetails ? (
264
+ <div className="mt-2">{renderDetails(row, { column: columnOf(row) })}</div>
265
+ ) : metaOf ? (
266
+ <div className="mt-1 text-xs text-muted-foreground">{metaOf(row)}</div>
267
+ ) : null}
268
+ </>
269
+ );
270
+ const moveTo = movable
271
+ ? shown
272
+ .filter((c) => !c.other && c.key !== columnOf(row))
273
+ .map((c) => ({ label: `Move to ${c.label}`, icon: "arrow-right", onSelect: () => move(row, c.key) }))
274
+ : [];
275
+ const items = [...(actionsOf?.(row) ?? []), ...moveTo];
276
+ return (
277
+ <div className="flex items-start gap-1">
278
+ {movable && !ghost ? (
279
+ <span
280
+ data-board-grip
281
+ aria-hidden="true"
282
+ className="-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex"
283
+ style={{ touchAction: "none" }}
284
+ >
285
+ <Icon name="grip" size={16} />
286
+ </span>
287
+ ) : null}
288
+ <div className="min-w-0 flex-1">{body}</div>
289
+ {!ghost ? <Menu items={items} label={`Actions for ${title}`} className="-mr-1 -mt-1 shrink-0" /> : null}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ return (
295
+ <div className={cx("grid gap-3", className)}>
296
+ {error ? (
297
+ <Notice tone="danger" onDismiss={() => setError(null)}>
298
+ {error}
299
+ </Notice>
300
+ ) : null}
301
+ <div
302
+ ref={boardRef}
303
+ role="region"
304
+ aria-label={label}
305
+ tabIndex={0}
306
+ className="flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none"
307
+ >
308
+ {shown.map((col) => {
309
+ const cards = byColumn.get(col.key) ?? [];
310
+ const limit = limits?.[col.key];
311
+ const over = typeof limit === "number" && cards.length > limit;
312
+ const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;
313
+ const headingId = `${uid}-${col.key}`;
314
+ return (
315
+ <section
316
+ key={col.key}
317
+ data-board-column={col.other ? undefined : col.key}
318
+ aria-labelledby={headingId}
319
+ className={cx(
320
+ "flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50",
321
+ target && "bg-accent ring-2 ring-ring",
322
+ )}
323
+ >
324
+ <header className="flex min-h-11 items-center justify-between gap-2 px-3">
325
+ <h3 id={headingId} className="truncate text-sm font-semibold">
326
+ {col.label}
327
+ </h3>
328
+ <span
329
+ data-nums
330
+ className={cx("shrink-0 font-mono text-xs", over ? "font-semibold text-warning" : "text-muted-foreground")}
331
+ title={typeof limit === "number" ? `${cards.length} of a limit of ${limit}` : undefined}
332
+ >
333
+ {typeof limit === "number" ? `${cards.length}/${limit}` : cards.length}
334
+ </span>
335
+ </header>
336
+ <ul className="flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
337
+ {cards.map((row) => {
338
+ const id = keyOf(row);
339
+ const lifted = drag?.id === id;
340
+ return (
341
+ <li
342
+ key={id}
343
+ data-board-card={id}
344
+ onPointerDown={(e) => onPointerDown(e, row)}
345
+ onPointerMove={onPointerMove}
346
+ onPointerUp={(e) => finish(e, true)}
347
+ onPointerCancel={(e) => finish(e, false)}
348
+ onClickCapture={(e) => {
349
+ if (suppressClick.current) {
350
+ e.preventDefault();
351
+ e.stopPropagation();
352
+ }
353
+ }}
354
+ className={cx(
355
+ "rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm",
356
+ movable && "sm:cursor-grab",
357
+ lifted && "opacity-40",
358
+ )}
359
+ >
360
+ {card(row)}
361
+ </li>
362
+ );
363
+ })}
364
+ {cards.length === 0 ? (
365
+ <li className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">{emptyLabel}</li>
366
+ ) : null}
367
+ </ul>
368
+ {onCreate && !col.other ? (
369
+ adding === col.key ? (
370
+ <form onSubmit={(e) => create(e, col.key)} className="px-2 pb-2">
371
+ <label htmlFor={`${headingId}-new`} className="sr-only">
372
+ New in {col.label}
373
+ </label>
374
+ <Input
375
+ id={`${headingId}-new`}
376
+ autoFocus
377
+ value={draft}
378
+ disabled={busy}
379
+ placeholder={createPlaceholder}
380
+ onChange={(e) => setDraft(e.target.value)}
381
+ onKeyDown={(e) => {
382
+ // Enter adds here, not through the form's implicit
383
+ // submit: a synthetic Enter (an agent testing the
384
+ // app) has no keypress to submit with. Never while
385
+ // an input method is composing a word.
386
+ if (e.key === "Enter" && !e.nativeEvent.isComposing) create(e, col.key);
387
+ else if (e.key === "Escape") {
388
+ setAdding(null);
389
+ setDraft("");
390
+ }
391
+ }}
392
+ onBlur={() => {
393
+ if (!draft.trim()) setAdding(null);
394
+ }}
395
+ />
396
+ </form>
397
+ ) : (
398
+ <div className="px-2 pb-2">
399
+ <Button
400
+ variant="quiet"
401
+ size="sm"
402
+ icon="plus"
403
+ className="w-full justify-start"
404
+ onClick={() => {
405
+ setAdding(col.key);
406
+ setDraft("");
407
+ }}
408
+ >
409
+ {addLabel}
410
+ </Button>
411
+ </div>
412
+ )
413
+ ) : null}
414
+ </section>
415
+ );
416
+ })}
417
+ </div>
418
+ <p aria-live="polite" className="sr-only">
419
+ {announce}
420
+ </p>
421
+ {drag && portal
422
+ ? createPortal(
423
+ <div
424
+ aria-hidden="true"
425
+ className="pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg"
426
+ style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
427
+ >
428
+ {card(drag.row, { ghost: true })}
429
+ </div>,
430
+ portal,
431
+ )
432
+ : null}
433
+ </div>
434
+ );
435
+ }