synthesisui 0.16.73 → 0.16.75

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.
@@ -6,6 +6,8 @@ import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.
6
6
  import { buildGuide } from "../guide.js";
7
7
  import { body as line, section, snippet } from "../output.js";
8
8
  import { fetchDesignSystem } from "../registry.js";
9
+ import { describeFiltered, rulesForProject } from "../rule-filter.js";
10
+ import { detectStack } from "../stack.js";
9
11
  async function readRootLock(path) {
10
12
  try {
11
13
  return JSON.parse(await readFile(path, "utf8"));
@@ -70,7 +72,19 @@ export async function add(slug, opts) {
70
72
  await writeFile(rootLockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
71
73
  // 5b. governance rules (personal DS) → rules.md at the slug root (stable path,
72
74
  // highest authority; the GUIDE tells the agent to read it first)
73
- const rules = payload.rules ?? [];
75
+ /**
76
+ * The structured set wins when the registry sent one, because only this side
77
+ * knows what the project is built with - a rule that names `next` has no
78
+ * business in a Vue app. Falling back to the flat strings keeps an older
79
+ * registry working unchanged.
80
+ */
81
+ const stack = await detectStack(projectRoot);
82
+ const rules = payload.ruleSet && payload.ruleSet.length > 0
83
+ ? rulesForProject(payload.ruleSet, stack)
84
+ : (payload.rules ?? []);
85
+ const leftOut = payload.ruleSet
86
+ ? describeFiltered(payload.ruleSet, stack)
87
+ : null;
74
88
  if (rules.length > 0) {
75
89
  const body = `# ${payload.name} - Rules\n\n` +
76
90
  "> Accumulated rules for this design system. **Max authority - follow these first.**\n" +
@@ -79,6 +93,10 @@ export async function add(slug, opts) {
79
93
  .join("\n")}\n`;
80
94
  await writeFile(join(slugDir, "rules.md"), body, "utf8");
81
95
  }
96
+ // NO SILENT DROPS. A rule removed because it belongs to another environment is
97
+ // a thing the person should hear once, not discover by its absence.
98
+ if (leftOut)
99
+ console.log(line(leftOut));
82
100
  // 5c. structured philosophy (personal DS) → philosophy.md at the slug root.
83
101
  // Narrative guidance (mission, principles, voice, motion doctrine…); the
84
102
  // GUIDE points the agent here right after rules.md.
@@ -13,6 +13,7 @@ import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
13
13
  import { buildTable } from "../doctor/tokens.js";
14
14
  import { rootClasses, rootTag, transcribe, transcribeParts, } from "../doctor/transcribe.js";
15
15
  import { body, paint, section } from "../output.js";
16
+ import { detectStack } from "../stack.js";
16
17
  import { walk, walkAll } from "./doctor.js";
17
18
  /**
18
19
  * How many distinct values travel, PER KIND.
@@ -93,70 +94,6 @@ async function siblingProjects(root) {
93
94
  .map((w) => w.rel),
94
95
  };
95
96
  }
96
- /**
97
- * Dependencies as the project actually resolves them - which means reading
98
- * ANCESTOR package.json files too.
99
- *
100
- * Measured on our own repo: `import --dir apps/web` reported "plain css" for a
101
- * Next + React + Tailwind app, because a workspace hoists those to the root and
102
- * the leaf package.json lists only its own icons. Three levels up covers every
103
- * pnpm/npm workspace layout without wandering into someone's home directory.
104
- */
105
- async function resolveDeps(root) {
106
- const deps = {};
107
- let dir = root;
108
- for (let up = 0; up < 4; up++) {
109
- const raw = await readFile(join(dir, "package.json"), "utf8").catch(() => null);
110
- if (raw) {
111
- try {
112
- const p = JSON.parse(raw);
113
- // The nearest package.json wins on a version clash; we only ever ask
114
- // whether a name is present, so first-seen is enough.
115
- for (const [k, v] of Object.entries({
116
- ...p.dependencies,
117
- ...p.devDependencies,
118
- })) {
119
- if (deps[k] == null)
120
- deps[k] = String(v);
121
- }
122
- }
123
- catch {
124
- // unreadable manifest costs the detection, not the run
125
- }
126
- }
127
- const parent = join(dir, "..");
128
- if (parent === dir)
129
- break;
130
- dir = parent;
131
- }
132
- return deps;
133
- }
134
- async function detectStack(root) {
135
- const stack = [];
136
- const has = async (f) => (await readFile(join(root, f), "utf8").catch(() => null)) !== null;
137
- const deps = await resolveDeps(root);
138
- if (await has("components.json"))
139
- stack.push("shadcn/ui");
140
- if (deps.next)
141
- stack.push("next");
142
- else if (deps.vite)
143
- stack.push("vite");
144
- if (deps.react)
145
- stack.push("react");
146
- else if (deps.vue)
147
- stack.push("vue");
148
- else if (deps.svelte)
149
- stack.push("svelte");
150
- if (deps.tailwindcss)
151
- stack.push("tailwind");
152
- if (await has("tokens.json"))
153
- stack.push("tokens.json");
154
- // Nothing recognised is itself a finding: plain CSS is a supported entrance,
155
- // and saying so beats an empty list that reads like a failed detection.
156
- if (stack.length === 0)
157
- stack.push("plain css");
158
- return stack;
159
- }
160
97
  /**
161
98
  * Their own vocabulary, read from stylesheets - the same harvest the doctor runs
162
99
  * when nothing of ours is installed.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * WHICH RULES BELONG IN *THIS* PROJECT.
3
+ *
4
+ * A rule travels with the design system, and the system gets installed places it
5
+ * was never written for. "The state arrives through this hook" is true about Next
6
+ * and actively wrong about Vue - so a rule carries the environments it holds in,
7
+ * and the filtering happens HERE, because the consumer's stack is the one thing
8
+ * the platform cannot know (dono, 01/08).
9
+ *
10
+ * THE DEFAULT WHEN WE CANNOT TELL IS TO KEEP IT. A rule whose condition does not
11
+ * match is removed, because obeying it would be a mistake. A rule we cannot
12
+ * evaluate - the stack came back empty, or it names something we do not detect -
13
+ * stays, because a rule nobody can judge is safer present than silently gone.
14
+ * Those are two different situations and they get two different answers.
15
+ */
16
+ const norm = (v) => v.trim().toLowerCase();
17
+ /**
18
+ * True when this rule holds in a project built with `stack`.
19
+ *
20
+ * `stack` is what `detectStack` reported, so `plain css` and an unrecognised
21
+ * entry both read as "we could not tell" rather than as a contradiction.
22
+ */
23
+ export function ruleApplies(rule, stack) {
24
+ const when = (rule.when ?? []).map(norm).filter(Boolean);
25
+ if (when.length === 0)
26
+ return true;
27
+ const here = new Set(stack.map(norm));
28
+ // Nothing recognised: we are not in a position to rule anything out.
29
+ if (here.size === 0 || (here.size === 1 && here.has("plain css")))
30
+ return true;
31
+ return when.some((w) => here.has(w));
32
+ }
33
+ /**
34
+ * The lines for `rules.md`, in the order they were written.
35
+ *
36
+ * A scoped rule names its components, so an agent reading the file knows whether
37
+ * the line is about the thing it has open - and a rule about a RELATION names
38
+ * both, which is the whole reason the field exists.
39
+ */
40
+ export function rulesForProject(rules, stack) {
41
+ const out = [];
42
+ for (const rule of rules) {
43
+ if (!ruleApplies(rule, stack))
44
+ continue;
45
+ const text = rule.text?.trim();
46
+ if (!text)
47
+ continue;
48
+ const scope = rule.applies?.length > 0
49
+ ? `${rule.applies.map((a) => `ds-${a}`).join(" + ")}: `
50
+ : "";
51
+ out.push(`${scope}${text}`);
52
+ }
53
+ return out;
54
+ }
55
+ /** What was left out, and why - said out loud rather than subtracted quietly. */
56
+ export function describeFiltered(rules, stack) {
57
+ const dropped = rules.filter((r) => !ruleApplies(r, stack));
58
+ if (dropped.length === 0)
59
+ return null;
60
+ const envs = [...new Set(dropped.flatMap((r) => r.when.map(norm)))].sort();
61
+ return `${dropped.length} rule${dropped.length === 1 ? "" : "s"} left out - ${dropped.length === 1 ? "it belongs" : "they belong"} to ${envs.join(", ")} and this project is ${stack.join(" · ")}`;
62
+ }
@@ -134,6 +134,23 @@ Open the project and answer the questions below. Then add a \`reading\` object t
134
134
  "roles": { "canvas": "#050505", "foreground": "#f9fafb", "primary": "#4A90E2" },
135
135
  "fonts": { "display": "Inter", "body": "Inter" },
136
136
  "concept": "one paragraph on what this product is",
137
+ "rules": [
138
+ {
139
+ "text": "A RootWrapper is what makes the app-shell components position correctly - never render a Sidebar or Topbar outside one",
140
+ "applies": ["RootWrapper", "Sidebar"],
141
+ "kind": "implementation",
142
+ "when": ["next"],
143
+ "files": 3,
144
+ "evidence": "all three app shells wrap them; none renders them loose"
145
+ },
146
+ {
147
+ "text": "MetricCard is only used inside a grid, never on its own",
148
+ "applies": ["MetricCard"],
149
+ "kind": "limit",
150
+ "files": 1,
151
+ "evidence": "one dashboard page; may just be how it happened"
152
+ }
153
+ ],
137
154
  "components": {
138
155
  "MetricCard": {
139
156
  "parts": [
@@ -175,6 +192,64 @@ about which one is the page. Read a screen and see.
175
192
  **\`fonts\` and \`concept\`** - the voice, and one paragraph on what this product is. The concept
176
193
  feeds every recommendation downstream, so a real one beats a generic one by a wide margin.
177
194
 
195
+ **\`rules\` - how this company BUILDS, which is half of what they actually made.**
196
+
197
+ A design system that arrives as tokens and recipes is only the vocabulary. The other half is the
198
+ grammar: that a wrapper exists so the thing inside it works, that state arrives through a
199
+ particular hook, that a card is never used loose. That is how the developer already thinks, and
200
+ writing it down is what makes it survive them.
201
+
202
+ Two dimensions, and they are independent:
203
+
204
+ \`\`\`
205
+ applies [] the whole system
206
+ [a] that component
207
+ [a, b] a RELATION - two components that only work together
208
+
209
+ kind limit a boundary, which the doctor can measure
210
+ implementation how it is built, which no linter checks and an agent
211
+ must be told
212
+ \`\`\`
213
+
214
+ The relation is the valuable one. \`[RootWrapper, Sidebar]\` says something neither name says
215
+ alone, and it is exactly what an agent needs in order not to assemble it wrongly.
216
+
217
+ **\`when\` - the environments a rule is true in.** Leave it out and the rule holds everywhere.
218
+
219
+ This exists because the system gets installed places it was never written for. *"The state arrives
220
+ through this hook"* is true about Next and **actively wrong** about Vue, so a rule that depends on
221
+ the framework has to say so - and the install filters against the project it lands in, because
222
+ only the project knows what it is built with.
223
+
224
+ One dimension: a member of the stack (\`next\`, \`vue\`, \`react\`, \`svelte\`, \`tailwind\`, \`shadcn/ui\`).
225
+ A rule may list several. It cannot combine conditions - \`next && !tailwind\` would be a query
226
+ language, and then somebody maintains an interpreter forever.
227
+
228
+ Use it only when the rule genuinely depends on the environment. Most rules do not: *"never render
229
+ a Sidebar loose"* is true about their design regardless of framework, and pinning it to \`next\`
230
+ would quietly drop it the day they add a second app.
231
+
232
+ **Report \`files\` honestly - it decides whether the rule governs.** Three or more files is a
233
+ habit and the rule arrives active; one file is a coincidence and it arrives as a candidate,
234
+ inactive, waiting for the person to promote it. You do not make that call; you report the
235
+ evidence and a threshold makes it. So a pattern you saw once should say \`"files": 1\` even when
236
+ you are confident - being wrong about a law is worse than being slow about one.
237
+
238
+ **Write \`evidence\` as what you actually saw.** "All three app shells wrap them; none renders
239
+ them loose" lets somebody disagree with a fact. "Best practice" lets them disagree only with
240
+ you.
241
+
242
+ Things worth looking for, none of them guessable from tokens:
243
+
244
+ - a wrapper or provider whose whole purpose is to make something else work
245
+ - an environment law - "this is Next, so the state comes through this hook" - which travels
246
+ with the system and only applies where that environment is
247
+ - a component that is never used alone, or never used outside something
248
+ - an axis that is always passed the same way in one place and never in another
249
+
250
+ If you cannot say where a rule came from, do not send it. An invented law is worse than a
251
+ missing one, because it will be obeyed.
252
+
178
253
  **\`components[Name].parts\` - what each component is MADE OF.** This is the field that decides
179
254
  whether a component previews as itself or as a grey box with a sentence in it, and only you can
180
255
  fill it.
package/dist/stack.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * WHAT THIS PROJECT IS BUILT WITH - and who needs to know.
3
+ *
4
+ * The census reports it so a system knows where it came from. The INSTALL reads
5
+ * it so a rule that only applies to Next does not land in a Vue project: a rule
6
+ * travels WITH the design system and is conditional on the consumer (dono, 01/08
7
+ * - "se o ambiente que estiver instalado esse DS for next, use isso").
8
+ *
9
+ * One implementation, because two detectors that disagreed would put a rule in a
10
+ * project the census said was something else.
11
+ */
12
+ import { readFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ /**
15
+ * Dependencies as the project actually resolves them - which means reading
16
+ * ANCESTOR package.json files too.
17
+ *
18
+ * Measured on our own repo: `import --dir apps/web` reported "plain css" for a
19
+ * Next + React + Tailwind app, because a workspace hoists those to the root and
20
+ * the leaf package.json lists only its own icons. Three levels up covers every
21
+ * pnpm/npm workspace layout without wandering into someone's home directory.
22
+ */
23
+ export async function resolveDeps(root) {
24
+ const deps = {};
25
+ let dir = root;
26
+ for (let up = 0; up < 4; up++) {
27
+ const raw = await readFile(join(dir, "package.json"), "utf8").catch(() => null);
28
+ if (raw) {
29
+ try {
30
+ const p = JSON.parse(raw);
31
+ // The nearest package.json wins on a version clash; we only ever ask
32
+ // whether a name is present, so first-seen is enough.
33
+ for (const [k, v] of Object.entries({
34
+ ...p.dependencies,
35
+ ...p.devDependencies,
36
+ })) {
37
+ if (deps[k] == null)
38
+ deps[k] = String(v);
39
+ }
40
+ }
41
+ catch {
42
+ // unreadable manifest costs the detection, not the run
43
+ }
44
+ }
45
+ const parent = join(dir, "..");
46
+ if (parent === dir)
47
+ break;
48
+ dir = parent;
49
+ }
50
+ return deps;
51
+ }
52
+ export async function detectStack(root) {
53
+ const stack = [];
54
+ const has = async (f) => (await readFile(join(root, f), "utf8").catch(() => null)) !== null;
55
+ const deps = await resolveDeps(root);
56
+ if (await has("components.json"))
57
+ stack.push("shadcn/ui");
58
+ if (deps.next)
59
+ stack.push("next");
60
+ else if (deps.vite)
61
+ stack.push("vite");
62
+ if (deps.react)
63
+ stack.push("react");
64
+ else if (deps.vue)
65
+ stack.push("vue");
66
+ else if (deps.svelte)
67
+ stack.push("svelte");
68
+ if (deps.tailwindcss)
69
+ stack.push("tailwind");
70
+ if (await has("tokens.json"))
71
+ stack.push("tokens.json");
72
+ // Nothing recognised is itself a finding: plain CSS is a supported entrance,
73
+ // and saying so beats an empty list that reads like a failed detection.
74
+ if (stack.length === 0)
75
+ stack.push("plain css");
76
+ return stack;
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.73",
3
+ "version": "0.16.75",
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": {