tutuca 0.11.2 → 0.12.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 (115) hide show
  1. package/dist/tutuca-cli.js +130 -104
  2. package/dist/tutuca-dev.ext.js +121 -90
  3. package/dist/tutuca-dev.js +121 -90
  4. package/dist/tutuca-dev.min.js +3 -3
  5. package/dist/tutuca-extra.ext.js +116 -59
  6. package/dist/tutuca-extra.js +116 -59
  7. package/dist/tutuca-extra.min.js +2 -2
  8. package/dist/tutuca-storybook.js +3 -3
  9. package/dist/tutuca.ext.js +116 -59
  10. package/dist/tutuca.js +116 -59
  11. package/dist/tutuca.min.js +2 -2
  12. package/package.json +1 -1
  13. package/skill/margaui/SKILL.md +105 -0
  14. package/skill/margaui/components/accordion.md +127 -0
  15. package/skill/margaui/components/alert.md +174 -0
  16. package/skill/margaui/components/aura.md +97 -0
  17. package/skill/margaui/components/avatar.md +220 -0
  18. package/skill/margaui/components/badge.md +193 -0
  19. package/skill/margaui/components/breadcrumbs.md +103 -0
  20. package/skill/margaui/components/button.md +322 -0
  21. package/skill/margaui/components/calendar.md +67 -0
  22. package/skill/margaui/components/card.md +373 -0
  23. package/skill/margaui/components/carousel.md +387 -0
  24. package/skill/margaui/components/chat.md +171 -0
  25. package/skill/margaui/components/checkbox.md +101 -0
  26. package/skill/margaui/components/collapse.md +172 -0
  27. package/skill/margaui/components/countdown.md +165 -0
  28. package/skill/margaui/components/diff.md +53 -0
  29. package/skill/margaui/components/divider.md +107 -0
  30. package/skill/margaui/components/dock.md +173 -0
  31. package/skill/margaui/components/drawer.md +184 -0
  32. package/skill/margaui/components/dropdown.md +388 -0
  33. package/skill/margaui/components/fab.md +346 -0
  34. package/skill/margaui/components/fieldset.md +88 -0
  35. package/skill/margaui/components/file-input.md +84 -0
  36. package/skill/margaui/components/filter.md +52 -0
  37. package/skill/margaui/components/footer.md +583 -0
  38. package/skill/margaui/components/hero.md +135 -0
  39. package/skill/margaui/components/hover-3d.md +129 -0
  40. package/skill/margaui/components/hover-gallery.md +49 -0
  41. package/skill/margaui/components/indicator.md +265 -0
  42. package/skill/margaui/components/input.md +389 -0
  43. package/skill/margaui/components/join.md +100 -0
  44. package/skill/margaui/components/kbd.md +127 -0
  45. package/skill/margaui/components/label.md +102 -0
  46. package/skill/margaui/components/link.md +96 -0
  47. package/skill/margaui/components/list.md +182 -0
  48. package/skill/margaui/components/loading.md +105 -0
  49. package/skill/margaui/components/mask.md +168 -0
  50. package/skill/margaui/components/megamenu.md +131 -0
  51. package/skill/margaui/components/menu.md +887 -0
  52. package/skill/margaui/components/mockup-browser.md +39 -0
  53. package/skill/margaui/components/mockup-code.md +81 -0
  54. package/skill/margaui/components/mockup-phone.md +39 -0
  55. package/skill/margaui/components/mockup-window.md +33 -0
  56. package/skill/margaui/components/modal.md +196 -0
  57. package/skill/margaui/components/navbar.md +282 -0
  58. package/skill/margaui/components/otp.md +171 -0
  59. package/skill/margaui/components/pagination.md +122 -0
  60. package/skill/margaui/components/progress.md +135 -0
  61. package/skill/margaui/components/radial-progress.md +67 -0
  62. package/skill/margaui/components/radio.md +133 -0
  63. package/skill/margaui/components/range.md +134 -0
  64. package/skill/margaui/components/rating.md +170 -0
  65. package/skill/margaui/components/select.md +225 -0
  66. package/skill/margaui/components/skeleton.md +64 -0
  67. package/skill/margaui/components/stack.md +142 -0
  68. package/skill/margaui/components/stat.md +254 -0
  69. package/skill/margaui/components/status.md +73 -0
  70. package/skill/margaui/components/steps.md +138 -0
  71. package/skill/margaui/components/swap.md +152 -0
  72. package/skill/margaui/components/tab.md +248 -0
  73. package/skill/margaui/components/table.md +1018 -0
  74. package/skill/margaui/components/text-rotate.md +91 -0
  75. package/skill/margaui/components/textarea.md +85 -0
  76. package/skill/margaui/components/theme-controller.md +266 -0
  77. package/skill/margaui/components/timeline.md +1356 -0
  78. package/skill/margaui/components/toast.md +165 -0
  79. package/skill/margaui/components/toggle.md +135 -0
  80. package/skill/margaui/components/tooltip.md +181 -0
  81. package/skill/margaui/components/validator.md +163 -0
  82. package/skill/tutuca/SKILL.md +56 -0
  83. package/skill/tutuca/advanced.md +212 -0
  84. package/skill/tutuca/cli.md +239 -0
  85. package/skill/tutuca/component-design.md +168 -0
  86. package/skill/tutuca/core.md +918 -0
  87. package/skill/tutuca/iteration.md +207 -0
  88. package/skill/tutuca/macros.md +86 -0
  89. package/skill/tutuca/margaui.md +175 -0
  90. package/skill/tutuca/messages-and-intents.md +399 -0
  91. package/skill/tutuca/patterns/README.md +48 -0
  92. package/skill/tutuca/patterns/add-a-story.md +26 -0
  93. package/skill/tutuca/patterns/bind-text-and-attributes.md +30 -0
  94. package/skill/tutuca/patterns/conditional-attribute-value.md +29 -0
  95. package/skill/tutuca/patterns/coordinate-components.md +54 -0
  96. package/skill/tutuca/patterns/edit-through-a-dynamic-target.md +27 -0
  97. package/skill/tutuca/patterns/enrich-each-item.md +25 -0
  98. package/skill/tutuca/patterns/file-input.md +39 -0
  99. package/skill/tutuca/patterns/filter-a-list.md +25 -0
  100. package/skill/tutuca/patterns/filter-and-paginate.md +60 -0
  101. package/skill/tutuca/patterns/handle-events.md +44 -0
  102. package/skill/tutuca/patterns/iterate-a-list.md +18 -0
  103. package/skill/tutuca/patterns/paginate-a-list.md +29 -0
  104. package/skill/tutuca/patterns/render-a-child-component.md +21 -0
  105. package/skill/tutuca/patterns/reuse-markup-with-macros.md +36 -0
  106. package/skill/tutuca/patterns/share-state-across-the-tree.md +38 -0
  107. package/skill/tutuca/patterns/show-or-hide-content.md +23 -0
  108. package/skill/tutuca/patterns/switch-between-views.md +30 -0
  109. package/skill/tutuca/patterns/tabbed-interface.md +43 -0
  110. package/skill/tutuca/semantics.md +195 -0
  111. package/skill/tutuca/storybook.md +270 -0
  112. package/skill/tutuca/styles.md +48 -0
  113. package/skill/tutuca/testing.md +345 -0
  114. package/skill/tutuca-source/SKILL.md +33 -0
  115. package/skill/tutuca-source/tutuca.ext.js +4301 -0
