tempestweb 0.1.0__py3-none-any.whl

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 (88) hide show
  1. tempestweb/__init__.py +7 -0
  2. tempestweb/_client/constants.js +17 -0
  3. tempestweb/_client/dom.js +373 -0
  4. tempestweb/_client/events.js +205 -0
  5. tempestweb/_client/livereload.js +29 -0
  6. tempestweb/_client/native/audio.js +58 -0
  7. tempestweb/_client/native/camera.js +73 -0
  8. tempestweb/_client/native/clipboard.js +46 -0
  9. tempestweb/_client/native/geolocation.js +44 -0
  10. tempestweb/_client/native/http.js +126 -0
  11. tempestweb/_client/native/index.js +158 -0
  12. tempestweb/_client/native/notifications.js +109 -0
  13. tempestweb/_client/native/share.js +48 -0
  14. tempestweb/_client/native/storage.js +103 -0
  15. tempestweb/_client/offline/store.js +316 -0
  16. tempestweb/_client/offline/sync.js +225 -0
  17. tempestweb/_client/push/web-push-client.js +190 -0
  18. tempestweb/_client/pwa/install-prompt.js +153 -0
  19. tempestweb/_client/pwa/manifest.js +253 -0
  20. tempestweb/_client/router.js +67 -0
  21. tempestweb/_client/style.js +359 -0
  22. tempestweb/_client/sw/register.js +159 -0
  23. tempestweb/_client/sw/sw.js +401 -0
  24. tempestweb/_client/tempestweb.js +151 -0
  25. tempestweb/_client/transport-sse.js +158 -0
  26. tempestweb/_client/transport-wasm.js +94 -0
  27. tempestweb/_client/transport-ws.js +154 -0
  28. tempestweb/_client/transport.js +49 -0
  29. tempestweb/_client/virtualize.js +160 -0
  30. tempestweb/_core/__init__.py +48 -0
  31. tempestweb/cli/__init__.py +83 -0
  32. tempestweb/cli/commands/__init__.py +43 -0
  33. tempestweb/cli/commands/build.py +737 -0
  34. tempestweb/cli/commands/dev.py +200 -0
  35. tempestweb/cli/commands/new.py +60 -0
  36. tempestweb/cli/commands/run.py +137 -0
  37. tempestweb/cli/config.py +95 -0
  38. tempestweb/cli/loader.py +133 -0
  39. tempestweb/cli/main.py +261 -0
  40. tempestweb/cli/scaffold.py +245 -0
  41. tempestweb/components/__init__.py +44 -0
  42. tempestweb/components/fields.py +123 -0
  43. tempestweb/components/forms.py +199 -0
  44. tempestweb/core/__init__.py +29 -0
  45. tempestweb/core/constants.py +44 -0
  46. tempestweb/devserver/__init__.py +80 -0
  47. tempestweb/devserver/http.py +184 -0
  48. tempestweb/devserver/reload.py +158 -0
  49. tempestweb/devserver/watcher.py +164 -0
  50. tempestweb/native/__init__.py +154 -0
  51. tempestweb/native/audio.py +74 -0
  52. tempestweb/native/bridges.py +171 -0
  53. tempestweb/native/camera.py +77 -0
  54. tempestweb/native/clipboard.py +54 -0
  55. tempestweb/native/dispatch.py +265 -0
  56. tempestweb/native/geolocation.py +62 -0
  57. tempestweb/native/http.py +307 -0
  58. tempestweb/native/notifications.py +120 -0
  59. tempestweb/native/share.py +91 -0
  60. tempestweb/native/storage.py +108 -0
  61. tempestweb/observability/__init__.py +111 -0
  62. tempestweb/observability/auth.py +440 -0
  63. tempestweb/observability/error_boundary.py +235 -0
  64. tempestweb/observability/feature_flags.py +360 -0
  65. tempestweb/observability/logger.py +238 -0
  66. tempestweb/observability/telemetry.py +303 -0
  67. tempestweb/pwa/__init__.py +54 -0
  68. tempestweb/pwa/icons.py +151 -0
  69. tempestweb/pwa/manifest.py +296 -0
  70. tempestweb/pwa/pyodide_vendor.py +159 -0
  71. tempestweb/runtime/__init__.py +46 -0
  72. tempestweb/runtime/events.py +124 -0
  73. tempestweb/runtime/serialize.py +257 -0
  74. tempestweb/runtime/session.py +335 -0
  75. tempestweb/runtime/wasm.py +395 -0
  76. tempestweb/runtime/wasm_main.py +166 -0
  77. tempestweb/server/__init__.py +29 -0
  78. tempestweb/server/app.py +224 -0
  79. tempestweb/server/webpush.py +345 -0
  80. tempestweb/transports/__init__.py +89 -0
  81. tempestweb/transports/base.py +231 -0
  82. tempestweb/transports/sse.py +234 -0
  83. tempestweb/transports/wasm.py +191 -0
  84. tempestweb/transports/websocket.py +184 -0
  85. tempestweb-0.1.0.dist-info/METADATA +110 -0
  86. tempestweb-0.1.0.dist-info/RECORD +88 -0
  87. tempestweb-0.1.0.dist-info/WHEEL +4 -0
  88. tempestweb-0.1.0.dist-info/entry_points.txt +2 -0
