@vysmo/text 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +429 -0
  3. package/dist/animate.d.ts +28 -0
  4. package/dist/animate.d.ts.map +1 -0
  5. package/dist/animate.js +418 -0
  6. package/dist/animate.js.map +1 -0
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +6 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/presets/emphasis.d.ts +11 -0
  12. package/dist/presets/emphasis.d.ts.map +1 -0
  13. package/dist/presets/emphasis.js +54 -0
  14. package/dist/presets/emphasis.js.map +1 -0
  15. package/dist/presets/enter.d.ts +8 -0
  16. package/dist/presets/enter.d.ts.map +1 -0
  17. package/dist/presets/enter.js +58 -0
  18. package/dist/presets/enter.js.map +1 -0
  19. package/dist/presets/exit.d.ts +5 -0
  20. package/dist/presets/exit.d.ts.map +1 -0
  21. package/dist/presets/exit.js +30 -0
  22. package/dist/presets/exit.js.map +1 -0
  23. package/dist/presets/generated.d.ts +240 -0
  24. package/dist/presets/generated.d.ts.map +1 -0
  25. package/dist/presets/generated.js +3084 -0
  26. package/dist/presets/generated.js.map +1 -0
  27. package/dist/presets/index.d.ts +15 -0
  28. package/dist/presets/index.d.ts.map +1 -0
  29. package/dist/presets/index.js +43 -0
  30. package/dist/presets/index.js.map +1 -0
  31. package/dist/properties.d.ts +10 -0
  32. package/dist/properties.d.ts.map +1 -0
  33. package/dist/properties.js +80 -0
  34. package/dist/properties.js.map +1 -0
  35. package/dist/split.d.ts +21 -0
  36. package/dist/split.d.ts.map +1 -0
  37. package/dist/split.js +171 -0
  38. package/dist/split.js.map +1 -0
  39. package/dist/stagger.d.ts +13 -0
  40. package/dist/stagger.d.ts.map +1 -0
  41. package/dist/stagger.js +53 -0
  42. package/dist/stagger.js.map +1 -0
  43. package/dist/types.d.ts +204 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +56 -0
  48. package/src/__tests__/animate.test.ts +638 -0
  49. package/src/__tests__/presets.test.ts +87 -0
  50. package/src/__tests__/properties.test.ts +62 -0
  51. package/src/__tests__/split.test.ts +140 -0
  52. package/src/__tests__/ssr.test.ts +48 -0
  53. package/src/__tests__/stagger.test.ts +47 -0
  54. package/src/__tests__/types-check.ts +80 -0
  55. package/src/animate.ts +469 -0
  56. package/src/index.ts +38 -0
  57. package/src/presets/emphasis.ts +60 -0
  58. package/src/presets/enter.ts +64 -0
  59. package/src/presets/exit.ts +33 -0
  60. package/src/presets/generated.ts +3315 -0
  61. package/src/presets/index.ts +62 -0
  62. package/src/properties.ts +78 -0
  63. package/src/split.ts +180 -0
  64. package/src/stagger.ts +55 -0
  65. package/src/types.ts +245 -0
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HANDCURATED_NAMES, listPresets, resolvePreset } from "../presets/index.js";
3
+ import { evaluateSpecs } from "../animate.js";
4
+ import type { PresetName } from "../types.js";
5
+
6
+ const ALL = listPresets();
7
+
8
+ describe("preset catalog", () => {
9
+ it("exposes 14 hand-curated starter presets — 6 enter, 3 exit, 5 emphasis", () => {
10
+ // Asserts the seed catalog only — `listPresets()` also includes any
11
+ // entries authored via the Studio's random generator and ingested
12
+ // into `presets/generated.ts`, which grows independently.
13
+ expect(HANDCURATED_NAMES).toHaveLength(14);
14
+ expect(HANDCURATED_NAMES.filter((n) => n.startsWith("enter/"))).toHaveLength(6);
15
+ expect(HANDCURATED_NAMES.filter((n) => n.startsWith("exit/"))).toHaveLength(3);
16
+ expect(HANDCURATED_NAMES.filter((n) => n.startsWith("emphasis/"))).toHaveLength(5);
17
+ });
18
+
19
+ it("includes 3D presets that set perspective and (where relevant) transformOrigin", () => {
20
+ const depth = resolvePreset("enter/depth-zoom");
21
+ expect(depth.perspective).toBeGreaterThan(0);
22
+ const flip = resolvePreset("enter/flip-x");
23
+ expect(flip.perspective).toBeGreaterThan(0);
24
+ expect(flip.transformOrigin).toBeDefined();
25
+ const coin = resolvePreset("emphasis/coin-flip");
26
+ expect(coin.perspective).toBeGreaterThan(0);
27
+ });
28
+
29
+ it("each preset has a namespaced kebab-case name", () => {
30
+ // Allow digits inside segments — generated names use a placeholder
31
+ // form like `enter/g-001` until the rename pass assigns semantic
32
+ // labels. Strict letters-only kebab-case still applies after rename.
33
+ for (const name of ALL) {
34
+ expect(name).toMatch(/^(enter|exit|emphasis)\/[a-z0-9]+(-[a-z0-9]+)*$/);
35
+ }
36
+ });
37
+
38
+ it("resolvePreset returns a fully-formed Preset with ≥1 animation", () => {
39
+ for (const name of ALL) {
40
+ const p = resolvePreset(name);
41
+ expect(p.name).toBe(name);
42
+ expect(p.stagger).toBeGreaterThan(0);
43
+ expect(p.animations.length).toBeGreaterThan(0);
44
+ }
45
+ });
46
+
47
+ it("resolvePreset throws on an unknown name", () => {
48
+ expect(() => resolvePreset("enter/bogus" as PresetName)).toThrow(/unknown preset/);
49
+ });
50
+
51
+ it("enter presets start invisible (opacity 0 at t=0)", () => {
52
+ for (const name of ALL.filter((n) => n.startsWith("enter/"))) {
53
+ const p = resolvePreset(name);
54
+ const v = evaluateSpecs(p.animations, 0);
55
+ expect(v.opacity).toBe(0);
56
+ }
57
+ });
58
+
59
+ it("exit presets end invisible (opacity 0 at end of timeline)", () => {
60
+ for (const name of ALL.filter((n) => n.startsWith("exit/"))) {
61
+ const p = resolvePreset(name);
62
+ const end = Math.max(
63
+ ...p.animations.map((a) => (a.delay ?? 0) + (a.duration ?? 600)),
64
+ );
65
+ const v = evaluateSpecs(p.animations, end);
66
+ expect(v.opacity).toBe(0);
67
+ }
68
+ });
69
+
70
+ it("emphasis presets return to their rest state at the end (mod 360 for rotations)", () => {
71
+ const rotationRest = (deg: number): number => {
72
+ const m = ((deg % 360) + 360) % 360;
73
+ return m > 180 ? m - 360 : m;
74
+ };
75
+ for (const name of ALL.filter((n) => n.startsWith("emphasis/"))) {
76
+ const p = resolvePreset(name);
77
+ const end = Math.max(
78
+ ...p.animations.map((a) => (a.delay ?? 0) + (a.duration ?? 600)),
79
+ );
80
+ const v = evaluateSpecs(p.animations, end);
81
+ if (v.scale !== undefined) expect(v.scale).toBeCloseTo(1, 3);
82
+ if (v.translateX !== undefined) expect(v.translateX).toBeCloseTo(0, 3);
83
+ if (v.rotate !== undefined) expect(rotationRest(v.rotate)).toBeCloseTo(0, 3);
84
+ if (v.rotateY !== undefined) expect(rotationRest(v.rotateY)).toBeCloseTo(0, 3);
85
+ }
86
+ });
87
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyProps, clearProps } from "../properties.js";
3
+
4
+ function el(): HTMLElement {
5
+ return document.createElement("span");
6
+ }
7
+
8
+ describe("applyProps", () => {
9
+ it("sets opacity as a raw string", () => {
10
+ const e = el();
11
+ applyProps(e, { opacity: 0.5 });
12
+ expect(e.style.opacity).toBe("0.5");
13
+ });
14
+
15
+ it("composes translate/rotate/scale into one transform declaration", () => {
16
+ const e = el();
17
+ applyProps(e, { translateX: 10, translateY: -5, rotate: 30, scale: 1.2 });
18
+ expect(e.style.transform).toMatch(/translate3d\(10px, -5px, 0px\)/);
19
+ expect(e.style.transform).toMatch(/rotate\(30deg\)/);
20
+ expect(e.style.transform).toMatch(/scale\(1\.2\)/);
21
+ });
22
+
23
+ it("emits translate3d when any single translate axis is set", () => {
24
+ const e = el();
25
+ applyProps(e, { translateY: 20 });
26
+ expect(e.style.transform).toBe("translate3d(0px, 20px, 0px)");
27
+ });
28
+
29
+ it("composes blur/brightness/etc into one filter declaration", () => {
30
+ const e = el();
31
+ applyProps(e, { blur: 4, brightness: 1.2, hueRotate: 90 });
32
+ expect(e.style.filter).toMatch(/blur\(4px\)/);
33
+ expect(e.style.filter).toMatch(/brightness\(1\.2\)/);
34
+ expect(e.style.filter).toMatch(/hue-rotate\(90deg\)/);
35
+ });
36
+
37
+ it("leaves transform/filter untouched when no relevant prop is present", () => {
38
+ const e = el();
39
+ e.style.transform = "translateX(100px)";
40
+ applyProps(e, { opacity: 0.9 });
41
+ expect(e.style.transform).toBe("translateX(100px)");
42
+ });
43
+
44
+ it("replaces the previous transform composition on each call", () => {
45
+ const e = el();
46
+ applyProps(e, { rotate: 10, scale: 2 });
47
+ applyProps(e, { translateX: 50 });
48
+ expect(e.style.transform).toBe("translate3d(50px, 0px, 0px)");
49
+ expect(e.style.transform).not.toContain("rotate");
50
+ });
51
+ });
52
+
53
+ describe("clearProps", () => {
54
+ it("removes opacity/transform/filter from the element", () => {
55
+ const e = el();
56
+ applyProps(e, { opacity: 0.3, translateY: 5, blur: 2 });
57
+ clearProps(e);
58
+ expect(e.style.opacity).toBe("");
59
+ expect(e.style.transform).toBe("");
60
+ expect(e.style.filter).toBe("");
61
+ });
62
+ });
@@ -0,0 +1,140 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { splitText } from "../split.js";
3
+
4
+ let mounted: HTMLElement[] = [];
5
+
6
+ function mount(text: string, style?: Partial<CSSStyleDeclaration>): HTMLElement {
7
+ const el = document.createElement("p");
8
+ el.textContent = text;
9
+ if (style) Object.assign(el.style, style);
10
+ document.body.appendChild(el);
11
+ mounted.push(el);
12
+ return el;
13
+ }
14
+
15
+ afterEach(() => {
16
+ for (const el of mounted) el.remove();
17
+ mounted = [];
18
+ });
19
+
20
+ describe("splitText — character mode", () => {
21
+ it("wraps each visible grapheme in a span, keeping whitespace as text nodes", () => {
22
+ const el = mount("ab c");
23
+ const splits = splitText(el, { mode: "character" });
24
+ expect(splits.slices).toHaveLength(3);
25
+ for (const s of splits.slices) {
26
+ expect(s.tagName).toBe("SPAN");
27
+ expect(s.getAttribute("data-text-slice")).toBe("character");
28
+ expect(s.style.display).toBe("inline-block");
29
+ }
30
+ expect(splits.slices.map((s) => s.textContent).join("")).toBe("abc");
31
+ });
32
+
33
+ it("treats emoji as a single grapheme", () => {
34
+ const el = mount("a👨‍👩‍👧b");
35
+ const splits = splitText(el, { mode: "character" });
36
+ const texts = splits.slices.map((s) => s.textContent);
37
+ expect(texts).toEqual(["a", "👨‍👩‍👧", "b"]);
38
+ });
39
+
40
+ it("treats combining marks as a single grapheme", () => {
41
+ // "é" built from e + combining acute (U+0065 U+0301) — two code points, one grapheme.
42
+ const el = mount("éa");
43
+ const splits = splitText(el, { mode: "character" });
44
+ expect(splits.slices).toHaveLength(2);
45
+ expect(splits.slices[0]!.textContent).toBe("é");
46
+ });
47
+
48
+ it("exposes the original string to screen readers via a visually-hidden copy", () => {
49
+ const el = mount("Accessible text");
50
+ splitText(el, { mode: "character" });
51
+ const sr = el.querySelector("[data-text-sr]") as HTMLElement | null;
52
+ expect(sr).not.toBeNull();
53
+ expect(sr!.textContent).toBe("Accessible text");
54
+ expect(sr!.style.position).toBe("absolute");
55
+ });
56
+
57
+ it("marks visible slices aria-hidden so screen readers use the SR copy", () => {
58
+ const el = mount("Hi");
59
+ const splits = splitText(el, { mode: "character" });
60
+ for (const s of splits.slices) {
61
+ expect(s.getAttribute("aria-hidden")).toBe("true");
62
+ }
63
+ });
64
+ });
65
+
66
+ describe("splitText — word mode", () => {
67
+ it("wraps words AND punctuation; only whitespace stays as raw text nodes", () => {
68
+ const el = mount("Hello, world!");
69
+ const splits = splitText(el, { mode: "word" });
70
+ expect(splits.slices.map((s) => s.textContent)).toEqual(["Hello", ",", "world", "!"]);
71
+ for (const s of splits.slices) {
72
+ expect(s.getAttribute("data-text-slice")).toBe("word");
73
+ }
74
+ });
75
+
76
+ it("preserves whitespace between words in rendered text", () => {
77
+ const el = mount("a bb ccc");
78
+ splitText(el, { mode: "word" });
79
+ const visible = el.textContent ?? "";
80
+ // SR-only copy prepends "a bb ccc"; the split copy appends "a bb ccc" again.
81
+ expect(visible).toContain("a bb ccc");
82
+ });
83
+
84
+ it("segments Arabic text into word slices with its locale-aware boundaries", () => {
85
+ // "مرحبا بالعالم" = "Hello world". Word mode keeps each word whole, so
86
+ // contextual shaping within each span still applies correctly.
87
+ const el = mount("مرحبا بالعالم", { direction: "rtl" });
88
+ const splits = splitText(el, { mode: "word", locale: "ar" });
89
+ const texts = splits.slices.map((s) => s.textContent);
90
+ expect(texts).toContain("مرحبا");
91
+ expect(texts).toContain("بالعالم");
92
+ });
93
+
94
+ it("wraps punctuation in non-latin scripts too (CJK fullwidth, Arabic)", () => {
95
+ const el = mount("你好,世界!", { direction: "ltr" });
96
+ const splits = splitText(el, { mode: "word", locale: "zh" });
97
+ const texts = splits.slices.map((s) => s.textContent);
98
+ expect(texts).toContain(",");
99
+ expect(texts).toContain("!");
100
+ });
101
+ });
102
+
103
+ describe("splitText — line mode", () => {
104
+ it("groups words into lines by their measured top position", () => {
105
+ const el = mount("one two three four five six seven eight nine ten eleven twelve", {
106
+ width: "60px",
107
+ fontSize: "16px",
108
+ lineHeight: "20px",
109
+ fontFamily: "monospace",
110
+ });
111
+ const splits = splitText(el, { mode: "line" });
112
+ expect(splits.slices.length).toBeGreaterThan(1);
113
+ for (const s of splits.slices) {
114
+ expect(s.getAttribute("data-text-slice")).toBe("line");
115
+ }
116
+ const rendered = splits.slices.map((s) => s.textContent?.trim()).join(" ");
117
+ for (const word of ["one", "twelve", "seven"]) {
118
+ expect(rendered).toContain(word);
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("splitText — restore", () => {
124
+ it("restore() returns the element to its original textContent", () => {
125
+ const el = mount("Hello world");
126
+ const splits = splitText(el, { mode: "character" });
127
+ expect(el.querySelectorAll("[data-text-slice]").length).toBeGreaterThan(0);
128
+ splits.restore();
129
+ expect(el.textContent).toBe("Hello world");
130
+ expect(el.querySelectorAll("[data-text-slice]").length).toBe(0);
131
+ });
132
+
133
+ it("restore() is idempotent", () => {
134
+ const el = mount("abc");
135
+ const splits = splitText(el, { mode: "character" });
136
+ splits.restore();
137
+ splits.restore();
138
+ expect(el.textContent).toBe("abc");
139
+ });
140
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * SSR safety: the module graph must load in Node without DOM globals.
5
+ * Uses the vitest.ssr.config.ts Node runner (see package.json scripts).
6
+ * The main vitest.config.ts runs all other tests in a browser for DOM
7
+ * coverage; this file is the exception that runs in plain Node.
8
+ */
9
+
10
+ describe("SSR safety", () => {
11
+ it("window is undefined in this runtime", () => {
12
+ expect(typeof window).toBe("undefined");
13
+ });
14
+
15
+ it("module loads without DOM globals", async () => {
16
+ const mod = await import("../index.js");
17
+ expect(mod).toBeDefined();
18
+ expect(typeof mod.animateText).toBe("function");
19
+ expect(typeof mod.splitText).toBe("function");
20
+ expect(typeof mod.applyProps).toBe("function");
21
+ expect(typeof mod.resolvePreset).toBe("function");
22
+ });
23
+
24
+ it("presets are plain-data and safe to access in Node", async () => {
25
+ const { fadeUp, listPresets, resolvePreset } = await import("../index.js");
26
+ const { HANDCURATED_NAMES } = await import("../presets/index.js");
27
+ expect(fadeUp.name).toBe("enter/fade-up");
28
+ expect(fadeUp.animations.length).toBeGreaterThan(0);
29
+ // Hand-curated catalog has the 14 starter entries; the registry
30
+ // returned by listPresets() may be larger if the Studio has
31
+ // ingested generated presets.
32
+ expect(HANDCURATED_NAMES).toHaveLength(14);
33
+ expect(listPresets().length).toBeGreaterThanOrEqual(14);
34
+ const emphasis = resolvePreset("emphasis/pulse");
35
+ expect(emphasis.animations[0]!.prop).toBe("scale");
36
+ });
37
+
38
+ it("splitText throws a readable error when called without DOM", async () => {
39
+ const { splitText } = await import("../index.js");
40
+ expect(() => splitText({} as unknown as HTMLElement)).toThrow(/browser environment/);
41
+ });
42
+
43
+ it("evaluateSpecs is pure and runs in Node", async () => {
44
+ const { evaluateSpecs } = await import("../index.js");
45
+ const v = evaluateSpecs([{ prop: "opacity", from: 0, to: 1, duration: 100 }], 50);
46
+ expect(v.opacity).toBeCloseTo(0.5, 3);
47
+ });
48
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeStaggerDelays } from "../stagger.js";
3
+
4
+ describe("computeStaggerDelays", () => {
5
+ it("start order returns 0, g, 2g, ...", () => {
6
+ expect(computeStaggerDelays(4, 10, "start")).toEqual([0, 10, 20, 30]);
7
+ });
8
+
9
+ it("end order reverses start", () => {
10
+ expect(computeStaggerDelays(4, 10, "end")).toEqual([30, 20, 10, 0]);
11
+ });
12
+
13
+ it("center order grows outward from the center", () => {
14
+ const d = computeStaggerDelays(5, 10, "center");
15
+ // mid = 2 → ranks: |0-2|, |1-2|, |2-2|, |3-2|, |4-2| = 2,1,0,1,2
16
+ expect(d).toEqual([20, 10, 0, 10, 20]);
17
+ });
18
+
19
+ it("edges order is the inverse of center — fires outer first", () => {
20
+ const d = computeStaggerDelays(5, 10, "edges");
21
+ // ranks: mid - |i-mid| = 0,1,2,1,0
22
+ expect(d).toEqual([0, 10, 20, 10, 0]);
23
+ });
24
+
25
+ it("random order produces a permutation of [0..N-1] × stagger", () => {
26
+ const rng = makeRng(42);
27
+ const d = computeStaggerDelays(6, 10, "random", rng);
28
+ const sorted = [...d].sort((a, b) => a - b);
29
+ expect(sorted).toEqual([0, 10, 20, 30, 40, 50]);
30
+ });
31
+
32
+ it("returns all zeros when stagger is 0", () => {
33
+ expect(computeStaggerDelays(4, 0, "start")).toEqual([0, 0, 0, 0]);
34
+ });
35
+
36
+ it("returns [] for count 0", () => {
37
+ expect(computeStaggerDelays(0, 10, "start")).toEqual([]);
38
+ });
39
+ });
40
+
41
+ function makeRng(seed: number): () => number {
42
+ let s = seed >>> 0;
43
+ return () => {
44
+ s = (s * 1664525 + 1013904223) >>> 0;
45
+ return s / 0xffffffff;
46
+ };
47
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ animateText,
3
+ evaluateSpecs,
4
+ listPresets,
5
+ resolvePreset,
6
+ splitText,
7
+ type AnimateTextHandle,
8
+ type AnimateTextOptions,
9
+ type HandcuratedPresetName,
10
+ type Preset,
11
+ type PresetName,
12
+ type Splits,
13
+ type TextAnimationSpec,
14
+ } from "../index.js";
15
+ import { power2Out } from "@vysmo/easings";
16
+
17
+ // --- HandcuratedPresetName is a closed union (typos rejected) --------------
18
+
19
+ const _h: HandcuratedPresetName = "enter/fade-up";
20
+ void _h;
21
+
22
+ // @ts-expect-error — typo is rejected against the closed union
23
+ const _hBad: HandcuratedPresetName = "enter/fadeup";
24
+ void _hBad;
25
+
26
+ // --- PresetName is open (so the catalog can grow via generated entries) ----
27
+
28
+ const _n: PresetName = "enter/fade-up";
29
+ const _nGen: PresetName = "enter/some-generated-name";
30
+ void [_n, _nGen];
31
+
32
+ // --- listPresets / resolvePreset return the union and a full Preset --------
33
+
34
+ const names = listPresets();
35
+ const _names: PresetName[] = names;
36
+ void _names;
37
+
38
+ const p: Preset = resolvePreset("emphasis/shake");
39
+ const _pname: PresetName = p.name;
40
+ const _pstagger: number = p.stagger;
41
+ const _panims: TextAnimationSpec[] = p.animations;
42
+ void [_pname, _pstagger, _panims];
43
+
44
+ // --- animateText options accept a preset OR raw animations -----------------
45
+
46
+ declare const el: HTMLElement;
47
+
48
+ const opt1: AnimateTextOptions = { preset: "enter/scale-in" };
49
+ const opt2: AnimateTextOptions = {
50
+ animations: [{ prop: "opacity", from: 0, to: 1, duration: 300, ease: power2Out }],
51
+ stagger: 20,
52
+ staggerOrder: "center",
53
+ };
54
+ void [opt1, opt2];
55
+
56
+ const handle: AnimateTextHandle = animateText(el, { preset: "enter/fade-up" });
57
+ const _s: Splits = handle.splits;
58
+ const _f: Promise<void> = handle.finished;
59
+ void [_s, _f];
60
+
61
+ // --- animateText rejects bogus props at compile time -----------------------
62
+
63
+ animateText(el, {
64
+ animations: [
65
+ // @ts-expect-error — "wiggle" is not a TextProperty
66
+ { prop: "wiggle", from: 0, to: 1 },
67
+ ],
68
+ });
69
+
70
+ // --- splitText returns Splits with slices: HTMLElement[] -------------------
71
+
72
+ const splits = splitText(el, { mode: "word" });
73
+ const _slices: HTMLElement[] = splits.slices;
74
+ void _slices;
75
+
76
+ // --- evaluateSpecs runs purely on TextAnimationSpec[] ----------------------
77
+
78
+ const v = evaluateSpecs(p.animations, 100);
79
+ const _opacity: number | undefined = v.opacity;
80
+ void _opacity;