@uistate/examples 1.0.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 (137) hide show
  1. package/README.md +40 -0
  2. package/cssState/.gitkeep +0 -0
  3. package/eventState/001-counter/README.md +44 -0
  4. package/eventState/001-counter/index.html +33 -0
  5. package/eventState/002-counter-improved/README.md +44 -0
  6. package/eventState/002-counter-improved/index.html +47 -0
  7. package/eventState/003-input-reactive/README.md +44 -0
  8. package/eventState/003-input-reactive/index.html +33 -0
  9. package/eventState/004-computed-state/README.md +45 -0
  10. package/eventState/004-computed-state/index.html +65 -0
  11. package/eventState/005-conditional-rendering/README.md +42 -0
  12. package/eventState/005-conditional-rendering/index.html +39 -0
  13. package/eventState/006-list-rendering/README.md +49 -0
  14. package/eventState/006-list-rendering/index.html +63 -0
  15. package/eventState/007-form-validation/README.md +52 -0
  16. package/eventState/007-form-validation/index.html +102 -0
  17. package/eventState/008-undo-redo/README.md +70 -0
  18. package/eventState/008-undo-redo/index.html +108 -0
  19. package/eventState/009-localStorage-side-effects/README.md +72 -0
  20. package/eventState/009-localStorage-side-effects/index.html +57 -0
  21. package/eventState/010-decoupled-components/README.md +74 -0
  22. package/eventState/010-decoupled-components/index.html +93 -0
  23. package/eventState/011-async-patterns/README.md +98 -0
  24. package/eventState/011-async-patterns/index.html +132 -0
  25. package/eventState/028-counter-improved-eventTest/LICENSE +55 -0
  26. package/eventState/028-counter-improved-eventTest/README.md +131 -0
  27. package/eventState/028-counter-improved-eventTest/app/store.js +9 -0
  28. package/eventState/028-counter-improved-eventTest/index.html +49 -0
  29. package/eventState/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
  30. package/eventState/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
  31. package/eventState/028-counter-improved-eventTest/runtime/core/eventStateNew.js +149 -0
  32. package/eventState/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
  33. package/eventState/028-counter-improved-eventTest/runtime/core/router.js +271 -0
  34. package/eventState/028-counter-improved-eventTest/store.d.ts +8 -0
  35. package/eventState/028-counter-improved-eventTest/style.css +170 -0
  36. package/eventState/028-counter-improved-eventTest/tests/README.md +208 -0
  37. package/eventState/028-counter-improved-eventTest/tests/counter.test.js +116 -0
  38. package/eventState/028-counter-improved-eventTest/tests/eventTest.js +176 -0
  39. package/eventState/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
  40. package/eventState/028-counter-improved-eventTest/tests/run.js +20 -0
  41. package/eventState/030-todo-app-with-eventTest/LICENSE +55 -0
  42. package/eventState/030-todo-app-with-eventTest/README.md +121 -0
  43. package/eventState/030-todo-app-with-eventTest/app/router.js +25 -0
  44. package/eventState/030-todo-app-with-eventTest/app/store.js +16 -0
  45. package/eventState/030-todo-app-with-eventTest/app/views/home.js +11 -0
  46. package/eventState/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
  47. package/eventState/030-todo-app-with-eventTest/index.html +65 -0
  48. package/eventState/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  49. package/eventState/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  50. package/eventState/030-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
  51. package/eventState/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  52. package/eventState/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
  53. package/eventState/030-todo-app-with-eventTest/store.d.ts +18 -0
  54. package/eventState/030-todo-app-with-eventTest/style.css +170 -0
  55. package/eventState/030-todo-app-with-eventTest/tests/README.md +208 -0
  56. package/eventState/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
  57. package/eventState/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
  58. package/eventState/030-todo-app-with-eventTest/tests/run.js +20 -0
  59. package/eventState/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
  60. package/eventState/031-todo-app-with-eventTest/LICENSE +55 -0
  61. package/eventState/031-todo-app-with-eventTest/README.md +54 -0
  62. package/eventState/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
  63. package/eventState/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  64. package/eventState/031-todo-app-with-eventTest/app/bridges.js +113 -0
  65. package/eventState/031-todo-app-with-eventTest/app/router.js +26 -0
  66. package/eventState/031-todo-app-with-eventTest/app/store.js +15 -0
  67. package/eventState/031-todo-app-with-eventTest/app/views/home.js +46 -0
  68. package/eventState/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  69. package/eventState/031-todo-app-with-eventTest/devtools/dock.js +41 -0
  70. package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  71. package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  72. package/eventState/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  73. package/eventState/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  74. package/eventState/031-todo-app-with-eventTest/index.html +103 -0
  75. package/eventState/031-todo-app-with-eventTest/package-lock.json +2184 -0
  76. package/eventState/031-todo-app-with-eventTest/package.json +24 -0
  77. package/eventState/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  78. package/eventState/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  79. package/eventState/031-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
  80. package/eventState/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  81. package/eventState/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
  82. package/eventState/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  83. package/eventState/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  84. package/eventState/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  85. package/eventState/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  86. package/eventState/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  87. package/eventState/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  88. package/eventState/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  89. package/eventState/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  90. package/eventState/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  91. package/eventState/031-todo-app-with-eventTest/store.d.ts +23 -0
  92. package/eventState/031-todo-app-with-eventTest/style.css +170 -0
  93. package/eventState/031-todo-app-with-eventTest/tests/README.md +208 -0
  94. package/eventState/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
  95. package/eventState/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  96. package/eventState/031-todo-app-with-eventTest/tests/run.js +20 -0
  97. package/eventState/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
  98. package/eventState/032-todo-app-with-eventTest/LICENSE +55 -0
  99. package/eventState/032-todo-app-with-eventTest/README.md +54 -0
  100. package/eventState/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
  101. package/eventState/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  102. package/eventState/032-todo-app-with-eventTest/app/actions/index.js +153 -0
  103. package/eventState/032-todo-app-with-eventTest/app/bridges.js +113 -0
  104. package/eventState/032-todo-app-with-eventTest/app/router.js +26 -0
  105. package/eventState/032-todo-app-with-eventTest/app/store.js +15 -0
  106. package/eventState/032-todo-app-with-eventTest/app/views/home.js +46 -0
  107. package/eventState/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  108. package/eventState/032-todo-app-with-eventTest/devtools/dock.js +41 -0
  109. package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  110. package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  111. package/eventState/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  112. package/eventState/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  113. package/eventState/032-todo-app-with-eventTest/index.html +87 -0
  114. package/eventState/032-todo-app-with-eventTest/package-lock.json +2184 -0
  115. package/eventState/032-todo-app-with-eventTest/package.json +24 -0
  116. package/eventState/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  117. package/eventState/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  118. package/eventState/032-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
  119. package/eventState/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  120. package/eventState/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
  121. package/eventState/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  122. package/eventState/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  123. package/eventState/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  124. package/eventState/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  125. package/eventState/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  126. package/eventState/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  127. package/eventState/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  128. package/eventState/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  129. package/eventState/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  130. package/eventState/032-todo-app-with-eventTest/store.d.ts +23 -0
  131. package/eventState/032-todo-app-with-eventTest/style.css +170 -0
  132. package/eventState/032-todo-app-with-eventTest/tests/README.md +208 -0
  133. package/eventState/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
  134. package/eventState/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  135. package/eventState/032-todo-app-with-eventTest/tests/run.js +20 -0
  136. package/eventState/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
  137. package/package.json +27 -0
