tutuca 0.15.0 → 1.0.0-beta.2

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 (170) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +1 -1
  3. package/README.md +64 -170
  4. package/{dist → deps}/immer.js +674 -249
  5. package/dist/tutuca.js +2342 -4057
  6. package/dist/tutuca.min.js +3 -3
  7. package/package.json +78 -96
  8. package/src/component.js +66 -0
  9. package/src/css/classes.js +77 -0
  10. package/src/dev/formatters.js +393 -0
  11. package/src/dev/jsonml.js +35 -0
  12. package/src/dev/panel.js +400 -0
  13. package/src/dev/summary.js +239 -0
  14. package/src/dev/vdom-formatters.js +102 -0
  15. package/src/drag.js +23 -0
  16. package/src/embed/element.js +111 -0
  17. package/src/env.js +30 -0
  18. package/src/events.js +85 -0
  19. package/src/history/bar.js +198 -0
  20. package/src/history/index.js +149 -0
  21. package/src/host.js +360 -0
  22. package/src/lint/index.js +470 -0
  23. package/src/meta.js +12 -0
  24. package/src/optimize.js +58 -0
  25. package/src/paths.js +205 -0
  26. package/src/playground/element.js +360 -0
  27. package/src/playground/index.js +5 -0
  28. package/src/playground/sources.js +107 -0
  29. package/src/playground/ui/playground.js +287 -0
  30. package/src/schema.js +460 -0
  31. package/src/scope.js +92 -0
  32. package/src/storybook/index.js +80 -0
  33. package/src/storybook/runner.js +452 -0
  34. package/src/storybook/stories.js +208 -0
  35. package/src/storybook/stress.js +76 -0
  36. package/src/storybook/ui/common.js +37 -0
  37. package/src/storybook/ui/link.js +80 -0
  38. package/src/storybook/ui/panels.js +32 -0
  39. package/src/storybook/ui/story-panel.js +432 -0
  40. package/src/storybook/ui/storybook.js +191 -0
  41. package/src/storybook/ui/stress-panel.js +231 -0
  42. package/src/sync/automerge.js +159 -0
  43. package/src/sync/codec.js +206 -0
  44. package/src/sync/diff.js +153 -0
  45. package/src/sync/index.js +97 -0
  46. package/src/sync/migrate.js +184 -0
  47. package/src/sync/ops.js +160 -0
  48. package/src/sync/remote.js +206 -0
  49. package/src/tag.js +24 -0
  50. package/src/tasks.js +244 -0
  51. package/src/test/driver.js +141 -0
  52. package/src/test/pbt.js +589 -0
  53. package/src/tutuca.js +66 -0
  54. package/src/vdom.js +349 -0
  55. package/src/view.js +354 -0
  56. package/src/walk.js +79 -0
  57. package/dist/chai.js +0 -4210
  58. package/dist/tutuca-cli.js +0 -16315
  59. package/dist/tutuca-components.js +0 -2232
  60. package/dist/tutuca-dev.ext.js +0 -8476
  61. package/dist/tutuca-dev.js +0 -13220
  62. package/dist/tutuca-dev.min.js +0 -5
  63. package/dist/tutuca-extra.ext.js +0 -4261
  64. package/dist/tutuca-extra.js +0 -4964
  65. package/dist/tutuca-extra.min.js +0 -3
  66. package/dist/tutuca-storybook.js +0 -970
  67. package/dist/tutuca.ext.js +0 -4183
  68. package/skill/margaui/SKILL.md +0 -105
  69. package/skill/margaui/components/accordion.md +0 -127
  70. package/skill/margaui/components/alert.md +0 -174
  71. package/skill/margaui/components/aura.md +0 -97
  72. package/skill/margaui/components/avatar.md +0 -220
  73. package/skill/margaui/components/badge.md +0 -193
  74. package/skill/margaui/components/breadcrumbs.md +0 -103
  75. package/skill/margaui/components/button.md +0 -322
  76. package/skill/margaui/components/calendar.md +0 -67
  77. package/skill/margaui/components/card.md +0 -373
  78. package/skill/margaui/components/carousel.md +0 -387
  79. package/skill/margaui/components/chat.md +0 -171
  80. package/skill/margaui/components/checkbox.md +0 -101
  81. package/skill/margaui/components/collapse.md +0 -172
  82. package/skill/margaui/components/countdown.md +0 -165
  83. package/skill/margaui/components/diff.md +0 -53
  84. package/skill/margaui/components/divider.md +0 -107
  85. package/skill/margaui/components/dock.md +0 -173
  86. package/skill/margaui/components/drawer.md +0 -184
  87. package/skill/margaui/components/dropdown.md +0 -388
  88. package/skill/margaui/components/fab.md +0 -346
  89. package/skill/margaui/components/fieldset.md +0 -88
  90. package/skill/margaui/components/file-input.md +0 -84
  91. package/skill/margaui/components/filter.md +0 -52
  92. package/skill/margaui/components/footer.md +0 -583
  93. package/skill/margaui/components/hero.md +0 -135
  94. package/skill/margaui/components/hover-3d.md +0 -129
  95. package/skill/margaui/components/hover-gallery.md +0 -49
  96. package/skill/margaui/components/indicator.md +0 -265
  97. package/skill/margaui/components/input.md +0 -389
  98. package/skill/margaui/components/join.md +0 -100
  99. package/skill/margaui/components/kbd.md +0 -127
  100. package/skill/margaui/components/label.md +0 -102
  101. package/skill/margaui/components/link.md +0 -96
  102. package/skill/margaui/components/list.md +0 -182
  103. package/skill/margaui/components/loading.md +0 -105
  104. package/skill/margaui/components/mask.md +0 -168
  105. package/skill/margaui/components/megamenu.md +0 -131
  106. package/skill/margaui/components/menu.md +0 -887
  107. package/skill/margaui/components/mockup-browser.md +0 -39
  108. package/skill/margaui/components/mockup-code.md +0 -81
  109. package/skill/margaui/components/mockup-phone.md +0 -39
  110. package/skill/margaui/components/mockup-window.md +0 -33
  111. package/skill/margaui/components/modal.md +0 -196
  112. package/skill/margaui/components/navbar.md +0 -282
  113. package/skill/margaui/components/otp.md +0 -171
  114. package/skill/margaui/components/pagination.md +0 -122
  115. package/skill/margaui/components/progress.md +0 -135
  116. package/skill/margaui/components/radial-progress.md +0 -67
  117. package/skill/margaui/components/radio.md +0 -133
  118. package/skill/margaui/components/range.md +0 -134
  119. package/skill/margaui/components/rating.md +0 -170
  120. package/skill/margaui/components/select.md +0 -225
  121. package/skill/margaui/components/skeleton.md +0 -64
  122. package/skill/margaui/components/stack.md +0 -142
  123. package/skill/margaui/components/stat.md +0 -254
  124. package/skill/margaui/components/status.md +0 -73
  125. package/skill/margaui/components/steps.md +0 -138
  126. package/skill/margaui/components/swap.md +0 -152
  127. package/skill/margaui/components/tab.md +0 -248
  128. package/skill/margaui/components/table.md +0 -1018
  129. package/skill/margaui/components/text-rotate.md +0 -91
  130. package/skill/margaui/components/textarea.md +0 -85
  131. package/skill/margaui/components/theme-controller.md +0 -266
  132. package/skill/margaui/components/timeline.md +0 -1356
  133. package/skill/margaui/components/toast.md +0 -165
  134. package/skill/margaui/components/toggle.md +0 -135
  135. package/skill/margaui/components/tooltip.md +0 -181
  136. package/skill/margaui/components/validator.md +0 -163
  137. package/skill/tutuca/SKILL.md +0 -56
  138. package/skill/tutuca/advanced.md +0 -292
  139. package/skill/tutuca/cli.md +0 -239
  140. package/skill/tutuca/component-design.md +0 -168
  141. package/skill/tutuca/core.md +0 -935
  142. package/skill/tutuca/iteration.md +0 -207
  143. package/skill/tutuca/macros.md +0 -86
  144. package/skill/tutuca/margaui.md +0 -175
  145. package/skill/tutuca/messages-and-intents.md +0 -414
  146. package/skill/tutuca/patterns/README.md +0 -48
  147. package/skill/tutuca/patterns/add-a-story.md +0 -26
  148. package/skill/tutuca/patterns/bind-text-and-attributes.md +0 -30
  149. package/skill/tutuca/patterns/conditional-attribute-value.md +0 -29
  150. package/skill/tutuca/patterns/coordinate-components.md +0 -54
  151. package/skill/tutuca/patterns/edit-through-a-dynamic-target.md +0 -32
  152. package/skill/tutuca/patterns/enrich-each-item.md +0 -25
  153. package/skill/tutuca/patterns/file-input.md +0 -39
  154. package/skill/tutuca/patterns/filter-a-list.md +0 -25
  155. package/skill/tutuca/patterns/filter-and-paginate.md +0 -60
  156. package/skill/tutuca/patterns/handle-events.md +0 -45
  157. package/skill/tutuca/patterns/iterate-a-list.md +0 -18
  158. package/skill/tutuca/patterns/paginate-a-list.md +0 -29
  159. package/skill/tutuca/patterns/render-a-child-component.md +0 -21
  160. package/skill/tutuca/patterns/reuse-markup-with-macros.md +0 -36
  161. package/skill/tutuca/patterns/share-state-across-the-tree.md +0 -53
  162. package/skill/tutuca/patterns/show-or-hide-content.md +0 -23
  163. package/skill/tutuca/patterns/switch-between-views.md +0 -30
  164. package/skill/tutuca/patterns/tabbed-interface.md +0 -43
  165. package/skill/tutuca/semantics.md +0 -219
  166. package/skill/tutuca/storybook.md +0 -270
  167. package/skill/tutuca/styles.md +0 -48
  168. package/skill/tutuca/testing.md +0 -345
  169. package/skill/tutuca-source/SKILL.md +0 -33
  170. package/skill/tutuca-source/tutuca.ext.js +0 -4183
