tutuca 0.9.79 → 0.9.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -168,7 +168,7 @@ npx tutuca install-skill --dot-agents
168
168
  npx tutuca install-skill --force
169
169
  ```
170
170
 
171
- The skill content is generated from `docs/llm/`, so the same reference
171
+ The skill content is generated from `docs/skill/`, so the same reference
172
172
  runs locally (`tutuca lint <module>` + `tutuca render <module> --title …`)
173
173
  and inside Claude.
174
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.79",
3
+ "version": "0.9.80",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SPA framework with immutable state and virtual DOM",
6
6
  "main": "./dist/tutuca.js",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: tutuca
3
- description: Use when authoring or reviewing tutuca modules — `component({...})` definitions, `html\`...\`` views, `@`-directives, `input` / `bubble` / `receive` / `response` / `alter` handlers, macros, or `getTests` exports — or when running the `tutuca` CLI (`lint` / `test` / `render` / `docs`). Covers the post-edit `tutuca <module> lint` → `test` → `render --title "<example>"` verification recipe.
3
+ description: Use when authoring or reviewing tutuca modules — `component({...})` definitions, `html`...`` views, `@`-directives, `input` / `bubble` / `receive` / `response` / `alter` handlers, macros, or `getTests` exports — or when running the `tutuca` CLI (`lint` / `test` / `render` / `docs`). Covers the post-edit `tutuca <module> lint` → `test` → `render --title "<example>"` verification recipe.
4
4
  ---
5
5
 
6
- <!-- This file is generated by scripts/build-skill.js from docs/llm/. Do not edit. -->
6
+ <!-- Source of truth: docs/skill/. The skill/tutuca/ copy is generated by scripts/build-skill.js edit docs/skill/, not skill/. -->
7
7
 
8
8
  # Tutuca
9
9
 
@@ -27,12 +27,13 @@ When authoring tutuca code, also load these if available:
27
27
 
28
28
  | Task | File |
29
29
  | ---------------------------------------------------------------------------------------------- | ------------------------------- |
30
- | Authoring `component({...})`, `html\`...\`` views, macros, fields, events, lists, styles | [core.md](./core.md) |
30
+ | Authoring `component({...})`, `html`...`` views, macros, fields, events, lists, styles | [core.md](./core.md) |
31
31
  | CLI commands, flags, exit codes, full linter rule list | [cli.md](./cli.md) |
32
32
  | `bubble` / `send`-`receive` / async `request`-`response` channels, `$unknown`, request-handler registration | [request-response.md](./request-response.md) |
33
33
  | Drag & drop, dynamic bindings (`*x`), pseudo-`x`, custom seq types, Tailwind/MargaUI | [advanced.md](./advanced.md) |
34
34
  | Runtime semantics — path steps, transaction lifecycle, dyn-var teleporting, async key pinning (`livePath`) | [semantics.md](./semantics.md) |
35
35
  | Authoring tests — `getTests` shape, calling methods/input/receive/bubble/response/alter handlers, designing handlers for testability | [testing.md](./testing.md) |
36
+ | Task-oriented recipes — iteration, filtering, conditional content, conditional attributes, dynamic vars, composition, events | [patterns/README.md](./patterns/README.md) |
36
37
 
37
38
  Read `core.md` first. Reach for the others only when the task touches
38
39
  them — each is referenced inline from `core.md` so you'll be pointed
@@ -19,7 +19,9 @@ the `tutuca` CLI.
19
19
  > [testing.md](./testing.md). Runtime semantics — path steps, the
20
20
  > transaction lifecycle, dyn-var teleporting, and async key pinning
21
21
  > (`livePath`): see [semantics.md](./semantics.md). Read those only when
22
- > the task touches them.
22
+ > the task touches them. Task-oriented "how do I do X" recipes (iteration,
23
+ > filtering, slicing, conditional content, conditional attributes, dynamic
24
+ > vars, composition, events, …): see [patterns/README.md](./patterns/README.md).
23
25
 
24
26
  ## Verifying changes
25
27
 
@@ -987,3 +989,6 @@ export function getTests({ describe, test, expect }) { /*...*/ } // optiona
987
989
  convention for tests.
988
990
  - [cli.md](./cli.md) — commands, flags, exit codes, and the full linter rule
989
991
  list.
992
+ - [patterns/README.md](./patterns/README.md) — task-oriented recipes ("how do I
993
+ iterate / filter / paginate / show-hide / build tabs / share state / …"),
994
+ each linking back here and to a runnable example.
@@ -0,0 +1,42 @@
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
+
18
+ ## Conditional content & attributes
19
+
20
+ - [Show or hide content](show-or-hide-content.md) — `@show` / `@hide` and the boolean predicates.
21
+ - [Switch between views](switch-between-views.md) — pick a component's own view with `as=` or `@push-view`.
22
+ - [Conditional attribute value](conditional-attribute-value.md) — set a class/title by condition with `@if` / `@then` / `@else`.
23
+ - [Tabbed interface](tabbed-interface.md) — a `currentView` field + predicates to show the panel and highlight the active tab.
24
+
25
+ ## Context & dynamic variables
26
+
27
+ - [Share state without prop-drilling](share-state-without-prop-drilling.md) — `provide` / `lookup` and reading `*name`.
28
+ - [Edit through a dynamic target](edit-through-a-dynamic-target.md) — render `*name` and teleport edits back to the owner.
29
+
30
+ ## Composition
31
+
32
+ - [Render a child component](render-a-child-component.md) — `<x render=".field">` and multiple views.
33
+ - [Reuse markup with macros](reuse-markup-with-macros.md) — `macro(...)` with parameters and slots.
34
+
35
+ ## Data & events
36
+
37
+ - [Bind text and attributes](bind-text-and-attributes.md) — `@text`, `:attr`, `$'…'` templates, scope enrichment.
38
+ - [Handle events](handle-events.md) — `@on.<event>`, handler args, modifiers, custom events.
39
+
40
+ ## Component communication
41
+
42
+ - [Coordinate components](coordinate-components.md) — `bubble`, `send`/`receive`, async `request`/`response`.
@@ -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 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,26 @@
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
+ ```js
7
+ // bubble — walk toward the root; the first ancestor with the handler runs
8
+ input: { onItemClick(ctx) { ctx.bubble("itemSelected", [this]); return this; } },
9
+ bubble: { itemSelected(item, ctx) { return this.insertInLogAt(0, item.label); } },
10
+
11
+ // send / receive — deliver to one target (self, or ctx.at.<step> for another)
12
+ methods: { submit(ctx) { ctx.at.field("status").send("flash", [this.draft]); return this; } },
13
+ receive: { flash(message, ctx) { return this.setMessage(message); } },
14
+
15
+ // request / response — async host work, result routed back
16
+ receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } },
17
+ response: { loadData(res, err, ctx) { return this.setIsLoading(false).setItems(res); } },
18
+ ```
19
+
20
+ Pick by direction: **bubble** for aggregate state an ancestor owns (logs,
21
+ selections); **send/receive** to address one known component
22
+ (`ctx.at.field("x")` / `.index(name, i)` / `.key(name, k)`, default self);
23
+ **request/response** for fetch/timer/IndexedDB — register the async fn with
24
+ `scope.registerRequestHandlers({...})`, and `response` gets `(res, err)`. `ctx`
25
+ is always the trailing arg. `receive.init` is a convention, not a lifecycle
26
+ hook — dispatch it with `app.sendAtRoot("init")`.
@@ -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-without-prop-drilling 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).
@@ -0,0 +1,23 @@
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 prefix drops: 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.
@@ -0,0 +1,27 @@
1
+ # Handle events
2
+
3
+ **Problem:** respond to a DOM event and update state.
4
+
5
+ ```html
6
+ <button @on.click="$inc">+</button> <!-- $ calls a method -->
7
+ <button @on.click="dec">-</button> <!-- bare name = input handler -->
8
+
9
+ <!-- pass args by name; ctx is auto-appended last -->
10
+ <input @on.input="$setStr value" />
11
+ <input @on.input="$setN valueAsInt" />
12
+ <button @on.click="$addItem JsonSelector">+</button>
13
+
14
+ <!-- modifiers: keydown +send (Enter) / +cancel (Esc), and +ctrl/+cmd/+alt -->
15
+ <input @on.keydown+send="$submit value" @on.keydown+cancel="$reset" />
16
+
17
+ <!-- custom elements: any CustomEvent reaches @on.<name>, detail is `value` -->
18
+ <emoji-picker @on.emoji-click="onPick value"></emoji-picker>
19
+ ```
20
+
21
+ Handlers return a (new) instance of `this`. The first slot is a handler name
22
+ (`$method`, or a bare name in `input`/`alter`); later slots are built-in arg
23
+ names — `value`, `valueAsInt`/`valueAsFloat`, `event`, `key`, `isAlt`,
24
+ `isShift`, `isCtrl`/`isCmd`, `dragInfo`, … `value` resolves to
25
+ `event.target.value` (or `.checked` for a checkbox, or `event.detail` for a
26
+ `CustomEvent`). Bind events declaratively with `@on.` rather than reaching for
27
+ the node and `addEventListener` — an outside listener bypasses the transactor.
@@ -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,27 @@
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`.
@@ -0,0 +1,20 @@
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
+ ```
14
+
15
+ The child draws its own view from its own fields, so inside `Greeting`'s view
16
+ `@text=".name"` reads the child's `name`. This is the idiomatic way to display
17
+ nested structure: make the nested thing a component and render it, rather than
18
+ trying to path into it. For a list of children use `render-each` (see the
19
+ iterate-a-list recipe); to flip which view renders, see the switch-between-views
20
+ recipe.
@@ -0,0 +1,35 @@
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`). For repeated markup that
34
+ *does* need state, use a child component instead (see the render-a-child-component
35
+ recipe).
@@ -0,0 +1,30 @@
1
+ # Share state without prop-drilling
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
+ ```js
7
+ // producer — exposes one of its fields under a name
8
+ const Producer = component({
9
+ name: "EntryEditorAndSelector",
10
+ fields: { items: [] },
11
+ provide: { entries: ".items" },
12
+ });
13
+
14
+ // consumer — forwards to the producer's binding by "Component.name"
15
+ const Consumer = component({
16
+ name: "Selector",
17
+ lookup: { entries: { for: "EntryEditorAndSelector.entries", default: ".items" } },
18
+ });
19
+ ```
20
+
21
+ ```html
22
+ <!-- read the dynamic with the * prefix — iterate or render it -->
23
+ <option @each="*entries" :value="@value" @text="@label"></option>
24
+ ```
25
+
26
+ `provide` publishes a field under a name; a descendant's `lookup` resolves
27
+ `*name` to the nearest matching producer, falling back to `default` when none
28
+ is in scope. `*name` works wherever a `.field` does for iteration/rendering.
29
+ This is the **read** side; to edit the producer's value through the dynamic,
30
+ see the edit-through-a-dynamic-target recipe.
@@ -0,0 +1,22 @@
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). For a condition spanning multiple fields, use a no-arg
20
+ method instead (`@show="$canSubmit"`). `@show`/`@hide` toggle visibility on a
21
+ host element; the wrapper form (`show=` / `hide=` on `<x>`) conditionally
22
+ emits the node with no surrounding element.
@@ -0,0 +1,26 @@
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 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
+
18
+ <!-- @push-view forces a view on every component rendered under the host -->
19
+ <div @push-view=".view"><x render-each=".items"></x></div>
20
+ ```
21
+
22
+ `as="edit"` applies to the direct component only and falls back to `main` if
23
+ the view is absent. `@push-view` pushes a view name onto the render stack so
24
+ every descendant picks the first matching view (else `main`) — use it to flip a
25
+ whole subtree (e.g. a list) into edit mode at once. To toggle *sibling panels*
26
+ by a field instead, see the tabbed-interface recipe.
@@ -0,0 +1,38 @@
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="$setCurrentView '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="$setCurrentView '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" }, // $setCurrentView is auto-generated
30
+ ```
31
+
32
+ One string field is the whole state machine. `equals? .currentView 'overview'`
33
+ drives both the panel's `@show` and the active-tab class via `@if.class` /
34
+ `@then` / `@else`. Tab clicks call the auto-generated setter with a
35
+ string-literal arg (`@on.click="$setCurrentView 'pricing'"`). This toggles
36
+ **sibling panels** by predicate; to swap a *component's own* rendered view
37
+ instead, see the switch-between-views recipe. The field name is yours to pick
38
+ (`tab`, `currentView`, …).