@dashfoo/core 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pedro Filho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,315 @@
1
+ # @dashfoo/core
2
+
3
+ The framework-free engine behind dashfoo's docking layout (FlexLayout / VS-Code-style
4
+ tiled, resizable, tabbed regions). Pure TypeScript: a zod schema for the model, a
5
+ pure reducer, self-healing invariants, drop geometry, an undo/redo history, JSON
6
+ serialization with validation, and two XState v5 machines.
7
+
8
+ This package has **no React**. It depends only on `zod` and `xstate`. The React
9
+ bindings, the react-resizable-panels and @dnd-kit adapters, and all rendering live
10
+ in `@dashfoo/react`. You can drive this engine from any runtime (a worker, a test,
11
+ a non-React UI) by dispatching actions and reading back the next model.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pnpm add @dashfoo/core
17
+ ```
18
+
19
+ ## The model
20
+
21
+ A layout is one `Dashfoo` value. It is JSON-serializable by construction: per-tab
22
+ `config` is validated against a recursive JSON schema, so a function or symbol
23
+ slipped into `config` fails parsing instead of corrupting a saved layout.
24
+
25
+ ```ts
26
+ type Dashfoo = {
27
+ version: number;
28
+ global: GlobalAttributes;
29
+ layout: RowNode; // the root is always a row
30
+ activeTabsetId?: string;
31
+ maximizedTabsetId?: string;
32
+ };
33
+ ```
34
+
35
+ The tree has three node kinds, each discriminated by `type`:
36
+
37
+ | Node | `type` | Holds |
38
+ | ------------ | ---------- | ----------------------------------------------------------------------- |
39
+ | `RowNode` | `"row"` | `children` (rows or tabsets), `orientation` (`row`/`column`), `weight?` |
40
+ | `TabsetNode` | `"tabset"` | `children` (tabs), `selected` index, optional `min`/`max`/`weight` |
41
+ | `TabNode` | `"tab"` | `component`, `name`, `id`, optional `config` + `enable*` flags |
42
+
43
+ Rows nest (a row's child can be another row), which is how arbitrary tiled splits
44
+ are represented. `min`/`max` are `Dimension` values (`{ unit, value }`) where
45
+ `unit` is one of `px`, `%`, `em`, `rem`, `vh`, `vw`. `global.tabSetMinSize` is
46
+ the default tabset minimum size in pixels for renderers that honor it; the React
47
+ adapter falls back to `320px` when it is omitted.
48
+
49
+ Every schema is exported as a zod object plus an inferred type, so untrusted input
50
+ can be validated before it reaches the reducer:
51
+
52
+ ```ts
53
+ import { dashfooSchema, type Dashfoo } from "@dashfoo/core";
54
+
55
+ const model: Dashfoo = dashfooSchema.parse(untrustedJson);
56
+ ```
57
+
58
+ ### Builders
59
+
60
+ `model` / `row` / `tabset` / `tab` construct a valid model without the
61
+ `type`/`version`/`selected` boilerplate:
62
+
63
+ ```ts
64
+ import { model, row, tabset, tab } from "@dashfoo/core";
65
+
66
+ const m = model(
67
+ row([
68
+ tabset([tab("chart", "Chart"), tab("depth", "Depth")], { id: "left", weight: 2 }),
69
+ tabset([tab("book", "Order Book")], { id: "right" }),
70
+ ]),
71
+ { activeTabsetId: "left" },
72
+ );
73
+ ```
74
+
75
+ A `tab`'s id defaults to its component name; `tabset`/`row` auto-generate an id
76
+ when omitted. Output is schema-valid by construction.
77
+
78
+ ## The pure reducer
79
+
80
+ `reducer(model, action)` is the canonical engine. It deep-copies the model with
81
+ `structuredClone` (so the input is never mutated, no Immer), applies one action
82
+ to the copy, then runs `normalize` so the result is always valid and canonical.
83
+
84
+ ```ts
85
+ const reducer: (model: Dashfoo, action: Action) => Dashfoo;
86
+ ```
87
+
88
+ Every mutation is one immutable, discriminated `Action`. The reducer is exhaustive
89
+ over the union; an unhandled case throws at runtime via `assertNever`. Validate
90
+ untrusted payloads against `actionSchema` before dispatch.
91
+
92
+ | `action.type` | Effect |
93
+ | ------------------------ | ---------------------------------------------------------------- |
94
+ | `addNode` | Insert a tab at a `DockLocation` (center / split-\*) |
95
+ | `moveNode` | Remove a tab by `sourceId`, re-insert it at a dock target |
96
+ | `moveTabset` | Remove a tabset by `sourceId`, re-dock it whole at a dock target |
97
+ | `selectTab` | Set a tabset's `selected` index |
98
+ | `setActiveTabset` | Mark the focused tabset |
99
+ | `setMaximizedTabset` | Maximize one tabset (or clear with `null`) |
100
+ | `renameTab` | Change a tab's `name` |
101
+ | `deleteTab` | Remove a tab |
102
+ | `deleteTabset` | Remove a whole tabset |
103
+ | `adjustSplit` | Set the `weights` of a row's children (splitter drag) |
104
+ | `updateNodeAttributes` | Patch mutable attrs on a tab / tabset / row |
105
+ | `updateGlobalAttributes` | Patch the `global` block |
106
+
107
+ The `DockLocation` union is `center` and
108
+ `split-top`/`split-bottom`/`split-left`/`split-right`. A `center` drop
109
+ stacks the tab into the target tabset. A `split-*` drop creates a new tabset beside
110
+ the target, reusing the parent row when its orientation already matches, otherwise
111
+ wrapping both in a fresh row.
112
+
113
+ ### Use the reducer directly
114
+
115
+ ```ts
116
+ import { reducer, parseModel, type Action, type Dashfoo } from "@dashfoo/core";
117
+
118
+ const model: Dashfoo = parseModel({
119
+ version: 1,
120
+ global: {},
121
+ layout: {
122
+ type: "row",
123
+ id: "root",
124
+ orientation: "row",
125
+ children: [
126
+ {
127
+ type: "tabset",
128
+ id: "ts-1",
129
+ selected: 0,
130
+ children: [{ type: "tab", id: "tab-1", component: "editor", name: "README.md" }],
131
+ },
132
+ ],
133
+ },
134
+ });
135
+
136
+ const stack: Action = {
137
+ type: "addNode",
138
+ targetId: "ts-1",
139
+ location: "center",
140
+ tab: { type: "tab", id: "tab-2", component: "editor", name: "index.ts" },
141
+ };
142
+
143
+ const next = reducer(model, stack);
144
+ // next.layout.children[0].children.length === 2; input `model` is untouched.
145
+ ```
146
+
147
+ ## normalize + invariants
148
+
149
+ `normalize(model)` is the self-healing pass run after every action. It keeps the
150
+ tree canonical so downstream code never has to defend against degenerate shapes:
151
+
152
+ - drops empty tabsets and empty rows
153
+ - simplifies a single-child row by lifting its lone child (which inherits the
154
+ lifted row's weight, so sizing is preserved)
155
+ - absorbs a root that reduces to a single nested row
156
+ - clamps every `selected` index into range
157
+ - forces `activeTabsetId` / `maximizedTabsetId` to point at a tabset that exists
158
+ (falling back to the first tabset, or clearing)
159
+
160
+ `normalize` is exported on its own if you build a model by hand and want it
161
+ canonicalized without dispatching an action.
162
+
163
+ ## Tree helpers
164
+
165
+ Read-only lookups over a model, all exported:
166
+
167
+ ```ts
168
+ collectTabsets(model): Array<TabsetNode>; // depth-first, layout only
169
+ getFirstTabset(model): TabsetNode | undefined;
170
+ findTabset(model, tabsetId): TabsetNode | undefined;
171
+ findTab(model, tabId): TabLocation | undefined; // searches tabsets
172
+ findRow(row, rowId): RowNode | undefined; // pass model.layout as the root
173
+ findAttributedNode(model, id): AttributedNode | undefined; // row, tabset, or tab
174
+ findTabsetParent(row, tabsetId): { index: number; parent: RowNode } | undefined;
175
+ findDuplicateIds(model): Array<string>; // ids used more than once
176
+ ```
177
+
178
+ `findTab` returns `{ container, index, tab }` so a caller knows where the tab
179
+ lives (a tabset in the layout).
180
+
181
+ ## Geometry
182
+
183
+ Pure functions translate a pointer position into a drop intent and back into an
184
+ indicator rect. The @dnd-kit adapter in `@dashfoo/react` feeds them rects; you can
185
+ call them directly for custom drag logic.
186
+
187
+ ```ts
188
+ resolveDockTarget(pointer, rect, opts?): DockTarget;
189
+ zoneRect(rect, location): Rect;
190
+ ```
191
+
192
+ `resolveDockTarget` decides where a drag over a tabset should land: `{ kind: "tab" }`
193
+ when the pointer is in the interior, or `{ kind: "split", edge }` when it is within
194
+ an outer band of one of the four edges (default 22%; the closer edge wins in
195
+ corners). It accepts `{ bandFraction }` to tune the band. `zoneRect` returns the
196
+ region the dock indicator highlights for a `DockLocation`: the whole tabset for a
197
+ `center` stack, the matching half for a split. `Point` and `Rect` are exported.
198
+
199
+ ## History (undo / redo)
200
+
201
+ A small `past` / `present` / `future` structure that wraps the reducer. `present`
202
+ is the live model.
203
+
204
+ ```ts
205
+ import { createHistory, dispatch, undo, redo, canUndo, canRedo } from "@dashfoo/core";
206
+
207
+ let history = createHistory(model);
208
+ history = dispatch(history, { type: "deleteTab", tabId: "tab-2" });
209
+
210
+ if (canUndo(history)) history = undo(history);
211
+ if (canRedo(history)) history = redo(history);
212
+ ```
213
+
214
+ Every dispatched action is its own undo step; there is no coalescing. A splitter
215
+ drag still lands as one step because react-resizable-panels v4 commits a single
216
+ `adjustSplit` when the drag is released, not a per-frame stream. Any new dispatch
217
+ clears the redo `future`.
218
+
219
+ ## Serialize
220
+
221
+ ```ts
222
+ toJSON(model): string; // JSON.stringify
223
+ fromJSON(json): Dashfoo; // parse → validate → normalize
224
+ parseModel(value): Dashfoo; // same, from an already-parsed value
225
+ ```
226
+
227
+ `fromJSON` and `parseModel` validate an untrusted value against `dashfooSchema`
228
+ and return a normalized model. They throw on an invalid value. The payload's
229
+ `version` field is pinned to `1` by the schema itself, so a payload written in
230
+ any other format fails validation; a future format change bumps the literal.
231
+
232
+ ## XState machines
233
+
234
+ Two XState v5 machines model the runtime. `@dashfoo/react` wires them to React, but
235
+ they are framework-free and usable on their own.
236
+
237
+ ### dashfooMachine
238
+
239
+ The document actor. It owns the undo/redo `History` (whose `present` is the live
240
+ model) and processes mutations through the history helpers. There is no lifecycle,
241
+ the document is data, so it has a single `ready` state and handles four events:
242
+
243
+ | Event | Payload | Effect |
244
+ | ----------- | ------------ | ------------------------------ |
245
+ | `DISPATCH` | `{ action }` | run the reducer via `dispatch` |
246
+ | `UNDO` | — | step back |
247
+ | `REDO` | — | step forward |
248
+ | `SET_MODEL` | `{ model }` | replace with a fresh history |
249
+
250
+ ```ts
251
+ import { createActor } from "xstate";
252
+ import { dashfooMachine } from "@dashfoo/core";
253
+
254
+ const actor = createActor(dashfooMachine, { input: { model } }).start();
255
+ actor.send({ type: "DISPATCH", action: { type: "deleteTab", tabId: "tab-2" } });
256
+ const current = actor.getSnapshot().context.history.present;
257
+ ```
258
+
259
+ ### dragDockMachine
260
+
261
+ The drag/dock interaction lifecycle (`idle` → `dragging` → `idle`), driven by
262
+ abstract events the dnd-kit adapter maps from pointer and keyboard input. It owns
263
+ transient drag state only and never touches the document. On a valid `DROP` it
264
+ **emits** a `COMMIT` carrying a `moveNode` action (the drag subject is a tab) or a
265
+ `moveTabset` action (the subject is a whole tabset, dragged by its grip), which
266
+ the React layer forwards to `dashfooMachine`.
267
+
268
+ | Event | Payload |
269
+ | -------- | -------------------- |
270
+ | `START` | `{ subject }` |
271
+ | `OVER` | `{ intent \| null }` |
272
+ | `DROP` | — |
273
+ | `CANCEL` | — |
274
+
275
+ ## Public exports
276
+
277
+ `schema` — `dashfooSchema`, `rowNodeSchema`, `tabsetNodeSchema`, `tabNodeSchema`,
278
+ `dimensionSchema`, `edgeSchema`, `unitSchema`,
279
+ `orientationSchema`, `globalAttributesSchema`,
280
+ `jsonValueSchema`; types `Dashfoo`, `RowNode`, `TabsetNode`, `TabNode`,
281
+ `Dimension`, `Edge`, `Unit`, `Orientation`,
282
+ `GlobalAttributes`, `Node`, `Json`.
283
+
284
+ `builders` — `model`, `row`, `tabset`, `tab`; option types `ModelOptions`,
285
+ `RowOptions`, `TabsetOptions`, `TabOptions`.
286
+
287
+ `ids` — `createNodeId`, `createTabId`.
288
+
289
+ `actions` — `actionSchema`, `dockLocationSchema`, `mutableNodeAttrsSchema`; types
290
+ `Action`, `DockLocation`, `DropIntent`, `MutableNodeAttrs`.
291
+
292
+ `reducer` — `reducer`. `invariants` — `normalize`.
293
+
294
+ `tree` — `collectTabsets`, `getFirstTabset`, `findTabset`, `findTab`, `findRow`,
295
+ `findAttributedNode`, `findTabsetParent`, `findDuplicateIds`;
296
+ types `AttributedNode`, `TabContainer`, `TabLocation`.
297
+
298
+ `geometry` — `resolveDockTarget`, `zoneRect`; types `DockTarget`,
299
+ `BandOptions`, `Point`, `Rect`.
300
+
301
+ `history` — `createHistory`, `dispatch`, `undo`, `redo`, `canUndo`, `canRedo`;
302
+ type `History`.
303
+
304
+ `serialize` — `toJSON`, `fromJSON`, `parseModel`.
305
+
306
+ `stack` — `stackModel` (flatten any layout into one row or column of all its
307
+ tabsets, the building block for a narrow-screen breakpoint).
308
+
309
+ `machines` — `dashfooMachine`, `dragDockMachine`; types `DashfooContext`,
310
+ `DashfooEvent`, `DashfooInput`, `DragContext`, `DragEvent`, `DragSubject`,
311
+ `DragEmitted`.
312
+
313
+ ## License
314
+
315
+ MIT