@glissade/core 0.12.1 → 0.13.0-pre.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.
package/dist/clips.d.ts CHANGED
@@ -111,4 +111,106 @@ declare function driftLoop(opts?: (DurationOpts & {
111
111
  amplitude?: number;
112
112
  })): Clip;
113
113
  //#endregion
114
- export { type ApplyOpts, type ApplyOpts as ClipApplyOpts, type ChannelOverride, type Clip, type ClipChannel, ClipError, type ClipListDelay, type ClipListOpts, type ClipResult, type ClipSpec, type ClipTarget, type DurationOpts, type SlideEdge, clip, clipList, driftLoop, popIn, pulse, slideIn };
114
+ //#region src/morph.d.ts
115
+ /** An axis-aligned box. `x`,`y` are the box CENTER (the Rect `position` convention). */
116
+ interface Box {
117
+ x: number;
118
+ y: number;
119
+ w: number;
120
+ h: number;
121
+ }
122
+ /**
123
+ * The shared-element contract: `morphNode` is the ONE element that travels (its
124
+ * `position`+`scale` are FLIPped); `fromNode`/`toNode` (both optional) cross-fade
125
+ * their `opacity`. The explicit map IS the "shared morphId" — there is no scene
126
+ * query, the caller names the three roles.
127
+ */
128
+ interface MorphTargets {
129
+ morphNode: TweenTarget;
130
+ fromNode?: TweenTarget;
131
+ toNode?: TweenTarget;
132
+ }
133
+ interface MorphOpts {
134
+ /** Wall-clock start second of the morph. */
135
+ at: number;
136
+ /** Total morph length in seconds (> 0). */
137
+ duration: number;
138
+ /** Arriving ease of position/scale and the cross-fade (default linear). */
139
+ ease?: EaseSpec;
140
+ /**
141
+ * The scale BASIS: `[base.w, base.h]` is the size at which `morphNode` renders
142
+ * at scale [1,1]. Defaults to `to` (the TO box), so the end scale is [1,1] when
143
+ * `morphNode` is authored at the document's size. `fromScale = from/base`,
144
+ * `toScale = to/base`.
145
+ */
146
+ base?: {
147
+ w: number;
148
+ h: number;
149
+ };
150
+ /**
151
+ * Cross-fade split in [0,1] (default 0.5): `fromNode` fades out over
152
+ * `duration*crossfade`; `toNode` fades in starting at `duration*(1-crossfade)`.
153
+ */
154
+ crossfade?: number;
155
+ /** v1 default `'transform'` (position+scale FLIP). `'size'` reserved/deferred. */
156
+ metric?: 'transform';
157
+ }
158
+ interface MorphResult {
159
+ tracks: ClipResult['tracks'];
160
+ /** Wall-clock end second (`at + duration`). */
161
+ end: number;
162
+ }
163
+ declare class MorphError extends Error {
164
+ constructor(message: string);
165
+ }
166
+ /**
167
+ * Compile a shared-element box-FLIP morph into hand-authored-equivalent tracks.
168
+ *
169
+ * morph({ x: 80, y: 200, w: 120, h: 36 }, { x: 320, y: 200, w: 480, h: 280 },
170
+ * { morphNode: 'morphFx', fromNode: 'chip', toNode: 'document' },
171
+ * { at: 0.5, duration: 1.2 })
172
+ *
173
+ * Emits (via the clip path):
174
+ * morphNode/position : vec2 [at]→from-center, [at+dur]→to-center
175
+ * morphNode/scale : vec2 [at]→from/base, [at+dur]→to/base
176
+ * fromNode/opacity : number[at]→1, [at+dur*crossfade]→0
177
+ * toNode/opacity : number[at+dur*(1-crossfade)]→0, [at+dur]→1
178
+ */
179
+ declare function morph(from: Box, to: Box, targets: MorphTargets, opts: MorphOpts): MorphResult;
180
+ //#endregion
181
+ //#region src/presence.d.ts
182
+ declare class PresenceError extends Error {
183
+ constructor(message: string);
184
+ }
185
+ interface PresenceOpts {
186
+ /** Wall-clock second the node ENTERS (becomes visible). */
187
+ show: number;
188
+ /** Wall-clock second the node has fully EXITED (the exit LANDS here). */
189
+ hide: number;
190
+ /** Entrance clip (default = a plain opacity fade-in 0→1, ~0.3s). */
191
+ enter?: Clip;
192
+ /** Exit clip (default = a plain opacity fade-out 1→0, ~0.3s). Back-timed to land on `hide`. */
193
+ exit?: Clip;
194
+ /** Forwarded to `enter.apply` (speed / per-channel overrides). */
195
+ enterOpts?: ApplyOpts;
196
+ /** Forwarded to `exit.apply` (speed / per-channel overrides). */
197
+ exitOpts?: ApplyOpts;
198
+ }
199
+ interface PresenceResult {
200
+ /** Reconciled tracks: the opacity guard (fused with enter/exit opacity keys) + any non-opacity channels. */
201
+ tracks: Track[];
202
+ /** The real exit second — siblings anchor here. Equals `hide`. */
203
+ end: number;
204
+ /** When the node became visible (= `show`). */
205
+ shownAt: number;
206
+ /** When the node finished exiting (= `hide`). */
207
+ hiddenAt: number;
208
+ }
209
+ /**
210
+ * Schedule a node's enter/exit presence. Emits keyed `Track[]` only.
211
+ *
212
+ * presence('card', { show: 1, hide: 5 }) // fade in at 1, fade out to land on 5
213
+ */
214
+ declare function presence(nodeId: TweenTarget, opts: PresenceOpts): PresenceResult;
215
+ //#endregion
216
+ export { type ApplyOpts, type ApplyOpts as ClipApplyOpts, type Box, type ChannelOverride, type Clip, type ClipChannel, ClipError, type ClipListDelay, type ClipListOpts, type ClipResult, type ClipSpec, type ClipTarget, type DurationOpts, MorphError, type MorphOpts, type MorphResult, type MorphTargets, PresenceError, type PresenceOpts, type PresenceResult, type SlideEdge, clip, clipList, driftLoop, morph, popIn, presence, pulse, slideIn };
package/dist/clips.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as resolveTweenTarget, p as track, s as key, x as inferValueType } from "./targetRef.js";
1
+ import { a as targetNodeId, i as resolveTweenTarget, p as track, s as key, t as TARGET_PATH, x as inferValueType } from "./targetRef.js";
2
2
  //#region src/clip.ts