tempestweb/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """tempestweb — build web apps in typed Python (WASM + server modes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # The root package re-exports nothing directly; import from the subpackages
6
+ # (tempestweb.native, tempestweb.runtime, tempestweb.transports, ...) instead.
7
+ __all__: list[str] = []
@@ -0,0 +1,17 @@
1
+ // constants.js — shared client-side constants (tunables used across modules).
2
+ //
3
+ // Module-private values stay in their module; this file holds the few constants
4
+ // that are shared or are worth naming/tuning in one place: gesture-recognition
5
+ // thresholds and the virtualization stylesheet id.
6
+
7
+ /** Minimum pointer travel (px) for a drag to count as a swipe. */
8
+ export const SWIPE_MIN_PX = 30;
9
+
10
+ /** Hold time (ms, with little travel) for a press to count as a long press. */
11
+ export const LONG_PRESS_MS = 500;
12
+
13
+ /** Widget type tag that opts into gesture events (tap/swipe/long_press). */
14
+ export const GESTURE_TYPE = "GestureDetector";
15
+
16
+ /** Id of the injected stylesheet that carries virtualized-list spacer heights. */
17
+ export const VIRT_STYLE_ID = "tw-virt-styles";
@@ -0,0 +1,373 @@
1
+ // dom.js — build a DOM tree from the Node IR and apply patch batches to it. W1.
2
+ //
3
+ // buildElement(node) turns one serialized Node into a live DOM element (recursing
4
+ // into children); applyPatches(root, patches) mutates a tree in place. Given the
5
+ // DOM built from node_initial.json, applying patches_all_kinds.json yields the
6
+ // expected DOM. Patch kinds are distinguished by key presence (see transport.js):
7
+ // - set_props present -> Update
8
+ // - node + index present -> Insert
9
+ // - index only -> Remove
10
+ // - order present -> Reorder
11
+ // - node without index -> Replace
12
+ //
13
+ // Every element carries `data-tw-key` when its Node has a key, so events.js can
14
+ // read the originating widget key via event delegation. Verify against
15
+ // ../tests/fixtures/ in tests/client/ (jsdom). No framework.
16
+
17
+ import { styleToCss } from "./style.js";
18
+
19
+ /** Attribute holding a widget's stable reconciliation key. */
20
+ export const KEY_ATTR = "data-tw-key";
21
+ /** Attribute holding a widget's IR type (so patches can re-key/inspect it). */
22
+ export const TYPE_ATTR = "data-tw-type";
23
+
24
+ // Each widget type maps to one HTML tag. Container-like widgets are <div>; Text is
25
+ // an inline <span>; Button is a real <button>. Unknown types fall back to <div> so
26
+ // a new core widget renders (as a generic box) rather than throwing.
27
+ const TAG_BY_TYPE = Object.freeze({
28
+ Column: "div",
29
+ Row: "div",
30
+ Container: "div",
31
+ Stack: "div",
32
+ Text: "span",
33
+ Button: "button",
34
+ Input: "input",
35
+ Checkbox: "input",
36
+ Image: "img",
37
+ });
38
+
39
+ /**
40
+ * Resolve the HTML tag name for an IR widget type.
41
+ * @param {string} type The widget type ("Column", "Text", "Button", ...).
42
+ * @returns {string} The HTML tag name (defaults to "div").
43
+ */
44
+ function tagForType(type) {
45
+ return TAG_BY_TYPE[type] ?? "div";
46
+ }
47
+
48
+ /**
49
+ * Apply a node's props to an element: style, key/type attributes and text.
50
+ *
51
+ * `content` (Text) and `label` (Button) become the element's text. The `style`
52
+ * prop is translated by {@link styleToCss} into the inline `style` attribute. The
53
+ * widget `key` and `type` are mirrored onto data attributes for event delegation.
54
+ *
55
+ * @param {HTMLElement} el The target element.
56
+ * @param {string} type The widget type.
57
+ * @param {?string} key The widget key, or null.
58
+ * @param {Object} props The widget props (may include `style`).
59
+ * @returns {void}
60
+ */
61
+ function applyNodeShape(el, type, key, props) {
62
+ el.setAttribute(TYPE_ATTR, type);
63
+ if (key != null) {
64
+ el.setAttribute(KEY_ATTR, key);
65
+ } else {
66
+ el.removeAttribute(KEY_ATTR);
67
+ }
68
+ applyProps(el, props ?? {});
69
+ }
70
+
71
+ /**
72
+ * Apply a bag of props onto an element (style + text-bearing props).
73
+ *
74
+ * Used both when first building an element and by Update patches. `style` is
75
+ * (re)translated to CSS; `content`/`label` set the text. Other keys are widget
76
+ * metadata the DOM does not render and are ignored.
77
+ *
78
+ * @param {HTMLElement} el The target element.
79
+ * @param {Object} props The props to apply.
80
+ * @returns {void}
81
+ */
82
+ function applyProps(el, props) {
83
+ if ("style" in props) {
84
+ const css = styleToCss(props.style);
85
+ if (css) {
86
+ el.style.cssText = css;
87
+ } else {
88
+ el.removeAttribute("style");
89
+ }
90
+ }
91
+ const type = el.getAttribute(TYPE_ATTR);
92
+ // Text-bearing props. A Checkbox is an <input> and cannot hold text, so its
93
+ // label rides as an accessible name instead of textContent.
94
+ if ("content" in props) {
95
+ el.textContent = props.content == null ? "" : String(props.content);
96
+ }
97
+ if ("label" in props) {
98
+ if (type === "Checkbox") {
99
+ el.setAttribute("aria-label", props.label == null ? "" : String(props.label));
100
+ } else {
101
+ el.textContent = props.label == null ? "" : String(props.label);
102
+ }
103
+ }
104
+ applyControlProps(el, type, props);
105
+ applyA11yProps(el, props);
106
+ applyLazyProps(el, type, props);
107
+ }
108
+
109
+ /**
110
+ * Apply accessibility props (semantics + focus) onto an element.
111
+ *
112
+ * Maps the core's renderer-agnostic a11y model to ARIA/DOM: ``semantics.label``
113
+ * → ``aria-label``, ``semantics.role`` → ``role``, ``semantics.hint`` →
114
+ * ``aria-description``; ``focus_order`` sets an explicit ``tabindex`` and
115
+ * ``focusable`` toggles a default one (``0`` to include, ``-1`` to exclude).
116
+ *
117
+ * @param {HTMLElement} el The target element.
118
+ * @param {Object} props The props to apply.
119
+ * @returns {void}
120
+ */
121
+ function applyA11yProps(el, props) {
122
+ const sem = props.semantics;
123
+ if (sem != null && typeof sem === "object") {
124
+ if (sem.label != null) {
125
+ el.setAttribute("aria-label", String(sem.label));
126
+ }
127
+ if (sem.role != null) {
128
+ el.setAttribute("role", String(sem.role));
129
+ }
130
+ if (sem.hint != null) {
131
+ el.setAttribute("aria-description", String(sem.hint));
132
+ }
133
+ }
134
+ if (props.focus_order != null) {
135
+ el.setAttribute("tabindex", String(props.focus_order));
136
+ } else if (props.focusable === true) {
137
+ el.setAttribute("tabindex", "0");
138
+ } else if (props.focusable === false) {
139
+ el.setAttribute("tabindex", "-1");
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Apply form-control / media props (Input, Checkbox, Image) onto an element.
145
+ *
146
+ * Maps the widget's typed props onto the right DOM property/attribute so the
147
+ * control is actually interactive (a real <input> holding `value`, a checkbox
148
+ * reflecting `checked`, an <img> pointing at `src`). No-ops for other types.
149
+ *
150
+ * @param {HTMLElement} el The target element.
151
+ * @param {?string} type The widget type (from the data-tw-type attribute).
152
+ * @param {Object} props The props to apply.
153
+ * @returns {void}
154
+ */
155
+ // Virtualized list widgets: rendered as scroll viewports whose visible window
156
+ // the runtime slides in response to scroll events (see client/virtualize.js).
157
+ const LAZY_TYPES = Object.freeze(["LazyColumn", "LazyRow", "LazyGrid"]);
158
+
159
+ /**
160
+ * Mark a virtualized list element and mirror its windowing metadata to data
161
+ * attributes so the scroll controller can compute the visible window.
162
+ *
163
+ * @param {HTMLElement} el The target element.
164
+ * @param {?string} type The widget type.
165
+ * @param {Object} props The props to apply.
166
+ * @returns {void}
167
+ */
168
+ function applyLazyProps(el, type, props) {
169
+ if (type == null || !LAZY_TYPES.includes(type)) {
170
+ return;
171
+ }
172
+ const horizontal = type === "LazyRow";
173
+ // A bounded, scrollable viewport: the app's Style sets the extent (e.g.
174
+ // height); overflow scrolls the materialized window, and scrolling past the
175
+ // edge slides the window (see client/virtualize.js). min-height:0 stops a flex
176
+ // parent from growing the viewport to fit its content instead of scrolling.
177
+ el.style.overflowY = horizontal ? "hidden" : "auto";
178
+ el.style.overflowX = horizontal ? "auto" : "hidden";
179
+ el.style.minHeight = "0";
180
+ if ("item_count" in props && props.item_count != null) {
181
+ el.setAttribute("data-tw-item-count", String(props.item_count));
182
+ }
183
+ if ("window_size" in props && props.window_size != null) {
184
+ el.setAttribute("data-tw-window-size", String(props.window_size));
185
+ }
186
+ // window is [start, end) when slid, or null (start at 0).
187
+ const start = Array.isArray(props.window) ? props.window[0] : 0;
188
+ el.setAttribute("data-tw-window-start", String(start ?? 0));
189
+ }
190
+
191
+ function applyControlProps(el, type, props) {
192
+ if (type === "Input") {
193
+ el.setAttribute("type", props.secure ? "password" : "text");
194
+ if ("value" in props) {
195
+ el.value = props.value == null ? "" : String(props.value);
196
+ }
197
+ if ("placeholder" in props && props.placeholder != null) {
198
+ el.setAttribute("placeholder", String(props.placeholder));
199
+ }
200
+ if (props.max_length != null) {
201
+ el.setAttribute("maxlength", String(props.max_length));
202
+ }
203
+ } else if (type === "Checkbox") {
204
+ el.setAttribute("type", "checkbox");
205
+ if ("checked" in props) {
206
+ el.checked = Boolean(props.checked);
207
+ }
208
+ } else if (type === "Image") {
209
+ if ("src" in props && props.src != null) {
210
+ el.setAttribute("src", String(props.src));
211
+ }
212
+ if ("alt" in props && props.alt != null) {
213
+ el.setAttribute("alt", String(props.alt));
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Build a DOM element from an IR node (recursing into its children).
220
+ * @param {import("./transport.js").Node} node The serialized node.
221
+ * @returns {HTMLElement} The constructed element subtree.
222
+ */
223
+ export function buildElement(node) {
224
+ const el = document.createElement(tagForType(node.type));
225
+ applyNodeShape(el, node.type, node.key ?? null, node.props ?? {});
226
+ for (const child of node.children ?? []) {
227
+ el.appendChild(buildElement(child));
228
+ }
229
+ return el;
230
+ }
231
+
232
+ /**
233
+ * Walk a path of child indices from `root` down to the target element.
234
+ * @param {HTMLElement} root The root element.
235
+ * @param {number[]} path Child indices from the root ([] = root).
236
+ * @returns {HTMLElement} The element at `path`.
237
+ * @throws {RangeError} If an index does not resolve to an element.
238
+ */
239
+ function resolvePath(root, path) {
240
+ /** @type {HTMLElement} */
241
+ let el = root;
242
+ for (const index of path) {
243
+ const next = el.children[index];
244
+ if (next == null) {
245
+ throw new RangeError(`tempestweb: patch path out of range at index ${index}`);
246
+ }
247
+ el = /** @type {HTMLElement} */ (next);
248
+ }
249
+ return el;
250
+ }
251
+
252
+ /**
253
+ * Apply a single Update patch: set/unset props on the node at `path`.
254
+ * @param {HTMLElement} root The root element.
255
+ * @param {{path:number[], set_props?:Object, unset_props?:string[]}} patch The patch.
256
+ * @returns {void}
257
+ */
258
+ function applyUpdate(root, patch) {
259
+ const el = resolvePath(root, patch.path);
260
+ if (patch.set_props) {
261
+ applyProps(el, patch.set_props);
262
+ }
263
+ for (const key of patch.unset_props ?? []) {
264
+ if (key === "style") {
265
+ el.removeAttribute("style");
266
+ } else if (key === "content" || key === "label") {
267
+ el.textContent = "";
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Apply a single Insert patch: insert a new child at `index` under `path`.
274
+ * @param {HTMLElement} root The root element.
275
+ * @param {{path:number[], index:number, node:import("./transport.js").Node}} patch
276
+ * @returns {void}
277
+ */
278
+ function applyInsert(root, patch) {
279
+ const parent = resolvePath(root, patch.path);
280
+ const child = buildElement(patch.node);
281
+ const ref = parent.children[patch.index] ?? null;
282
+ parent.insertBefore(child, ref);
283
+ }
284
+
285
+ /**
286
+ * Apply a single Remove patch: remove the child at `index` under `path`.
287
+ * @param {HTMLElement} root The root element.
288
+ * @param {{path:number[], index:number}} patch The patch.
289
+ * @returns {void}
290
+ */
291
+ function applyRemove(root, patch) {
292
+ const parent = resolvePath(root, patch.path);
293
+ const child = parent.children[patch.index];
294
+ if (child != null) {
295
+ parent.removeChild(child);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Apply a single Reorder patch: new child `i` = old child `order[i]`.
301
+ *
302
+ * Snapshots the current children first so indices in `order` refer to the
303
+ * pre-reorder positions, then re-appends them in the requested order.
304
+ *
305
+ * @param {HTMLElement} root The root element.
306
+ * @param {{path:number[], order:number[]}} patch The patch.
307
+ * @returns {void}
308
+ */
309
+ function applyReorder(root, patch) {
310
+ const parent = resolvePath(root, patch.path);
311
+ const before = Array.from(parent.children);
312
+ for (const index of patch.order) {
313
+ const child = before[index];
314
+ if (child != null) {
315
+ parent.appendChild(child);
316
+ }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Apply a single Replace patch: swap the element at `path` for a fresh subtree.
322
+ * @param {HTMLElement} root The root element.
323
+ * @param {{path:number[], node:import("./transport.js").Node}} patch The patch.
324
+ * @returns {void}
325
+ */
326
+ function applyReplace(root, patch) {
327
+ const old = resolvePath(root, patch.path);
328
+ const fresh = buildElement(patch.node);
329
+ if (old.parentNode) {
330
+ old.parentNode.replaceChild(fresh, old);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Classify a patch by key presence and dispatch it to the right applier.
336
+ * @param {HTMLElement} root The root element.
337
+ * @param {import("./transport.js").Patch} patch The patch to apply.
338
+ * @returns {void}
339
+ * @throws {TypeError} If the patch shape is unrecognized.
340
+ */
341
+ function applyPatch(root, patch) {
342
+ if ("set_props" in patch || "unset_props" in patch) {
343
+ applyUpdate(root, /** @type {any} */ (patch));
344
+ } else if ("order" in patch) {
345
+ applyReorder(root, /** @type {any} */ (patch));
346
+ } else if ("node" in patch && "index" in patch) {
347
+ applyInsert(root, /** @type {any} */ (patch));
348
+ } else if ("node" in patch) {
349
+ applyReplace(root, /** @type {any} */ (patch));
350
+ } else if ("index" in patch) {
351
+ applyRemove(root, /** @type {any} */ (patch));
352
+ } else {
353
+ throw new TypeError(`tempestweb: unrecognized patch shape ${JSON.stringify(patch)}`);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Apply a coalesced batch of patches to the DOM tree rooted at `root`.
359
+ *
360
+ * The reconciler coalesces a tick's mutations into one ordered list; the whole
361
+ * list is applied before the next frame. Patches are applied in array order — the
362
+ * order the core emitted them — so index-relative ops (insert/remove/reorder)
363
+ * stay consistent.
364
+ *
365
+ * @param {HTMLElement} root The mounted root element.
366
+ * @param {import("./transport.js").Patch[]} patches The tick's patch batch.
367
+ * @returns {void}
368
+ */
369
+ export function applyPatches(root, patches) {
370
+ for (const patch of patches) {
371
+ applyPatch(root, patch);
372
+ }
373
+ }
@@ -0,0 +1,205 @@
1
+ // events.js — capture DOM events and route them through a transport. PHASE W3.
2
+ //
3
+ // bindEvents(root, transport) installs delegated listeners on `root` so that a
4
+ // click on a keyed element (e.g. a Button) calls
5
+ // transport.sendEvent({ type: "click", key, payload })
6
+ // reading the widget key from the `data-tw-key` attribute set by dom.js. It maps
7
+ // the DOM events click/input/change/submit onto the TWEvent shape in transport.js.
8
+ // Delegation means a single listener per event type survives patch churn (children
9
+ // are added/removed/replaced without rebinding).
10
+ //
11
+ // Verify in tests/client/ with a mock transport (jsdom dispatchEvent).
12
+
13
+ import { GESTURE_TYPE, LONG_PRESS_MS, SWIPE_MIN_PX } from "./constants.js";
14
+ import { KEY_ATTR, TYPE_ATTR } from "./dom.js";
15
+
16
+ // The DOM event names captured and their corresponding TWEvent `type`. Identity
17
+ // here, but kept explicit so the captured set is the contract, not "whatever fires".
18
+ const EVENT_TYPES = Object.freeze({
19
+ click: "click",
20
+ input: "input",
21
+ change: "change",
22
+ submit: "submit",
23
+ });
24
+
25
+ /**
26
+ * Find the nearest ancestor-or-self element carrying a widget key.
27
+ *
28
+ * Delegation fires on the deepest target; the keyed widget may be that element or
29
+ * an ancestor (e.g. a click lands on text inside a keyed Button). Walks up until a
30
+ * `data-tw-key` is found or the delegation root is passed.
31
+ *
32
+ * @param {EventTarget|null} target The event's target node.
33
+ * @param {HTMLElement} root The delegation root (search stops above it).
34
+ * @returns {?string} The widget key, or null when none is keyed.
35
+ */
36
+ function keyedAncestor(target, root) {
37
+ let node = /** @type {Node|null} */ (target);
38
+ while (node != null && node.nodeType !== 1) {
39
+ node = node.parentNode;
40
+ }
41
+ let el = /** @type {HTMLElement|null} */ (node);
42
+ while (el != null) {
43
+ if (el.hasAttribute && el.hasAttribute(KEY_ATTR)) {
44
+ return el.getAttribute(KEY_ATTR);
45
+ }
46
+ if (el === root) {
47
+ break;
48
+ }
49
+ el = el.parentElement;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Find the nearest ancestor-or-self GestureDetector element (keyed + typed).
56
+ *
57
+ * @param {EventTarget|null} target The event's target node.
58
+ * @param {HTMLElement} root The delegation root.
59
+ * @returns {?string} The gesture widget's key, or null.
60
+ */
61
+ function gestureAncestor(target, root) {
62
+ let node = /** @type {Node|null} */ (target);
63
+ while (node != null && node.nodeType !== 1) {
64
+ node = node.parentNode;
65
+ }
66
+ let el = /** @type {HTMLElement|null} */ (node);
67
+ while (el != null) {
68
+ if (
69
+ el.getAttribute &&
70
+ el.getAttribute(TYPE_ATTR) === GESTURE_TYPE &&
71
+ el.hasAttribute(KEY_ATTR)
72
+ ) {
73
+ return el.getAttribute(KEY_ATTR);
74
+ }
75
+ if (el === root) {
76
+ break;
77
+ }
78
+ el = el.parentElement;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Classify a completed pointer interaction into a gesture TWEvent.
85
+ *
86
+ * Swipe wins when travel crosses `SWIPE_MIN_PX` (direction from the dominant
87
+ * axis); otherwise a hold past `LONG_PRESS_MS` is a long press, and a quick
88
+ * release is a tap. Coordinates are the press origin.
89
+ *
90
+ * @param {{x:number, y:number, t:number}} start The pointerdown origin.
91
+ * @param {{x:number, y:number, t:number}} end The pointerup point.
92
+ * @returns {{type:string, payload:Object}} The gesture type + payload.
93
+ */
94
+ function classifyGesture(start, end) {
95
+ const dx = Math.round(end.x - start.x);
96
+ const dy = Math.round(end.y - start.y);
97
+ const dist = Math.hypot(dx, dy);
98
+ if (dist >= SWIPE_MIN_PX) {
99
+ const horizontal = Math.abs(dx) >= Math.abs(dy);
100
+ const direction = horizontal
101
+ ? dx > 0
102
+ ? "right"
103
+ : "left"
104
+ : dy > 0
105
+ ? "down"
106
+ : "up";
107
+ return { type: "swipe", payload: { direction, dx, dy } };
108
+ }
109
+ if (end.t - start.t >= LONG_PRESS_MS) {
110
+ return { type: "long_press", payload: { x: Math.round(start.x), y: Math.round(start.y) } };
111
+ }
112
+ return { type: "tap", payload: { x: Math.round(start.x), y: Math.round(start.y) } };
113
+ }
114
+
115
+ /**
116
+ * Build the TWEvent payload for a captured DOM event.
117
+ *
118
+ * `input`/`change` carry the control's current `value`; other event types carry an
119
+ * empty payload (the key alone identifies the action server-side).
120
+ *
121
+ * @param {string} domType The DOM event type ("click", "input", ...).
122
+ * @param {EventTarget|null} target The event target.
123
+ * @returns {{value?: string}} The TWEvent `payload` ({ value } for input/change, else {}).
124
+ */
125
+ function payloadFor(domType, target) {
126
+ if (domType === "input" || domType === "change") {
127
+ const value = target && "value" in target ? target.value : undefined;
128
+ if (value !== undefined) {
129
+ return { value };
130
+ }
131
+ }
132
+ return {};
133
+ }
134
+
135
+ /**
136
+ * Bind delegated DOM event listeners on `root` that forward to the transport.
137
+ *
138
+ * One listener per captured event type is attached to `root`; each resolves the
139
+ * originating widget key by walking up to the nearest `data-tw-key`, and — when a
140
+ * keyed widget owns the event — calls `transport.sendEvent` with the TWEvent.
141
+ * Events on unkeyed elements are ignored (no key = nothing for Python to resolve).
142
+ *
143
+ * @param {HTMLElement} root The mounted root element to delegate from.
144
+ * @param {import("./transport.js").Transport} transport The event sink.
145
+ * @returns {() => void} An unbind function that removes every listener.
146
+ */
147
+ export function bindEvents(root, transport) {
148
+ /** @type {Array<[string, (event: Event) => void]>} */
149
+ const bound = [];
150
+ for (const domType of Object.keys(EVENT_TYPES)) {
151
+ /** @param {Event} event */
152
+ const handler = (event) => {
153
+ const key = keyedAncestor(event.target, root);
154
+ if (key == null) {
155
+ return;
156
+ }
157
+ transport.sendEvent({
158
+ type: EVENT_TYPES[domType],
159
+ key,
160
+ payload: payloadFor(domType, event.target),
161
+ });
162
+ };
163
+ root.addEventListener(domType, handler);
164
+ bound.push([domType, handler]);
165
+ }
166
+
167
+ // Gesture recognition: pair a pointerdown over a GestureDetector with its
168
+ // pointerup to emit tap / swipe / long_press. Tracked per pointerId so
169
+ // overlapping pointers don't clobber each other.
170
+ /** @type {Map<number, {key: string, x: number, y: number, t: number}>} */
171
+ const pending = new Map();
172
+ const now = () => (globalThis.performance?.now?.() ?? 0);
173
+
174
+ /** @param {PointerEvent} event */
175
+ const onPointerDown = (event) => {
176
+ const key = gestureAncestor(event.target, root);
177
+ if (key == null) {
178
+ return;
179
+ }
180
+ pending.set(event.pointerId, { key, x: event.clientX, y: event.clientY, t: now() });
181
+ };
182
+ /** @param {PointerEvent} event */
183
+ const onPointerUp = (event) => {
184
+ const start = pending.get(event.pointerId);
185
+ if (start === undefined) {
186
+ return;
187
+ }
188
+ pending.delete(event.pointerId);
189
+ const { type, payload } = classifyGesture(start, {
190
+ x: event.clientX,
191
+ y: event.clientY,
192
+ t: now(),
193
+ });
194
+ transport.sendEvent({ type, key: start.key, payload });
195
+ };
196
+ root.addEventListener("pointerdown", onPointerDown);
197
+ root.addEventListener("pointerup", onPointerUp);
198
+ bound.push(["pointerdown", onPointerDown], ["pointerup", onPointerUp]);
199
+
200
+ return () => {
201
+ for (const [domType, handler] of bound) {
202
+ root.removeEventListener(domType, handler);
203
+ }
204
+ };
205
+ }
@@ -0,0 +1,29 @@
1
+ // livereload.js — dev-only auto-reload over Server-Sent Events.
2
+ //
3
+ // Injected into the served index.html by the dev server (`tempestweb dev`) and
4
+ // NEVER bundled into a production build. It opens an EventSource to the dev
5
+ // server's `/__livereload` endpoint; when a watched file changes the server
6
+ // rebuilds the artifact and emits a `reload` event, and this reloads the tab so
7
+ // the fresh bundle (Mode A) or fresh app (Mode B) takes effect.
8
+ //
9
+ // JSDoc-typed, pure JS, no build step — same conventions as the rest of client/.
10
+
11
+ /**
12
+ * Connect to the dev server's livereload stream and reload on each `reload`
13
+ * event. The browser's EventSource reconnects automatically if the dev server
14
+ * restarts, so a server bounce just resumes the stream.
15
+ *
16
+ * @param {string} [url] The SSE endpoint. Defaults to "/__livereload".
17
+ * @returns {EventSource} The open EventSource (so callers can close it in tests).
18
+ */
19
+ export function connectLiveReload(url = "/__livereload") {
20
+ const source = new EventSource(url);
21
+ source.addEventListener("reload", () => {
22
+ // A rebuild completed server-side before this event fired; reload to pick it
23
+ // up. location.reload() re-fetches index.html and every (cache-busted) asset.
24
+ globalThis.location.reload();
25
+ });
26
+ return source;
27
+ }
28
+
29
+ connectLiveReload();
@@ -0,0 +1,58 @@
1
+ // native/audio.js — browser HTMLAudioElement glue for the N1 audio capability.
2
+ //
3
+ // One Audio element per channel; a new sound on a channel replaces the previous.
4
+ // Browsers block autoplay until the first user gesture — a blocked play resolves
5
+ // `{ played:false, blocked:true }` instead of throwing (graceful degradation).
6
+
7
+ import { CapabilityError } from "./index.js";
8
+
9
+ /** @type {Map<string, HTMLAudioElement>} per-channel players. */
10
+ const players = new Map();
11
+
12
+ /**
13
+ * Play a short sound on a channel.
14
+ * @param {{src:string,volume:number,channel:string}} args
15
+ * @param {import("./index.js").NativeDeps} deps
16
+ * @returns {Promise<{played:boolean,blocked:boolean,channel:string}>}
17
+ */
18
+ export async function audioPlay(args, deps) {
19
+ const channel = args.channel || "default";
20
+ const AudioCtor = deps.Audio || /** @type {any} */ (globalThis).Audio;
21
+ if (!AudioCtor) throw new CapabilityError("unavailable", "Audio is not available");
22
+
23
+ const previous = players.get(channel);
24
+ if (previous) {
25
+ previous.pause();
26
+ }
27
+ const el = new AudioCtor(args.src);
28
+ el.volume = typeof args.volume === "number" ? args.volume : 1.0;
29
+ players.set(channel, el);
30
+
31
+ try {
32
+ const maybe = el.play();
33
+ if (maybe && typeof maybe.then === "function") await maybe;
34
+ return { played: true, blocked: false, channel };
35
+ } catch {
36
+ // NotAllowedError (autoplay blocked) is normal before a user gesture.
37
+ return { played: false, blocked: true, channel };
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Stop and reset playback on a channel.
43
+ * @param {{channel:string}} args
44
+ * @returns {Promise<Object>}
45
+ */
46
+ export async function audioStop(args) {
47
+ const channel = args.channel || "default";
48
+ const el = players.get(channel);
49
+ if (el) {
50
+ el.pause();
51
+ try {
52
+ el.currentTime = 0;
53
+ } catch {
54
+ // jsdom Audio may not implement currentTime; ignore.
55
+ }
56
+ }
57
+ return {};
58
+ }