@danxbot/ui 3.4.2 → 3.5.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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The `lib/board` module's own barrel — model.ts, move.ts and use-board.ts
3
+ * (this agent's three files) plus a re-export of the sibling `types.ts` this
4
+ * module is built against. Mirrors `lib/dnd/index.ts`'s own shape for the
5
+ * same reason: a consumer reaching for anything board-related should have
6
+ * one import path, not one per file.
7
+ *
8
+ * NOT wired into the package's top-level `src/index.ts` — that barrel is
9
+ * owned by the contract's author, not this agent (see this round's brief).
10
+ */
11
+ export type { BoardAccessors, BoardColumnDef, BoardColumnId, BoardColumnModel, BoardItemId, BoardMove, BoardMoveRequest, BoardMoveSource, BoardMoveVerdict, UseBoardOptions, } from "./types";
12
+ export { buildBoardModel, type BoardModel } from "./model";
13
+ export { applyBoardMoves, resolveMove, type ResolveMoveContext, type ResolvedMove, } from "./move";
14
+ export { useBoard, type UseBoardResult } from "./use-board";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * ==== BOARD MODEL ====
3
+ *
4
+ * Pure derivation: `items` + column defs + accessors -> one `BoardColumnModel`
5
+ * per declared column, ready to render. No React, no mutation, no I/O other
6
+ * than the diagnostic `console.warn` below — the same inputs always produce
7
+ * the same output, which is what makes this testable without a browser and
8
+ * safe to call on every render (`use-board.ts` wraps it in a `useMemo`, it
9
+ * does not need to defend itself).
10
+ *
11
+ * WHY AN ITEM THAT MATCHES NO COLUMN IS NEVER DROPPED. GitHub Projects has a
12
+ * live, cited bug (RES-GAME / tracker) where grouping by parent silently
13
+ * drops sub-issues that carry a valid link — the item is real, the grouping
14
+ * key is merely stale, and the user has no way to discover the card still
15
+ * exists. A board built the same way inherits the identical bug the moment a
16
+ * column is renamed or retired out from under live data, which is routine
17
+ * here (a status gets retired, a card's derived status hasn't caught up to a
18
+ * board's `columns` prop yet, a consumer passes a filtered `columns` list).
19
+ * So an item whose `getColumnId` names no declared column is never
20
+ * discarded — it is routed into `unassigned`, a structurally present field
21
+ * of the model rather than a side channel a caller has to remember to check.
22
+ * Every item that goes in comes out somewhere: either inside exactly one
23
+ * `columns[].items`, or inside `unassigned`. That invariant is asserted by
24
+ * the "floor" test in board-core.test.ts, so a future edit that starts
25
+ * quietly filtering can't pass silently either.
26
+ *
27
+ * A single summarising `console.warn` also fires when any item orphans,
28
+ * matching the `[danxbot] …` prefix `ErrorBoundary.tsx` already uses (and
29
+ * unguarded, as that one is — this library has no dev-only convention). It is
30
+ * a diagnostic ONLY, never the sole signal, since a console message is easy to
31
+ * miss and impossible to act on from inside a pure function. The `data` itself
32
+ * is what must never be lost; the warning just makes the gap visible before a
33
+ * consumer has built an "Unsorted" affordance for `unassigned`.
34
+ */
35
+ import type { BoardAccessors, BoardColumnDef, BoardColumnModel } from "./types";
36
+ /** The full derivation result. See the file header for why `unassigned` exists. */
37
+ export interface BoardModel<T> {
38
+ /** One entry per declared column, in the SAME order as the `columns` input. */
39
+ columns: readonly BoardColumnModel<T>[];
40
+ /** Items whose `getColumnId` matched no entry in `columns`. Never dropped. */
41
+ unassigned: readonly T[];
42
+ }
43
+ export declare function buildBoardModel<T>(items: readonly T[], columns: readonly BoardColumnDef[], accessors: BoardAccessors<T>): BoardModel<T>;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * ==== MOVE ====
3
+ *
4
+ * The one validated pipeline every `BoardMoveRequest` passes through, no
5
+ * matter which of the four `BoardMoveSource` values produced it. `types.ts`
6
+ * names this the load-bearing rule: pointer drag, keyboard, the mandatory
7
+ * "Move to…" menu (WCAG 2.5.7) and bulk selection all construct a request
8
+ * and call the SAME function here. A per-source branch that skips a step is
9
+ * exactly how the accessible paths rot — the drag path gets exercised daily
10
+ * and the menu path silently stops agreeing with it, taking the 2.5.7
11
+ * alternative down with it and nobody notices until an audit.
12
+ *
13
+ * TWO RESPONSIBILITIES, DELIBERATELY SEPARATE FUNCTIONS:
14
+ *
15
+ * `resolveMove` decides WHETHER a request may commit. It is the only thing
16
+ * `use-board.ts`'s `requestMove` calls, and it never touches `T[]` — it
17
+ * returns a verdict plus the subset of moves worth writing.
18
+ *
19
+ * `applyBoardMoves` decides WHAT the resulting `items` array looks like,
20
+ * given moves already known to be valid (typically `resolveMove(...).commit`).
21
+ * It does no validation of its own — that would duplicate `resolveMove` — and
22
+ * it is exported separately because `onMove` on `UseBoardOptions` returns
23
+ * `void`: this module hands the consumer a verdict, not a next state, so a
24
+ * consumer's own `onMove` needs a correct way to turn "these moves are real"
25
+ * into "here is my next `items` array." Without it, every consumer either
26
+ * re-derives the exact index arithmetic `applyMove` exists to remove, or
27
+ * worse, folds it over a bulk request and gets the trap below.
28
+ *
29
+ * WHY `resolveMove` DOES ITS OWN STRUCTURAL CHECKS BEFORE EVER CALLING THE
30
+ * CALLER'S `validateMove`. `validateMove` is optional and, per `types.ts`,
31
+ * "Absent means every move is allowed" — so nothing upstream is guaranteed to
32
+ * have checked that `itemId` names a real item or that a `columnId` names a
33
+ * declared column. A malformed request must never reach a consumer's domain
34
+ * rule carrying numbers that don't correspond to reality, and a consumer's
35
+ * `validateMove` should never have to re-derive checks this module can prove
36
+ * from its own inputs for free. So structural validity runs FIRST, always on,
37
+ * not configurable — before `validateMove`, before no-op filtering.
38
+ *
39
+ * WHY NO-OP FILTERING IS PER-MOVE BUT VALIDITY IS ALL-OR-NOTHING. These are
40
+ * different questions. Atomicity ("did the batch survive as one unit")
41
+ * governs whether the request is safe to commit AT ALL — RES-GAME names this
42
+ * exactly: "moving five selected cards should not accept the first two and
43
+ * reject the remaining three." One bad move rejects the whole batch, full
44
+ * stop. But once a batch is known-good, a card that happens to already be
45
+ * exactly where it is going is not an error — it is nothing to write.
46
+ * Dropping that ONE entry from the committed set changes nothing about the
47
+ * other four having moved correctly, and `onMove`'s own doc on
48
+ * `UseBoardOptions` names the cost of not doing this: "the previous board
49
+ * wrote its ordering field on every release that landed where it started."
50
+ *
51
+ * NO-OP IS A PER-MOVE, SYNTACTIC CHECK — REUSING `isNoOp` FROM `lib/dnd`,
52
+ * NOT A FULL SIMULATION OF FINAL POSITION. `isNoOp(from, to)` says "same
53
+ * column, same index" for exactly one move, taken at face value. For a bulk
54
+ * request whose moves interact (two items landing in the same column), a
55
+ * move's own `{column, index}` pair can be untouched while its ABSOLUTE final
56
+ * position shifts because a sibling move inserted ahead of it. This module
57
+ * does not attempt to detect that: `to.index` is the caller's stated INTENT,
58
+ * so a move that literally says "column A index 2 to column A index 2" is
59
+ * declaring no intended change regardless of what its bulk-mates do, and
60
+ * that is the same per-item promise the drag engine's own `DragEndEvent.moved`
61
+ * makes — it never reasoned about siblings either, because a single drag
62
+ * gesture never has any.
63
+ *
64
+ * THE BULK INDEX CONVENTION, AND WHY IT HAS TO BE STATED EXPLICITLY. The
65
+ * engine's single-item convention — `to.index` counts the destination list
66
+ * WITH THE DRAGGED ITEM ALREADY REMOVED — has no obvious generalisation to N
67
+ * items removed from possibly-different columns at once, and `types.ts`
68
+ * does not pick one; that is this module's decision to make and document.
69
+ * THE RULE: every move's `to.index`, bulk or not, counts its destination
70
+ * column's items with EVERY item named ANYWHERE in the same request already
71
+ * removed — not just itself. For a single-move request that is identical to
72
+ * the engine's own rule (nothing else is being removed). For a bulk request
73
+ * it means a UI constructing the request reasons about one shared reference
74
+ * frame per target column ("insert these three selected cards starting at
75
+ * position two among the cards that are staying put"), not about where its
76
+ * OTHER selected cards will land — which is the only version of "bulk move
77
+ * to a position" an operator selecting five cards can sensibly form an
78
+ * intention about in the first place.
79
+ *
80
+ * THE TRAP THIS CONVENTION EXISTS TO AVOID: SEQUENTIAL applyMove FOLDING
81
+ * SILENTLY CORRUPTS A BULK REORDER. Applying move 1 then move 2 via
82
+ * `applyMove` one at a time is NOT equivalent to applying them together,
83
+ * because move 1's splice shifts every index move 2 was computed against —
84
+ * and worse, if move 2's stored index is blindly reused against the
85
+ * ALREADY-SHIFTED array (rather than re-resolved), it can silently pick up
86
+ * the wrong item's position entirely. `applyBoardMoves` never folds moves
87
+ * sequentially over a mutating array for this reason. Instead it resolves
88
+ * every move against ONE shared snapshot — the residual list per column with
89
+ * every moved item already removed — and inserts all of a column's incoming
90
+ * items in a single deterministic pass: sorted by `to.index` ascending
91
+ * (ties broken by the item's original position in `moves`, i.e. request
92
+ * order), each inserted at `to.index + (insertions already made into this
93
+ * column)`. That running offset is exactly what accounts for earlier
94
+ * insertions shifting the positions later ones land at, computed once
95
+ * instead of re-derived per move. board-core.test.ts pins a concrete bulk
96
+ * case where the naive sequential-splice answer and this one visibly differ,
97
+ * so the property is proven rather than incidentally true.
98
+ *
99
+ * WHY `applyBoardMoves` CALLS THE SHARED `applyMove` FOR THE ONE CASE WHERE
100
+ * IT APPLIES, AND HAND-SPLICES EVERYWHERE ELSE. `applyMove(list, from, to)`
101
+ * splices ONE array — it has no way to express "remove from list A, insert
102
+ * into list B," because a cross-column move is inherently a two-list
103
+ * operation. The design system's own `DragDropPage` demo shows this exact
104
+ * split already: `applyMove` for the same-container branch, a manual
105
+ * remove-then-insert for the cross-container one. This module follows the
106
+ * identical rule for the single-move, same-column case (delegates to
107
+ * `applyMove` outright — see the equivalence test, which proves the general
108
+ * residual-based path used for every other case produces byte-identical
109
+ * output to `applyMove` when it degenerates to N=1, same column). Every
110
+ * other shape — cross-column, or any bulk request — has no single shared
111
+ * array to splice, so it goes through the residual-and-insert pass above.
112
+ */
113
+ import type { BoardAccessors, BoardColumnDef, BoardMove, BoardMoveRequest, BoardMoveVerdict } from "./types";
114
+ /** Everything `resolveMove` reads to prove a request is structurally real. */
115
+ export interface ResolveMoveContext<T> {
116
+ items: readonly T[];
117
+ columns: readonly BoardColumnDef[];
118
+ accessors: BoardAccessors<T>;
119
+ /** Consulted after structural validity holds. See the file header for order. */
120
+ validateMove?: (request: BoardMoveRequest) => BoardMoveVerdict;
121
+ }
122
+ export interface ResolvedMove {
123
+ verdict: BoardMoveVerdict;
124
+ /**
125
+ * The subset of the request's moves worth committing, in original order.
126
+ * Empty whenever there is nothing to write — either every move was a
127
+ * no-op, or `verdict.ok` is false. Only ever meaningful when `verdict.ok`.
128
+ */
129
+ commit: readonly BoardMove[];
130
+ }
131
+ export declare function resolveMove<T>(context: ResolveMoveContext<T>, request: BoardMoveRequest): ResolvedMove;
132
+ /**
133
+ * Turn a set of already-valid moves into the consumer's next `items` array.
134
+ * Call this with moves already proven valid — typically `resolveMove(...).commit`
135
+ * — never with raw, unvalidated input: this function does no checking of its
136
+ * own, on purpose, so it stays a pure array transform with no notion of
137
+ * "reject." Handles N=1 and N>1 (same-column or cross-column, mixed) with the
138
+ * one algorithm described in the file header.
139
+ *
140
+ * Reorders which COLUMN each item's block appears in within the returned
141
+ * flat array (columns are emitted in first-seen order, any brand-new target
142
+ * column appended after); WITHIN a column, order is always exactly what the
143
+ * moves + original relative order produce. Between-column interleaving in
144
+ * the input `items` array is not preserved because nothing in this module's
145
+ * contract depends on it — `BoardAccessors.getColumnId` is what determines
146
+ * column membership on every subsequent `buildBoardModel` call, and
147
+ * `buildBoardModel` only ever reads WITHIN-column order.
148
+ *
149
+ * NEVER WRITES A FIELD ON ANY ITEM — ONLY REPOSITIONS. `BoardAccessors` has
150
+ * no setter, on purpose (the file header on `types.ts` — items are opaque),
151
+ * so this function CANNOT change what `getColumnId` reports for a moved
152
+ * item; every returned item is the exact same reference it was given, just
153
+ * possibly in a different slot. For a CROSS-column move this means the
154
+ * repositioning alone does nothing observable until the caller ALSO updates
155
+ * the moved item's own underlying field (a `status`, a `columnId`, whatever
156
+ * the consumer's domain calls it) — that write is the consumer's, entirely
157
+ * outside this module, because only the consumer's own accessor knows how to
158
+ * produce it. The usual order is: update the field(s) first, THEN call this
159
+ * to fix array position — see board-core.test.ts's "what it does and does
160
+ * not mutate" tests, which pin both halves of this split.
161
+ */
162
+ export declare function applyBoardMoves<T>(items: readonly T[], accessors: BoardAccessors<T>, moves: readonly BoardMove[]): T[];
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The board contract. One model shape, one movement pipeline, read by the
3
+ * headless core and by the React slot layer above it.
4
+ *
5
+ * WHAT A BOARD IS HERE. Columns of items, where the items are OPAQUE to us. The
6
+ * board never learns what an item means — no `title`, no `priority`, no
7
+ * `assignee`, no status vocabulary. It knows an item's identity, which column
8
+ * holds it, and nothing else; everything visible is rendered by the consumer.
9
+ * That is not squeamishness about coupling, it is the only way the same
10
+ * component serves a second application: the moment a named prop describes a
11
+ * domain field, every consumer whose domain differs has to fight it.
12
+ *
13
+ * WHY ACCESSORS RATHER THAN A REQUIRED ITEM SHAPE. Demanding
14
+ * `{id, columnId}` forces every consumer to map their data into our shape and
15
+ * back out again, and that mapping is where identity gets lost — a remap that
16
+ * regenerates ids breaks drag mid-gesture, and it breaks silently. Reading the
17
+ * consumer's own objects through accessor functions means their array IS the
18
+ * source of truth and no copy exists to drift.
19
+ *
20
+ * THE MOVEMENT PIPELINE IS SINGULAR, AND THAT IS THE LOAD-BEARING RULE.
21
+ * Pointer drag, keyboard, the action menu and a bulk move all terminate in the
22
+ * SAME validated `onMove`. The alternative — a code path per input — is exactly
23
+ * how the accessible paths rot: the drag path gets exercised daily and the menu
24
+ * path silently stops agreeing with it, taking the WCAG 2.5.7 alternative
25
+ * (below) down with it. There is one funnel, so a fix or a bug reaches every
26
+ * input at once.
27
+ *
28
+ * WCAG 2.2 SC 2.5.7 IS WHY A MENU PATH EXISTS AT ALL, AND KEYBOARD DOES NOT
29
+ * SATISFY IT. Verbatim from the W3C's own understanding document: "achieving
30
+ * keyboard equivalence for a dragging operation does not automatically meet
31
+ * this success criterion, unless that equivalent keyboard operation also
32
+ * provides controls that can be clicked or tapped with a pointer." A touchscreen
33
+ * user may have no keyboard at all. So a single-pointer, non-dragging route to
34
+ * every drag outcome is mandatory rather than a courtesy, and `"menu"` is a
35
+ * first-class `BoardMoveSource` for that reason.
36
+ *
37
+ * INDICES COUNT THE LIST WITH THE MOVED ITEM ALREADY REMOVED. Inherited
38
+ * deliberately from `lib/dnd`'s `DropTarget` (see its header): it makes
39
+ * same-column and cross-column moves arithmetically identical and removes the
40
+ * whole off-by-one class. Every index in this file means that. Restating it
41
+ * here rather than assuming it, because a board that re-derives its own
42
+ * convention alongside the engine's is how the two drift.
43
+ */
44
+ import type { ContainerId, DragId } from "../dnd";
45
+ /**
46
+ * Identities are the engine's, not parallel types. A board column IS a drag
47
+ * container and a board item IS a draggable, so aliasing them keeps a value
48
+ * from having to be cast on its way between the two layers — the kind of cast
49
+ * that compiles forever and is wrong once.
50
+ */
51
+ export type BoardColumnId = ContainerId;
52
+ export type BoardItemId = DragId;
53
+ /**
54
+ * How the board reads the consumer's own objects. `getId` and `getColumnId` are
55
+ * required because they are the two facts a board cannot function without;
56
+ * everything else about an item is the consumer's business.
57
+ */
58
+ export interface BoardAccessors<T> {
59
+ /** Stable across renders. Never an array index — see the engine's `DragId`. */
60
+ getId: (item: T) => BoardItemId;
61
+ getColumnId: (item: T) => BoardColumnId;
62
+ }
63
+ export interface BoardColumnDef {
64
+ id: BoardColumnId;
65
+ /**
66
+ * Spoken in every announcement about this column and shown in the move menu.
67
+ * Required, unlike the engine's optional `label`: a column whose accessible
68
+ * name falls back to an id reads as "column c-3-todo" to a screen reader,
69
+ * which is speakable and useless. The board always has a real name available
70
+ * because the consumer had to name the column to render its header anyway.
71
+ */
72
+ label: string;
73
+ /**
74
+ * Soft cap on item count. Exceeding it is a VISUAL event, not a blocked move
75
+ * — a WIP limit exists to interrupt a human, and a board that refuses the
76
+ * drop instead just teaches people to work around the board. The core reports
77
+ * `overWip`; the view layer is responsible for making it loud.
78
+ */
79
+ wipLimit?: number;
80
+ }
81
+ /** One item's move. `to.index` counts the destination list without this item. */
82
+ export interface BoardMove {
83
+ itemId: BoardItemId;
84
+ from: {
85
+ columnId: BoardColumnId;
86
+ index: number;
87
+ };
88
+ to: {
89
+ columnId: BoardColumnId;
90
+ index: number;
91
+ };
92
+ }
93
+ /**
94
+ * Which input produced the move. Kept on the request rather than inferred
95
+ * downstream because the three that are not `"pointer"` are the ones with no
96
+ * visible symptom when they break: a consumer's telemetry, and our own tests,
97
+ * need to assert that all four routes actually reach the funnel.
98
+ */
99
+ export type BoardMoveSource = "pointer" | "keyboard" | "menu" | "bulk";
100
+ /**
101
+ * `moves.length > 1` only for `"bulk"`, and then it is ALL-OR-NOTHING.
102
+ * Partially applying a multi-select move — accepting the first two cards and
103
+ * rejecting the rest — leaves the operator with a board state they did not ask
104
+ * for and cannot infer, which is worse than a clean refusal.
105
+ */
106
+ export interface BoardMoveRequest {
107
+ moves: readonly BoardMove[];
108
+ source: BoardMoveSource;
109
+ }
110
+ /**
111
+ * A rejection MUST carry a reason, and that is why this is a verdict object
112
+ * rather than a boolean. A bare refusal is the failure mode GitHub Actions is
113
+ * still generating support threads over — a job sits in "queued" and the UI
114
+ * cannot say whether it is a concurrency limit, an approval or capacity. The
115
+ * reason is surfaced to the user and to the live-region announcer, so it is
116
+ * written for a person to read, not logged.
117
+ */
118
+ export type BoardMoveVerdict = {
119
+ ok: true;
120
+ } | {
121
+ ok: false;
122
+ reason: string;
123
+ };
124
+ /** A column as the core computes it, ready to render. */
125
+ export interface BoardColumnModel<T> {
126
+ id: BoardColumnId;
127
+ label: string;
128
+ items: readonly T[];
129
+ /** Count is on the model because the view needs it before it renders rows. */
130
+ count: number;
131
+ wipLimit: number | undefined;
132
+ /** `true` when `count` exceeds `wipLimit`. Always false with no limit set. */
133
+ overWip: boolean;
134
+ }
135
+ export interface UseBoardOptions<T> {
136
+ items: readonly T[];
137
+ columns: readonly BoardColumnDef[];
138
+ accessors: BoardAccessors<T>;
139
+ /**
140
+ * Consulted before ANY move commits, from any of the four sources. Absent
141
+ * means every move is allowed — the common case, and not something a consumer
142
+ * should have to write boilerplate to say.
143
+ */
144
+ validateMove?: (request: BoardMoveRequest) => BoardMoveVerdict;
145
+ /**
146
+ * The single commit point. Fires only for a move that is both valid and not a
147
+ * no-op: the engine's `DragEndEvent.moved` exists because the previous board
148
+ * wrote its ordering field on every release that landed where it started, and
149
+ * a write that changes nothing still costs a request, a re-render and an audit
150
+ * row. That filtering happens in the core so no consumer has to remember it.
151
+ */
152
+ onMove: (request: BoardMoveRequest) => void;
153
+ /** Selection is board state because bulk atomicity is a board concern. */
154
+ selection?: {
155
+ selected: readonly BoardItemId[];
156
+ onChange: (selected: readonly BoardItemId[]) => void;
157
+ };
158
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * ==== USE BOARD ====
3
+ *
4
+ * The React binding over the two pure modules. Deliberately thin: every claim
5
+ * this hook makes has already been proven in model.ts and move.ts without a
6
+ * browser, so the only thing left for React-specific code to get wrong is
7
+ * memoisation and the selection wiring — logic that lived INSIDE a hook
8
+ * instead of a pure function would be logic no test in board-core.test.ts
9
+ * could reach without mounting a component. `renderHook` here exists only to
10
+ * prove the composition wires up, not to re-prove validation or bulk
11
+ * atomicity, which are already pinned in the pure-module tests.
12
+ *
13
+ * `requestMove` IS THE SINGLE FUNNEL. Pointer drag's `onDragEnd`, a keyboard
14
+ * handler, the mandatory "Move to…" menu (WCAG 2.5.7 — see `types.ts`), and a
15
+ * bulk-selection action all construct a `BoardMoveRequest` and call this ONE
16
+ * function. It never mutates `items` itself — `UseBoardOptions.onMove` returns
17
+ * `void` by contract, so the actual next `items` array is the CONSUMER's to
18
+ * produce, typically via `applyBoardMoves` from `./move`. `requestMove` only
19
+ * decides whether that consumer callback fires at all, and returns the verdict
20
+ * synchronously so the caller (e.g. the menu) can show a rejection reason
21
+ * immediately rather than guessing from a UI that just didn't change.
22
+ *
23
+ * SELECTION IS FULLY CONTROLLED, NOT A SECOND SOURCE OF TRUTH. `selection` on
24
+ * `UseBoardOptions` supplies BOTH `selected` and `onChange` — this hook never
25
+ * keeps its own copy of which ids are selected, for the same reason `items`
26
+ * is never copied: a second copy is a copy that can drift. What this hook
27
+ * "owns" is the BEHAVIOUR built on top of that controlled pair (toggle one,
28
+ * replace the set, clear) so every input source computes a selection change
29
+ * the same way, mirroring why `requestMove` exists as a single funnel for
30
+ * moves rather than one per source.
31
+ */
32
+ import type { BoardColumnModel, BoardItemId, BoardMoveRequest, BoardMoveVerdict, UseBoardOptions } from "./types";
33
+ export interface UseBoardResult<T> {
34
+ /** One entry per declared column, in `columns` order, ready to render. */
35
+ columns: readonly BoardColumnModel<T>[];
36
+ /** Items whose `getColumnId` matched no declared column. See model.ts. */
37
+ unassigned: readonly T[];
38
+ /** The current controlled selection, or `[]` when `selection` was not supplied. */
39
+ selected: readonly BoardItemId[];
40
+ /** No-ops when `selection` was not supplied — see the file header. */
41
+ toggleSelected: (id: BoardItemId) => void;
42
+ setSelected: (ids: readonly BoardItemId[]) => void;
43
+ clearSelection: () => void;
44
+ /** The single funnel every input source calls. See the file header. */
45
+ requestMove: (request: BoardMoveRequest) => BoardMoveVerdict;
46
+ }
47
+ export declare function useBoard<T>(options: UseBoardOptions<T>): UseBoardResult<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danxbot/ui",
3
- "version": "3.4.2",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "description": "Danxbot — a domain-agnostic React design system with motion as a first-class primitive.",
6
6
  "license": "MIT",
@@ -63,13 +63,14 @@
63
63
  "build:types": "tsc -p tsconfig.build.json",
64
64
  "build:demo": "vite build",
65
65
  "build": "npm run build:lib && npm run build:demo",
66
- "verify": "npm run typecheck && npm run test && npm run build && npm run typecheck:previews && npm run audit:previews",
66
+ "verify": "npm run typecheck && npm run test && npm run build && npm run typecheck:previews && npm run audit:previews && npm run audit:visual-stale",
67
+ "audit:visual-stale": "node scripts/audit-visual-stale.mjs",
67
68
  "preview": "vite preview",
68
69
  "preview:built": "vite preview --outDir demo-dist --port 5801 --strictPort",
69
70
  "gates": "npm run shoot && npm run a11y && node scripts/shoot-overlays.mjs && npm run probe",
70
71
  "a11y": "node scripts/a11y-scan.mjs",
71
72
  "shoot": "node scripts/shoot.mjs",
72
- "probe": "node scripts/probe-scale.mjs && node scripts/probe-switch.mjs && node scripts/probe-slider.mjs && node scripts/probe-panel.mjs && node scripts/probe-popover-width.mjs && node scripts/probe-api.mjs && node scripts/probe-api-live.mjs && node scripts/probe-tabs-overflow.mjs && node scripts/probe-stat-row.mjs && node scripts/probe-packaging.mjs && node scripts/probe-containment.mjs && node scripts/probe-shell.mjs",
73
+ "probe": "node scripts/probe-scale.mjs && node scripts/probe-switch.mjs && node scripts/probe-slider.mjs && node scripts/probe-panel.mjs && node scripts/probe-popover-width.mjs && node scripts/probe-api.mjs && node scripts/probe-api-live.mjs && node scripts/probe-tabs-overflow.mjs && node scripts/probe-stat-row.mjs && node scripts/probe-packaging.mjs && node scripts/probe-containment.mjs && node scripts/probe-shell.mjs && node scripts/probe-card-header.mjs",
73
74
  "sync": "node scripts/sync.mjs",
74
75
  "visual": "node scripts/visual-docker.mjs",
75
76
  "visual:update": "node scripts/visual-docker.mjs --update-snapshots",
@@ -1,31 +0,0 @@
1
- import type { DragEndEvent } from "../../lib/dnd";
2
- import type { KanbanLane, KanbanMove, KanbanRefusal } from "./types";
3
- export type KanbanDrop =
4
- /** Nothing happened: the card was put back where it came from. */
5
- {
6
- kind: "none";
7
- } | {
8
- kind: "refused";
9
- refusal: KanbanRefusal;
10
- } | {
11
- kind: "move";
12
- move: KanbanMove;
13
- };
14
- export declare function resolveKanbanDrop<T>(lanes: readonly KanbanLane<T>[], event: DragEndEvent): KanbanDrop;
15
- export interface KanbanDropHandlers {
16
- onMove: (move: KanbanMove) => void;
17
- onRefuse?: (refusal: KanbanRefusal) => void;
18
- }
19
- /**
20
- * Resolve a drop and hand it to exactly one of the two callbacks.
21
- *
22
- * The dispatch is here rather than inline in the component because it is the
23
- * other half of the same decision, and the half a resolver test cannot see: a
24
- * board that resolved "refused" correctly and then called `onMove` anyway would
25
- * keep every test in `resolveKanbanDrop` green while writing the move the
26
- * refusal was supposed to stop. Found by mutation — the inline version was the
27
- * one thing in this component nothing failed on.
28
- *
29
- * EXACTLY ONE, OR NEITHER. Never both.
30
- */
31
- export declare function handleKanbanDrop<T>(lanes: readonly KanbanLane<T>[], event: DragEndEvent, handlers: KanbanDropHandlers): KanbanDrop;