3
3
  /**
4
4
  * Motion clips (DESIGN.md §2 build-time sugar): `clip()` captures a relative-time
@@ -66,11 +66,19 @@ function compileChannel(target, ch, startSec, speed, override) {
66
66
  if (ch.keys.length === 0) throw new ClipError(`clip channel '${target}' has no keys`);
67
67
  const type = ch.type ?? inferValueType(ch.keys[0].value);
68
68
  const lastIdx = ch.keys.length - 1;
69
+ if (override && lastIdx === 0 && override.from !== void 0) throw new ClipError(override.to !== void 0 ? `clip override for '${target}' supplies both 'from' and 'to' on a single-key channel — ambiguous (both target the only key)` : `clip override for '${target}' supplies 'from' on a single-key channel — ambiguous (the only key already is the 'to' value)`);
69
70
  return track(target, type, ch.keys.map((k, i) => {
70
71
  let value = k.value;
72
+ let overridden = false;
71
73
  if (override) {
72
- if (i === 0 && override.from !== void 0) value = assertOverrideType(target, type, override.from);
73
- if (i === lastIdx && override.to !== void 0) value = assertOverrideType(target, type, override.to);
74
+ if (i === 0 && override.from !== void 0) {
75
+ value = assertOverrideType(target, type, override.from);
76
+ overridden = true;
77
+ }
78
+ if (i === lastIdx && override.to !== void 0) {
79
+ value = assertOverrideType(target, type, override.to);
80
+ overridden = true;
81
+ }
74
82
  }
75
83
  const out = {
76
84
  t: startSec + k.t / speed,
@@ -80,7 +88,8 @@ function compileChannel(target, ch, startSec, speed, override) {
80
88
  if (ease !== void 0) out.ease = ease;
81
89
  if (k.interp !== void 0) out.interp = k.interp;
82
90
  if (k.id !== void 0) out.id = k.id;
83
- if (k.derived !== void 0) out.derived = k.derived;
91
+ if (k.derived !== void 0 && !overridden) out.derived = k.derived;
92
+ if (k.from !== void 0) out.from = k.from;
84
93
  return out;
85
94
  }));
86
95
  }
@@ -227,4 +236,237 @@ function driftLoop(opts) {
227
236
  } } });
228
237
  }
229
238
  //#endregion
230
- export { ClipError, clip, clipList, driftLoop, popIn, pulse, slideIn };
239
+ //#region src/morph.ts
240
+ /**
241
+ * Shared-element box-FLIP morph (0.13 build-time sugar). `morph()` takes two
242
+ * caller-supplied `Box` literals (a FROM rect and a TO rect, both with the Rect
243
+ * CENTER convention for x,y) and a map of target nodes, and compiles a FLIP
244
+ * (First-Last-Invert-Play) position+scale tween on ONE shared element plus an
245
+ * optional opacity cross-fade between the from/to nodes.
246
+ *
247
+ * It is PURE CORE: the FLIP delta is plain arithmetic over the two boxes — no
248
+ * Yoga, no worldMatrix, no signal evaluation, no scene query (a scene-side
249
+ * `worldBoxOf(node)` convenience is a deferred 0.14 fast-follow). The emission
250
+ * delegates to the validated `clip()` path (one track-emission codepath), so the
251
+ * output is BYTE-INDISTINGUISHABLE from hand-authored `track(...)` — same
252
+ * determinism contract as `clip`/`stagger`/`springTo`.
253
+ */
254
+ var MorphError = class extends Error {
255
+ constructor(message) {
256
+ super(message);
257
+ this.name = "MorphError";
258
+ }
259
+ };
260
+ function assertFiniteBox(label, b) {
261
+ for (const [k, v] of [
262
+ ["x", b.x],
263
+ ["y", b.y],
264
+ ["w", b.w],
265
+ ["h", b.h]
266
+ ]) if (!Number.isFinite(v)) throw new MorphError(`morph ${label}.${k} must be finite (got ${v})`);
267
+ if (!(b.w > 0) || !(b.h > 0)) throw new MorphError(`morph ${label} must have w>0 and h>0 (got w=${b.w}, h=${b.h}); a zero/negative box yields a NaN/Infinity scale`);
268
+ }
269
+ /**
270
+ * Resolve a node-level `TweenTarget` and append a prop suffix, producing the
271
+ * `'<nodeId>/<prop>'` string the clip map form resolves (which re-runs the
272
+ * structural/anonymous-id rejection via resolveTweenTarget). A string target is
273
+ * a bare node id; a property-signal carrier exposes its node via TARGET_PATH.
274
+ */
275
+ function nodeTarget(target, prop) {
276
+ const id = typeof target === "string" ? target : target[TARGET_PATH];
277
+ if (typeof id !== "string" || id.length === 0) return `${String(id)}/${prop}`;
278
+ return `${targetNodeId(id)}/${prop}`;
279
+ }
280
+ /**
281
+ * Compile a shared-element box-FLIP morph into hand-authored-equivalent tracks.
282
+ *
283
+ * morph({ x: 80, y: 200, w: 120, h: 36 }, { x: 320, y: 200, w: 480, h: 280 },
284
+ * { morphNode: 'morphFx', fromNode: 'chip', toNode: 'document' },
285
+ * { at: 0.5, duration: 1.2 })
286
+ *
287
+ * Emits (via the clip path):
288
+ * morphNode/position : vec2 [at]→from-center, [at+dur]→to-center
289
+ * morphNode/scale : vec2 [at]→from/base, [at+dur]→to/base
290
+ * fromNode/opacity : number[at]→1, [at+dur*crossfade]→0
291
+ * toNode/opacity : number[at+dur*(1-crossfade)]→0, [at+dur]→1
292
+ */
293
+ function morph(from, to, targets, opts) {
294
+ const { at, duration } = opts;
295
+ const ease = opts.ease;
296
+ const crossfade = opts.crossfade ?? .5;
297
+ const metric = opts.metric ?? "transform";
298
+ if (metric !== "transform") throw new MorphError(`morph metric '${metric}' is not supported in v1 (only 'transform')`);
299
+ assertFiniteBox("from", from);
300
+ assertFiniteBox("to", to);
301
+ if (!(duration > 0)) throw new MorphError(`morph duration must be > 0 (got ${duration})`);
302
+ if (!(crossfade >= 0 && crossfade <= 1)) throw new MorphError(`morph crossfade must be in [0,1] (got ${crossfade})`);
303
+ const base = opts.base ?? {
304
+ w: to.w,
305
+ h: to.h
306
+ };
307
+ assertFiniteBox("to", {
308
+ x: 0,
309
+ y: 0,
310
+ w: base.w,
311
+ h: base.h
312
+ });
313
+ const fromScale = [from.w / base.w, from.h / base.h];
314
+ const toScale = [to.w / base.w, to.h / base.h];
315
+ const fromCenter = [from.x, from.y];
316
+ const toCenter = [to.x, to.y];
317
+ const channels = {
318
+ position: {
319
+ path: "position",
320
+ type: "vec2",
321
+ keys: [key(0, fromCenter), key(duration, toCenter, ease)]
322
+ },
323
+ scale: {
324
+ path: "scale",
325
+ type: "vec2",
326
+ keys: [key(0, fromScale), key(duration, toScale, ease)]
327
+ }
328
+ };
329
+ const targetMap = {
330
+ position: nodeTarget(targets.morphNode, "position"),
331
+ scale: nodeTarget(targets.morphNode, "scale")
332
+ };
333
+ if (targets.fromNode !== void 0 && duration * crossfade > 0) {
334
+ channels["fromOpacity"] = {
335
+ path: "opacity",
336
+ type: "number",
337
+ keys: [key(0, 1), key(duration * crossfade, 0, ease)]
338
+ };
339
+ targetMap["fromOpacity"] = nodeTarget(targets.fromNode, "opacity");
340
+ }
341
+ if (targets.toNode !== void 0 && duration * (1 - crossfade) < duration) {
342
+ channels["toOpacity"] = {
343
+ path: "opacity",
344
+ type: "number",
345
+ keys: [key(duration * (1 - crossfade), 0), key(duration, 1, ease)]
346
+ };
347
+ targetMap["toOpacity"] = nodeTarget(targets.toNode, "opacity");
348
+ }
349
+ const { tracks } = clip({ channels }).apply(targetMap, at);
350
+ return {
351
+ tracks,
352
+ end: at + duration
353
+ };
354
+ }
355
+ //#endregion
356
+ //#region src/presence.ts
357
+ /**
358
+ * Presence scheduling (the 0.13 enter/exit sugar): build-time sugar over `clip`
359
+ * that schedules a node's ENTER on `show`, its EXIT to land exactly on `hide`,
360
+ * and authors a real `<nodeId>/opacity` WINDOW GUARD so the node is culled
361
+ * (opacity<=0, see scene/node.ts §3) outside the live window.
362
+ *
363
+ * Like `clip`/`springTo`/`stagger`, this is authoring SUGAR — it compiles to
364
+ * ordinary keyed `Track[]` via `track()`, byte-INDISTINGUISHABLE from the
365
+ * hand-authored form. There is NO runtime visibility flag and no non-track
366
+ * document state: a presence is a pure function of its arguments. Back-timing
367
+ * the exit is pure arithmetic; reconciling the enter/exit opacity keys with the
368
+ * guard uses the builder's deterministic later-wins coincident-`t` dedup with a
369
+ * fixed merge order, so goldens stay byte-identical.
370
+ *
371
+ * The canonical case is a "send-line agency moment": a node enters, lives, and
372
+ * exits on a beat. Siblings anchor to the real exit via the returned `end`.
373
+ */
374
+ var PresenceError = class extends Error {
375
+ constructor(message) {
376
+ super(message);
377
+ this.name = "PresenceError";
378
+ }
379
+ };
380
+ /** The default enter/exit: a plain opacity fade over `DEFAULT_FADE` seconds. */
381
+ const DEFAULT_FADE = .3;
382
+ /** Plain opacity fade-in 0→1 (the default enter). */
383
+ function defaultEnter() {
384
+ return clip({ channels: { opacity: {
385
+ path: "opacity",
386
+ keys: [key(0, 0), key(DEFAULT_FADE, 1)]
387
+ } } });
388
+ }
389
+ /** Plain opacity fade-out 1→0 (the default exit). */
390
+ function defaultExit() {
391
+ return clip({ channels: { opacity: {
392
+ path: "opacity",
393
+ keys: [key(0, 1), key(DEFAULT_FADE, 0)]
394
+ } } });
395
+ }
396
+ /** Effective duration of a clip after a speed multiplier (`apply` divides every t by speed). */
397
+ function effectiveDuration(c, opts) {
398
+ return c.duration / (opts?.speed ?? 1);
399
+ }
400
+ /**
401
+ * Sort keys by `t`, breaking ties by original index (a STABLE sort, so that at a
402
+ * coincident t the LATER-emitted key survives the later-wins dedup regardless of
403
+ * the engine's `Array.sort` stability). Pure: returns a new array.
404
+ */
405
+ function stableSortByT(keys) {
406
+ return keys.map((k, i) => [k, i]).sort((a, b) => a[0].t - b[0].t || a[1] - b[1]).map(([k]) => k);
407
+ }
408
+ /**
409
+ * Pull the keys of the clip's OWN `<nodeId>/opacity` track out of an applied
410
+ * result (if it authored one). Non-opacity tracks pass straight through.
411
+ */
412
+ function partitionOpacity(tracks, opacityTarget) {
413
+ const rest = [];
414
+ let opacityKeys = [];
415
+ for (const tr of tracks) if (tr.target === opacityTarget) opacityKeys = tr.keys;
416
+ else rest.push(tr);
417
+ return {
418
+ opacityKeys,
419
+ rest
420
+ };
421
+ }
422
+ /**
423
+ * Schedule a node's enter/exit presence. Emits keyed `Track[]` only.
424
+ *
425
+ * presence('card', { show: 1, hide: 5 }) // fade in at 1, fade out to land on 5
426
+ */
427
+ function presence(nodeId, opts) {
428
+ const { show, hide } = opts;
429
+ const baseTarget = resolveTweenTarget(typeof nodeId === "string" ? `${nodeId}/opacity` : nodeId);
430
+ const slash = baseTarget.indexOf("/");
431
+ const nodeIdStr = slash < 0 ? baseTarget : baseTarget.slice(0, slash);
432
+ const opacityTarget = `${nodeIdStr}/opacity`;
433
+ const enter = opts.enter ?? defaultEnter();
434
+ const exit = opts.exit ?? defaultExit();
435
+ const enterRes = enter.apply(nodeIdStr, show, opts.enterOpts);
436
+ const enterEnd = show + effectiveDuration(enter, opts.enterOpts);
437
+ const exitDur = effectiveDuration(exit, opts.exitOpts);
438
+ const exitStart = hide - exitDur;
439
+ if (exitStart < show) throw new PresenceError(`presence('${nodeIdStr}') exit needs ${exitDur.toFixed(3)}s but only ${(hide - show).toFixed(3)}s between show (${show}) and hide (${hide}) — widen the window or shorten/speed up the exit`);
440
+ const exitRes = exit.apply(nodeIdStr, exitStart, opts.exitOpts);
441
+ const { opacityKeys: enterOpacity, rest: enterRest } = partitionOpacity(enterRes.tracks, opacityTarget);
442
+ const { opacityKeys: exitOpacity, rest: exitRest } = partitionOpacity(exitRes.tracks, opacityTarget);
443
+ const bareGuard = [
444
+ key(0, 0, { interp: "hold" }),
445
+ key(enterEnd, 1, { interp: "hold" }),
446
+ key(hide, 0, { interp: "hold" })
447
+ ];
448
+ const overlay = [];
449
+ if (enterOpacity.length > 0) for (const k of enterOpacity) overlay.push(k);
450
+ else overlay.push(key(show, 0), key(enterEnd, 1));
451
+ if (exitOpacity.length > 0) for (const k of exitOpacity) overlay.push(k);
452
+ else overlay.push(key(exitStart, 1), key(hide, 0));
453
+ const sorted = stableSortByT([...bareGuard, ...overlay]);
454
+ const deduped = [];
455
+ for (const k of sorted) {
456
+ const last = deduped[deduped.length - 1];
457
+ if (last && last.t === k.t) deduped[deduped.length - 1] = k;
458
+ else deduped.push(k);
459
+ }
460
+ return {
461
+ tracks: [
462
+ track(opacityTarget, "number", deduped),
463
+ ...enterRest,
464
+ ...exitRest
465
+ ],
466
+ end: hide,
467
+ shownAt: show,
468
+ hiddenAt: hide
469
+ };
470
+ }
471
+ //#endregion
472
+ export { ClipError, MorphError, PresenceError, clip, clipList, driftLoop, morph, popIn, presence, pulse, slideIn };
package/dist/targetRef.js CHANGED
@@ -602,6 +602,14 @@ const pathType = {
602
602
  };
