@teleporthq/teleport-shared 0.43.69 → 0.43.71

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 (160) hide show
  1. package/__tests__/motion-runtime/motion-engine.ts +102 -0
  2. package/__tests__/motion-runtime/scroll-video-engine.ts +28 -0
  3. package/__tests__/scroll-scene/story-lanes.ts +126 -0
  4. package/__tests__/utils/inline-script-error-guard.ts +121 -0
  5. package/dist/cjs/index.d.ts +6 -1
  6. package/dist/cjs/index.d.ts.map +1 -1
  7. package/dist/cjs/index.js +11 -1
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/motion-runtime/index.d.ts +3 -0
  10. package/dist/cjs/motion-runtime/index.d.ts.map +1 -0
  11. package/dist/cjs/motion-runtime/index.js +8 -0
  12. package/dist/cjs/motion-runtime/index.js.map +1 -0
  13. package/dist/cjs/motion-runtime/motion-engine-source.d.ts +16 -0
  14. package/dist/cjs/motion-runtime/motion-engine-source.d.ts.map +1 -0
  15. package/dist/cjs/motion-runtime/motion-engine-source.js +20 -0
  16. package/dist/cjs/motion-runtime/motion-engine-source.js.map +1 -0
  17. package/dist/cjs/motion-runtime/scroll-video-engine-source.d.ts +9 -0
  18. package/dist/cjs/motion-runtime/scroll-video-engine-source.d.ts.map +1 -0
  19. package/dist/cjs/motion-runtime/scroll-video-engine-source.js +13 -0
  20. package/dist/cjs/motion-runtime/scroll-video-engine-source.js.map +1 -0
  21. package/dist/cjs/page-transition/index.d.ts +4 -0
  22. package/dist/cjs/page-transition/index.d.ts.map +1 -0
  23. package/dist/cjs/page-transition/index.js +20 -0
  24. package/dist/cjs/page-transition/index.js.map +1 -0
  25. package/dist/cjs/page-transition/morph-source.d.ts +20 -0
  26. package/dist/cjs/page-transition/morph-source.d.ts.map +1 -0
  27. package/dist/cjs/page-transition/morph-source.js +25 -0
  28. package/dist/cjs/page-transition/morph-source.js.map +1 -0
  29. package/dist/cjs/page-transition/variants.d.ts +87 -0
  30. package/dist/cjs/page-transition/variants.d.ts.map +1 -0
  31. package/dist/cjs/page-transition/variants.js +330 -0
  32. package/dist/cjs/page-transition/variants.js.map +1 -0
  33. package/dist/cjs/page-transition/view-transition-css.d.ts +21 -0
  34. package/dist/cjs/page-transition/view-transition-css.d.ts.map +1 -0
  35. package/dist/cjs/page-transition/view-transition-css.js +188 -0
  36. package/dist/cjs/page-transition/view-transition-css.js.map +1 -0
  37. package/dist/cjs/scroll-scene/chapter-index.d.ts +15 -0
  38. package/dist/cjs/scroll-scene/chapter-index.d.ts.map +1 -0
  39. package/dist/cjs/scroll-scene/chapter-index.js +30 -0
  40. package/dist/cjs/scroll-scene/chapter-index.js.map +1 -0
  41. package/dist/cjs/scroll-scene/controller-source.d.ts +26 -0
  42. package/dist/cjs/scroll-scene/controller-source.d.ts.map +1 -0
  43. package/dist/cjs/scroll-scene/controller-source.js +34 -0
  44. package/dist/cjs/scroll-scene/controller-source.js.map +1 -0
  45. package/dist/cjs/scroll-scene/engine-source.d.ts +2 -0
  46. package/dist/cjs/scroll-scene/engine-source.d.ts.map +1 -0
  47. package/dist/cjs/scroll-scene/engine-source.js +26 -0
  48. package/dist/cjs/scroll-scene/engine-source.js.map +1 -0
  49. package/dist/cjs/scroll-scene/index.d.ts +7 -0
  50. package/dist/cjs/scroll-scene/index.d.ts.map +1 -0
  51. package/dist/cjs/scroll-scene/index.js +22 -0
  52. package/dist/cjs/scroll-scene/index.js.map +1 -0
  53. package/dist/cjs/scroll-scene/moment.d.ts +21 -0
  54. package/dist/cjs/scroll-scene/moment.d.ts.map +1 -0
  55. package/dist/cjs/scroll-scene/moment.js +50 -0
  56. package/dist/cjs/scroll-scene/moment.js.map +1 -0
  57. package/dist/cjs/scroll-scene/points.d.ts +23 -0
  58. package/dist/cjs/scroll-scene/points.d.ts.map +1 -0
  59. package/dist/cjs/scroll-scene/points.js +51 -0
  60. package/dist/cjs/scroll-scene/points.js.map +1 -0
  61. package/dist/cjs/scroll-scene/unclip.d.ts +15 -0
  62. package/dist/cjs/scroll-scene/unclip.d.ts.map +1 -0
  63. package/dist/cjs/scroll-scene/unclip.js +44 -0
  64. package/dist/cjs/scroll-scene/unclip.js.map +1 -0
  65. package/dist/cjs/state-bindings/index.d.ts +74 -0
  66. package/dist/cjs/state-bindings/index.d.ts.map +1 -0
  67. package/dist/cjs/state-bindings/index.js +193 -0
  68. package/dist/cjs/state-bindings/index.js.map +1 -0
  69. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  70. package/dist/cjs/utils/inline-script-error-guard.d.ts +29 -0
  71. package/dist/cjs/utils/inline-script-error-guard.d.ts.map +1 -0
  72. package/dist/cjs/utils/inline-script-error-guard.js +38 -0
  73. package/dist/cjs/utils/inline-script-error-guard.js.map +1 -0
  74. package/dist/esm/index.d.ts +6 -1
  75. package/dist/esm/index.d.ts.map +1 -1
  76. package/dist/esm/index.js +6 -1
  77. package/dist/esm/index.js.map +1 -1
  78. package/dist/esm/motion-runtime/index.d.ts +3 -0
  79. package/dist/esm/motion-runtime/index.d.ts.map +1 -0
  80. package/dist/esm/motion-runtime/index.js +3 -0
  81. package/dist/esm/motion-runtime/index.js.map +1 -0
  82. package/dist/esm/motion-runtime/motion-engine-source.d.ts +16 -0
  83. package/dist/esm/motion-runtime/motion-engine-source.d.ts.map +1 -0
  84. package/dist/esm/motion-runtime/motion-engine-source.js +16 -0
  85. package/dist/esm/motion-runtime/motion-engine-source.js.map +1 -0
  86. package/dist/esm/motion-runtime/scroll-video-engine-source.d.ts +9 -0
  87. package/dist/esm/motion-runtime/scroll-video-engine-source.d.ts.map +1 -0
  88. package/dist/esm/motion-runtime/scroll-video-engine-source.js +9 -0
  89. package/dist/esm/motion-runtime/scroll-video-engine-source.js.map +1 -0
  90. package/dist/esm/page-transition/index.d.ts +4 -0
  91. package/dist/esm/page-transition/index.d.ts.map +1 -0
  92. package/dist/esm/page-transition/index.js +4 -0
  93. package/dist/esm/page-transition/index.js.map +1 -0
  94. package/dist/esm/page-transition/morph-source.d.ts +20 -0
  95. package/dist/esm/page-transition/morph-source.d.ts.map +1 -0
  96. package/dist/esm/page-transition/morph-source.js +21 -0
  97. package/dist/esm/page-transition/morph-source.js.map +1 -0
  98. package/dist/esm/page-transition/variants.d.ts +87 -0
  99. package/dist/esm/page-transition/variants.d.ts.map +1 -0
  100. package/dist/esm/page-transition/variants.js +318 -0
  101. package/dist/esm/page-transition/variants.js.map +1 -0
  102. package/dist/esm/page-transition/view-transition-css.d.ts +21 -0
  103. package/dist/esm/page-transition/view-transition-css.d.ts.map +1 -0
  104. package/dist/esm/page-transition/view-transition-css.js +161 -0
  105. package/dist/esm/page-transition/view-transition-css.js.map +1 -0
  106. package/dist/esm/scroll-scene/chapter-index.d.ts +15 -0
  107. package/dist/esm/scroll-scene/chapter-index.d.ts.map +1 -0
  108. package/dist/esm/scroll-scene/chapter-index.js +26 -0
  109. package/dist/esm/scroll-scene/chapter-index.js.map +1 -0
  110. package/dist/esm/scroll-scene/controller-source.d.ts +26 -0
  111. package/dist/esm/scroll-scene/controller-source.d.ts.map +1 -0
  112. package/dist/esm/scroll-scene/controller-source.js +26 -0
  113. package/dist/esm/scroll-scene/controller-source.js.map +1 -0
  114. package/dist/esm/scroll-scene/engine-source.d.ts +2 -0
  115. package/dist/esm/scroll-scene/engine-source.d.ts.map +1 -0
  116. package/dist/esm/scroll-scene/engine-source.js +22 -0
  117. package/dist/esm/scroll-scene/engine-source.js.map +1 -0
  118. package/dist/esm/scroll-scene/index.d.ts +7 -0
  119. package/dist/esm/scroll-scene/index.d.ts.map +1 -0
  120. package/dist/esm/scroll-scene/index.js +7 -0
  121. package/dist/esm/scroll-scene/index.js.map +1 -0
  122. package/dist/esm/scroll-scene/moment.d.ts +21 -0
  123. package/dist/esm/scroll-scene/moment.d.ts.map +1 -0
  124. package/dist/esm/scroll-scene/moment.js +46 -0
  125. package/dist/esm/scroll-scene/moment.js.map +1 -0
  126. package/dist/esm/scroll-scene/points.d.ts +23 -0
  127. package/dist/esm/scroll-scene/points.d.ts.map +1 -0
  128. package/dist/esm/scroll-scene/points.js +45 -0
  129. package/dist/esm/scroll-scene/points.js.map +1 -0
  130. package/dist/esm/scroll-scene/unclip.d.ts +15 -0
  131. package/dist/esm/scroll-scene/unclip.d.ts.map +1 -0
  132. package/dist/esm/scroll-scene/unclip.js +40 -0
  133. package/dist/esm/scroll-scene/unclip.js.map +1 -0
  134. package/dist/esm/state-bindings/index.d.ts +74 -0
  135. package/dist/esm/state-bindings/index.d.ts.map +1 -0
  136. package/dist/esm/state-bindings/index.js +176 -0
  137. package/dist/esm/state-bindings/index.js.map +1 -0
  138. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  139. package/dist/esm/utils/inline-script-error-guard.d.ts +29 -0
  140. package/dist/esm/utils/inline-script-error-guard.d.ts.map +1 -0
  141. package/dist/esm/utils/inline-script-error-guard.js +34 -0
  142. package/dist/esm/utils/inline-script-error-guard.js.map +1 -0
  143. package/package.json +3 -3
  144. package/src/index.ts +10 -0
  145. package/src/motion-runtime/index.ts +2 -0
  146. package/src/motion-runtime/motion-engine-source.ts +289 -0
  147. package/src/motion-runtime/scroll-video-engine-source.ts +591 -0
  148. package/src/page-transition/index.ts +3 -0
  149. package/src/page-transition/morph-source.ts +86 -0
  150. package/src/page-transition/variants.ts +407 -0
  151. package/src/page-transition/view-transition-css.ts +237 -0
  152. package/src/scroll-scene/chapter-index.ts +25 -0
  153. package/src/scroll-scene/controller-source.ts +451 -0
  154. package/src/scroll-scene/engine-source.ts +330 -0
  155. package/src/scroll-scene/index.ts +12 -0
  156. package/src/scroll-scene/moment.ts +64 -0
  157. package/src/scroll-scene/points.ts +46 -0
  158. package/src/scroll-scene/unclip.ts +44 -0
  159. package/src/state-bindings/index.ts +207 -0
  160. package/src/utils/inline-script-error-guard.ts +51 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * The three states of a route transition for a preset, and the preset the
