@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,62 @@
1
+ import type { Preset, PresetName } from "../types.js";
2
+ import { blurIn, depthZoom, elasticRise, fadeUp, flipX, scaleIn } from "./enter.js";
3
+ import { fadeDown, flipAway, scaleOut } from "./exit.js";
4
+ import { coinFlip, pulse, shake, spin, wobble } from "./emphasis.js";
5
+ import { ALL_GENERATED } from "./generated.js";
6
+
7
+ const HANDCURATED: Record<string, Preset> = {
8
+ "enter/fade-up": fadeUp,
9
+ "enter/elastic-rise": elasticRise,
10
+ "enter/blur-in": blurIn,
11
+ "enter/scale-in": scaleIn,
12
+ "enter/flip-x": flipX,
13
+ "enter/depth-zoom": depthZoom,
14
+ "exit/fade-down": fadeDown,
15
+ "exit/scale-out": scaleOut,
16
+ "exit/flip-away": flipAway,
17
+ "emphasis/pulse": pulse,
18
+ "emphasis/shake": shake,
19
+ "emphasis/wobble": wobble,
20
+ "emphasis/coin-flip": coinFlip,
21
+ "emphasis/spin": spin,
22
+ };
23
+
24
+ // Merge handcurated + generated into a single runtime registry. Generated
25
+ // entries don't shadow handcurated ones — the catalog grows by addition.
26
+ const PRESETS: Record<string, Preset> = { ...HANDCURATED };
27
+ for (const { name, preset } of ALL_GENERATED) PRESETS[name] = preset;
28
+
29
+ /**
30
+ * The hand-curated seed catalog, keyed by the closed
31
+ * `HandcuratedPresetName` literal union. Useful for tests + tooling
32
+ * that want to assert against the seed set without picking up
33
+ * generated entries (which grow dynamically via `ALL_GENERATED`).
34
+ */
35
+ export const HANDCURATED_NAMES: readonly string[] = Object.keys(HANDCURATED);
36
+
37
+ export function resolvePreset(name: PresetName): Preset {
38
+ const p = PRESETS[name];
39
+ if (!p) throw new Error(`@vysmo/text: unknown preset "${name}"`);
40
+ return p;
41
+ }
42
+
43
+ export function listPresets(): PresetName[] {
44
+ return Object.keys(PRESETS) as PresetName[];
45
+ }
46
+
47
+ export {
48
+ fadeUp,
49
+ elasticRise,
50
+ blurIn,
51
+ scaleIn,
52
+ flipX,
53
+ depthZoom,
54
+ fadeDown,
55
+ scaleOut,
56
+ flipAway,
57
+ pulse,
58
+ shake,
59
+ wobble,
60
+ coinFlip,
61
+ spin,
62
+ };
@@ -0,0 +1,78 @@
1
+ import type { TextProperty } from "./types.js";
2
+
3
+ export type PropValues = Partial<Record<TextProperty, number>>;
4
+
5
+ const TRANSFORM_PROPS: ReadonlyArray<TextProperty> = [
6
+ "translateX",
7
+ "translateY",
8
+ "translateZ",
9
+ "rotate",
10
+ "rotateX",
11
+ "rotateY",
12
+ "rotateZ",
13
+ "scale",
14
+ "scaleX",
15
+ "scaleY",
16
+ "skewX",
17
+ "skewY",
18
+ ];
19
+
20
+ const FILTER_PROPS: ReadonlyArray<TextProperty> = [
21
+ "blur",
22
+ "brightness",
23
+ "contrast",
24
+ "saturate",
25
+ "hueRotate",
26
+ ];
27
+
28
+ /**
29
+ * Compose numeric per-axis values into the CSS strings the browser wants.
30
+ * `transform` and `filter` are each composed as a single declaration so
31
+ * successive writes don't leak stale parts from previous frames.
32
+ */
33
+ export function applyProps(el: HTMLElement, vals: PropValues): void {
34
+ if (vals.opacity !== undefined) {
35
+ el.style.opacity = String(vals.opacity);
36
+ }
37
+
38
+ const hasTransform = TRANSFORM_PROPS.some((p) => vals[p] !== undefined);
39
+ if (hasTransform) {
40
+ const parts: string[] = [];
41
+ if (
42
+ vals.translateX !== undefined ||
43
+ vals.translateY !== undefined ||
44
+ vals.translateZ !== undefined
45
+ ) {
46
+ parts.push(
47
+ `translate3d(${vals.translateX ?? 0}px, ${vals.translateY ?? 0}px, ${vals.translateZ ?? 0}px)`,
48
+ );
49
+ }
50
+ if (vals.rotate !== undefined) parts.push(`rotate(${vals.rotate}deg)`);
51
+ if (vals.rotateX !== undefined) parts.push(`rotateX(${vals.rotateX}deg)`);
52
+ if (vals.rotateY !== undefined) parts.push(`rotateY(${vals.rotateY}deg)`);
53
+ if (vals.rotateZ !== undefined) parts.push(`rotateZ(${vals.rotateZ}deg)`);
54
+ if (vals.scale !== undefined) parts.push(`scale(${vals.scale})`);
55
+ if (vals.scaleX !== undefined) parts.push(`scaleX(${vals.scaleX})`);
56
+ if (vals.scaleY !== undefined) parts.push(`scaleY(${vals.scaleY})`);
57
+ if (vals.skewX !== undefined) parts.push(`skewX(${vals.skewX}deg)`);
58
+ if (vals.skewY !== undefined) parts.push(`skewY(${vals.skewY}deg)`);
59
+ el.style.transform = parts.join(" ");
60
+ }
61
+
62
+ const hasFilter = FILTER_PROPS.some((p) => vals[p] !== undefined);
63
+ if (hasFilter) {
64
+ const parts: string[] = [];
65
+ if (vals.blur !== undefined) parts.push(`blur(${vals.blur}px)`);
66
+ if (vals.brightness !== undefined) parts.push(`brightness(${vals.brightness})`);
67
+ if (vals.contrast !== undefined) parts.push(`contrast(${vals.contrast})`);
68
+ if (vals.saturate !== undefined) parts.push(`saturate(${vals.saturate})`);
69
+ if (vals.hueRotate !== undefined) parts.push(`hue-rotate(${vals.hueRotate}deg)`);
70
+ el.style.filter = parts.join(" ");
71
+ }
72
+ }
73
+
74
+ export function clearProps(el: HTMLElement): void {
75
+ el.style.removeProperty("opacity");
76
+ el.style.removeProperty("transform");
77
+ el.style.removeProperty("filter");
78
+ }
package/src/split.ts ADDED
@@ -0,0 +1,180 @@
1
+ import type { SplitMode, SplitOptions, Splits } from "./types.js";
2
+
3
+ const WHITESPACE_ONLY = /^\s+$/;
4
+
5
+ function hasSegmenter(): boolean {
6
+ return typeof Intl !== "undefined" && typeof Intl.Segmenter === "function";
7
+ }
8
+
9
+ function graphemeSegments(text: string, locale: string | undefined): string[] {
10
+ if (hasSegmenter()) {
11
+ const seg = new Intl.Segmenter(locale, { granularity: "grapheme" });
12
+ return Array.from(seg.segment(text), (s) => s.segment);
13
+ }
14
+ return Array.from(text);
15
+ }
16
+
17
+ function wordSegments(
18
+ text: string,
19
+ locale: string | undefined,
20
+ ): Array<{ segment: string; isWordLike: boolean }> {
21
+ if (hasSegmenter()) {
22
+ const seg = new Intl.Segmenter(locale, { granularity: "word" });
23
+ return Array.from(seg.segment(text), (s) => ({
24
+ segment: s.segment,
25
+ isWordLike: s.isWordLike ?? false,
26
+ }));
27
+ }
28
+ const parts = text.split(/(\s+)/).filter((s) => s.length > 0);
29
+ return parts.map((segment) => ({
30
+ segment,
31
+ isWordLike: !WHITESPACE_ONLY.test(segment),
32
+ }));
33
+ }
34
+
35
+ function makeSliceSpan(text: string, kind: SplitMode): HTMLElement {
36
+ const span = document.createElement("span");
37
+ span.textContent = text;
38
+ span.style.display = "inline-block";
39
+ span.style.willChange = "transform, opacity, filter";
40
+ span.setAttribute("data-text-slice", kind);
41
+ span.setAttribute("aria-hidden", "true");
42
+ return span;
43
+ }
44
+
45
+ function appendScreenReaderCopy(element: HTMLElement, text: string): void {
46
+ const sr = document.createElement("span");
47
+ sr.textContent = text;
48
+ sr.setAttribute("data-text-sr", "");
49
+ const s = sr.style;
50
+ s.position = "absolute";
51
+ s.width = "1px";
52
+ s.height = "1px";
53
+ s.padding = "0";
54
+ s.margin = "-1px";
55
+ s.overflow = "hidden";
56
+ s.clip = "rect(0, 0, 0, 0)";
57
+ s.whiteSpace = "nowrap";
58
+ s.border = "0";
59
+ element.appendChild(sr);
60
+ }
61
+
62
+ /**
63
+ * Split an element's text into per-slice spans suitable for independent
64
+ * transform/filter/opacity animation. Grapheme-safe via `Intl.Segmenter`
65
+ * when available (falls back to `Array.from(text)` for character mode and
66
+ * a whitespace-preserving regex for word mode).
67
+ *
68
+ * Line mode requires the element to be in the DOM and laid out — line
69
+ * boundaries are detected via `getBoundingClientRect().top` after words
70
+ * have been inserted.
71
+ *
72
+ * Script compatibility:
73
+ * - LTR and RTL (Arabic, Hebrew): word and line modes work correctly; the
74
+ * browser's bidi algorithm places inline-block siblings in visual order.
75
+ * - Connected / contextually-shaped scripts (Arabic, Devanagari, Lao,
76
+ * Khmer, …): character mode breaks shaping because each grapheme lands
77
+ * in its own inline-block box, preventing letters from joining. Prefer
78
+ * word or line mode for these scripts.
79
+ */
80
+ export function splitText(element: HTMLElement, options: SplitOptions = {}): Splits {
81
+ if (typeof document === "undefined") {
82
+ throw new Error("splitText: requires a browser environment");
83
+ }
84
+
85
+ const mode: SplitMode = options.mode ?? "character";
86
+ const original = element.textContent ?? "";
87
+
88
+ element.textContent = "";
89
+ appendScreenReaderCopy(element, original);
90
+
91
+ const slices: HTMLElement[] = [];
92
+
93
+ if (mode === "character") {
94
+ for (const g of graphemeSegments(original, options.locale)) {
95
+ if (WHITESPACE_ONLY.test(g)) {
96
+ element.appendChild(document.createTextNode(g));
97
+ } else {
98
+ const span = makeSliceSpan(g, "character");
99
+ element.appendChild(span);
100
+ slices.push(span);
101
+ }
102
+ }
103
+ } else if (mode === "word") {
104
+ // Wrap every non-whitespace segment (words AND punctuation) so nothing
105
+ // is left behind at opacity 0 / blur. Intl.Segmenter's word granularity
106
+ // classifies commas/exclamations/etc as `isWordLike: false`; relying on
107
+ // that would leave punctuation unanimated while its neighbours faded.
108
+ for (const { segment } of wordSegments(original, options.locale)) {
109
+ if (WHITESPACE_ONLY.test(segment)) {
110
+ element.appendChild(document.createTextNode(segment));
111
+ } else {
112
+ const span = makeSliceSpan(segment, "word");
113
+ element.appendChild(span);
114
+ slices.push(span);
115
+ }
116
+ }
117
+ } else {
118
+ // Line mode: insert temporary word spans, measure, wrap each visual
119
+ // line into a single line span, then demote inner word spans back to
120
+ // plain text so only the line span carries animatable style.
121
+ const tempWords: HTMLElement[] = [];
122
+ for (const { segment } of wordSegments(original, options.locale)) {
123
+ if (WHITESPACE_ONLY.test(segment)) {
124
+ element.appendChild(document.createTextNode(segment));
125
+ } else {
126
+ const span = makeSliceSpan(segment, "word");
127
+ element.appendChild(span);
128
+ tempWords.push(span);
129
+ }
130
+ }
131
+
132
+ const groups: HTMLElement[][] = [];
133
+ let currentTop: number | null = null;
134
+ let currentGroup: HTMLElement[] = [];
135
+ for (const span of tempWords) {
136
+ const top = Math.round(span.getBoundingClientRect().top);
137
+ if (currentTop === null || Math.abs(top - currentTop) > 1) {
138
+ if (currentGroup.length > 0) groups.push(currentGroup);
139
+ currentGroup = [];
140
+ currentTop = top;
141
+ }
142
+ currentGroup.push(span);
143
+ }
144
+ if (currentGroup.length > 0) groups.push(currentGroup);
145
+
146
+ for (const group of groups) {
147
+ const firstWord = group[0]!;
148
+ const lastWord = group[group.length - 1]!;
149
+ const lineWrap = makeSliceSpan("", "line");
150
+ lineWrap.textContent = "";
151
+ firstWord.parentNode!.insertBefore(lineWrap, firstWord);
152
+ let node: ChildNode | null = firstWord;
153
+ while (node) {
154
+ const next: ChildNode | null = node.nextSibling;
155
+ lineWrap.appendChild(node);
156
+ if (node === lastWord) break;
157
+ node = next;
158
+ }
159
+ // Demote inner word spans to plain text — only the line span animates.
160
+ for (const word of group) {
161
+ word.replaceWith(document.createTextNode(word.textContent ?? ""));
162
+ }
163
+ slices.push(lineWrap);
164
+ }
165
+ }
166
+
167
+ let restored = false;
168
+ const splits: Splits = {
169
+ slices,
170
+ mode,
171
+ original,
172
+ restore() {
173
+ if (restored) return;
174
+ restored = true;
175
+ element.textContent = original;
176
+ },
177
+ };
178
+
179
+ return splits;
180
+ }
package/src/stagger.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { StaggerOrder } from "./types.js";
2
+
3
+ /**
4
+ * Given N slices, a per-step gap, and an order strategy, return the
5
+ * start-delay (in ms) to apply to each slice — index-aligned.
6
+ *
7
+ * - "start": 0, 1g, 2g, 3g, …
8
+ * - "end": reversed
9
+ * - "center": grows outward from the center index
10
+ * - "edges": grows inward from the edges toward the center
11
+ * - "random": a uniform random permutation of the above ranks
12
+ */
13
+ export function computeStaggerDelays(
14
+ count: number,
15
+ stagger: number,
16
+ order: StaggerOrder,
17
+ rng: () => number = Math.random,
18
+ ): number[] {
19
+ if (count <= 0) return [];
20
+ if (stagger <= 0) return new Array<number>(count).fill(0);
21
+
22
+ const ranks = new Array<number>(count);
23
+
24
+ switch (order) {
25
+ case "start":
26
+ for (let i = 0; i < count; i++) ranks[i] = i;
27
+ break;
28
+ case "end":
29
+ for (let i = 0; i < count; i++) ranks[i] = count - 1 - i;
30
+ break;
31
+ case "center": {
32
+ const mid = (count - 1) / 2;
33
+ for (let i = 0; i < count; i++) ranks[i] = Math.round(Math.abs(i - mid));
34
+ break;
35
+ }
36
+ case "edges": {
37
+ const mid = (count - 1) / 2;
38
+ for (let i = 0; i < count; i++) ranks[i] = Math.round(mid - Math.abs(i - mid));
39
+ break;
40
+ }
41
+ case "random": {
42
+ const perm = Array.from({ length: count }, (_, i) => i);
43
+ for (let i = perm.length - 1; i > 0; i--) {
44
+ const j = Math.floor(rng() * (i + 1));
45
+ const tmp = perm[i]!;
46
+ perm[i] = perm[j]!;
47
+ perm[j] = tmp;
48
+ }
49
+ for (let rank = 0; rank < count; rank++) ranks[perm[rank]!] = rank;
50
+ break;
51
+ }
52
+ }
53
+
54
+ return ranks.map((r) => r * stagger);
55
+ }
package/src/types.ts ADDED
@@ -0,0 +1,245 @@
1
+ import type { EasingFn } from "@vysmo/easings";
2
+ import type { Scheduler } from "@vysmo/animations";
3
+
4
+ export type SplitMode = "character" | "word" | "line";
5
+
6
+ export type SplitOptions = {
7
+ /** Granularity of the split. Default "character". */
8
+ mode?: SplitMode;
9
+ /** BCP-47 locale for Intl.Segmenter. Falls back to the environment default. */
10
+ locale?: string;
11
+ };
12
+
13
+ export type Splits = {
14
+ /** Slice elements in document order (characters, words, or lines). */
15
+ readonly slices: HTMLElement[];
16
+ /** The split granularity. */
17
+ readonly mode: SplitMode;
18
+ /** The original text captured before the split. */
19
+ readonly original: string;
20
+ /** Restore the element's original text. Idempotent. */
21
+ restore(): void;
22
+ };
23
+
24
+ /**
25
+ * The animatable axes understood by animateText. Numeric-only by design — the
26
+ * runtime composes these into `transform` / `filter` / `opacity` strings per
27
+ * frame, so shape mismatches are impossible and per-slice blending stays cheap.
28
+ */
29
+ export type TextProperty =
30
+ | "opacity"
31
+ | "translateX"
32
+ | "translateY"
33
+ | "translateZ"
34
+ | "scale"
35
+ | "scaleX"
36
+ | "scaleY"
37
+ | "rotate"
38
+ | "rotateX"
39
+ | "rotateY"
40
+ | "rotateZ"
41
+ | "skewX"
42
+ | "skewY"
43
+ | "blur"
44
+ | "brightness"
45
+ | "contrast"
46
+ | "saturate"
47
+ | "hueRotate";
48
+
49
+ /**
50
+ * A scalar or a uniform range. Range values resolve **per slice** at
51
+ * animate-start: every slice samples its own value, so animations like
52
+ * "letters scatter from random offsets and converge on 0" become a
53
+ * one-line spec change. `{ min: x, max: x }` resolves to the constant
54
+ * `x` without consuming an rng draw.
55
+ */
56
+ export type TextValue = number | { min: number; max: number };
57
+
58
+ /**
59
+ * Transform-origin in normalized form. `x`/`y` are fractions (0..1, where
60
+ * 0 is left/top and 1 is right/bottom — `{ x: 0.5, y: 1 }` = bottom
61
+ * center). Optional `z` is in pixels and lets 3D transforms pivot in
62
+ * front of / behind the slice. Stored as data instead of a CSS string so
63
+ * the same preset can drive a future canvas/Skia runtime without parsing.
64
+ */
65
+ export type TransformOrigin = {
66
+ x: number;
67
+ y: number;
68
+ z?: number;
69
+ };
70
+
71
+ export type TextAnimationSpec = {
72
+ prop: TextProperty;
73
+ from: TextValue;
74
+ to: TextValue;
75
+ /** Duration in milliseconds. Default 600. */
76
+ duration?: number;
77
+ /**
78
+ * Delay (ms) from the slice's stagger offset before this spec begins.
79
+ * Sequential specs targeting the same prop chain via their delays.
80
+ */
81
+ delay?: number;
82
+ /**
83
+ * Easing for this spec. Default linear.
84
+ *
85
+ * Two forms accepted:
86
+ * - **String** (preferred for catalog presets) — a GSAP-style spec like
87
+ * `"power2.out"`, `"back.out(2)"`, `"elastic.out(1.2, 0.4)"`,
88
+ * `"cubic-bezier(0.42, 0, 0.58, 1)"`. Resolved at animate-start via
89
+ * `parseEasing()` from `@vysmo/easings`. Strings are JSON-serializable
90
+ * so the same preset data can drive a future canvas/Skia runtime
91
+ * without losing fidelity.
92
+ * - **Function** — any `EasingFn`/`(t: number) => number`. Use this when
93
+ * you bring a custom curve that isn't in the catalog.
94
+ */
95
+ ease?: string | EasingFn | ((t: number) => number);
96
+ /**
97
+ * Override the root stagger for this spec only. Enables a per-prop cadence
98
+ * — e.g. translateY staggered at 30ms while opacity staggers at 10ms —
99
+ * which is the main knob the authoring studio uses to explore variety.
100
+ */
101
+ stagger?: number;
102
+ /** Override the root staggerOrder for this spec only. */
103
+ staggerOrder?: StaggerOrder;
104
+ /**
105
+ * Per-slice random delay in ms added on top of the slice's stagger
106
+ * offset. Each slice gets its own value uniformly sampled from
107
+ * `[0, jitterDelay]` at animate-start. Default 0. Useful for breaking
108
+ * the "metronome" feel of pure stagger by spraying start times.
109
+ */
110
+ jitterDelay?: number;
111
+ /**
112
+ * Override the preset / option-level `transformOrigin` for the
113
+ * duration of this spec. Written to each slice's inline style at the
114
+ * moment the spec's window opens for that slice; persists until the
115
+ * next override on the same slice (or `stop()`). When two specs with
116
+ * different origins overlap on a slice, the most recently-opened
117
+ * spec wins (last-write-wins).
118
+ */
119
+ transformOrigin?: TransformOrigin;
120
+ /**
121
+ * Override the container `perspective` (in px) for the duration of
122
+ * this spec. Written to the container element when the spec's window
123
+ * first opens for any slice; persists until the next override (or
124
+ * `stop()`). Container-scoped, so it affects every slice — same
125
+ * scope as the option / preset-level `perspective`.
126
+ */
127
+ perspective?: number;
128
+ };
129
+
130
+ export type StaggerOrder = "start" | "end" | "center" | "edges" | "random";
131
+
132
+ export type AnimateTextOptions = {
133
+ /** Split granularity. Overridden by the preset's split when unset. Default "character". */
134
+ split?: SplitMode;
135
+ /** BCP-47 locale for Intl.Segmenter. */
136
+ locale?: string;
137
+ /** Milliseconds between consecutive slices starting to animate. Default 30. */
138
+ stagger?: number;
139
+ /** Order in which slices receive their stagger offset. Default "start". */
140
+ staggerOrder?: StaggerOrder;
141
+ /** One or more animated properties running in parallel per slice. */
142
+ animations?: TextAnimationSpec[];
143
+ /**
144
+ * Preset to apply. Accepts either the catalog name (convenience, pulls
145
+ * the whole registry) or a `Preset` object reference (tree-shakable —
146
+ * the unused 14/15/… presets stay out of the bundle).
147
+ */
148
+ preset?: PresetName | Preset;
149
+ /**
150
+ * CSS perspective (px) applied to the container so children's rotateX /
151
+ * rotateY / translateZ render with depth. Required for 3D transforms to
152
+ * look 3D — without it, rotateY(90deg) is just a 1px-wide line.
153
+ */
154
+ perspective?: number;
155
+ /** CSS perspective-origin string applied to the container (e.g., "50% 30%"). */
156
+ perspectiveOrigin?: string;
157
+ /** Transform-origin applied to every slice. `{ x: 0.5, y: 0 }` = top center. */
158
+ transformOrigin?: TransformOrigin;
159
+ /** Begin playing automatically. Default true. */
160
+ autoPlay?: boolean;
161
+ /** When true, skip animation under prefers-reduced-motion. Default true. */
162
+ respectReducedMotion?: boolean;
163
+ /**
164
+ * Delay in ms before the first play begins. Useful for sequencing an
165
+ * entry after some other animation completes.
166
+ */
167
+ delay?: number;
168
+ /**
169
+ * How many times the whole choreography plays. `1` (default) = play
170
+ * once. A number > 1 = that many cycles. `"infinite"` = loop forever
171
+ * (or until `.stop()`). Emphasis presets like pulse typically want
172
+ * `repeat: 3, repeatDelay: 400` for a "triple-tap" feel.
173
+ */
174
+ repeat?: number | "infinite";
175
+ /** Delay between successive cycles when `repeat > 1`. Default 0. */
176
+ repeatDelay?: number;
177
+ /** Override the time source for deterministic playback in tests. */
178
+ scheduler?: Scheduler;
179
+ /** Deterministic RNG for "random" stagger order. Defaults to Math.random. */
180
+ rng?: () => number;
181
+ };
182
+
183
+ export type AnimateTextHandle = {
184
+ /** Start or resume playback. */
185
+ play(): AnimateTextHandle;
186
+ /** Pause playback, preserving progress. */
187
+ pause(): AnimateTextHandle;
188
+ /** Stop playback and reset slices to their un-animated style. */
189
+ stop(): AnimateTextHandle;
190
+ /** Jump every slice to the given progress in [0, 1]. */
191
+ seek(progress: number): AnimateTextHandle;
192
+ /** Resolves when every slice has finished naturally. */
193
+ readonly finished: Promise<void>;
194
+ /** The split result backing this animation. */
195
+ readonly splits: Splits;
196
+ };
197
+
198
+ export type Preset = {
199
+ name: PresetName;
200
+ /** Split granularity this preset was tuned against. */
201
+ split?: SplitMode;
202
+ /** Default stagger; callers can override. */
203
+ stagger: number;
204
+ /** Stagger order this preset was tuned against. */
205
+ staggerOrder?: StaggerOrder;
206
+ animations: TextAnimationSpec[];
207
+ /** Container perspective (px) required for this preset's 3D transforms. */
208
+ perspective?: number;
209
+ /** Slice-level transform-origin this preset was tuned against. */
210
+ transformOrigin?: TransformOrigin;
211
+ /** Default play count for this preset (e.g. emphasis/pulse repeating 3×). */
212
+ repeat?: number | "infinite";
213
+ /** Default gap between cycles in ms. */
214
+ repeatDelay?: number;
215
+ };
216
+
217
+ /**
218
+ * Hand-curated preset names — the seed catalog. These autocomplete in
219
+ * IDEs and stay typed in tests / consumer code.
220
+ */
221
+ export type HandcuratedPresetName =
222
+ | "enter/fade-up"
223
+ | "enter/elastic-rise"
224
+ | "enter/blur-in"
225
+ | "enter/scale-in"
226
+ | "enter/flip-x"
227
+ | "enter/depth-zoom"
228
+ | "exit/fade-down"
229
+ | "exit/scale-out"
230
+ | "exit/flip-away"
231
+ | "emphasis/pulse"
232
+ | "emphasis/shake"
233
+ | "emphasis/wobble"
234
+ | "emphasis/coin-flip"
235
+ | "emphasis/spin";
236
+
237
+ /**
238
+ * Any registered preset name — handcurated entries autocomplete via the
239
+ * literal union; the `string & {}` branch keeps the type open for the
240
+ * generated catalog (300+ entries authored via the Studio's random
241
+ * generator). Lookup at runtime is a string key against `PRESETS`, so
242
+ * unknown names throw at `resolvePreset` rather than at the type
243
+ * boundary.
244
+ */
245
+ export type PresetName = HandcuratedPresetName | (string & {});