603
603
  const lerpN = (a, b, t) => a + (b - a) * t;
604
604
  const lerpPt = (a, b, t) => [lerpN(a[0], b[0], t), lerpN(a[1], b[1], t)];
605
+ /** Same hue at alpha 0 — a transparent stand-in so an appearing/disappearing
606
+ * `bg` (mesh baseline) fades through alpha instead of popping at the boundary. */
607
+ function transparentOf(color) {
608
+ return formatColor({
609
+ ...parseColor(color),
610
+ a: 0
611
+ });
612
+ }
605
613
  /** Lift a solid color to a uniform gradient matching `shape` (every stop = color),
606
614
  * so a color↔gradient tween fades smoothly instead of snapping. */
607
615
  function liftColor(color, shape) {
@@ -625,10 +633,10 @@ function liftColor(color, shape) {
625
633
  };
626
634
  }
627
635
  let warnedPaintShape = false;
628
- function paintSnap(t, a, b) {
636
+ function paintSnap(t, a, b, message = "paint lerp across mismatched gradient shapes (different kind or stop count): snapping instead of interpolating — match the kind + stop count on both keyframes (§2.2)") {
629
637
  if (!warnedPaintShape) {
630
638
  warnedPaintShape = true;
631
- emitDevWarning("paint lerp across mismatched gradient shapes (different kind or stop count): snapping instead of interpolating — match the kind + stop count on both keyframes (§2.2)");
639
+ emitDevWarning(message);
632
640
  }
633
641
  return t >= 1 ? b : a;
634
642
  }
@@ -649,17 +657,20 @@ const paintType = {
649
657
  };
650
658
  if (a.kind === "mesh" && b.kind === "mesh") {
651
659
  if (a.points.length !== b.points.length) return paintSnap(t, a, b);
660
+ if (a.interpolation !== b.interpolation) return paintSnap(t, a, b, `paint lerp across mismatched mesh interpolation modes ('${a.interpolation ?? "smooth"}' → '${b.interpolation ?? "smooth"}'): snapping instead of interpolating — the blend kernel is discrete, so it switches once at the boundary; match \`interpolation\` on both keyframes to morph the points (§3 Paint)`);
661
+ const points = a.points.map((pa, i) => {
662
+ const pb = b.points[i];
663
+ return {
664
+ pos: lerpPt(pa.pos, pb.pos, t),
665
+ color: lerpColor(pa.color, pb.color, t)
666
+ };
667
+ });
668
+ const bg = a.bg !== void 0 || b.bg !== void 0 ? { bg: lerpColor(a.bg ?? transparentOf(b.bg), b.bg ?? transparentOf(a.bg), t) } : {};
652
669
  return {
653
670
  kind: "mesh",
654
- points: a.points.map((pa, i) => {
655
- const pb = b.points[i];
656
- return {
657
- pos: lerpPt(pa.pos, pb.pos, t),
658
- color: lerpColor(pa.color, pb.color, t)
659
- };
660
- }),
671
+ points,
661
672
  ...a.interpolation ? { interpolation: a.interpolation } : {},
662
- ...a.bg !== void 0 && b.bg !== void 0 ? { bg: lerpColor(a.bg, b.bg, t) } : a.bg !== void 0 ? { bg: a.bg } : {}
673
+ ...bg
663
674
  };
664
675
  }
665
676
  if (a.kind === "mesh" || b.kind === "mesh") return paintSnap(t, a, b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/core",
3
- "version": "0.12.1",
3
+ "version": "0.13.0-pre.0",
4
4
  "description": "glissade core: signals, tracks, timeline document, evaluation, easing, springs, seeded RNG. Zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {