tutuca 0.9.98 → 0.9.99

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.
@@ -1,5 +1,48 @@
1
- // src/storybook.js
1
+ // src/storybook/index.js
2
2
  import { component, dispatchPhase, html, injectCss, phaseHasBubble, tutuca } from "tutuca";
3
+ import { getComponents as getInspectorComponents } from "tutuca/components";
4
+
5
+ // src/storybook/inspect.js
6
+ import { buildInspectorViews, isComponentInstance } from "tutuca/components";
7
+ function buildTestIndex(modules) {
8
+ const index = new Map;
9
+ for (const m of modules) {
10
+ if (typeof m.getTests !== "function")
11
+ continue;
12
+ const components = m.getComponents?.() ?? [];
13
+ for (const c of components) {
14
+ if (!index.has(c.name))
15
+ index.set(c.name, { getTests: m.getTests, components });
16
+ }
17
+ }
18
+ return index;
19
+ }
20
+ async function buildExampleInspectors(example, scope, testIndex, dev) {
21
+ const value = example.value;
22
+ const comp = isComponentInstance(value) ? scope.getCompFor(value) : null;
23
+ const entry = comp ? testIndex.get(comp.name) : null;
24
+ const views = await buildInspectorViews(value, scope, {
25
+ getTests: entry?.getTests ?? null,
26
+ components: entry?.components ?? [],
27
+ dev
28
+ });
29
+ return example.setInstanceView(views.instanceView).setComponentView(views.componentView).setLintView(views.lintView).setTestView(views.testView).setHasInspect(views.hasInspect).setHasComponent(views.hasComponent).setHasLint(views.hasLint).setHasTest(views.hasTest);
30
+ }
31
+ async function attachInspectorViews(root, scope, modules, dev = null) {
32
+ const testIndex = buildTestIndex(modules);
33
+ let sections = root.sections;
34
+ for (let si = 0;si < sections.size; si++) {
35
+ const section = sections.get(si);
36
+ let items = section.items;
37
+ for (let ii = 0;ii < items.size; ii++) {
38
+ items = items.set(ii, await buildExampleInspectors(items.get(ii), scope, testIndex, dev));
39
+ }
40
+ sections = sections.set(si, section.setItems(items));
41
+ }
42
+ return root.setSections(sections);
43
+ }
44
+
45
+ // src/storybook/index.js
3
46
  var Storybook = component({
4
47
  name: "Storybook",
5
48
  fields: {
@@ -247,7 +290,16 @@ var Example = component({
247
290
  value: null,
248
291
  view: "main",
249
292
  requestHandlers: null,
250
- on: null
293
+ on: null,
294
+ activeTab: "preview",
295
+ hasInspect: false,
296
+ hasComponent: false,
297
+ hasLint: false,
298
+ hasTest: false,
299
+ componentView: null,
300
+ instanceView: null,
301
+ lintView: null,
302
+ testView: null
251
303
  },
252
304
  requestOverridesField: "requestHandlers",
253
305
  statics: {
@@ -317,8 +369,72 @@ var Example = component({
317
369
  </div>
318
370
  </h2>
319
371
  <p class="text-md italic opacity-60" @text=".description"></p>
320
- <div class="bg-base-100 p-3" @push-view=".view">
321
- <x render=".value"></x>
372
+ <div role="tablist" class="tabs tabs-border" @show=".hasInspect">
373
+ <a
374
+ role="tab"
375
+ @if.class="equals? .activeTab 'preview'"
376
+ @then="'tab tab-active'"
377
+ @else="'tab'"
378
+ @on.click="$setActiveTab 'preview'"
379
+ >
380
+ Preview
381
+ </a>
382
+ <a
383
+ role="tab"
384
+ @show=".hasComponent"
385
+ @if.class="equals? .activeTab 'component'"
386
+ @then="'tab tab-active'"
387
+ @else="'tab'"
388
+ @on.click="$setActiveTab 'component'"
389
+ >
390
+ Component
391
+ </a>
392
+ <a
393
+ role="tab"
394
+ @if.class="equals? .activeTab 'instance'"
395
+ @then="'tab tab-active'"
396
+ @else="'tab'"
397
+ @on.click="$setActiveTab 'instance'"
398
+ >
399
+ Instance
400
+ </a>
401
+ <a
402
+ role="tab"
403
+ @show=".hasLint"
404
+ @if.class="equals? .activeTab 'lint'"
405
+ @then="'tab tab-active'"
406
+ @else="'tab'"
407
+ @on.click="$setActiveTab 'lint'"
408
+ >
409
+ Lint
410
+ </a>
411
+ <a
412
+ role="tab"
413
+ @show=".hasTest"
414
+ @if.class="equals? .activeTab 'test'"
415
+ @then="'tab tab-active'"
416
+ @else="'tab'"
417
+ @on.click="$setActiveTab 'test'"
418
+ >
419
+ Test
420
+ </a>
421
+ </div>
422
+ <div @show="equals? .activeTab 'preview'">
423
+ <div class="bg-base-100 p-3" @push-view=".view">
424
+ <x render=".value"></x>
425
+ </div>
426
+ </div>
427
+ <div class="p-3" @show="equals? .activeTab 'component'">
428
+ <x render=".componentView"></x>
429
+ </div>
430
+ <div class="p-3" @show="equals? .activeTab 'instance'">
431
+ <x render=".instanceView"></x>
432
+ </div>
433
+ <div class="p-3" @show="equals? .activeTab 'lint'">
434
+ <x render=".lintView"></x>
435
+ </div>
436
+ <div class="p-3" @show="equals? .activeTab 'test'">
437
+ <x render=".testView"></x>
322
438
  </div>
323
439
  </div>
324
440
  </div>`
@@ -360,7 +476,7 @@ function buildStorybook(modules) {
360
476
  return Array.isArray(raw) ? raw : [raw];
361
477
  });
362
478
  const sections = rawSections.map((s) => Section.Class.fromData(s)).sort((a, b) => a.title.localeCompare(b.title));
363
- const components = new Set([Storybook, Section, Example]);
479
+ const components = new Set([Storybook, Section, Example, ...getInspectorComponents()]);
364
480
  const macros = {};
365
481
  const requestHandlers = {};
366
482
  const overrideNames = new Set;
@@ -413,7 +529,7 @@ function buildExampleRequestHandlers({ requestHandlers: reals, overrideNames })
413
529
  handlers[name] = makeMeta(name);
414
530
  return handlers;
415
531
  }
416
- async function mountStorybook(selector, modules, { compileCss, root, persistUrl = true } = {}) {
532
+ async function mountStorybook(selector, modules, { compileCss, root, persistUrl = true, dev = null } = {}) {
417
533
  const app = tutuca(selector);
418
534
  const built = buildStorybook(modules);
419
535
  app.state.set(root ?? built.root);
@@ -424,6 +540,9 @@ async function mountStorybook(selector, modules, { compileCss, root, persistUrl
424
540
  if (persistUrl) {
425
541
  scope.registerRequestHandlers({ persistState });
426
542
  }
543
+ if (dev && app.state.val?.sections) {
544
+ app.state.set(await attachInspectorViews(app.state.val, scope, modules, dev));
545
+ }
427
546
  if (compileCss) {
428
547
  injectCss("tutuca-storybook", await compileCss(app));
429
548
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.98",
3
+ "version": "0.9.99",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SPA framework with immutable state and virtual DOM",
6
6
  "main": "./dist/tutuca.js",
@@ -14,6 +14,7 @@
14
14
  "./extra-ext": "./dist/tutuca-extra.ext.js",
15
15
  "./dev-ext": "./dist/tutuca-dev.ext.js",
16
16
  "./storybook": "./dist/tutuca-storybook.js",
17
+ "./components": "./dist/tutuca-components.js",
17
18
  "./immutable": "./dist/immutable.js",
18
19
  "./chai": "./dist/chai.js",
19
20
  "./package.json": "./package.json"
@@ -59,6 +60,7 @@
59
60
  "dist/tutuca-extra.ext.js",
60
61
  "dist/tutuca-dev.ext.js",
61
62
  "dist/tutuca-storybook.js",
63
+ "dist/tutuca-components.js",
62
64
  "dist/immutable.js",
63
65
  "dist/chai.js",
64
66
  "skill"
@@ -151,77 +151,19 @@ Filters:
151
151
  Default format is `cli` (a tree with ✓/✗/○ and per-test durations);
152
152
  `-f md` and `-f json` work too.
153
153
 
154
- A worked `getTests()` export covering methods, input handlers (called
155
- via `Comp.input.x.call(inst)`), and immutability:
156
-
157
- ```js
158
- export function getTests({ describe, test, expect }) {
159
- describe(Counter, () => {
160
- describe("inc()", () => { // method
161
- test("returns a Counter with count + 1", () => {
162
- const next = Counter.make().inc();
163
- expect(next).toBeInstanceOf(Counter.Class);
164
- expect(next.count).toBe(1);
165
- });
166
- test("does not mutate the original instance", () => {
167
- const c = Counter.make({ count: 7 });
168
- c.inc();
169
- expect(c.count).toBe(7); // immutability
170
- });
171
- });
172
-
173
- describe("dec()", () => { // input handler
174
- test("returns a Counter with count - 1", () => {
175
- const next = Counter.input.dec.call(Counter.make());
176
- expect(next.count).toBe(-1);
177
- });
178
- });
179
-
180
- test("inc and dec round-trip", () => { // untagged path
181
- expect(Counter.input.dec.call(Counter.make().inc()).count).toBe(0);
182
- });
183
- });
184
- }
185
- ```
186
-
187
- `describe(Counter, fn)` auto-tags the suite path with `Counter.name`, so
188
- `tutuca test <module> Counter` picks it up. Untagged `test(...)` at the
189
- top of a tagged `describe` inherits the tag.
154
+ The `getTests` shape and the handler calling conventions (`Comp.method()`,
155
+ `Comp.input.x.call(inst, …)`, the `drive` cascade helper, iteration
156
+ handlers) are in [testing.md](./testing.md).
190
157
 
191
158
  ## storybook — live component catalog
192
159
 
193
- `tutuca storybook [dir]` serves a browser storybook for a project with no
194
- setup. It recursively discovers co-located `*.dev.js` modules (see the
195
- `.dev.js` convention below), mounts them via the shipped `tutuca/storybook`
196
- library, and serves an ephemeral page — no config, no HTML to write.
197
-
198
- ```sh
199
- tutuca storybook # scan + serve the current directory
200
- tutuca storybook ./packages/ui # scan + serve another directory
201
- tutuca storybook --port 4321 # preferred port (falls back to a free one if taken)
202
- tutuca storybook --out ./_site # write a static index.html + bootstrap instead of serving
203
- tutuca storybook --dry-run # do all the prep + print what would be shown, don't serve (smoke test)
204
- tutuca storybook --dry-run --json # same, machine-readable for agents
205
- tutuca storybook --no-tests # skip the pre-serve getTests() run
206
- tutuca storybook --no-margaui # render unstyled (skip margaui)
207
- tutuca storybook --no-check # skip the in-browser check(app)
208
- ```
209
-
210
- It is **batteries-included by default**: before serving it runs each module's
211
- `getTests()` in the terminal, the page wires margaui styling, and the browser
212
- runs `check(app)`. Each is individually disablable with the `--no-*` flags.
213
-
214
- How tutuca itself is resolved (convention over configuration): a local
215
- `node_modules/tutuca` install if present, else the CLI's own `dist`, else the
216
- version-pinned CDN. All tutuca specifiers resolve to a single runtime, which
217
- component scope/identity requires. `--out` always pins the CDN so the static
218
- artifact is portable (host it from the project root so `/*.dev.js` paths resolve).
219
-
220
- ### Authoring `.dev.js` story modules
221
-
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).
160
+ `tutuca storybook [dir]` serves a browser storybook with no setup it
161
+ discovers co-located `*.dev.js` modules, runs their `getTests()`, wires
162
+ margaui, and serves an ephemeral page. Its flags (`--port`, `--out`,
163
+ `--dry-run`, `--no-margaui`, `--no-check`, `--no-tests`) are in the
164
+ Commands table above. Authoring `.dev.js` modules, the example/section shape,
165
+ per-example request mocking, and runtime resolution are all in
166
+ [storybook.md](./storybook.md).
225
167
 
226
168
  ## Install skill assets
227
169
 
@@ -657,12 +657,11 @@ inbound sources (WebSocket, `postMessage`, timers) have no element to bind
657
657
  — route those through `app.sendAtRoot` instead (see
658
658
  [request-response.md](./request-response.md)).
659
659
 
660
- Pitfall: binding camelCase JS properties on a custom element silently
661
- fails. `:mapId=".id"` does *not* invoke a `set mapId` setter
662
- the HTML parser lowercased the attribute name, so the framework assigns
663
- to `node.mapid` instead, creating an own property and bypassing the
664
- setter. Use kebab-case attributes / lowercased setters when authoring
665
- custom elements for use with Tutuca. See *Attribute Binding* above.
660
+ Pitfall: binding a camelCase JS property on a custom element silently
661
+ fails `:mapId=".id"` assigns to `node.mapid`, never invoking
662
+ `set mapId`. Author custom elements with kebab-case attributes /
663
+ lowercased setters and bind via `:kebab-name`. See *Attribute Binding*
664
+ above for the full explanation.
666
665
 
667
666
  ## Conditional Display
668
667
 
@@ -931,66 +930,29 @@ CSS via the extra build), see [margaui.md](./margaui.md).
931
930
 
932
931
  ## Triggers and Handlers
933
932
 
934
- Tutuca has four orchestration channels. Each one pairs a trigger with
935
- a same-shape handler block:
933
+ Tutuca has four orchestration channels. Each pairs a trigger with a
934
+ same-shape handler block:
936
935
 
937
- | Triggered by | Handler block |
938
- | ------------------------------------------- | ------------------- |
939
- | DOM event (`click`, `input`, …) | `input: { ... }` |
940
- | `ctx.send(name)` — message to a target path | `receive: { ... }` |
941
- | `ctx.request(name)` — async request | `response: { ... }` |
942
- | `ctx.bubble(name)` — event up the tree | `bubble: { ... }` |
936
+ | Triggered by | Handler block | Use for |
937
+ | ------------------------------------------- | ------------------- | --------------------------------------------------- |
938
+ | DOM event (`click`, `input`, …) | `input: { ... }` | the component handling its own events |
939
+ | `ctx.bubble(name)` — event up the tree | `bubble: { ... }` | aggregate state an ancestor owns (logs, selections) |
940
+ | `ctx.send(name)` — message to a target path | `receive: { ... }` | addressing one known component (or self) |
941
+ | `ctx.request(name)` — async request | `response: { ... }` | fetch / timer / IndexedDB, result routed back |
943
942
 
944
943
  Every handler is called as `handler(...args, ctx)` and returns a
945
- (possibly updated) instance of `this`; the framework swaps the
946
- returned value into the dispatch path. The three event-driven channels
947
- beyond `input` — `bubble`, `send`/`receive`, async `request`/`response`
948
- plus the shared `$unknown` fallback and request-handler registration
949
- are documented in [request-response.md](./request-response.md); the
950
- brief anchors below cover the essentials.
944
+ (possibly updated) instance of `this`, which the framework swaps into
945
+ the dispatch path; `ctx` is always the trailing argument. The three
946
+ channels beyond `input` — plus `ctx.at`, the `$unknown` fallback,
947
+ per-call handler-name overrides, error handling, and request-handler
948
+ registration — are in [request-response.md](./request-response.md);
949
+ worked snippets in
950
+ [patterns/coordinate-components.md](./patterns/coordinate-components.md).
951
951
 
952
952
  `alter` is a fifth handler block, but unlike the four above it isn't
953
953
  event-triggered — the renderer invokes alter handlers to produce
954
954
  binds, not to update state. See *Mental model* and *Scope Enrichment*.
955
955
 
956
- ## Orchestration channels (bubble / send-receive / request-response)
957
-
958
- Beyond local `input` handlers, three channels move state between
959
- components. Full mechanics — when-to-use guidance, the `ctx.at`
960
- `PathBuilder`, error handling, per-call handler-name overrides, the
961
- `$unknown` fallback, and request-handler registration — are in
962
- [request-response.md](./request-response.md). The essentials:
963
-
964
- - **`bubble`** — `ctx.bubble("name", args)` walks the dispatch path
965
- toward the root; each ancestor with `bubble.<name>(...args, ctx)`
966
- runs (after descendants transact); `ctx.stopPropagation()` halts it.
967
- Use for aggregate state owned by an ancestor (logs, selections).
968
-
969
- ```js
970
- input: { onClick(ctx) { ctx.bubble("itemSelected", [this]); return this; } },
971
- bubble: { itemSelected(item, ctx) { return this.insertInLogAt(0, item.label); } },
972
- ```
973
-
974
- - **`send` / `receive`** — `ctx.send("name", args)` delivers a message
975
- to one target (self by default, or `ctx.at.field("x").send(...)` /
976
- `.index(name, i)` / `.key(name, k)` for another); the target's
977
- `receive.<name>(...args, ctx)` runs. `receive.init` is a convention,
978
- not a lifecycle hook — dispatch it via `app.sendAtRoot("init")`.
979
-
980
- - **`request` / `response`** — `ctx.request("name", args)` runs a
981
- host-registered async handler (registered with
982
- `scope.registerRequestHandlers({...})`) and routes the result to
983
- `response.<name>(res, err, ctx)` — `res` set on success, `err` on
984
- failure. Use for fetch / timer / IndexedDB work.
985
-
986
- ```js
987
- receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } },
988
- response: { loadData(res, err, ctx) { return this.setIsLoading(false).setItems(res); } },
989
- ```
990
-
991
- `ctx` is always the last argument of every `bubble` / `receive` /
992
- `response` handler.
993
-
994
956
  ## Macros
995
957
 
996
958
  Pure template expansion — no state, no methods. Calls inside a macro
@@ -1115,22 +1077,11 @@ export function getTests({ describe, test, expect }) { /*...*/ } // optiona
1115
1077
  ```
1116
1078
 
1117
1079
  An example item may carry an optional **`requestHandlers`** map — per-example
1118
- mocks for the request handlers its component triggers, used by the storybook
1119
- only. Each is a plain async function keyed by request name; it overrides the
1120
- module's real `getRequestHandlers()` handler **for that example instance only**,
1121
- so two examples of the same component can show different responses side by side.
1122
- Return a fixture, `throw` to exercise the error path, or never resolve to hold a
1123
- loading state:
1124
-
1125
- ```js
1126
- items: [
1127
- { title: "Loaded", value: Widget.make(),
1128
- requestHandlers: { async load() { return [{ id: 1, name: "Ada" }]; } } },
1129
- { title: "Error", value: Widget.make(),
1130
- requestHandlers: { async load() { throw new Error("boom"); } } },
1131
- { title: "Default", value: Widget.make() }, // no mock → real handler / 404
1132
- ]
1133
- ```
1080
+ mocks (keyed by request name) that override the module's real
1081
+ `getRequestHandlers()` for that one instance, so two examples of the same
1082
+ component show different responses side by side. Return a fixture, `throw` for
1083
+ the error path, or never resolve to hold a loading state. Full treatment, plus
1084
+ the `on` lifecycle hooks, in [storybook.md](./storybook.md).
1134
1085
 
1135
1086
  Best practice: have `getComponents()` return **every** component the module
1136
1087
  defines — child and helper components included — and give each one at least
@@ -83,9 +83,10 @@ return curLeaf !== newLeaf ? txnPath.setValue(curRoot, newLeaf) : curRoot;
83
83
  ```
84
84
 
85
85
  The root swap is atomic and identity-cheap: unchanged subtrees keep their
86
- references, so re-render is incremental. Cross-transaction ordering and
87
- fan-out completion are tracked by `Task` (a transaction's `task` resolves
88
- once its dependency tasks do).
86
+ references, so re-render is incremental. Per-dispatch completion is tracked
87
+ by `Completion` (counter-based): `whenSettled()` resolves once a
88
+ transaction's own work finishes, `whenSubtreeSettled()` once the subtree it
89
+ spawned (requests, follow-on sends) settles too.
89
90
 
90
91
  ## Dispatch channels, semantically
91
92
 
@@ -269,12 +269,9 @@ The "bad" forms force every test to construct
269
269
  `{ target: { value: "42" } }` (or a fuller stub when more fields are
270
270
  read), which is brittle and obscures intent.
271
271
 
272
- Built-in named args (full list in [core.md](./core.md) *Event
273
- Handling*): `value`, `valueAsInt`, `valueAsFloat`, `target`, `event`,
274
- `isAlt`, `isShift`, `isCtrl` / `isCmd`, `key`, `keyCode`, `isUpKey`,
275
- `isDownKey`, `isSend`, `isCancel`, `isTabKey`, `ctx`, `dragInfo`.
276
- `ctx` is auto-appended last. Reach for `event` only when no narrower
277
- arg fits.
272
+ The built-in named args are listed in [core.md](./core.md) *Event
273
+ Handling*; `ctx` is auto-appended last. Reach for `event` only when no
274
+ narrower arg fits.
278
275
 
279
276
  ## Worked example
280
277