3
+ * back button plays. Mirrored VERBATIM from teleport-gui's
4
+ * `constants/primitives/page-transition.ts` — the settings picker demonstrates
5
+ * these exact states. Plain JS inside the bodies on purpose: their compiled
6
+ * source is injected into the generated `TqPageTransition` wrapper with
7
+ * `toString()`, so no template literals, no backticks, no helpers from outside
8
+ * the two functions.
9
+ */
10
+ export interface PageTransitionContext {
11
+ reverse: boolean
12
+ originX: string
13
+ originY: string
14
+ }
15
+
16
+ export interface PageTransitionPanel {
17
+ from: Record<string, unknown>
18
+ cover: Record<string, unknown>
19
+ away: Record<string, unknown>
20
+ side?: 'left' | 'right'
21
+ }
22
+
23
+ export interface PageTransitionVariants {
24
+ initial: Record<string, unknown>
25
+ animate: Record<string, unknown>
26
+ exit: Record<string, unknown>
27
+ }
28
+
29
+ export function reversePageTransitionPreset(preset: string): string {
30
+ const pairs: Record<string, string> = {
31
+ 'slide-up': 'slide-down',
32
+ 'slide-down': 'slide-up',
33
+ 'slide-left': 'slide-right',
34
+ 'slide-right': 'slide-left',
35
+ 'zoom-in': 'zoom-out',
36
+ 'zoom-out': 'zoom-in',
37
+ 'wipe-down': 'wipe-up',
38
+ 'wipe-up': 'wipe-down',
39
+ 'wipe-left': 'wipe-right',
40
+ 'wipe-right': 'wipe-left',
41
+ stack: 'unstack',
42
+ unstack: 'stack',
43
+ }
44
+ return pairs[preset] || preset
45
+ }
46
+
47
+ export interface PageTransitionCustomState {
48
+ opacity: number
49
+ x: number
50
+ y: number
51
+ scale: number
52
+ blur: number
53
+ }
54
+
55
+ export interface PageTransitionCustom {
56
+ durationSeconds: number
57
+ easing: string
58
+ arrive: PageTransitionCustomState
59
+ leave: PageTransitionCustomState
60
+ }
61
+
62
+ export function pageTransitionVariants(
63
+ preset: string,
64
+ slidePx: number,
65
+ context?: PageTransitionContext,
66
+ options?: Record<string, string>,
67
+ custom?: PageTransitionCustom
68
+ ): PageTransitionVariants | null {
69
+ const reverse = context ? context.reverse : false
70
+ const originX = context ? context.originX : '50%'
71
+ const originY = context ? context.originY : '50%'
72
+ const name = reverse ? reversePageTransitionPreset(preset) : preset
73
+ const bold = options
74
+ ? options.distance === 'bold' || options.depth === 'bold' || options.amount === 'bold'
75
+ : false
76
+ const near = bold ? slidePx * 4 : slidePx
77
+ const far = near * 2
78
+ const blurPx = options && options.softness === 'heavy' ? 28 : 12
79
+ const shrink = bold ? 0.85 : 0.94
80
+ const zoomOut = bold ? 0.85 : 0.96
81
+ const zoomIn = bold ? 1.15 : 1.04
82
+ if (name === 'custom') {
83
+ // Neutral in the middle; the two ends are exactly the user's values.
84
+ const c = custom || {
85
+ arrive: { opacity: 0, x: 0, y: 24, scale: 1, blur: 0 },
86
+ leave: { opacity: 0, x: 0, y: -24, scale: 1, blur: 0 },
87
+ }
88
+ return {
89
+ initial: {
90
+ opacity: c.arrive.opacity,
91
+ x: c.arrive.x,
92
+ y: c.arrive.y,
93
+ scale: c.arrive.scale,
94
+ filter: 'blur(' + c.arrive.blur + 'px)',
95
+ },
96
+ animate: { opacity: 1, x: 0, y: 0, scale: 1, filter: 'blur(0px)' },
97
+ exit: {
98
+ opacity: c.leave.opacity,
99
+ x: c.leave.x,
100
+ y: c.leave.y,
101
+ scale: c.leave.scale,
102
+ filter: 'blur(' + c.leave.blur + 'px)',
103
+ },
104
+ }
105
+ }
106
+ if (name === 'fade') {
107
+ return { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
108
+ }
109
+ if (name === 'crossfade') {
110
+ return {
111
+ initial: { opacity: 0, scale: 1.02 },
112
+ animate: { opacity: 1, scale: 1 },
113
+ exit: { opacity: 0, scale: 0.98 },
114
+ }
115
+ }
116
+ if (name === 'blur') {
117
+ return {
118
+ initial: { opacity: 0, filter: 'blur(' + blurPx + 'px)' },
119
+ animate: { opacity: 1, filter: 'blur(0px)' },
120
+ exit: { opacity: 0, filter: 'blur(' + blurPx + 'px)' },
121
+ }
122
+ }
123
+ if (name === 'slide-up') {
124
+ return {
125
+ initial: { opacity: 0, y: near },
126
+ animate: { opacity: 1, y: 0 },
127
+ exit: { opacity: 0, y: -near },
128
+ }
129
+ }
130
+ if (name === 'slide-down') {
131
+ return {
132
+ initial: { opacity: 0, y: -near },
133
+ animate: { opacity: 1, y: 0 },
134
+ exit: { opacity: 0, y: near },
135
+ }
136
+ }
137
+ if (name === 'slide-left') {
138
+ return {
139
+ initial: { opacity: 0, x: far },
140
+ animate: { opacity: 1, x: 0 },
141
+ exit: { opacity: 0, x: -far },
142
+ }
143
+ }
144
+ if (name === 'slide-right') {
145
+ return {
146
+ initial: { opacity: 0, x: -far },
147
+ animate: { opacity: 1, x: 0 },
148
+ exit: { opacity: 0, x: far },
149
+ }
150
+ }
151
+ if (name === 'stack') {
152
+ return {
153
+ initial: { opacity: 0, y: far },
154
+ animate: { opacity: 1, y: 0, scale: 1 },
155
+ exit: { opacity: 0, scale: shrink },
156
+ }
157
+ }
158
+ if (name === 'unstack') {
159
+ return {
160
+ initial: { opacity: 0, scale: shrink },
161
+ animate: { opacity: 1, y: 0, scale: 1 },
162
+ exit: { opacity: 0, y: far },
163
+ }
164
+ }
165
+ if (name === 'zoom-in') {
166
+ return {
167
+ initial: { opacity: 0, scale: zoomOut },
168
+ animate: { opacity: 1, scale: 1 },
169
+ exit: { opacity: 0, scale: zoomIn },
170
+ }
171
+ }
172
+ if (name === 'zoom-out') {
173
+ return {
174
+ initial: { opacity: 0, scale: zoomIn },
175
+ animate: { opacity: 1, scale: 1 },
176
+ exit: { opacity: 0, scale: zoomOut },
177
+ }
178
+ }
179
+ if (name === 'wipe-down') {
180
+ return {
181
+ initial: { clipPath: 'inset(0 0 100% 0)' },
182
+ animate: { clipPath: 'inset(0 0 0% 0)' },
183
+ exit: { clipPath: 'inset(100% 0 0 0)' },
184
+ }
185
+ }
186
+ if (name === 'wipe-up') {
187
+ return {
188
+ initial: { clipPath: 'inset(100% 0 0 0)' },
189
+ animate: { clipPath: 'inset(0% 0 0 0)' },
190
+ exit: { clipPath: 'inset(0 0 100% 0)' },
191
+ }
192
+ }
193
+ if (name === 'wipe-left') {
194
+ return {
195
+ initial: { clipPath: 'inset(0 0 0 100%)' },
196
+ animate: { clipPath: 'inset(0 0 0 0%)' },
197
+ exit: { clipPath: 'inset(0 100% 0 0)' },
198
+ }
199
+ }
200
+ if (name === 'wipe-right') {
201
+ return {
202
+ initial: { clipPath: 'inset(0 100% 0 0)' },
203
+ animate: { clipPath: 'inset(0 0% 0 0)' },
204
+ exit: { clipPath: 'inset(0 0 0 100%)' },
205
+ }
206
+ }
207
+ if (name === 'circle') {
208
+ const at = ' at ' + originX + ' ' + originY + ')'
209
+ return {
210
+ initial: { clipPath: 'circle(0%' + at },
211
+ animate: { clipPath: 'circle(150%' + at },
212
+ exit: { clipPath: 'circle(0%' + at },
213
+ }
214
+ }
215
+ if (name === 'cover' || name === 'curtain') {
216
+ // The panels do the work; the page itself only has to STAY while it is
217
+ // being covered. An exit that changes nothing would end instantly, so it
218
+ // moves opacity by an invisible amount — that is what keeps the leaving
219
+ // page on screen for exactly one panel sweep.
220
+ return { initial: { opacity: 1 }, animate: { opacity: 1 }, exit: { opacity: 0.999 } }
221
+ }
222
+ return null
223
+ }
224
+
225
+ export function pageTransitionCover(preset: string): PageTransitionPanel[] | null {
226
+ if (preset === 'cover') {
227
+ return [{ from: { y: '100%' }, cover: { y: '0%' }, away: { y: '-100%' } }]
228
+ }
229
+ if (preset === 'curtain') {
230
+ return [
231
+ { from: { x: '-100%' }, cover: { x: '0%' }, away: { x: '-100%' }, side: 'left' },
232
+ { from: { x: '100%' }, cover: { x: '0%' }, away: { x: '100%' }, side: 'right' },
233
+ ]
234
+ }
235
+ return null
236
+ }
237
+
238
+ /**
239
+ * The matcher for a route a page opted out of transitions with: exact path,
240
+ * optional trailing slash, and `[param]` segments (how the editor spells a
241
+ * details page) matching any single segment. Injected into the wrapper.
242
+ */
243
+ export function pageTransitionSkipPattern(route: string): RegExp {
244
+ const escaped = route.replace(/[.*+?^$()|{}\\]/g, '\\$&')
245
+ return new RegExp('^' + escaped.replace(/\[[^\]]+\]/g, '[^/]+') + '/?$')
246
+ }
247
+
248
+ /** How far a page travels while sliding, in px — pages move, they do not fly. */
249
+ export const PAGE_TRANSITION_SLIDE_PX = 24
250
+
251
+ /**
252
+ * The preset's choices with gaps filled by defaults and unknown words replaced
253
+ * by the default. Mirrored verbatim from teleport-gui; runs at generation time.
254
+ */
255
+ export function resolvePageTransitionOptions(
256
+ preset: string,
257
+ chosen?: Record<string, string>
258
+ ): Record<string, string> {
259
+ const catalog: Record<string, Array<[string, string[]]>> = {
260
+ blur: [['softness', ['light', 'heavy']]],
261
+ 'slide-up': [['distance', ['subtle', 'bold']]],
262
+ 'slide-down': [['distance', ['subtle', 'bold']]],
263
+ 'slide-left': [['distance', ['subtle', 'bold']]],
264
+ 'slide-right': [['distance', ['subtle', 'bold']]],
265
+ stack: [['depth', ['subtle', 'bold']]],
266
+ 'zoom-in': [['amount', ['subtle', 'bold']]],
267
+ 'zoom-out': [['amount', ['subtle', 'bold']]],
268
+ circle: [['origin', ['click', 'center']]],
269
+ cover: [['color', ['brand', 'dark', 'light']]],
270
+ curtain: [['color', ['brand', 'dark', 'light']]],
271
+ }
272
+ const resolved: Record<string, string> = {}
273
+ const entries = catalog[preset] || []
274
+ for (let i = 0; i < entries.length; i++) {
275
+ const key = entries[i][0]
276
+ const words = entries[i][1]
277
+ const value = chosen ? chosen[key] : undefined
278
+ const known =
279
+ value !== undefined &&
280
+ (words.indexOf(value) >= 0 || (key === 'color' && isPageTransitionColorValue(value)))
281
+ resolved[key] = known ? (value as string) : words[0]
282
+ }
283
+ return resolved
284
+ }
285
+
286
+ /**
287
+ * A colour a cover panel may carry: a project token id, a hex colour, or
288
+ * rgb/hsl with plain numbers — the value lands verbatim in generated code.
289
+ * Mirrored verbatim from teleport-gui.
290
+ */
291
+ export function isPageTransitionColorValue(value: string | undefined): boolean {
292
+ if (typeof value !== 'string' || value.length === 0 || value.length > 120) {
293
+ return false
294
+ }
295
+ return (
296
+ /^token:[a-z0-9_-]+$/i.test(value) ||
297
+ /^--[a-z0-9_-]+$/i.test(value) ||
298
+ /^#[0-9a-f]{3,8}$/i.test(value) ||
299
+ /^(rgb|rgba|hsl|hsla)\([0-9.,%\s]+\)$/i.test(value)
300
+ )
301
+ }
302
+
303
+ /** The CSS colour a cover-family panel paints with: token → var(), literal as is, words → theme tokens. Mirrored verbatim from teleport-gui. */
304
+ export function pageTransitionCoverColor(color: string | undefined): string {
305
+ const dark = 'var(--dl-color-theme-neutral-dark, #111111)'
306
+ if (color === 'dark') {
307
+ return dark
308
+ }
309
+ if (color === 'light') {
310
+ return 'var(--dl-color-theme-neutral-light, #ffffff)'
311
+ }
312
+ if (
313
+ color &&
314
+ color !== 'brand' &&
315
+ color.indexOf('token:') !== 0 &&
316
+ isPageTransitionColorValue(color)
317
+ ) {
318
+ return color.indexOf('--') === 0 ? 'var(' + color + ', ' + dark + ')' : color
319
+ }
320
+ return 'var(--dl-color-theme-primary1, ' + dark + ')'
321
+ }
322
+
323
+ const CUSTOM_EASINGS = [
324
+ 'ease',
325
+ 'ease-in',
326
+ 'ease-out',
327
+ 'ease-in-out',
328
+ 'linear',
329
+ 'spring',
330
+ 'back',
331
+ 'bounce',
332
+ ]
333
+
334
+ const DEFAULT_CUSTOM: PageTransitionCustom = {
335
+ durationSeconds: 0.35,
336
+ easing: 'ease-out',
337
+ arrive: { opacity: 0, x: 0, y: 24, scale: 1, blur: 0 },
338
+ leave: { opacity: 0, x: 0, y: -24, scale: 1, blur: 0 },
339
+ }
340
+
341
+ const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
342
+ const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback
343
+ return Math.min(max, Math.max(min, n))
344
+ }
345
+
346
+ const sanitizeCustomState = (
347
+ input: Partial<PageTransitionCustomState> | undefined,
348
+ fallback: PageTransitionCustomState
349
+ ): PageTransitionCustomState => ({
350
+ opacity: clamp(input?.opacity, 0, 1, fallback.opacity),
351
+ x: clamp(input?.x, -400, 400, fallback.x),
352
+ y: clamp(input?.y, -400, 400, fallback.y),
353
+ scale: clamp(input?.scale, 0.25, 2, fallback.scale),
354
+ blur: clamp(input?.blur, 0, 60, fallback.blur),
355
+ })
356
+
357
+ /** Every custom value inside its range and a known easing; mirrored verbatim from teleport-gui. Runs at generation time. */
358
+ export function sanitizePageTransitionCustom(
359
+ input: Partial<PageTransitionCustom> | undefined
360
+ ): PageTransitionCustom {
361
+ const easing =
362
+ typeof input?.easing === 'string' && CUSTOM_EASINGS.indexOf(input.easing) >= 0
363
+ ? input.easing
364
+ : DEFAULT_CUSTOM.easing
365
+ return {
366
+ durationSeconds: clamp(input?.durationSeconds, 0.05, 3, DEFAULT_CUSTOM.durationSeconds),
367
+ easing,
368
+ arrive: sanitizeCustomState(input?.arrive, DEFAULT_CUSTOM.arrive),
369
+ leave: sanitizeCustomState(input?.leave, DEFAULT_CUSTOM.leave),
370
+ }
371
+ }
372
+
373
+ /** What the editor exports under `globals.settings.pageTransition`. */
374
+ export interface PageTransitionConfig {
375
+ preset: string
376
+ duration: number
377
+ easing: string
378
+ skipRoutes?: string[]
379
+ options?: Record<string, string>
380
+ custom?: { arrive?: Record<string, number>; leave?: Record<string, number> }
381
+ /** False when the author turned the flying pictures off; absent means they fly. */
382
+ flyingPictures?: boolean
383
+ }
384
+
385
+ /** The same curves the motion widget plays, so a route transition shares the site's motion character. */
386
+ export const PAGE_TRANSITION_EASING_CURVES: Record<string, [number, number, number, number]> = {
387
+ ease: [0.25, 0.1, 0.25, 1],
388
+ 'ease-in': [0.42, 0, 1, 1],
389
+ 'ease-out': [0, 0, 0.58, 1],
390
+ 'ease-in-out': [0.42, 0, 0.58, 1],
391
+ linear: [0, 0, 1, 1],
392
+ spring: [0.34, 1.56, 0.64, 1],
393
+ back: [0.68, -0.6, 0.32, 1.6],
394
+ bounce: [0.22, 1.2, 0.36, 1],
395
+ }
396
+
397
+ /** The resolved transition the editor exported, or null when pages switch instantly. */
398
+ export const projectPageTransition = (uidl: {
399
+ globals?: { settings?: unknown }
400
+ }): PageTransitionConfig | null => {
401
+ const settings = (uidl.globals?.settings || {}) as { pageTransition?: PageTransitionConfig }
402
+ const transition = settings.pageTransition
403
+ if (!transition || !transition.preset || transition.preset === 'none') {
404
+ return null
405
+ }
406
+ return transition
407
+ }
@@ -0,0 +1,237 @@
1
+ import * as PageTransition from './variants'
2
+
3
+ /**
4
+ * A site's page transition as View Transitions CSS. The static HTML export
5
+ * plays it between two ordinary pages (cross-document View Transitions, no
6
+ * script driving it); the Next export uses the same rules for the one kind of
7
+ * navigation it plays as a View Transition, an image flying from a list into
8
+ * its details page (see morph-source.ts).
9
+ *
10
+ * The states are not written here. They come from the same registry the Next
11
+ * export's TqPageTransition is generated from (teleport-shared), evaluated once
12
+ * at generation time, so a preset looks the same in both exports and a new
13
+ * preset reaches both. What differs is only who plays them:
14
+ *
15
+ * Next AnimatePresence, mode "wait": the leaving page runs its exit, then
16
+ * the arriving page runs initial → animate.
17
+ * HTML the browser holds a picture of the leaving page and one of the
18
+ * arriving page; the first animates over [0, D], the second waits D
19
+ * and animates over [D, 2D]. Same order, same timing.
20
+ *
21
+ * The reveals (Circle, the Wipes) are the exception, in both exports: the
22
+ * arriving page is revealed OVER the leaving one, which holds still underneath
23
+ * for the whole run. Played one after the other, the leaving page was wiped
24
+ * away to the site's bare background and the arriving one wiped in over it —
25
+ * a white sheet between every two pages. Here the leaving picture keeps no
26
+ * animation (the browser would otherwise fade it out under the arriving one)
27
+ * and the arriving picture opens over [0, D].
28
+ *
29
+ * A browser without the feature (Firefox today) simply navigates, which is
30
+ * also what a visitor who asked for less motion gets.
31
+ */
32
+
33
+ type State = Record<string, unknown>
34
+
35
+ export const BACK_ATTR = 'data-tq-nav'
36
+ export const ORIGIN_X = '--tq-origin-x'
37
+ export const ORIGIN_Y = '--tq-origin-y'
38
+
39
+ const hasAny = (state: State, keys: string[]): boolean =>
40
+ keys.some((key) => state[key] !== undefined)
41
+
42
+ /** One keyframe. Both ends of an animation declare the same properties, so every one of them interpolates. */
43
+ const declarations = (state: State, other: State): string[] => {
44
+ const lines: string[] = []
45
+ const both = (keys: string[]) => hasAny(state, keys) || hasAny(other, keys)
46
+ if (both(['opacity'])) {
47
+ lines.push(`opacity: ${state.opacity ?? 1};`)
48
+ }
49
+ if (both(['x', 'y', 'scale'])) {
50
+ lines.push(
51
+ `transform: translate(${state.x ?? 0}px, ${state.y ?? 0}px) scale(${state.scale ?? 1});`
52
+ )
53
+ }
54
+ if (both(['filter'])) {
55
+ lines.push(`filter: ${state.filter ?? 'blur(0px)'};`)
56
+ }
57
+ if (both(['clipPath'])) {
58
+ lines.push(`clip-path: ${state.clipPath ?? 'inset(0 0 0 0)'};`)
59
+ }
60
+ return lines
61
+ }
62
+
63
+ const keyframes = (name: string, from: State, to: State): string =>
64
+ `@keyframes ${name} {\n from { ${declarations(from, to).join(' ')} }\n to { ${declarations(
65
+ to,
66
+ from
67
+ ).join(' ')} }\n}`
68
+
69
+ /**
70
+ * The cover family in the Next export is one or two coloured panels that close
71
+ * over the leaving page and open on the arriving one. A picture of a page can
72
+ * be clipped, so here the panel is what shows where the picture is clipped
73
+ * away: the colour sits behind both pictures and the clip edge is the panel's
74
+ * leading edge. Same sweep, same colour, no extra element on any page.
75
+ */
76
+ const COVER_CLIPS: Record<string, { covered: string; opening: string }> = {
77
+ // one panel rising from the bottom, then leaving through the top
78
+ cover: { covered: 'inset(0 0 100% 0)', opening: 'inset(100% 0 0 0)' },
79
+ // two panels meeting in the middle, then parting
80
+ curtain: { covered: 'inset(0 50% 0 50%)', opening: 'inset(0 50% 0 50%)' },
81
+ }
82
+ const OPEN = 'inset(0 0 0 0)'
83
+
84
+ interface Animations {
85
+ leave: [State, State]
86
+ arrive: [State, State]
87
+ }
88
+
89
+ const animationsFor = (
90
+ preset: string,
91
+ context: PageTransition.PageTransitionContext,
92
+ options: Record<string, string>,
93
+ custom: PageTransition.PageTransitionCustom | undefined
94
+ ): Animations | null => {
95
+ const cover = COVER_CLIPS[preset]
96
+ if (cover) {
97
+ return {
98
+ leave: [{ clipPath: OPEN }, { clipPath: cover.covered }],
99
+ arrive: [{ clipPath: cover.opening }, { clipPath: OPEN }],
100
+ }
101
+ }
102
+ const variants = PageTransition.pageTransitionVariants(
103
+ preset,
104
+ PageTransition.PAGE_TRANSITION_SLIDE_PX,
105
+ context,
106
+ options,
107
+ custom
108
+ )
109
+ if (!variants) {
110
+ return null
111
+ }
112
+ return {
113
+ leave: [variants.animate, variants.exit],
114
+ arrive: [variants.initial, variants.animate],
115
+ }
116
+ }
117
+
118
+ /** The name of the one image that flies from the leaving page into the arriving page. */
119
+ export const MORPH_NAME = 'tq-morph'
120
+
121
+ export interface ViewTransitionPlan {
122
+ css: string
123
+ /** The back button plays a different preset, so the page has to tell the stylesheet which way it came. */
124
+ needsDirection: boolean
125
+ /** The reveal grows from where the visitor pressed, so the page has to hand that point over. */
126
+ needsOrigin: boolean
127
+ /** A picture flies from the followed link into the next page; off when the author turned it off. */
128
+ flies: boolean
129
+ /** The arriving page is revealed over the leaving one, which holds (Circle, the Wipes). */
130
+ revealsOver: boolean
131
+ }
132
+
133
+ export const viewTransitionPlan = (
134
+ config: PageTransition.PageTransitionConfig,
135
+ target: { crossDocument: boolean } = { crossDocument: true }
136
+ ): ViewTransitionPlan | null => {
137
+ const preset = String(config.preset).replace(/[^a-z-]/g, '')
138
+ const chosen =
139
+ config.options && typeof config.options === 'object'
140
+ ? Object.fromEntries(
141
+ Object.entries(config.options).filter(([, value]) => typeof value === 'string')
142
+ )
143
+ : undefined
144
+ const options = PageTransition.resolvePageTransitionOptions(preset, chosen)
145
+ const duration = Number.isFinite(config.duration) && config.duration > 0 ? config.duration : 0.35
146
+ const curve =
147
+ PageTransition.PAGE_TRANSITION_EASING_CURVES[config.easing] ||
148
+ PageTransition.PAGE_TRANSITION_EASING_CURVES['ease-out']
149
+ const custom =
150
+ preset === 'custom'
151
+ ? PageTransition.sanitizePageTransitionCustom({
152
+ durationSeconds: duration,
153
+ easing: config.easing,
154
+ arrive: config.custom?.arrive as never,
155
+ leave: config.custom?.leave as never,
156
+ })
157
+ : undefined
158
+
159
+ const needsOrigin = preset === 'circle' && options.origin !== 'center'
160
+ const origin = needsOrigin
161
+ ? { originX: `var(${ORIGIN_X}, 50%)`, originY: `var(${ORIGIN_Y}, 50%)` }
162
+ : { originX: '50%', originY: '50%' }
163
+ const forward = animationsFor(preset, { reverse: false, ...origin }, options, custom)
164
+ if (!forward) {
165
+ return null
166
+ }
167
+ const needsDirection = PageTransition.reversePageTransitionPreset(preset) !== preset
168
+ const back = needsDirection
169
+ ? animationsFor(preset, { reverse: true, originX: '50%', originY: '50%' }, options, custom)
170
+ : null
171
+
172
+ const timing = `${duration}s cubic-bezier(${curve.join(', ')})`
173
+ // A reveal opens the arriving page over the leaving one: only the arrival
174
+ // animates, from the start, and the leaving picture just stays.
175
+ const revealsOver = !COVER_CLIPS[preset] && forward.arrive[1].clipPath !== undefined
176
+ const arriveTiming = revealsOver ? `${timing} both` : `${timing} ${duration}s both`
177
+ const rules = [
178
+ // Opting every page in is for page-to-page navigations only; a single-page
179
+ // app starts its own transitions and must not turn full reloads into them.
180
+ ...(target.crossDocument ? ['@view-transition {\n navigation: auto;\n}'] : []),
181
+ '::view-transition-old(root),\n::view-transition-new(root) {\n mix-blend-mode: normal;\n}',
182
+ revealsOver
183
+ ? '::view-transition-old(root) {\n animation: none;\n}'
184
+ : `::view-transition-old(root) {\n animation: tq-page-leave ${timing} both;\n}`,
185
+ `::view-transition-new(root) {\n animation: tq-page-arrive ${arriveTiming};\n}`,
186
+ ...(revealsOver ? [] : [keyframes('tq-page-leave', forward.leave[0], forward.leave[1])]),
187
+ keyframes('tq-page-arrive', forward.arrive[0], forward.arrive[1]),
188
+ ]
189
+ if (COVER_CLIPS[preset]) {
190
+ rules.push(
191
+ `::view-transition-group(root) {\n background: ${PageTransition.pageTransitionCoverColor(
192
+ options.color
193
+ )};\n}`
194
+ )
195
+ }
196
+ if (back) {
197
+ if (!revealsOver) {
198
+ rules.push(
199
+ `html[${BACK_ATTR}="back"]::view-transition-old(root) {\n animation-name: tq-page-leave-back;\n}`
200
+ )
201
+ }
202
+ rules.push(
203
+ `html[${BACK_ATTR}="back"]::view-transition-new(root) {\n animation-name: tq-page-arrive-back;\n}`,
204
+ ...(revealsOver ? [] : [keyframes('tq-page-leave-back', back.leave[0], back.leave[1])]),
205
+ keyframes('tq-page-arrive-back', back.arrive[0], back.arrive[1])
206
+ )
207
+ }
208
+ // The flying image spans both halves (the leaving page's and the arriving
209
+ // page's — one half when the arrival plays over the leaving page), and a
210
+ // card's 4:3 picture grows into a 16:9 hero cropped, never stretched. A
211
+ // picture the arriving page does not show has nowhere to land: it leaves
212
+ // with its page instead of lingering over the next one.
213
+ const flies = config.flyingPictures !== false
214
+ if (flies) {
215
+ const flight = revealsOver ? duration : duration * 2
216
+ rules.push(
217
+ `::view-transition-group(${MORPH_NAME}) {\n animation-duration: ${flight}s;\n animation-timing-function: cubic-bezier(${curve.join(
218
+ ', '
219
+ )});\n}`,
220
+ `::view-transition-old(${MORPH_NAME}),\n::view-transition-new(${MORPH_NAME}) {\n height: 100%;\n object-fit: cover;\n overflow: clip;\n animation-duration: ${flight}s;\n}`,
221
+ revealsOver
222
+ ? `::view-transition-old(${MORPH_NAME}):only-child {\n animation: tq-morph-away ${timing} both;\n}`
223
+ : `::view-transition-old(${MORPH_NAME}):only-child {\n animation: tq-page-leave ${timing} both, tq-morph-away ${timing} both;\n}`,
224
+ `::view-transition-new(${MORPH_NAME}):only-child {\n animation: tq-page-arrive ${arriveTiming}, tq-morph-in ${arriveTiming};\n}`,
225
+ '@keyframes tq-morph-away {\n to { opacity: 0; }\n}',
226
+ '@keyframes tq-morph-in {\n from { opacity: 0; }\n}'
227
+ )
228
+ }
229
+ const indented = rules.map((rule) => rule.replace(/^/gm, ' ')).join('\n')
230
+ return {
231
+ css: `@media (prefers-reduced-motion: no-preference) {\n${indented}\n}\n`,
232
+ needsDirection,
233
+ needsOrigin,
234
+ flies,
235
+ revealsOver,
236
+ }
237
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which chapter is ON STAGE at a scene progress: the chapter whose moment is
3
+ * the latest one the visitor has passed (a later chapter wins a tie), -1 before
4
+ * any moment is reached. Moments are read in document order but need not be
5
+ * monotone — an author's "Snap here" can put an early chapter's moment late.
6
+ * The published TqScrollScene announces every change of this index as a
7
+ * `tq-chapter-reached` DOM event on the chapter element — the hook the
8
+ * "A Chapter Is Reached" workflow trigger listens for.
9
+ *
10
+ * Plain JS inside: this function's SOURCE is injected into the generated
11
+ * runtime via toString(), so no TypeScript-only runtime constructs and no
12
+ * template literals.
13
+ */
14
+ export function activeChapterIndex(moments: number[], progress: number): number {
15
+ let active = -1
16
+ let best = -Infinity
17
+ for (let index = 0; index < moments.length; index++) {
18
+ const moment = moments[index]
19
+ if (moment <= progress + 0.000001 && moment >= best) {
20
+ best = moment
21
+ active = index
22
+ }
23
+ }
24
+ return active
25
+ }