@zenithbuild/runtime 0.7.4 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ It does not define a public virtual-DOM framework API.
23
23
  - **Fine-Grained Reactivity**: signal/state/effect primitives used by emitted code.
24
24
  - **Hydration**: deterministic client-side hydration for server-rendered HTML.
25
25
  - **Lifecycle Cleanup**: explicit mount/effect cleanup semantics.
26
+ - **Narrow Presence Helper**: canonical `zenPresence(...)` plus optional `presence(...)` alias for ref-owned always-mounted nodes when app code explicitly imports the runtime package.
26
27
 
27
28
  ## Usage
28
29
  This package is installed as an internal framework dependency. App code should normally use the public Zenith surface instead of importing `@zenithbuild/runtime` directly.
@@ -171,6 +171,7 @@ export { signal } // Create reactive signal
171
171
  export { state } // Create deep reactive state proxy
172
172
  export { zeneffect } // Canonical reactive effect subscription
173
173
  export { zenMount } // Canonical component bootstrap lifecycle hook
174
+ export { zenPresence } // Explicit import for ref-owned always-mounted node presence
174
175
  export { zenWindow } // Canonical SSR-safe global window access
175
176
  export { zenDocument } // Canonical SSR-safe global document access
176
177
  export { hydrate } // Mount page module into container
@@ -182,10 +183,17 @@ For developer convenience, the runtime also exports optional, standard-named ali
182
183
  ```js
183
184
  export { effect } // Alias for zeneffect
184
185
  export { mount } // Alias for zenMount
186
+ export { presence } // Alias for zenPresence
185
187
  export { window } // Alias for zenWindow
186
188
  export { document } // Alias for zenDocument
187
189
  ```
188
190
 
191
+ `zenPresence` is the canonical presence helper name. `presence` is an optional convenience alias only.
192
+
193
+ `zenPresence` is intentionally not a compiler-owned implicit global. It is a narrow runtime import used with `zenMount(...)` + `zeneffect(...)` for always-mounted nodes only.
194
+
195
+ Its options may include narrow node-local coordination such as `onPhaseChange`, but it does not widen into fragment retention, focus trapping, or a generalized accessibility framework.
196
+
189
197
  ---
190
198
 
191
199
  ## 9. Alignment Verification
package/dist/index.d.ts CHANGED
@@ -2,5 +2,6 @@ export { signal } from "./signal.js";
2
2
  export { state } from "./state.js";
3
3
  export { hydrate } from "./hydrate.js";
4
4
  export { effect, mount, zeneffect, zenEffect, zenMount } from "./zeneffect.js";
5
+ export { zenPresence, presence } from "./presence.js";
5
6
  export { window, document, zenWindow, zenDocument } from "./env.js";
6
7
  export { zenOn, zenResize, collectRefs } from "./platform.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { signal } from './signal.js';
2
2
  export { state } from './state.js';
3
3
  export { effect, mount, zeneffect, zenEffect, zenMount } from './zeneffect.js';
4
+ export { zenPresence, presence } from './presence.js';
4
5
  export { hydrate } from './hydrate.js';
5
6
  export { window, document, zenWindow, zenDocument } from './env.js';
6
7
  export { zenOn, zenResize, collectRefs } from './platform.js';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Ref-owned presence controller for always-mounted nodes.
