@weatherboard/gyde-design 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/stylex.mjs ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * G-100 — StyleX is the mandated styling layer.
3
+ *
4
+ * Completes what G-98 began. G-98 banned Tailwind because a utility framework
5
+ * is an uncontrolled escape hatch out of the token layer; that was a statement
6
+ * about Tailwind and it left the positive question open. This answers it: there
7
+ * is one styling implementation, and it is StyleX.
8
+ *
9
+ * WHAT WAS ARGUED AGAINST THIS, AND WHAT REMAINS TRUE.
10
+ *
11
+ * G-58 Decision 3 rejected the mandate on two measured grounds, and honesty
12
+ * about them is the point of writing this here rather than only in the ADR:
13
+ *
14
+ * 1. **System A reached 98% tokenisation without StyleX** — the
15
+ * highest of the three consumers — with a typed dictionary compiled to CSS
16
+ * custom properties and no build plugin anywhere. That is still true, and
17
+ * this mandate costs them a migration that fixes no measured defect. It is
18
+ * the price of one styling layer instead of two, and it was paid
19
+ * deliberately.
20
+ *
21
+ * 2. **The two-file problem.** `tokens.stylex.ts` cannot import `index.ts`,
22
+ * because `defineVars` must resolve statically, so a consumer maintained the
23
+ * same values by hand in two files. This one is **answered rather than
24
+ * accepted**: `generateStyleX` emits the StyleX module from the same
25
+ * dictionary as the CSS, in the same run, so there is no second copy for
26
+ * anybody to keep in step. G-58's own words — "the problem not existing
27
+ * rather than being solved".
28
+ *
29
+ * The first cost stands. A mandate whose costs are only recorded in the
30
+ * document nobody reads at 2am is a mandate that will be litigated again in six
31
+ * months by someone who thinks it was never considered.
32
+ *
33
+ * THE WIRING IS CHECKED, NOT ASSUMED — AND IT IS THE PART THAT ACTUALLY BREAKS.
34
+ *
35
+ * StyleX is a compiler. A project that declares the dependency and imports the
36
+ * API but never wires the plugin gets **no styles at all**: `stylex.create`
37
+ * returns objects, `stylex.props` returns class names, nothing throws, the
38
+ * build passes, and the page renders unstyled. It is the same failure shape as
39
+ * G-98's dependency removal and as `wiring.mjs`'s missing stylesheet — the
40
+ * third time this class of defect has appeared in this codebase, which is why
41
+ * it is checked rather than trusted.
42
+ *
43
+ * @gyde-emits-source-for-another-repo — the fixtures beside this file are
44
+ * package manifests and configuration written as data for another repository.
45
+ */
46
+
47
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
48
+ import { join } from "node:path";
49
+
50
+ export const RULE = "styling-layer";
51
+
52
+ const STYLEX_RUNTIME = /^@stylexjs\/stylex$/;
53
+ const STYLEX_TOOLING = /^@stylexjs\/(babel-plugin|postcss-plugin|webpack-plugin|rollup-plugin|nextjs-plugin|esbuild-plugin|open-props)$/;
54
+
55
+ /**
56
+ * Styling layers that compete with StyleX for the same job.
57
+ *
58
+ * CSS Modules and plain stylesheets are deliberately absent: they carry the
59
+ * generated custom properties and are how the token layer reaches the page. The
60
+ * ban is on a SECOND way to express a component's styles, not on CSS.
61
+ */
62
+ const COMPETING = {
63
+ "styled-components": "runtime CSS-in-JS",
64
+ "@emotion/react": "runtime CSS-in-JS",
65
+ "@emotion/styled": "runtime CSS-in-JS",
66
+ "@vanilla-extract/css": "a second compile-time CSS-in-JS",
67
+ "@stitches/react": "runtime CSS-in-JS",
68
+ "linaria": "a second compile-time CSS-in-JS",
69
+ "@linaria/core": "a second compile-time CSS-in-JS",
70
+ "jss": "runtime CSS-in-JS",
71
+ };
72
+
73
+ const CONFIG_HINT = /stylex/i;
74
+ const CONFIG_FILES = /^(next\.config\.(js|mjs|ts)|vite\.config\.(js|mjs|ts)|babel\.config\.(js|json|cjs)|\.babelrc(\.js|\.json)?|postcss\.config\.(js|cjs|mjs))$/;
75
+
76
+ function manifestOf(root, pkgPath) {
77
+ const p = join(root, pkgPath === "." ? "" : pkgPath, "package.json");
78
+ if (!existsSync(p)) return null;
79
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
80
+ }
81
+
82
+ const allDeps = (json) => ({
83
+ ...(json.dependencies || {}), ...(json.devDependencies || {}), ...(json.peerDependencies || {}),
84
+ });
85
+
86
+ /**
87
+ * Is the styling layer StyleX, is it wired, and is anything competing with it?
88
+ *
89
+ * `renders` narrows the check to packages that actually render UI — the same
90
+ * set `wiring.mjs` uses. A utility package with no components does not need a
91
+ * styling layer, and demanding one there is how a mandate gets a reputation for
92
+ * being about compliance rather than about the product.
93
+ */
94
+ export function checkStyleX(root, { packages = [], renders = null } = {}) {
95
+ const scope = renders ? packages.filter((p) => renders.includes(p.path)) : packages;
96
+
97
+ if (scope.length === 0) {
98
+ return {
99
+ unknown: true,
100
+ why: renders
101
+ ? "no package in this workspace renders UI, so there is no styling layer to mandate"
102
+ : "which packages render UI is not known, so the mandate cannot be scoped",
103
+ missing: [], unwired: [], competing: [],
104
+ };
105
+ }
106
+
107
+ const missing = [];
108
+ const unwired = [];
109
+ const competing = [];
110
+
111
+ for (const pkg of scope) {
112
+ const json = manifestOf(root, pkg.path);
113
+ if (!json) continue;
114
+ const deps = allDeps(json);
115
+
116
+ const hasRuntime = Object.keys(deps).some((n) => STYLEX_RUNTIME.test(n));
117
+ const hasTooling = Object.keys(deps).some((n) => STYLEX_TOOLING.test(n));
118
+
119
+ for (const [name, why] of Object.entries(COMPETING)) {
120
+ if (deps[name]) competing.push({ package: pkg.path, name, version: deps[name], why });
121
+ }
122
+
123
+ if (!hasRuntime) { missing.push({ package: pkg.path }); continue; }
124
+
125
+ // A compiler that is installed and not wired produces no styles and no
126
+ // error. Checked here rather than trusted, because this is the third time
127
+ // this codebase has met the same shape (G-98, wiring.mjs, and now this).
128
+ if (!hasTooling && !configMentionsStyleX(root, pkg.path)) {
129
+ unwired.push({ package: pkg.path });
130
+ }
131
+ }
132
+
133
+ return {
134
+ unknown: false,
135
+ missing, unwired, competing,
136
+ packagesChecked: scope.length,
137
+ ok: missing.length === 0 && unwired.length === 0 && competing.length === 0,
138
+ };
139
+ }
140
+
141
+ function configMentionsStyleX(root, pkgPath) {
142
+ const dir = join(root, pkgPath === "." ? "" : pkgPath);
143
+ let entries; try { entries = readdirSync(dir); } catch { return false; }
144
+ for (const name of entries) {
145
+ if (!CONFIG_FILES.test(name)) continue;
146
+ try { if (CONFIG_HINT.test(readFileSync(join(dir, name), "utf8"))) return true; } catch { /* unreadable */ }
147
+ }
148
+ return false;
149
+ }
150
+
151
+ export function stylexFindings(result) {
152
+ if (result.unknown) return [];
153
+ const at = (p) => `${p === "." ? "" : p + "/"}package.json`;
154
+ return [
155
+ ...result.missing.map((m) => ({ file: at(m.package), rule: RULE, line: null, raw: "@stylexjs/stylex", kind: "missing" })),
156
+ ...result.unwired.map((u) => ({ file: at(u.package), rule: RULE, line: null, raw: "stylex-plugin", kind: "unwired" })),
157
+ ...result.competing.map((c) => ({ file: at(c.package), rule: RULE, line: null, raw: c.name, kind: "competing" })),
158
+ ];
159
+ }
160
+
161
+ export function formatStyleX(result) {
162
+ if (result.unknown) return `styling layer could not tell — ${result.why}`;
163
+ if (result.ok) return `styling layer StyleX, wired, in all ${result.packagesChecked} package(s) that render UI`;
164
+
165
+ const L = [`styling layer ${result.missing.length + result.unwired.length + result.competing.length} problem(s) across ${result.packagesChecked} UI package(s)`];
166
+ for (const m of result.missing) L.push(` ${m.package} no @stylexjs/stylex — StyleX is the mandated styling layer (G-100)`);
167
+ for (const u of result.unwired) L.push(` ${u.package} StyleX installed but no plugin and no config mentioning it`);
168
+ for (const c of result.competing) L.push(` ${c.package} ${c.name}@${c.version} — ${c.why}, competing with StyleX`);
169
+
170
+ if (result.unwired.length) {
171
+ L.push("");
172
+ L.push(" StyleX is a compiler. Installed and unwired it produces NO styles and no");
173
+ L.push(" error: create returns objects, props returns class names, the build passes,");
174
+ L.push(" and the page renders unstyled. That is the same failure as removing a");
175
+ L.push(" Tailwind dependency with classes still in source.");
176
+ }
177
+ return L.join("\n");
178
+ }
package/tailwind.mjs ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * G-98 — Tailwind is not a permitted styling layer.
3
+ *
4
+ * Reverses G-58 Decision 3, which left the styling layer unmandated. That
5
+ * decision was correct on the evidence it had and this one is a product
6
+ * decision rather than a measurement: Gyde must own the token layer completely,
7
+ * and a utility framework is a standing escape hatch out of it. `p-[13px]`
8
+ * needs no approval, leaves no trace in a dictionary, and is one keystroke away
9
+ * from every call site.
10
+ *
11
+ * THE ORDERING IS THE WHOLE DESIGN, AND IT IS NOT OBVIOUS.
12
+ *
13
+ * Tailwind exists in a repository at four levels, and they are removed in the
14
+ * opposite order to the one people reach for:
15
+ *
16
+ * 1. the DEPENDENCY `tailwindcss` in a package.json
17
+ * 2. the CONFIG tailwind.config.*, or an `@import "tailwindcss"`
18
+ * 3. the CLASSES `p-4`, `rounded-lg`, `bg-muted` in source
19
+ * 4. the STYLESHEETS `@apply p-4`, `theme(spacing.4)` in CSS
20
+ *
21
+ * The fourth was missed on the first pass and is the one that makes the ban a
22
+ * fiction if left out: a repository can hold nothing in any manifest, no
23
+ * config, and not one utility class in any component, and still be written in
24
+ * Tailwind — `.card { @apply p-4 rounded-lg bg-muted; }`.
25
+ *
26
+ * The obvious first move is to delete the dependency, because it is one line
27
+ * and it makes the ban pass. It is also the single most destructive thing
28
+ * anybody could do here. Utility classes are inert strings in a `className`
29
+ * attribute: with the dependency gone nothing throws, no build fails, no type
30
+ * errors — every one of those classes silently resolves to no style at all and
31
+ * the application renders unstyled. One consumer has 4,842 of them.
32
+ *
33
+ * So this check refuses to be satisfied in the wrong order. It reports the
34
+ * dependency as removable ONLY when the classes are gone, and until then it
35
+ * says so in the finding itself. A gate that can be passed by an action that
36
+ * breaks production is not a gate, it is a trap — and CHARTER §5's rule applies
37
+ * here in an unusual direction: "the ban is satisfied" and "the styling has
38
+ * been removed" must never render the same.
39
+ *
40
+ * WHAT THIS DELIBERATELY DOES NOT DO.
41
+ *
42
+ * It does not migrate anything, and it does not count utility classes as a new
43
+ * finding class. They are already counted — `normalise.mjs` reduces them to
44
+ * declarations and the existing rules judge them. Adding a second count of the
45
+ * same code would double the debt and move the ratchet for a reason that is not
46
+ * a change in the code.
47
+ *
48
+ * @gyde-emits-source-for-another-repo — the fixtures beside this file are
49
+ * package manifests and CSS written as data for a repository that is not this one.
50
+ */
51
+
52
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
53
+ import { join } from "node:path";
54
+
55
+ export const RULE = "tailwind-present";
56
+
57
+ /** Package names that ARE Tailwind, rather than merely mentioning it. */
58
+ const TAILWIND_PACKAGE = /^(tailwindcss|@tailwindcss\/.+)$/;
59
+
60
+ /** A plugin is only Tailwind's problem while Tailwind is there; it is not itself the ban. */
61
+ const TAILWIND_PLUGIN = /^(prettier-plugin-tailwindcss|tailwind-merge|tailwind-variants|clsx)$/;
62
+
63
+ const CONFIG_NAMES = /^tailwind\.config\.(js|cjs|mjs|ts)$/;
64
+
65
+ /** `@import "tailwindcss"` / `@tailwind base` — the v4 and v3 spellings. */
66
+ const CSS_ENTRY = /@import\s+["']tailwindcss["']|@tailwind\s+(base|components|utilities)\b/;
67
+
68
+ /**
69
+ * Where Tailwind is declared, configured, and used.
70
+ *
71
+ * `utilityClasses` is passed in rather than counted here. `normalise.mjs`
72
+ * already reduces utilities to declarations and is the one place that knows
73
+ * what a Tailwind class is; counting them again here would be a second
74
+ * implementation of the same question, free to disagree with the first.
75
+ */
76
+ export function detectTailwind(root, { packages = [], utilityClasses = null, cssDirectives = null } = {}) {
77
+ const dependencies = [];
78
+ const plugins = [];
79
+
80
+ for (const pkg of packages) {
81
+ const manifest = join(root, pkg.path === "." ? "" : pkg.path, "package.json");
82
+ if (!existsSync(manifest)) continue;
83
+ let json; try { json = JSON.parse(readFileSync(manifest, "utf8")); } catch { continue; }
84
+
85
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
86
+ for (const [name, version] of Object.entries(json[field] || {})) {
87
+ if (TAILWIND_PACKAGE.test(name)) dependencies.push({ package: pkg.path, name, version, field });
88
+ else if (TAILWIND_PLUGIN.test(name)) plugins.push({ package: pkg.path, name, version, field });
89
+ }
90
+ }
91
+ }
92
+
93
+ const configs = [];
94
+ for (const pkg of packages) {
95
+ const dir = join(root, pkg.path === "." ? "" : pkg.path);
96
+ let entries; try { entries = readdirSync(dir); } catch { continue; }
97
+ for (const name of entries) if (CONFIG_NAMES.test(name)) configs.push(`${pkg.path === "." ? "" : pkg.path + "/"}${name}`);
98
+ }
99
+
100
+ return { dependencies, plugins, configs, utilityClasses, cssDirectives, ...verdict({ dependencies, configs, utilityClasses, cssDirectives }) };
101
+ }
102
+
103
+ /** Does a stylesheet pull Tailwind in? Exported so a caller can walk CSS without this module walking twice. */
104
+ export const importsTailwind = (css) => CSS_ENTRY.test(css);
105
+
106
+ function verdict({ dependencies, configs, utilityClasses, cssDirectives }) {
107
+ const present = dependencies.length > 0 || configs.length > 0;
108
+ if (!present) return { present: false, safeToRemove: null, why: "Tailwind is not installed" };
109
+
110
+ // The load-bearing branch. Not knowing how many classes remain is not the
111
+ // same as knowing there are none, and only one of those is safe to act on.
112
+ // A stylesheet directive breaks on removal exactly as a class does, so
113
+ // "no classes left" is not the same as "safe". Counting only one of the two
114
+ // is how the ordering guard would have been satisfied by a repository that
115
+ // still stops rendering the moment the dependency goes.
116
+ if (utilityClasses === null || cssDirectives === null) {
117
+ return {
118
+ present: true, safeToRemove: null,
119
+ why: "Tailwind is installed, and how much still resolves through it is unknown — " +
120
+ "removing it now could leave every class and directive resolving to nothing",
121
+ };
122
+ }
123
+ if (utilityClasses > 0 || cssDirectives > 0) {
124
+ const parts = [];
125
+ if (utilityClasses > 0) parts.push(`${utilityClasses} utility class(es)`);
126
+ if (cssDirectives > 0) parts.push(`${cssDirectives} stylesheet directive(s)`);
127
+ return {
128
+ present: true, safeToRemove: false,
129
+ why: `Tailwind is installed and ${parts.join(" and ")} still resolve through it. ` +
130
+ "Remove them first: they fail silently, so nothing throws when the dependency goes",
131
+ };
132
+ }
133
+ return {
134
+ present: true, safeToRemove: true,
135
+ why: "nothing resolves through Tailwind any more, so the dependency and config can go",
136
+ };
137
+ }
138
+
139
+ /** The finding, shaped like every other so it enters the ledger unchanged. */
140
+ export function tailwindFindings(result) {
141
+ if (!result.present) return [];
142
+ return [
143
+ ...result.dependencies.map((d) => ({
144
+ file: `${d.package === "." ? "" : d.package + "/"}package.json`,
145
+ rule: RULE, line: null, raw: d.name, kind: "dependency", detail: `${d.name}@${d.version} in ${d.field}`,
146
+ })),
147
+ ...result.configs.map((c) => ({ file: c, rule: RULE, line: null, raw: c, kind: "config", detail: "Tailwind configuration" })),
148
+ ];
149
+ }
150
+
151
+ export function formatTailwind(result) {
152
+ if (!result.present) return "tailwind not installed";
153
+
154
+ const L = [`tailwind PRESENT — ${result.dependencies.length} declaration(s), ${result.configs.length} config file(s)`];
155
+ for (const d of result.dependencies) L.push(` ${d.package} ${d.name}@${d.version} (${d.field})`);
156
+ for (const c of result.configs) L.push(` ${c}`);
157
+ if (result.plugins.length) {
158
+ L.push(` ${result.plugins.length} companion package(s): ${result.plugins.map((p) => p.name).join(", ")}`);
159
+ L.push(" not the ban themselves — they stop mattering when Tailwind goes");
160
+ }
161
+
162
+ L.push("");
163
+ if (result.safeToRemove === true) {
164
+ L.push(" SAFE TO REMOVE. No utility class resolves through Tailwind any more.");
165
+ } else if (result.safeToRemove === false) {
166
+ L.push(" DO NOT REMOVE THE DEPENDENCY YET.");
167
+ if (result.utilityClasses > 0) L.push(` ${result.utilityClasses} utility class(es) — inert strings in a className attribute`);
168
+ if (result.cssDirectives > 0) L.push(` ${result.cssDirectives} stylesheet directive(s) — @apply and friends, which stop compiling`);
169
+ L.push(" Deleting the dependency throws nothing, fails no build, and renders the");
170
+ L.push(" application unstyled. Remove these first, then the config, then the dependency.");
171
+ } else {
172
+ L.push(` ${result.why}.`);
173
+ }
174
+ return L.join("\n");
175
+ }
176
+
177
+ export const CSS_RULE = "tailwind-in-css";
178
+
179
+ /**
180
+ * Tailwind's vocabulary, carried in a stylesheet.
181
+ *
182
+ * The hole G-98 and G-100 both recorded and neither closed. A repository can
183
+ * satisfy the dependency ban completely — nothing in any manifest, no config,
184
+ * not one utility class in any JSX file — and still be written in Tailwind:
185
+ *
186
+ * .card { @apply p-4 rounded-lg bg-muted; }
187
+ *
188
+ * That is the ban being satisfiable without the thing it bans being gone, which
189
+ * is the same trap the removal-ordering guard above exists to prevent. A gate
190
+ * with a documented way through it is a gate that teaches people the way
191
+ * through it.
192
+ *
193
+ * WHAT IS AND IS NOT TAILWIND HERE.
194
+ *
195
+ * `@layer` is NOT included, and that is the one to get right: it is standard
196
+ * CSS cascade layers, it appears in stylesheets that have never seen Tailwind,
197
+ * and flagging it would make this rule fire on the CSS the mandate is asking
198
+ * people to write. The directives below have no meaning outside Tailwind's
199
+ * compiler, so their presence is not a guess.
200
+ *
201
+ * `theme()` is included with the same reasoning: it is a compile-time lookup
202
+ * into Tailwind's config, so a stylesheet using it cannot be read without
203
+ * Tailwind, whatever the manifest says.
204
+ */
205
+ const CSS_DIRECTIVE = /(^|[\s;{}])@(apply|tailwind|screen|variants|responsive|config)\b|(^|[^\w-])theme\s*\(/;
206
+
207
+ /** Strip comments, preserving line count so a finding points at the right line. */
208
+ const stripCss = (text) => text.replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, " "));
209
+
210
+ export function tailwindInCss(text, { filename = "" } = {}) {
211
+ const found = [];
212
+ stripCss(text).split("\n").forEach((line, i) => {
213
+ const m = line.match(CSS_DIRECTIVE);
214
+ if (!m) return;
215
+ const directive = (m[2] ? "@" + m[2] : "theme()");
216
+ found.push({
217
+ file: filename, rule: CSS_RULE, line: i + 1,
218
+ raw: directive, directive, source: line.trim(),
219
+ });
220
+ });
221
+ return found;
222
+ }
223
+
224
+ export function formatTailwindCss(findings) {
225
+ if (findings.length === 0) return "no stylesheet carries Tailwind's vocabulary";
226
+ const byFile = {};
227
+ for (const f of findings) (byFile[f.file] ??= []).push(f);
228
+ const L = [`${findings.length} Tailwind directive(s) in ${Object.keys(byFile).length} stylesheet(s):`];
229
+ for (const [file, fs] of Object.entries(byFile)) {
230
+ L.push(` ${file} ${[...new Set(fs.map((f) => f.directive))].join(", ")} (${fs.length})`);
231
+ }
232
+ L.push("");
233
+ L.push(" A repository can pass the dependency ban with no manifest entry, no config and");
234
+ L.push(" no utility class in any component, and still be written in Tailwind. These");
235
+ L.push(" directives have no meaning outside its compiler, so the stylesheet stops");
236
+ L.push(" working the moment the dependency goes — silently, like the classes.");
237
+ return L.join("\n");
238
+ }