@@ -0,0 +1,212 @@
1
+ // helpers.js
2
+ export const intent = (store, name, payload = true) => store.set(`intent.${name}`, payload);
3
+
4
+ export const bindIntentClicks = (root, store, payloadFromEvent) => {
5
+ root.addEventListener('click', (e) => {
6
+ const t = e.target.closest('[data-intent]');
7
+ if (!t) return;
8
+ const name = t.dataset.intent;
9
+ const payload = payloadFromEvent ? payloadFromEvent(e, t) : true;
10
+ store.set(`intent.${name}`, payload);
11
+ });
12
+ };
13
+
14
+ export const mount = (a, b) => {
15
+ // Overloads:
16
+ // - mount(selectorsMap) -> root defaults to document
17
+ // - mount(root, selectorsMap)
18
+ let root, selectors;
19
+ if (typeof b === 'undefined') {
20
+ selectors = a;
21
+ root = document;
22
+ } else {
23
+ root = a;
24
+ selectors = b;
25
+ }
26
+ const entries = Object.entries(selectors).map(([k, sel]) => {
27
+ const el = typeof sel === 'string' ? root.querySelector(sel) : sel;
28
+ return [k, el];
29
+ });
30
+ return Object.fromEntries(entries);
31
+ };
32
+
33
+ export const renderJson = (el, getSnapshot) => {
34
+ try { el.textContent = JSON.stringify(getSnapshot(), null, 2); }
35
+ catch { el.textContent = String(getSnapshot()); }
36
+ };
37
+
38
+ // Simple plug-and-play state panel. Attempts to use a provided target (selector or element),
39
+ // then '#state' if present, otherwise creates a floating <pre> panel in the bottom-right.
40
+ export const showStatePanel = (store, target) => {
41
+ let el = null;
42
+ if (typeof target === 'string') {
43
+ el = document.querySelector(target);
44
+ } else if (target && target.nodeType === 1) {
45
+ el = target; // DOM Element
46
+ }
47
+ if (!el) {
48
+ el = document.querySelector('#state');
49
+ }
50
+ if (!el) {
51
+ el = document.createElement('pre');
52
+ el.setAttribute('id', 'state');
53
+ Object.assign(el.style, {
54
+ position: 'fixed', right: '8px', bottom: '8px',
55
+ minWidth: '240px', maxWidth: '40vw', maxHeight: '40vh', overflow: 'auto',
56
+ padding: '8px', background: 'rgba(0,0,0,0.7)', color: '#0f0',
57
+ font: '12px/1.4 monospace', borderRadius: '6px', zIndex: 99999,
58
+ boxShadow: '0 2px 12px rgba(0,0,0,0.35)'
59
+ });
60
+ document.body.appendChild(el);
61
+ }
62
+ const render = () => {
63
+ try { el.textContent = JSON.stringify(store.get(), null, 2); }
64
+ catch { el.textContent = String(store.get()); }
65
+ };
66
+ store.subscribe('*', render);
67
+ render();
68
+ return el;
69
+ };
70
+
71
+ // Subscribe multiple path/handler pairs at once. Returns a SubGroup with:
72
+ // - dispose(): unsubscribe all
73
+ // - unsubs: individual unsubscribe functions (in pair order)
74
+ // - byPath: Record<string, Function[]> to selectively dispose by path
75
+ // - size: number of subscriptions created
76
+ export const groupSubs = (store, ...args) => {
77
+ if (args.length % 2 !== 0) {
78
+ throw new Error('groupSubs expects alternating path/handler pairs');
79
+ }
80
+ const unsubs = [];
81
+ const byPath = Object.create(null);
82
+ for (let i = 0; i < args.length; i += 2) {
83
+ const path = args[i];
84
+ const handler = args[i + 1];
85
+ const unsub = store.subscribe(path, handler);
86
+ unsubs.push(unsub);
87
+ (byPath[path] || (byPath[path] = [])).push(unsub);
88
+ }
89
+ const dispose = () => {
90
+ for (let i = unsubs.length - 1; i >= 0; i--) {
91
+ try { unsubs[i](); } catch (_) { /* noop */ }
92
+ }
93
+ };
94
+ return { dispose, unsubs, byPath, size: unsubs.length };
95
+ };
96
+
97
+ // Console logger for state changes; subscribes to '*' wildcard and logs path/value.
98
+ // Optional custom formatter receives the event object `{ path, value }`.
99
+ export const consoleLogState = (store, formatter) => {
100
+ const handler = (evt) => {
101
+ const payload = evt && typeof evt === 'object' && 'path' in evt ? evt : { path: '*', value: store.get() };
102
+ if (formatter) return formatter(payload);
103
+ console.log('[state]', payload.path, payload.value);
104
+ };
105
+ return store.subscribe('*', handler);
106
+ };
107
+
108
+ // JS-only wiring helpers (no DOM data-* exposure)
109
+ export const onClick = (el, handler) => {
110
+ el.addEventListener('click', handler);
111
+ return () => el.removeEventListener('click', handler);
112
+ };
113
+
114
+ export const bump = (store, path, delta = 1) => {
115
+ const n = (store.get(path) || 0) + delta;
116
+ store.set(path, n);
117
+ };
118
+ export const inc = (store, path) => bump(store, path, 1);
119
+ export const dec = (store, path) => bump(store, path, -1);
120
+
121
+ // Intent wiring without using data-* attributes
122
+ export const bindIntent = (store, el, name, payloadFromEvent) => {
123
+ const listener = (e) => {
124
+ const payload = payloadFromEvent ? payloadFromEvent(e, el) : true;
125
+ store.set(`intent.${name}`, payload);
126
+ };
127
+ el.addEventListener('click', listener);
128
+ return () => el.removeEventListener('click', listener);
129
+ };
130
+
131
+ export const bindIntents = (store, tuples) => {
132
+ const unsubs = tuples.map(([el, name, payloadFromEvent]) => bindIntent(store, el, name, payloadFromEvent));
133
+ return () => { for (let i = unsubs.length - 1; i >= 0; i--) { try { unsubs[i](); } catch (_) {} } };
134
+ };
135
+
136
+ // Orchestrator: initialize a view by mounting elements, subscribing handlers, wiring events, and optional dev tools.
137
+ // API:
138
+ // initView({
139
+ // store, // required
140
+ // mount: selectors | [root, selectors],
141
+ // paths: { ALIAS: 'a.b.c' }, // optional path aliases
142
+ // view: [ ['path', (value, els, store, p) => { /* render */ }], ... ],
143
+ // dev: { log: true, panel: true },
144
+ // events: (els, store, p) => [ /* array of unsubs */ ],
145
+ // })
146
+ export const initView = (cfg) => {
147
+ if (!cfg || !cfg.store) throw new Error('initView: cfg.store is required');
148
+ const store = cfg.store;
149
+ const mountCfg = cfg.mount;
150
+ const p = Object.assign({}, cfg.paths || {});
151
+ const dev = cfg.dev || {};
152
+
153
+ // Resolve elements once
154
+ const els = Array.isArray(mountCfg) ? mount(mountCfg[0], mountCfg[1]) : mount(mountCfg || {});
155
+
156
+ // Build grouped subscriptions from view tuples, wrapping to inject (els, store, p)
157
+ const viewTuples = (cfg.view || []).flatMap(([path, handler]) => {
158
+ let wrapped;
159
+ if (typeof handler === 'string') {
160
+ const key = handler;
161
+ wrapped = (value) => {
162
+ const el = els[key];
163
+ if (el) el.textContent = String(value);
164
+ };
165
+ } else {
166
+ wrapped = (value) => handler && handler(value, els, store, p);
167
+ }
168
+ return [path, wrapped];
169
+ });
170
+ const subs = viewTuples.length ? groupSubs(store, ...viewTuples) : { dispose(){} };
171
+
172
+ // Dev tools
173
+ const devUnsubs = [];
174
+ if (dev.log) {
175
+ try { devUnsubs.push(consoleLogState(store)); } catch (_) {}
176
+ }
177
+ let panelEl = null;
178
+ if (dev.panel) {
179
+ try { panelEl = showStatePanel(store); } catch (_) {}
180
+ }
181
+
182
+ // Events wiring
183
+ let eventUnsubs = [];
184
+ if (typeof cfg.events === 'function') {
185
+ try { eventUnsubs = cfg.events(els, store, p) || []; } catch (_) { eventUnsubs = []; }
186
+ } else if (cfg.events && typeof cfg.events === 'object') {
187
+ // Sugar: events map { elKey: (store, paths, el) => void | unsub }
188
+ const map = cfg.events;
189
+ eventUnsubs = Object.entries(map).map(([key, fn]) => {
190
+ const el = els[key];
191
+ if (!el || typeof fn !== 'function') return () => {};
192
+ // Default to click wiring; allow handler to return its own unsub.
193
+ const handler = () => fn(store, p, el);
194
+ el.addEventListener('click', handler);
195
+ return () => el.removeEventListener('click', handler);
196
+ });
197
+ }
198
+
199
+ // Unified disposer
200
+ const dispose = () => {
201
+ try { subs && subs.dispose && subs.dispose(); } catch (_) {}
202
+ for (let i = eventUnsubs.length - 1; i >= 0; i--) {
203
+ try { eventUnsubs[i] && eventUnsubs[i](); } catch (_) {}
204
+ }
205
+ for (let i = devUnsubs.length - 1; i >= 0; i--) {
206
+ try { devUnsubs[i] && devUnsubs[i](); } catch (_) {}
207
+ }
208
+ // Note: showStatePanel returns an element; we do not auto-remove it by default.
209
+ };
210
+
211
+ return { els, paths: p, subs, eventUnsubs, devUnsubs, dispose };
212
+ };
@@ -0,0 +1,271 @@
1
+ // runtime/core/router.js — Generic SPA router factory for eventState stores
2
+ // Usage:
3
+ // const router = createRouter({
4
+ // routes: [{ path: '/', view: 'home', component: HomeView }],
5
+ // store,
6
+ // rootSelector: '[data-route-root]',
7
+ // debug: true
8
+ // });
9
+ // router.start();
10
+
11
+ export function createRouter(config) {
12
+ const {
13
+ routes = [],
14
+ store,
15
+ rootSelector = '[data-route-root]',
16
+ fallback = null,
17
+ debug = false,
18
+ linkSelector = 'a[data-link]',
19
+ navSelector = 'nav a[data-link]',
20
+ } = config;
21
+
22
+ // Detect base path from <base href> if present
23
+ const BASE_PATH = (() => {
24
+ const b = document.querySelector('base[href]');
25
+ if (!b) return '';
26
+ try {
27
+ const u = new URL(b.getAttribute('href'), location.href);
28
+ let p = u.pathname;
29
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
30
+ return p;
31
+ } catch { return ''; }
32
+ })();
33
+
34
+ function stripBase(pathname) {
35
+ if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
36
+ const rest = pathname.slice(BASE_PATH.length) || '/';
37
+ return rest.startsWith('/') ? rest : ('/' + rest);
38
+ }
39
+ return pathname;
40
+ }
41
+
42
+ function withBase(pathname) {
43
+ if (!BASE_PATH) return pathname;
44
+ if (pathname === '/') return BASE_PATH || '/';
45
+ return (BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname);
46
+ }
47
+
48
+ function normalizePath(p) {
49
+ if (!p) return '/';
50
+ try {
51
+ if (p[0] !== '/') p = '/' + p;
52
+ if (p === '/index.html') return '/';
53
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
54
+ return p;
55
+ } catch { return '/'; }
56
+ }
57
+
58
+ function resolve(pathname) {
59
+ const p = normalizePath(pathname);
60
+ const r = routes.find(r => r.path === p);
61
+ if (r) return { ...r, params: {} };
62
+ if (fallback) return { ...fallback, params: {} };
63
+ return null;
64
+ }
65
+
66
+ function getRoot() {
67
+ const el = document.querySelector(rootSelector);
68
+ if (!el) throw new Error('Route root not found: ' + rootSelector);
69
+ return el;
70
+ }
71
+
72
+ function setActiveNav(pathname) {
73
+ document.querySelectorAll(navSelector).forEach(a => {
74
+ const url = new URL(a.getAttribute('href'), location.href);
75
+ const linkPath = normalizePath(stripBase(url.pathname));
76
+ const here = normalizePath(pathname);
77
+ const isExact = linkPath === here;
78
+ const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
79
+ const active = isExact || isParent;
80
+ a.classList.toggle('active', active);
81
+ if (isExact) a.setAttribute('aria-current', 'page');
82
+ else a.removeAttribute('aria-current');
83
+ });
84
+ }
85
+
86
+ // State
87
+ let current = { viewKey: null, unboot: null, path: null, search: '' };
88
+ let navController = null;
89
+ const scrollPositions = new Map();
90
+ history.scrollRestoration = 'manual';
91
+
92
+ // Core navigate function
93
+ async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
94
+ const root = getRoot();
95
+ const appPath = normalizePath(stripBase(pathname));
96
+ const resolved = resolve(appPath);
97
+
98
+ if (!resolved) {
99
+ if (debug) console.warn('[router] No route found for:', appPath);
100
+ return;
101
+ }
102
+
103
+ const viewKey = resolved.view;
104
+ const component = resolved.component;
105
+ const html = document.documentElement;
106
+ const prevViewKey = current.viewKey;
107
+
108
+ const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
109
+
110
+ // Always log navigation for telemetry
111
+ console.log('[nav] navigate', { from: current.path, to: appPath, view: viewKey });
112
+
113
+ if (debug) {
114
+ console.debug('[router] navigate', { pathname, appPath, searchStr, view: viewKey, params: resolved.params });
115
+ }
116
+
117
+ // Same-route no-op guard
118
+ if (current.path === appPath && current.search === searchStr) {
119
+ return;
120
+ }
121
+
122
+ // Abort in-flight boot
123
+ if (navController) navController.abort();
124
+ navController = new AbortController();
125
+ const { signal } = navController;
126
+
127
+ // Transition start
128
+ if (store) {
129
+ try { store.set('ui.route.transitioning', true); } catch {}
130
+ }
131
+ html.setAttribute('data-transitioning', 'on');
132
+
133
+ // Save scroll position
134
+ if (current.path) {
135
+ scrollPositions.set(current.path, { x: scrollX, y: scrollY });
136
+ }
137
+
138
+ // Unboot previous view
139
+ if (typeof current.unboot === 'function') {
140
+ try { await current.unboot(); } catch (_e) {}
141
+ }
142
+
143
+ // Clear DOM
144
+ root.replaceChildren();
145
+
146
+ // Boot new view
147
+ const unboot = await (component.boot?.({ store, el: root, signal }) || Promise.resolve(() => {}));
148
+ current = { viewKey, unboot, path: appPath, search: searchStr };
149
+
150
+ // Update nav active state
151
+ setActiveNav(appPath);
152
+
153
+ // Parse query params
154
+ const urlForQuery = new URL(location.origin + withBase(appPath) + searchStr);
155
+ const q = {};
156
+ urlForQuery.searchParams.forEach((v, k) => { q[k] = v; });
157
+
158
+ // Update store
159
+ if (store) {
160
+ try {
161
+ if (store.setMany) {
162
+ store.setMany({
163
+ 'ui.route.view': viewKey,
164
+ 'ui.route.path': appPath,
165
+ 'ui.route.params': resolved.params || {},
166
+ 'ui.route.query': q,
167
+ });
168
+ } else {
169
+ store.set('ui.route.view', viewKey);
170
+ store.set('ui.route.path', appPath);
171
+ store.set('ui.route.params', resolved.params || {});
172
+ store.set('ui.route.query', q);
173
+ }
174
+ } catch {}
175
+ }
176
+
177
+ // Update browser history
178
+ const useReplace = replace || (prevViewKey === viewKey);
179
+ if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
180
+ else history.pushState({}, '', withBase(appPath) + searchStr);
181
+
182
+ // Set view attribute
183
+ html.setAttribute('data-view', viewKey);
184
+
185
+ if (debug) {
186
+ console.debug('[router] view', { viewKey, path: appPath, query: q });
187
+ }
188
+
189
+ // Transition end
190
+ if (store) {
191
+ try { store.set('ui.route.transitioning', false); } catch {}
192
+ }
193
+ html.setAttribute('data-transitioning', 'off');
194
+
195
+ // Focus management
196
+ if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
197
+ try { root.focus({ preventScroll: true }); } catch {}
198
+
199
+ // Scroll restoration
200
+ if (restoreScroll) {
201
+ const pos = scrollPositions.get(appPath);
202
+ if (pos) scrollTo(pos.x, pos.y);
203
+ } else {
204
+ scrollTo(0, 0);
205
+ }
206
+ }
207
+
208
+ function navigateQuery(patch = {}, { replace = true } = {}) {
209
+ const params = new URLSearchParams(current.search || '');
210
+ for (const [k, v] of Object.entries(patch)) {
211
+ if (v === null || v === undefined || v === '') params.delete(k);
212
+ else params.set(k, String(v));
213
+ }
214
+ const searchStr = params.toString();
215
+ const prefixed = searchStr ? ('?' + searchStr) : '';
216
+ const path = current.path || normalizePath(stripBase(location.pathname));
217
+ return navigate(path, { search: prefixed, replace });
218
+ }
219
+
220
+ function navigatePath(path, { replace = true } = {}) {
221
+ const appPath = normalizePath(stripBase(path));
222
+ const searchStr = current.search || '';
223
+ return navigate(appPath, { search: searchStr, replace });
224
+ }
225
+
226
+ function onClick(e) {
227
+ const a = e.target.closest(linkSelector);
228
+ if (!a) return;
229
+ if (e.defaultPrevented) return;
230
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
231
+ const url = new URL(a.getAttribute('href'), location.href);
232
+ if (url.origin !== location.origin) return;
233
+ e.preventDefault();
234
+
235
+ // Log navigation click
236
+ console.log('[nav] click', { href: a.getAttribute('href'), text: a.textContent.trim() });
237
+
238
+ navigate(url.pathname, { search: url.search }).catch(() => {});
239
+ }
240
+
241
+ function onPop() {
242
+ navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true }).catch(() => {});
243
+ }
244
+
245
+ // Public API
246
+ return {
247
+ navigate,
248
+ navigateQuery,
249
+ navigatePath,
250
+
251
+ start() {
252
+ window.addEventListener('click', onClick);
253
+ window.addEventListener('popstate', onPop);
254
+ navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true });
255
+ return this;
256
+ },
257
+
258
+ stop() {
259
+ window.removeEventListener('click', onClick);
260
+ window.removeEventListener('popstate', onPop);
261
+ if (typeof current.unboot === 'function') {
262
+ try { current.unboot(); } catch {}
263
+ }
264
+ return this;
265
+ },
266
+
267
+ getCurrent() {
268
+ return { ...current };
269
+ }
270
+ };
271
+ }
@@ -0,0 +1,36 @@
1
+ // boundary.js — Minimal boundary helper for UIstate
2
+ // Design goals:
3
+ // - Never throw; always resolve to a value (or undefined) so callers can remain declarative
4
+ // - Toggle loading flags via provided setLoading(boolean)
5
+ // - Report errors via onError(error)
6
+ // - Optional mapError(error) to convert errors into return values
7
+ // - Optional finally() callback invoked regardless of outcome
8
+
9
+ /**
10
+ * @template T
11
+ * @param {() => Promise<T>} fn async function to execute within the boundary
12
+ * @param {{
13
+ * setLoading?: (v:boolean)=>void,
14
+ * onError?: (error:any)=>void,
15
+ * mapError?: (error:any)=>any,
16
+ * finally?: ()=>void,
17
+ * }} opts
18
+ * @returns {Promise<T|any|undefined>}
19
+ */
20
+ export async function runWithBoundary(fn, opts = {}){
21
+ const { setLoading, onError, mapError, finally: onFinally } = opts;
22
+ try {
23
+ if (setLoading) try { setLoading(true); } catch {}
24
+ const out = await fn();
25
+ return out;
26
+ } catch (err) {
27
+ try { onError && onError(err); } catch {}
28
+ if (typeof mapError === 'function') {
29
+ try { return mapError(err); } catch { return undefined; }
30
+ }
31
+ return undefined;
32
+ } finally {
33
+ if (setLoading) try { setLoading(false); } catch {}
34
+ if (onFinally) try { onFinally(); } catch {}
35
+ }
36
+ }
@@ -0,0 +1,63 @@
1
+ // converge.js — last-write-wins (or last-local-wins) convergence helpers
2
+ // Expects a store with get/set APIs.
3
+
4
+ function pathKey(path) {
5
+ return String(path || '');
6
+ }
7
+
8
+ /**
9
+ * Apply an inbound change if its clock is newer than the local clock.
10
+ * @param {any} store
11
+ * @param {{ path: string, value: any, clock: number }} change
12
+ * @param {{ clocksPath?: string, arrayStrategy?: 'replace'|'keyedMerge', keyField?: string }} opts
13
+ */
14
+ export function applyConverge(store, change, opts = {}) {
15
+ const { path, value, clock } = change || {};
16
+ if (!path) return false;
17
+ const { clocksPath = 'clocks', arrayStrategy = 'replace', keyField = 'id' } = opts;
18
+
19
+ const ck = pathKey(path);
20
+ const localClock = store.get(`${clocksPath}.${ck}`) || 0;
21
+ if (!(clock > localClock)) return false; // reject stale or equal
22
+
23
+ // Apply the value with optional array strategy
24
+ if (Array.isArray(value)) {
25
+ if (arrayStrategy === 'replace') {
26
+ store.set(path, value);
27
+ } else {
28
+ const local = store.get(path) || [];
29
+ const result = [...local];
30
+ for (const inc of value) {
31
+ const k = inc && typeof inc === 'object' ? inc[keyField] : undefined;
32
+ if (k == null) { result.push(inc); continue; }
33
+ const sk = String(k);
34
+ const idx = result.findIndex((x) => x && x[keyField] != null && String(x[keyField]) === sk);
35
+ if (idx >= 0) {
36
+ const old = result[idx];
37
+ result[idx] = (old && typeof old === 'object' && inc && typeof inc === 'object')
38
+ ? { ...old, ...inc }
39
+ : inc;
40
+ } else {
41
+ result.push(inc);
42
+ }
43
+ }
44
+ store.set(path, result);
45
+ }
46
+ } else {
47
+ store.set(path, value);
48
+ }
49
+
50
+ // Update clock
51
+ store.set(`${clocksPath}.${ck}`, clock);
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Build an outbound change with clock and origin-id (for crossTabSync).
57
+ */
58
+ export function publishChange(store, path, value, { clocksPath = 'clocks', now = () => Date.now(), origin } = {}) {
59
+ const clock = Number(now());
60
+ store.set(path, value);
61
+ store.set(`${clocksPath}.${pathKey(path)}`, clock);
62
+ return { path, value, clock, origin };
63
+ }