synthesisui 0.16.113 → 0.16.118

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.
@@ -30,6 +30,7 @@
30
30
  * of the three outcomes this pipeline has paid for twice.
31
31
  */
32
32
  import { transcribe, } from "./doctor/transcribe.js";
33
+ import { frontierKind } from "./frontier-kind.js";
33
34
  /** The ten forms, closed. A renderer that accepts any tag draws anything. */
34
35
  const FORMS = new Set([
35
36
  "image",
@@ -295,6 +296,28 @@ sketch) {
295
296
  if (typeof node.refName === "string" && node.refName.trim())
296
297
  node_.refName = node.refName.trim();
297
298
  }
299
+ else if (typeof node.at === "number") {
300
+ /**
301
+ * OR OFF THE SKETCH, WHICH IS THE CASE THAT ACTUALLY HAPPENS.
302
+ *
303
+ * The deriver stamps this on the read; an AUTHORED reading never does - it names
304
+ * `trigger` at index 1 and says nothing about `Collapsible.Trigger`, because it
305
+ * has no reason to. So the snippet kept emitting `<div>` on the very path the
306
+ * owner runs, and I read my own probe as proof of the opposite (dono, 04/08).
307
+ *
308
+ * The sketch measured the tag and the package, so this is the same lookup
309
+ * `when` already does one line up. Gated on HEADLESS: an opaque library is named
310
+ * and stopped at, and a plain `div` is nobody's element.
311
+ */
312
+ const measured = sketch?.[node.at];
313
+ if (measured?.from &&
314
+ measured.tag &&
315
+ /^[A-Z]/.test(measured.tag) &&
316
+ frontierKind(measured.from) === "headless") {
317
+ node_.from = measured.from;
318
+ node_.refName = measured.tag;
319
+ }
320
+ }
298
321
  // The NAME is what carries the styles, so it is claimed even when nothing
299
322
  // resolved: the tree triggers on presence, and structure is most of the
300
323
  // value. A node with no name is pure structure and that is legitimate.
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { emitCn, readVocabulary } from "../cn-codegen.js";
@@ -84,6 +85,34 @@ export async function component(slug, name, opts) {
84
85
  .filter(Boolean)
85
86
  .map((p) => p[0].toUpperCase() + p.slice(1))
86
87
  .join("");
88
+ /**
89
+ * THEY ALREADY HAVE THIS COMPONENT, and the recipe says where.
90
+ *
91
+ * `findCollision` guards against shadowing by walking `packages` three levels down;
92
+ * their `CollapsibleCard` sits six levels below it, so the guard never reached it and
93
+ * `component` would write a second, worse copy of a component in their own
94
+ * `packages/ui`. The recipe carries the path now - repo-relative, so this is one
95
+ * `stat` rather than a walk - and the import line their own manifest declares.
96
+ *
97
+ * Only refuses when the file is actually HERE: the same system installed in another
98
+ * project has no such component, and there generating is exactly right.
99
+ */
100
+ const theirs = res.recipe?.api;
101
+ const already = theirs?.file && existsSync(join(root, theirs.file)) ? theirs : null;
102
+ if (already && !opts.force) {
103
+ console.log(section("You already have this component"));
104
+ console.log(body(`\`${already.name}\` lives in ${already.file} - writing another one would leave you with two.`));
105
+ if (already.from) {
106
+ console.log("");
107
+ console.log(snippet([`import { ${already.name} } from "${already.from}";`]));
108
+ }
109
+ else if (already.exported === false) {
110
+ console.log(body(`It is not re-exported from that folder's entry, so import it by path.`));
111
+ }
112
+ console.log("");
113
+ console.log(body("The recipe and the compiled CSS are on disk either way - only the .tsx was not written. Pass --force to write it anyway."));
114
+ return;
115
+ }
87
116
  const clash = await findCollision(root, config.componentsDir, res.name, pascalName);
88
117
  if (!opts.force && (clash.exported || (clash.file && !clash.ours))) {
89
118
  console.log(section("This name is already taken in your project"));
@@ -13,17 +13,20 @@ import { describeGate, gateComponent, groupSkips, SCREENS_FOR_SYSTEM, } from "..
13
13
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
14
14
  import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
15
15
  import { crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } from "../doctor/crosswalk.js";
16
- import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
16
+ import { keyframeOffsets, moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
17
17
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
18
18
  import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
19
19
  import { callSiteNode, placeLayers, } from "../doctor/place-layers.js";
20
+ import { publicApi, requiredProps } from "../doctor/public-api.js";
20
21
  import { diagnose, scanSource } from "../doctor/scan.js";
21
22
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
22
23
  import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "../doctor/signals.js";
23
24
  import { sketchOf } from "../doctor/sketch.js";
25
+ import { describeForeign, readStyleIslands, } from "../doctor/style-island.js";
24
26
  import { buildTable } from "../doctor/tokens.js";
25
27
  import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
26
28
  import { transcribeVariants } from "../doctor/variant-read.js";
29
+ import { frontierKind } from "../frontier-kind.js";
27
30
  import { withLibraryStructure } from "../library-structure.js";
28
31
  import { body, paint, section } from "../output.js";
29
32
  import { phase, startProgress } from "../progress.js";
@@ -305,7 +308,20 @@ export async function takeCensus(root, opts) {
305
308
  /** How much came out of CSS Modules, for the coverage report. */
306
309
  let moduleFiles = 0;
307
310
  let moduleDeclarations = 0;
308
- const moduleKeyframes = {};
311
+ /**
312
+ * THE KEYFRAMES THE READING NEEDS, by name - and this was collected and dropped.
313
+ *
314
+ * Nothing read `moduleKeyframes`: an app whose animation lives in a CSS module lost it
315
+ * in silence, and the four Tailwind ships had nowhere to travel either, so
316
+ * `animation: var(--animate-spin)` would have pointed at a keyframe the system does not
317
+ * define (dono, 04/08). The document's shape is offset → declarations, which is what
318
+ * `keyframeOffsets` turns their raw body into and what `BUILTIN_KEYFRAMES` already is.
319
+ */
320
+ const keyframes = {};
321
+ /** Names their `animate-*` utilities point at - see `Transcription.animations`. */
322
+ const animations = new Set();
323
+ /** Per component, the rules a `<style>` island aims at somebody else's markup. */
324
+ const islands = new Map();
309
325
  /** How each component expresses style, so the census can state its own coverage. */
310
326
  const shapes = new Map();
311
327
  /** Component FILES the gate accepted - the honest denominator for coverage. A first
@@ -483,8 +499,9 @@ export async function takeCensus(root, opts) {
483
499
  moduleFiles += 1;
484
500
  moduleDeclarations += readModule.read;
485
501
  for (const [name, frames] of Object.entries(readModule.keyframes)) {
486
- if (!moduleKeyframes[name])
487
- moduleKeyframes[name] = frames;
502
+ const offsets = keyframeOffsets(frames);
503
+ if (!keyframes[name] && Object.keys(offsets).length > 0)
504
+ keyframes[name] = offsets;
488
505
  }
489
506
  // One module per component is the shape every one of the 530 follows.
490
507
  break;
@@ -526,6 +543,83 @@ export async function takeCensus(root, opts) {
526
543
  * the owner's rule, adopted verbatim (dono, 01/08).
527
544
  */
528
545
  const sketch = sketchOf(src, found[0].name);
546
+ /**
547
+ * THE CSS THE COMPONENT CARRIES INSIDE ITSELF.
548
+ *
549
+ * Their `TypingIndicator` styles with a `<style>` element and nothing else: read as
550
+ * utilities it arrived with three declarations and no animation, so the component
551
+ * that IS an animation previewed as three static circles (dono, 04/08). Three kinds
552
+ * of content, three destinations - see `readStyleIslands`.
553
+ */
554
+ const writes = new Set();
555
+ for (const node of sketch)
556
+ for (const cls of (node.classes ?? "").split(/\s+/))
557
+ if (cls)
558
+ writes.add(cls);
559
+ const island = readStyleIslands(src, writes);
560
+ /** Sketch index → what the island declares for that element. */
561
+ const islandStyle = {};
562
+ for (const [name, frames] of Object.entries(island.keyframes)) {
563
+ keyframes[name] ??= frames;
564
+ animations.add(name);
565
+ }
566
+ /**
567
+ * A PATCH OVER SOMEBODY ELSE'S MARKUP IS A LAW, never a part: giving
568
+ * `.sketch-picker input` a part of ours would claim we drew a colour wheel.
569
+ */
570
+ /**
571
+ * A RULE ON A CLASS THEY WRITE IS THE LOOK OF THE PART THAT WEARS IT.
572
+ *
573
+ * Their three dots are three sketch nodes all carrying `class="dot"`, and the
574
+ * anatomy already named them `dot`, `dot-2`, `dot-3` - three parts with EMPTY
575
+ * bases, because a utility reader has nothing to read on them. The island's `.dot`
576
+ * rule is their shared look and `:nth-child(1..3)` is the delay that makes the
577
+ * wave, one per part, in the order the nodes appear.
578
+ *
579
+ * Carried on the look and merged when the anatomy resolves, which is the only
580
+ * moment a class maps to a part name - the same seam the per-part layers use.
581
+ */
582
+ if (island.own.length > 0) {
583
+ const seen = new Map();
584
+ for (const [index, node] of sketch.entries()) {
585
+ for (const cls of (node.classes ?? "").split(/\s+/)) {
586
+ if (!cls)
587
+ continue;
588
+ const nth = (seen.get(cls) ?? 0) + 1;
589
+ seen.set(cls, nth);
590
+ const mine = island.own.filter((rule) => rule.class === cls && (rule.nth ?? nth) === nth);
591
+ if (mine.length === 0)
592
+ continue;
593
+ islandStyle[index] = Object.assign(islandStyle[index] ?? {}, ...mine.map((rule) => rule.declarations));
594
+ }
595
+ }
596
+ }
597
+ if (island.foreign.length > 0) {
598
+ /**
599
+ * WHOSE MARKUP IS BEING PATCHED, measured rather than guessed: the opaque
600
+ * package this component composes. A headless one renders no markup of its own
601
+ * to patch.
602
+ */
603
+ const drawn = sketch.find((node) => node.from && frontierKind(node.from) === "opaque")?.from;
604
+ islands.set(found[0].name, { foreign: island.foreign, drawn });
605
+ }
606
+ /**
607
+ * THE ANIMATIONS THIS COMPONENT NAMES, from the whole sketch rather than from the
608
+ * root's transcription - an `animate-ping` lives on a part three levels down and
609
+ * the resolver that reads a part does not report back up.
610
+ *
611
+ * The NAME only. The platform owns a library of frames and gives each one this
612
+ * system's own clock; a name it cannot define becomes a dangling reference the
613
+ * screen already knows how to say out loud, which beats a silent `animation` that
614
+ * animates nothing.
615
+ */
616
+ for (const node of sketch) {
617
+ for (const raw of (node.classes ?? "").split(/\s+/)) {
618
+ const named = /^(?:[\w-]+:)*animate-([a-z][\w-]*)$/.exec(raw);
619
+ if (named && named[1] !== "none")
620
+ animations.add(named[1]);
621
+ }
622
+ }
529
623
  /**
530
624
  * A PER-PART LAYER FINDS ITS NODE, by the class string of the call site that
531
625
  * consumes the field. See `ReadLayer.onClasses`: the reader knows a
@@ -566,6 +660,7 @@ export async function takeCensus(root, opts) {
566
660
  ...(tag ? { rootTag: tag } : {}),
567
661
  ...rootPackage(tag, sketch),
568
662
  ...(placed.length > 0 ? { layers: placed } : {}),
663
+ ...(Object.keys(islandStyle).length > 0 ? { islandStyle } : {}),
569
664
  ...(v.unslotted.length > 0
570
665
  ? { unplaced: [...new Set(v.unslotted)] }
571
666
  : {}),
@@ -1013,6 +1108,19 @@ export async function takeCensus(root, opts) {
1013
1108
  * happens, which is the whole reason `--usage` exists.
1014
1109
  */
1015
1110
  const laws = new Map();
1111
+ /**
1112
+ * A PATCH OVER SOMEBODY ELSE'S MARKUP IS A LAW, and it is written here because this is
1113
+ * where laws are collected - giving `.sketch-picker input` a part of ours would claim we
1114
+ * drew a colour wheel. See `readStyleIslands`.
1115
+ */
1116
+ for (const [name, read] of islands) {
1117
+ const line = describeForeign(read.foreign, read.drawn);
1118
+ if (!line)
1119
+ continue;
1120
+ const own = laws.get(name) ?? [];
1121
+ own.push(line);
1122
+ laws.set(name, own);
1123
+ }
1016
1124
  for (const r of observedRules(merged)) {
1017
1125
  laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
1018
1126
  }
@@ -1083,6 +1191,42 @@ export async function takeCensus(root, opts) {
1083
1191
  for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
1084
1192
  declaredNames.add(m[1]);
1085
1193
  }
1194
+ /**
1195
+ * HOW SOMEBODY CALLS THE COMPONENT THEY ALREADY HAVE.
1196
+ *
1197
+ * The usage panel was handing people the markup of a component rebuilt out of our
1198
+ * compiled classes, for a component sitting in their own `packages/ui` with a name and
1199
+ * props - "você inventou diversas classes que não existem" (dono, 04/08), and the half
1200
+ * that lands is that those classes appear nowhere in his repo. He has
1201
+ * `<CollapsibleCard>`; the usage is its props.
1202
+ *
1203
+ * Measured, never inferred: the specifier is the name in THEIR manifest for the folder
1204
+ * being imported, and a component only carries it when the entry actually re-exports
1205
+ * the name - 35 of their 36, with `TypingIndicator` internal to `Message` and reported
1206
+ * as not exported. See `publicApi`.
1207
+ */
1208
+ const api = await publicApi(root, scopeLabel);
1209
+ if (api) {
1210
+ for (const c of components) {
1211
+ const file = defined.find((d) => d.name === c.name)?.file;
1212
+ const source = file
1213
+ ? sources.find((s) => s.file.endsWith(file))?.source
1214
+ : undefined;
1215
+ const required = source ? requiredProps(source, c.name) : [];
1216
+ c.api = {
1217
+ ...(api.exports.has(c.name) ? { from: api.from } : { exported: false }),
1218
+ /**
1219
+ * REPO-RELATIVE, so the answer needs no second search. `defined[].file` is
1220
+ * relative to the scope; joined with the scope it is a path anybody can check
1221
+ * with one `stat` - which is how `component` knows they already have it without
1222
+ * walking a monorepo (their component sits six levels below `packages/`, and the
1223
+ * shallow walk that guards against shadowing never reached it).
1224
+ */
1225
+ ...(file ? { file: `${scopeLabel}/${file}` } : {}),
1226
+ ...(required.length > 0 ? { required } : {}),
1227
+ };
1228
+ }
1229
+ }
1086
1230
  const brokenRefs = findBrokenRefs(sources, declaredNames);
1087
1231
  const conventions = detectConventions(sources);
1088
1232
  const classStyle = detectClassStyle(sources);
@@ -1090,6 +1234,8 @@ export async function takeCensus(root, opts) {
1090
1234
  census: 1,
1091
1235
  project: { name, stack: await detectStack(root) },
1092
1236
  declared: Object.fromEntries(table.byName),
1237
+ ...(Object.keys(keyframes).length > 0 ? { keyframes } : {}),
1238
+ ...(animations.size > 0 ? { animations: [...animations].sort() } : {}),
1093
1239
  ...(brokenRefs.length > 0 ? { brokenRefs } : {}),
1094
1240
  ...(conventions.length > 0 ? { conventions } : {}),
1095
1241
  classStyle,
@@ -1866,6 +2012,26 @@ async function resolveReadParts(census, root) {
1866
2012
  const split = placeLayers((look?.layers ?? []), partAt, (look?.sketch ?? []));
1867
2013
  for (const line of split.unplaced)
1868
2014
  notes.add(line);
2015
+ /**
2016
+ * WHAT A `<style>` ISLAND DECLARED, ONTO THE PART THAT WEARS IT.
2017
+ *
2018
+ * Their three dots share `class="dot"` and the anatomy named them `dot`, `dot-2`,
2019
+ * `dot-3` - three parts with empty bases, because a utility reader has nothing to read
2020
+ * on a component that styles itself in CSS. This is the first moment an index maps to a
2021
+ * part name, which is why it happens here and not where the island was read.
2022
+ *
2023
+ * A utility of theirs on the same element still wins: the island only fills what the
2024
+ * class string did not already say.
2025
+ */
2026
+ for (const [index, style] of Object.entries(look?.islandStyle ?? {})) {
2027
+ const part = partAt[Number(index)];
2028
+ const target = part ? resolved.parts[part] : undefined;
2029
+ if (!target) {
2030
+ notes.add("a `<style>` element declares a look for an element the anatomy does not name - it is not applied, because that would paint the wrong thing");
2031
+ continue;
2032
+ }
2033
+ target.base = { ...style, ...target.base };
2034
+ }
1869
2035
  /**
1870
2036
  * REPLACED, NOT APPENDED - because this runs more than once.
1871
2037
  *
@@ -74,7 +74,7 @@ export function partName(authored) {
74
74
  .replace(/^-|-$/g, "");
75
75
  }
76
76
  /** Comments out, so a `{` inside one cannot end a block early. */
77
- function stripCssComments(css) {
77
+ export function stripCssComments(css) {
78
78
  return css.replace(/\/\*[\s\S]*?\*\//g, "");
79
79
  }
80
80
  function rules(css, media = null, out = []) {
@@ -168,6 +168,40 @@ function emptyClass() {
168
168
  * same way the utility reader does - so the value travels as `{radius.md}` rather than
169
169
  * as a literal, and the recipe re-themes.
170
170
  */
171
+ /**
172
+ * A KEYFRAME'S OWN OFFSETS, so the document can hold it.
173
+ *
174
+ * `keyframes` carried the body as raw CSS text and it was collected and then dropped -
175
+ * `moduleKeyframes` in `import.ts` had no reader at all, so an app whose animation lives
176
+ * in a CSS module lost it in silence (dono, 04/08). The document's shape is offset →
177
+ * declarations, which is the same rule parser one level down.
178
+ */
179
+ export function keyframeOffsets(body) {
180
+ const out = {};
181
+ for (const rule of rules(stripCssComments(body))) {
182
+ /**
183
+ * `0%,\n 100%` is one offset written across two lines. Kept verbatim it becomes a
184
+ * selector with a newline in it, which the compiler emits and the browser ignores.
185
+ */
186
+ const offset = rule.selector.replace(/\s+/g, " ").trim();
187
+ if (!offset)
188
+ continue;
189
+ const block = {};
190
+ for (const line of rule.body.split(";")) {
191
+ const at = line.indexOf(":");
192
+ if (at === -1)
193
+ continue;
194
+ const prop = line.slice(0, at).trim();
195
+ const value = line.slice(at + 1).trim();
196
+ if (!prop || !value)
197
+ continue;
198
+ block[prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
199
+ }
200
+ if (Object.keys(block).length > 0)
201
+ out[offset] = block;
202
+ }
203
+ return out;
204
+ }
171
205
  export function readModuleCss(css) {
172
206
  const out = { classes: {}, keyframes: {}, unslotted: [] };
173
207
  const clean = stripCssComments(css);
@@ -0,0 +1,119 @@
1
+ /**
2
+ * HOW SOMEBODY CALLS THE COMPONENT THEY ALREADY HAVE.
3
+ *
4
+ * The usage panel was emitting the markup of a component rebuilt out of our compiled
5
+ * classes - `<div className="ds-collapsible-card-trigger">` and twenty lines like it. The
6
+ * owner's objection was exact (dono, 04/08): "você inventou diversas classes que não
7
+ * existem". They exist in our stylesheet and they exist NOWHERE in his repo, which is the
8
+ * half that matters. He has `<CollapsibleCard>`; the usage is its props.
9
+ *
10
+ * That question has a measured answer and we were not reading it:
11
+ *
12
+ * the package name their own `package.json` for the scope being imported
13
+ * is it exported follow `export * from` out of the entry - 35 of their 36
14
+ * which props the destructuring, already read (36 of 36)
15
+ * which are required their `interface XProps` - 23 of 38 files declare one
16
+ *
17
+ * Nothing here is inferred. A component the barrel does not reach is reported as not
18
+ * exported, which is a fact worth having - their `TypingIndicator` is internal to
19
+ * `Message`, and telling somebody to import it would be the invention.
20
+ */
21
+ import { readFile } from "node:fs/promises";
22
+ import { dirname, join, normalize } from "node:path";
23
+ const NAMED = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
24
+ const DECLARED = /export\s+(?:const|function|class)\s+([A-Za-z_$][\w$]*)/g;
25
+ const STAR = /export\s+\*\s+from\s*["']([^"']+)["']/g;
26
+ async function readAt(base, rel) {
27
+ for (const candidate of [
28
+ rel,
29
+ `${rel}.ts`,
30
+ `${rel}.tsx`,
31
+ join(rel, "index.ts"),
32
+ join(rel, "index.tsx"),
33
+ ]) {
34
+ const path = normalize(join(base, candidate));
35
+ const source = await readFile(path, "utf8").catch(() => null);
36
+ if (source != null)
37
+ return [path, source];
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * Every name the entry exposes, following `export *` as far as it goes.
43
+ *
44
+ * Depth-capped and cycle-guarded: a barrel chain is somebody's folder layout, and
45
+ * `packages/ui` reaches its components through three of them.
46
+ */
47
+ async function exportsOf(path, source, depth = 0, seen = new Set()) {
48
+ const out = new Set();
49
+ if (depth > 6 || seen.has(path))
50
+ return out;
51
+ seen.add(path);
52
+ NAMED.lastIndex = 0;
53
+ for (const m of source.matchAll(NAMED)) {
54
+ for (const part of m[1].split(",")) {
55
+ const name = part.split(" as ").pop()?.trim();
56
+ if (name && name !== "default")
57
+ out.add(name);
58
+ }
59
+ }
60
+ DECLARED.lastIndex = 0;
61
+ for (const m of source.matchAll(DECLARED))
62
+ out.add(m[1]);
63
+ STAR.lastIndex = 0;
64
+ for (const m of source.matchAll(STAR)) {
65
+ const next = await readAt(dirname(path), m[1]);
66
+ if (!next)
67
+ continue;
68
+ for (const name of await exportsOf(next[0], next[1], depth + 1, seen))
69
+ out.add(name);
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * The public API of the folder being imported, or null when it has no manifest.
75
+ *
76
+ * `root` is the repository, `scope` the folder the census measured (`packages/ui`). A
77
+ * scope with no `package.json` of its own is not a package and there is no specifier to
78
+ * offer - an app's `src/components` is imported by path, which is theirs to know and not
79
+ * ours to guess.
80
+ */
81
+ export async function publicApi(root, scope) {
82
+ if (!scope)
83
+ return null;
84
+ const manifest = await readFile(join(root, scope, "package.json"), "utf8")
85
+ .then((raw) => JSON.parse(raw))
86
+ .catch(() => null);
87
+ if (!manifest?.name)
88
+ return null;
89
+ const entry = manifest.main ?? manifest.module ?? "src/index.ts";
90
+ const found = await readAt(join(root, scope), entry);
91
+ if (!found)
92
+ return { from: manifest.name, exports: new Set() };
93
+ return {
94
+ from: manifest.name,
95
+ exports: await exportsOf(found[0], found[1]),
96
+ };
97
+ }
98
+ const PROPS_BLOCK = /(?:export\s+)?(?:interface|type)\s+(\w*Props)\s*(?:=\s*)?\{([\s\S]*?)\n\}/;
99
+ /**
100
+ * WHICH PROPS THEIR TYPE MARKS AS REQUIRED.
101
+ *
102
+ * The one thing that makes a call snippet copy-pasteable: `title: string` has to be
103
+ * filled and `subtitle?: string` does not. 23 of their 38 files declare a readable
104
+ * `interface XProps`; the rest extend a library's type or use generics, and there we say
105
+ * nothing rather than guessing which props somebody must pass.
106
+ */
107
+ export function requiredProps(source, name) {
108
+ const scoped = new RegExp(`(?:export\\s+)?(?:interface|type)\\s+${name}Props\\s*(?:=\\s*)?\\{([\\s\\S]*?)\\n\\}`).exec(source);
109
+ const body = scoped?.[1] ?? PROPS_BLOCK.exec(source)?.[2];
110
+ if (!body)
111
+ return [];
112
+ const out = [];
113
+ for (const line of body.split("\n")) {
114
+ const m = /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)(\?)?\s*:/.exec(line);
115
+ if (m && !m[2])
116
+ out.push(m[1]);
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * THE CSS A COMPONENT CARRIES INSIDE ITSELF.
3
+ *
4
+ * Some components do not style with utilities at all - they render a `<style>` element
5
+ * with real CSS in it. Their `TypingIndicator` is the whole case: three dots, one
6
+ * `@keyframes wave-bounce`, and a `.dot` rule with a per-dot delay. Read as utilities it
7
+ * arrived with three declarations and no animation, so the component that IS an animation
8
+ * previewed as three static circles (dono, 04/08).
9
+ *
10
+ * Measured across their repository: 2 islands, both in `packages/ui`, and none in any of
11
+ * the three apps - 1 keyframe, 6 rules on classes the component itself writes, 3 on a
12
+ * third party's internals, and 12 `!important`.
13
+ *
14
+ * THREE KINDS OF CONTENT, THREE DESTINATIONS, and telling them apart is the whole job:
15
+ *
16
+ * @keyframes the system's motion vocabulary, by name
17
+ * a rule on a class THEY write the look of the part that wears that class
18
+ * a rule on somebody else's a patch over a third party: prose, never a look
19
+ *
20
+ * The last one is the line. `.sketch-picker input { background: … !important }` is not
21
+ * their component's anatomy - it is a re-skin of `react-color`'s own markup - and giving
22
+ * it a part would claim we drew something we did not. It travels as a law instead, with
23
+ * the package named and the `!important` said out loud.
24
+ */
25
+ import { keyframeOffsets, readModuleCss, stripCssComments, } from "./css-modules.js";
26
+ /** `<style>{`…`}</style>` and `<style>…</style>` - the two spellings in JSX. */
27
+ const ISLAND = /<style[^>]*>\s*\{?\s*(?:`([\s\S]*?)`|([\s\S]*?))\s*\}?\s*<\/style>/g;
28
+ /** `.dot:nth-child(2)` → the class and which one. A compound selector is foreign. */
29
+ const OWN_SELECTOR = /^\.([\w-]+)(?::nth-child\((\d+)\))?$/;
30
+ /**
31
+ * The CSS a component keeps inside itself, split by where each rule belongs.
32
+ *
33
+ * `writes` is the set of classes the component's own markup carries - from the sketch,
34
+ * which measured them. A selector naming one of those is about an element we know; any
35
+ * other selector is about markup somebody else renders.
36
+ */
37
+ export function readStyleIslands(source, writes) {
38
+ const out = { keyframes: {}, own: [], foreign: [] };
39
+ ISLAND.lastIndex = 0;
40
+ for (const island of source.matchAll(ISLAND)) {
41
+ const raw = island[1] ?? island[2] ?? "";
42
+ if (!raw.trim())
43
+ continue;
44
+ /**
45
+ * COMMENTS OUT FIRST. Their `.dot` rule carries `/* Cor ocean-600 *``/` between two
46
+ * declarations, and the split-on-semicolon read it as part of the NEXT property's
47
+ * name - so `borderRadius` arrived as `/* Cor … *``/\n borderRadius`.
48
+ */
49
+ const body = stripCssComments(raw);
50
+ const read = readModuleCss(body);
51
+ for (const [name, frames] of Object.entries(read.keyframes)) {
52
+ const offsets = keyframeOffsets(frames);
53
+ if (Object.keys(offsets).length > 0)
54
+ out.keyframes[name] ??= offsets;
55
+ }
56
+ /**
57
+ * `readModuleCss` files a rule under each class it names, which is what a CSS module
58
+ * needs - a class there IS the export. Here the SELECTOR decides, because
59
+ * `.dark .sketch-picker span` names a class we write (`dark`) and is still about
60
+ * somebody else's span. So the rules are re-read off the body.
61
+ */
62
+ for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
63
+ const selector = rule[1].trim().split("\n").pop()?.trim() ?? "";
64
+ if (!selector || selector.startsWith("@"))
65
+ continue;
66
+ // An offset inside a keyframe body is not a rule about an element.
67
+ if (/^(?:from|to|\d+%)(?:\s*,\s*(?:from|to|\d+%))*$/.test(selector))
68
+ continue;
69
+ const declarations = {};
70
+ let important = 0;
71
+ for (const line of rule[2].split(";")) {
72
+ const at = line.indexOf(":");
73
+ if (at === -1)
74
+ continue;
75
+ const property = line.slice(0, at).trim();
76
+ let value = line.slice(at + 1).trim();
77
+ if (!property || !value)
78
+ continue;
79
+ if (/!important$/.test(value)) {
80
+ important += 1;
81
+ value = value.replace(/\s*!important$/, "");
82
+ }
83
+ declarations[property.replace(/-([a-z])/g, (_m, c) => c.toUpperCase())] = value;
84
+ }
85
+ if (Object.keys(declarations).length === 0)
86
+ continue;
87
+ /**
88
+ * THEIR `animation: wave-bounce 1s infinite` NAMES A KEYFRAME THE SYSTEM OWNS.
89
+ *
90
+ * The compiler scopes every keyframe and mints `--animate-<name>` for it, so a raw
91
+ * name in a recipe points at nothing. The reference replaces the name and their own
92
+ * timing stays as longhands beside it - which is what makes the wave a wave.
93
+ */
94
+ if (declarations.animation) {
95
+ const named = /^([\w-]+)\s*(.*)$/.exec(declarations.animation.trim());
96
+ if (named && out.keyframes[named[1]]) {
97
+ declarations.animation = `var(--animate-${named[1]})`;
98
+ for (const part of named[2].split(/\s+/).filter(Boolean)) {
99
+ if (/^\d+(?:\.\d+)?m?s$/.test(part))
100
+ declarations.animationDuration ??= part;
101
+ else if (part === "infinite" || /^\d+$/.test(part))
102
+ declarations.animationIterationCount ??= part;
103
+ else if (/^(linear|ease|ease-in|ease-out|ease-in-out|steps\(|cubic-bezier\()/.test(part))
104
+ declarations.animationTimingFunction ??= part;
105
+ }
106
+ }
107
+ }
108
+ const own = OWN_SELECTOR.exec(selector);
109
+ if (own && writes.has(own[1])) {
110
+ out.own.push({
111
+ class: own[1],
112
+ ...(own[2] ? { nth: Number(own[2]) } : {}),
113
+ declarations,
114
+ });
115
+ continue;
116
+ }
117
+ out.foreign.push({
118
+ selector,
119
+ declarations: Object.keys(declarations).length,
120
+ important,
121
+ });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ /**
127
+ * What a patch over somebody else's component is, said in one line.
128
+ *
129
+ * A law rather than a look: an agent reading this system needs to know the re-skin exists
130
+ * and that it leans on `!important`, and no part of ours should claim to have drawn it.
131
+ */
132
+ export function describeForeign(rules, library) {
133
+ if (rules.length === 0)
134
+ return null;
135
+ const declarations = rules.reduce((n, r) => n + r.declarations, 0);
136
+ const important = rules.reduce((n, r) => n + r.important, 0);
137
+ const selectors = [...new Set(rules.map((r) => r.selector))].slice(0, 3);
138
+ return (`This component re-skins ${library ?? "a third party's markup"} from the inside: ` +
139
+ `${declarations} declaration${declarations === 1 ? "" : "s"} across ${rules.length} rule${rules.length === 1 ? "" : "s"} ` +
140
+ `in a \`<style>\` element it renders itself, on selectors that are not ours ` +
141
+ `(${selectors.join(", ")})${important > 0 ? `, ${important} of them \`!important\`` : ""}. ` +
142
+ `Those are not part styles and editing this system's tokens will not move them.`);
143
+ }
@@ -90,7 +90,36 @@ const SPACING_PROPERTY = {
90
90
  m: "margin",
91
91
  mx: "marginInline",
92
92
  my: "marginBlock",
93
+ /**
94
+ * THE FOUR SIDES, which had no entry - so `mt-1` read as nothing.
95
+ *
96
+ * On their card it is the 4px between the eyebrow and the title; across the library it
97
+ * is 20 utilities in 12 components. A margin is a spacing decision and the contract
98
+ * always held it; only the table was short (dono, 04/08).
99
+ */
100
+ mt: "marginTop",
101
+ mr: "marginRight",
102
+ mb: "marginBottom",
103
+ ml: "marginLeft",
93
104
  gap: "gap",
105
+ "gap-x": "columnGap",
106
+ "gap-y": "rowGap",
107
+ /**
108
+ * `space-y-1` sets a margin on the CHILDREN, which is not a declaration on the parent.
109
+ * As a row/column gap it is exact wherever the parent arranges - which is every one of
110
+ * their uses - and inert where it does not, so it never paints a wrong pixel.
111
+ */
112
+ "space-y": "rowGap",
113
+ "space-x": "columnGap",
114
+ /** Position offsets - what makes `absolute inset-0` cover its parent. */
115
+ inset: "inset",
116
+ "inset-x": "insetInline",
117
+ "inset-y": "insetBlock",
118
+ top: "top",
119
+ right: "right",
120
+ bottom: "bottom",
121
+ left: "left",
122
+ basis: "flexBasis",
94
123
  /**
95
124
  * SIZE, which the first version had no entry for at all.
96
125
  *
@@ -330,6 +359,149 @@ const TYPE_KEYWORD = {
330
359
  italic: { property: "fontStyle", value: "italic" },
331
360
  underline: { property: "textDecoration", value: "underline" },
332
361
  };
362
+ /**
363
+ * THE DECLARATIONS THAT ARE A WHOLE WORD, and were being dropped as layout.
364
+ *
365
+ * The rule that keeps layout out of a recipe is right about `flex` and `gap` - identical
366
+ * in every design system, and a recipe full of them is a recipe about nothing. It was
367
+ * wrong about everything that came along for the ride. Swept across their 36 components,
368
+ * 564 of 1443 utilities produced no declaration at all, and the biggest groups were not
369
+ * arrangement but PLACE: 116 distribution utilities in 25 components, 69 position ones in
370
+ * 17 (dono, 04/08).
371
+ *
372
+ * The cost was visible on one card: the loading veil is `absolute inset-0 bg-white/50`,
373
+ * and without the position it stopped being a veil and became a block in the flow; the
374
+ * chevron's `justify-between` is what puts it at the far edge of the header.
375
+ *
376
+ * `flex` and `flex-col` stay OUT on purpose - they travel as the node's own form
377
+ * (`row`/`stack`), and declaring the same thing twice is how two sources of truth start
378
+ * disagreeing. What comes in here is the DIMENSION (`flex-1`, `shrink-0`) and the PLACE.
379
+ */
380
+ const PLACE_KEYWORD = {
381
+ // Position: what makes an overlay an overlay.
382
+ relative: { property: "position", value: "relative" },
383
+ absolute: { property: "position", value: "absolute" },
384
+ fixed: { property: "position", value: "fixed" },
385
+ sticky: { property: "position", value: "sticky" },
386
+ static: { property: "position", value: "static" },
387
+ // Flex dimension. The direction is the node's form; how it grows is a decision.
388
+ "flex-1": { property: "flex", value: "1 1 0%" },
389
+ "flex-auto": { property: "flex", value: "1 1 auto" },
390
+ "flex-initial": { property: "flex", value: "0 1 auto" },
391
+ "flex-none": { property: "flex", value: "none" },
392
+ grow: { property: "flexGrow", value: "1" },
393
+ "grow-0": { property: "flexGrow", value: "0" },
394
+ "flex-grow": { property: "flexGrow", value: "1" },
395
+ "flex-grow-0": { property: "flexGrow", value: "0" },
396
+ shrink: { property: "flexShrink", value: "1" },
397
+ "shrink-0": { property: "flexShrink", value: "0" },
398
+ "flex-shrink": { property: "flexShrink", value: "1" },
399
+ "flex-shrink-0": { property: "flexShrink", value: "0" },
400
+ // Overflow: what makes a clip clip, and a scroller scroll.
401
+ "overflow-hidden": { property: "overflow", value: "hidden" },
402
+ "overflow-auto": { property: "overflow", value: "auto" },
403
+ "overflow-visible": { property: "overflow", value: "visible" },
404
+ "overflow-scroll": { property: "overflow", value: "scroll" },
405
+ "overflow-x-auto": { property: "overflowX", value: "auto" },
406
+ "overflow-y-auto": { property: "overflowY", value: "auto" },
407
+ "overflow-x-hidden": { property: "overflowX", value: "hidden" },
408
+ "overflow-y-hidden": { property: "overflowY", value: "hidden" },
409
+ "overflow-x-scroll": { property: "overflowX", value: "scroll" },
410
+ "overflow-y-scroll": { property: "overflowY", value: "scroll" },
411
+ // Display, when it is not the arrangement we already carry as a form.
412
+ "inline-flex": { property: "display", value: "inline-flex" },
413
+ "inline-block": { property: "display", value: "inline-block" },
414
+ inline: { property: "display", value: "inline" },
415
+ block: { property: "display", value: "block" },
416
+ hidden: { property: "display", value: "none" },
417
+ // Focus and the pointer: real decisions, and `outline-none` is 21 of theirs.
418
+ "outline-none": { property: "outline", value: "none" },
419
+ "cursor-pointer": { property: "cursor", value: "pointer" },
420
+ "cursor-default": { property: "cursor", value: "default" },
421
+ "cursor-not-allowed": { property: "cursor", value: "not-allowed" },
422
+ "cursor-text": { property: "cursor", value: "text" },
423
+ "cursor-grab": { property: "cursor", value: "grab" },
424
+ "cursor-wait": { property: "cursor", value: "wait" },
425
+ "pointer-events-none": { property: "pointerEvents", value: "none" },
426
+ "pointer-events-auto": { property: "pointerEvents", value: "auto" },
427
+ "select-none": { property: "userSelect", value: "none" },
428
+ "select-text": { property: "userSelect", value: "text" },
429
+ "select-all": { property: "userSelect", value: "all" },
430
+ // Text that has to fit, and numbers that have to line up.
431
+ "whitespace-nowrap": { property: "whiteSpace", value: "nowrap" },
432
+ "whitespace-pre": { property: "whiteSpace", value: "pre" },
433
+ "whitespace-pre-wrap": { property: "whiteSpace", value: "pre-wrap" },
434
+ "break-words": { property: "overflowWrap", value: "break-word" },
435
+ "break-all": { property: "wordBreak", value: "break-all" },
436
+ "tabular-nums": { property: "fontVariantNumeric", value: "tabular-nums" },
437
+ // How an image fills its frame.
438
+ "object-cover": { property: "objectFit", value: "cover" },
439
+ "object-contain": { property: "objectFit", value: "contain" },
440
+ "object-fill": { property: "objectFit", value: "fill" },
441
+ "object-none": { property: "objectFit", value: "none" },
442
+ "object-scale-down": { property: "objectFit", value: "scale-down" },
443
+ /**
444
+ * ONE UTILITY, THREE DECLARATIONS - and any one of them alone paints nothing, which is
445
+ * why this needed the `prop:value|…` form rather than the pair form.
446
+ */
447
+ truncate: {
448
+ property: "overflow+textOverflow+whiteSpace",
449
+ value: "overflow:hidden|textOverflow:ellipsis|whiteSpace:nowrap",
450
+ },
451
+ };
452
+ /**
453
+ * MOVEMENT IS A DECLARATION TOO, and 91 utilities of it were being dropped.
454
+ *
455
+ * Measured across their 36 components (dono, 04/08): 34 `transition-*` in 20 components,
456
+ * 23 `duration-*` in 16, 2 `ease-*`, 18 `animate-*` in 10. Their chevron turns over 300ms
457
+ * and their spinner spins - and the recipe said neither, so a rebuilt component snapped
458
+ * between states and sat still where it should turn.
459
+ *
460
+ * READ AS THE FOUR LONGHANDS, never as the shorthand. `transition-colors duration-200
461
+ * ease-out` is three utilities and three properties: composing them into one `transition`
462
+ * would mean guessing what the other two will be, and whichever landed last would erase
463
+ * the others. Longhands compose the way their own utilities do.
464
+ */
465
+ const TRANSITION_PROPERTY = {
466
+ all: "all",
467
+ none: "none",
468
+ colors: "color, background-color, border-color, text-decoration-color, fill, stroke",
469
+ opacity: "opacity",
470
+ shadow: "box-shadow",
471
+ transform: "transform, translate, scale, rotate",
472
+ // Tailwind's bare `transition` is this set, and it is what 16 of theirs write.
473
+ "": "color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter",
474
+ };
475
+ /** Tailwind's easing scale, verbatim - a curve nobody should retype. */
476
+ const EASING = {
477
+ linear: "linear",
478
+ in: "cubic-bezier(0.4, 0, 1, 1)",
479
+ out: "cubic-bezier(0, 0, 0.2, 1)",
480
+ "in-out": "cubic-bezier(0.4, 0, 0.2, 1)",
481
+ };
482
+ /** `justify-between` → `space-between`, and the four that are not a straight copy. */
483
+ const ALIGNMENT = {
484
+ start: "flex-start",
485
+ end: "flex-end",
486
+ between: "space-between",
487
+ around: "space-around",
488
+ evenly: "space-evenly",
489
+ center: "center",
490
+ stretch: "stretch",
491
+ baseline: "baseline",
492
+ };
493
+ /** Which property each alignment prefix sets. */
494
+ const ALIGN_PROP = {
495
+ justify: "justifyContent",
496
+ items: "alignItems",
497
+ self: "alignSelf",
498
+ content: "alignContent",
499
+ "justify-items": "justifyItems",
500
+ "justify-self": "justifySelf",
501
+ "place-items": "placeItems",
502
+ "place-content": "placeContent",
503
+ "place-self": "placeSelf",
504
+ };
333
505
  /** States we recognise. Anything else is skipped rather than invented. */
334
506
  /**
335
507
  * A MODIFIER'S STATE, IN THE CONTRACT'S SPELLING.
@@ -419,7 +591,22 @@ function withAlpha(d, alpha) {
419
591
  return d;
420
592
  return { ...d, value: `color-mix(in srgb, ${d.value} ${pct}%, transparent)` };
421
593
  }
594
+ /** `w-1/2`, `basis-2/3` - a fraction is a width, and the `/` is not an alpha. */
595
+ const FRACTION = /^(w|h|basis|max-w|max-h|min-w|min-h|top|right|bottom|left|inset)-(\d+)\/(\d+)$/;
422
596
  export function readUtility(utility, declared) {
597
+ /**
598
+ * BEFORE THE ALPHA SPLIT, because the two share a slash and only one of them is a
599
+ * colour. `w-1/2` was reaching the size reader as `w-1` and coming back `0.25rem` -
600
+ * silently wrong, which is worse than unread. Their `packages/ui` has none; two of
601
+ * their apps do (dono, 04/08).
602
+ */
603
+ const fraction = FRACTION.exec(utility);
604
+ if (fraction) {
605
+ const property = SPACING_PROPERTY[fraction[1]];
606
+ const pct = (Number(fraction[2]) / Number(fraction[3])) * 100;
607
+ if (property)
608
+ return { property, value: `${Number(pct.toFixed(4))}%` };
609
+ }
423
610
  const read = readUtilityCore(utility, declared);
424
611
  return read ? withAlpha(read, utility.split("/")[1]) : read;
425
612
  }
@@ -442,9 +629,79 @@ function readUtilityCore(utility, declared) {
442
629
  const [core] = utility.split("/");
443
630
  // A bare keyword carries a declaration with no scale behind it, so it is read
444
631
  // before the dash split that everything else needs.
445
- const keyword = TYPE_KEYWORD[core];
632
+ const keyword = TYPE_KEYWORD[core] ?? PLACE_KEYWORD[core];
446
633
  if (keyword)
447
634
  return keyword;
635
+ /**
636
+ * A BARE `rounded` IS TAILWIND'S OWN DEFAULT RADIUS, and it read as nothing - so six
637
+ * elements across their library arrived square. Their token wins if they declared one
638
+ * under that name, exactly as the scaled form already does.
639
+ */
640
+ if (core === "rounded") {
641
+ return declared.has("--radius-DEFAULT")
642
+ ? {
643
+ property: "borderRadius",
644
+ value: "{radius.DEFAULT}",
645
+ token: "--radius-DEFAULT",
646
+ }
647
+ : { property: "borderRadius", value: "0.25rem" };
648
+ }
649
+ /**
650
+ * TRANSITION, DURATION, EASING, DELAY - four utilities, four properties.
651
+ *
652
+ * 59 of these across their library, and the recipe carried none: a rebuilt component
653
+ * snapped between every state their own code eases through (dono, 04/08).
654
+ */
655
+ const bareTransition = /^transition(?:-(.+))?$/.exec(core);
656
+ if (bareTransition) {
657
+ const arg = bareTransition[1] ?? "";
658
+ const arbitrary = /^\[(.+)\]$/.exec(arg);
659
+ const value = arbitrary
660
+ ? arbitrary[1].replace(/_/g, " ")
661
+ : TRANSITION_PROPERTY[arg];
662
+ if (value)
663
+ return { property: "transitionProperty", value };
664
+ }
665
+ const ms = /^(duration|delay)-(\d+)$/.exec(core);
666
+ if (ms) {
667
+ return {
668
+ property: ms[1] === "duration" ? "transitionDuration" : "transitionDelay",
669
+ value: `${ms[2]}ms`,
670
+ };
671
+ }
672
+ const ease = /^ease-(.+)$/.exec(core);
673
+ if (ease && EASING[ease[1]]) {
674
+ return { property: "transitionTimingFunction", value: EASING[ease[1]] };
675
+ }
676
+ /**
677
+ * `animate-spin` - the animation, by the name the system defines it under.
678
+ *
679
+ * `var(--animate-spin)` rather than a shorthand of ours: the compiler mints that
680
+ * variable from the document's own keyframes and gives it this system's clock. The
681
+ * frames themselves travel beside the reading - see `BUILTIN_KEYFRAMES` - because a
682
+ * recipe cannot point at a keyframe the system does not define.
683
+ */
684
+ const animate = /^animate-([a-z][\w-]*)$/.exec(core);
685
+ if (animate && animate[1] !== "none") {
686
+ return {
687
+ property: "animation",
688
+ value: `var(--animate-${animate[1]})`,
689
+ keyframe: animate[1],
690
+ };
691
+ }
692
+ /**
693
+ * ALIGNMENT - `justify-between`, `items-center`, `self-end`.
694
+ *
695
+ * 116 of these across 25 of their components, and they are not arrangement: they are
696
+ * WHERE the thing sits. The chevron on their card is at the far edge because of one.
697
+ */
698
+ const align = /^(place-items|place-content|place-self|justify-items|justify-self|justify|items|self|content)-([a-z]+)$/.exec(core);
699
+ if (align) {
700
+ const property = ALIGN_PROP[align[1]];
701
+ const value = ALIGNMENT[align[2]];
702
+ if (property && value)
703
+ return { property, value };
704
+ }
448
705
  /**
449
706
  * BORDER WIDTH - `border`, `border-2`, `border-b`, `border-t-4`.
450
707
  *
@@ -533,6 +790,18 @@ function readUtilityCore(utility, declared) {
533
790
  return { property: "borderCollapse", value: rest };
534
791
  }
535
792
  }
793
+ /**
794
+ * `ring-1` IS A WIDTH, and it reached the colour reader first - which looked for a
795
+ * colour called `1`, found none, and returned null for all three of theirs. Its colour
796
+ * already reads as `outlineColor`, so the width belongs on the same family; a bare
797
+ * `ring` is 3px, Tailwind's own default.
798
+ */
799
+ if (prefix === "ring" && (rest === "" || /^\d+$/.test(rest))) {
800
+ return { property: "outlineWidth", value: `${rest || 3}px` };
801
+ }
802
+ if (core === "ring") {
803
+ return { property: "outlineWidth", value: "3px" };
804
+ }
536
805
  const colorProp = COLOR_PROPERTY[prefix];
537
806
  if (colorProp) {
538
807
  /**
@@ -677,6 +946,14 @@ function readUtilityCore(utility, declared) {
677
946
  sizeRest = axis[2];
678
947
  }
679
948
  }
949
+ /** The same recombination for the axis-suffixed families: `space-y-1`, `inset-x-0`. */
950
+ if (prefix === "space" || prefix === "inset") {
951
+ const axis = /^([xy])-(.+)$/.exec(rest);
952
+ if (axis) {
953
+ sizePrefix = `${prefix}-${axis[1]}`;
954
+ sizeRest = axis[2];
955
+ }
956
+ }
680
957
  const spaceProp = SPACING_PROPERTY[sizePrefix];
681
958
  if (spaceProp) {
682
959
  /** `gap-x-6` / `gap-y-2` - the two halves of gap, which one prefix cannot say. */
@@ -716,7 +993,87 @@ function readUtilityCore(utility, declared) {
716
993
  if (MAX_WIDTH[sizeRest] && /^(max-w|max-h)$/.test(sizePrefix)) {
717
994
  return { property: spaceProp, value: MAX_WIDTH[sizeRest] };
718
995
  }
996
+ /**
997
+ * `top-full`, `inset-0`, `basis-auto` - the words an offset takes and a padding does
998
+ * not. Their Menu positions its popup with `top-full`, and a positioner that does not
999
+ * position is not the component.
1000
+ */
1001
+ const OFFSET_WORD = {
1002
+ full: "100%",
1003
+ auto: "auto",
1004
+ px: "1px",
1005
+ };
1006
+ if (OFFSET_WORD[sizeRest] &&
1007
+ /^(inset|inset-x|inset-y|top|right|bottom|left|basis)$/.test(sizePrefix)) {
1008
+ return { property: spaceProp, value: OFFSET_WORD[sizeRest] };
1009
+ }
719
1010
  }
1011
+ /**
1012
+ * `z-50` - the stacking order, which is the difference between an overlay above the
1013
+ * content and one behind it. Tailwind's own scale is the numbers themselves.
1014
+ */
1015
+ if (prefix === "z" && (/^\d+$/.test(rest) || rest === "auto")) {
1016
+ return { property: "zIndex", value: rest };
1017
+ }
1018
+ /**
1019
+ * `backdrop-blur-[1px]`, `backdrop-blur-sm` - the frosted layer behind an overlay.
1020
+ * Their loading veil is `bg-white/50 backdrop-blur-[1px]`, and without the blur the
1021
+ * veil is a flat wash.
1022
+ */
1023
+ const BLUR = {
1024
+ none: "0",
1025
+ sm: "4px",
1026
+ "": "8px",
1027
+ md: "12px",
1028
+ lg: "16px",
1029
+ xl: "24px",
1030
+ };
1031
+ const backdrop = /^backdrop-blur(?:-(.+))?$/.exec(core);
1032
+ if (backdrop) {
1033
+ const arg = backdrop[1] ?? "";
1034
+ const arbitrary = /^\[(.+)\]$/.exec(arg);
1035
+ const value = arbitrary ? arbitrary[1] : BLUR[arg];
1036
+ if (value)
1037
+ return { property: "backdropFilter", value: `blur(${value})` };
1038
+ }
1039
+ const blur = /^blur(?:-(.+))?$/.exec(core);
1040
+ if (blur) {
1041
+ const arg = blur[1] ?? "";
1042
+ const arbitrary = /^\[(.+)\]$/.exec(arg);
1043
+ const value = arbitrary ? arbitrary[1] : BLUR[arg];
1044
+ if (value)
1045
+ return { property: "filter", value: `blur(${value})` };
1046
+ }
1047
+ /**
1048
+ * `rotate-180`, `scale-[0.98]` - a static transform, which is a state and not motion:
1049
+ * their chevron points up when the card is open. Each one lands on `transform` whole,
1050
+ * so two on the same element would overwrite - none of their 3 co-occur, and saying
1051
+ * one of them is better than saying neither.
1052
+ */
1053
+ const rotate = /^-?rotate-(.+)$/.exec(core);
1054
+ if (rotate) {
1055
+ const arbitrary = /^\[(.+)\]$/.exec(rotate[1]);
1056
+ const deg = arbitrary ? arbitrary[1] : `${rotate[1]}deg`;
1057
+ return {
1058
+ property: "transform",
1059
+ value: `rotate(${core.startsWith("-") ? `-${deg}` : deg})`,
1060
+ };
1061
+ }
1062
+ const scale = /^scale-(.+)$/.exec(core);
1063
+ if (scale) {
1064
+ const arbitrary = /^\[(.+)\]$/.exec(scale[1]);
1065
+ const factor = arbitrary
1066
+ ? arbitrary[1]
1067
+ : /^\d+$/.test(scale[1])
1068
+ ? String(Number(scale[1]) / 100)
1069
+ : null;
1070
+ if (factor)
1071
+ return { property: "transform", value: `scale(${factor})` };
1072
+ }
1073
+ /** `line-clamp-2` - how many lines survive before the ellipsis. */
1074
+ const clamp = /^line-clamp-(\d+)$/.exec(core);
1075
+ if (clamp)
1076
+ return { property: "WebkitLineClamp", value: clamp[1] };
720
1077
  if (prefix === "rounded") {
721
1078
  /**
722
1079
  * `rounded-t-lg` - one side, two corners. The side names which half; the scale is
@@ -1239,6 +1596,25 @@ export function transcribe(classes, declared) {
1239
1596
  // `rounded-t-lg` names two corners: the reader returns them joined with `+`, and
1240
1597
  // the write splits - the same shape the `size` expansion above uses.
1241
1598
  if (decl.property.includes("+")) {
1599
+ /**
1600
+ * AND WITH ITS OWN VALUE EACH, when one utility means three things.
1601
+ *
1602
+ * `truncate` is `overflow:hidden; text-overflow:ellipsis; white-space:nowrap` - any
1603
+ * one of the three alone paints nothing, so the pair form (same value, two
1604
+ * properties) could not carry it. `prop:value|prop:value` says both halves.
1605
+ */
1606
+ if (decl.value.includes("|")) {
1607
+ for (const pair of decl.value.split("|")) {
1608
+ const [property, value] = pair.split(":");
1609
+ if (property && value && target[property] == null)
1610
+ target[property] = value;
1611
+ }
1612
+ if (decl.token)
1613
+ out.fromToken += 1;
1614
+ else
1615
+ out.fromLiteral += 1;
1616
+ continue;
1617
+ }
1242
1618
  for (const property of decl.property.split("+")) {
1243
1619
  if (target[property] == null)
1244
1620
  target[property] = decl.value;
@@ -1249,6 +1625,11 @@ export function transcribe(classes, declared) {
1249
1625
  out.fromLiteral += 1;
1250
1626
  continue;
1251
1627
  }
1628
+ if (decl.keyframe) {
1629
+ out.animations ??= [];
1630
+ if (!out.animations.includes(decl.keyframe))
1631
+ out.animations.push(decl.keyframe);
1632
+ }
1252
1633
  if (target[decl.property] != null)
1253
1634
  continue;
1254
1635
  target[decl.property] = decl.value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.113",
3
+ "version": "0.16.118",
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": {