@ethlete/cli 3.0.0-next.24 → 3.0.0-next.26

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 (194) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/design/define-call.ts +69 -0
  3. package/design/define-part.ts +43 -9
  4. package/design/embed-page.ts +37 -4
  5. package/design/icon-sheet.ts +151 -0
  6. package/design/logo-sheet.ts +52 -0
  7. package/design/mark-part.ts +79 -2
  8. package/design/motion-drive.ts +215 -0
  9. package/design/motion-runtime.ts +224 -5
  10. package/design/motion-tokens.ts +177 -0
  11. package/design/spring.ts +53 -0
  12. package/design/token-sheet.ts +87 -0
  13. package/design/web/frame-fill.ts +4 -0
  14. package/design/web/frame-scheme.ts +44 -0
  15. package/design/web/frame.ts +239 -31
  16. package/package.json +1 -1
  17. package/src/lib/design/calls-tsconfig.d.ts +2 -0
  18. package/src/lib/design/calls-tsconfig.js +11 -5
  19. package/src/lib/design/calls-tsconfig.js.map +1 -1
  20. package/src/lib/design/check.d.ts +1 -0
  21. package/src/lib/design/check.js +62 -6
  22. package/src/lib/design/check.js.map +1 -1
  23. package/src/lib/design/contact-sheet.d.ts +9 -0
  24. package/src/lib/design/contact-sheet.js +38 -4
  25. package/src/lib/design/contact-sheet.js.map +1 -1
  26. package/src/lib/design/design-command.js +56 -7
  27. package/src/lib/design/design-command.js.map +1 -1
  28. package/src/lib/design/draw-ledger.d.ts +36 -0
  29. package/src/lib/design/draw-ledger.js +52 -0
  30. package/src/lib/design/draw-ledger.js.map +1 -0
  31. package/src/lib/design/force-interactions.d.ts +2 -1
  32. package/src/lib/design/force-interactions.js +7 -4
  33. package/src/lib/design/force-interactions.js.map +1 -1
  34. package/src/lib/design/mcp-command.d.ts +81 -0
  35. package/src/lib/design/mcp-command.js +422 -0
  36. package/src/lib/design/mcp-command.js.map +1 -0
  37. package/src/lib/design/motion-command.d.ts +71 -0
  38. package/src/lib/design/motion-command.js +308 -0
  39. package/src/lib/design/motion-command.js.map +1 -0
  40. package/src/lib/design/motion-endpoint.d.ts +61 -0
  41. package/src/lib/design/motion-endpoint.js +133 -0
  42. package/src/lib/design/motion-endpoint.js.map +1 -0
  43. package/src/lib/design/motion-flow.d.ts +5 -0
  44. package/src/lib/design/motion-flow.js +29 -0
  45. package/src/lib/design/motion-flow.js.map +1 -0
  46. package/src/lib/design/motion-shot.d.ts +43 -0
  47. package/src/lib/design/motion-shot.js +97 -0
  48. package/src/lib/design/motion-shot.js.map +1 -0
  49. package/src/lib/design/paths.d.ts +14 -0
  50. package/src/lib/design/paths.js +50 -1
  51. package/src/lib/design/paths.js.map +1 -1
  52. package/src/lib/design/ready-shots.d.ts +7 -0
  53. package/src/lib/design/ready-shots.js +22 -1
  54. package/src/lib/design/ready-shots.js.map +1 -1
  55. package/src/lib/design/render-packages.d.ts +14 -0
  56. package/src/lib/design/render-packages.js +18 -3
  57. package/src/lib/design/render-packages.js.map +1 -1
  58. package/src/lib/design/render-worker.js +1 -0
  59. package/src/lib/design/render-worker.js.map +1 -1
  60. package/src/lib/design/render.js +3 -3
  61. package/src/lib/design/render.js.map +1 -1
  62. package/src/lib/design/serve.d.ts +2 -2
  63. package/src/lib/design/serve.js +191 -21
  64. package/src/lib/design/serve.js.map +1 -1
  65. package/src/lib/design/shot-command.d.ts +13 -4
  66. package/src/lib/design/shot-command.js +30 -24
  67. package/src/lib/design/shot-command.js.map +1 -1
  68. package/src/lib/design/shot-store.d.ts +24 -0
  69. package/src/lib/design/shot-store.js +120 -0
  70. package/src/lib/design/shot-store.js.map +1 -0
  71. package/src/lib/design/shot.d.ts +37 -1
  72. package/src/lib/design/shot.js +185 -34
  73. package/src/lib/design/shot.js.map +1 -1
  74. package/src/lib/design/sketch-command.js +197 -51
  75. package/src/lib/design/sketch-command.js.map +1 -1
  76. package/src/lib/design/ssr-shims.d.ts +2 -0
  77. package/src/lib/design/ssr-shims.js +12 -1
  78. package/src/lib/design/ssr-shims.js.map +1 -1
  79. package/src/lib/design/studio-api.d.ts +31 -0
  80. package/src/lib/design/studio-api.js +118 -1
  81. package/src/lib/design/studio-api.js.map +1 -1
  82. package/src/lib/design/system/check-system.d.ts +6 -2
  83. package/src/lib/design/system/check-system.js +102 -20
  84. package/src/lib/design/system/check-system.js.map +1 -1
  85. package/src/lib/design/system/colour.d.ts +4 -1
  86. package/src/lib/design/system/colour.js +54 -2
  87. package/src/lib/design/system/colour.js.map +1 -1
  88. package/src/lib/design/system/complete-command.d.ts +1 -1
  89. package/src/lib/design/system/complete-command.js +80 -6
  90. package/src/lib/design/system/complete-command.js.map +1 -1
  91. package/src/lib/design/system/complete.d.ts +172 -9
  92. package/src/lib/design/system/complete.js +792 -167
  93. package/src/lib/design/system/complete.js.map +1 -1
  94. package/src/lib/design/system/contrast-check.d.ts +6 -1
  95. package/src/lib/design/system/contrast-check.js +167 -12
  96. package/src/lib/design/system/contrast-check.js.map +1 -1
  97. package/src/lib/design/system/drawn-open-points.d.ts +16 -0
  98. package/src/lib/design/system/drawn-open-points.js +43 -0
  99. package/src/lib/design/system/drawn-open-points.js.map +1 -0
  100. package/src/lib/design/system/exceptions.d.ts +59 -0
  101. package/src/lib/design/system/exceptions.js +132 -0
  102. package/src/lib/design/system/exceptions.js.map +1 -0
  103. package/src/lib/design/system/flows/flow.d.ts +3 -0
  104. package/src/lib/design/system/flows/flow.js +5 -3
  105. package/src/lib/design/system/flows/flow.js.map +1 -1
  106. package/src/lib/design/system/handoff.d.ts +11 -0
  107. package/src/lib/design/system/handoff.js +30 -0
  108. package/src/lib/design/system/handoff.js.map +1 -0
  109. package/src/lib/design/system/inventory-approval.d.ts +41 -0
  110. package/src/lib/design/system/inventory-approval.js +71 -0
  111. package/src/lib/design/system/inventory-approval.js.map +1 -0
  112. package/src/lib/design/system/inventory.d.ts +65 -0
  113. package/src/lib/design/system/inventory.js +168 -0
  114. package/src/lib/design/system/inventory.js.map +1 -0
  115. package/src/lib/design/system/list-system.d.ts +4 -1
  116. package/src/lib/design/system/list-system.js +11 -3
  117. package/src/lib/design/system/list-system.js.map +1 -1
  118. package/src/lib/design/system/open-points.d.ts +101 -0
  119. package/src/lib/design/system/open-points.js +156 -0
  120. package/src/lib/design/system/open-points.js.map +1 -0
  121. package/src/lib/design/system/part-ready.d.ts +52 -0
  122. package/src/lib/design/system/part-ready.js +101 -0
  123. package/src/lib/design/system/part-ready.js.map +1 -0
  124. package/src/lib/design/system/read-open-points.d.ts +27 -0
  125. package/src/lib/design/system/read-open-points.js +81 -0
  126. package/src/lib/design/system/read-open-points.js.map +1 -0
  127. package/src/lib/design/system/ready-command.d.ts +7 -0
  128. package/src/lib/design/system/ready-command.js +65 -0
  129. package/src/lib/design/system/ready-command.js.map +1 -0
  130. package/src/lib/design/system/render-check.d.ts +2 -0
  131. package/src/lib/design/system/render-check.js +216 -32
  132. package/src/lib/design/system/render-check.js.map +1 -1
  133. package/src/lib/design/system/resolve-product.d.ts +4 -4
  134. package/src/lib/design/system/resolve-product.js +22 -7
  135. package/src/lib/design/system/resolve-product.js.map +1 -1
  136. package/src/lib/design/system/rules/implements.d.ts +7 -0
  137. package/src/lib/design/system/rules/implements.js +43 -0
  138. package/src/lib/design/system/rules/implements.js.map +1 -0
  139. package/src/lib/design/system/rules/motion.d.ts +2 -0
  140. package/src/lib/design/system/rules/motion.js +26 -1
  141. package/src/lib/design/system/rules/motion.js.map +1 -1
  142. package/src/lib/design/system/rules/option-values.d.ts +15 -0
  143. package/src/lib/design/system/rules/option-values.js +145 -0
  144. package/src/lib/design/system/rules/option-values.js.map +1 -0
  145. package/src/lib/design/system/rules/stale-replace.js +80 -3
  146. package/src/lib/design/system/rules/stale-replace.js.map +1 -1
  147. package/src/lib/design/system/rules/token-selectors.js +1 -1
  148. package/src/lib/design/system/rules/token-selectors.js.map +1 -1
  149. package/src/lib/design/system/sketch/baseline.d.ts +11 -2
  150. package/src/lib/design/system/sketch/baseline.js +4 -2
  151. package/src/lib/design/system/sketch/baseline.js.map +1 -1
  152. package/src/lib/design/system/sketch/check-sketches.d.ts +1 -0
  153. package/src/lib/design/system/sketch/check-sketches.js +4 -2
  154. package/src/lib/design/system/sketch/check-sketches.js.map +1 -1
  155. package/src/lib/design/system/sketch/host-edit.d.ts +4 -0
  156. package/src/lib/design/system/sketch/host-edit.js +68 -0
  157. package/src/lib/design/system/sketch/host-edit.js.map +1 -0
  158. package/src/lib/design/system/sketch/merge.d.ts +3 -2
  159. package/src/lib/design/system/sketch/merge.js +79 -12
  160. package/src/lib/design/system/sketch/merge.js.map +1 -1
  161. package/src/lib/design/system/sketch/merged-parts.d.ts +11 -1
  162. package/src/lib/design/system/sketch/merged-parts.js +24 -3
  163. package/src/lib/design/system/sketch/merged-parts.js.map +1 -1
  164. package/src/lib/design/system/sketch/no-visible-change.d.ts +6 -0
  165. package/src/lib/design/system/sketch/no-visible-change.js +25 -1
  166. package/src/lib/design/system/sketch/no-visible-change.js.map +1 -1
  167. package/src/lib/design/system/sketch/overlay.js +11 -3
  168. package/src/lib/design/system/sketch/overlay.js.map +1 -1
  169. package/src/lib/design/system/sketch/part-definition.d.ts +36 -0
  170. package/src/lib/design/system/sketch/part-definition.js +56 -2
  171. package/src/lib/design/system/sketch/part-definition.js.map +1 -1
  172. package/src/lib/design/system/sketch/sketch.d.ts +47 -0
  173. package/src/lib/design/system/sketch/sketch.js +137 -44
  174. package/src/lib/design/system/sketch/sketch.js.map +1 -1
  175. package/src/lib/design/system/sketch/unchanged-states.d.ts +4 -1
  176. package/src/lib/design/system/sketch/unchanged-states.js +9 -5
  177. package/src/lib/design/system/sketch/unchanged-states.js.map +1 -1
  178. package/src/lib/design/system/sketch/unused-closure.d.ts +12 -0
  179. package/src/lib/design/system/sketch/unused-closure.js +195 -0
  180. package/src/lib/design/system/sketch/unused-closure.js.map +1 -0
  181. package/src/lib/design/system/sketch/unused-files.d.ts +1 -1
  182. package/src/lib/design/system/sketch/unused-files.js +6 -3
  183. package/src/lib/design/system/sketch/unused-files.js.map +1 -1
  184. package/src/lib/design/system/system-modules.js +6 -9
  185. package/src/lib/design/system/system-modules.js.map +1 -1
  186. package/src/lib/design/system/system-tsconfig.d.ts +2 -1
  187. package/src/lib/design/system/system-tsconfig.js +10 -6
  188. package/src/lib/design/system/system-tsconfig.js.map +1 -1
  189. package/src/lib/design/system/ts-source.d.ts +3 -0
  190. package/src/lib/design/system/ts-source.js +21 -4
  191. package/src/lib/design/system/ts-source.js.map +1 -1
  192. package/src/lib/design/thumb.d.ts +31 -4
  193. package/src/lib/design/thumb.js +105 -40
  194. package/src/lib/design/thumb.js.map +1 -1
