tutuca 0.9.89 → 0.9.91

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.89",
3
+ "version": "0.9.91",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SPA framework with immutable state and virtual DOM",
6
6
  "main": "./dist/tutuca.js",
@@ -30,6 +30,7 @@ When authoring tutuca code, also load these if available:
30
30
  | Authoring `component({...})`, `html`...`` views, macros, fields, events, lists, styles | [core.md](./core.md) |
31
31
  | Designing components — responsibilities, state ownership, channel choice, do's & don'ts | [component-design.md](./component-design.md) |
32
32
  | CLI commands, flags, exit codes, full linter rule list | [cli.md](./cli.md) |
33
+ | Authoring `*.dev.js` story modules, `getExamples()` sections, per-example request mocking, running `tutuca storybook` | [storybook.md](./storybook.md) |
33
34
  | `bubble` / `send`-`receive` / async `request`-`response` channels, `$unknown`, request-handler registration | [request-response.md](./request-response.md) |
34
35
  | Drag & drop, dynamic bindings (`*x`), pseudo-`x`, custom seq types, Tailwind/MargaUI | [advanced.md](./advanced.md) |
35
36
  | Runtime semantics — path steps, transaction lifecycle, dyn-var teleporting, async key pinning (`livePath`) | [semantics.md](./semantics.md) |
@@ -217,33 +217,11 @@ version-pinned CDN. All tutuca specifiers resolve to a single runtime, which
217
217
  component scope/identity requires. `--out` always pins the CDN so the static
218
218
  artifact is portable (host it from the project root so `/*.dev.js` paths resolve).
219
219
 
220
- ### The `.dev.js` convention
220
+ ### Authoring `.dev.js` story modules
221
221
 
222
- A `*.dev.js` file is a **dev-only module**: it holds stories
223
- (`getComponents()` + `getExamples()`), tests (`getTests()`), and any other
224
- development-time helpers for nearby components, and is **never shipped to
225
- production or the UI**. The `.dev.js` suffix is the contract — your app imports
226
- its real components directly and never a `.dev.js`, and a production build glob
227
- can exclude `**/*.dev.js`. Because they follow the full module convention, the
228
- same files are valid targets for `tutuca test`/`lint`/`render` too.
229
-
230
- ```js
231
- // counter.dev.js — lives next to counter.js
232
- import { component, html } from "tutuca";
233
- import { Counter } from "./counter.js";
234
-
235
- export function getComponents() {
236
- return [Counter];
237
- }
238
- export function getExamples() {
239
- return { title: "Counter", items: [{ title: "Basic", value: Counter.make({}) }] };
240
- }
241
- export function getTests({ describe, test, expect }) {
242
- describe(Counter, () => {
243
- test("starts at zero", () => expect(Counter.make({}).count).toBe(0));
244
- });
245
- }
246
- ```
222
+ The `*.dev.js` convention (a dev-only module holding `getComponents()` +
223
+ `getExamples()` + `getTests()`, never shipped), the example/section shape, and
224
+ per-example request mocking are covered in [storybook.md](./storybook.md).
247
225
 
248
226
  ## Install skill assets
249
227
 
@@ -1005,12 +1005,31 @@ export function getExamples() {
1005
1005
  return {
1006
1006
  title: "...",
1007
1007
  description: "...",
1008
- items: [{ title, description, value, view }], // value = Comp.make(...)
1008
+ // value = Comp.make(...); requestHandlers (optional) mocks this example's requests
1009
+ items: [{ title, description, value, view, requestHandlers }],
1009
1010
  };
1010
1011
  }
1011
1012
  export function getTests({ describe, test, expect }) { /*...*/ } // optional — see cli.md
1012
1013
  ```
1013
1014
 
1015
+ An example item may carry an optional **`requestHandlers`** map — per-example
1016
+ mocks for the request handlers its component triggers, used by the storybook
1017
+ only. Each is a plain async function keyed by request name; it overrides the
1018
+ module's real `getRequestHandlers()` handler **for that example instance only**,
1019
+ so two examples of the same component can show different responses side by side.
1020
+ Return a fixture, `throw` to exercise the error path, or never resolve to hold a
1021
+ loading state:
1022
+
1023
+ ```js
1024
+ items: [
1025
+ { title: "Loaded", value: Widget.make(),
1026
+ requestHandlers: { async load() { return [{ id: 1, name: "Ada" }]; } } },
1027
+ { title: "Error", value: Widget.make(),
1028
+ requestHandlers: { async load() { throw new Error("boom"); } } },
1029
+ { title: "Default", value: Widget.make() }, // no mock → real handler / 404
1030
+ ]
1031
+ ```
1032
+
1014
1033
  Best practice: have `getComponents()` return **every** component the module
1015
1034
  defines — child and helper components included — and give each one at least
1016
1035
  one item in `getExamples()`. A component left out of `getComponents()` is
@@ -41,3 +41,7 @@ task.
41
41
  ## Component communication
42
42
 
43
43
  - [Coordinate components](coordinate-components.md) — `bubble`, `send`/`receive`, async `request`/`response`.
44
+
45
+ ## Stories & catalog
46
+
47
+ - [Add a story for a component](add-a-story.md) — a `*.dev.js` with `getComponents()` + `getExamples()`, optional per-example request mocks.
@@ -0,0 +1,26 @@
1
+ # Add a story for a component
2
+
3
+ **Problem:** show a component (and its states) in the storybook.
4
+
5
+ Create `foo.dev.js` next to `foo.js`:
6
+
7
+ ```js
8
+ import { Foo } from "./foo.js";
9
+
10
+ export function getComponents() {
11
+ return [Foo];
12
+ }
13
+ export function getExamples() {
14
+ return { title: "Foo", items: [
15
+ { title: "Empty", value: Foo.make({}) },
16
+ { title: "Loaded", value: Foo.make({ isLoading: true }),
17
+ requestHandlers: { async load() { return [{ id: 1 }]; } } },
18
+ ] };
19
+ }
20
+ ```
21
+
22
+ `value` must be a real `Foo.make(...)` instance, not a plain object. Add a
23
+ `requestHandlers` map to an item to mock that example's requests
24
+ (fixture / `throw` / never-resolve) — these are storybook-only. Run
25
+ `tutuca storybook` to view, or `--dry-run --json` to smoke-test. See
26
+ [storybook.md](../storybook.md).
@@ -274,7 +274,7 @@ You can also fire several in one handler
274
274
  ### The request-handler contract
275
275
 
276
276
  Registered request handlers run with **no `this`** (they're invoked as
277
- `fn.apply(null, args)`), so they can't read component state — pass
277
+ `fn.apply(null, [...args, ctx])`), so they can't read component state — pass
278
278
  everything they need through `args`
279
279
  (`ctx.request("persistState", [{ key, value }])`). They're plain async
280
280
  functions or closures. Aggregate handlers from sub-modules with spread:
@@ -285,6 +285,15 @@ export function getRequestHandlers() {
285
285
  }
286
286
  ```
287
287
 
288
+ The handler also receives a request context as its **final argument**
289
+ (consistent with `receive`/`input`/`response`, where `ctx` is last) — usually
290
+ ignored, but available when needed. Like every ctx it exposes
291
+ `ctx.walkPath(callback)`, which walks the component instances on the issuing
292
+ path **leaf→root**, calling `callback(Component, instance)` (return `false` to
293
+ stop early). It captures the immutable dispatch root/path, so it may be called
294
+ before or after an `await`. (The storybook uses this to let an example mock the
295
+ request handlers its component triggers — per example, in isolation.)
296
+
288
297
  ### Chaining from a response handler
289
298
 
290
299
  A `response` handler gets the full `ctx`, so it can issue further
