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 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
@@ -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,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;
@@ -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\`/\`animation\` (e.g. \`transition: color var(--ds-motion-durations-fast) var(--ds-motion-easings-standard)\`)
369
- so timing stays on-brand. The DS ships timing tokens, **not** a runtime - for entrance/reveal/stagger
370
- pair them with a motion lib (e.g. \`motion\`/Framer) or CSS \`@keyframes\`.
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
- synthesisui doctor [paths…] [--verbose] audit for DRIFT: every design value written by
41
- hand, the token your system already has for it, and the
42
- laws your system carries for what you use
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.25",
3
+ "version": "0.16.27",
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": {