@@ -5,6 +5,11 @@ const SKIPPED = /^(?:\s+|<!--[\s\S]*?-->|<(style|script)\b[\s\S]*?<\/\1\s*>|<lin
5
5
  const TAG = /^<[a-zA-Z][\w:-]*/;
6
6
  const ATTRIBUTES = /^(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*/;
7
7
  const MARK = /(\sdata-et-part=")([^"]*)"/;
8
+ const SLOT = /(\sdata-et-slot=")([^"]*)"/;
9
+ const TAG_AT = /<[a-zA-Z][\w:-]*/y;
10
+ const ATTRIBUTES_AT = new RegExp(ATTRIBUTES.source.slice(1), 'y');
11
+ const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title']);
12
+ const MAX_PROP_DEPTH = 4;
8
13
 
9
14
  const attributesAtOf = (body: string) => {
10
15
  let at = 0;
@@ -43,10 +48,82 @@ export const forceBody = (body: string, interaction: Interaction) => {
43
48
  return `${body.slice(0, attributesAt)}${forced}${body.slice(attributesAt)}`;
44
49
  };
45
50
 
51
+ /** Adds `key` to `data-et-slot` on every element of `markup`: the markup a host passes into the part `key` through its props. */
52
+ export const markSlot = (markup: string, key: string) => {
53
+ let marked = '';
54
+ let at = 0;
55
+
56
+ for (let open = markup.indexOf('<'); open >= 0; open = markup.indexOf('<', at)) {
57
+ if (markup.startsWith('<!--', open)) {
58
+ const close = markup.indexOf('-->', open + 4);
59
+ const end = close < 0 ? markup.length : close + 3;
60
+
61
+ marked += markup.slice(at, end);
62
+ at = end;
63
+ continue;
64
+ }
65
+
66
+ TAG_AT.lastIndex = open;
67
+ const tag = TAG_AT.exec(markup);
68
+
69
+ if (!tag) {
70
+ marked += markup.slice(at, open + 1);
71
+ at = open + 1;
72
+ continue;
73
+ }
74
+
75
+ const attributesAt = open + tag[0].length;
76
+
77
+ ATTRIBUTES_AT.lastIndex = attributesAt;
78
+ const attributes = ATTRIBUTES_AT.exec(markup)?.[0] ?? '';
79
+ const slot = SLOT.exec(attributes);
80
+
81
+ if (slot) {
82
+ const valueEnd = attributesAt + slot.index + (slot[1]?.length ?? 0) + (slot[2]?.length ?? 0);
83
+
84
+ marked += `${markup.slice(at, valueEnd)} ${key}`;
85
+ at = valueEnd;
86
+ } else {
87
+ marked += `${markup.slice(at, attributesAt)} data-et-slot="${key}"`;
88
+ at = attributesAt;
89
+ }
90
+
91
+ const name = tag[0].slice(1).toLowerCase();
92
+
93
+ if (RAW_TEXT.has(name)) {
94
+ const close = markup.toLowerCase().indexOf(`</${name}`, attributesAt);
95
+ const end = close < 0 ? markup.length : close;
96
+
97
+ marked += markup.slice(at, end);
98
+ at = end;
99
+ }
100
+ }
101
+
102
+ return marked + markup.slice(at);
103
+ };
104
+
105
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
106
+ !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype;
107
+
108
+ const slotProps = (value: unknown, { key, depth = 0 }: { key: string; depth?: number }): unknown => {
109
+ const inner = { key, depth: depth + 1 };
110
+
111
+ if (typeof value === 'string') return /<[a-zA-Z]/.test(value) ? markSlot(value, key) : value;
112
+ if (depth >= MAX_PROP_DEPTH) return value;
113
+ if (Array.isArray(value)) return value.map((item) => slotProps(item, inner));
114
+ if (isPlainObject(value))
115
+ return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, slotProps(item, inner)]));
116
+
117
+ return value;
118
+ };
119
+
46
120
  const markDrawing = <TDrawing extends Drawing>(drawn: TDrawing, key: string): TDrawing =>
