synthesisui 0.16.112 → 0.16.116

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",
@@ -265,6 +266,25 @@ sketch) {
265
266
  continue;
266
267
  }
267
268
  const node_ = { as };
269
+ /**
270
+ * THE GUARD TRAVELS WITH THE NODE, off the sketch it points at - so neither a
271
+ * reading nor the deriver has to carry it, and both get it.
272
+ */
273
+ if (typeof node.at === "number") {
274
+ const measured = sketch?.[node.at];
275
+ const when = node.when ?? measured?.when;
276
+ if (when)
277
+ node_.when = when;
278
+ const atRest = node.atRest ?? measured?.atRest;
279
+ if (atRest === false)
280
+ node_.atRest = false;
281
+ }
282
+ else {
283
+ if (node.when)
284
+ node_.when = node.when;
285
+ if (node.atRest === false)
286
+ node_.atRest = false;
287
+ }
268
288
  /**
269
289
  * A PART THAT IS A LIBRARY ELEMENT CARRIES BOTH FACTS - see `AnatomyRead.from`.
270
290
  * Inert for the renderer, which dispatches on `as`; read by the usage snippet,
@@ -276,6 +296,28 @@ sketch) {
276
296
  if (typeof node.refName === "string" && node.refName.trim())
277
297
  node_.refName = node.refName.trim();
278
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
+ }
279
321
  // The NAME is what carries the styles, so it is claimed even when nothing
280
322
  // resolved: the tree triggers on presence, and structure is most of the
281
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"));
@@ -17,6 +17,7 @@ import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from
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";
@@ -1083,6 +1084,42 @@ export async function takeCensus(root, opts) {
1083
1084
  for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
1084
1085
  declaredNames.add(m[1]);
1085
1086
  }
1087
+ /**
1088
+ * HOW SOMEBODY CALLS THE COMPONENT THEY ALREADY HAVE.
1089
+ *
1090
+ * The usage panel was handing people the markup of a component rebuilt out of our
1091
+ * compiled classes, for a component sitting in their own `packages/ui` with a name and
1092
+ * props - "você inventou diversas classes que não existem" (dono, 04/08), and the half
1093
+ * that lands is that those classes appear nowhere in his repo. He has
1094
+ * `<CollapsibleCard>`; the usage is its props.
1095
+ *
1096
+ * Measured, never inferred: the specifier is the name in THEIR manifest for the folder
1097
+ * being imported, and a component only carries it when the entry actually re-exports
1098
+ * the name - 35 of their 36, with `TypingIndicator` internal to `Message` and reported
1099
+ * as not exported. See `publicApi`.
1100
+ */
1101
+ const api = await publicApi(root, scopeLabel);
1102
+ if (api) {
1103
+ for (const c of components) {
1104
+ const file = defined.find((d) => d.name === c.name)?.file;
1105
+ const source = file
1106
+ ? sources.find((s) => s.file.endsWith(file))?.source
1107
+ : undefined;
1108
+ const required = source ? requiredProps(source, c.name) : [];
1109
+ c.api = {
1110
+ ...(api.exports.has(c.name) ? { from: api.from } : { exported: false }),
1111
+ /**
1112
+ * REPO-RELATIVE, so the answer needs no second search. `defined[].file` is
1113
+ * relative to the scope; joined with the scope it is a path anybody can check
1114
+ * with one `stat` - which is how `component` knows they already have it without
1115
+ * walking a monorepo (their component sits six levels below `packages/`, and the
1116
+ * shallow walk that guards against shadowing never reached it).
1117
+ */
1118
+ ...(file ? { file: `${scopeLabel}/${file}` } : {}),
1119
+ ...(required.length > 0 ? { required } : {}),
1120
+ };
1121
+ }
1122
+ }
1086
1123
  const brokenRefs = findBrokenRefs(sources, declaredNames);
1087
1124
  const conventions = detectConventions(sources);
1088
1125
  const classStyle = detectClassStyle(sources);