@@ -0,0 +1,151 @@
1
+ # Storybook
2
+
3
+ Reach this file when authoring `*.dev.js` story modules or running
4
+ `tutuca storybook` — defining `getExamples()` sections, mocking requests per
5
+ example, or rendering a live component catalog. For the framework primer see
6
+ [core.md](./core.md); for the full CLI flag/exit-code table see
7
+ [cli.md](./cli.md); for the `getTests` shape see [testing.md](./testing.md).
8
+
9
+ ## Mental model
10
+
11
+ `tutuca storybook [dir]` recursively discovers co-located `*.dev.js` modules,
12
+ mounts them via the shipped `tutuca/storybook` library, and serves an ephemeral
13
+ page — no config, no HTML to write. It is **batteries-included by default**:
14
+ before serving it runs each module's `getTests()` in the terminal, the page
15
+ wires margaui styling, and the browser runs `check(app)`. Each is individually
16
+ disablable with a `--no-*` flag. All tutuca specifiers resolve to **one
17
+ runtime** — component scope and identity require it.
18
+
19
+ ## The `.dev.js` module
20
+
21
+ A `*.dev.js` file is a **dev-only module**: it holds stories, tests, and
22
+ development-time helpers for nearby components, and is **never shipped to
23
+ production or the UI**. The `.dev.js` suffix is the contract — your app imports
24
+ its real components directly and never a `.dev.js`, and a production build glob
25
+ can exclude `**/*.dev.js`. Because it follows the full module convention, the
26
+ same file is a valid target for `tutuca lint` / `test` / `render` too.
27
+
28
+ | Export | Returns | Used for |
29
+ | ------ | ------- | -------- |
30
+ | `getComponents()` | `[Comp, ...]` | stories — return **every** component the module defines, children and helpers included |
31
+ | `getExamples()` | one section, or an array of sections | the catalog cards |
32
+ | `getTests({ describe, test, expect })` | tests | the pre-serve test run (optional) |
33
+ | `getMacros()` | `{ name: macro }` | macros referenced in views (optional) |
34
+ | `getRequestHandlers()` | `{ name: async fn }` | the module's **real** request handlers (optional) |
35
+ | `getRoot()` | `Root.make({...})` | root state when examples need it (optional) |
36
+
37
+ ## Authoring stories (`getExamples`)
38
+
39
+ Return one section, or an array of sections to group examples under multiple
40
+ headings. A section is `{ title, description?, items: [...] }`:
41
+
42
+ ```js
43
+ import { component, html } from "tutuca";
44
+ import { Counter } from "./counter.js";
45
+
46
+ export function getComponents() {
47
+ return [Counter];
48
+ }
49
+ export function getExamples() {
50
+ return { // one section, or an array of these
51
+ title: "Counter",
52
+ description: "A button that counts clicks.", // optional
53
+ items: [
54
+ { title: "Basic", description: "starts at zero", value: Counter.make({ count: 0 }) },
55
+ { title: "Pre-filled", value: Counter.make({ count: 5 }) },
56
+ ],
57
+ };
58
+ }
59
+ ```
60
+
61
+ Item fields:
62
+
63
+ - `title` — required.
64
+ - `description?` — shown under the card title.
65
+ - `value` — required, the instance to render, usually `Comp.make({...})`.
66
+ - `view?` — selects a pushed named view, rendered via `@push-view` in the card.
67
+ - `requestHandlers?` — per-example request mocks (next section).
68
+
69
+ The storybook sorts sections by title and renders a sidebar with a filter, so
70
+ one example item per meaningful state reads as a state matrix.
71
+
72
+ ## Mocking requests per example
73
+
74
+ An item's optional `requestHandlers` map holds async functions keyed by request
75
+ name that override the module's real `getRequestHandlers()` handler **for that
76
+ one example instance only** — so two examples of the same component show
77
+ different responses side by side. The three idioms:
78
+
79
+ ```js
80
+ items: [
81
+ { title: "Loaded", value: Widget.make({ isLoading: true }),
82
+ requestHandlers: { async load() { return [{ id: 1, name: "Ada" }]; } } }, // fixture
83
+ { title: "Error", value: Widget.make({ isLoading: true }),
84
+ requestHandlers: { async load() { throw new Error("boom"); } } }, // error path
85
+ { title: "Loading", value: Widget.make({ isLoading: true }),
86
+ requestHandlers: { load() { return new Promise(() => {}); } } }, // never resolves
87
+ { title: "Default", value: Widget.make() }, // no mock → real handler / "Request not found"
88
+ ]
89
+ ```
90
+
91
+ How it resolves: the storybook registers one meta-handler per request name. On
92
+ dispatch it walks the issuing component's path leaf→root to find the nearest
93
+ example carrying a mock for that name (**nearest example wins**), else falls
94
+ back to the module's real handler, else surfaces `Request not found: <name>`.
95
+ This is **storybook-only** — at runtime your real `getRequestHandlers()` apply.
96
+ See [request-response.md](./request-response.md) for the handler contract (the
97
+ `ctx` is the handler's final argument). `tutuca storybook --dry-run --json`
98
+ lists each example's mocked names.
99
+
100
+ ## Stories as tests (`getTests`)
101
+
102
+ `getTests` runs through the same machinery as `tutuca test`; the storybook runs
103
+ it in the terminal before serving (skip with `--no-tests`). `describe(Comp, fn)`
104
+ auto-tags the suite by `Comp.name`:
105
+
106
+ ```js
107
+ export function getTests({ describe, test, expect }) {
108
+ describe(Counter, () => {
109
+ test("starts at zero", () => expect(Counter.make({}).count).toBe(0));
110
+ });
111
+ }
112
+ ```
113
+
114
+ See [testing.md](./testing.md) for the full `getTests` shape and how to call
115
+ methods / input / receive / bubble / response / alter handlers.
116
+
117
+ ## Running it
118
+
119
+ ```sh
120
+ tutuca storybook # scan + serve the current directory
121
+ tutuca storybook ./packages/ui # scan + serve another directory
122
+ tutuca storybook --dry-run # prep + print what would be shown, don't serve (smoke test)
123
+ tutuca storybook --dry-run --json # same, machine-readable for agents
124
+ tutuca storybook --out ./_site # write a static index.html + bootstrap instead of serving
125
+ tutuca storybook --no-tests # skip the pre-serve getTests() run
126
+ ```
127
+
128
+ Runtime resolution (convention over configuration): a local
129
+ `node_modules/tutuca` install if present, else the CLI's own `dist`, else the
130
+ version-pinned CDN. `--out` always pins the CDN so the artifact is portable —
131
+ host it from the project root so `/*.dev.js` paths resolve. See [cli.md](./cli.md)
132
+ for the exhaustive flag list and exit codes.
133
+
134
+ ## Footguns
135
+
136
+ - ⚠️ `value` must be a real instance (`Comp.make(...)`), not a plain object or
137
+ the class itself — examples need an addressable instance for event dispatch.
138
+ - ⚠️ `requestHandlers` mocks are **storybook-only** and per-instance; don't rely
139
+ on them in `getTests` or production code.
140
+ - ⚠️ Never import a `.dev.js` from app/production code — the suffix is the
141
+ ship / no-ship boundary.
142
+ - ⚠️ An example whose component triggers a request with no real handler and no
143
+ per-example mock surfaces `Request not found: <name>`.
144
+ - ⚠️ Keep one tutuca runtime — mixed specifiers or installs break scope identity.
145
+
146
+ ## Verify
147
+
148
+ After editing a `*.dev.js`: `tutuca lint <module>.dev.js` →
149
+ `tutuca test <module>.dev.js` → `tutuca storybook --dry-run --json <dir>`
150
+ (smoke-test discovery, counts, and mocked names without serving), then
151
+ `tutuca storybook <dir>` to view it live.
@@ -250,6 +250,17 @@ class Path {
250
250
  }
251
251
  return curVal;
252
252
  }
253
+ resolveChain(root) {
254
+ const out = [root];
255
+ let curVal = root;
256
+ for (const step of this.steps) {
257
+ curVal = step.lookup(curVal, NONE);
258
+ if (curVal === NONE)
259
+ break;
260
+ out.push(curVal);
261
+ }
262
+ return out;
263
+ }
253
264
  setValue(root, v) {
254
265
  const intermediates = new Array(this.steps.length);
255
266
  let curVal = root;
@@ -2926,6 +2937,7 @@ class Transactor {
2926
2937
  const txnPath = path.toTransactionPath();
2927
2938
  const curLeaf = txnPath.lookup(curRoot);
2928
2939
  const handler = this.comps.getRequestFor(curLeaf, name) ?? mkReq404(name);
2940
+ const reqCtx = new RequestContext(path, this, parent, curRoot);
2929
2941
  const resHandlerName = opts?.onResName ?? name;
2930
2942
  const resPath = opts?.livePath ? null : txnPath.pinKeys(curRoot);
2931
2943
  const push = (specificName, baseName, singleArg, result, error) => {
@@ -2934,7 +2946,7 @@ class Transactor {
2934
2946
  this.pushTransaction(t);
2935
2947
  };
2936
2948
  try {
2937
- const result = await handler.fn.apply(null, args);
2949
+ const result = await handler.fn.apply(null, [...args, reqCtx]);
2938
2950
  push(opts?.onOkName, resHandlerName, result, result, null);
2939
2951
  } catch (error) {
2940
2952
  push(opts?.onErrorName, resHandlerName, error, null, error);
@@ -3177,10 +3189,20 @@ class Task {
3177
3189
  }
3178
3190
 
3179
3191
  class Dispatcher {
3180
- constructor(path, transactor, parentTransaction) {
3192
+ constructor(path, transactor, parentTransaction, root = transactor.state.val) {
3181
3193
  this.path = path;
3182
3194
  this.transactor = transactor;
3183
3195
  this.parent = parentTransaction;
3196
+ this.root = root;
3197
+ }
3198
+ walkPath(callback) {
3199
+ const comps = this.transactor.comps;
3200
+ const chain = this.path.toTransactionPath().resolveChain(this.root);
3201
+ for (let i = chain.length - 1;i >= 0; i--) {
3202
+ const comp = comps.getCompFor(chain[i]);
3203
+ if (comp && callback(comp, chain[i]) === false)
3204
+ return;
3205
+ }
3184
3206
  }
3185
3207
  get at() {
3186
3208
  return new PathChanges(this);
@@ -3217,6 +3239,9 @@ class EventContext extends Dispatcher {
3217
3239
  }
3218
3240
  }
3219
3241
 
3242
+ class RequestContext extends Dispatcher {
3243
+ }
3244
+
3220
3245
  class PathChanges extends PathBuilder {
3221
3246
  constructor(dispatcher) {
3222
3247
  super();