@ethlete/cli 3.0.0-next.23 → 3.0.0-next.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/design/contrast.ts +49 -0
- package/design/define-call.ts +48 -8
- package/design/define-part.ts +83 -1
- package/design/embed-page.ts +37 -4
- package/design/icon-sheet.ts +151 -0
- package/design/mark-part.ts +79 -2
- package/design/morph.ts +67 -0
- package/design/motion-runtime.ts +407 -0
- package/design/spring.ts +53 -0
- package/design/token-sheet.ts +87 -0
- package/design/web/env.d.ts +6 -2
- package/design/web/frame.ts +159 -6
- package/package.json +2 -1
- package/src/lib/design/calls-tsconfig.js +1 -2
- package/src/lib/design/calls-tsconfig.js.map +1 -1
- package/src/lib/design/changed-parts.d.ts +4 -4
- package/src/lib/design/changed-parts.js +10 -6
- package/src/lib/design/changed-parts.js.map +1 -1
- package/src/lib/design/contact-sheet.d.ts +26 -13
- package/src/lib/design/contact-sheet.js +122 -40
- package/src/lib/design/contact-sheet.js.map +1 -1
- package/src/lib/design/design-command.js +50 -2
- package/src/lib/design/design-command.js.map +1 -1
- package/src/lib/design/force-interactions.d.ts +2 -1
- package/src/lib/design/force-interactions.js +7 -4
- package/src/lib/design/force-interactions.js.map +1 -1
- package/src/lib/design/generated-stylesheets.d.ts +15 -0
- package/src/lib/design/generated-stylesheets.js +52 -0
- package/src/lib/design/generated-stylesheets.js.map +1 -0
- package/src/lib/design/inbox-command.d.ts +1 -0
- package/src/lib/design/inbox-command.js +15 -0
- package/src/lib/design/inbox-command.js.map +1 -1
- package/src/lib/design/mcp-command.d.ts +60 -0
- package/src/lib/design/mcp-command.js +327 -0
- package/src/lib/design/mcp-command.js.map +1 -0
- package/src/lib/design/merge-compare.d.ts +10 -0
- package/src/lib/design/merge-compare.js +17 -4
- package/src/lib/design/merge-compare.js.map +1 -1
- package/src/lib/design/motion-command.d.ts +71 -0
- package/src/lib/design/motion-command.js +314 -0
- package/src/lib/design/motion-command.js.map +1 -0
- package/src/lib/design/motion-endpoint.d.ts +61 -0
- package/src/lib/design/motion-endpoint.js +133 -0
- package/src/lib/design/motion-endpoint.js.map +1 -0
- package/src/lib/design/motion-flow.d.ts +5 -0
- package/src/lib/design/motion-flow.js +29 -0
- package/src/lib/design/motion-flow.js.map +1 -0
- package/src/lib/design/motion-shot.d.ts +43 -0
- package/src/lib/design/motion-shot.js +97 -0
- package/src/lib/design/motion-shot.js.map +1 -0
- package/src/lib/design/paths.d.ts +11 -0
- package/src/lib/design/paths.js.map +1 -1
- package/src/lib/design/ready-shots.d.ts +7 -0
- package/src/lib/design/ready-shots.js +22 -1
- package/src/lib/design/ready-shots.js.map +1 -1
- package/src/lib/design/render-packages.d.ts +9 -0
- package/src/lib/design/render-packages.js +13 -3
- package/src/lib/design/render-packages.js.map +1 -1
- package/src/lib/design/render.js +3 -3
- package/src/lib/design/render.js.map +1 -1
- package/src/lib/design/serve.js +108 -21
- package/src/lib/design/serve.js.map +1 -1
- package/src/lib/design/shot-command.d.ts +12 -6
- package/src/lib/design/shot-command.js +60 -48
- package/src/lib/design/shot-command.js.map +1 -1
- package/src/lib/design/shot.d.ts +30 -4
- package/src/lib/design/shot.js +138 -53
- package/src/lib/design/shot.js.map +1 -1
- package/src/lib/design/sketch-command.js +132 -39
- package/src/lib/design/sketch-command.js.map +1 -1
- package/src/lib/design/sketch-stylesheets.d.ts +7 -0
- package/src/lib/design/sketch-stylesheets.js +63 -0
- package/src/lib/design/sketch-stylesheets.js.map +1 -0
- package/src/lib/design/studio-api.d.ts +28 -6
- package/src/lib/design/studio-api.js +102 -17
- package/src/lib/design/studio-api.js.map +1 -1
- package/src/lib/design/system/check-system.js +8 -0
- package/src/lib/design/system/check-system.js.map +1 -1
- package/src/lib/design/system/comments/comment.d.ts +17 -0
- package/src/lib/design/system/comments/comment.js +35 -1
- package/src/lib/design/system/comments/comment.js.map +1 -1
- package/src/lib/design/system/comments/complete.js +4 -2
- package/src/lib/design/system/comments/complete.js.map +1 -1
- package/src/lib/design/system/complete-command.d.ts +9 -0
- package/src/lib/design/system/complete-command.js +134 -0
- package/src/lib/design/system/complete-command.js.map +1 -0
- package/src/lib/design/system/complete.d.ts +171 -0
- package/src/lib/design/system/complete.js +1106 -0
- package/src/lib/design/system/complete.js.map +1 -0
- package/src/lib/design/system/contrast-check.d.ts +26 -0
- package/src/lib/design/system/contrast-check.js +153 -0
- package/src/lib/design/system/contrast-check.js.map +1 -0
- package/src/lib/design/system/exceptions.d.ts +59 -0
- package/src/lib/design/system/exceptions.js +132 -0
- package/src/lib/design/system/exceptions.js.map +1 -0
- package/src/lib/design/system/flows/flow.d.ts +3 -0
- package/src/lib/design/system/flows/flow.js +5 -3
- package/src/lib/design/system/flows/flow.js.map +1 -1
- package/src/lib/design/system/handoff.d.ts +11 -0
- package/src/lib/design/system/handoff.js +30 -0
- package/src/lib/design/system/handoff.js.map +1 -0
- package/src/lib/design/system/imports.d.ts +28 -3
- package/src/lib/design/system/imports.js +84 -7
- package/src/lib/design/system/imports.js.map +1 -1
- package/src/lib/design/system/inventory-approval.d.ts +41 -0
- package/src/lib/design/system/inventory-approval.js +71 -0
- package/src/lib/design/system/inventory-approval.js.map +1 -0
- package/src/lib/design/system/inventory.d.ts +65 -0
- package/src/lib/design/system/inventory.js +168 -0
- package/src/lib/design/system/inventory.js.map +1 -0
- package/src/lib/design/system/list-system.d.ts +11 -0
- package/src/lib/design/system/list-system.js +26 -3
- package/src/lib/design/system/list-system.js.map +1 -1
- package/src/lib/design/system/product-stylesheets.d.ts +3 -0
- package/src/lib/design/system/product-stylesheets.js +40 -0
- package/src/lib/design/system/product-stylesheets.js.map +1 -0
- package/src/lib/design/system/render-check.js +30 -4
- package/src/lib/design/system/render-check.js.map +1 -1
- package/src/lib/design/system/resolve-product.d.ts +2 -3
- package/src/lib/design/system/resolve-product.js +4 -6
- package/src/lib/design/system/resolve-product.js.map +1 -1
- package/src/lib/design/system/rules/canonical-class.d.ts +9 -0
- package/src/lib/design/system/rules/canonical-class.js +132 -0
- package/src/lib/design/system/rules/canonical-class.js.map +1 -0
- package/src/lib/design/system/rules/inline-svg.d.ts +7 -0
- package/src/lib/design/system/rules/inline-svg.js +28 -0
- package/src/lib/design/system/rules/inline-svg.js.map +1 -0
- package/src/lib/design/system/rules/motion.d.ts +4 -0
- package/src/lib/design/system/rules/motion.js +46 -2
- package/src/lib/design/system/rules/motion.js.map +1 -1
- package/src/lib/design/system/rules/stale-replace.js +41 -2
- package/src/lib/design/system/rules/stale-replace.js.map +1 -1
- package/src/lib/design/system/rules/token-selectors.js +1 -1
- package/src/lib/design/system/rules/token-selectors.js.map +1 -1
- package/src/lib/design/system/rules/utility-css.d.ts +11 -0
- package/src/lib/design/system/rules/utility-css.js +290 -0
- package/src/lib/design/system/rules/utility-css.js.map +1 -0
- package/src/lib/design/system/sketch/baseline.d.ts +3 -2
- package/src/lib/design/system/sketch/baseline.js +10 -4
- package/src/lib/design/system/sketch/baseline.js.map +1 -1
- package/src/lib/design/system/sketch/host-edit.d.ts +4 -0
- package/src/lib/design/system/sketch/host-edit.js +68 -0
- package/src/lib/design/system/sketch/host-edit.js.map +1 -0
- package/src/lib/design/system/sketch/merge-preview.d.ts +4 -2
- package/src/lib/design/system/sketch/merge-preview.js +20 -3
- package/src/lib/design/system/sketch/merge-preview.js.map +1 -1
- package/src/lib/design/system/sketch/merge-shared.d.ts +42 -6
- package/src/lib/design/system/sketch/merge-shared.js +122 -24
- package/src/lib/design/system/sketch/merge-shared.js.map +1 -1
- package/src/lib/design/system/sketch/merge.d.ts +6 -5
- package/src/lib/design/system/sketch/merge.js +95 -35
- package/src/lib/design/system/sketch/merge.js.map +1 -1
- package/src/lib/design/system/sketch/merged-parts.d.ts +17 -0
- package/src/lib/design/system/sketch/merged-parts.js +35 -2
- package/src/lib/design/system/sketch/merged-parts.js.map +1 -1
- package/src/lib/design/system/sketch/no-visible-change.d.ts +10 -3
- package/src/lib/design/system/sketch/no-visible-change.js +75 -20
- package/src/lib/design/system/sketch/no-visible-change.js.map +1 -1
- package/src/lib/design/system/sketch/overlay.js +16 -3
- package/src/lib/design/system/sketch/overlay.js.map +1 -1
- package/src/lib/design/system/sketch/part-definition.d.ts +12 -0
- package/src/lib/design/system/sketch/part-definition.js +40 -1
- package/src/lib/design/system/sketch/part-definition.js.map +1 -1
- package/src/lib/design/system/sketch/sketch.d.ts +40 -3
- package/src/lib/design/system/sketch/sketch.js +147 -50
- package/src/lib/design/system/sketch/sketch.js.map +1 -1
- package/src/lib/design/system/sketch/unchanged-states.d.ts +10 -4
- package/src/lib/design/system/sketch/unchanged-states.js +37 -23
- package/src/lib/design/system/sketch/unchanged-states.js.map +1 -1
- package/src/lib/design/system/sketch/unused-closure.d.ts +12 -0
- package/src/lib/design/system/sketch/unused-closure.js +185 -0
- package/src/lib/design/system/sketch/unused-closure.js.map +1 -0
- package/src/lib/design/system/system-modules.d.ts +5 -1
- package/src/lib/design/system/system-modules.js +32 -17
- package/src/lib/design/system/system-modules.js.map +1 -1
- package/src/lib/design/system/system-tsconfig.d.ts +2 -1
- package/src/lib/design/system/system-tsconfig.js +11 -6
- package/src/lib/design/system/system-tsconfig.js.map +1 -1
- package/src/lib/design/thumb.d.ts +6 -2
- package/src/lib/design/thumb.js +36 -15
- package/src/lib/design/thumb.js.map +1 -1
- package/src/lib/design/variant-diff.d.ts +12 -10
- package/src/lib/design/variant-diff.js +14 -13
- package/src/lib/design/variant-diff.js.map +1 -1
- package/src/lib/design/warm-pages.d.ts +20 -0
- package/src/lib/design/warm-pages.js +78 -0
- package/src/lib/design/warm-pages.js.map +1 -0
- package/src/lib/update/commits.d.ts +9 -0
- package/src/lib/update/commits.js +15 -1
- package/src/lib/update/commits.js.map +1 -1
- package/src/lib/update/migration-manifest.d.ts +6 -0
- package/src/lib/update/migration-manifest.js +7 -0
- package/src/lib/update/migration-manifest.js.map +1 -1
- package/src/lib/update/migrations-command.js +1 -0
- package/src/lib/update/migrations-command.js.map +1 -1
- package/src/lib/update/run-migrations.d.ts +22 -0
- package/src/lib/update/run-migrations.js +60 -1
- package/src/lib/update/run-migrations.js.map +1 -1
- package/src/lib/update/update-command.d.ts +10 -0
- package/src/lib/update/update-command.js +28 -2
- package/src/lib/update/update-command.js.map +1 -1
package/design/mark-part.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
},
|
package/design/morph.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const keyOf = (node: Node) =>
|
|
2
|
+
node instanceof Element
|
|
3
|
+
? `${node.nodeName}#${node.id}#${node.getAttribute('data-et-key') ?? ''}`
|
|
4
|
+
: `${node.nodeType}`;
|
|
5
|
+
|
|
6
|
+
const morphAttributes = (from: Element, to: Element) => {
|
|
7
|
+
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name)) from.removeAttribute(name);
|
|
8
|
+
|
|
9
|
+
for (const { name, value } of [...to.attributes])
|
|
10
|
+
if (from.getAttribute(name) !== value) from.setAttribute(name, value);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const morphNode = (from: Node, to: Node) => {
|
|
14
|
+
if (from instanceof Element && to instanceof Element) {
|
|
15
|
+
morphAttributes(from, to);
|
|
16
|
+
morphChildren(
|
|
17
|
+
from instanceof HTMLTemplateElement ? from.content : from,
|
|
18
|
+
to instanceof HTMLTemplateElement ? to.content : to,
|
|
19
|
+
);
|
|
20
|
+
} else if (from.nodeValue !== to.nodeValue) {
|
|
21
|
+
from.nodeValue = to.nodeValue;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const morphChildren = (from: Node, to: Node) => {
|
|
26
|
+
let cursor = from.firstChild;
|
|
27
|
+
|
|
28
|
+
for (const next of [...to.childNodes]) {
|
|
29
|
+
const key = keyOf(next);
|
|
30
|
+
let match: ChildNode | null = cursor;
|
|
31
|
+
|
|
32
|
+
while (match && keyOf(match) !== key) match = match.nextSibling;
|
|
33
|
+
|
|
34
|
+
if (match) {
|
|
35
|
+
while (cursor && cursor !== match) {
|
|
36
|
+
const removed: ChildNode = cursor;
|
|
37
|
+
|
|
38
|
+
cursor = cursor.nextSibling;
|
|
39
|
+
removed.remove();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
morphNode(match, next);
|
|
43
|
+
cursor = match.nextSibling;
|
|
44
|
+
} else {
|
|
45
|
+
from.insertBefore(next, cursor);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
while (cursor) {
|
|
50
|
+
const removed: ChildNode = cursor;
|
|
51
|
+
|
|
52
|
+
cursor = cursor.nextSibling;
|
|
53
|
+
removed.remove();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turns the children of `root` into `markup` by changing only what differs: attributes, text, and the nodes that
|
|
59
|
+
* appear or go. A node is kept when its tag, `id` and `data-et-key` match one at the same place, so its CSS
|
|
60
|
+
* transitions run and a node that appears gets `@starting-style`.
|
|
61
|
+
*/
|
|
62
|
+
export const morph = (root: Element, markup: string) => {
|
|
63
|
+
const parsed = root.ownerDocument.createElement('template');
|
|
64
|
+
|
|
65
|
+
parsed.innerHTML = markup;
|
|
66
|
+
morphChildren(root, parsed.content);
|
|
67
|
+
};
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { Motion, MotionContext, PartRule } from './define-part';
|
|
2
|
+
import { morph } from './morph';
|
|
3
|
+
|
|
4
|
+
/** The time of a motion in ms from its start: `at` runs `fn` once `now` reaches `ms`, `advance` moves time on. */
|
|
5
|
+
export type MotionClock = {
|
|
6
|
+
now: () => number;
|
|
7
|
+
at: (ms: number, fn: () => void) => void;
|
|
8
|
+
advance: (ms: number) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** A clock that only moves when `advance` is called, so a caller can pause and step a motion. */
|
|
12
|
+
export const manualClock = (): MotionClock => {
|
|
13
|
+
let time = 0;
|
|
14
|
+
let queue: { ms: number; fn: () => void }[] = [];
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
now: () => time,
|
|
18
|
+
at: (ms, fn) => {
|
|
19
|
+
queue.push({ ms, fn });
|
|
20
|
+
},
|
|
21
|
+
advance: (ms) => {
|
|
22
|
+
const target = time + ms;
|
|
23
|
+
|
|
24
|
+
for (;;) {
|
|
25
|
+
const due = queue.reduce<{ ms: number; fn: () => void } | null>(
|
|
26
|
+
(first, task) => (task.ms <= target && (!first || task.ms < first.ms) ? task : first),
|
|
27
|
+
null,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!due) break;
|
|
31
|
+
|
|
32
|
+
queue = queue.filter((task) => task !== due);
|
|
33
|
+
time = Math.max(time, due.ms);
|
|
34
|
+
due.fn();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
time = target;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A clock that follows real time while it has work, one step per animation frame. */
|
|
43
|
+
export const realTimeClock = (
|
|
44
|
+
frame: (step: () => void) => void = (step) => requestAnimationFrame(step),
|
|
45
|
+
wall: () => number = () => performance.now(),
|
|
46
|
+
): MotionClock => {
|
|
47
|
+
const clock = manualClock();
|
|
48
|
+
let waiting = 0;
|
|
49
|
+
let last: number | null = null;
|
|
50
|
+
|
|
51
|
+
const tick = () => {
|
|
52
|
+
const now = wall();
|
|
53
|
+
|
|
54
|
+
clock.advance(now - (last ?? now));
|
|
55
|
+
last = now;
|
|
56
|
+
|
|
57
|
+
if (waiting > 0) frame(tick);
|
|
58
|
+
else last = null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...clock,
|
|
63
|
+
at: (ms, fn) => {
|
|
64
|
+
waiting++;
|
|
65
|
+
clock.at(ms, () => {
|
|
66
|
+
waiting--;
|
|
67
|
+
fn();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (last === null) {
|
|
71
|
+
last = wall();
|
|
72
|
+
frame(tick);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The same sequence in every run: mulberry32 from a fixed seed. */
|
|
79
|
+
export const seededRandom = (seed = 1) => {
|
|
80
|
+
let state = seed >>> 0;
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
84
|
+
|
|
85
|
+
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
86
|
+
|
|
87
|
+
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value);
|
|
88
|
+
|
|
89
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const measure = (root: Element) => {
|
|
94
|
+
const rects = [...root.querySelectorAll('*')].map((element) => element.getBoundingClientRect());
|
|
95
|
+
const copy = root.cloneNode(true) as Element;
|
|
96
|
+
const copies = [...copy.querySelectorAll('*')];
|
|
97
|
+
|
|
98
|
+
return (selector: string) => {
|
|
99
|
+
const found = copy.querySelector(selector);
|
|
100
|
+
const rect = found ? rects[copies.indexOf(found)] : undefined;
|
|
101
|
+
|
|
102
|
+
if (!rect) throw new Error(`design-explore: ctx.rect("${selector}") matches nothing in the from state`);
|
|
103
|
+
|
|
104
|
+
return rect;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
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
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Morphs `root` to `markup`, inside a View Transition when the motion asks for one and the browser has it, then runs
|
|
122
|
+
* the motion's `play`. `ctx.rect` measures in the DOM before the morph. Resolves once `play` has run.
|
|
123
|
+
*/
|
|
124
|
+
export const switchState = ({ root, markup, motion, clock, transitioned }: StateSwitch): Promise<void> => {
|
|
125
|
+
const rect = motion?.play ? measure(root) : null;
|
|
126
|
+
const apply = () => morph(root, markup);
|
|
127
|
+
const play = () => {
|
|
128
|
+
if (!motion?.play || !rect) return;
|
|
129
|
+
|
|
130
|
+
const time = clock ?? realTimeClock();
|
|
131
|
+
const ctx: MotionContext = { at: (ms, fn) => time.at(ms, fn), random: seededRandom(), rect };
|
|
132
|
+
|
|
133
|
+
motion.play(ctx);
|
|
134
|
+
};
|
|
135
|
+
const document = root.ownerDocument;
|
|
136
|
+
|
|
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
|
+
}
|
|
144
|
+
|
|
145
|
+
apply();
|
|
146
|
+
play();
|
|
147
|
+
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
};
|
|
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
|
+
/** A motion paused at its start: `seek` moves it forward to a time in ms, `tracks` lists what ran so far. */
|
|
166
|
+
export type SteppedMotion = {
|
|
167
|
+
seek: (ms: number) => void;
|
|
168
|
+
tracks: () => MotionTrack[];
|
|
169
|
+
end: () => number;
|
|
170
|
+
bounds: () => MotionBox;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** A rectangle in page coordinates. */
|
|
174
|
+
export type MotionBox = { left: number; top: number; right: number; bottom: number };
|
|
175
|
+
|
|
176
|
+
const KEYFRAME_FIELDS = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
|
177
|
+
|
|
178
|
+
const elementOf = (element: Element | null | undefined) =>
|
|
179
|
+
element?.hasAttribute('data-et-key')
|
|
180
|
+
? `[data-et-key="${element.getAttribute('data-et-key')}"]`
|
|
181
|
+
: element
|
|
182
|
+
? [
|
|
183
|
+
element.tagName.toLowerCase(),
|
|
184
|
+
element.id ? `#${element.id}` : '',
|
|
185
|
+
...[...element.classList].map((name) => `.${name}`),
|
|
186
|
+
].join('')
|
|
187
|
+
: '';
|
|
188
|
+
|
|
189
|
+
const trackOf = (animation: Animation, start: number): MotionTrack => {
|
|
190
|
+
const effect = animation.effect as KeyframeEffect | null;
|
|
191
|
+
const timing = effect?.getComputedTiming();
|
|
192
|
+
const keyframes = effect && typeof effect.getKeyframes === 'function' ? effect.getKeyframes() : [];
|
|
193
|
+
const own = effect?.getTiming().easing ?? 'linear';
|
|
194
|
+
const keyed = keyframes.find((frame) => frame.easing && frame.easing !== 'linear')?.easing;
|
|
195
|
+
const iterations = timing?.iterations ?? 1;
|
|
196
|
+
const endTime = Number(timing?.endTime ?? 0);
|
|
197
|
+
const properties = [
|
|
198
|
+
...new Set(
|
|
199
|
+
keyframes.flatMap((frame) =>
|
|
200
|
+
Object.keys(frame)
|
|
201
|
+
.filter((field) => !KEYFRAME_FIELDS.has(field))
|
|
202
|
+
.map((field) => field.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
];
|
|
206
|
+
const kind = 'transitionProperty' in animation ? 'transition' : 'animationName' in animation ? 'animation' : 'script';
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
element: `${elementOf(effect?.target)}${effect?.pseudoElement ?? ''}`,
|
|
210
|
+
kind,
|
|
211
|
+
...(kind === 'animation' ? { name: String((animation as CSSAnimation).animationName) } : {}),
|
|
212
|
+
properties: kind === 'transition' ? [String((animation as CSSTransition).transitionProperty)] : properties,
|
|
213
|
+
start,
|
|
214
|
+
delay: Number(timing?.delay ?? 0),
|
|
215
|
+
duration: Number(timing?.duration ?? 0),
|
|
216
|
+
iterations: Number.isFinite(iterations) ? iterations : null,
|
|
217
|
+
easing: own === 'linear' ? (keyed ?? own) : own,
|
|
218
|
+
end: Number.isFinite(endTime) ? start + endTime : null,
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Switches `root` to `markup` with `motion` as `switchState` does, on a manual clock, and pauses every animation it
|
|
224
|
+
* starts, so a caller can step through the motion and draw it at any time from its start. A motion with `interrupt`
|
|
225
|
+
* switches back to `reverse.markup` at that time, with `reverse.motion` (its `ctx.at` counts from the interrupt and a
|
|
226
|
+
* View Transition is not used); a forward `ctx.at` step after it is dropped and a cancelled animation ends there.
|
|
227
|
+
*/
|
|
228
|
+
export const stepMotion = (options: {
|
|
229
|
+
root: Element;
|
|
230
|
+
markup: string;
|
|
231
|
+
motion: Motion;
|
|
232
|
+
reverse?: { markup: string; motion?: Motion };
|
|
233
|
+
}): Promise<SteppedMotion> => {
|
|
234
|
+
const { root, markup, motion, reverse } = options;
|
|
235
|
+
const document = root.ownerDocument;
|
|
236
|
+
const clock = manualClock();
|
|
237
|
+
const started = new Map<Animation, MotionTrack>();
|
|
238
|
+
const cut = new Set<Animation>();
|
|
239
|
+
const interrupt = motion.interrupt;
|
|
240
|
+
let interrupted = false;
|
|
241
|
+
let lastTask = 0;
|
|
242
|
+
let ready: Promise<unknown> = Promise.resolve();
|
|
243
|
+
|
|
244
|
+
if (interrupt !== undefined && !reverse)
|
|
245
|
+
throw new Error('design-explore: a motion with interrupt needs the markup of its from state to switch back to');
|
|
246
|
+
|
|
247
|
+
const collect = (start: number) => {
|
|
248
|
+
for (const animation of started.keys()) {
|
|
249
|
+
const track = started.get(animation);
|
|
250
|
+
|
|
251
|
+
if (cut.has(animation) || animation.playState !== 'idle' || !track) continue;
|
|
252
|
+
|
|
253
|
+
cut.add(animation);
|
|
254
|
+
track.end = Math.min(track.end ?? start, start);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const animation of document.getAnimations()) {
|
|
258
|
+
if (started.has(animation)) continue;
|
|
259
|
+
|
|
260
|
+
animation.pause();
|
|
261
|
+
started.set(animation, trackOf(animation, start));
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const pose = (ms: number) => {
|
|
266
|
+
for (const [animation, track] of started)
|
|
267
|
+
if (!cut.has(animation)) animation.currentTime = Math.max(0, ms - track.start);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const timedFrom = (offset: number, live: () => boolean): MotionClock => ({
|
|
271
|
+
...clock,
|
|
272
|
+
at: (ms, fn) =>
|
|
273
|
+
clock.at(offset + ms, () => {
|
|
274
|
+
if (!live()) return;
|
|
275
|
+
|
|
276
|
+
lastTask = Math.max(lastTask, offset + ms);
|
|
277
|
+
pose(offset + ms);
|
|
278
|
+
fn();
|
|
279
|
+
collect(offset + ms);
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
const timed = timedFrom(0, () => !interrupted);
|
|
283
|
+
|
|
284
|
+
return switchState({
|
|
285
|
+
root,
|
|
286
|
+
markup,
|
|
287
|
+
motion,
|
|
288
|
+
clock: timed,
|
|
289
|
+
transitioned: (transition) => {
|
|
290
|
+
ready = transition.ready.catch(() => undefined);
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
.then(() => ready)
|
|
294
|
+
.then(() => {
|
|
295
|
+
collect(0);
|
|
296
|
+
|
|
297
|
+
if (interrupt !== undefined && reverse) {
|
|
298
|
+
clock.at(interrupt, () => {
|
|
299
|
+
interrupted = true;
|
|
300
|
+
lastTask = Math.max(lastTask, interrupt);
|
|
301
|
+
pose(interrupt);
|
|
302
|
+
void switchState({
|
|
303
|
+
root,
|
|
304
|
+
markup: reverse.markup,
|
|
305
|
+
motion: reverse.motion ? { ...reverse.motion, viewTransition: false } : undefined,
|
|
306
|
+
clock: timedFrom(interrupt, () => true),
|
|
307
|
+
});
|
|
308
|
+
collect(interrupt);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
seek: (ms) => {
|
|
314
|
+
if (ms < clock.now())
|
|
315
|
+
throw new Error(`design-explore: a motion steps forward only, from ${clock.now()} ms to ${ms} ms`);
|
|
316
|
+
|
|
317
|
+
clock.advance(ms - clock.now());
|
|
318
|
+
|
|
319
|
+
pose(ms);
|
|
320
|
+
},
|
|
321
|
+
tracks: () => [...started.values()],
|
|
322
|
+
end: () => Math.max(lastTask, ...[...started.values()].map((track) => track.end ?? 0)),
|
|
323
|
+
bounds: () => {
|
|
324
|
+
const view = document.defaultView;
|
|
325
|
+
const boxes = [
|
|
326
|
+
root,
|
|
327
|
+
...[...started.keys()].map((animation) => (animation.effect as KeyframeEffect | null)?.target),
|
|
328
|
+
]
|
|
329
|
+
.filter((element): element is Element => !!element)
|
|
330
|
+
.map((element) => element.getBoundingClientRect())
|
|
331
|
+
.filter((rect, index) => index === 0 || rect.width > 0 || rect.height > 0);
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
left: Math.min(...boxes.map((rect) => rect.left)) + (view?.scrollX ?? 0),
|
|
335
|
+
top: Math.min(...boxes.map((rect) => rect.top)) + (view?.scrollY ?? 0),
|
|
336
|
+
right: Math.max(...boxes.map((rect) => rect.right)) + (view?.scrollX ?? 0),
|
|
337
|
+
bottom: Math.max(...boxes.map((rect) => rect.bottom)) + (view?.scrollY ?? 0),
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* How `runRules` reaches the part: the state drawn first, its `rules` and `motions`, and the markup of a state. `clock`
|
|
346
|
+
* makes the clock of each switch, `switched` hears each finished switch and `failed` each error.
|
|
347
|
+
*/
|
|
348
|
+
export type PartRules = {
|
|
349
|
+
state: string;
|
|
350
|
+
rules: PartRule[];
|
|
351
|
+
motions?: Record<string, Motion>;
|
|
352
|
+
render: (state: string) => string | Promise<string>;
|
|
353
|
+
clock?: () => MotionClock;
|
|
354
|
+
switched?: (state: string) => void;
|
|
355
|
+
failed?: (error: unknown) => void;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Runs the rules of a part in `root`: an event that matches a rule's `on`, such as `click .open`, morphs the part to the
|
|
360
|
+
* rule's state with the motion it names. Switches run one after another. Returns a function that stops the rules.
|
|
361
|
+
*/
|
|
362
|
+
export const runRules = (root: HTMLElement, part: PartRules) => {
|
|
363
|
+
let current = part.state;
|
|
364
|
+
let queue = Promise.resolve();
|
|
365
|
+
const types = [...new Set(part.rules.map((rule) => rule.on.trim().split(/\s+/u)[0] ?? ''))].filter(Boolean);
|
|
366
|
+
|
|
367
|
+
const go = (rule: PartRule) => {
|
|
368
|
+
queue = queue
|
|
369
|
+
.then(() => (rule.to === current ? null : part.render(rule.to)))
|
|
370
|
+
.then((markup) => {
|
|
371
|
+
if (markup === null) return;
|
|
372
|
+
|
|
373
|
+
current = rule.to;
|
|
374
|
+
|
|
375
|
+
return switchState({
|
|
376
|
+
root,
|
|
377
|
+
markup,
|
|
378
|
+
motion: rule.motion ? part.motions?.[rule.motion] : undefined,
|
|
379
|
+
clock: part.clock?.(),
|
|
380
|
+
}).then(() => part.switched?.(rule.to));
|
|
381
|
+
})
|
|
382
|
+
.catch((error: unknown) => (part.failed ?? console.error)(error));
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const listener = (event: Event) => {
|
|
386
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
387
|
+
const rule = part.rules.find((entry) => {
|
|
388
|
+
const [type = '', ...rest] = entry.on.trim().split(/\s+/u);
|
|
389
|
+
const selector = rest.join(' ');
|
|
390
|
+
|
|
391
|
+
if (type !== event.type) return false;
|
|
392
|
+
if (!selector) return true;
|
|
393
|
+
|
|
394
|
+
const matched = target?.closest(selector);
|
|
395
|
+
|
|
396
|
+
return matched ? root.contains(matched) : false;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
if (rule) go(rule);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
for (const type of types) root.addEventListener(type, listener);
|
|
403
|
+
|
|
404
|
+
return () => {
|
|
405
|
+
for (const type of types) root.removeEventListener(type, listener);
|
|
406
|
+
};
|
|
407
|
+
};
|
package/design/spring.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** A spring as a CSS easing: `easing` is a `linear(...)` function, `duration` the ms until it settles; as a string, `<duration>ms <easing>`. */
|
|
2
|
+
export type Spring = { easing: string; duration: number; toString: () => string };
|
|
3
|
+
|
|
4
|
+
const SETTLED = 0.001;
|
|
5
|
+
const LONGEST_MS = 10000;
|
|
6
|
+
|
|
7
|
+
const positionOf = (damping: number, frequency: number) => {
|
|
8
|
+
const natural = 2 * Math.PI * frequency;
|
|
9
|
+
|
|
10
|
+
if (damping < 1) {
|
|
11
|
+
const damped = natural * Math.sqrt(1 - damping * damping);
|
|
12
|
+
const decay = damping * natural;
|
|
13
|
+
|
|
14
|
+
return (seconds: number) =>
|
|
15
|
+
1 - Math.exp(-decay * seconds) * (Math.cos(damped * seconds) + (decay / damped) * Math.sin(damped * seconds));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (damping === 1) return (seconds: number) => 1 - Math.exp(-natural * seconds) * (1 + natural * seconds);
|
|
19
|
+
|
|
20
|
+
const root = Math.sqrt(damping * damping - 1);
|
|
21
|
+
const slow = -natural * (damping - root);
|
|
22
|
+
const fast = -natural * (damping + root);
|
|
23
|
+
|
|
24
|
+
return (seconds: number) => 1 - (fast * Math.exp(slow * seconds) - slow * Math.exp(fast * seconds)) / (fast - slow);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Samples a spring that moves from 0 to 1 at rest, for a motion token in `foundations`, e.g.
|
|
29
|
+
* `--motion-capsule: ${spring({ damping: 0.6, frequency: 2 })}`. `damping` is the damping ratio (below 1 it
|
|
30
|
+
* overshoots) and `frequency` the undamped frequency in Hz.
|
|
31
|
+
*/
|
|
32
|
+
export const spring = (options: { damping: number; frequency: number }): Spring => {
|
|
33
|
+
const { damping, frequency } = options;
|
|
34
|
+
|
|
35
|
+
if (!(damping > 0) || !(frequency > 0) || !Number.isFinite(damping) || !Number.isFinite(frequency))
|
|
36
|
+
throw new Error(
|
|
37
|
+
`design-explore: spring() needs a damping and a frequency above 0, got ${damping} and ${frequency}`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const position = positionOf(damping, frequency);
|
|
41
|
+
let last = 0;
|
|
42
|
+
|
|
43
|
+
for (let ms = 0; ms <= LONGEST_MS; ms++) if (Math.abs(1 - position(ms / 1000)) >= SETTLED) last = ms;
|
|
44
|
+
|
|
45
|
+
const duration = Math.max(1, last + 1);
|
|
46
|
+
const count = Math.min(200, Math.max(10, Math.ceil(duration / 16)));
|
|
47
|
+
const points = Array.from({ length: count + 1 }, (_, index) =>
|
|
48
|
+
index === count ? 1 : Math.round(position(((index / count) * duration) / 1000) * 10000) / 10000,
|
|
49
|
+
);
|
|
50
|
+
const easing = `linear(${points.join(', ')})`;
|
|
51
|
+
|
|
52
|
+
return { easing, duration, toString: () => `${duration}ms ${easing}` };
|
|
53
|
+
};
|