@@ -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,225 @@
1
+ /**
2
+ * IS THIS ELEMENT THERE WHEN NOBODY HAS TOUCHED THE COMPONENT?
3
+ *
4
+ * Their markup guards elements with conditions - `{isLoading && (…)}`,
5
+ * `{status === "success" && (…)}` - and the preview drew every one of them. So a card
6
+ * that is not loading wore a loading veil across its whole surface, and a status that is
7
+ * `waiting` showed the ping that belongs to `success` (dono, 03/08).
8
+ *
9
+ * ANSWERED FROM THEIR OWN DECLARATIONS, or not answered at all. Three facts, all
10
+ * measured in their source and none of them ours:
11
+ *
12
+ * a prop's default `{ status = "waiting" }`
13
+ * a local alias `const isLoading = status === "loading"`
14
+ * a state's first value `useState(defaultOpen)` → the default of `defaultOpen`
15
+ *
16
+ * `null` means the condition cannot be settled - `{tag && <Tag/>}` on a component whose
17
+ * `tag` has no default is optional CONTENT, and drawing it is right: 56 of their 79
18
+ * guards are that shape, and hiding them would empty the previews out. Only a condition
19
+ * that resolves to FALSE takes an element off the resting view, and the blueprint says
20
+ * every one of them out loud either way.
21
+ */
22
+ const STATE = /const\s*\[\s*([A-Za-z_$][\w$]*)\s*,[^\]]*\]\s*=\s*(?:React\.)?useState[^(]*\(\s*([^)]*?)\s*\)/g;
23
+ const ALIAS = /const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*([^;\n]+);/g;
24
+ /**
25
+ * The aliases and first-render state values a component declares about itself.
26
+ *
27
+ * A state read is honest here for one reason: its INITIAL value is what "at rest" means.
28
+ * `useState(defaultOpen)` with `defaultOpen = false` says a collapsible card starts
29
+ * closed, which is a fact about their component rather than a guess about a user.
30
+ */
31
+ /**
32
+ * EVERY DEFAULT THE DESTRUCTURING DECLARES, not only the string ones.
33
+ *
34
+ * `readDefinitionProps` reads string literals on purpose - it answers "which option is
35
+ * the resting one", and a boolean is not an option. Here a boolean is the whole answer:
36
+ * `{ defaultOpen = false }` is what says a collapsible card starts closed.
37
+ */
38
+ function paramDefaults(body) {
39
+ const open = body.indexOf("({");
40
+ if (open === -1)
41
+ return {};
42
+ let depth = 0;
43
+ let end = -1;
44
+ for (let i = open + 1; i < body.length; i++) {
45
+ const c = body[i];
46
+ if (c === "{")
47
+ depth++;
48
+ else if (c === "}") {
49
+ depth--;
50
+ if (depth === 0) {
51
+ end = i;
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ if (end === -1)
57
+ return {};
58
+ const out = {};
59
+ let depth2 = 0;
60
+ let part = "";
61
+ const take = () => {
62
+ const eq = part.indexOf("=");
63
+ if (eq > 0) {
64
+ const key = part.slice(0, eq).split(":")[0].trim();
65
+ const value = part.slice(eq + 1).trim();
66
+ if (/^[A-Za-z_$][\w$]*$/.test(key) && value)
67
+ out[key] = value;
68
+ }
69
+ part = "";
70
+ };
71
+ for (const c of body.slice(open + 2, end)) {
72
+ if ("([{".includes(c))
73
+ depth2++;
74
+ else if (")]}".includes(c))
75
+ depth2--;
76
+ if (c === "," && depth2 === 0)
77
+ take();
78
+ else
79
+ part += c;
80
+ }
81
+ take();
82
+ return out;
83
+ }
84
+ export function restingFacts(body, defaults) {
85
+ const aliases = {};
86
+ ALIAS.lastIndex = 0;
87
+ for (const m of body.matchAll(ALIAS)) {
88
+ const value = m[2].trim();
89
+ // A function or a component is not a condition; only value-shaped reads help.
90
+ if (/=>|\bfunction\b/.test(value))
91
+ continue;
92
+ if (!aliases[m[1]])
93
+ aliases[m[1]] = value;
94
+ }
95
+ STATE.lastIndex = 0;
96
+ for (const m of body.matchAll(STATE)) {
97
+ if (!aliases[m[1]])
98
+ aliases[m[1]] = m[2].trim() || "undefined";
99
+ }
100
+ /**
101
+ * THE DESTRUCTURING WINS, because it kept the quotes.
102
+ *
103
+ * `readDefinitionProps` hands back `waiting` already unquoted, and an unquoted word is
104
+ * indistinguishable from a reference to something else - so `status === "success"` went
105
+ * unsettled while the answer sat in the source. Read verbatim, `"waiting"` is a value
106
+ * and `defaultOpen` is a name, and the shape says which.
107
+ */
108
+ return { defaults: { ...defaults, ...paramDefaults(body) }, aliases };
109
+ }
110
+ const LITERAL = /^(?:"([^"]*)"|'([^']*)'|`([^`]*)`)$/;
111
+ /** A value their code wrote, as the string a comparison would see. */
112
+ function literalOf(text) {
113
+ const t = text.trim();
114
+ const m = LITERAL.exec(t);
115
+ if (m)
116
+ return m[1] ?? m[2] ?? m[3] ?? "";
117
+ if (t === "true" || t === "false" || t === "undefined" || t === "null")
118
+ return t;
119
+ if (/^-?\d+(\.\d+)?$/.test(t))
120
+ return t;
121
+ return null;
122
+ }
123
+ /** Split on a top-level operator, so `a && (b || c)` reads as two halves. */
124
+ function splitTop(text, op) {
125
+ let depth = 0;
126
+ for (let i = 0; i < text.length - 1; i++) {
127
+ const c = text[i];
128
+ if (c === "(" || c === "[" || c === "{")
129
+ depth++;
130
+ else if (c === ")" || c === "]" || c === "}")
131
+ depth--;
132
+ else if (depth === 0 && text.startsWith(op, i))
133
+ return [text.slice(0, i), text.slice(i + 2)];
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * True, false, or `null` when their own declarations do not settle it.
139
+ *
140
+ * `seen` guards a chain that refers back to itself - `const a = b; const b = a` is not
141
+ * something to crash on.
142
+ */
143
+ export function holdsAtRest(condition, facts, seen = new Set()) {
144
+ const text = condition.trim();
145
+ if (!text)
146
+ return null;
147
+ if (text.startsWith("(") && text.endsWith(")")) {
148
+ const inner = text.slice(1, -1);
149
+ if (splitTop(inner, "&&") || splitTop(inner, "||") || !/[()]/.test(inner))
150
+ return holdsAtRest(inner, facts, seen);
151
+ }
152
+ const and = splitTop(text, "&&");
153
+ if (and) {
154
+ const [a, b] = [
155
+ holdsAtRest(and[0], facts, seen),
156
+ holdsAtRest(and[1], facts, seen),
157
+ ];
158
+ if (a === false || b === false)
159
+ return false;
160
+ return a === true && b === true ? true : null;
161
+ }
162
+ const or = splitTop(text, "||");
163
+ if (or) {
164
+ const [a, b] = [
165
+ holdsAtRest(or[0], facts, seen),
166
+ holdsAtRest(or[1], facts, seen),
167
+ ];
168
+ if (a === true || b === true)
169
+ return true;
170
+ return a === false && b === false ? false : null;
171
+ }
172
+ if (text.startsWith("!")) {
173
+ const inner = holdsAtRest(text.slice(1), facts, seen);
174
+ return inner === null ? null : !inner;
175
+ }
176
+ const cmp = /^(.+?)\s*(===|!==|==|!=)\s*(.+)$/.exec(text);
177
+ if (cmp) {
178
+ const left = restingValue(cmp[1], facts, seen);
179
+ const right = literalOf(cmp[3]) ?? restingValue(cmp[3], facts, seen);
180
+ if (left === null || right === null)
181
+ return null;
182
+ const same = left === right;
183
+ return cmp[2].startsWith("!") ? !same : same;
184
+ }
185
+ const value = restingValue(text, facts, seen);
186
+ if (value === null)
187
+ return null;
188
+ return (value !== "false" &&
189
+ value !== "undefined" &&
190
+ value !== "null" &&
191
+ value !== "");
192
+ }
193
+ /** What a name holds at rest, as a literal string, or null when nothing says. */
194
+ function restingValue(name, facts, seen) {
195
+ const key = name.trim();
196
+ if (!/^[A-Za-z_$][\w$]*$/.test(key) || seen.has(key))
197
+ return null;
198
+ const direct = facts.defaults[key];
199
+ if (direct != null) {
200
+ const asLiteral = literalOf(direct);
201
+ if (asLiteral != null)
202
+ return asLiteral;
203
+ /**
204
+ * `readDefinitionProps` hands back `waiting`, already unquoted, while the
205
+ * destructuring reads `defaultOpen` - a NAME to follow. One is a value and the other
206
+ * is a reference, and the shape says which.
207
+ */
208
+ if (/^[A-Za-z_$][\w$]*$/.test(direct.trim()))
209
+ return restingValue(direct, facts, new Set([...seen, key]));
210
+ return direct.trim();
211
+ }
212
+ const alias = facts.aliases[key];
213
+ if (alias == null)
214
+ return null;
215
+ const next = new Set([...seen, key]);
216
+ const asLiteral = literalOf(alias);
217
+ if (asLiteral != null)
218
+ return asLiteral;
219
+ /**
220
+ * The alias is an expression - `const isLoading = status === "loading"`. Settling it is
221
+ * the same question one level down, and the answer comes back as a boolean.
222
+ */
223
+ const held = holdsAtRest(alias, facts, next);
224
+ return held === null ? null : String(held);
225
+ }
@@ -18,8 +18,10 @@
18
18
  * GENERALIZED BY CONSTRUCTION: it reads JSX shape, not folder layout, so it works the
19
19
  * same in an atomic library, a feature-foldered app, or a single flat directory.
20
20
  */
21
+ import { readDefinitionProps } from "./call-sites.js";
21
22
  import { scanTags } from "./css-modules.js";
22
23
  import { importMap, packageOfTag } from "./imports.js";
24
+ import { holdsAtRest, restingFacts } from "./resting.js";
23
25
  import { definitionSpan, returnAt } from "./transcribe.js";
24
26
  /** `{children}` / `{props.children}` - the caller's content, spelled either way. */
25
27
  const CHILDREN = /\{\s*(?:props\.)?children\s*\}/;
@@ -160,6 +162,87 @@ function staticCallClasses(body) {
160
162
  * sitting directly between an open and its close - dynamic children are the caller's,
161
163
  * and the slot form already says so.
162
164
  */
165
+ /**
166
+ * THE CONDITION THAT HAS TO HOLD FOR THIS ELEMENT TO EXIST.
167
+ *
168
+ * Their code guards markup with `{isLoading && (…)}` and `{imageUrl ? <img/> : <box/>}`,
169
+ * and the sketch recorded the element without the guard - so the preview drew a loading
170
+ * veil over a card that is not loading and a status ping for a status it does not have
171
+ * (dono, 03/08). Measured on their library: 76 guards across 22 of 36 components.
172
+ *
173
+ * VERBATIM, in their own vocabulary. This is not a condition we evaluate; it is a fact
174
+ * about their markup that the screen can say out loud ("only when `isLoading`"). The
175
+ * falsy branch of a ternary reads as the negation of the same expression, which is their
176
+ * logic restated rather than anything of ours.
177
+ */
178
+ export function guardAt(source, at) {
179
+ let i = at - 1;
180
+ const skip = () => {
181
+ while (i >= 0 && /\s/.test(source[i]))
182
+ i--;
183
+ };
184
+ skip();
185
+ // `{cond && (` and `{cond ? (` both put a paren between the operator and the tag.
186
+ if (source[i] === "(") {
187
+ i--;
188
+ skip();
189
+ }
190
+ let negated = false;
191
+ if (source[i] === "&" && source[i - 1] === "&")
192
+ i -= 2;
193
+ else if (source[i] === "?")
194
+ i -= 1;
195
+ else if (source[i] === ":") {
196
+ // The ELSE half of `cond ? <a/> : <b/>` - it exists when the condition does not.
197
+ negated = true;
198
+ i -= 1;
199
+ // Walk back over the truthy branch to reach the `?`.
200
+ let depth = 0;
201
+ while (i >= 0) {
202
+ const c = source[i];
203
+ if (c === ">" || c === ")" || c === "}")
204
+ depth++;
205
+ else if (c === "<" || c === "(" || c === "{") {
206
+ if (depth === 0)
207
+ return null;
208
+ depth--;
209
+ }
210
+ else if (c === "?" && depth === 0)
211
+ break;
212
+ i--;
213
+ }
214
+ if (i < 0 || source[i] !== "?")
215
+ return null;
216
+ i -= 1;
217
+ }
218
+ else
219
+ return null;
220
+ // Back to the `{` that opened the expression, minding nesting on the way.
221
+ let depth = 0;
222
+ const end = i;
223
+ while (i >= 0) {
224
+ const c = source[i];
225
+ if (c === ")" || c === "]" || c === "}")
226
+ depth++;
227
+ else if (c === "(" || c === "[") {
228
+ if (depth === 0)
229
+ return null;
230
+ depth--;
231
+ }
232
+ else if (c === "{") {
233
+ if (depth === 0)
234
+ break;
235
+ depth--;
236
+ }
237
+ i--;
238
+ }
239
+ if (i < 0 || source[i] !== "{")
240
+ return null;
241
+ const cond = source.slice(i + 1, end + 1).trim();
242
+ if (!cond || cond.length > 90 || cond.includes("\n"))
243
+ return null;
244
+ return negated ? `!(${cond})` : cond;
245
+ }
163
246
  export function sketchOf(source, name) {
164
247
  /**
165
248
  * READ THE IMPORTS BEFORE SLICING - they live at the top of the file, and the
@@ -189,6 +272,14 @@ export function sketchOf(source, name) {
189
272
  */
190
273
  const ownReturn = returnAt(source, name);
191
274
  const rootAt = ownReturn === -1 ? -1 : source.indexOf("<", ownReturn);
275
+ /**
276
+ * WHAT THE COMPONENT DECLARES ABOUT ITSELF, read once for the whole sketch.
277
+ *
278
+ * Against the SLICED source, so a file that declares two components does not lend one
279
+ * of them the other's defaults - `RadioCard.tsx` declares `RadioCard` and
280
+ * `RadioCardGroup`, and that is the same slice the node indices are numbered against.
281
+ */
282
+ const facts = restingFacts(source, readDefinitionProps(name ?? "", source).defaults);
192
283
  const events = scanTags(source);
193
284
  const out = [];
194
285
  let depth = 0;
@@ -210,6 +301,14 @@ export function sketchOf(source, name) {
210
301
  if (pkg)
211
302
  node.from = pkg;
212
303
  }
304
+ // What has to be true for this element to exist at all - see `guardAt`.
305
+ const guard = guardAt(source, event.at);
306
+ if (guard) {
307
+ node.when = guard;
308
+ // And whether their own defaults settle it - see `holdsAtRest`.
309
+ if (holdsAtRest(guard, facts) === false)
310
+ node.atRest = false;
311
+ }
213
312
  const cls = CLASS_ATTR.exec(event.body);
214
313
  if (cls?.[1].trim())
215
314
  node.classes = cls[1].trim();
@@ -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,119 @@ 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
+ /** `justify-between` → `space-between`, and the four that are not a straight copy. */
453
+ const ALIGNMENT = {
454
+ start: "flex-start",
455
+ end: "flex-end",
456
+ between: "space-between",
457
+ around: "space-around",
458
+ evenly: "space-evenly",
459
+ center: "center",
460
+ stretch: "stretch",
461
+ baseline: "baseline",
462
+ };
463
+ /** Which property each alignment prefix sets. */
464
+ const ALIGN_PROP = {
465
+ justify: "justifyContent",
466
+ items: "alignItems",
467
+ self: "alignSelf",
468
+ content: "alignContent",
469
+ "justify-items": "justifyItems",
470
+ "justify-self": "justifySelf",
471
+ "place-items": "placeItems",
472
+ "place-content": "placeContent",
473
+ "place-self": "placeSelf",
474
+ };
333
475
  /** States we recognise. Anything else is skipped rather than invented. */
334
476
  /**
335
477
  * A MODIFIER'S STATE, IN THE CONTRACT'S SPELLING.
@@ -419,7 +561,22 @@ function withAlpha(d, alpha) {
419
561
  return d;
420
562
  return { ...d, value: `color-mix(in srgb, ${d.value} ${pct}%, transparent)` };
421
563
  }
564
+ /** `w-1/2`, `basis-2/3` - a fraction is a width, and the `/` is not an alpha. */
565
+ const FRACTION = /^(w|h|basis|max-w|max-h|min-w|min-h|top|right|bottom|left|inset)-(\d+)\/(\d+)$/;
422
566
  export function readUtility(utility, declared) {
567
+ /**
568
+ * BEFORE THE ALPHA SPLIT, because the two share a slash and only one of them is a
569
+ * colour. `w-1/2` was reaching the size reader as `w-1` and coming back `0.25rem` -
570
+ * silently wrong, which is worse than unread. Their `packages/ui` has none; two of
571
+ * their apps do (dono, 04/08).
572
+ */
573
+ const fraction = FRACTION.exec(utility);
574
+ if (fraction) {
575
+ const property = SPACING_PROPERTY[fraction[1]];
576
+ const pct = (Number(fraction[2]) / Number(fraction[3])) * 100;
577
+ if (property)
578
+ return { property, value: `${Number(pct.toFixed(4))}%` };
579
+ }
423
580
  const read = readUtilityCore(utility, declared);
424
581
  return read ? withAlpha(read, utility.split("/")[1]) : read;
425
582
  }
@@ -442,9 +599,36 @@ function readUtilityCore(utility, declared) {
442
599
  const [core] = utility.split("/");
443
600
  // A bare keyword carries a declaration with no scale behind it, so it is read
444
601
  // before the dash split that everything else needs.
445
- const keyword = TYPE_KEYWORD[core];
602
+ const keyword = TYPE_KEYWORD[core] ?? PLACE_KEYWORD[core];
446
603
  if (keyword)
447
604
  return keyword;
605
+ /**
606
+ * A BARE `rounded` IS TAILWIND'S OWN DEFAULT RADIUS, and it read as nothing - so six
607
+ * elements across their library arrived square. Their token wins if they declared one
608
+ * under that name, exactly as the scaled form already does.
609
+ */
610
+ if (core === "rounded") {
611
+ return declared.has("--radius-DEFAULT")
612
+ ? {
613
+ property: "borderRadius",
614
+ value: "{radius.DEFAULT}",
615
+ token: "--radius-DEFAULT",
616
+ }
617
+ : { property: "borderRadius", value: "0.25rem" };
618
+ }
619
+ /**
620
+ * ALIGNMENT - `justify-between`, `items-center`, `self-end`.
621
+ *
622
+ * 116 of these across 25 of their components, and they are not arrangement: they are
623
+ * WHERE the thing sits. The chevron on their card is at the far edge because of one.
624
+ */
625
+ const align = /^(place-items|place-content|place-self|justify-items|justify-self|justify|items|self|content)-([a-z]+)$/.exec(core);
626
+ if (align) {
627
+ const property = ALIGN_PROP[align[1]];
628
+ const value = ALIGNMENT[align[2]];
629
+ if (property && value)
630
+ return { property, value };
631
+ }
448
632
  /**
449
633
  * BORDER WIDTH - `border`, `border-2`, `border-b`, `border-t-4`.
450
634
  *
@@ -533,6 +717,18 @@ function readUtilityCore(utility, declared) {
533
717
  return { property: "borderCollapse", value: rest };
534
718
  }
535
719
  }
720
+ /**
721
+ * `ring-1` IS A WIDTH, and it reached the colour reader first - which looked for a
722
+ * colour called `1`, found none, and returned null for all three of theirs. Its colour
723
+ * already reads as `outlineColor`, so the width belongs on the same family; a bare
724
+ * `ring` is 3px, Tailwind's own default.
725
+ */
726
+ if (prefix === "ring" && (rest === "" || /^\d+$/.test(rest))) {
727
+ return { property: "outlineWidth", value: `${rest || 3}px` };
728
+ }
729
+ if (core === "ring") {
730
+ return { property: "outlineWidth", value: "3px" };
731
+ }
536
732
  const colorProp = COLOR_PROPERTY[prefix];
537
733
  if (colorProp) {
538
734
  /**
@@ -677,6 +873,14 @@ function readUtilityCore(utility, declared) {
677
873
  sizeRest = axis[2];
678
874
  }
679
875
  }
876
+ /** The same recombination for the axis-suffixed families: `space-y-1`, `inset-x-0`. */
877
+ if (prefix === "space" || prefix === "inset") {
878
+ const axis = /^([xy])-(.+)$/.exec(rest);
879
+ if (axis) {
880
+ sizePrefix = `${prefix}-${axis[1]}`;
881
+ sizeRest = axis[2];
882
+ }
883
+ }
680
884
  const spaceProp = SPACING_PROPERTY[sizePrefix];
681
885
  if (spaceProp) {
682
886
  /** `gap-x-6` / `gap-y-2` - the two halves of gap, which one prefix cannot say. */
@@ -716,7 +920,87 @@ function readUtilityCore(utility, declared) {
716
920
  if (MAX_WIDTH[sizeRest] && /^(max-w|max-h)$/.test(sizePrefix)) {
717
921
  return { property: spaceProp, value: MAX_WIDTH[sizeRest] };
718
922
  }
923
+ /**
924
+ * `top-full`, `inset-0`, `basis-auto` - the words an offset takes and a padding does
925
+ * not. Their Menu positions its popup with `top-full`, and a positioner that does not
926
+ * position is not the component.
927
+ */
928
+ const OFFSET_WORD = {
929
+ full: "100%",
930
+ auto: "auto",
931
+ px: "1px",
932
+ };
933
+ if (OFFSET_WORD[sizeRest] &&
934
+ /^(inset|inset-x|inset-y|top|right|bottom|left|basis)$/.test(sizePrefix)) {
935
+ return { property: spaceProp, value: OFFSET_WORD[sizeRest] };
936
+ }
937
+ }
938
+ /**
939
+ * `z-50` - the stacking order, which is the difference between an overlay above the
940
+ * content and one behind it. Tailwind's own scale is the numbers themselves.
941
+ */
942
+ if (prefix === "z" && (/^\d+$/.test(rest) || rest === "auto")) {
943
+ return { property: "zIndex", value: rest };
719
944
  }
945
+ /**
946
+ * `backdrop-blur-[1px]`, `backdrop-blur-sm` - the frosted layer behind an overlay.
947
+ * Their loading veil is `bg-white/50 backdrop-blur-[1px]`, and without the blur the
948
+ * veil is a flat wash.
949
+ */
950
+ const BLUR = {
951
+ none: "0",
952
+ sm: "4px",
953
+ "": "8px",
954
+ md: "12px",
955
+ lg: "16px",
956
+ xl: "24px",
957
+ };
958
+ const backdrop = /^backdrop-blur(?:-(.+))?$/.exec(core);
959
+ if (backdrop) {
960
+ const arg = backdrop[1] ?? "";
961
+ const arbitrary = /^\[(.+)\]$/.exec(arg);
962
+ const value = arbitrary ? arbitrary[1] : BLUR[arg];
963
+ if (value)
964
+ return { property: "backdropFilter", value: `blur(${value})` };
965
+ }
966
+ const blur = /^blur(?:-(.+))?$/.exec(core);
967
+ if (blur) {
968
+ const arg = blur[1] ?? "";
969
+ const arbitrary = /^\[(.+)\]$/.exec(arg);
970
+ const value = arbitrary ? arbitrary[1] : BLUR[arg];
971
+ if (value)
972
+ return { property: "filter", value: `blur(${value})` };
973
+ }
974
+ /**
975
+ * `rotate-180`, `scale-[0.98]` - a static transform, which is a state and not motion:
976
+ * their chevron points up when the card is open. Each one lands on `transform` whole,
977
+ * so two on the same element would overwrite - none of their 3 co-occur, and saying
978
+ * one of them is better than saying neither.
979
+ */
980
+ const rotate = /^-?rotate-(.+)$/.exec(core);
981
+ if (rotate) {
982
+ const arbitrary = /^\[(.+)\]$/.exec(rotate[1]);
983
+ const deg = arbitrary ? arbitrary[1] : `${rotate[1]}deg`;
984
+ return {
985
+ property: "transform",
986
+ value: `rotate(${core.startsWith("-") ? `-${deg}` : deg})`,
987
+ };
988
+ }
989
+ const scale = /^scale-(.+)$/.exec(core);
990
+ if (scale) {
991
+ const arbitrary = /^\[(.+)\]$/.exec(scale[1]);
992
+ const factor = arbitrary
993
+ ? arbitrary[1]
994
+ : /^\d+$/.test(scale[1])
995
+ ? String(Number(scale[1]) / 100)
996
+ : null;
997
+ if (factor)
998
+ return { property: "transform", value: `scale(${factor})` };
999
+ }
1000
+ /** `line-clamp-2` - how many lines survive before the ellipsis. */
1001
+ const clamp = /^line-clamp-(\d+)$/.exec(core);
1002
+ if (clamp)
1003
+ return { property: "WebkitLineClamp", value: clamp[1] };
720
1004
  if (prefix === "rounded") {
721
1005
  /**
722
1006
  * `rounded-t-lg` - one side, two corners. The side names which half; the scale is
@@ -1239,6 +1523,25 @@ export function transcribe(classes, declared) {
1239
1523
  // `rounded-t-lg` names two corners: the reader returns them joined with `+`, and
1240
1524
  // the write splits - the same shape the `size` expansion above uses.
1241
1525
  if (decl.property.includes("+")) {
1526
+ /**
1527
+ * AND WITH ITS OWN VALUE EACH, when one utility means three things.
1528
+ *
1529
+ * `truncate` is `overflow:hidden; text-overflow:ellipsis; white-space:nowrap` - any
1530
+ * one of the three alone paints nothing, so the pair form (same value, two
1531
+ * properties) could not carry it. `prop:value|prop:value` says both halves.
1532
+ */
1533
+ if (decl.value.includes("|")) {
1534
+ for (const pair of decl.value.split("|")) {
1535
+ const [property, value] = pair.split(":");
1536
+ if (property && value && target[property] == null)
1537
+ target[property] = value;
1538
+ }
1539
+ if (decl.token)
1540
+ out.fromToken += 1;
1541
+ else
1542
+ out.fromLiteral += 1;
1543
+ continue;
1544
+ }
1242
1545
  for (const property of decl.property.split("+")) {
1243
1546
  if (target[property] == null)
1244
1547
  target[property] = decl.value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.112",
3
+ "version": "0.16.116",
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": {