@@ -0,0 +1,399 @@
1
+ # Tutuca — Messages & Intents
2
+
3
+ The two dispatch channels: **messages** (`ctx.send` / `ctx.at.….send` /
4
+ `app.sendAtRoot`, and a view's own `@on.*` → a `receive` handler on one
5
+ addressed component) and **intents** (`ctx.intent` → a walk along a
6
+ *route* until something answers). Read this file when writing `receive`
7
+ / `intent` handlers, calling `ctx.send` / `ctx.intent` / `ctx.reply` /
8
+ `ctx.fail` / `ctx.forward` / `ctx.stop`, or registering intent handlers
9
+ with `registerIntentHandlers`. General authoring lives in
10
+ [core.md](./core.md); testing these handlers is in
11
+ [testing.md](./testing.md).
12
+
13
+ ## The two channels
14
+
15
+ One question separates them: **does the sender know who handles this?**
16
+
17
+ - If yes, it **sends a message**. It goes to one component and stops.
18
+ - If no, it **raises an intent**. It walks a route until something answers.
19
+
20
+ | Triggered by | handler bucket |
21
+ | --- | --- |
22
+ | DOM event (`@on.click`, `@on.input`, …) | `receive` |
23
+ | `ctx.send(name, args)` / `ctx.at.….send(…)` | `receive` |
24
+ | `app.sendAtRoot(name, args)` | `receive` |
25
+ | an answer to an intent this component raised | `receive` |
26
+ | `ctx.intent(name, args, opts)` — walks a route | `intent` |
27
+
28
+ The first four rows are one bucket, and there is **no way to tell them
29
+ apart**. That is deliberate: a component that answered its own click
30
+ differently from the identical `ctx.send` from its parent could be driven
31
+ neither from a test nor from a parent.
32
+
33
+ Every dispatched handler is called as `handler(draft, ...args, ctx)`. Mutate
34
+ `draft` and return nothing to commit; `this` remains the immutable current
35
+ instance. Returning another value swaps it into the dispatch path. `ctx` (an
36
+ `EventContext`) is always the trailing argument.
37
+
38
+ `alter` is a third block, but it isn't dispatched — the renderer invokes alter
39
+ handlers to produce binds, not to update state. See *Mental model* in
40
+ [core.md](./core.md) and *Scope Enrichment* in [iteration.md](./iteration.md).
41
+
42
+ ## Messages — `ctx.send`, `receive`
43
+
44
+ `ctx.send(name, args)` delivers a message to the **current** component;
45
+ `ctx.at.<place>.send(name, args)` delivers it to an addressed one. The
46
+ target's `receive.<name>(...args, ctx)` handler runs. There is **no built-in
47
+ lifecycle** — `receive.init` is just a convention; the host must dispatch it
48
+ (typically after `app.start()`) for it to run.
49
+
50
+ ```js
51
+ receive: {
52
+ init(_draft, ctx) { ctx.at.field("status").send("flash", ["Ready"]); },
53
+ flash(draft, text) { draft.text = text; },
54
+ }
55
+ ```
56
+
57
+ Dispatch from anywhere:
58
+
59
+ ```js
60
+ app.sendAtRoot("init"); // host code, top-level
61
+ ctx.at.field("personalSite").send("init"); // child by field name
62
+ ctx.at.index("items", 3).send("startEditing"); // list element at index 3
63
+ ctx.at.key("byKey", "k1").send("ping"); // map entry by key
64
+ ctx.at.field("a").field("b").index("xs", 0).send("ping"); // chain freely
65
+ ctx.send("loadData"); // self
66
+ ```
67
+
68
+ `ctx.at` returns a `PathBuilder` with `.field(name)`, `.index(name, i)`, and
69
+ `.key(name, k)`. Each call appends a step before `.send(...)` / `.intent(...)`
70
+ fires; the handler runs inside the child instance with `this` bound to it.
71
+ Paths are positional, not references — see *Positional delivery* below.
72
+
73
+ **When to send.** Send when *one specific component* must be told something: a
74
+ form telling its email field to focus after a failed submit, a list telling
75
+ item 3 to enter edit mode, a "Reload" button reusing the `receive.loadData`
76
+ body that `receive.init` also calls. Don't `send` to self when a direct method
77
+ call would do — and don't send when you don't know who should answer. That is
78
+ what an intent is for.
79
+
80
+ ## Intents — routes and legs
81
+
82
+ `ctx.intent(name, args, opts)` raises a job the sender does not address. The
83
+ runtime walks a **route**, offering the intent to each hop in turn.
84
+
85
+ A route is a list of **legs**, and there are two:
86
+
87
+ | leg | walks |
88
+ | --- | --- |
89
+ | `"dyn"` | the **dispatch path** — the sender's *parent*, then its parent, up to the root |
90
+ | `"lex"` | the **registration scope chain** — the handlers `registerIntentHandlers` put on the scope, then the scopes above it |
91
+
92
+ With no `opts.route`, an intent takes the default route `["dyn", "lex"]`: try
93
+ the ancestors, then the registered handlers. That default is written down in
94
+ exactly one place (`DEFAULT_ROUTE` in `src/transactor.js`), so "what does a
95
+ bare `ctx.intent` do" has one answer and no second copy.
96
+
97
+ ```js
98
+ receive: {
99
+ go(ctx) {
100
+ ctx.intent("saveDraft", [this.name]); // dyn, then lex
101
+ ctx.intent("picked", [this.page], { route: ["dyn"] }); // ancestors only
102
+ ctx.intent("loadRows", [], { route: ["lex"] }); // the scope only
103
+ ctx.intent("saveDraft", [this.name], { route: ["lex", "dyn"] }); // in the order written
104
+ return this;
105
+ },
106
+ }
107
+ ```
108
+
109
+ The `dyn` leg starts at the sender's **parent**, not at the sender: an intent
110
+ is never offered to the component that raised it. (One that wanted to handle it
111
+ itself would have written the body inline.)
112
+
113
+ Walks are depth-bounded — 64 hops, after which the runtime ends the walk as an
114
+ exhaustion rather than looping, so the sender still hears something.
115
+
116
+ ## Answering an intent
117
+
118
+ A component answers with an `intent.<name>` handler. Inside it:
119
+
120
+ - `ctx.reply(value)` — answer with a result. **Ends the walk.**
121
+ - `ctx.fail(error)` — answer with an error. **Ends the walk.**
122
+ - `ctx.forward(opts)` — hand the intent to the next hop (see below).
123
+ - `ctx.stop()` — end the walk **answering nothing**.
124
+ - ...or none of the above: the body runs, returns new state, and the walk goes
125
+ on. A handler that does not reply is an **observer**.
126
+
127
+ ```js
128
+ intent: {
129
+ // Answered where it arrives.
130
+ saveDraft(draft, text, ctx) { draft.count++; ctx.reply(draft.count); },
131
+ // An observer: it records the intent and lets it keep walking.
132
+ picked(draft, k) { draft.page = k; },
133
+ }
134
+ ```
135
+
136
+ The one rule to hold on to: **a reply ends the walk; running does not.** An
137
+ observer and an answerer are the same construct with and without a `reply`,
138
+ which is why no separate "listener" bucket exists.
139
+
140
+ Two consequences worth knowing:
141
+
142
+ - The one-shot is **per intent, across hops** — not per body. A body may call
143
+ `ctx.reply` twice; the first wins and the second is refused.
144
+ - A hop whose handler throws never completes its transition, so it did not
145
+ answer, and the walk continues past it as if it had declined.
146
+
147
+ ## The three outcomes
148
+
149
+ An intent has exactly three ends, each with its own name and its own payload
150
+ shape:
151
+
152
+ | outcome | dispatched name | payload |
153
+ | --- | --- | --- |
154
+ | a hop replied | `<name>Ok` | the replied value |
155
+ | a hop failed | `<name>Error` | the error value |
156
+ | the route ran out | `<name>Unhandled` | the intent's own arguments |
157
+
158
+ They arrive back at the sender as **ordinary messages, in the `receive`
159
+ bucket**. A handler cannot tell an answer from a message a parent sent, and
160
+ does not need to.
161
+
162
+ **Declaring the arms is what makes an intent a request rather than a
163
+ notification** — a sender expects an answer if and only if it declares one.
164
+ Nobody writes that down twice: at the moment a walk ends, the runtime looks the
165
+ derived names up in the sender's own `receive` bucket.
166
+
167
+ ```js
168
+ receive: {
169
+ init(draft, ctx) { draft.isLoading = true; ctx.intent("loadData", [], { route: ["lex"] }); },
170
+
171
+ // The three ANSWERS. Declaring them is what wires `loadData` up.
172
+ loadDataOk(draft, res) { draft.isLoading = false; draft.items = res; },
173
+ loadDataError(draft, err) { draft.isLoading = false; draft.error = String(err); },
174
+ loadDataUnhandled(draft) { draft.isLoading = false; draft.error = "nothing answers loadData"; },
175
+ }
176
+ ```
177
+
178
+ There is no arm that can be handed both a result and an error, so none can read
179
+ the wrong one. (The old combined `(res, err)` shape is gone, and so is the bug
180
+ it caused.)
181
+
182
+ `<name>Unhandled` is what a route running out means, and it carries **the
183
+ intent's own arguments** so the sender can degrade or retry without keeping a
184
+ copy. What the sender hears when a route runs out depends only on what it
185
+ declares:
186
+
187
+ 1. it declares `<name>Unhandled` → that, with the intent's own args;
188
+ 2. else it declares `<name>Error` → that, with `"noHandler"`;
189
+ 3. else it declares only `<name>Ok` → a console warning, because an answer was
190
+ expected and none came; an answer never disappears in silence;
191
+ 4. else nothing at all → silence. That is a **notification**, and it is the
192
+ idiomatic fire-and-forget shape.
193
+
194
+ A handler that must answer has no way to say "not mine" — it can only invent an
195
+ error — which is why declining is a separate answer from failing. "Nothing
196
+ claimed it" and "a handler refused it" are different sentences, so they have
197
+ different names.
198
+
199
+ ## `ctx.forward` — one word, two sides
200
+
201
+ `ctx.forward(opts)` is the same word from both ends of a walk, and which one you
202
+ get depends on which bucket you are in:
203
+
204
+ - **In an `intent` handler** it *amends the hop*: the walk goes on, optionally
205
+ with new `args` or a narrowed `route`. It does not push a hop itself — a walk
206
+ advances on its own.
207
+ - **In a `receive` handler** it *starts a walk*: the message that arrived
208
+ becomes an intent, keeping its name and payload.
209
+
210
+ ```js
211
+ receive: {
212
+ saveDraft(_draft, text, ctx) { ctx.forward(); }, // default route
213
+ picked(_draft, k, ctx) { ctx.forward({ route: ["dyn"] }); }, // ancestors only
214
+ logThenPass(draft, t, ctx) { draft.count++; ctx.forward(); },
215
+ },
216
+ intent: {
217
+ picked(draft, k, ctx) { draft.page = k; ctx.forward({ args: [k, "seen"] }); },
218
+ }
219
+ ```
220
+
221
+ This is what lets a view's name **leave** the component. A view says
222
+ `@on.click="saveDraft .text"` — what the user asked for, not who answers it.
223
+ The component may answer it today and an ancestor may answer it tomorrow, and
224
+ the view never changes.
225
+
226
+ ## Registering intent handlers — the `lex` leg
227
+
228
+ The `lex` leg walks handlers registered on the **scope**, not on components.
229
+ They are plain functions — usually `async` — registered as a **list per name**,
230
+ because the leg walks: a declining handler hands the intent to the next one.
231
+
232
+ | the handler | meaning |
233
+ | --- | --- |
234
+ | resolves a value | answered; the sender hears `<name>Ok` |
235
+ | throws / rejects | failed; the sender hears `<name>Error` |
236
+ | returns `PASS` | **declines**; the walk goes on to the next hop |
237
+
238
+ ```js
239
+ import { PASS } from "tutuca";
240
+
241
+ export function getIntentHandlers() {
242
+ return {
243
+ async loadData() {
244
+ const r = await fetch("https://example.com/data.json");
245
+ return await r.json();
246
+ },
247
+ // A list when more than one handler can answer a name.
248
+ persistState: [
249
+ async (state, instance, push) => (push ? PASS : save(state)),
250
+ async (state) => pushHistory(state),
251
+ ],
252
+ };
253
+ }
254
+
255
+ // register at the same scope where you registerComponents
256
+ const scope = app.registerComponents([Comp]);
257
+ scope.registerIntentHandlers(getIntentHandlers());
258
+ ```
259
+
260
+ `PASS` is the handler's half of "running is not answering", and it is what
261
+ makes `<name>Unhandled` reachable. An intent name that **nothing** is
262
+ registered for is not a crash and not an error: the route runs out and the
263
+ sender hears `<name>Unhandled`. A typo surfaces there.
264
+
265
+ ### The handler contract
266
+
267
+ Registered handlers run with **no `this`** (they are invoked as
268
+ `fn.apply(null, [...args, ctx])`), so they cannot read component state — pass
269
+ everything they need through `args`. Aggregate handlers from sub-modules with
270
+ spread:
271
+
272
+ ```js
273
+ export function getIntentHandlers() {
274
+ return { ...getIntentHandlersA(), ...getIntentHandlersB() };
275
+ }
276
+ ```
277
+
278
+ The handler also receives an intent context as its **final argument** —
279
+ usually ignored, but available when needed. Like every ctx it exposes
280
+ `ctx.walkPath(callback)`, which walks the component instances on the issuing
281
+ path **leaf→root**, calling `callback(Component, instance)` (return `false` to
282
+ stop early). It captures the immutable dispatch root/path, so it may be called
283
+ before or after an `await`. (The storybook uses this to let an example mock the
284
+ intent handlers its component raises — per example, in isolation.)
285
+
286
+ ### Chaining from an answer arm
287
+
288
+ An answer arm gets the full `ctx`, so it can raise further intents or send
289
+ messages:
290
+
291
+ ```js
292
+ receive: {
293
+ loadUserOk(draft, user, ctx) { draft.user = user; ctx.intent("loadUserDetails", [user.id], { route: ["lex"] }); },
294
+ loadUserDetailsOk(draft, details) { draft.userDetails = details; },
295
+ }
296
+ ```
297
+
298
+ ## Integrating with the outside world
299
+
300
+ A tutuca app talks to the outside world in two directions, and both go through
301
+ handlers — never around them.
302
+
303
+ - **Outbound** — the app reaches out (fetch, timers, IndexedDB, external SDKs).
304
+ `ctx.intent(name, args, { route: ["lex"] })`; the scope-registered handler
305
+ does the async work and the answer lands back in component state as
306
+ `<name>Ok` / `<name>Error`.
307
+ - **Inbound** — the outside world pushes an event in (a WebSocket message, a
308
+ `postMessage`, a timer, a third-party callback). Use
309
+ `app.sendAtRoot(name, args)` from the host / glue code. It dispatches a
310
+ message to the **root component**, running its
311
+ `receive.<name>(draft, ...args, ctx)` handler under the same draft-first
312
+ transaction contract as every other handler.
313
+
314
+ ```js
315
+ // host / glue code, outside the component tree
316
+ ws.onmessage = (e) => app.sendAtRoot("serverPushed", [JSON.parse(e.data)]);
317
+
318
+ // root component
319
+ receive: {
320
+ serverPushed(msg) { return this.prependEvent(msg); },
321
+ }
322
+ ```
323
+
324
+ ⚠️ **Do not** reach into `app.state` and call the raw `State.set(val)` /
325
+ `State.update(fn)` methods to inject external data. That bypasses the component
326
+ handler model, the draft-first transaction discipline, scope enrichment,
327
+ and the transactor's batching — state mutated that way is invisible to the
328
+ components that own it and easily clobbered by the next transaction. Route
329
+ every inbound event through `app.sendAtRoot` instead.
330
+
331
+ `sendAtRoot` only targets the root (`Path([])`). To land an inbound event on
332
+ nested state, let the root's `receive` handler forward it with
333
+ `ctx.at.field(...).send(...)` — one entry point, still reaching deep.
334
+
335
+ ## Fire-and-forget
336
+
337
+ An intent whose answer you don't need declares no answer arms, so the outcome
338
+ is dropped. Idiomatic for side-effect-only work like persisting state:
339
+
340
+ ```js
341
+ receive: {
342
+ applyFilter(draft, value, ctx) {
343
+ draft.filter = value;
344
+ ctx.intent("persistState", [{ key: "sectionFilter", value }], { route: ["lex"] });
345
+ },
346
+ }
347
+ ```
348
+
349
+ Fire several in one handler when needed — they go out in the order written.
350
+
351
+ ## `livePath` — pinning vs following a moving key
352
+
353
+ `opts` takes `livePath`. It controls where the answer lands when the sender's
354
+ path addresses a seq-access entry (`.sheets[.selId]`): by **default** the
355
+ resolved key is *pinned* at dispatch time, so the answer updates the item that
356
+ raised the intent even if `.selId` moved while the walk was in flight (e.g. the
357
+ user switched tabs). Set `livePath: true` to opt out and re-resolve the key
358
+ live, delivering to whatever the key now points at:
359
+
360
+ ```js
361
+ ctx.intent("save", [payload], { route: ["lex"] }); // pinned
362
+ ctx.intent("refresh", [], { route: ["lex"], livePath: true }); // live
363
+ ```
364
+
365
+ The pinning rules per step kind (and why list indices still slide) are in
366
+ [semantics.md](./semantics.md) (*Key resolution & async races*).
367
+
368
+ ## `$unknown` fallback
369
+
370
+ `receive` and `intent` share one fallback: when no handler matches the
371
+ dispatched name, the runtime looks for `<block>.$unknown(...args, ctx)` and
372
+ runs that instead; `ctx.name` tells it which name was dispatched. Absent both
373
+ the named handler and `$unknown`, the message is silently dropped (the value
374
+ passes through unchanged). Use `$unknown` for a single catch-all (logging, a
375
+ generic router).
376
+
377
+ An `intent.$unknown` that calls `ctx.reply` would answer **every** intent that
378
+ reaches it and swallow every walk — make it an observer, or `ctx.forward()`.
379
+
380
+ ## Positional delivery across async
381
+
382
+ The path a message or an answer is delivered to is **positional** — an array of
383
+ steps from the root, not a captured reference. This is why an answer survives
384
+ intervening transactions that rebuilt the root (see *Mental model* in
385
+ [core.md](./core.md)). Practical rule: anchor on map keys, not list indices,
386
+ when an async answer must reach a specific item — the per-step-kind pinning
387
+ rules are in [semantics.md](./semantics.md).
388
+
389
+ ## See also
390
+
391
+ - [core.md](./core.md) — the core mental model, `view` directives, handler
392
+ blocks overview, and *Conventional Module Exports*.
393
+ - [component-design.md](./component-design.md) — which channel to reach for when.
394
+ - [semantics.md](./semantics.md) — the path/transaction model behind these
395
+ channels: path steps, the transaction lifecycle, teleporting, and the
396
+ key-pinning rules `livePath` toggles.
397
+ - [testing.md](./testing.md) — driving message and intent flows from tests.
398
+ - [cli.md](./cli.md) — the full linter rule list, exit codes, and
399
+ `render` / `test` flags.
@@ -0,0 +1,48 @@
1
+ # Tutuca — Patterns
2
+
3
+ Task-oriented recipes: "how do I do X" with a minimal working snippet and the
4
+ one pitfall worth knowing. Each recipe is self-contained and brief; for the
5
+ full directive *semantics* behind a pattern, see [core.md](../core.md) and its
6
+ spokes.
7
+
8
+ New to Tutuca? Read [core.md](../core.md) first, then reach here for a specific
9
+ task.
10
+
11
+ ## Iteration & lists
12
+
13
+ - [Iterate a list](iterate-a-list.md) — render one element per item with `@each` / `render-each`.
14
+ - [Filter a list](filter-a-list.md) — keep only matching items with `@when`.
15
+ - [Enrich each item](enrich-each-item.md) — expose derived per-item values as `@`-bindings.
16
+ - [Paginate a list](paginate-a-list.md) — slice the iteration with `@loop-with` `start`/`end`.
17
+ - [Filter and paginate a list](filter-and-paginate.md) — do both with `@loop-with` `keys` (filter-then-slice, identity preserved).
18
+
19
+ ## Conditional content & attributes
20
+
21
+ - [Show or hide content](show-or-hide-content.md) — `@show` / `@hide` and the boolean predicates.
22
+ - [Switch between views](switch-between-views.md) — pick a component's own view with `as=` or `@push-view`.
23
+ - [Conditional attribute value](conditional-attribute-value.md) — set a class/title by condition with `@if` / `@then` / `@else`.
24
+ - [Tabbed interface](tabbed-interface.md) — a `currentView` field + predicates to show the panel and highlight the active tab.
25
+
26
+ ## Context & dynamic bindings
27
+
28
+ - [Share state across the tree](share-state-across-the-tree.md) — `provide` / `lookup` and reading `*name`.
29
+ - [Edit through a dynamic target](edit-through-a-dynamic-target.md) — render `*name` and teleport edits back to the owner.
30
+
31
+ ## Composition
32
+
33
+ - [Render a child component](render-a-child-component.md) — `<x render=".field">` and multiple views.
34
+ - [Reuse markup with macros](reuse-markup-with-macros.md) — `macro(...)` with parameters and slots.
35
+
36
+ ## Data & events
37
+
38
+ - [Bind text and attributes](bind-text-and-attributes.md) — `@text`, `:attr`, `$'…'` templates, scope enrichment.
39
+ - [Handle events](handle-events.md) — `@on.<event>`, handler args, modifiers, custom events.
40
+ - [Read a picked file](file-input.md) — `@on.change="… e.target"` and reading `e.target.files`.
41
+
42
+ ## Component communication
43
+
44
+ - [Coordinate components](coordinate-components.md) — addressed `send`/`receive` vs routed `intent` (`dyn` / `lex`).
45
+
46
+ ## Stories & catalog
47
+
48
+ - [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
+ intentHandlers: { 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
+ `intentHandlers` 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).
@@ -0,0 +1,30 @@
1
+ # Bind text and attributes
2
+
3
+ **Problem:** display a field as text, bind it to an attribute, or compose a
4
+ string from several values.
5
+
6
+ ```html
7
+ <!-- text -->
8
+ <span @text=".str"></span> <!-- into a host element -->
9
+ <x text="$getStrUpper"></x> <!-- $ calls a method; no wrapping element -->
10
+
11
+ <!-- attributes: plain = static, :attr = dynamic -->
12
+ <input :value=".str" @on.input="setStr e.value" />
13
+ <a :href=".url" :title="$'Hi {.name}'">link</a> <!-- $'…' string template -->
14
+ <button :class="$'btn btn-{.kind}'">x</button>
15
+
16
+ <!-- derive values for a subtree without putting them on the component -->
17
+ <div @enrich-with="enrichScope">Len: <x text="@len"></x></div>
18
+ ```
19
+
20
+ ```js
21
+ methods: { getStrUpper() { return this.str.toUpperCase(); } },
22
+ alter: { enrichScope() { return { len: this.text.length }; } }, // keys → @len, …
23
+ ```
24
+
25
+ Value slots take `.field`, `$method`, or `@binding` — never a path
26
+ (`.user.name` fails). Multi-word strings **must** be quoted (`'flex gap-3'`) or
27
+ written as a `$'…'` template (`$'btn {.kind}'`); a bare unquoted string returns
28
+ `null`. Boolean HTML attributes (`disabled`, `checked`, …) are auto-recognized
29
+ — pass a boolean field. Scope `@enrich-with` (no `@each` on the element) is the
30
+ path-free way to expose derived values to a subtree.
@@ -0,0 +1,29 @@
1
+ # Conditional attribute value
2
+
3
+ **Problem:** set an attribute (class, title, …) to one value or another
4
+ depending on a condition.
5
+
6
+ ```html
7
+ <button
8
+ @if.class=".isActive"
9
+ @then="'btn btn-success'"
10
+ @else="'btn btn-ghost'"
11
+ @on.click="toggleIsActive"
12
+ >
13
+ toggle
14
+ </button>
15
+ ```
16
+
17
+ `@if.<attr>` takes the condition (a `.field`, a `$method`, or a predicate like
18
+ `equals? .tab 'x'`); `@then`/`@else` are the two values. String literals need
19
+ quotes (`'btn ok'`); a `$'…'` template works too. **Multiple `@if` on one
20
+ element:** every `@then`/`@else` after the first must name its attr
21
+ (`@then.title`, `@else.title`) — HTML forbids duplicate attribute names, so an
22
+ unnamed second `@then` is dropped silently.
23
+
24
+ ```html
25
+ <button
26
+ @if.class=".isActive" @then="'on'" @else="'off'"
27
+ @if.title=".isActive" @then.title="'On'" @else.title="'Off'"
28
+ ></button>
29
+ ```
@@ -0,0 +1,54 @@
1
+ # Coordinate components
2
+
3
+ **Problem:** move state between components — notify an ancestor, message a
4
+ specific component, or run async work and fold in the result.
5
+
6
+ One question picks the channel: **does the sender know who handles this?**
7
+
8
+ ```js
9
+ // send / receive — YES: deliver to one target (self, or ctx.at.<step> for another)
10
+ receive: {
11
+ submit(_draft, ctx) { ctx.at.field("status").send("flash", [this.draft]); },
12
+ flash(draft, message) { draft.message = message; },
13
+ },
14
+
15
+ // intent on the `dyn` leg — NO: walk the ancestors; a handler that replies ends the walk
16
+ receive: { onItemClick(_draft, ctx) { ctx.intent("itemSelected", [this.label], { route: ["dyn"] }); } },
17
+ intent: { itemSelected(draft, label) { draft.log.unshift(label); } },
18
+
19
+ // intent on the `lex` leg — NO: async host work, answered back into state
20
+ receive: {
21
+ init(draft, ctx) { draft.isLoading = true; ctx.intent("loadData", [], { route: ["lex"] }); },
22
+ loadDataOk(draft, items) { draft.isLoading = false; draft.items = items; },
23
+ loadDataError(draft, err) { draft.isLoading = false; draft.error = String(err); },
24
+ loadDataUnhandled(draft) { draft.isLoading = false; },
25
+ },
26
+ ```
27
+
28
+ **send/receive** addresses one known component (`ctx.at.field("x")` /
29
+ `.index(name, i)` / `.key(name, k)`, default self) and stops there.
30
+ **intent** names a job and walks a *route* until something answers: `["dyn"]`
31
+ up the ancestors (aggregate state — logs, selections), `["lex"]` the handlers
32
+ registered with `scope.registerIntentHandlers({...})` (fetch, timer,
33
+ IndexedDB), or the default `["dyn","lex"]` for both.
34
+
35
+ The verb no longer decides which scope answers — the route does, and it is
36
+ written at the call site where the decision actually is.
37
+
38
+ An intent answers in three named ways, each with **one** payload:
39
+ `<name>Ok`, `<name>Error`, `<name>Unhandled` (the route ran out; it carries the
40
+ intent's own args). They arrive as ordinary `receive` messages, and declaring
41
+ them is what makes an intent a *request* rather than a *notification* — declare
42
+ none and the outcome is dropped, which is the idiomatic fire-and-forget shape.
43
+
44
+ A handler that runs without calling `ctx.reply` is an **observer**: the walk
45
+ goes on. A `ctx.reply` ends it. That one rule is why there is no separate
46
+ listener bucket.
47
+
48
+ `ctx` is always the trailing arg. `receive.init` is a convention, not a
49
+ lifecycle hook — dispatch it with `app.sendAtRoot("init")`.
50
+
51
+ Carry the most granular payload across the channel, not whole objects you
52
+ won't use — `ctx.intent("itemSelected", [item.label], …)` over passing the
53
+ entire component (same reasoning as handler args: [testing.md](../testing.md)
54
+ *Designing handlers so tests stay simple*).
@@ -0,0 +1,27 @@
1
+ # Edit through a dynamic target
2
+
3
+ **Problem:** render a value owned by a distant ancestor *and* let edits made in
4
+ the child land back on the owner — without forwarding events up by hand.
5
+
6
+ ```js
7
+ // producer exposes a field (or a seq-access) as a dynamic
8
+ const Workspace = component({
9
+ name: "Workspace",
10
+ fields: { sheet: null },
11
+ provide: { active: ".sheet" }, // or ".items[.selectedKey]"
12
+ });
13
+
14
+ // a distant consumer renders it as a target
15
+ const Toolbar = component({
16
+ name: "Toolbar",
17
+ lookup: { active: { for: "Workspace.active", default: ".missing" } },
18
+ view: html`<x render="*active" as="edit"></x>`,
19
+ });
20
+ ```
21
+
22
+ Because `*active` resolves to a real **path** (not a copied value), the event
23
+ fired inside the rendered child is *teleported*: the mutation skips the
24
+ intermediate components and lands on `Workspace.sheet`, so the owner and any
25
+ other view of the same value update in lock-step. A `provide` can even point at
26
+ a seq-access (`.items[.selectedKey]`) to expose "the selected item". This is
27
+ the **edit** counterpart of the share-state-across-the-tree recipe.
@@ -0,0 +1,25 @@
1
+ # Enrich each item
2
+
3
+ **Problem:** show a value derived from each item (a count, a formatted label)
4
+ without storing it on the data.
5
+
6
+ ```html
7
+ <li @each=".items" @enrich-with="enrichItem">
8
+ <x text="@value"></x> (<x text="@count"></x> characters)
9
+ </li>
10
+ ```
11
+
12
+ ```js
13
+ alter: {
14
+ enrichItem(binds, _key, item) {
15
+ binds.count = item.length; // becomes @count in the template
16
+ },
17
+ }
18
+ ```
19
+
20
+ `@enrich-with` receives a **mutable** `binds` object (seeded with `{ key,
21
+ value }`); every key you set becomes an `@`-prefixed binding for that item's
22
+ subtree. The return value is ignored. Combine freely with `@when` and
23
+ `@loop-with` on the same element. Without an `@each` on the same element,
24
+ `@enrich-with` enriches the whole scope instead (see the bind-text-and-attributes
25
+ recipe).