synthesisui 0.16.26 → 0.16.28

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.
@@ -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. */
@@ -59,6 +59,34 @@ 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
+ // `]` ends the value too: Tailwind's arbitrary syntax (`[animation:...]`)
73
+ // closes with it, and without the stop the "value" swallowed the rest of the
74
+ // className - first real report blamed classes that sat beside the animation.
75
+ const MOTION_PROP = /\b(?:transition|animation)(?:-(?:duration|delay|timing-function)|Duration|Delay|TimingFunction)?\s*:\s*["'`]?([^;}\]"'`\n]*)/g;
76
+ // Trailing guard instead of `\b`: in arbitrary syntax spaces are `_`, which is
77
+ // a word char, so `200ms_ease` failed `\b` and the hand-written clock passed
78
+ // unseen (found on the generated button.tsx, first field run of the family).
79
+ const TIME_LITERAL = /(\d*\.?\d+)(ms|s)(?![a-zA-Z0-9%])/g;
80
+ const BEZIER = /cubic-bezier\([^)]*\)/g;
81
+ /** `duration-300`, `duration-[350ms]`, `delay-75` - Tailwind's stock clock. */
82
+ const TW_TIME = /\b(?:duration|delay)-(?:\[([^\]]+)\]|(\d+)\b)/g;
83
+ const TW_EASE = /\bease-\[([^\]]+)\]/g;
84
+ /** `animate-fade-up`, `animate-[wiggle_1s_ease]` - checked against the
85
+ * installed vocabulary, so only when the system ships one. */
86
+ const ANIMATE_UTIL = /\banimate-(\[[^\]]+\]|[a-z0-9-]+)/g;
87
+ /** tailwindcss-animate's grammar (the shadcn bridge): `animate-in`/`-out` are
88
+ * composable micro-transition idiom, not a keyframe selection to police. */
89
+ const ANIMATE_IDIOM = new Set(["none", "in", "out"]);
62
90
  /**
63
91
  * Uses of the system. Coverage is meaningless without them.
64
92
  *
@@ -308,6 +336,68 @@ export function scanSource(file, source, table) {
308
336
  continue;
309
337
  push("font", stack);
310
338
  }
339
+ for (const m of line.matchAll(MOTION_PROP)) {
340
+ const value = m[1];
341
+ const base = (m.index ?? 0) + m[0].length - value.length;
342
+ for (const t of value.matchAll(TIME_LITERAL)) {
343
+ // `0s` is idiom (disabling a transition), not a clock chosen by hand.
344
+ if (Number.parseFloat(t[1]) === 0)
345
+ continue;
346
+ push("motion", `${t[1]}${t[2]}`, base + (t.index ?? 0));
347
+ }
348
+ for (const b of value.matchAll(BEZIER)) {
349
+ push("motion", b[0], base + (b.index ?? 0));
350
+ }
351
+ // A `ds-*` animation name that the installed css never declares renders
352
+ // as NOTHING, silently - the keyframe cousin of a phantom token.
353
+ //
354
+ // Two guards, both from the first field run (soft-test, 29/07): the
355
+ // lookbehind keeps `var(--ds-motion-durations-base)` from reading as a
356
+ // keyframe named after the token (`-` before `ds-` means it is a custom
357
+ // property, not a name); and the name must END on a word - the greedy
358
+ // run used to stop at Tailwind's `_` separator and report a truncated
359
+ // `ds-soft-test-` as phantom while the real keyframe existed.
360
+ if (table.keyframes.size > 0) {
361
+ for (const w of value.matchAll(/(?<![\w-])(ds-[a-z0-9]+(?:-[a-z0-9]+)*)/g)) {
362
+ if (!table.keyframes.has(w[1]))
363
+ phantoms.push({ name: `@keyframes ${w[1]}`, line: at });
364
+ }
365
+ }
366
+ }
367
+ for (const m of line.matchAll(TW_TIME)) {
368
+ const literal = m[1] ?? `${m[2]}ms`;
369
+ // `duration-[var(--ds-motion-durations-base)]` is the token being USED.
370
+ if (literal.startsWith("var("))
371
+ continue;
372
+ if (Number.parseFloat(literal) === 0)
373
+ continue;
374
+ push("motion", literal, m.index ?? -1);
375
+ }
376
+ for (const m of line.matchAll(TW_EASE)) {
377
+ if (m[1].startsWith("var("))
378
+ continue;
379
+ push("motion", m[1], m.index ?? -1);
380
+ }
381
+ // Vocabulary checks exist only when there is a vocabulary: a system that
382
+ // ships no keyframes left nothing to select from, and silence is honest.
383
+ if (table.keyframes.size > 0 && table.slug) {
384
+ const scope = `ds-${table.slug}-`;
385
+ const vocabulary = new Set([...table.keyframes]
386
+ .filter((k) => k.startsWith(scope))
387
+ .map((k) => k.slice(scope.length)));
388
+ for (const m of line.matchAll(ANIMATE_UTIL)) {
389
+ const name = m[1];
390
+ if (name.startsWith("[")) {
391
+ // An arbitrary animation is a whole hand-rolled shorthand in a
392
+ // class - improvisation by definition once a vocabulary exists.
393
+ push("motion", `animate-${name}`, m.index ?? -1);
394
+ continue;
395
+ }
396
+ if (ANIMATE_IDIOM.has(name) || vocabulary.has(name))
397
+ continue;
398
+ push("motion", `animate-${name}`, m.index ?? -1);
399
+ }
400
+ }
311
401
  });
312
402
  return {
313
403
  file,
@@ -328,6 +418,7 @@ export function diagnose(files) {
328
418
  radius: 0,
329
419
  spacing: 0,
330
420
  font: 0,
421
+ motion: 0,
331
422
  };
332
423
  for (const f of flat)
333
424
  counts[f.kind] += 1;
@@ -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
@@ -147,10 +147,18 @@ ${depLines.join("\n")}
147
147
  loop: "ambient loop · infinite - decorative, use sparingly",
148
148
  attention: "attention · one shot, only when asked",
149
149
  };
150
+ // Promise only what the ARTIFACTS actually contain, not what this CLI
151
+ // version knows how to describe. First field run (soft-test, 29/07): a
152
+ // newer CLI wrote "already compiled as ready utilities" against a theme.css
153
+ // an older server had compiled without --animate-* keys - the agent
154
+ // verified `animate-rise` emitted nothing and filed the GUIDE's own promise
155
+ // as a gap. The artifacts travel in this payload; reading them is free.
156
+ const hasAnimateKeys = /--animate-/.test(payload.artifacts["theme.css"] ?? "");
157
+ const hasReducedMotionFloor = Object.values(payload.artifacts).some((css) => /prefers-reduced-motion/.test(css ?? ""));
150
158
  const keyframeLines = Object.entries(motion.keyframes).map(([kname, frames]) => {
151
159
  const kf = frames;
152
160
  const intent = classifyKeyframe(kname, kf);
153
- const usage = hasTailwind
161
+ const usage = hasTailwind && hasAnimateKeys
154
162
  ? `\`animate-${kebab(kname)}\``
155
163
  : `\`animation: ${animationShorthand(`ds-${slug}-${kebab(kname)}`, intent, isFullRotation(kf))}\``;
156
164
  return `- ${usage} - ${INTENT_LINE[intent]} (${describeKeyframe(kf)})`;
@@ -398,7 +406,7 @@ ${keyframeLines.length > 0
398
406
 
399
407
  The base is QUIET: nothing moves until someone asks. When they do, animation is **selection
400
408
  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:
409
+ into ${hasTailwind && hasAnimateKeys ? "`theme.css` as ready utilities" : `\`tokens.css\` as \`@keyframes ds-${slug}-<name>\``}, each on the clock its intent calls for:
402
410
 
403
411
  ${keyframeLines.join("\n")}
404
412
  ${patternLines.length > 0
@@ -409,8 +417,9 @@ ${patternLines.join("\n")}
409
417
  `
410
418
  : ""}
411
419
  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
420
+ plenty; ${hasReducedMotionFloor
421
+ ? "`prefers-reduced-motion` is already honored in the shipped CSS (a floor collapses all\nanimations) - don't undo it"
422
+ : "honor `prefers-reduced-motion` yourself (wrap animations in `motion-safe:` or a media query) - this build of the CSS ships no floor"}. If a request needs a motion this vocabulary lacks, do NOT hand-roll
414
423
  \`@keyframes\` or raw durations: file it (\`request_token\` via MCP, or \`synthesisui request\`) and
415
424
  tell the person what you chose from the vocabulary instead.`
416
425
  : ""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.26",
3
+ "version": "0.16.28",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {