synthesisui 0.16.25 → 0.16.27
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/dist/claude-md.js +7 -0
- package/dist/commands/doctor.js +1 -0
- package/dist/doctor/scan.js +78 -0
- package/dist/doctor/tokens.js +11 -0
- package/dist/guide.js +47 -3
- package/dist/index.js +18 -3
- package/dist/motion.js +115 -0
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -290,6 +290,13 @@ against their own, so the last one written wins, which is what the call site rea
|
|
|
290
290
|
If an override is ignored, that component predates the resolver: regenerate it with
|
|
291
291
|
\`npx synthesisui@latest component <slug> <name>\` rather than reaching for \`!\`.
|
|
292
292
|
|
|
293
|
+
**Motion is selection, not improvisation.** The base is quiet: nothing moves until a person
|
|
294
|
+
asks. When they do, pick from the **Motion vocabulary** in GUIDE.md - \`animate-<name>\`
|
|
295
|
+
utilities already compiled on this system's clock - and never hand-roll \`@keyframes\` or raw
|
|
296
|
+
durations. A motion the vocabulary lacks is a gap like any other: file it (\`request_token\`
|
|
297
|
+
MCP tool, or \`npx synthesisui@latest request\`) and say what you chose from the vocabulary
|
|
298
|
+
instead.
|
|
299
|
+
|
|
293
300
|
Only write something new when nothing in the manifest covers the purpose - and when you do,
|
|
294
301
|
say which entry you considered and why it did not fit, then FILE it while the reasoning is
|
|
295
302
|
still yours: the \`request_component\` MCP tool, or \`npx synthesisui@latest request component
|
package/dist/commands/doctor.js
CHANGED
|
@@ -191,6 +191,7 @@ const KIND_LABEL = {
|
|
|
191
191
|
radius: "radius",
|
|
192
192
|
spacing: "spacing",
|
|
193
193
|
font: "type",
|
|
194
|
+
motion: "motion",
|
|
194
195
|
};
|
|
195
196
|
/** "1 file", "2 files" - a report that says "1 files read" on its very first
|
|
196
197
|
* line spends credibility before it has said anything. */
|
package/dist/doctor/scan.js
CHANGED
|
@@ -59,6 +59,28 @@ const RADIUS = new RegExp(`(?:border-?[Rr]adius\\s*:\\s*${OPEN}|rounded(?:-[a-z]
|
|
|
59
59
|
const SPACING = new RegExp(`(?:\\b[pmg](?:[trblxy])?-\\[|gap-\\[|(?:padding|margin|gap)(?:[A-Z][a-z]+)?\\s*:\\s*${OPEN})(-?\\d*\\.?\\d+)(px|rem)`, "g");
|
|
60
60
|
/** A font stack written by hand rather than taken from the type scale. */
|
|
61
61
|
const FONT = /font-family\s*:\s*([^;}\n]+)/g;
|
|
62
|
+
/**
|
|
63
|
+
* MOTION, the family the scan never read (item 12, F3).
|
|
64
|
+
*
|
|
65
|
+
* A transition or animation declaration carrying a literal clock - `300ms`,
|
|
66
|
+
* `.2s`, a hand-tuned `cubic-bezier` - is timing decided outside the system,
|
|
67
|
+
* exactly like a hex outside the palette. Anchored to the property so a
|
|
68
|
+
* number elsewhere on the line (a scale factor, a z-index) is never read as
|
|
69
|
+
* time; the value normalizer makes `.3s` and `300ms` one literal, so the
|
|
70
|
+
* system's own duration token gets NAMED, not just flagged.
|
|
71
|
+
*/
|
|
72
|
+
const MOTION_PROP = /\b(?:transition|animation)(?:-(?:duration|delay|timing-function)|Duration|Delay|TimingFunction)?\s*:\s*["'`]?([^;}"'`\n]*)/g;
|
|
73
|
+
const TIME_LITERAL = /(\d*\.?\d+)(ms|s)\b/g;
|
|
74
|
+
const BEZIER = /cubic-bezier\([^)]*\)/g;
|
|
75
|
+
/** `duration-300`, `duration-[350ms]`, `delay-75` - Tailwind's stock clock. */
|
|
76
|
+
const TW_TIME = /\b(?:duration|delay)-(?:\[([^\]]+)\]|(\d+)\b)/g;
|
|
77
|
+
const TW_EASE = /\bease-\[([^\]]+)\]/g;
|
|
78
|
+
/** `animate-fade-up`, `animate-[wiggle_1s_ease]` - checked against the
|
|
79
|
+
* installed vocabulary, so only when the system ships one. */
|
|
80
|
+
const ANIMATE_UTIL = /\banimate-(\[[^\]]+\]|[a-z0-9-]+)/g;
|
|
81
|
+
/** tailwindcss-animate's grammar (the shadcn bridge): `animate-in`/`-out` are
|
|
82
|
+
* composable micro-transition idiom, not a keyframe selection to police. */
|
|
83
|
+
const ANIMATE_IDIOM = new Set(["none", "in", "out"]);
|
|
62
84
|
/**
|
|
63
85
|
* Uses of the system. Coverage is meaningless without them.
|
|
64
86
|
*
|
|
@@ -308,6 +330,61 @@ export function scanSource(file, source, table) {
|
|
|
308
330
|
continue;
|
|
309
331
|
push("font", stack);
|
|
310
332
|
}
|
|
333
|
+
for (const m of line.matchAll(MOTION_PROP)) {
|
|
334
|
+
const value = m[1];
|
|
335
|
+
const base = (m.index ?? 0) + m[0].length - value.length;
|
|
336
|
+
for (const t of value.matchAll(TIME_LITERAL)) {
|
|
337
|
+
// `0s` is idiom (disabling a transition), not a clock chosen by hand.
|
|
338
|
+
if (Number.parseFloat(t[1]) === 0)
|
|
339
|
+
continue;
|
|
340
|
+
push("motion", `${t[1]}${t[2]}`, base + (t.index ?? 0));
|
|
341
|
+
}
|
|
342
|
+
for (const b of value.matchAll(BEZIER)) {
|
|
343
|
+
push("motion", b[0], base + (b.index ?? 0));
|
|
344
|
+
}
|
|
345
|
+
// A `ds-*` animation name that the installed css never declares renders
|
|
346
|
+
// as NOTHING, silently - the keyframe cousin of a phantom token.
|
|
347
|
+
if (table.keyframes.size > 0) {
|
|
348
|
+
for (const w of value.matchAll(/\b(ds-[a-z0-9-]+)\b/g)) {
|
|
349
|
+
if (!table.keyframes.has(w[1]))
|
|
350
|
+
phantoms.push({ name: `@keyframes ${w[1]}`, line: at });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const m of line.matchAll(TW_TIME)) {
|
|
355
|
+
const literal = m[1] ?? `${m[2]}ms`;
|
|
356
|
+
// `duration-[var(--ds-motion-durations-base)]` is the token being USED.
|
|
357
|
+
if (literal.startsWith("var("))
|
|
358
|
+
continue;
|
|
359
|
+
if (Number.parseFloat(literal) === 0)
|
|
360
|
+
continue;
|
|
361
|
+
push("motion", literal, m.index ?? -1);
|
|
362
|
+
}
|
|
363
|
+
for (const m of line.matchAll(TW_EASE)) {
|
|
364
|
+
if (m[1].startsWith("var("))
|
|
365
|
+
continue;
|
|
366
|
+
push("motion", m[1], m.index ?? -1);
|
|
367
|
+
}
|
|
368
|
+
// Vocabulary checks exist only when there is a vocabulary: a system that
|
|
369
|
+
// ships no keyframes left nothing to select from, and silence is honest.
|
|
370
|
+
if (table.keyframes.size > 0 && table.slug) {
|
|
371
|
+
const scope = `ds-${table.slug}-`;
|
|
372
|
+
const vocabulary = new Set([...table.keyframes]
|
|
373
|
+
.filter((k) => k.startsWith(scope))
|
|
374
|
+
.map((k) => k.slice(scope.length)));
|
|
375
|
+
for (const m of line.matchAll(ANIMATE_UTIL)) {
|
|
376
|
+
const name = m[1];
|
|
377
|
+
if (name.startsWith("[")) {
|
|
378
|
+
// An arbitrary animation is a whole hand-rolled shorthand in a
|
|
379
|
+
// class - improvisation by definition once a vocabulary exists.
|
|
380
|
+
push("motion", `animate-${name}`, m.index ?? -1);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (ANIMATE_IDIOM.has(name) || vocabulary.has(name))
|
|
384
|
+
continue;
|
|
385
|
+
push("motion", `animate-${name}`, m.index ?? -1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
311
388
|
});
|
|
312
389
|
return {
|
|
313
390
|
file,
|
|
@@ -328,6 +405,7 @@ export function diagnose(files) {
|
|
|
328
405
|
radius: 0,
|
|
329
406
|
spacing: 0,
|
|
330
407
|
font: 0,
|
|
408
|
+
motion: 0,
|
|
331
409
|
};
|
|
332
410
|
for (const f of flat)
|
|
333
411
|
counts[f.kind] += 1;
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -19,6 +19,7 @@ export const EMPTY_TABLE = {
|
|
|
19
19
|
byName: new Map(),
|
|
20
20
|
byValue: new Map(),
|
|
21
21
|
declared: new Set(),
|
|
22
|
+
keyframes: new Set(),
|
|
22
23
|
};
|
|
23
24
|
const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n)))
|
|
24
25
|
.toString(16)
|
|
@@ -91,6 +92,14 @@ function args(body) {
|
|
|
91
92
|
*/
|
|
92
93
|
export function normalizeValue(raw) {
|
|
93
94
|
const v = raw.trim().toLowerCase().replace(/\s+/g, " ");
|
|
95
|
+
// Time lands on milliseconds: `.3s`, `0.3s` and `300ms` are one value, the
|
|
96
|
+
// same way every colour lands on 8-digit hex. Without this, a system that
|
|
97
|
+
// authors `--ds-motion-durations-base: 0.3s` never names an author's `300ms`.
|
|
98
|
+
const time = /^(\d*\.?\d+)(ms|s)$/.exec(v);
|
|
99
|
+
if (time) {
|
|
100
|
+
const ms = Number.parseFloat(time[1]) * (time[2] === "s" ? 1000 : 1);
|
|
101
|
+
return `${ms}ms`;
|
|
102
|
+
}
|
|
94
103
|
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/.exec(v);
|
|
95
104
|
if (short) {
|
|
96
105
|
const d = (c) => c + c;
|
|
@@ -369,6 +378,7 @@ export function buildTable(input) {
|
|
|
369
378
|
byName,
|
|
370
379
|
byValue,
|
|
371
380
|
declared: parseDeclaredNames(input.css),
|
|
381
|
+
keyframes: new Set([...input.css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map((m) => m[1])),
|
|
372
382
|
};
|
|
373
383
|
}
|
|
374
384
|
/**
|
|
@@ -433,6 +443,7 @@ const FAMILY = {
|
|
|
433
443
|
radius: "--ds-radius-",
|
|
434
444
|
spacing: "--ds-spacing-",
|
|
435
445
|
font: "--ds-typography-",
|
|
446
|
+
motion: "--ds-motion-",
|
|
436
447
|
};
|
|
437
448
|
export function tokenFor(table, literal, kind) {
|
|
438
449
|
const hit = table.byValue.get(normalizeValue(literal));
|
package/dist/guide.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { nextFontSnippet } from "./fonts.js";
|
|
2
|
+
import { animationShorthand, classifyKeyframe, describeKeyframe, isFullRotation, } from "./motion.js";
|
|
2
3
|
const kebab = (v) => v.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
3
4
|
const list = (items) => items.length ? items.map((i) => `\`${i}\``).join(", ") : "_(none)_";
|
|
4
5
|
const dataAttrs = (variants) => Object.entries(variants).map(([axis, opts]) => `data-${kebab(axis)}="${Object.keys(opts).join("|")}"`);
|
|
@@ -136,6 +137,25 @@ ${depLines.join("\n")}
|
|
|
136
137
|
const hasTailwind = "theme.css" in payload.artifacts;
|
|
137
138
|
const hasParts = Object.values(components).some((r) => r.parts && Object.keys(r.parts).length > 0);
|
|
138
139
|
const componentLines = Object.entries(components).map(([cname, recipe]) => componentEntry(cname, recipe));
|
|
140
|
+
// Motion vocabulary: the system's keyframes, named and usable. Intent is
|
|
141
|
+
// classified from the frames (same logic that compiled the animate-*
|
|
142
|
+
// utilities into theme.css), so what this section PROMISES about a utility's
|
|
143
|
+
// clock is what the CSS actually does.
|
|
144
|
+
const INTENT_LINE = {
|
|
145
|
+
entrance: "entrance · runs once on mount",
|
|
146
|
+
exit: "exit · runs once, before removal",
|
|
147
|
+
loop: "ambient loop · infinite - decorative, use sparingly",
|
|
148
|
+
attention: "attention · one shot, only when asked",
|
|
149
|
+
};
|
|
150
|
+
const keyframeLines = Object.entries(motion.keyframes).map(([kname, frames]) => {
|
|
151
|
+
const kf = frames;
|
|
152
|
+
const intent = classifyKeyframe(kname, kf);
|
|
153
|
+
const usage = hasTailwind
|
|
154
|
+
? `\`animate-${kebab(kname)}\``
|
|
155
|
+
: `\`animation: ${animationShorthand(`ds-${slug}-${kebab(kname)}`, intent, isFullRotation(kf))}\``;
|
|
156
|
+
return `- ${usage} - ${INTENT_LINE[intent]} (${describeKeyframe(kf)})`;
|
|
157
|
+
});
|
|
158
|
+
const patternLines = Object.entries(motion.patterns ?? {}).map(([pname, p]) => `- **${pname}** (on ${p.trigger}): ${p.description}`);
|
|
139
159
|
// Engagement blocks (gamification library) - category apart from core components.
|
|
140
160
|
const blockEntries = Object.entries(doc.blocks ?? {});
|
|
141
161
|
const blockLines = blockEntries.map(([bname, recipe]) => componentEntry(bname, recipe));
|
|
@@ -365,11 +385,35 @@ ${hasTailwind
|
|
|
365
385
|
scale \`--ds-typography-scale-<key>-font-size\`${hasTailwind ? " (utility: `text-<key>`)" : ""}: ${list(Object.keys(foundations.typography.scale))}.
|
|
366
386
|
- Motion: durations \`--ds-motion-durations-<key>\` (${list(Object.keys(motion.durations))}) and
|
|
367
387
|
easings \`--ds-motion-easings-<key>\` (${list(Object.keys(motion.easings))}). Use them on
|
|
368
|
-
\`transition
|
|
369
|
-
so timing stays on-brand.
|
|
370
|
-
|
|
388
|
+
\`transition\` (e.g. \`transition: color var(--ds-motion-durations-fast) var(--ds-motion-easings-standard)\`)
|
|
389
|
+
so timing stays on-brand. For ANIMATION, this system ships a named vocabulary - see
|
|
390
|
+
**Motion vocabulary** below; never hand-roll \`@keyframes\` or raw durations.
|
|
371
391
|
- When **creating a new component** the DS does not cover yet: compose it from these semantic
|
|
372
392
|
tokens to inherit the system's identity; do not invent colors/measures outside the scale.
|
|
393
|
+
${keyframeLines.length > 0
|
|
394
|
+
? `
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## Motion vocabulary
|
|
398
|
+
|
|
399
|
+
The base is QUIET: nothing moves until someone asks. When they do, animation is **selection
|
|
400
|
+
from this list**, not improvisation - these are the system's own animations, already compiled
|
|
401
|
+
into ${hasTailwind ? "`theme.css` as ready utilities" : `\`tokens.css\` as \`@keyframes ds-${slug}-<name>\``}, each on the clock its intent calls for:
|
|
402
|
+
|
|
403
|
+
${keyframeLines.join("\n")}
|
|
404
|
+
${patternLines.length > 0
|
|
405
|
+
? `
|
|
406
|
+
Interaction patterns the system pairs with them:
|
|
407
|
+
|
|
408
|
+
${patternLines.join("\n")}
|
|
409
|
+
`
|
|
410
|
+
: ""}
|
|
411
|
+
Rules: entrances run once, never on scroll-loop; ambient loops are decoration, one per view is
|
|
412
|
+
plenty; \`prefers-reduced-motion\` is already honored in the shipped CSS (a floor collapses all
|
|
413
|
+
animations) - don't undo it. If a request needs a motion this vocabulary lacks, do NOT hand-roll
|
|
414
|
+
\`@keyframes\` or raw durations: file it (\`request_token\` via MCP, or \`synthesisui request\`) and
|
|
415
|
+
tell the person what you chose from the vocabulary instead.`
|
|
416
|
+
: ""}
|
|
373
417
|
|
|
374
418
|
---
|
|
375
419
|
|
package/dist/index.js
CHANGED
|
@@ -37,9 +37,21 @@ Usage - deterministic, FREE:
|
|
|
37
37
|
synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
|
|
38
38
|
synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
|
|
39
39
|
synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
|
|
41
|
+
Usage - governance (deterministic, FREE):
|
|
42
|
+
synthesisui adopt [--write] turn the design system you ALREADY have into a contract
|
|
43
|
+
your agent follows - without touching your CSS
|
|
44
|
+
synthesisui connect wire your agent: the check as an editor hook, the system
|
|
45
|
+
as MCP tools, and a contract that stops repeating itself
|
|
46
|
+
synthesisui doctor [paths…] [--verbose] audit for DRIFT: every design value written by hand, the
|
|
47
|
+
token your system already has for it, coherence, and the
|
|
48
|
+
record over time
|
|
49
|
+
synthesisui request [component|token] the queue of what your agent needed and the system
|
|
50
|
+
refused to invent (--done <id> closes one)
|
|
51
|
+
synthesisui sync send the local record - checks, fixes, open requests -
|
|
52
|
+
to your system's dashboard (by hand, never automatic)
|
|
53
|
+
synthesisui hook the check itself; installed by connect, run by your editor
|
|
54
|
+
synthesisui mcp the system as tools; installed by connect, run by your agent
|
|
43
55
|
|
|
44
56
|
Usage - AI, USES CREDITS (login required):
|
|
45
57
|
synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
|
|
@@ -73,12 +85,15 @@ Options:
|
|
|
73
85
|
-h, --help this help
|
|
74
86
|
|
|
75
87
|
Examples:
|
|
88
|
+
synthesisui adopt # you already have a system: start here
|
|
76
89
|
synthesisui login
|
|
77
90
|
synthesisui init --target next
|
|
78
91
|
synthesisui init --target next --ds halogen bootstrap + bring a system in
|
|
79
92
|
synthesisui doctor
|
|
80
93
|
synthesisui doctor apps/web packages/ui # scope the read in a monorepo
|
|
81
94
|
synthesisui doctor --strict
|
|
95
|
+
synthesisui connect # before opening your agent
|
|
96
|
+
synthesisui sync # after a session, or from CI
|
|
82
97
|
synthesisui list
|
|
83
98
|
synthesisui add halogen
|
|
84
99
|
synthesisui add halogen --version 3
|
package/dist/motion.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion intent, classified from the frames alone - the CLI copy.
|
|
3
|
+
*
|
|
4
|
+
* The web side (`apps/web/src/lib/ds/motion-vocabulary.ts`) uses this exact
|
|
5
|
+
* logic to decide the clock behind each `animate-<name>` utility it compiles
|
|
6
|
+
* into theme.css. The GUIDE this package writes has to DESCRIBE those same
|
|
7
|
+
* utilities, and the CLI is zero-dependency, so the logic exists twice - like
|
|
8
|
+
* the ledger's summarize. The web spec imports both copies and fails on drift.
|
|
9
|
+
*
|
|
10
|
+
* Intent from geometry:
|
|
11
|
+
* entrance starts off-identity, lands on identity → runs once, fills both
|
|
12
|
+
* exit the reverse → runs once
|
|
13
|
+
* loop round trip, full rotation, or travel with no identity at
|
|
14
|
+
* either end (marquee, shimmer) → ambient, infinite
|
|
15
|
+
* attention a round trip that is a gesture, not a mood (shake, tada)
|
|
16
|
+
* → one shot
|
|
17
|
+
*
|
|
18
|
+
* The one non-geometric input: a round trip is ambiguous (pulse loops, shake
|
|
19
|
+
* does not), and the tiebreak is the keyframe's NAME - authored, part of the
|
|
20
|
+
* contract, not a guess about it.
|
|
21
|
+
*/
|
|
22
|
+
const offsetOf = (key) => {
|
|
23
|
+
if (key === "from")
|
|
24
|
+
return 0;
|
|
25
|
+
if (key === "to")
|
|
26
|
+
return 100;
|
|
27
|
+
const n = Number.parseFloat(key);
|
|
28
|
+
return Number.isNaN(n) ? 0 : n;
|
|
29
|
+
};
|
|
30
|
+
const isIdentityValue = (prop, value) => {
|
|
31
|
+
const v = value.trim();
|
|
32
|
+
if (prop === "opacity")
|
|
33
|
+
return v === "1";
|
|
34
|
+
if (prop === "transform") {
|
|
35
|
+
if (v === "none")
|
|
36
|
+
return true;
|
|
37
|
+
return [...v.matchAll(/([a-zA-Z]+)\(([^)]*)\)/g)].every(([, fn, args]) => {
|
|
38
|
+
const nums = args.split(",").map((a) => Number.parseFloat(a));
|
|
39
|
+
if (fn.startsWith("translate"))
|
|
40
|
+
return nums.every((n) => n === 0);
|
|
41
|
+
if (fn.startsWith("scale"))
|
|
42
|
+
return nums.every((n) => n === 1);
|
|
43
|
+
if (fn.startsWith("rotate") || fn.startsWith("skew"))
|
|
44
|
+
return nums.every((n) => n === 0);
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
const isIdentityFrame = (decls) => Object.entries(decls).every(([p, v]) => isIdentityValue(p, v));
|
|
51
|
+
const LOOP_NAMES = /pulse|spin|rotate|orbit|float|drift|marquee|breathe|shimmer|sheen|glow|ping|wave|bounce|ambient|scroll/i;
|
|
52
|
+
export function classifyKeyframe(name, frames) {
|
|
53
|
+
const ordered = Object.entries(frames).sort(([a], [b]) => offsetOf(a) - offsetOf(b));
|
|
54
|
+
if (ordered.length < 2)
|
|
55
|
+
return "attention";
|
|
56
|
+
const first = ordered[0][1];
|
|
57
|
+
const last = ordered[ordered.length - 1][1];
|
|
58
|
+
const fullTurn = /rotate\(\s*-?(360|720)deg\s*\)/.test(last.transform ?? "");
|
|
59
|
+
if (fullTurn)
|
|
60
|
+
return "loop";
|
|
61
|
+
const firstId = isIdentityFrame(first);
|
|
62
|
+
const lastId = isIdentityFrame(last);
|
|
63
|
+
if (JSON.stringify(first) === JSON.stringify(last)) {
|
|
64
|
+
// A round trip that never touches rest is a mood hovering around its own
|
|
65
|
+
// state (a flame at 0.7 opacity) → loop. One that departs FROM rest is
|
|
66
|
+
// ambiguous - pulse loops, shake does not - and only there the authored
|
|
67
|
+
// NAME breaks the tie.
|
|
68
|
+
if (!firstId)
|
|
69
|
+
return "loop";
|
|
70
|
+
return LOOP_NAMES.test(name) ? "loop" : "attention";
|
|
71
|
+
}
|
|
72
|
+
if (!firstId && lastId)
|
|
73
|
+
return "entrance";
|
|
74
|
+
if (firstId && !lastId) {
|
|
75
|
+
// Leaving is disappearing: an exit fades out. Rest → offset with the
|
|
76
|
+
// opacity intact is a conveyor starting its lap (marquee), not a goodbye.
|
|
77
|
+
const gone = Number.parseFloat(last.opacity ?? "") === 0;
|
|
78
|
+
return gone ? "exit" : "loop";
|
|
79
|
+
}
|
|
80
|
+
return "loop";
|
|
81
|
+
}
|
|
82
|
+
export const isFullRotation = (frames) => Object.values(frames).some((decls) => /rotate\(\s*-?(360|720)deg\s*\)/.test(decls.transform ?? ""));
|
|
83
|
+
export function animationShorthand(scopedName, intent, fullTurn) {
|
|
84
|
+
switch (intent) {
|
|
85
|
+
case "entrance":
|
|
86
|
+
case "exit":
|
|
87
|
+
return `${scopedName} var(--ds-motion-durations-base, 200ms) var(--ds-motion-easings-standard, ease) both`;
|
|
88
|
+
case "loop":
|
|
89
|
+
return fullTurn
|
|
90
|
+
? `${scopedName} var(--ds-motion-durations-ambient, 1s) linear infinite`
|
|
91
|
+
: `${scopedName} var(--ds-motion-durations-ambient, 2s) var(--ds-motion-easings-gentle, ease-in-out) infinite`;
|
|
92
|
+
case "attention":
|
|
93
|
+
return `${scopedName} var(--ds-motion-durations-slow, 400ms) var(--ds-motion-easings-standard, ease)`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function describeKeyframe(frames) {
|
|
97
|
+
const verbs = new Set();
|
|
98
|
+
for (const decls of Object.values(frames)) {
|
|
99
|
+
if ("opacity" in decls)
|
|
100
|
+
verbs.add("fades");
|
|
101
|
+
const t = decls.transform ?? "";
|
|
102
|
+
if (/translate/.test(t))
|
|
103
|
+
verbs.add("slides");
|
|
104
|
+
if (/scale/.test(t))
|
|
105
|
+
verbs.add("scales");
|
|
106
|
+
if (/rotate/.test(t))
|
|
107
|
+
verbs.add("rotates");
|
|
108
|
+
if (/skew/.test(t))
|
|
109
|
+
verbs.add("skews");
|
|
110
|
+
for (const prop of Object.keys(decls))
|
|
111
|
+
if (prop !== "opacity" && prop !== "transform")
|
|
112
|
+
verbs.add(`moves ${prop}`);
|
|
113
|
+
}
|
|
114
|
+
return [...verbs].join(", ") || "animates";
|
|
115
|
+
}
|
package/package.json
CHANGED