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,39 @@
1
+ # Read a picked file
2
+
3
+ **Problem:** let the user pick a file and show its metadata.
4
+
5
+ ```js
6
+ component({
7
+ name: "FilePicker",
8
+ fields: { name: "", size: 0, type: "", hasFile: false },
9
+ receive: {
10
+ // e.target is the <input> node; the File is on e.target.files
11
+ onPickFile(draft, target) {
12
+ const file = target.files?.[0];
13
+ draft.hasFile = !!file;
14
+ if (!file) return;
15
+ draft.name = file.name;
16
+ draft.size = file.size;
17
+ draft.type = file.type;
18
+ },
19
+ },
20
+ view: html`<section>
21
+ <input type="file" @on.change="onPickFile e.target" />
22
+ <p @hide=".hasFile">No file selected yet.</p>
23
+ <dl @show=".hasFile">
24
+ <dt>Name</dt><dd @text=".name"></dd>
25
+ <dt>Size</dt><dd @text=".size"></dd>
26
+ <dt>Type</dt><dd @text=".type"></dd>
27
+ </dl>
28
+ </section>`,
29
+ });
30
+ ```
31
+
32
+ Pass `event` (not `value`) to the handler: for a file input `value` is just the
33
+ fake `C:\fakepath\…` string, while `event.target.files` holds the real `File`
34
+ objects. The metadata (`name`/`size`/`type`/`lastModified`) is available
35
+ synchronously; the *contents* are not — read those with the async `File` API
36
+ (`file.text()`, `file.arrayBuffer()`) and feed the result back in through a
37
+ an `intent` or a follow-up `send`. Flatten what you need into fields so
38
+ the view can bind each piece; gate the summary on a `hasFile` flag with
39
+ `@show`/`@hide`.
@@ -0,0 +1,25 @@
1
+ # Filter a list
2
+
3
+ **Problem:** render only the items that match a condition.
4
+
5
+ ```html
6
+ <li @each=".items" @when="filterItem">
7
+ <span @text="@key"></span>: <x text="@value"></x>
8
+ </li>
9
+ <!-- on <x render-each> the same directive applies: @when="filterItem" -->
10
+ ```
11
+
12
+ ```js
13
+ alter: {
14
+ filterItem(_key, item) {
15
+ return item.toLowerCase().includes(this.query.toLowerCase());
16
+ },
17
+ }
18
+ ```
19
+
20
+ `@when` names an `alter` handler called per item as `(key, value, iterData)`;
21
+ return `false` to skip. It filters *after* any `@loop-with` slice, so a page
22
+ can yield fewer than its window. Filtering reads other fields off `this`
23
+ directly (`this.query`) — there are no paths in the template. To filter
24
+ *before* paging, return `keys` from `@loop-with` instead — see
25
+ [filter-and-paginate.md](filter-and-paginate.md).
@@ -0,0 +1,60 @@
1
+ # Filter and paginate a list
2
+
3
+ **Problem:** show one page of the items that match a query — filtering
4
+ *before* paging, so page counts reflect the filtered total and a row's
5
+ identity survives editing or deleting across pages — without scanning the
6
+ list more than necessary.
7
+
8
+ ```html
9
+ <section @enrich-with="pageInfo"> <!-- COUNT pass: runs once -->
10
+ <input :value=".query" @on.input="search e.value" />
11
+ <li @each=".items" @when="onlyMatches" @loop-with="page"> <!-- COLLECT pass -->
12
+ <span @text="@key"></span> <x render-it></x>
13
+ <button @on.click="removeInItemsAt @key">✕</button>
14
+ </li>
15
+ <button :disabled="@isFirst" @on.click="prev">‹</button>
16
+ <button @text="@pageLabel"></button>
17
+ <button :disabled="@isLast" @on.click="next">›</button>
18
+ </section>
19
+ ```
20
+
21
+ ```js
22
+ methods: { matchCount() { /* one scan: how many match this.query */ } },
23
+ alter: {
24
+ onlyMatches(_key, p) { return matches(p, this.query); }, // the predicate
25
+ pageInfo() { // scope enrich: the COUNT scan
26
+ const total = this.matchCount();
27
+ const { pageCount, currentPage } = clamp(this.page, total, this.pageSize);
28
+ return { currentPage, isFirst: currentPage <= 0, isLast: currentPage >= pageCount - 1,
29
+ pageLabel: `Page ${currentPage + 1} of ${pageCount} · ${total}` };
30
+ },
31
+ page(seq, { lookup, filter }) { // @loop-with: the COLLECT scan
32
+ const start = lookup("currentPage") * this.pageSize, end = start + this.pageSize;
33
+ const keys = [];
34
+ let m = 0;
35
+ for (let i = 0; i < seq.size && m < end; i++) // early-exit: stops at page end
36
+ if (filter(i, seq.get(i))) { if (m >= start) keys.push(i); m++; }
37
+ return { keys };
38
+ },
39
+ },
40
+ ```
41
+
42
+ Returning **`keys`** (ordered *original* keys) is what makes this work: the
43
+ renderer visits exactly those and does **not** re-apply `@when`, and because
44
+ `@key` stays the original index, deleting row `@key` on page 2 of a filtered
45
+ view hits the right item. The page controls live *outside* the loop, so they
46
+ can't read its `iterData`; instead a scope `@enrich-with` does the one counting
47
+ scan and publishes the clamped page + labels as `@`-bindings. The `@loop-with`
48
+ handler's `ctx` lets it avoid repeating that work: `ctx.lookup` reads the
49
+ clamped page the enrich already computed, and `ctx.filter` reuses the declared
50
+ `@when` predicate — so the collect pass scans just far enough to fill the page.
51
+ Reset `page` to 0 when the query changes.
52
+
53
+ This is one of three wiring strategies (naive two-scan, shared, coupled
54
+ one-scan) — the trade-offs and the other two are in
55
+ [iteration.md](../iteration.md) *Filter-then-paginate strategies*. Test
56
+ whichever wiring with `collectIterBindings` — pass
57
+ `{ when, loopWith, scopeEnrich }` and assert on the returned keys; see
58
+ [testing.md](../testing.md) *Testing iteration handlers*. See
59
+ [filter-a-list.md](filter-a-list.md) and
60
+ [paginate-a-list.md](paginate-a-list.md) for each half on its own.
@@ -0,0 +1,44 @@
1
+ # Handle events
2
+
3
+ **Problem:** respond to a DOM event and update state.
4
+
5
+ ```html
6
+ <button @on.click="inc">+</button>
7
+ <button @on.click="dec">-</button> <!-- bare name = receive handler -->
8
+
9
+ <!-- pass args by name; ctx is auto-appended last -->
10
+ <input @on.input="setStr e.value" />
11
+ <input @on.input="setN e.valueAsInt" />
12
+ <button @on.click="addItem JsonSelector">+</button>
13
+
14
+ <!-- guards: keydown +send (Enter) / +cancel (Esc), and +ctrl/+cmd/+alt -->
15
+ <input @on.keydown+send="submit e.value" @on.keydown+cancel="reset" />
16
+
17
+ <!-- effects on any event: +prevent, +stop (applied only if the guards passed) -->
18
+ <form @on.submit+prevent="save">…</form>
19
+
20
+ <!-- custom elements: any CustomEvent reaches @on.<name>, detail is `e.value` -->
21
+ <emoji-picker @on.emoji-click="onPick e.value"></emoji-picker>
22
+ ```
23
+
24
+ Event handlers are entries in `receive`: Tutuca passes an Immer draft first,
25
+ the written arguments next, and `ctx` last. Returning nothing commits draft
26
+ changes; returning another component swaps the current component. The first
27
+ slot in `@on.*` is always a bare receive name; `$method` is rejected. Later slots always carry a sigil — event-member reads (`e.value`,
28
+ `e.key`, `e.altKey`, `e.target`, dotted paths like `e.target.dataset.slot`
29
+ or `e.detail.x`, null-safe at every link), one-level computed conveniences
30
+ (`e.valueAsInt`, `e.isCtrl` mac-aware, and on drags `e.dragInfo`/
31
+ `e.dragKey`/`e.dragValue`/`e.dragType`), state fields (`.field`), bindings
32
+ (`@bind`), methods (`$m`), dynamics (`*dyn`). A sigil-less word fails to
33
+ parse.
34
+ `e.value` normalizes the read: `.checked` for a checkbox, `detail` for a
35
+ `CustomEvent`, otherwise `target.value`. Bind events declaratively with `@on.`
36
+ rather than reaching for the node and `addEventListener` — an outside listener
37
+ bypasses the transactor.
38
+
39
+ Pass the most granular arg the handler needs — `e.value`/`e.valueAsInt`/`e.key`,
40
+ not the raw event object — so tests call it with plain literals; reach for
41
+ `e.target` only when nothing narrower fits (e.g. a file input reading
42
+ `e.target.files`).
43
+ Why this keeps tests simple: [testing.md](../testing.md) *Designing handlers so
44
+ tests stay simple*.
@@ -0,0 +1,18 @@
1
+ # Iterate a list
2
+
3
+ **Problem:** render one element per item in a list/map field.
4
+
5
+ ```html
6
+ <!-- a host element per item: @key and @value are bound in the loop -->
7
+ <li @each=".items"><span @text="@key"></span>: <x text="@value"></x></li>
8
+
9
+ <!-- a child component per item -->
10
+ <x render-each=".items"></x>
11
+ <div @each=".items"><x render-it></x></div>
12
+ ```
13
+
14
+ `@each` accepts a `.field` or a `*dynamic` (not a `$method` — a method result
15
+ has no addressable path for event dispatch). `@key`/`@value` are auto-bound on
16
+ host-element loops; under `render-each` / `render-it` each item is rendered as
17
+ its own component (no `@value`). Use `render-each` for lists of components,
18
+ `@each` for plain values.
@@ -0,0 +1,29 @@
1
+ # Paginate a list
2
+
3
+ **Problem:** show one page at a time without iterating or rendering the
4
+ off-page items.
5
+
6
+ ```html
7
+ <li @each=".items" @loop-with="paginate">
8
+ <span class="badge" @text="@key"></span> <x text="@value"></x>
9
+ </li>
10
+ ```
11
+
12
+ ```js
13
+ fields: { items: [], page: 0, pageSize: 5 },
14
+ alter: {
15
+ paginate(seq) { // runs once per render, before iteration
16
+ const start = this.page * this.pageSize;
17
+ return { iterData: { total: seq.size }, start, end: start + this.pageSize };
18
+ },
19
+ }
20
+ ```
21
+
22
+ `@loop-with` returns `{ iterData?, start?, end? }`, all optional. `start`/`end`
23
+ slice with `Array.prototype.slice` semantics (`end` exclusive, negatives count
24
+ from the end). Slicing is positional but **preserves each item's original
25
+ key** — `@key` is the index in the full list, so events and two-way binding
26
+ keep their identity across pages. `iterData` is the shared per-loop value
27
+ handed to `@when` / `@enrich-with`. To paginate a *filtered* list, return
28
+ `keys` instead of `start`/`end` — see
29
+ [filter-and-paginate.md](filter-and-paginate.md).
@@ -0,0 +1,21 @@
1
+ # Render a child component
2
+
3
+ **Problem:** a component holds another component in a field and wants to render
4
+ it (reaching into nested data is not allowed — `@text=".child.name"` fails).
5
+
6
+ ```js
7
+ fields: { greeting: Greeting.make({ name: "world" }) },
8
+ ```
9
+
10
+ ```html
11
+ <x render=".greeting"></x> <!-- default ("main") view -->
12
+ <x render=".greeting" as="edit"></x> <!-- a named view -->
13
+ <x render=".greeting" as=".mode"></x> <!-- view chosen by a field at runtime -->
14
+ ```
15
+
16
+ The child draws its own view from its own fields, so inside `Greeting`'s view
17
+ `@text=".name"` reads the child's `name`. This is the idiomatic way to display
18
+ nested structure: make the nested thing a component and render it, rather than
19
+ trying to path into it. For a list of children use `render-each` (see the
20
+ iterate-a-list recipe); to flip which view renders, see the switch-between-views
21
+ recipe.
@@ -0,0 +1,36 @@
1
+ # Reuse markup with macros
2
+
3
+ **Problem:** the same markup fragment repeats across a view and you want one
4
+ definition — but it has no state of its own.
5
+
6
+ ```js
7
+ import { macro, html } from "tutuca";
8
+
9
+ const badge = macro(
10
+ { label: "'New'", kind: "'info'" }, // defaults are *expressions*
11
+ html`<span :class="$'badge badge-{^kind}'" @text="^label"></span>`,
12
+ );
13
+
14
+ const card = macro(
15
+ { title: "'Card'" },
16
+ html`<div class="card"><h2 @text="^title"></h2><x:slot></x:slot></div>`,
17
+ );
18
+
19
+ export function getMacros() { return { badge, card }; }
20
+ ```
21
+
22
+ ```html
23
+ <x:badge></x:badge> <!-- defaults -->
24
+ <x:badge label="Sale"></x:badge> <!-- static string (no quotes needed) -->
25
+ <x:badge :label=".status"></x:badge> <!-- bind a field -->
26
+ <x:card title="Hi"><p>body</p></x:card> <!-- children fill <x:slot> -->
27
+ ```
28
+
29
+ A macro is pure template expansion — no fields, no handlers. Parameters are
30
+ read as `^name`; calls inside the body (`$method`, `.field`) resolve against
31
+ the *host* component. `<x:slot>` (or `<x:slot name="…">` for named slots)
32
+ receives the caller's children. Register with `scope.registerMacros(...)`;
33
+ registry keys are lowercased (`<x:Card>` → `card`). Full semantics (named
34
+ slots, quoting of parameter values) in [macros.md](../macros.md). For repeated
35
+ markup that *does* need state, use a child component instead (see the
36
+ render-a-child-component recipe).
@@ -0,0 +1,38 @@
1
+ # Share state across the tree
2
+
3
+ **Problem:** a deep descendant needs a value owned by a distant ancestor, and
4
+ you don't want to thread it through every component in between.
5
+
6
+ > **Reach for this last.** Keep state local to the component and use
7
+ > `provide` / `lookup` only when it is genuinely the only solution — a value
8
+ > owned far away that a deep descendant needs and nothing in between should
9
+ > know about. Dynamic bindings couple a consumer to a producer that may not be
10
+ > in scope, so keep components as self-contained as possible: let a child
11
+ > render the field it needs from its owner, and lift state only as far up the
12
+ > tree as it needs to live.
13
+
14
+ ```js
15
+ // producer — exposes one of its fields under a name
16
+ const Producer = component({
17
+ name: "EntryEditorAndSelector",
18
+ fields: { items: [] },
19
+ provide: { entries: ".items" },
20
+ });
21
+
22
+ // consumer — forwards to the producer's binding by "Component.name"
23
+ const Consumer = component({
24
+ name: "Selector",
25
+ lookup: { entries: { for: "EntryEditorAndSelector.entries", default: ".items" } },
26
+ });
27
+ ```
28
+
29
+ ```html
30
+ <!-- read the dynamic with the * prefix — iterate or render it -->
31
+ <option @each="*entries" :value="@value" @text="@label"></option>
32
+ ```
33
+
34
+ `provide` publishes a field under a name; a descendant's `lookup` resolves
35
+ `*name` to the nearest matching producer, falling back to `default` when none
36
+ is in scope. `*name` works wherever a `.field` does for iteration/rendering.
37
+ This is the **read** side; to edit the producer's value through the dynamic,
38
+ see the edit-through-a-dynamic-target recipe.
@@ -0,0 +1,23 @@
1
+ # Show or hide content
2
+
3
+ **Problem:** render an element only when a condition holds.
4
+
5
+ ```html
6
+ <div @show=".isOpen">Details</div>
7
+ <p @hide=".isOpen">(hidden when open)</p>
8
+
9
+ <!-- boolean predicates for one-field checks -->
10
+ <p @show="empty? .items">No results</p>
11
+ <p @show="truthy? .query">Searching…</p>
12
+ <div @show="equals? .view 'detail'">detail view</div>
13
+
14
+ <!-- on an <x> render op: wraps the produced node, no extra DOM element -->
15
+ <x text=".count" @show=".isOpen"></x>
16
+ ```
17
+
18
+ The closed set of predicates is `empty?`, `truthy?`, `falsy?`, `null?`,
19
+ `equals?` (binary) — semantics in [core.md](../core.md) *Conditional Display*.
20
+ For a condition spanning multiple fields, use a no-arg method instead
21
+ (`@show="$canSubmit"`). `@show`/`@hide` toggle visibility on a host element;
22
+ the wrapper form (`show=` / `hide=` on `<x>`) conditionally emits the node
23
+ with no surrounding element.
@@ -0,0 +1,30 @@
1
+ # Switch between views
2
+
3
+ **Problem:** render the *same* component in a different view (e.g. a read-only
4
+ "main" vs an "edit" form).
5
+
6
+ ```js
7
+ component({
8
+ view: html`<p @text=".title"></p>`, // "main"
9
+ views: { edit: html`<input :value=".title" @on.input="setTitle e.value" />` },
10
+ });
11
+ ```
12
+
13
+ ```html
14
+ <!-- as= picks the view for one <x render> element only -->
15
+ <x render=".value"></x>
16
+ <x render=".value" as="edit"></x>
17
+ <x render=".value" as=".mode"></x> <!-- view chosen by a field at runtime -->
18
+
19
+ <!-- @push-view forces a view on every component rendered under the host -->
20
+ <div @push-view=".view"><x render-each=".items"></x></div>
21
+ ```
22
+
23
+ `as` applies to the direct component only and falls back to `main` if the view
24
+ is absent. It takes the same value forms as `@push-view` — a literal name
25
+ (`as="edit"`) or a dynamic value (`as=".mode"`, `*dyn`, `@bind`, `$method`,
26
+ `$'…'`), evaluated against the host component at render time (for `render-each`,
27
+ once for all items). `@push-view` instead pushes a view name onto the render
28
+ stack so every descendant picks the first matching view (else `main`) — use it
29
+ to flip a whole subtree (e.g. a list) into edit mode at once. To toggle
30
+ *sibling panels* by a field instead, see the tabbed-interface recipe.
@@ -0,0 +1,43 @@
1
+ # Tabbed interface
2
+
3
+ **Problem:** build tabs — a single `currentView` field decides which panel
4
+ shows, and the active tab button is highlighted.
5
+
6
+ ```html
7
+ <div role="tablist" class="tabs">
8
+ <button
9
+ role="tab"
10
+ @if.class="equals? .currentView 'overview'"
11
+ @then="'tab tab-active'"
12
+ @else="'tab'"
13
+ @on.click="selectView 'overview'"
14
+ >Overview</button>
15
+ <button
16
+ role="tab"
17
+ @if.class="equals? .currentView 'pricing'"
18
+ @then="'tab tab-active'"
19
+ @else="'tab'"
20
+ @on.click="selectView 'pricing'"
21
+ >Pricing</button>
22
+ </div>
23
+
24
+ <div @show="equals? .currentView 'overview'">…overview…</div>
25
+ <div @show="equals? .currentView 'pricing'">…pricing…</div>
26
+ ```
27
+
28
+ ```js
29
+ fields: { currentView: "overview" },
30
+ receive: {
31
+ selectView(draft, view) {
32
+ draft.currentView = view;
33
+ },
34
+ },
35
+ ```
36
+
37
+ One string field is the whole state machine. `equals? .currentView 'overview'`
38
+ drives both the panel's `@show` and the active-tab class via `@if.class` /
39
+ `@then` / `@else`. Tab clicks call the draft recipe with a string-literal
40
+ argument (`@on.click="selectView 'pricing'"`). This toggles
41
+ **sibling panels** by predicate; to swap a *component's own* rendered view
42
+ instead, see the switch-between-views recipe. The field name is yours to pick
43
+ (`tab`, `currentView`, …).
@@ -0,0 +1,195 @@
1
+ # Tutuca — Runtime Semantics (paths · transactions · dispatch)
2
+
3
+ How a click becomes a state mutation, and what survives across async.
4
+ Read this when reasoning about **why** a handler ran where it did,
5
+ debugging a dispatch or async-timing bug, or changing `src/path.js` /
6
+ `src/transactor.js`. Not needed for ordinary component authoring — for
7
+ that start at [core.md](./core.md).
8
+
9
+ The step and transaction names below are the ones in the source; confirm
10
+ behavior against `src/path.js` / `src/transactor.js` (or grep the
11
+ `tutuca-source` skill) rather than trusting this doc when they disagree.
12
+
13
+ ## State & identity (in one paragraph)
14
+
15
+ The application is a single immutable root value; the view is a pure
16
+ function of it; every handler takes the old self and returns a new self,
17
+ and the transactor swaps the root atomically. Updating a deep child
18
+ produces a new root that shares structure with the old one along the
19
+ unchanged spine, so the renderer's `===`-keyed cache skips untouched
20
+ subtrees. Full version: *Mental model* in [core.md](./core.md).
21
+
22
+ ## Paths are positional addresses
23
+
24
+ A `Path` is an array of `Step`s from the root to the value a handler runs
25
+ against — a **position**, not a captured reference (see *Paths, not
26
+ references* in [core.md](./core.md)). The step kinds:
27
+
28
+ | Step | Addresses | Source syntax |
29
+ | ------------------- | ---------------------------------- | ------------------------ |
30
+ | `FieldStep` | a named field | `.field` |
31
+ | `SeqStep` | a sequence entry by **literal** key/index | `.items[2]` |
32
+ | `SeqAccessStep` | a sequence entry whose key is **read from another field** | `.sheets[.selId]` |
33
+ | `EachRenderItStep` | an iterated `render-it` item | `<x render-it>` per iter |
34
+ | `DynStep` / `DynEachStep` | a dynamic-var (`*x`) render target — a teleport marker | `<x render="*x">` |
35
+ | `BindStep` / `EachBindStep` | nothing — frame-only (carry scope binds, no addressing) | `@each`, `@enrich-with` |
36
+
37
+ `SeqAccessStep` is the important one for async correctness: it stores the
38
+ field *names* `seqField` and `keyField`, and resolves the key from the
39
+ live data each time it runs — see *Key resolution & async races* below.
40
+
41
+ ### Two derived paths
42
+
43
+ The reconstructed path is transformed two ways depending on use:
44
+
45
+ - **`compact()` → the dispatch path.** Drops frame-only steps, keeps one
46
+ step per crossed component (including `DynStep`s). `popStep()` over it
47
+ walks every component. Used to drive `ctx.send` / `ctx.intent` and to
48
+ locate handlers.
49
+ - **`toTransactionPath()` → the transaction path.** Teleports every
50
+ `DynStep` (drops the steps interior to its producer..consumer span and
51
+ splices in the producer's own steps) so a mutation lands on the data's
52
+ real location. A path with no `DynStep` is returned unchanged. Used by
53
+ `lookup` / `setValue` to read and write state.
54
+
55
+ ## Reconstructing a path from the DOM
56
+
57
+ The DOM is the only thing that survives between render and click, so the
58
+ renderer leaves breadcrumbs: `data-cid` / `data-nid` / `data-eid` on
59
+ elements, and `§…§` comment "metas" adjacent to component boundaries,
60
+ iteration entries, and scope boundaries (loop-less `@enrich-with`, so
61
+ their custom binds can be replayed). On an event, `Path.fromNodeAndEventName` walks from the
62
+ target up to the root, reads the breadcrumbs, and rebuilds the path. Along
63
+ the way it resolves the handler: normally on the **leaf** component, but
64
+ for DOM-bubbling events it can resolve on an
65
+ **ancestor**, in which case the descending steps below that ancestor are
66
+ dropped so the path resolves to the ancestor's value.
67
+
68
+ ## The transaction lifecycle
69
+
70
+ Each dispatch is a `Transaction`. The `Transactor` holds a FIFO queue;
71
+ `App` drains it in time-budgeted batches on a `setTimeout(…, 0)` (see
72
+ `src/app.js`), so transactions complete **asynchronously and interleaved**
73
+ — which is exactly why an intent's answer can land after other
74
+ transactions have rebuilt the root.
75
+
76
+ The core of applying one is `Transaction.updateRootValue`:
77
+
78
+ ```js
79
+ const txnPath = this.getTransactionPath(); // toTransactionPath(), or a pinned path
80
+ const curLeaf = txnPath.lookup(curRoot); // read the addressed value NOW
81
+ const newLeaf = this.callHandler(curRoot, curLeaf, comps); // old self → new self
82
+ return curLeaf !== newLeaf ? txnPath.setValue(curRoot, newLeaf) : curRoot;
83
+ ```
84
+
85
+ The root swap is atomic and identity-cheap: unchanged subtrees keep their
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.
90
+
91
+ ## Dispatch channels, semantically
92
+
93
+ The authoring API (`ctx.send` / `ctx.intent`, the handler blocks) is in
94
+ [messages-and-intents.md](./messages-and-intents.md). Underneath there are two
95
+ channels, and one question separates them: **does the sender know who handles
96
+ this?**
97
+
98
+ | Channel | Transaction | Notes |
99
+ | --- | --- | --- |
100
+ | DOM event → `receive` | `InputEvent` | transacted **synchronously** (`transactInputNow`), not queued; resolves its handler from the compiled view |
101
+ | `ctx.send` → `receive` | `SendEvent` | queued; addressed at one component and stops there |
102
+ | an intent's answer → `receive` | `SendEvent` | queued; named `<name>Ok` / `<name>Error` / `<name>Unhandled`, and indistinguishable from any other message |
103
+ | `ctx.intent` → `intent` | `IntentEvent` per `dyn` hop; no transaction for a `lex` hop | queued; the walk state lives in `IntentWalk`, shared **by reference** across hops |
104
+
105
+ The `dyn` leg is walking up the dispatch path one `popStep` at a time, starting
106
+ at the sender's **parent**. `targetPath` (the originator's path) stays fixed as
107
+ `path` shortens, so a hop can reach the originator via
108
+ `ctx.sendAtPath(ctx.targetPath, …)`.
109
+
110
+ The walk object is shared across hops on purpose: that is what makes the
111
+ one-shot **per intent** rather than per hop, so a second `ctx.reply` — from this
112
+ handler or one three hops up — finds the walk already ended.
113
+
114
+ ## Dynamic-var teleporting
115
+
116
+ A component rendered through `<x render="*sel">` *physically lives* at the
117
+ producer that declared `provide: { sel: … }`, not under the consumer that
118
+ wrote the render. The reconstructed dispatch path keeps every intermediate
119
+ component (so a walk visits them), but `toTransactionPath()` teleports
120
+ the `DynStep`: it pops the steps tagged with the marker's `interiorCids`
121
+ and splices in the producer's own steps (`DynStep.teleportSteps()`). The
122
+ mutation therefore lands on the producer's data, and the consumer's view
123
+ of it updates in lock-step. Authoring view: *Teleporting* in
124
+ [advanced.md](./advanced.md).
125
+
126
+ When the producer's `provide` value is a seq-access (`.sheets[.selId]`),
127
+ the teleported steps include a `SeqAccessStep` — which is where async key
128
+ races come from.
129
+
130
+ ## Key resolution & async races
131
+
132
+ A `SeqAccessStep` resolves `keyField` from the live root **every time it
133
+ runs**. For synchronous dispatch this is invisible — the key cannot change
134
+ mid-transaction. For an async intent it is the whole problem: between
135
+ raising the intent and applying its answer, the key may move (e.g. the
136
+ user switches the selected tab, so `.selId` changes), and a naive
137
+ re-resolution would deliver the answer to **whatever item is selected
138
+ now**, not the one that raised the intent.
139
+
140
+ **Key pinning is the default.** `pushIntent` snapshots the resolved key
141
+ at dispatch time by running `Path.pinKeys(curRoot)` over the transaction
142
+ path — each `SeqAccessStep(seq, keyField)` becomes a literal
143
+ `SeqStep(seq, resolvedKey)`. The pinned path is stored on the
144
+ `IntentWalk`, so the answer updates the item that raised the intent
145
+ regardless of later key changes. (Pinning runs on the transaction path,
146
+ after teleporting, because the `SeqAccessStep` may have come from a
147
+ `DynStep`.)
148
+
149
+ **Opt out per intent with `livePath: true`:**
150
+
151
+ ```js
152
+ ctx.intent("save", [payload], { route: ["lex"], livePath: true }); // re-resolve live
153
+ ```
154
+
155
+ With `livePath`, the answer re-evaluates the key at apply time — the old
156
+ "follow the latest selection" behavior. Use it only when the answer is
157
+ *meant* to follow wherever the key now points.
158
+
159
+ Edge cases:
160
+
161
+ - **Pinned target deleted before the answer arrives** — the pinned
162
+ `SeqStep` resolves to nothing, the handler runs against a null leaf, and
163
+ the result equals the input → a safe no-op (root unchanged). With
164
+ `livePath` it would instead hit the current item.
165
+ - **The `EventContext` path stays live (un-pinned).** An answer arm that
166
+ itself re-dispatches via `ctx.send` / `ctx.intent` re-resolves
167
+ against current state — pinning covers the *update*, not nested
168
+ re-dispatch.
169
+
170
+ ## What "positional delivery" guarantees
171
+
172
+ Because a path is a position, an async response survives intervening
173
+ transactions that rebuild the root — but "the right slot" means different
174
+ things per step kind:
175
+
176
+ - **`SeqAccessStep` (`.seq[.key]`)** — the key is **pinned by default**, so
177
+ the response reaches the entry that issued the request even if the key
178
+ field moved. Opt out with `livePath: true`.
179
+ - **`SeqStep` with a list index (`.items[3]`)** — the index is literal and
180
+ **not** pinned to identity: if the list re-sorted or an item was inserted
181
+ ahead of it, index 3 is now a different item and the response lands
182
+ there. Anchor on **map keys**, not list indices, when an async result
183
+ must reach a specific item.
184
+ - **`FieldStep`** — a named field is stable; no ambiguity.
185
+
186
+ ## See also
187
+
188
+ - [core.md](./core.md) — *Mental model* and *Paths, not references* (the
189
+ high-level invariants this file expands on), `view` directives, handler
190
+ blocks.
191
+ - [messages-and-intents.md](./messages-and-intents.md) — the dispatch **API**:
192
+ addressed `send`-`receive` vs routed `intent`, `ctx.at`, `$unknown`,
193
+ request-handler registration, and the `livePath` request option.
194
+ - [advanced.md](./advanced.md) — dynamic bindings (`*x`) and the authoring
195
+ view of teleporting.