47
121
  typeof drawn?.body === 'string' ? { ...drawn, body: markBody(drawn.body, key) } : drawn;
48
122
 
49
- /** The part with a `render` that marks the first element of each drawing as the root of the part `key`. */
123
+ /**
124
+ * The part with a `render` that marks the first element of each drawing as the root of the part `key`, and every
125
+ * element of the markup its props pass in as slotted into `key`.
126
+ */
50
127
  export const markPart = <TProps, TState extends string>(
51
128
  part: Part<TProps, TState>,
52
129
  key: string,
@@ -58,7 +135,7 @@ export const markPart = <TProps, TState extends string>(
58
135
  return {
59
136
  ...part,
60
137
  render: (props: TProps, options?: RenderOptions) => {
61
- const drawn = render.call(part, props, options);
138
+ const drawn = render.call(part, slotProps(props, { key }) as TProps, options);
62
139
 
63
140
  return drawn instanceof Promise ? drawn.then((done) => markDrawing(done, key)) : markDrawing(drawn, key);
64
141
  },
@@ -0,0 +1,215 @@
1
+ import { SteppedMotion } from './motion-runtime';
2
+
3
+ /** What the Studio timeline posts into a live frame to drive a motion of its part. */
4
+ export type MotionDriveMessage =
5
+ | { type: 'design-explore:motion-start'; motion: string; reduced: boolean }
6
+ | { type: 'design-explore:motion-seek'; ms: number; rate: number }
7
+ | { type: 'design-explore:motion-play'; ms: number; rate: number }
8
+ | { type: 'design-explore:motion-stop' };
9
+
10
+ /** What a live frame posts back once a motion started, or failed to. */
11
+ export type MotionDriveReply =
12
+ | { type: 'design-explore:motion-ready'; motion: string }
13
+ | { type: 'design-explore:motion-failed'; motion: string; error: string };
14
+
15
+ const TAIL_MS = 30000;
16
+
17
+ const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0;
18
+
19
+ const isRate = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0;
20
+
21
+ /** The drive message `data` holds, or `null` for any other message. */
22
+ export const motionDriveMessageOf = (data: unknown): MotionDriveMessage | null => {
23
+ if (typeof data !== 'object' || data === null) return null;
24
+
25
+ const message = data as Record<string, unknown>;
26
+
27
+ switch (message['type']) {
28
+ case 'design-explore:motion-start':
29
+ return typeof message['motion'] === 'string'
30
+ ? { type: message['type'], motion: message['motion'], reduced: message['reduced'] === true }
31
+ : null;
32
+ case 'design-explore:motion-seek':
33
+ case 'design-explore:motion-play':
34
+ return isTime(message['ms']) && isRate(message['rate'])
35
+ ? { type: message['type'], ms: message['ms'], rate: message['rate'] }
36
+ : null;
37
+ case 'design-explore:motion-stop':
38
+ return { type: message['type'] };
39
+ default:
40
+ return null;
41
+ }
42
+ };
43
+
44
+ const REDUCED_FEATURE = /\(\s*prefers-reduced-motion\s*(?::\s*(reduce|no-preference)\s*)?\)/g;
45
+ const ALWAYS = '(min-width: 0px)';
46
+ const NEVER = '(max-width: -1px)';
47
+
48
+ type MediaHolder = CSSRule & { media: MediaList };
49
+
50
+ const mediaRulesOf = (rules: CSSRuleList): MediaHolder[] =>
51
+ [...rules].flatMap((rule) =>
52
+ 'cssRules' in rule
53
+ ? [...('media' in rule ? [rule as MediaHolder] : []), ...mediaRulesOf((rule as CSSGroupingRule).cssRules)]
54
+ : [],
55
+ );
56
+
57
+ const sheetRulesOf = (sheet: CSSStyleSheet) => {
58
+ try {
59
+ return mediaRulesOf(sheet.cssRules);
60
+ } catch {
61
+ return [];
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Makes every `prefers-reduced-motion` media rule of `document` match as if the user asked for `reduced` motion, and
67
+ * returns a function that puts the rules back. Scripts that call `matchMedia` still see the real preference.
68
+ */
69
+ export const emulateReducedMotion = (document: Document, reduced: boolean) => {
70
+ const changed = [...document.styleSheets].flatMap(sheetRulesOf).flatMap((rule) => {
71
+ const original = rule.media.mediaText;
72
+ const emulated = original.replace(REDUCED_FEATURE, (_, value?: string) =>
73
+ (value === 'no-preference' ? !reduced : reduced) ? ALWAYS : NEVER,
74
+ );
75
+
76
+ if (emulated === original) return [];
77
+
78
+ rule.media.mediaText = emulated;
79
+
80
+ return [{ rule, original }];
81
+ });
82
+
83
+ return () => {
84
+ for (const { rule, original } of changed) rule.media.mediaText = original;
85
+ };
86
+ };
87
+
88
+ /**
89
+ * Drives a motion in a live frame from drive messages: `start` (re)starts the motion from its `from` state, a seek
90
+ * pauses every animation of the document at that time, play runs the motion's clock and animations at `rate`, and
91
+ * stop resumes every animation at 1x. `released` hears each stop.
92
+ */
93
+ export const createMotionDriver = (options: {
94
+ document: Document;
95
+ start: (motion: string, reduced: boolean) => Promise<SteppedMotion>;
96
+ reply: (reply: MotionDriveReply) => void;
97
+ released?: () => void;
98
+ frame?: (step: (time: number) => void) => unknown;
99
+ }) => {
100
+ const { document, reply } = options;
101
+ const frame = options.frame ?? ((step: (time: number) => void) => requestAnimationFrame(step));
102
+ let started: { motion: string; reduced: boolean } | null = null;
103
+ let stepped: SteppedMotion | null = null;
104
+ let loop = 0;
105
+ let queue: Promise<void> = Promise.resolve();
106
+
107
+ const holdAll = (rate: number) => {
108
+ for (const animation of document.getAnimations()) {
109
+ animation.pause();
110
+ animation.playbackRate = rate;
111
+ }
112
+ };
113
+
114
+ const resumeAll = () => {
115
+ for (const animation of document.getAnimations()) {
116
+ const end = animation.effect?.getComputedTiming().endTime;
117
+
118
+ animation.playbackRate = 1;
119
+
120
+ if (typeof end === 'number' && Number.isFinite(end) && Number(animation.currentTime ?? 0) >= end)
121
+ animation.finish();
122
+ else animation.play();
123
+ }
124
+ };
125
+
126
+ const run = (play: { motion: SteppedMotion; from: number; rate: number; step: () => void }) => {
127
+ const { motion, rate, step } = play;
128
+ const id = ++loop;
129
+ let time = play.from;
130
+ let last: number | null = null;
131
+
132
+ const tick = (now: number) => {
133
+ if (id !== loop) return;
134
+
135
+ time += (now - (last ?? now)) * rate;
136
+ last = now;
137
+ motion.seek(Math.min(time, TAIL_MS));
138
+ step();
139
+
140
+ if (time < TAIL_MS) frame(tick);
141
+ };
142
+
143
+ frame(tick);
144
+ };
145
+
146
+ const moveTo = (ms: number): Promise<SteppedMotion | null> => {
147
+ if (!stepped || !started) return Promise.resolve(null);
148
+ if (ms >= stepped.now()) {
149
+ stepped.seek(ms);
150
+
151
+ return Promise.resolve(stepped);
152
+ }
153
+
154
+ return options.start(started.motion, started.reduced).then((restarted) => {
155
+ stepped = restarted;
156
+ restarted.seek(ms);
157
+
158
+ return restarted;
159
+ });
160
+ };
161
+
162
+ const handled = (message: MotionDriveMessage): Promise<void> => {
163
+ loop++;
164
+
165
+ switch (message.type) {
166
+ case 'design-explore:motion-start':
167
+ started = { motion: message.motion, reduced: message.reduced };
168
+ stepped = null;
169
+
170
+ return options.start(message.motion, message.reduced).then(
171
+ (motion) => {
172
+ stepped = motion;
173
+ reply({ type: 'design-explore:motion-ready', motion: message.motion });
174
+ },
175
+ (error: unknown) =>
176
+ reply({
177
+ type: 'design-explore:motion-failed',
178
+ motion: message.motion,
179
+ error: error instanceof Error ? error.message : String(error),
180
+ }),
181
+ );
182
+ case 'design-explore:motion-seek':
183
+ return moveTo(message.ms).then((motion) => {
184
+ if (motion) holdAll(message.rate);
185
+ });
186
+ case 'design-explore:motion-play':
187
+ return moveTo(message.ms).then((motion) => {
188
+ if (!motion) return;
189
+
190
+ holdAll(message.rate);
191
+ run({ motion, from: message.ms, rate: message.rate, step: () => holdAll(message.rate) });
192
+ });
193
+ case 'design-explore:motion-stop': {
194
+ const motion = stepped;
195
+
196
+ if (!motion) return Promise.resolve();
197
+
198
+ motion.release();
199
+ resumeAll();
200
+ options.released?.();
201
+ run({ motion, from: motion.now(), rate: 1, step: () => undefined });
202
+
203
+ return Promise.resolve();
204
+ }
205
+ }
206
+ };
207
+
208
+ return {
209
+ handle: (message: MotionDriveMessage) => {
210
+ queue = queue.then(() => handled(message)).catch(() => undefined);
211
+
212
+ return queue;
213
+ },
214
+ };
215
+ };
@@ -105,14 +105,23 @@ const measure = (root: Element) => {
105
105
  };
106
106
  };
107
107
 
108
- /** What a switch of `root` to `markup` does: `motion` names its `play` and `viewTransition`, `clock` the time `ctx.at` runs on. */
109
- export type StateSwitch = { root: Element; markup: string; motion?: Motion; clock?: MotionClock };
108
+ /**
109
+ * What a switch of `root` to `markup` does: `motion` names its `play` and `viewTransition`, `clock` the time `ctx.at`
110
+ * runs on, and `transitioned` hears the View Transition when one starts.
111
+ */
112
+ export type StateSwitch = {
113
+ root: Element;
114
+ markup: string;
115
+ motion?: Motion;
116
+ clock?: MotionClock;
117
+ transitioned?: (transition: ViewTransition) => void;
118
+ };
110
119
 
111
120
  /**
112
121
  * Morphs `root` to `markup`, inside a View Transition when the motion asks for one and the browser has it, then runs
113
122
  * the motion's `play`. `ctx.rect` measures in the DOM before the morph. Resolves once `play` has run.
114
123
  */
115
- export const switchState = ({ root, markup, motion, clock }: StateSwitch): Promise<void> => {
124
+ export const switchState = ({ root, markup, motion, clock, transitioned }: StateSwitch): Promise<void> => {
116
125
  const rect = motion?.play ? measure(root) : null;
117
126
  const apply = () => morph(root, markup);
118
127
  const play = () => {
@@ -125,8 +134,13 @@ export const switchState = ({ root, markup, motion, clock }: StateSwitch): Promi
125
134
  };
126
135
  const document = root.ownerDocument;
127
136
 
128
- if (motion?.viewTransition && typeof document.startViewTransition === 'function')
129
- return document.startViewTransition(apply).updateCallbackDone.then(play);
137
+ if (motion?.viewTransition && typeof document.startViewTransition === 'function') {
138
+ const transition = document.startViewTransition(apply);
139
+
140
+ transitioned?.(transition);
141
+
142
+ return transition.updateCallbackDone.then(play);
143
+ }
130
144
 
131
145
  apply();
132
146
  play();
@@ -134,6 +148,211 @@ export const switchState = ({ root, markup, motion, clock }: StateSwitch): Promi
134
148
  return Promise.resolve();
135
149
  };
136
150
 
151
+ /** One animation of a motion, in ms from the start of the motion. `iterations` and `end` are `null` when it never ends. */
152
+ export type MotionTrack = {
153
+ element: string;
154
+ kind: 'transition' | 'animation' | 'script';
155
+ name?: string;
156
+ properties: string[];
157
+ start: number;
158
+ delay: number;
159
+ duration: number;
160
+ iterations: number | null;
161
+ easing: string;
162
+ end: number | null;
163
+ };
164
+
165
+ /**
166
+ * A motion paused at its start: `seek` moves it forward to a time in ms, `tracks` lists what ran so far. `release`
167
+ * stops pausing and posing its animations, so a later `seek` only runs its `ctx.at` steps.
168
+ */
169
+ export type SteppedMotion = {
170
+ seek: (ms: number) => void;
171
+ now: () => number;
172
+ release: () => void;
173
+ tracks: () => MotionTrack[];
174
+ end: () => number;
175
+ bounds: () => MotionBox;
176
+ };
177
+
178
+ /** A rectangle in page coordinates. */
179
+ export type MotionBox = { left: number; top: number; right: number; bottom: number };
180
+
181
+ const KEYFRAME_FIELDS = new Set(['offset', 'computedOffset', 'easing', 'composite']);
182
+
183
+ const elementOf = (element: Element | null | undefined) =>
184
+ element?.hasAttribute('data-et-key')
185
+ ? `[data-et-key="${element.getAttribute('data-et-key')}"]`
186
+ : element
187
+ ? [
188
+ element.tagName.toLowerCase(),
189
+ element.id ? `#${element.id}` : '',
190
+ ...[...element.classList].map((name) => `.${name}`),
191
+ ].join('')
192
+ : '';
193
+
194
+ const trackOf = (animation: Animation, start: number): MotionTrack => {
195
+ const effect = animation.effect as KeyframeEffect | null;
196
+ const timing = effect?.getComputedTiming();
197
+ const keyframes = effect && typeof effect.getKeyframes === 'function' ? effect.getKeyframes() : [];
198
+ const own = effect?.getTiming().easing ?? 'linear';
199
+ const keyed = keyframes.find((frame) => frame.easing && frame.easing !== 'linear')?.easing;
200
+ const iterations = timing?.iterations ?? 1;
201
+ const endTime = Number(timing?.endTime ?? 0);
202
+ const properties = [
203
+ ...new Set(
204
+ keyframes.flatMap((frame) =>
205
+ Object.keys(frame)
206
+ .filter((field) => !KEYFRAME_FIELDS.has(field))
207
+ .map((field) => field.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)),
208
+ ),
209
+ ),
210
+ ];
211
+ const kind = 'transitionProperty' in animation ? 'transition' : 'animationName' in animation ? 'animation' : 'script';
212
+
213
+ return {
214
+ element: `${elementOf(effect?.target)}${effect?.pseudoElement ?? ''}`,
215
+ kind,
216
+ ...(kind === 'animation' ? { name: String((animation as CSSAnimation).animationName) } : {}),
217
+ properties: kind === 'transition' ? [String((animation as CSSTransition).transitionProperty)] : properties,
218
+ start,
219
+ delay: Number(timing?.delay ?? 0),
220
+ duration: Number(timing?.duration ?? 0),
221
+ iterations: Number.isFinite(iterations) ? iterations : null,
222
+ easing: own === 'linear' ? (keyed ?? own) : own,
223
+ end: Number.isFinite(endTime) ? start + endTime : null,
224
+ };
225
+ };
226
+
227
+ /**
228
+ * Switches `root` to `markup` with `motion` as `switchState` does, on a manual clock, and pauses every animation it
229
+ * starts, so a caller can step through the motion and draw it at any time from its start. A motion with `interrupt`
230
+ * switches back to `reverse.markup` at that time, with `reverse.motion` (its `ctx.at` counts from the interrupt and a
231
+ * View Transition is not used); a forward `ctx.at` step after it is dropped and a cancelled animation ends there.
232
+ */
233
+ export const stepMotion = (options: {
234
+ root: Element;
235
+ markup: string;
236
+ motion: Motion;
237
+ reverse?: { markup: string; motion?: Motion };
238
+ }): Promise<SteppedMotion> => {
239
+ const { root, markup, motion, reverse } = options;
240
+ const document = root.ownerDocument;
241
+ const clock = manualClock();
242
+ const started = new Map<Animation, MotionTrack>();
243
+ const cut = new Set<Animation>();
244
+ const interrupt = motion.interrupt;
245
+ let interrupted = false;
246
+ let released = false;
247
+ let lastTask = 0;
248
+ let ready: Promise<unknown> = Promise.resolve();
249
+
250
+ if (interrupt !== undefined && !reverse)
251
+ throw new Error('design-explore: a motion with interrupt needs the markup of its from state to switch back to');
252
+
253
+ const collect = (start: number) => {
254
+ for (const animation of started.keys()) {
255
+ const track = started.get(animation);
256
+
257
+ if (cut.has(animation) || animation.playState !== 'idle' || !track) continue;
258
+
259
+ cut.add(animation);
260
+ track.end = Math.min(track.end ?? start, start);
261
+ }
262
+
263
+ for (const animation of document.getAnimations()) {
264
+ if (started.has(animation)) continue;
265
+
266
+ if (!released) animation.pause();
267
+ started.set(animation, trackOf(animation, start));
268
+ }
269
+ };
270
+
271
+ const pose = (ms: number) => {
272
+ if (released) return;
273
+
274
+ for (const [animation, track] of started)
275
+ if (!cut.has(animation)) animation.currentTime = Math.max(0, ms - track.start);
276
+ };
277
+
278
+ const timedFrom = (offset: number, live: () => boolean): MotionClock => ({
279
+ ...clock,
280
+ at: (ms, fn) =>
281
+ clock.at(offset + ms, () => {
282
+ if (!live()) return;
283
+
284
+ lastTask = Math.max(lastTask, offset + ms);
285
+ pose(offset + ms);
286
+ fn();
287
+ collect(offset + ms);
288
+ }),
289
+ });
290
+ const timed = timedFrom(0, () => !interrupted);
291
+
292
+ return switchState({
293
+ root,
294
+ markup,
295
+ motion,
296
+ clock: timed,
297
+ transitioned: (transition) => {
298
+ ready = transition.ready.catch(() => undefined);
299
+ },
300
+ })
301
+ .then(() => ready)
302
+ .then(() => {
303
+ collect(0);
304
+
305
+ if (interrupt !== undefined && reverse) {
306
+ clock.at(interrupt, () => {
307
+ interrupted = true;
308
+ lastTask = Math.max(lastTask, interrupt);
309
+ pose(interrupt);
310
+ void switchState({
311
+ root,
312
+ markup: reverse.markup,
313
+ motion: reverse.motion ? { ...reverse.motion, viewTransition: false } : undefined,
314
+ clock: timedFrom(interrupt, () => true),
315
+ });
316
+ collect(interrupt);
317
+ });
318
+ }
319
+
320
+ return {
321
+ seek: (ms) => {
322
+ if (ms < clock.now())
323
+ throw new Error(`design-explore: a motion steps forward only, from ${clock.now()} ms to ${ms} ms`);
324
+
325
+ clock.advance(ms - clock.now());
326
+
327
+ pose(ms);
328
+ },
329
+ now: () => clock.now(),
330
+ release: () => {
331
+ released = true;
332
+ },
333
+ tracks: () => [...started.values()],
334
+ end: () => Math.max(lastTask, ...[...started.values()].map((track) => track.end ?? 0)),
335
+ bounds: () => {
336
+ const view = document.defaultView;
337
+ const boxes = [
338
+ root,
339
+ ...[...started.keys()].map((animation) => (animation.effect as KeyframeEffect | null)?.target),
340
+ ]
341
+ .filter((element): element is Element => !!element)
342
+ .map((element) => element.getBoundingClientRect())
343
+ .filter((rect, index) => index === 0 || rect.width > 0 || rect.height > 0);
344
+
345
+ return {
346
+ left: Math.min(...boxes.map((rect) => rect.left)) + (view?.scrollX ?? 0),
347
+ top: Math.min(...boxes.map((rect) => rect.top)) + (view?.scrollY ?? 0),
348
+ right: Math.max(...boxes.map((rect) => rect.right)) + (view?.scrollX ?? 0),
349
+ bottom: Math.max(...boxes.map((rect) => rect.bottom)) + (view?.scrollY ?? 0),
350
+ };
351
+ },
352
+ };
353
+ });
354
+ };
355
+
137
356
  /**
138
357
  * How `runRules` reaches the part: the state drawn first, its `rules` and `motions`, and the markup of a state. `clock`
139
358
  * makes the clock of each switch, `switched` hears each finished switch and `failed` each error.