@wcstack/raf 1.18.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.
@@ -0,0 +1,698 @@
1
+ const _config = {
2
+ autoTrigger: true,
3
+ triggerAttribute: "data-raftarget",
4
+ tagNames: {
5
+ raf: "wcs-raf",
6
+ },
7
+ };
8
+ function deepFreeze(obj) {
9
+ if (obj === null || typeof obj !== "object")
10
+ return obj;
11
+ Object.freeze(obj);
12
+ for (const key of Object.keys(obj)) {
13
+ deepFreeze(obj[key]);
14
+ }
15
+ return obj;
16
+ }
17
+ function deepClone(obj) {
18
+ if (obj === null || typeof obj !== "object")
19
+ return obj;
20
+ const clone = {};
21
+ for (const key of Object.keys(obj)) {
22
+ clone[key] = deepClone(obj[key]);
23
+ }
24
+ return clone;
25
+ }
26
+ let frozenConfig = null;
27
+ // Internal-only live handle to the mutable config. NOT part of the public API
28
+ // (deliberately absent from exports.ts) — it is exported solely so sibling
29
+ // modules in this package can read current settings cheaply. External consumers
30
+ // must use getConfig() (returns a deep-frozen snapshot) / setConfig(). Mutating
31
+ // this object directly bypasses the frozenConfig cache and is unsupported.
32
+ const config = _config;
33
+ function getConfig() {
34
+ if (!frozenConfig) {
35
+ frozenConfig = deepFreeze(deepClone(_config));
36
+ }
37
+ return frozenConfig;
38
+ }
39
+ function setConfig(partialConfig) {
40
+ if (typeof partialConfig.autoTrigger === "boolean") {
41
+ _config.autoTrigger = partialConfig.autoTrigger;
42
+ }
43
+ if (typeof partialConfig.triggerAttribute === "string") {
44
+ _config.triggerAttribute = partialConfig.triggerAttribute;
45
+ }
46
+ if (partialConfig.tagNames) {
47
+ Object.assign(_config.tagNames, partialConfig.tagNames);
48
+ }
49
+ frozenConfig = null;
50
+ }
51
+
52
+ /**
53
+ * Headless requestAnimationFrame primitive — `TimerCore`'s sibling with the
54
+ * time source swapped from `setInterval` (a period) to rAF (the browser's
55
+ * rendering opportunity). Exposed through the wc-bindable protocol: it streams
56
+ * `tick` (frame counter), `elapsed` (accumulated ACTIVE milliseconds), `dt`
57
+ * (delta to the previous frame) and the `running` / `suspended` pair, and is
58
+ * driven by the `start` / `stop` / `reset` / `pause` / `resume` commands.
59
+ *
60
+ * `tick` / `elapsed` / `dt` are all surfaced via the single `wcs-raf:tick`
61
+ * event (read through getters, mirroring how FetchCore exposes value/status
62
+ * from one `wcs-fetch:response` event).
63
+ *
64
+ * Contracts specific to this node (docs/raf-tag-design.md):
65
+ *
66
+ * - **dt describes continuous running only.** The first frame after `start()`,
67
+ * `resume()`, or a visibility interruption reports `dt = 0` — a value that
68
+ * spans an interruption never reaches observers. Like `suspended`, the
69
+ * visibility boundary is only detected once observe() has subscribed to
70
+ * `visibilitychange`; a headless setup that skips observe() will see the
71
+ * raw spanning delta on the first frame after a hidden gap. There is
72
+ * deliberately NO upper clamp: how to treat a slow frame is the consumer's
73
+ * domain decision.
74
+ * - **elapsed is Σdt (active time).** Because interruption-spanning deltas are
75
+ * normalized to 0, summing dt yields exactly the time frames were actually
76
+ * being delivered — no separate segment bookkeeping is needed, and hidden /
77
+ * paused periods contribute nothing. Granularity is one frame: between
78
+ * frames the getter returns the value as of the last tick.
79
+ * - **running / suspended are a desired/actual pair** (the wakelock split): in
80
+ * a hidden tab the browser delivers no frames at all, so `running` (the
81
+ * started intent) stays true while `suspended` reports that delivery is
82
+ * actually stopped. `suspended` is only meaningful after `observe()` has
83
+ * subscribed to `visibilitychange`; without a document it stays false.
84
+ * - **No `error` surface.** rAF has no persistent failure mode; on a platform
85
+ * without it, `start()` is a silent no-op (never-throw, resize precedent).
86
+ */
87
+ class RafCore extends EventTarget {
88
+ static wcBindable = {
89
+ protocol: "wc-bindable",
90
+ version: 1,
91
+ properties: [
92
+ { name: "tick", event: "wcs-raf:tick", getter: (e) => e.detail.count },
93
+ { name: "elapsed", event: "wcs-raf:tick", getter: (e) => e.detail.elapsed },
94
+ { name: "dt", event: "wcs-raf:tick", getter: (e) => e.detail.dt },
95
+ { name: "running", event: "wcs-raf:running-changed" },
96
+ { name: "suspended", event: "wcs-raf:suspended-changed" },
97
+ ],
98
+ commands: [
99
+ { name: "start" },
100
+ { name: "stop" },
101
+ { name: "reset" },
102
+ { name: "pause" },
103
+ { name: "resume" },
104
+ ],
105
+ };
106
+ _target;
107
+ _injectedScheduler;
108
+ _handle = null;
109
+ // Lazily-created wrapper around the global rAF pair, cached so the hot
110
+ // frame-reschedule path (_frame, once per delivered frame) does not
111
+ // allocate a new object + closures every call. `request`/`cancel` still
112
+ // dereference `globalThis.requestAnimationFrame` / `cancelAnimationFrame`
113
+ // live on every invocation (they are not snapshotted here), so call-time
114
+ // resolution (§3.7) is unchanged — only the wrapper object itself is
115
+ // reused once the global functions are first found present.
116
+ _globalScheduler = null;
117
+ // Generation guard (§3.4): a monotonic arming counter. Bumped when a run is
118
+ // armed (start()/resume()), when an armed handle is cancelled
119
+ // (_clearHandle()) and on dispose(). _requestFrame() captures the value in
120
+ // each request's closure and drops the frame if it no longer matches the
121
+ // live field when it fires. cancel() is best-effort against a non-compliant
122
+ // scheduler; the captured generation is the guarantee — a stale callback can
123
+ // neither mutate state, dispatch on a torn-down element, nor corrupt a
124
+ // newer run's `_handle` bookkeeping. A live-field comparison (the previous
125
+ // `_runGen` scheme) could not survive a dispose() → start() round trip: the
126
+ // new start() re-synced the pair and let the stale callback through,
127
+ // permanently doubling the frame loop.
128
+ _gen = 0;
129
+ // SSR (§3.8): there is no asynchronous probe, so readiness is immediate.
130
+ _ready = Promise.resolve();
131
+ _tick = 0;
132
+ _dt = 0;
133
+ _elapsed = 0;
134
+ _running = false;
135
+ _suspended = false;
136
+ _paused = false;
137
+ // Timestamp of the previous frame within the current continuous run.
138
+ // `null` means "the next frame starts a run segment": its dt is reported as
139
+ // 0 (the G3 normalization). Cleared at start()/resume() and on every
140
+ // visibilitychange (an interruption boundary).
141
+ _lastTs = null;
142
+ // `_tick` value captured at the start of the current run. `repeat` counts
143
+ // frames *per run*, so the stop condition compares against this baseline
144
+ // rather than the cumulative `_tick` (which only resets on reset()).
145
+ _repeat = 0;
146
+ _runStartTick = 0;
147
+ // The document whose visibility drives `suspended`, subscribed in observe()
148
+ // and released in dispose(). Null before observe() or in non-DOM
149
+ // environments — `suspended` then simply stays false.
150
+ _visibilityDoc = null;
151
+ constructor(target, scheduler) {
152
+ super();
153
+ this._target = target ?? this;
154
+ this._injectedScheduler = scheduler ?? null;
155
+ }
156
+ get tick() {
157
+ return this._tick;
158
+ }
159
+ get elapsed() {
160
+ return this._elapsed;
161
+ }
162
+ get dt() {
163
+ return this._dt;
164
+ }
165
+ get running() {
166
+ return this._running;
167
+ }
168
+ get suspended() {
169
+ return this._suspended;
170
+ }
171
+ // SSR readiness (§3.8): resolves after the first probe. There is nothing to
172
+ // probe, so this is an already-resolved promise.
173
+ get ready() {
174
+ return this._ready;
175
+ }
176
+ // Lifecycle (§3.5). observe() establishes the one ambient subscription this
177
+ // node has — `visibilitychange`, which drives the `suspended` output and the
178
+ // dt=0 normalization across a hidden period. Idempotent; a no-op without a
179
+ // document (SSR pre-pass, worker). dispose() tears everything down and bumps
180
+ // the generation so a frame already queued cannot fire onto a torn-down
181
+ // element.
182
+ observe() {
183
+ if (this._visibilityDoc === null && typeof document !== "undefined") {
184
+ this._visibilityDoc = document;
185
+ document.addEventListener("visibilitychange", this._onVisibilityChange);
186
+ // Sync `suspended` to the visibility state at subscription time: with a
187
+ // start()-before-observe() ordering (headless Core usage) the document
188
+ // may already be hidden, and waiting for the next visibilitychange
189
+ // would report suspended=false until then. Same-value guarded, so the
190
+ // common visible-at-observe case dispatches nothing.
191
+ this._updateSuspended();
192
+ }
193
+ return this._ready;
194
+ }
195
+ dispose() {
196
+ this._gen++;
197
+ this.stop();
198
+ if (this._visibilityDoc !== null) {
199
+ this._visibilityDoc.removeEventListener("visibilitychange", this._onVisibilityChange);
200
+ this._visibilityDoc = null;
201
+ }
202
+ }
203
+ // --- State setters with event dispatch ---
204
+ _dispatchTick(timestamp) {
205
+ this._target.dispatchEvent(new CustomEvent("wcs-raf:tick", {
206
+ detail: { count: this._tick, elapsed: this._elapsed, dt: this._dt, timestamp },
207
+ bubbles: true,
208
+ }));
209
+ }
210
+ _setRunning(running) {
211
+ if (this._running === running)
212
+ return;
213
+ this._running = running;
214
+ this._target.dispatchEvent(new CustomEvent("wcs-raf:running-changed", {
215
+ detail: running,
216
+ bubbles: true,
217
+ }));
218
+ // `suspended` is derived from (running && hidden), so every running
219
+ // transition re-evaluates it: stop/pause drop a suspension, and a start()
220
+ // inside an already-hidden tab reports it immediately (honestly: no frame
221
+ // will arrive until the tab is visible again).
222
+ this._updateSuspended();
223
+ }
224
+ _setSuspended(suspended) {
225
+ if (this._suspended === suspended)
226
+ return;
227
+ this._suspended = suspended;
228
+ this._target.dispatchEvent(new CustomEvent("wcs-raf:suspended-changed", {
229
+ detail: suspended,
230
+ bubbles: true,
231
+ }));
232
+ }
233
+ _updateSuspended() {
234
+ const hidden = this._visibilityDoc !== null && this._visibilityDoc.visibilityState === "hidden";
235
+ this._setSuspended(this._running && hidden);
236
+ }
237
+ // --- Public API ---
238
+ start(options = {}) {
239
+ // Idempotent while running: a redundant start() must not stack a second
240
+ // frame loop (which would double the tick rate). Reconfiguring an active
241
+ // run is done via stop() + start().
242
+ if (this._running)
243
+ return;
244
+ // Resolve the platform API at call time (§3.7). Absent rAF (SSR pre-pass,
245
+ // worker) makes start() a silent no-op — never-throw, and this node has no
246
+ // error surface by design.
247
+ const scheduler = this._resolveScheduler();
248
+ if (scheduler === null)
249
+ return;
250
+ // start() begins a fresh run, so clear any lingering pause from a prior
251
+ // pause()-without-resume(). Without this, the loop would run while _paused
252
+ // stayed true, leaving pause() a no-op and letting resume() overwrite the
253
+ // live handle (leak + double fire).
254
+ this._paused = false;
255
+ // `repeat` is per-run intent, NOT persistent configuration: every start()
256
+ // re-establishes it from the options, defaulting to "unlimited" when
257
+ // omitted. This keeps a bare start() after a bounded run from silently
258
+ // inheriting the old bounds.
259
+ this._repeat = (typeof options.repeat === "number" && options.repeat > 0) ? options.repeat : 0;
260
+ // New arming generation (§3.4): invalidates any callback still in flight
261
+ // from a previous run (e.g. one whose cancel() a non-compliant scheduler
262
+ // ignored). Bumped BEFORE the running-changed dispatch below, so that a
263
+ // re-entrant restart from a listener arms with the newest generation —
264
+ // the re-entrancy guard then keeps this outer call from arming (and
265
+ // bumping) on top of it.
266
+ this._gen++;
267
+ this._setRunning(true);
268
+ // Baseline this run's per-run repeat counting (set after _setRunning so a
269
+ // re-start of a completed bounded run fires the full N frames again).
270
+ this._runStartTick = this._tick;
271
+ // G3: the first frame of a run reports dt = 0.
272
+ this._lastTs = null;
273
+ // Re-entrancy guard: _setRunning(true) just dispatched running-changed
274
+ // synchronously, and a listener may have changed the world from inside it.
275
+ // - `!_running`: the listener called stop()/pause()/dispose(). Without
276
+ // this check a "ghost" frame would still be scheduled for an
277
+ // already-stopped run — it would either tick once while running stays
278
+ // false, or leave an uncancellable handle behind.
279
+ // - `_handle !== null`: the listener restarted the loop itself
280
+ // (stop()→start()); the inner start() already armed the new run, and
281
+ // requesting again here would overwrite `_handle` (losing the inner
282
+ // handle, never cancelled) and stack a permanent second frame loop.
283
+ // On the normal path `_handle` is always null here — every transition
284
+ // to `_running === false` clears it — so non-null can only mean a
285
+ // re-entrant listener already scheduled the run for us.
286
+ if (!this._running || this._handle !== null)
287
+ return;
288
+ this._requestFrame(scheduler);
289
+ }
290
+ stop() {
291
+ this._clearHandle();
292
+ this._paused = false;
293
+ this._setRunning(false);
294
+ }
295
+ reset() {
296
+ this._clearHandle();
297
+ this._paused = false;
298
+ this._tick = 0;
299
+ this._elapsed = 0;
300
+ this._dt = 0;
301
+ this._lastTs = null;
302
+ this._setRunning(false);
303
+ // Notify observers that the counter/elapsed/dt have returned to zero. The
304
+ // notification is not a frame, so `timestamp` is 0 (see WcsRafTickDetail).
305
+ this._dispatchTick(0);
306
+ }
307
+ pause() {
308
+ // Pause only a live loop; a no-op otherwise so it composes safely with the
309
+ // declarative lifecycle. Unlike stop(), it records `_paused` so resume()
310
+ // can tell an intentional pause from a full stop. No elapsed bookkeeping
311
+ // is needed: elapsed is Σdt, and the resume boundary's dt is 0.
312
+ if (!this._running || this._paused)
313
+ return;
314
+ this._clearHandle();
315
+ this._paused = true;
316
+ this._setRunning(false);
317
+ }
318
+ resume() {
319
+ if (!this._paused)
320
+ return;
321
+ const scheduler = this._resolveScheduler();
322
+ if (scheduler === null)
323
+ return;
324
+ this._paused = false;
325
+ // New arming generation (§3.4), bumped before the running-changed
326
+ // dispatch for the same re-entrancy reason as start().
327
+ this._gen++;
328
+ this._setRunning(true);
329
+ // G3: the first frame after a pause reports dt = 0 (elapsed therefore does
330
+ // not count the paused period — the "active time" contract).
331
+ this._lastTs = null;
332
+ // Re-entrancy guard, for the same reasons as start() (see the comment
333
+ // there): a running-changed listener may have synchronously stopped this
334
+ // node — or restarted it, leaving `_handle` already armed — from inside
335
+ // _setRunning(true) above.
336
+ if (!this._running || this._handle !== null)
337
+ return;
338
+ this._requestFrame(scheduler);
339
+ }
340
+ // --- Internal ---
341
+ _frame = (timestamp) => {
342
+ // Reached only through _requestFrame's generation-checked closure (§3.4):
343
+ // a stale callback — disposed, cancelled by a non-compliant scheduler, or
344
+ // superseded by a newer run — never gets here.
345
+ this._handle = null;
346
+ // dt: delta to the previous frame within this continuous run; 0 when this
347
+ // frame opens a segment (start/resume/visibility boundary — G3).
348
+ const dt = this._lastTs === null ? 0 : timestamp - this._lastTs;
349
+ this._lastTs = timestamp;
350
+ this._tick++;
351
+ this._dt = dt;
352
+ this._elapsed += dt;
353
+ this._dispatchTick(timestamp);
354
+ // Auto-stop once this run has fired the requested number of frames
355
+ // (repeat=0 runs forever). Counted per-run via `_runStartTick`, so a
356
+ // re-start after a completed bounded run fires N frames again. `once` is
357
+ // expressed by the Shell as repeat=1.
358
+ //
359
+ // The cleanup mirrors stop() exactly, because a tick listener may have
360
+ // synchronously paused — or paused and resumed — DURING the final frame's
361
+ // dispatch above. The run's budget is exhausted either way, so clear the
362
+ // pause (a later resume() must be a no-op, not an N+1th frame) and cancel
363
+ // any handle a re-entrant resume() armed (it would otherwise survive as a
364
+ // ghost frame and tick past the budget). On the normal path both are
365
+ // already clear (no-ops). A stop()→start() restart is NOT affected: the
366
+ // new run re-baselines `_runStartTick`, so this branch is not taken.
367
+ if (this._repeat > 0 && (this._tick - this._runStartTick) >= this._repeat) {
368
+ this._clearHandle();
369
+ this._paused = false;
370
+ this._setRunning(false);
371
+ return;
372
+ }
373
+ // Re-request the next frame — unless a tick listener stopped the loop
374
+ // synchronously during the dispatch above, or already scheduled a new run
375
+ // itself (a synchronous stop()→start() / pause()→resume() restart leaves
376
+ // _handle non-null; re-requesting on top of it would stack a permanent
377
+ // second frame loop. The generation guard cannot catch this: a tail
378
+ // request here would capture the restart's own — current — generation
379
+ // and produce a second equally-valid loop).
380
+ if (this._running && this._handle === null) {
381
+ const scheduler = this._resolveScheduler();
382
+ if (scheduler !== null) {
383
+ this._requestFrame(scheduler);
384
+ }
385
+ }
386
+ };
387
+ _onVisibilityChange = () => {
388
+ // Either direction is an interruption boundary: entering hidden means the
389
+ // browser stops delivering frames, so the NEXT delivered frame must not
390
+ // report a delta spanning the gap (G3). Clearing on the visible edge too
391
+ // is belt-and-braces for a missed hidden event — the worst case is one
392
+ // extra dt=0 frame.
393
+ this._lastTs = null;
394
+ this._updateSuspended();
395
+ };
396
+ _resolveScheduler() {
397
+ if (this._injectedScheduler !== null)
398
+ return this._injectedScheduler;
399
+ const g = globalThis;
400
+ // The availability check itself still runs on every call (§3.7: resolved
401
+ // at call time, not cached across an absence/presence flip).
402
+ if (typeof g.requestAnimationFrame !== "function" || typeof g.cancelAnimationFrame !== "function") {
403
+ return null;
404
+ }
405
+ if (this._globalScheduler === null) {
406
+ // `g` is just a typed alias for `globalThis` (not a snapshot), so these
407
+ // closures keep dereferencing the live global functions even though the
408
+ // wrapper object itself is created only once.
409
+ this._globalScheduler = {
410
+ request: (cb) => g.requestAnimationFrame(cb),
411
+ cancel: (handle) => g.cancelAnimationFrame(handle),
412
+ };
413
+ }
414
+ return this._globalScheduler;
415
+ }
416
+ // Arm the next frame (§3.4). The callback closes over the generation
417
+ // current at request time and re-checks it against the live `_gen` when the
418
+ // frame arrives; a callback that outlived its run bails here. See the
419
+ // `_gen` field comment for why this must be a per-request capture and not a
420
+ // live-field comparison.
421
+ _requestFrame(scheduler) {
422
+ const gen = this._gen;
423
+ this._handle = scheduler.request((timestamp) => {
424
+ if (gen !== this._gen)
425
+ return;
426
+ this._frame(timestamp);
427
+ });
428
+ }
429
+ _clearHandle() {
430
+ if (this._handle !== null) {
431
+ this._resolveScheduler()?.cancel(this._handle);
432
+ this._handle = null;
433
+ // Invalidate the cancelled callback's captured generation as well:
434
+ // cancel() is best-effort against a non-compliant scheduler, the
435
+ // generation is the guarantee (§3.4).
436
+ this._gen++;
437
+ }
438
+ }
439
+ }
440
+
441
+ let registered = false;
442
+ function handleClick(event) {
443
+ const target = event.target;
444
+ if (!(target instanceof Element))
445
+ return;
446
+ const triggerElement = target.closest(`[${config.triggerAttribute}]`);
447
+ if (!triggerElement)
448
+ return;
449
+ const rafId = triggerElement.getAttribute(config.triggerAttribute);
450
+ if (!rafId)
451
+ return;
452
+ // Resolve the registered constructor at call time instead of importing Raf
453
+ // as a value. The value import created a components/Raf.ts ⇄ autoTrigger.ts
454
+ // cycle (Raf.connectedCallback() calls registerAutoTrigger()). instanceof
455
+ // against the customElements registry keeps the exact same identity guarantee
456
+ // — only the registered <wcs-raf> class matches — without the import cycle.
457
+ const RafCtor = customElements.get(config.tagNames.raf);
458
+ const rafElement = document.getElementById(rafId);
459
+ if (!RafCtor || !(rafElement instanceof RafCtor))
460
+ return;
461
+ // Suppress the element's default action so a loop can start without
462
+ // navigating. Intentional: do not attach data-raftarget to an element whose
463
+ // default action you also want (real <a href> link, form-submit button) — it
464
+ // will be cancelled. See README "Optional DOM Triggering".
465
+ event.preventDefault();
466
+ rafElement.start();
467
+ }
468
+ function registerAutoTrigger() {
469
+ if (registered)
470
+ return;
471
+ registered = true;
472
+ document.addEventListener("click", handleClick);
473
+ }
474
+
475
+ class Raf extends HTMLElement {
476
+ static hasConnectedCallbackPromise = true;
477
+ static wcBindable = {
478
+ ...RafCore.wcBindable,
479
+ properties: [
480
+ ...RafCore.wcBindable.properties,
481
+ { name: "trigger", event: "wcs-raf:trigger-changed" },
482
+ ],
483
+ // Shell-level settable surface. `attribute` is a purely descriptive hint
484
+ // (per SPEC-extensions.md the binding core does not act on it) naming the
485
+ // mirrored HTML attribute, matching <wcs-timer>. `trigger` is a momentary
486
+ // command-property with no backing attribute, so it carries no hint.
487
+ // `start` / `stop` / `reset` / `pause` / `resume` commands are inherited
488
+ // from the Core above. Deliberately absent vs <wcs-timer>: `interval`
489
+ // (rAF has no period) and `immediate` (the first frame already IS the
490
+ // next rendering opportunity — no earlier meaningful moment exists).
491
+ inputs: [
492
+ { name: "once", attribute: "once" },
493
+ { name: "repeat", attribute: "repeat" },
494
+ { name: "manual", attribute: "manual" },
495
+ { name: "trigger" },
496
+ ],
497
+ };
498
+ _core;
499
+ _trigger = false;
500
+ _connectedCallbackPromise = Promise.resolve();
501
+ _internals = null;
502
+ constructor() {
503
+ super();
504
+ this._core = new RafCore(this);
505
+ this._internals = this._initInternals();
506
+ this._wireStates({
507
+ "wcs-raf:running-changed": (d) => ({ running: d === true }),
508
+ "wcs-raf:suspended-changed": (d) => ({ suspended: d === true }),
509
+ });
510
+ }
511
+ // CSS state reflection (:state()) — debug-only snapshot getter. NOT part of
512
+ // wc-bindable (not a bind target); see README "CSS styling with :state()".
513
+ // MUST NOT return the live CustomStateSet (that would let callers write
514
+ // states from outside, defeating the point of :state() being read-only).
515
+ get debugStates() {
516
+ return this._internals ? [...this._internals.states] : [];
517
+ }
518
+ _initInternals() {
519
+ // never-throw (async-io-node-guidelines.md §3.6): attachInternals is absent
520
+ // in happy-dom / older environments, and pre-125 Chromium rejects
521
+ // non-dashed state names from states.add() (probed and discarded here).
522
+ // Either case silently disables reflection — the component still works,
523
+ // it just doesn't expose :state() selectors.
524
+ try {
525
+ if (typeof this.attachInternals !== "function")
526
+ return null;
527
+ const internals = this.attachInternals();
528
+ internals.states.add("wcs-probe");
529
+ internals.states.delete("wcs-probe");
530
+ return internals;
531
+ }
532
+ catch {
533
+ return null;
534
+ }
535
+ }
536
+ _wireStates(map) {
537
+ if (this._internals === null)
538
+ return;
539
+ const states = this._internals.states;
540
+ for (const [event, toStates] of Object.entries(map)) {
541
+ this.addEventListener(event, (e) => {
542
+ const debug = this.hasAttribute("debug-states");
543
+ for (const [name, on] of Object.entries(toStates(e.detail))) {
544
+ try {
545
+ if (on) {
546
+ states.add(name);
547
+ }
548
+ else {
549
+ states.delete(name);
550
+ }
551
+ }
552
+ catch { /* never-throw */ }
553
+ if (debug)
554
+ this.toggleAttribute(`data-wcs-state-${name}`, on);
555
+ }
556
+ });
557
+ }
558
+ }
559
+ // SSR (§4.1/§4.4): the Shell exposes the Core's readiness so a server-side
560
+ // renderer can await the connect-time probe before snapshotting. There is no
561
+ // async probe here (observe() resolves immediately), but the contract is
562
+ // uniform across IO nodes.
563
+ get connectedCallbackPromise() {
564
+ return this._connectedCallbackPromise;
565
+ }
566
+ // --- Attribute accessors ---
567
+ get once() {
568
+ return this.hasAttribute("once");
569
+ }
570
+ set once(value) {
571
+ if (value) {
572
+ this.setAttribute("once", "");
573
+ }
574
+ else {
575
+ this.removeAttribute("once");
576
+ }
577
+ }
578
+ get repeat() {
579
+ const attr = this.getAttribute("repeat");
580
+ if (attr === null || attr.trim() === "")
581
+ return 0;
582
+ // Strict parse via Number() ("3px" -> NaN, not 3), matching <wcs-timer>.
583
+ // Normalise any non-positive / non-numeric value to 0 (= unlimited).
584
+ const parsed = Number(attr);
585
+ return (Number.isFinite(parsed) && parsed > 0) ? parsed : 0;
586
+ }
587
+ set repeat(value) {
588
+ this.setAttribute("repeat", String(value));
589
+ }
590
+ get manual() {
591
+ return this.hasAttribute("manual");
592
+ }
593
+ set manual(value) {
594
+ if (value) {
595
+ this.setAttribute("manual", "");
596
+ }
597
+ else {
598
+ this.removeAttribute("manual");
599
+ }
600
+ }
601
+ // --- Core delegated getters ---
602
+ get tick() {
603
+ return this._core.tick;
604
+ }
605
+ get elapsed() {
606
+ return this._core.elapsed;
607
+ }
608
+ get dt() {
609
+ return this._core.dt;
610
+ }
611
+ get running() {
612
+ return this._core.running;
613
+ }
614
+ get suspended() {
615
+ return this._core.suspended;
616
+ }
617
+ // --- Command property ---
618
+ get trigger() {
619
+ return this._trigger;
620
+ }
621
+ set trigger(value) {
622
+ // Momentary command-property: a false→true write starts the loop. Mirrors
623
+ // <wcs-timer>. Prefer the command-token protocol (`command.start:
624
+ // $command.begin`) for state-driven starts; this exists mainly for the DOM
625
+ // click trigger and simple boolean bindings.
626
+ const v = !!value;
627
+ if (v) {
628
+ this._trigger = true;
629
+ this.start();
630
+ this._trigger = false;
631
+ // The `trigger-changed` event reports the momentary flag returning to
632
+ // false, i.e. that the trigger property *changed* — it is deliberately
633
+ // not gated on whether start() actually began a new run (same contract
634
+ // as <wcs-timer>).
635
+ this.dispatchEvent(new CustomEvent("wcs-raf:trigger-changed", {
636
+ detail: false,
637
+ bubbles: true,
638
+ }));
639
+ }
640
+ }
641
+ // --- Commands ---
642
+ start() {
643
+ // `once` is sugar for "fire exactly one frame": map it to repeat=1, but
644
+ // let an explicit repeat attribute win when both are present.
645
+ const repeat = this.repeat > 0 ? this.repeat : (this.once ? 1 : 0);
646
+ this._core.start({ repeat });
647
+ }
648
+ stop() {
649
+ this._core.stop();
650
+ }
651
+ reset() {
652
+ this._core.reset();
653
+ }
654
+ pause() {
655
+ this._core.pause();
656
+ }
657
+ resume() {
658
+ this._core.resume();
659
+ }
660
+ // --- Lifecycle ---
661
+ connectedCallback() {
662
+ this.style.display = "none";
663
+ if (config.autoTrigger) {
664
+ registerAutoTrigger();
665
+ }
666
+ // Establish monitoring (§3.5): observe() subscribes visibilitychange (the
667
+ // `suspended` output) and resolves once ready; expose it as
668
+ // connectedCallbackPromise for SSR. Note for SSR pages: an auto-started
669
+ // frame loop keeps scheduling — prefer `manual` in server-rendered markup
670
+ // (see README).
671
+ this._connectedCallbackPromise = this._core.observe();
672
+ if (!this.manual) {
673
+ this.start();
674
+ }
675
+ }
676
+ disconnectedCallback() {
677
+ // dispose() stops the loop, releases the visibility subscription and bumps
678
+ // the generation so a frame already queued cannot fire onto a disconnected
679
+ // element (§3.5 / §4.4).
680
+ this._core.dispose();
681
+ }
682
+ }
683
+
684
+ function registerComponents() {
685
+ if (!customElements.get(config.tagNames.raf)) {
686
+ customElements.define(config.tagNames.raf, Raf);
687
+ }
688
+ }
689
+
690
+ function bootstrapRaf(userConfig) {
691
+ if (userConfig) {
692
+ setConfig(userConfig);
693
+ }
694
+ registerComponents();
695
+ }
696
+
697
+ export { RafCore, Raf as WcsRaf, bootstrapRaf, getConfig };
698
+ //# sourceMappingURL=index.esm.js.map