@@ -0,0 +1,102 @@
1
+ // DevTools custom formatters for vdom trees (Chrome/Firefox), shown like the Elements panel.
2
+ import { HTML_NS, VBase, VComment, VFragment, VNode, VText } from "../vdom.js";
3
+ import { install, makeFormatter, objectRef as ref, span, truncate } from "./jsonml.js";
4
+
5
+ const S = {
6
+ tag: "color:#9333ea",
7
+ attr: "color:#d97706",
8
+ str: "color:#16a34a",
9
+ muted: "opacity:0.6",
10
+ removed: "opacity:0.6;text-decoration:line-through",
11
+ };
12
+ const MAX_STR = 40;
13
+ const MAX_INLINE_ATTRS = 4;
14
+
15
+ const quoted = (s) => span(S.str, JSON.stringify(truncate(s, MAX_STR)));
16
+
17
+ // every stops at the first non vnode, so big arrays of other things bail out on item 0.
18
+ const isVdomArray = (v) => Array.isArray(v) && v.length > 0 && v.every((c) => c instanceof VBase);
19
+
20
+ const tagName = (node) => {
21
+ const implicit = node.namespace === null || node.namespace === HTML_NS;
22
+ if (implicit || node.tag === "svg" || node.tag === "math") return node.tag;
23
+ return `${node.namespace.split("/").at(-1).toLowerCase()}:${node.tag}`; // e.g. svg:circle
24
+ };
25
+
26
+ const attrParts = (name, value) => {
27
+ if (value === true) return [" ", span(S.attr, name)];
28
+ if (value === false || value == null) return [" ", span(S.removed, `${name}=${value}`)];
29
+ if (typeof value === "function") return [" ", span(S.muted, `${name}=ƒ`)];
30
+ if (typeof value === "object") return [" ", span(S.attr, name), "=", ref(value)];
31
+ return [" ", span(S.attr, name), "=", quoted(String(value))];
32
+ };
33
+ const keyParts = (key) => (key === undefined ? [] : [" ", span(S.muted, `key="${key}"`)]);
34
+
35
+ const openTag = (node) => {
36
+ const names = Object.keys(node.attrs);
37
+ const parts = [span(S.tag, `<${tagName(node)}`), ...keyParts(node.key)];
38
+ for (const name of names.slice(0, MAX_INLINE_ATTRS)) {
39
+ parts.push(...attrParts(name, node.attrs[name]));
40
+ }
41
+ if (names.length > MAX_INLINE_ATTRS) {
42
+ parts.push(" ", span(S.muted, `…+${names.length - MAX_INLINE_ATTRS}`));
43
+ }
44
+ parts.push(span(S.tag, ">"));
45
+ return parts;
46
+ };
47
+ const closeTag = (node) => span(S.tag, `</${tagName(node)}>`);
48
+ // A lone short text child renders inline, like the Elements panel does.
49
+ const inlineText = (node) => {
50
+ const [child] = node.childs;
51
+ return node.childs.length === 1 && child instanceof VText && child.text.length <= MAX_STR
52
+ ? child
53
+ : null;
54
+ };
55
+
56
+ const vdomFormatter = (comment) => {
57
+ const commentParts = (node) => comment?.(node.text) ?? [span(S.muted, `<!--${node.text}-->`)];
58
+ const describe = (v) => {
59
+ if (v instanceof VNode) {
60
+ const text = inlineText(v);
61
+ if (v.childs.length === 0 || text !== null) {
62
+ const inner = text === null ? [] : [text.text];
63
+ return { header: [...openTag(v), ...inner, closeTag(v)], childs: [] };
64
+ }
65
+ return {
66
+ header: [...openTag(v), span(S.muted, "…"), closeTag(v)],
67
+ childs: v.childs,
68
+ close: closeTag(v),
69
+ };
70
+ }
71
+ if (v instanceof VText) return { header: [quoted(v.text)], childs: [] };
72
+ if (v instanceof VComment) return { header: commentParts(v), childs: [] };
73
+ if (v instanceof VFragment) {
74
+ return {
75
+ header: [span(S.tag, "<>"), ...keyParts(v.key), span(S.muted, ` (${v.childs.length})`)],
76
+ childs: v.childs,
77
+ close: span(S.tag, "</>"),
78
+ };
79
+ }
80
+ if (isVdomArray(v)) {
81
+ const noun = v.length === 1 ? "vnode" : "vnodes";
82
+ return { header: [span(S.muted, `[${v.length} ${noun}]`)], childs: v };
83
+ }
84
+ return null;
85
+ };
86
+ return makeFormatter({
87
+ header: (v) => describe(v)?.header ?? null,
88
+ rows(v) {
89
+ const d = describe(v);
90
+ if (d === null || d.childs.length === 0) return [];
91
+ const rows = d.childs.map((c) => ["li", {}, ref(c)]);
92
+ if (d.close !== undefined) rows.push(["li", {}, d.close]);
93
+ return rows;
94
+ },
95
+ });
96
+ };
97
+
98
+ const installed = new WeakMap(); // target → formatter, so installing twice is a no-op
99
+ export const installVdomFormatters = (target = globalThis, { comment } = {}) => {
100
+ if (!installed.has(target)) installed.set(target, install(target, vdomFormatter(comment)));
101
+ return installed.get(target);
102
+ };
package/src/drag.js ADDED
@@ -0,0 +1,23 @@
1
+ // Drag and drop: the drag the host keeps from dragstart to dragend, read by handlers as ctx.drag.
2
+
3
+ // The events over a drop target. They reach the nearest enclosing component that handles them,
4
+ // so a drop on a child's elements gets to the parent's on:drop.
5
+ export const DROP_EVENTS = new Set(["dragenter", "dragover", "dragleave", "drop"]);
6
+ // What the host listens to once a component handles drop.
7
+ export const DRAG_EVENTS = ["dragstart", "dragend", ...DROP_EVENTS];
8
+
9
+ // The element being dragged: where it was rendered, its data-dragtype and the value there
10
+ // when the drag started.
11
+ export class Drag {
12
+ constructor(path, type, node, value) {
13
+ this.path = path;
14
+ this.type = type;
15
+ this.node = node;
16
+ this.value = value;
17
+ }
18
+ // The key of the child of receiver the dragged element is in, as sourceKey is for a
19
+ // message; null when it was dragged from outside receiver.
20
+ keyIn(receiver) {
21
+ return this.path.sourceKeyIn(receiver);
22
+ }
23
+ }
@@ -0,0 +1,111 @@
1
+ // An isolated tutuca app inside another: <tutuca-app app-key="..."> asks its ancestors for
2
+ // an app to mount in its shadow root. The outer render never morphs shadow content and
3
+ // events leaving it are retargeted to the element, so the outer Host never sees inside.
4
+ //
5
+ // Protocol, both as bubbling CustomEvents on the element:
6
+ // - REQUEST, on connect and when app-key changes. Its detail is { key, element, rootNode,
7
+ // document, adoptCss(text) → <style>, adoptLink(href), emit(type, data), mount(controller) }; a
8
+ // listener mounts an app on rootNode and passes mount a controller with dispose().
9
+ // - REPORT, sent by emit, with detail { key, type, data }. An outer view can handle it
10
+ // like any event: h("tutuca-app", { "app-key": ..., "on:tutucareport": send(...) }).
11
+ // Reports go out in a microtask, in order: a request answered during the outer render,
12
+ // or a call from an outer task, never reenters the outer Host.
13
+ // The outer side calls the app through element.controller or its own registry by key.
14
+ import { Host } from "../tutuca.js";
15
+
16
+ export const REQUEST = "tutucarequest";
17
+ export const REPORT = "tutucareport";
18
+
19
+ // Defines the element in window once; returns its class.
20
+ export const defineTutucaApp = (window, tagName = "tutuca-app") => {
21
+ const existing = window.customElements.get(tagName);
22
+ if (existing !== undefined) return existing;
23
+ class TutucaApp extends window.HTMLElement {
24
+ static observedAttributes = ["app-key"];
25
+ controller = null;
26
+ mountedKey = null;
27
+ current = null; // The detail of the live request; emits from older ones are dropped.
28
+ get key() {
29
+ return this.getAttribute("app-key");
30
+ }
31
+ // A keyed move (insertBefore) disconnects and reconnects; keep the app through it.
32
+ connectedCallback() {
33
+ if (this.current === null || this.mountedKey !== this.key) this.request();
34
+ }
35
+ disconnectedCallback() {
36
+ queueMicrotask(() => {
37
+ if (!this.isConnected) this.dispose();
38
+ });
39
+ }
40
+ attributeChangedCallback() {
41
+ if (this.isConnected) this.request();
42
+ }
43
+ request() {
44
+ this.dispose();
45
+ const doc = this.ownerDocument;
46
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
47
+ const rootNode = doc.createElement("div");
48
+ shadow.replaceChildren(rootNode);
49
+ const key = this.key;
50
+ let controller = null;
51
+ const detail = {
52
+ key,
53
+ element: this,
54
+ rootNode,
55
+ document: doc,
56
+ adoptCss: (text) => {
57
+ const style = doc.createElement("style");
58
+ style.textContent = text;
59
+ shadow.insertBefore(style, rootNode);
60
+ return style; // To update as the app needs more.
61
+ },
62
+ adoptLink: (href) => {
63
+ const link = doc.createElement("link");
64
+ link.rel = "stylesheet";
65
+ link.href = href;
66
+ shadow.insertBefore(link, rootNode);
67
+ },
68
+ emit: (type, data) =>
69
+ queueMicrotask(() => {
70
+ if (this.current !== detail) return;
71
+ const init = { bubbles: true, detail: { key, type, data } };
72
+ this.dispatchEvent(new window.CustomEvent(REPORT, init));
73
+ }),
74
+ mount: (c) => {
75
+ controller = c;
76
+ },
77
+ };
78
+ this.current = detail;
79
+ this.mountedKey = key;
80
+ this.dispatchEvent(new window.CustomEvent(REQUEST, { bubbles: true, detail }));
81
+ if (controller === null) rootNode.textContent = `no app for ${key}`;
82
+ this.controller = controller;
83
+ }
84
+ dispose() {
85
+ const { controller } = this;
86
+ this.controller = null;
87
+ this.current = null;
88
+ this.mountedKey = null;
89
+ controller?.dispose();
90
+ }
91
+ }
92
+ window.customElements.define(tagName, TutucaApp);
93
+ return TutucaApp;
94
+ };
95
+
96
+ // Mounts a plain Host for a request: register(scope) registers its components, make(C)
97
+ // makes the root from them. Returns the controller, already passed to detail.mount.
98
+ export const mountTutucaApp = (detail, { register, make, dev = false, onNotify }) => {
99
+ const host = new Host({ document: detail.document, rootNode: detail.rootNode, dev });
100
+ register(host.scope);
101
+ if (onNotify !== undefined) host.onNotify = onNotify;
102
+ host.start(make(host.scope.components));
103
+ const controller = {
104
+ host,
105
+ dispose() {
106
+ host.stop();
107
+ },
108
+ };
109
+ detail.mount(controller);
110
+ return controller;
111
+ };
package/src/env.js ADDED
@@ -0,0 +1,30 @@
1
+ // What handlers read from the world, through ctx.now(), ctx.random() and ctx.uuid(): the
2
+ // clock in ms since the epoch, a number in [0, 1) and a new id. Each mirrors the global it
3
+ // stands for, so a host can swap them for ones that repeat (testEnv).
4
+ export const browserEnv = {
5
+ now: () => Date.now(),
6
+ random: () => Math.random(),
7
+ uuid: () => globalThis.crypto.randomUUID(),
8
+ };
9
+ // An env whose runs repeat: a clock stopped at start until advance(ms) moves it, numbers from
10
+ // a generator seeded with seed, and uuid-shaped ids that count up.
11
+ export const testEnv = ({ seed = 1, start = Date.UTC(2026, 0, 1) } = {}) => {
12
+ let time = start;
13
+ let state = seed >>> 0;
14
+ let ids = 0;
15
+ return {
16
+ now: () => time,
17
+ advance(ms) {
18
+ time += ms;
19
+ },
20
+ // mulberry32
21
+ random() {
22
+ state = (state + 0x6d2b79f5) >>> 0;
23
+ let x = state;
24
+ x = Math.imul(x ^ (x >>> 15), x | 1);
25
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
26
+ return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
27
+ },
28
+ uuid: () => `00000000-0000-4000-8000-${(++ids).toString(16).padStart(12, "0")}`,
29
+ };
30
+ };
package/src/events.js ADDED
@@ -0,0 +1,85 @@
1
+ // DOM events: finds the component, path and handlers an event belongs to from the meta comments.
2
+ import { isMetaOpen, META_CLOSE, parseMeta } from "./meta.js";
3
+ import { Address, DispatchPath, Frame } from "./paths.js";
4
+ import { specOf } from "./schema.js";
5
+
6
+ const META_TYPES = new Set(["component", "frame"]);
7
+ function* enclosingNodeMeta(node) {
8
+ let closed = 0;
9
+ for (let n = node.previousSibling; n; n = n.previousSibling) {
10
+ if (n.nodeType !== 8) continue;
11
+ const data = n.data;
12
+ if (data === META_CLOSE) {
13
+ closed++;
14
+ continue;
15
+ }
16
+ if (!isMetaOpen(data)) continue;
17
+ if (closed > 0) {
18
+ closed--;
19
+ continue;
20
+ }
21
+ let meta;
22
+ try {
23
+ meta = parseMeta(data);
24
+ } catch (err) {
25
+ console.error("bad meta comment", err, n, node);
26
+ continue;
27
+ }
28
+ if (META_TYPES.has(meta?.t)) yield meta; // Skip look-alike comments from unmanaged DOM.
29
+ }
30
+ }
31
+ // From node up to rootNode: the metas, innermost first; where each component meta is in them;
32
+ // and the elements with handlers, each with the number of components below it. null when
33
+ // node is outside rootNode.
34
+ const walkUp = (node, rootNode) => {
35
+ const metas = [];
36
+ const components = [];
37
+ const eids = [];
38
+ while (node && node !== rootNode) {
39
+ // The attribute, not dataset, which elements outside HTML and SVG (MathML in jsdom) lack.
40
+ const eid = node.getAttribute?.("data-eid");
41
+ if (eid != null) eids.push({ eid: parseInt(eid, 10), node, level: components.length });
42
+ for (const meta of enclosingNodeMeta(node)) {
43
+ if (meta.t === "component") components.push(metas.length);
44
+ metas.push(meta);
45
+ }
46
+ node = node.parentNode;
47
+ }
48
+ return node === rootNode ? { metas, components, eids } : null;
49
+ };
50
+ // The path the metas, innermost first, describe.
51
+ const pathFrom = (metas) => {
52
+ const frames = [];
53
+ for (const meta of [...metas].reverse()) {
54
+ if (meta.t === "frame") frames.push(Frame.fromMeta(meta));
55
+ else if (frames.length > 0) frames.push(frames.pop().asComponent());
56
+ }
57
+ return new DispatchPath(frames);
58
+ };
59
+ // The path of the component, and the loop items in it, node was rendered in.
60
+ export const pathOf = (node, rootNode) => {
61
+ const walk = walkUp(node, rootNode);
62
+ return walk === null || walk.components.length === 0 ? null : pathFrom(walk.metas);
63
+ };
64
+ // The handler for a type event on target, with the address of its component and its element.
65
+ // Only the component target is in handles it, unless bubbles: then the nearest enclosing
66
+ // component with a handler for it does.
67
+ export const getEventInfoFor = (target, type, rootNode, rootComponent, bubbles = false) => {
68
+ const walk = walkUp(target, rootNode);
69
+ if (walk === null) return null;
70
+ const levels = bubbles ? walk.components.length : Math.min(1, walk.components.length);
71
+ for (let level = 0; level < levels; level++) {
72
+ const eids = walk.eids.filter((e) => e.level === level);
73
+ if (eids.length === 0) continue;
74
+ // What is outside the component below: its frame is the child the event came through.
75
+ const start = level === 0 ? 0 : walk.components[level - 1] + 1;
76
+ const to = Address.at(pathFrom(walk.metas.slice(start)), rootComponent);
77
+ const { eventHandlers } = specOf(to.updatePath.getIn(rootComponent));
78
+ for (const { eid, node } of eids) {
79
+ for (const handler of eventHandlers.get(eid) ?? []) {
80
+ if (handler.name === type) return { handler, to, node };
81
+ }
82
+ }
83
+ }
84
+ return null;
85
+ };
@@ -0,0 +1,198 @@
1
+ // The time travel bar: ⏮ ◀ ▶ ⏭, a slider over the states and what made the current one. It
2
+ // holds no timeline, it shows the one it's given and asks "history" to move through it, with
3
+ // [target, op, arg], op one of goto, back, forward, toStart and toEnd. mountTimeTravel puts
4
+ // one, with a historyEngine, next to a live Host.
5
+
6
+ import { ORIGINS } from "../storybook/ui/common.js";
7
+ import {
8
+ $if,
9
+ button,
10
+ component,
11
+ DispatchPath,
12
+ field,
13
+ HOST,
14
+ Host,
15
+ send,
16
+ Target,
17
+ Task,
18
+ t,
19
+ tag,
20
+ targetValue,
21
+ } from "../tutuca.js";
22
+ import { historyEngine } from "./index.js";
23
+
24
+ const { div, input, span } = tag;
25
+
26
+ const OPS = ["goto", "back", "forward", "toStart", "toEnd"];
27
+ const BUTTON = "btn btn-xs btn-ghost font-mono";
28
+ const KEYS = { ArrowLeft: "back", ArrowRight: "forward", Home: "toStart", End: "toEnd" };
29
+
30
+ export const TimeTravel = component({
31
+ name: "TimeTravel",
32
+ fields: {
33
+ target: t.string(), // Passed along with each op: the app the timeline belongs to.
34
+ size: t.number().int().min(0),
35
+ cursor: t.number().int().min(0),
36
+ label: t.string(),
37
+ origin: t.string(),
38
+ },
39
+ methods: {
40
+ // One state is not a history to travel through.
41
+ get isShown() {
42
+ return this.size > 1;
43
+ },
44
+ get last() {
45
+ return Math.max(0, this.size - 1);
46
+ },
47
+ get atStart() {
48
+ return this.cursor === 0;
49
+ },
50
+ get atEnd() {
51
+ return this.cursor >= this.last;
52
+ },
53
+ get position() {
54
+ return `state ${this.cursor + 1} of ${this.size}`;
55
+ },
56
+ get icon() {
57
+ return ORIGINS[this.origin] ?? "";
58
+ },
59
+ },
60
+ view: $if(
61
+ field("isShown"),
62
+ div(
63
+ {
64
+ class: "flex w-full items-center gap-2 text-sm",
65
+ role: "group",
66
+ "aria-label": "Time travel",
67
+ title: "Alt+← and Alt+→ step back and forward",
68
+ "on:keydown": send("key"),
69
+ },
70
+ button(
71
+ {
72
+ class: BUTTON,
73
+ title: "First state",
74
+ disabled: field("atStart"),
75
+ "on:click": send("op", "toStart"),
76
+ },
77
+ "⏮",
78
+ ),
79
+ button(
80
+ {
81
+ class: BUTTON,
82
+ title: "Back",
83
+ disabled: field("atStart"),
84
+ "on:click": send("op", "back"),
85
+ },
86
+ "◀",
87
+ ),
88
+ button(
89
+ {
90
+ class: BUTTON,
91
+ title: "Forward",
92
+ disabled: field("atEnd"),
93
+ "on:click": send("op", "forward"),
94
+ },
95
+ "▶",
96
+ ),
97
+ button(
98
+ {
99
+ class: BUTTON,
100
+ title: "Last state",
101
+ disabled: field("atEnd"),
102
+ "on:click": send("op", "toEnd"),
103
+ },
104
+ "⏭",
105
+ ),
106
+ input({
107
+ type: "range",
108
+ class: "range range-xs range-primary w-64 max-w-[40vw] shrink-0",
109
+ "aria-label": "State",
110
+ min: "0",
111
+ max: field("last"),
112
+ value: field("cursor"),
113
+ "on:input": send("goto", targetValue()),
114
+ }),
115
+ span(
116
+ { class: "min-w-0 flex-1 truncate font-mono text-xs tabular-nums opacity-70" },
117
+ field("position"),
118
+ " · ",
119
+ field("icon"),
120
+ " ",
121
+ field("label"),
122
+ ),
123
+ ),
124
+ "",
125
+ ),
126
+ on: {
127
+ show: {
128
+ args: [t.number().int().min(0), t.number().int().min(0), t.string(), t.string()],
129
+ run(next, [size, cursor, label, origin]) {
130
+ Object.assign(next, { size, cursor, label, origin });
131
+ },
132
+ },
133
+ op: {
134
+ args: [t.enum(...OPS)],
135
+ emits: { ask: ["history"] },
136
+ run(_next, [op], ctx) {
137
+ ctx.ask("history", [this.target, op, null]);
138
+ },
139
+ },
140
+ goto: {
141
+ args: [t.string()],
142
+ emits: { ask: ["history"] },
143
+ run(_next, [value], ctx) {
144
+ const i = Number(value);
145
+ if (Number.isInteger(i)) ctx.ask("history", [this.target, "goto", i]);
146
+ },
147
+ },
148
+ // Alt+← and Alt+→ step, Alt+Home and Alt+End jump to the ends.
149
+ key: {
150
+ args: [],
151
+ emits: { ask: ["history"] },
152
+ run(_next, _args, ctx) {
153
+ const op = ctx.event?.altKey ? KEYS[ctx.event.key] : undefined;
154
+ if (op === undefined) return;
155
+ ctx.event.preventDefault?.();
156
+ ctx.ask("history", [this.target, op, null]);
157
+ },
158
+ },
159
+ },
160
+ answers: {
161
+ history: {
162
+ ok() {},
163
+ error(_next, [error]) {
164
+ console.error("time travel failed", error);
165
+ },
166
+ },
167
+ },
168
+ });
169
+
170
+ // What the bar shows of a Timeline, as the args of its show message.
171
+ export const showArgs = ({ size, cursor, current }) => [
172
+ size,
173
+ cursor,
174
+ current?.label ?? "",
175
+ current?.origin ?? "",
176
+ ];
177
+ // Sends name(...args) to host's root component from outside the app.
178
+ const sendRoot = (host, name, args) => {
179
+ const task = new Task(name, args, HOST, new Target(new DispatchPath()), null);
180
+ task.prepare(host.rootComponent);
181
+ host.pushTask(task);
182
+ };
183
+
184
+ // Records host's timeline with a historyEngine of at most max states and shows a TimeTravel
185
+ // bar for it in rootNode, on a Host of its own; returns { engine, bar, stop }.
186
+ export const mountTimeTravel = ({ document, rootNode, host, max = 1000 }) => {
187
+ const bar = new Host({ document, rootNode });
188
+ bar.scope.register({ TimeTravel });
189
+ // A message, not a new root: travel runs while the bar handles its ask.
190
+ const show = (timeline) => sendRoot(bar, "show", showArgs(timeline));
191
+ const engine = historyEngine({ max, onChange: show });
192
+ bar.scope.registerAskHandlers({
193
+ history: ([_target, op, arg]) => (op === "goto" ? engine.travel(arg) : engine[op]()),
194
+ });
195
+ bar.start(bar.scope.components.TimeTravel());
196
+ host.attachSync(engine);
197
+ return { engine, bar, stop: () => bar.stop() };
198
+ };
@@ -0,0 +1,149 @@
1
+ // Time travel: a Timeline of states with a cursor, and historyEngine, which records one for a
2
+ // live Host as a sync engine and travels by applying a recorded state as a change from
3
+ // elsewhere, so the other engines (storage, peers) follow.
4
+
5
+ import { truncate } from "../dev/jsonml.js";
6
+ import { jsonText } from "../paths.js";
7
+ import { EventTask } from "../tasks.js";
8
+
9
+ export const argsLabel = (args) => truncate(args.map(jsonText).join(", "), 80);
10
+ // The task a task's chain started with: an event, or a message sent from outside.
11
+ export const rootTask = (task) => {
12
+ while (task.parent !== null) task = task.parent;
13
+ return task;
14
+ };
15
+ // A task as a timeline shows it, like "click → toggle()".
16
+ export const taskLabel = (task) => {
17
+ const call = `${task.name}(${argsLabel(task.args)})`;
18
+ return task instanceof EventTask ? `${task.event.type} → ${call}` : call;
19
+ };
20
+
21
+ // The left entry of the two closest in time, the one to drop to keep the rest spread out,
22
+ // sparing the first and the current; -1 when every one is spared.
23
+ const closestLeft = (entries, cursor) => {
24
+ let found = -1;
25
+ let closest = Infinity;
26
+ for (let i = 2; i < entries.length; i++) {
27
+ const left = i - 1;
28
+ if (left === cursor) continue;
29
+ const gap = entries[i].at - entries[left].at;
30
+ // <= drops the most recent of equally close pairs, keeping older ones.
31
+ if (gap <= closest) {
32
+ closest = gap;
33
+ found = left;
34
+ }
35
+ }
36
+ return found;
37
+ };
38
+
39
+ // States in order, entries { state, label, origin, at, ... }, and a cursor at the one shown.
40
+ // Recording after going back drops the entries after the cursor. Past max entries it drops
41
+ // one of the two closest in time, so a burst of changes thins out but the history keeps its
42
+ // spread; the first entry and the current one stay.
43
+ export class Timeline {
44
+ constructor({ max = 1000, now = Date.now } = {}) {
45
+ this.max = max;
46
+ this.now = now;
47
+ this.entries = [];
48
+ this.cursor = 0;
49
+ }
50
+ get size() {
51
+ return this.entries.length;
52
+ }
53
+ get current() {
54
+ return this.entries[this.cursor];
55
+ }
56
+ get atEnd() {
57
+ return this.cursor === this.entries.length - 1;
58
+ }
59
+ begin(entry) {
60
+ this.entries = [{ at: this.now(), ...entry }];
61
+ this.cursor = 0;
62
+ return this.current;
63
+ }
64
+ record(entry) {
65
+ this.entries.length = this.cursor + 1;
66
+ this.entries.push({ at: this.now(), ...entry });
67
+ this.cursor = this.entries.length - 1;
68
+ while (this.entries.length > this.max) {
69
+ const i = closestLeft(this.entries, this.cursor);
70
+ if (i < 0) break;
71
+ this.entries.splice(i, 1);
72
+ if (i < this.cursor) this.cursor--;
73
+ }
74
+ return this.current;
75
+ }
76
+ // Folds task into the current entry when it's the last one and task continues the chain
77
+ // that made it, as async replies do; false when task starts an entry of its own.
78
+ merge(task, state) {
79
+ const entry = this.current;
80
+ if (!this.atEnd || entry?.rootTask === undefined || entry.rootTask !== rootTask(task)) {
81
+ return false;
82
+ }
83
+ entry.state = state;
84
+ entry.tasks = [...(entry.tasks ?? []), task];
85
+ return true;
86
+ }
87
+ goto(i) {
88
+ this.cursor = Math.max(0, Math.min(Number(i), this.entries.length - 1));
89
+ return this.current;
90
+ }
91
+ back() {
92
+ return this.goto(this.cursor - 1);
93
+ }
94
+ forward() {
95
+ return this.goto(this.cursor + 1);
96
+ }
97
+ toStart() {
98
+ return this.goto(0);
99
+ }
100
+ toEnd() {
101
+ return this.goto(this.entries.length - 1);
102
+ }
103
+ }
104
+
105
+ // Records the timeline of the Host it's attached to (host.attachSync(engine)): one entry per
106
+ // user action, its async replies folded in, and one per change from another engine. The
107
+ // first entry is the state before the first change. travel(i) clears the queued tasks and
108
+ // applies entry i as a change from this engine, so it isn't recorded and every other engine
109
+ // sees it; an async reply that lands after it starts a new branch from there. onChange gets
110
+ // the timeline after each change and travel.
111
+ export const historyEngine = ({ max = 1000, now = Date.now, onChange = () => {} } = {}) => {
112
+ const timeline = new Timeline({ max, now });
113
+ let link = null;
114
+ const seed = (state) => {
115
+ if (timeline.size === 0) timeline.begin({ state, label: "initial", origin: "initial" });
116
+ };
117
+ const travel = (move) => {
118
+ if (link === null || timeline.size === 0) return;
119
+ const { state } = move();
120
+ link.host.clearTasks();
121
+ link.apply(() => state);
122
+ onChange(timeline);
123
+ };
124
+ return {
125
+ timeline,
126
+ attach(l) {
127
+ link = l;
128
+ },
129
+ detach() {
130
+ link = null;
131
+ },
132
+ commit({ prev, next, task }) {
133
+ seed(prev);
134
+ if (task === null) {
135
+ timeline.record({ state: next, label: "synced change", origin: "external" });
136
+ } else if (!timeline.merge(task, next)) {
137
+ const root = rootTask(task);
138
+ const label = taskLabel(root);
139
+ timeline.record({ state: next, label, origin: "user", rootTask: root, tasks: [task] });
140
+ }
141
+ onChange(timeline);
142
+ },
143
+ travel: (i) => travel(() => timeline.goto(i)),
144
+ back: () => travel(() => timeline.back()),
145
+ forward: () => travel(() => timeline.forward()),
146
+ toStart: () => travel(() => timeline.toStart()),
147
+ toEnd: () => travel(() => timeline.toEnd()),
148
+ };
149
+ };