3
+ *
4
+ * Canonical pattern:
5
+ * - create once per ref
6
+ * - call `presence.mount()` inside `zenMount`
7
+ * - drive `presence.setPresent(next)` from reactive state
8
+ *
9
+ * @template {Element} T
10
+ * @param {{ current?: T | null }} ref
11
+ * @param {{ timeoutMs?: number, onPhaseChange?: ((phase: ZenPresencePhase, context: { node: T | null, previousPhase: ZenPresencePhase | null, present: boolean }) => void) } | null | undefined} [options]
12
+ * @returns {{
13
+ * mount: () => () => void,
14
+ * destroy: () => void,
15
+ * getPhase: () => ZenPresencePhase,
16
+ * setPresent: (nextPresent: boolean) => void
17
+ * }}
18
+ */
19
+ export function zenPresence<T extends Element>(ref: {
20
+ current?: T | null;
21
+ }, options?: {
22
+ timeoutMs?: number;
23
+ onPhaseChange?: ((phase: ZenPresencePhase, context: {
24
+ node: T | null;
25
+ previousPhase: ZenPresencePhase | null;
26
+ present: boolean;
27
+ }) => void);
28
+ } | null | undefined): {
29
+ mount: () => () => void;
30
+ destroy: () => void;
31
+ getPhase: () => ZenPresencePhase;
32
+ setPresent: (nextPresent: boolean) => void;
33
+ };
34
+ /**
35
+ * Ref-owned presence controller for always-mounted nodes.
36
+ *
37
+ * Canonical pattern:
38
+ * - create once per ref
39
+ * - call `presence.mount()` inside `zenMount`
40
+ * - drive `presence.setPresent(next)` from reactive state
41
+ *
42
+ * @template {Element} T
43
+ * @param {{ current?: T | null }} ref
44
+ * @param {{ timeoutMs?: number, onPhaseChange?: ((phase: ZenPresencePhase, context: { node: T | null, previousPhase: ZenPresencePhase | null, present: boolean }) => void) } | null | undefined} [options]
45
+ * @returns {{
46
+ * mount: () => () => void,
47
+ * destroy: () => void,
48
+ * getPhase: () => ZenPresencePhase,
49
+ * setPresent: (nextPresent: boolean) => void
50
+ * }}
51
+ */
52
+ export function presence<T extends Element>(ref: {
53
+ current?: T | null;
54
+ }, options?: {
55
+ timeoutMs?: number;
56
+ onPhaseChange?: ((phase: ZenPresencePhase, context: {
57
+ node: T | null;
58
+ previousPhase: ZenPresencePhase | null;
59
+ present: boolean;
60
+ }) => void);
61
+ } | null | undefined): {
62
+ mount: () => () => void;
63
+ destroy: () => void;
64
+ getPhase: () => ZenPresencePhase;
65
+ setPresent: (nextPresent: boolean) => void;
66
+ };
67
+ export type ZenPresencePhase = "hidden" | "entering" | "present" | "exiting";
@@ -0,0 +1,297 @@
1
+ // @ts-nocheck
2
+ import { zenOn } from './platform.js';
3
+ /**
4
+ * @typedef {'hidden' | 'entering' | 'present' | 'exiting'} ZenPresencePhase
5
+ */
6
+ function isRefLike(value) {
7
+ return !!value && typeof value === 'object' && 'current' in value;
8
+ }
9
+ function normalizeOptions(options) {
10
+ if (options === undefined || options === null) {
11
+ return {
12
+ timeoutMs: undefined,
13
+ onPhaseChange: null
14
+ };
15
+ }
16
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
17
+ throw new Error('[Zenith Runtime] zenPresence(ref, options) requires an options object when provided');
18
+ }
19
+ if (options.timeoutMs !== undefined) {
20
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0) {
21
+ throw new Error('[Zenith Runtime] zenPresence options.timeoutMs must be a non-negative number');
22
+ }
23
+ }
24
+ if (options.onPhaseChange !== undefined && typeof options.onPhaseChange !== 'function') {
25
+ throw new Error('[Zenith Runtime] zenPresence options.onPhaseChange must be a function when provided');
26
+ }
27
+ return {
28
+ timeoutMs: options.timeoutMs === undefined ? undefined : Math.floor(options.timeoutMs),
29
+ onPhaseChange: typeof options.onPhaseChange === 'function' ? options.onPhaseChange : null
30
+ };
31
+ }
32
+ function parseCssTimeToken(token) {
33
+ const value = String(token || '').trim();
34
+ if (value.length === 0) {
35
+ return 0;
36
+ }
37
+ if (value.endsWith('ms')) {
38
+ const ms = Number.parseFloat(value.slice(0, -2));
39
+ return Number.isFinite(ms) ? Math.max(0, ms) : 0;
40
+ }
41
+ if (value.endsWith('s')) {
42
+ const seconds = Number.parseFloat(value.slice(0, -1));
43
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : 0;
44
+ }
45
+ const numeric = Number.parseFloat(value);
46
+ return Number.isFinite(numeric) ? Math.max(0, numeric) : 0;
47
+ }
48
+ function parseCssTimeList(value) {
49
+ return String(value || '')
50
+ .split(',')
51
+ .map((token) => parseCssTimeToken(token))
52
+ .filter((candidate) => Number.isFinite(candidate));
53
+ }
54
+ function computeMaxCssTotal(durations, delays) {
55
+ if (!Array.isArray(durations) || durations.length === 0) {
56
+ return 0;
57
+ }
58
+ let maxTotal = 0;
59
+ for (let index = 0; index < durations.length; index += 1) {
60
+ const duration = durations[index] || 0;
61
+ const delay = Array.isArray(delays) && delays.length > 0
62
+ ? delays[index % delays.length] || 0
63
+ : 0;
64
+ const total = duration + delay;
65
+ if (total > maxTotal) {
66
+ maxTotal = total;
67
+ }
68
+ }
69
+ return maxTotal;
70
+ }
71
+ function resolveFallbackTimeoutMs(node, explicitTimeoutMs) {
72
+ if (Number.isFinite(explicitTimeoutMs)) {
73
+ return explicitTimeoutMs;
74
+ }
75
+ const activeWindow = node?.ownerDocument?.defaultView;
76
+ if (!activeWindow || typeof activeWindow.getComputedStyle !== 'function') {
77
+ return 34;
78
+ }
79
+ const styles = activeWindow.getComputedStyle(node);
80
+ const transitionTotal = computeMaxCssTotal(parseCssTimeList(styles.transitionDuration), parseCssTimeList(styles.transitionDelay));
81
+ const animationTotal = computeMaxCssTotal(parseCssTimeList(styles.animationDuration), parseCssTimeList(styles.animationDelay));
82
+ const total = Math.max(transitionTotal, animationTotal);
83
+ return total > 0 ? Math.ceil(total + 34) : 34;
84
+ }
85
+ function getTimerApi(node) {
86
+ const activeWindow = node?.ownerDocument?.defaultView;
87
+ if (activeWindow && typeof activeWindow.setTimeout === 'function' && typeof activeWindow.clearTimeout === 'function') {
88
+ return {
89
+ setTimeout: activeWindow.setTimeout.bind(activeWindow),
90
+ clearTimeout: activeWindow.clearTimeout.bind(activeWindow)
91
+ };
92
+ }
93
+ return {
94
+ setTimeout: globalThis.setTimeout.bind(globalThis),
95
+ clearTimeout: globalThis.clearTimeout.bind(globalThis)
96
+ };
97
+ }
98
+ function isOwnedEvent(event, node) {
99
+ return !!event && event.target === node;
100
+ }
101
+ /**
102
+ * Ref-owned presence controller for always-mounted nodes.
103
+ *
104
+ * Canonical pattern:
105
+ * - create once per ref
106
+ * - call `presence.mount()` inside `zenMount`
107
+ * - drive `presence.setPresent(next)` from reactive state
108
+ *
109
+ * @template {Element} T
110
+ * @param {{ current?: T | null }} ref
111
+ * @param {{ timeoutMs?: number, onPhaseChange?: ((phase: ZenPresencePhase, context: { node: T | null, previousPhase: ZenPresencePhase | null, present: boolean }) => void) } | null | undefined} [options]
112
+ * @returns {{
113
+ * mount: () => () => void,
114
+ * destroy: () => void,
115
+ * getPhase: () => ZenPresencePhase,
116
+ * setPresent: (nextPresent: boolean) => void
117
+ * }}
118
+ */
119
+ export function zenPresence(ref, options = null) {
120
+ if (!isRefLike(ref)) {
121
+ throw new Error('[Zenith Runtime] zenPresence(ref, options) requires a ref-like object with current');
122
+ }
123
+ const normalizedOptions = normalizeOptions(options);
124
+ let desiredPresent = false;
125
+ /** @type {ZenPresencePhase} */
126
+ let currentPhase = 'hidden';
127
+ let mounted = false;
128
+ let mountEpoch = 0;
129
+ let pendingCompletion = null;
130
+ function getNode() {
131
+ const candidate = ref.current;
132
+ if (!candidate || typeof candidate !== 'object' || typeof candidate.nodeType !== 'number') {
133
+ return null;
134
+ }
135
+ return candidate;
136
+ }
137
+ function notifyPhaseChange(previousPhase) {
138
+ if (typeof normalizedOptions.onPhaseChange !== 'function') {
139
+ return;
140
+ }
141
+ normalizedOptions.onPhaseChange(currentPhase, {
142
+ node: getNode(),
143
+ previousPhase,
144
+ present: desiredPresent
145
+ });
146
+ }
147
+ function applyPhaseToNode() {
148
+ const node = getNode();
149
+ if (!node) {
150
+ return;
151
+ }
152
+ node.setAttribute('data-zen-presence', currentPhase);
153
+ }
154
+ function setPhase(nextPhase, forceApply = false) {
155
+ const previousPhase = currentPhase;
156
+ const changed = previousPhase !== nextPhase;
157
+ if (!changed && !forceApply) {
158
+ return;
159
+ }
160
+ currentPhase = nextPhase;
161
+ applyPhaseToNode();
162
+ if (changed) {
163
+ notifyPhaseChange(previousPhase);
164
+ }
165
+ }
166
+ function cancelPendingCompletion() {
167
+ if (!pendingCompletion) {
168
+ return;
169
+ }
170
+ pendingCompletion.cancel();
171
+ pendingCompletion = null;
172
+ }
173
+ function scheduleCompletion(targetPhase, node) {
174
+ cancelPendingCompletion();
175
+ if (!mounted || !node) {
176
+ setPhase(targetPhase);
177
+ return;
178
+ }
179
+ const timerApi = getTimerApi(node);
180
+ const timeoutMs = resolveFallbackTimeoutMs(node, normalizedOptions.timeoutMs);
181
+ const disposers = [];
182
+ let settled = false;
183
+ let timeoutId = null;
184
+ const settle = () => {
185
+ if (settled) {
186
+ return;
187
+ }
188
+ settled = true;
189
+ while (disposers.length > 0) {
190
+ const dispose = disposers.pop();
191
+ try {
192
+ dispose();
193
+ }
194
+ catch {
195
+ }
196
+ }
197
+ if (timeoutId !== null) {
198
+ timerApi.clearTimeout(timeoutId);
199
+ timeoutId = null;
200
+ }
201
+ pendingCompletion = null;
202
+ setPhase(targetPhase);
203
+ };
204
+ const handleEnd = (event) => {
205
+ if (!isOwnedEvent(event, node)) {
206
+ return;
207
+ }
208
+ settle();
209
+ };
210
+ disposers.push(zenOn(node, 'transitionend', handleEnd));
211
+ disposers.push(zenOn(node, 'animationend', handleEnd));
212
+ timeoutId = timerApi.setTimeout(settle, timeoutMs);
213
+ pendingCompletion = {
214
+ cancel() {
215
+ if (settled) {
216
+ return;
217
+ }
218
+ settled = true;
219
+ while (disposers.length > 0) {
220
+ const dispose = disposers.pop();
221
+ try {
222
+ dispose();
223
+ }
224
+ catch {
225
+ }
226
+ }
227
+ if (timeoutId !== null) {
228
+ timerApi.clearTimeout(timeoutId);
229
+ timeoutId = null;
230
+ }
231
+ }
232
+ };
233
+ }
234
+ function reconcile() {
235
+ if (!mounted) {
236
+ return;
237
+ }
238
+ const node = getNode();
239
+ if (!node) {
240
+ cancelPendingCompletion();
241
+ return;
242
+ }
243
+ if (desiredPresent) {
244
+ if (currentPhase === 'entering' || currentPhase === 'present') {
245
+ return;
246
+ }
247
+ setPhase('entering');
248
+ scheduleCompletion('present', node);
249
+ return;
250
+ }
251
+ if (currentPhase === 'hidden' || currentPhase === 'exiting') {
252
+ return;
253
+ }
254
+ setPhase('exiting');
255
+ scheduleCompletion('hidden', node);
256
+ }
257
+ function destroyCurrentMount() {
258
+ mounted = false;
259
+ cancelPendingCompletion();
260
+ currentPhase = 'hidden';
261
+ const node = getNode();
262
+ if (node) {
263
+ node.removeAttribute('data-zen-presence');
264
+ }
265
+ }
266
+ return {
267
+ mount() {
268
+ mountEpoch += 1;
269
+ const activeMount = mountEpoch;
270
+ mounted = true;
271
+ setPhase(currentPhase, true);
272
+ reconcile();
273
+ return () => {
274
+ if (activeMount !== mountEpoch) {
275
+ return;
276
+ }
277
+ destroyCurrentMount();
278
+ };
279
+ },
280
+ destroy() {
281
+ mountEpoch += 1;
282
+ destroyCurrentMount();
283
+ },
284
+ getPhase() {
285
+ return currentPhase;
286
+ },
287
+ setPresent(nextPresent) {
288
+ desiredPresent = nextPresent === true;
289
+ reconcile();
290
+ }
291
+ };
292
+ }
293
+ /**
294
+ * @alias zenPresence
295
+ * @description Optional secondary alias for the canonical zenPresence helper.
296
+ */
297
+ export const presence = zenPresence;
package/dist/template.js CHANGED
@@ -20,7 +20,7 @@ function buildRuntimeModuleSource() {
20
20
  const segments = [
21
21
  'reactivity-core.js', 'side-effect-scope.js', 'effect-utils.js', 'effect-scheduler.js',
22
22
  'effect-runtime.js', 'mount-runtime.js', 'zeneffect.js', 'ref.js', 'env.js',
23
- 'platform.js', 'signal.js', 'state.js',
23
+ 'platform.js', 'presence.js', 'signal.js', 'state.js',
24
24
  'diagnostics.js', 'cleanup.js', 'template-parser.js', 'markup.js', 'payload.js',
25
25
  'expressions.js', 'render.js', 'fragment-patch.js', 'scanner.js', 'events.js', 'hydrate.js'
26
26
  ].map((fileName) => stripImports(readRuntimeSourceFile(fileName))).filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/runtime",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {