@supertype.ai/foundations 0.1.34 → 0.1.36

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/README.md CHANGED
@@ -55,7 +55,15 @@ yarn add @supertype.ai/foundations
55
55
  # or: npm install @supertype.ai/foundations
56
56
  ```
57
57
 
58
- Peers are React 19+, Next 15+, `next-view-transitions` 0.3+ and `@base-ui/react` 1.4+.
58
+ Peers are **Tailwind 4+**, React 19+, Next 15+, `next-view-transitions` 0.3+ and
59
+ `@base-ui/react` 1.4+.
60
+
61
+ **Tailwind v4 is required, not preferred.** `tokens.css` declares
62
+ `@custom-variant` and `@theme inline`, and the `@source` line below is v4-only
63
+ syntax; on v3 they are parse errors. If you are still on v3, run
64
+ [`npx @tailwindcss/upgrade`](https://tailwindcss.com/docs/upgrade-guide) first —
65
+ `foundations init` will tell you so rather than writing a block your build
66
+ cannot parse.
59
67
 
60
68
  <details>
61
69
  <summary>Installing from a git tag instead</summary>
@@ -66,28 +74,52 @@ untagged git dependency resolves to a different commit on a fresh install.
66
74
 
67
75
  ```jsonc
68
76
  // package.json
69
- "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.34"
77
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.36"
70
78
  ```
71
79
 
72
80
  </details>
73
81
 
74
- ### 2. Import the CSS, in this order
82
+ ### 2. Import the CSS
83
+
84
+ ```css
85
+ /* app/globals.css */
86
+ @import "tailwindcss";
87
+ @import "@supertype.ai/foundations";
88
+ ```
89
+
90
+ That one line carries `tokens.css`, `theme.css`, `type.css` and `prose.css` in
91
+ the order the cascade needs, and registers the package&rsquo;s own `@source` so
92
+ Tailwind scans the components it ships. There is no path for you to work out and
93
+ no order for you to keep: Tailwind v4 resolves `@source` relative to the file
94
+ that declares it, so the package points at its own `dist/`, correctly, wherever
95
+ it happens to be installed.
96
+
97
+ Add `@import "@supertype.ai/foundations/shiki.css";` after it only if you render
98
+ code fences.
99
+
100
+ <details>
101
+ <summary>Importing the parts separately</summary>
102
+
103
+ The granular entry points are still exported and still supported, for the app
104
+ that paints every colour role itself and wants `tokens.css` without `theme.css`.
105
+ Taking them means owning the order and the scan path yourself:
75
106
 
76
107
  ```css
77
- /* app/global.css */
108
+ /* app/globals.css */
78
109
  @import "tailwindcss";
79
110
  @import "@supertype.ai/foundations/tokens.css"; /* structural tokens + dark variant */
80
111
  @import "@supertype.ai/foundations/theme.css"; /* the house palette */
81
112
  @import "@supertype.ai/foundations/type.css"; /* the type ramp + font roles */
82
113
  @import "@supertype.ai/foundations/prose.css"; /* inline-code rule */
83
- @import "@supertype.ai/foundations/shiki.css"; /* only if you render code fences */
84
114
 
85
115
  @source '../node_modules/@supertype.ai/foundations/dist/**/*.js';
86
116
  ```
87
117
 
88
- **The `@source` line is required.** Tailwind does not scan `node_modules` by
89
- default, so without it the package&rsquo;s classes get purged and the
90
- components render without styles.
118
+ **The `@source` line is required in this form.** Tailwind does not scan
119
+ `node_modules` by default, so without it the package&rsquo;s classes get purged
120
+ and the components render without styles. The path is relative to your CSS file,
121
+ so it changes with your layout — and in a workspace, where the package hoists to
122
+ the repo root, `../node_modules` is not where it lives.
91
123
 
92
124
  **`theme.css` is required.** `tokens.css` names the colour roles, and
93
125
  `theme.css` gives them values. Without it, the colour utilities cannot be
@@ -97,6 +129,8 @@ for marker highlights, and the `accordion-down` and `accordion-up` keyframes.
97
129
  Skip it only if you declare every role yourself; `foundations doctor` fails if
98
130
  neither path is true.
99
131
 
132
+ </details>
133
+
100
134
  ### 3. Bind the fonts
101
135
 
102
136
  The package cannot load the typefaces for you. `next/font` runs in your app and
@@ -22,7 +22,7 @@
22
22
  * that changes in the package changes here on the next release.
23
23
  */
24
24
  import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
25
- import { dirname, join, relative, resolve, sep } from "node:path";
25
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
 
28
28
  const PKG_NAME = "@supertype.ai/foundations";
@@ -80,11 +80,19 @@ const readJson = (file) => {
80
80
  }
81
81
  };
82
82
 
83
+ /**
84
+ * Directories that never hold an app's own source: dependencies, and the build
85
+ * output that mirrors it. Walking from the app root rather than a list of
86
+ * blessed directories means these have to be named, but naming what is not
87
+ * source is a much shorter and much more stable list than guessing what is.
88
+ */
89
+ const NOT_SOURCE = new Set(["node_modules", "dist", "build", "out", "coverage", "public", "vendor", "target"]);
90
+
83
91
  /** Files under `dir` with one of `exts`, depth-limited and blind to build output. */
84
92
  const walk = (dir, exts, depth = 4, out = []) => {
85
93
  if (depth < 0 || !existsSync(dir)) return out;
86
94
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
87
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
95
+ if (entry.name.startsWith(".") || NOT_SOURCE.has(entry.name)) continue;
88
96
  const full = join(dir, entry.name);
89
97
  if (entry.isDirectory()) walk(full, exts, depth - 1, out);
90
98
  else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(full);
@@ -120,15 +128,94 @@ const findAppRoot = (from) => {
120
128
  }
121
129
  };
122
130
 
131
+ /**
132
+ * `name` as Node would resolve it from `dir`: the first `node_modules` on the
133
+ * way up that actually holds it. A workspace hoists dependencies to the repo
134
+ * root, so the copy an app runs against is routinely nowhere near its own
135
+ * package.json, and assuming otherwise is how a check ends up reporting a
136
+ * missing peer that is installed and an `@source` path that points at nothing.
137
+ */
138
+ const resolveDep = (dir, name) => {
139
+ const segments = name.split("/");
140
+ let at = resolve(dir);
141
+ for (;;) {
142
+ const candidate = join(at, "node_modules", ...segments);
143
+ if (existsSync(candidate)) return candidate;
144
+ const up = dirname(at);
145
+ if (up === at) return null;
146
+ at = up;
147
+ }
148
+ };
149
+
150
+ /**
151
+ * The installed Tailwind's major version, or null when it is not installed yet
152
+ * — which is the normal case for `npx … init` run before the first install, and
153
+ * not something to fail on.
154
+ *
155
+ * This is read from the installed package rather than inferred from which
156
+ * directives the app's CSS uses, because the two questions are genuinely
157
+ * separate: `@tailwind utilities;` is still legal in v4, and a v3 app being
158
+ * upgraded passes through states where its CSS and its lockfile disagree.
159
+ */
160
+ const tailwindMajor = (appRoot) => {
161
+ const at = resolveDep(appRoot, "tailwindcss");
162
+ const meta = at ? readJson(join(at, "package.json")) : null;
163
+ return meta ? (versionParts(meta.version)?.[0] ?? null) : null;
164
+ };
165
+
166
+ /**
167
+ * The dialect a stylesheet is written in, for when the installed copy cannot
168
+ * answer — an app running `npx … init` before its first install. v4 imports the
169
+ * framework; v3 uses `@tailwind base`/`components`, which v4 removed.
170
+ * `@tailwind utilities` is deliberately not listed: it is still legal in v4, so
171
+ * it is not evidence of anything.
172
+ */
173
+ const dialectOf = (css) => {
174
+ if (/@import\s+["']tailwindcss(?:\/[\w./-]+)?["']/.test(css)) return 4;
175
+ if (/@tailwind\s+(?:base|components)\b/.test(css)) return 3;
176
+ return null;
177
+ };
178
+
179
+ /**
180
+ * Which Tailwind this app is on, best evidence first: the installed copy, then
181
+ * the dialect of its entry stylesheet. Null when neither can say, which is a
182
+ * normal state and not a failure — a fresh app has no CSS and no install yet.
183
+ */
184
+ const tailwindOf = (appRoot, cssFile) =>
185
+ tailwindMajor(appRoot) ?? (cssFile ? dialectOf(stripComments(read(cssFile) ?? "")) : null);
186
+
187
+ /** Everything the style layer does is v4 syntax, so this is the one way out. */
188
+ const TAILWIND_UPGRADE =
189
+ "Upgrade first: `npx @tailwindcss/upgrade` — https://tailwindcss.com/docs/upgrade-guide";
190
+
191
+ /** Why v4 is not negotiable, said the same way wherever it comes up. */
192
+ const TAILWIND_WHY =
193
+ "tokens.css declares @custom-variant and @theme inline, and the @source line is v4-only syntax.";
194
+
123
195
  /* -------------------------------------------------- what the package wants */
124
196
 
125
197
  /**
126
- * The CSS entry points, in the order they have to be imported: tokens, then
127
- * theme, then type. `exports` is already in that order, which is also the order
128
- * the README documents.
198
+ * The one import that carries the whole style layer. index.css registers the
199
+ * package's own `@source` and imports its own files in the one order the
200
+ * cascade accepts, so an app taking this single line cannot get the order, the
201
+ * path, or the completeness wrong — there is nothing left for it to get wrong.
202
+ */
203
+ const BUNDLE = PKG_NAME;
204
+
205
+ /** Either spelling: the bare specifier, or the file named outright. */
206
+ const isBundle = (spec) => spec === PKG_NAME || spec === `${PKG_NAME}/index.css`;
207
+
208
+ /**
209
+ * The granular entry points, in the order they have to be imported: tokens,
210
+ * then theme, then type. `exports` is already in that order, which is also the
211
+ * order the README documented before the bundle existed.
212
+ *
213
+ * They remain exported, and remain supported, for the app that paints every
214
+ * colour role itself and wants tokens.css without theme.css. index.css is not
215
+ * one of them: it is the whole, not a part.
129
216
  */
130
217
  const cssEntries = Object.keys(pkgJson.exports)
131
- .filter((key) => key.endsWith(".css"))
218
+ .filter((key) => key.endsWith(".css") && key !== "./index.css")
132
219
  .map((key) => `${PKG_NAME}/${key.slice(2)}`);
133
220
 
134
221
  /** Only needed if the app renders code fences. */
@@ -164,11 +251,47 @@ const fontVars = (() => {
164
251
 
165
252
  /* -------------------------------------------------------- consumer probing */
166
253
 
167
- /** The CSS file that starts the cascade, i.e. the one importing Tailwind. */
254
+ /** What Tailwind is processed from. v4 drops the preprocessors; v3 apps use them. */
255
+ const STYLE_EXTS = [".css", ".pcss", ".postcss", ".scss"];
256
+
257
+ /**
258
+ * A file that starts a Tailwind cascade, in either dialect: v4 pulls the
259
+ * framework in with `@import` (including the layered form, where the three
260
+ * layers are imported separately), v3 with `@tailwind`. Both name the same
261
+ * file — the one the app's styles begin at — so finding it does not depend on
262
+ * which version is installed, and must not, or an app on the wrong version
263
+ * gets told it has no stylesheet at all.
264
+ */
265
+ const TAILWIND_ENTRY =
266
+ /@import\s+["']tailwindcss(?:\/[\w./-]+)?["']|@tailwind\s+(?:base|components|utilities)\b/;
267
+
268
+ /** Conventional entry names, best first. Only ever used to break a tie. */
269
+ const ENTRY_NAMES = ["globals", "global", "index", "main", "app", "styles", "tailwind"];
270
+
271
+ /**
272
+ * The CSS file that starts the cascade. Searched from the app root rather than
273
+ * a list of blessed directories, because `app/`, `src/` and `styles/` are three
274
+ * of the places it lives and not the only three.
275
+ *
276
+ * More than one file can import Tailwind — an embed, a Storybook preview, an
277
+ * email template. The real entry is the shallowest, and among equals the one
278
+ * named the way the scaffolds name it.
279
+ */
168
280
  const findCssEntry = (appRoot) => {
169
- const roots = ["app", "src", "styles"].map((d) => join(appRoot, d));
170
- const files = roots.flatMap((dir) => walk(dir, [".css"]));
171
- return files.find((file) => /@import\s+["']tailwindcss["']/.test(read(file) ?? "")) ?? null;
281
+ const candidates = walk(appRoot, STYLE_EXTS, 6).filter((file) =>
282
+ TAILWIND_ENTRY.test(stripComments(read(file) ?? "")),
283
+ );
284
+ const rank = (file) => {
285
+ const rel = relative(appRoot, file);
286
+ const at = ENTRY_NAMES.indexOf(basename(rel).replace(/\.[^.]+$/, ""));
287
+ return [rel.split(sep).length, at === -1 ? ENTRY_NAMES.length : at, rel];
288
+ };
289
+ return (
290
+ candidates
291
+ .map((file) => [rank(file), file])
292
+ .sort(([a], [b]) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] < b[2] ? -1 : 1))
293
+ .map(([, file]) => file)[0] ?? null
294
+ );
172
295
  };
173
296
 
174
297
  const LAYOUTS = ["app/layout.tsx", "src/app/layout.tsx", "app/layout.jsx", "src/app/layout.jsx"];
@@ -213,6 +336,13 @@ const stripComments = (css) => {
213
336
  /** `@import` targets in source order, so one parse gives presence and ordering. */
214
337
  const importsOf = (css) => [...css.matchAll(/@import\s+["']([^"']+)["']/g)].map((m) => m[1]);
215
338
 
339
+ /**
340
+ * An import of the framework itself. v4's layered form names three files rather
341
+ * than one, and the block has to land after the last of them, so anywhere the
342
+ * code asks "is this Tailwind coming in here" has to accept both spellings.
343
+ */
344
+ const isTailwindImport = (spec) => spec === "tailwindcss" || spec.startsWith("tailwindcss/");
345
+
216
346
  const sourceDirectives = (css) => [...css.matchAll(/@source\s+["']([^"']+)["']/g)].map((m) => m[1]);
217
347
 
218
348
  /** Consts assigned from a next/font call, so we can check how they are used. */
@@ -234,7 +364,11 @@ const fontConsts = (src) => {
234
364
 
235
365
  /** The @source line for this app, relative to the CSS file wherever yarn put us. */
236
366
  const sourceGlob = (appRoot, cssFile) => {
237
- const installed = join(appRoot, "node_modules", ...PKG_NAME.split("/"));
367
+ // Where it actually is, which in a workspace is the repo root rather than
368
+ // this app. The conventional path is the fallback for an app that has not
369
+ // installed the package yet, where there is nothing to resolve.
370
+ const installed =
371
+ resolveDep(appRoot, PKG_NAME) ?? join(appRoot, "node_modules", ...PKG_NAME.split("/"));
238
372
  let path = relative(dirname(cssFile), join(installed, "dist"));
239
373
  path = path.split(sep).join("/");
240
374
  if (!path.startsWith(".")) path = `./${path}`;
@@ -255,7 +389,7 @@ const isGitSpec = (spec) =>
255
389
  const checkInstall = (appRoot) => {
256
390
  const out = [];
257
391
  const appPkg = readJson(join(appRoot, "package.json"));
258
- const installed = join(appRoot, "node_modules", ...PKG_NAME.split("/"));
392
+ const installed = resolveDep(appRoot, PKG_NAME);
259
393
 
260
394
  const spec = appPkg?.dependencies?.[PKG_NAME] ?? appPkg?.devDependencies?.[PKG_NAME] ?? null;
261
395
 
@@ -298,7 +432,7 @@ const checkInstall = (appRoot) => {
298
432
  }
299
433
  }
300
434
 
301
- if (existsSync(installed)) {
435
+ if (installed) {
302
436
  if (lstatSync(installed).isSymbolicLink()) {
303
437
  out.push(
304
438
  finding(
@@ -328,7 +462,8 @@ const checkInstall = (appRoot) => {
328
462
 
329
463
  for (const [peer, range] of Object.entries(pkgJson.peerDependencies ?? {})) {
330
464
  const soft = peer === "@base-ui/react"; // Only Accordion and Tabs need it.
331
- const meta = readJson(join(appRoot, "node_modules", ...peer.split("/"), "package.json"));
465
+ const at = resolveDep(appRoot, peer);
466
+ const meta = at ? readJson(join(at, "package.json")) : null;
332
467
  if (!meta) {
333
468
  out.push(
334
469
  finding(
@@ -345,25 +480,80 @@ const checkInstall = (appRoot) => {
345
480
  return out;
346
481
  };
347
482
 
348
- const checkStyles = (appRoot) => {
483
+ const checkStyles = (appRoot, major) => {
349
484
  const out = [];
350
485
  const cssFile = findCssEntry(appRoot);
351
486
  if (!cssFile) {
352
487
  out.push(
353
- finding("error", "no CSS entry importing tailwindcss", "looked under app/, src/, styles/", "Create one (app/global.css), then run `foundations init`."),
488
+ finding(
489
+ "error",
490
+ "no CSS entry importing Tailwind",
491
+ `looked for a ${STYLE_EXTS.join(", ")} file under ${appRoot}`,
492
+ "Create one (app/globals.css), then run `foundations init`.",
493
+ ),
354
494
  );
355
495
  return out;
356
496
  }
357
497
 
358
498
  const css = stripComments(read(cssFile) ?? "");
359
499
  const rel = relative(appRoot, cssFile);
500
+
501
+ // Every check below reads a v4 cascade. Running them against a v3 stylesheet
502
+ // reports four separate failures that are all one cause, and buries it.
503
+ if (major !== null && major < 4) {
504
+ out.push(
505
+ finding(
506
+ "error",
507
+ `${rel} is a Tailwind v${major} stylesheet, and the package needs v4`,
508
+ TAILWIND_WHY,
509
+ TAILWIND_UPGRADE,
510
+ ),
511
+ );
512
+ return out;
513
+ }
360
514
  const order = importsOf(css);
515
+ const tailwindAt = order.reduce((at, spec, i) => (isTailwindImport(spec) ? i : at), -1);
516
+
517
+ // Applies to either shape, so it is asked before they diverge.
518
+ if (/@custom-variant\s+dark/.test(css)) {
519
+ out.push(
520
+ finding(
521
+ "warn",
522
+ "this app declares its own dark variant",
523
+ rel,
524
+ "tokens.css already binds dark: to the .dark class. With two declarations the later one wins, and nothing tells you which.",
525
+ ),
526
+ );
527
+ }
528
+
529
+ // The bundle carries the @source line and fixes the order inside the package,
530
+ // so the only thing an app can still get wrong is putting it before Tailwind.
531
+ const bundleAt = order.findIndex(isBundle);
532
+ if (bundleAt !== -1) {
533
+ out.push(
534
+ bundleAt < tailwindAt
535
+ ? finding(
536
+ "error",
537
+ `${BUNDLE} is imported before Tailwind`,
538
+ rel,
539
+ "It re-points variables Tailwind defines, so it has to come after.",
540
+ )
541
+ : finding("ok", "the style layer is complete", `${rel} → ${BUNDLE}`),
542
+ );
543
+ if (!order.some((spec) => OPTIONAL_CSS.has(spec))) {
544
+ out.push(
545
+ finding("info", `${[...OPTIONAL_CSS][0]} is not imported`, "only needed if you render code fences", "Add it when you add Shiki."),
546
+ );
547
+ }
548
+ return out;
549
+ }
550
+
361
551
  const seen = new Map(order.map((spec, i) => [spec, i]));
362
552
 
363
553
  const missing = [];
364
554
  /** Set when theme.css is absent but the app declares every role itself. */
365
555
  let selfPainted = false;
366
- let last = seen.get("tailwindcss") ?? -1;
556
+ let last = tailwindAt;
367
557
  for (const entry of cssEntries) {
368
558
  const at = seen.get(entry);
369
559
  if (at === undefined) {
@@ -423,17 +613,6 @@ const checkStyles = (appRoot) => {
423
613
  out.push(finding("ok", "@source scans the package", ours));
424
614
  }
425
615
 
426
- if (/@custom-variant\s+dark/.test(css)) {
427
- out.push(
428
- finding(
429
- "warn",
430
- "this app declares its own dark variant",
431
- rel,
432
- "tokens.css already binds dark: to the .dark class. With two declarations the later one wins, and nothing tells you which.",
433
- ),
434
- );
435
- }
436
-
437
616
  const required = missing.filter(
438
617
  (entry) => !OPTIONAL_CSS.has(entry) && !(entry === PALETTE_CSS && selfPainted),
439
618
  );
@@ -500,8 +679,16 @@ const expandImports = (file, { includePackage }, seen = new Set()) => {
500
679
  if (seen.has(file)) return "";
501
680
  seen.add(file);
502
681
  return (read(file) ?? "").replace(/@import\s+["']([^"']+)["'];?/g, (_line, spec) => {
503
- if (spec.startsWith(`${PKG_NAME}/`))
504
- return includePackage ? (read(join(pkgRoot, "src", spec.slice(PKG_NAME.length + 1))) ?? "") : "";
682
+ // The bundle is ours too, and it pulls the rest in relatively — so it has to
683
+ // be followed rather than read. Reading it would yield four @import lines
684
+ // and no palette, and every role would measure as unpainted.
685
+ const ours = isBundle(spec)
686
+ ? "index.css"
687
+ : spec.startsWith(`${PKG_NAME}/`)
688
+ ? spec.slice(PKG_NAME.length + 1)
689
+ : null;
690
+ if (ours !== null)
691
+ return includePackage ? expandImports(join(pkgRoot, "src", ours), { includePackage }, seen) : "";
505
692
  if (spec.startsWith(".")) return expandImports(resolve(dirname(file), spec), { includePackage }, seen);
506
693
  return "";
507
694
  });
@@ -561,10 +748,14 @@ const serif = Average({ variable: "--font-average", weight: "400", subsets: ["la
561
748
  <html className={\`\${sans.variable} \${mono.variable} \${serif.variable} font-sans\`}>`;
562
749
 
563
750
  const doctor = async (appRoot) => {
751
+ // Asked once, because it decides what the rest of the report can mean.
752
+ const major = tailwindOf(appRoot, findCssEntry(appRoot));
564
753
  const install = checkInstall(appRoot);
565
- const styles = checkStyles(appRoot);
754
+ const styles = checkStyles(appRoot, major);
566
755
  const fonts = checkFonts(appRoot);
567
- const contrast = await checkContrast(appRoot);
756
+ // On v3 the cascade never assembles, so measuring the colours it did not
757
+ // produce would fill the report with failures that all have one cause.
758
+ const contrast = major !== null && major < 4 ? [] : await checkContrast(appRoot);
568
759
  const all = [...install, ...styles, ...fonts, ...contrast];
569
760
 
570
761
  console.log(`\n${bold(PKG_NAME)} ${dim(`doctor · ${appRoot}`)}`);
@@ -588,12 +779,27 @@ const doctor = async (appRoot) => {
588
779
 
589
780
  const init = (appRoot, { dryRun }) => {
590
781
  const cssFile = findCssEntry(appRoot);
782
+ const major = tailwindOf(appRoot, cssFile);
783
+
784
+ // Patching a v3 stylesheet would trade a working build for a pile of parse
785
+ // errors, so this stops at the diagnosis instead. Nothing is written.
786
+ if (major !== null && major < 4) {
787
+ console.log(`\n${paint(31, "✖")} this app is on ${bold(`Tailwind v${major}`)}, and the package needs v4.`);
788
+ if (cssFile) console.log(` ${dim(`${relative(appRoot, cssFile)} is the entry, and it is in the v3 dialect.`)}`);
789
+ console.log(` ${dim(TAILWIND_WHY)} ${dim("On v3 they are parse errors.")}`);
790
+ console.log(`\n ${dim("→")} ${TAILWIND_UPGRADE}`);
791
+ console.log(`\nThen run ${bold("foundations init")} again.\n`);
792
+ return 1;
793
+ }
794
+
591
795
  if (!cssFile) {
592
- console.log(`\nNo CSS entry importing tailwindcss under ${appRoot}.`);
593
- console.log("Create app/global.css with:\n");
594
- console.log(
595
- ['@import "tailwindcss";', ...cssEntries.map((e) => `@import "${e}";`), "", `@source '../node_modules/${PKG_NAME}/dist/**/*.js';`].join("\n"),
596
- );
796
+ // Named where the scaffolds actually put it, so the instruction matches the
797
+ // file the reader is about to create.
798
+ const target = existsSync(join(appRoot, "src/app")) ? "src/app/globals.css" : "app/globals.css";
799
+ console.log(`\nNo CSS entry importing Tailwind under ${appRoot}.`);
800
+ console.log(dim(`Looked for a ${STYLE_EXTS.join(", ")} file that imports it.`));
801
+ console.log(`\nCreate ${bold(target)} with:\n`);
802
+ console.log(`@import "tailwindcss";\n@import "${BUNDLE}";`);
597
803
  console.log();
598
804
  return 1;
599
805
  }
@@ -602,13 +808,52 @@ const init = (appRoot, { dryRun }) => {
602
808
  const live = stripComments(before);
603
809
  const rel = relative(appRoot, cssFile);
604
810
  const lines = before.split("\n");
811
+ const present = new Set(importsOf(live));
605
812
 
606
- // Lift the package's own @import lines out, then lay them back down in the
607
- // one order the cascade accepts. Lifting the whole LINE keeps a trailing
608
- // comment attached to the import a consumer wrote it against, and makes a
609
- // file that is merely out of order repairable rather than only diagnosable.
813
+ /** After Tailwind itself: everything the package ships re-points its variables. */
814
+ const anchorIn = (from) =>
815
+ from.reduce((at, line, i) => (importsOf(stripComments(line)).some(isTailwindImport) ? i : at), -1);
816
+
817
+ if ([...present].some(isBundle)) {
818
+ console.log(`\n${rel} already imports the style layer.`);
819
+ } else if (!cssEntries.some((entry) => present.has(entry))) {
820
+ // Nothing of ours in the file yet, so it gets the single import. There is
821
+ // no order to arrange and no @source to compute: the package does both.
822
+ const line = `@import "${BUNDLE}";`;
823
+ lines.splice(anchorIn(lines) + 1, 0, line);
824
+ if (dryRun) {
825
+ console.log(`\n${bold(rel)} ${dim("(dry run — nothing written)")}`);
826
+ } else {
827
+ writeFileSync(cssFile, lines.join("\n"));
828
+ console.log(`\n${paint(32, "✔")} patched ${bold(rel)}`);
829
+ }
830
+ console.log(` ${paint(32, "+")} ${line}`);
831
+ } else {
832
+ legacy(appRoot, cssFile, { before, live, rel, lines, present, anchorIn, dryRun });
833
+ }
834
+
835
+ console.log(`\n${bold("Bind the fonts")} in your root layout. The package cannot load them for you:\n`);
836
+ console.log(fontSnippet());
837
+ console.log(`\n${bold("If you use a coding agent")}, point it at the API summary:\n`);
838
+ console.log(` ${dim("# CLAUDE.md, AGENTS.md, or your agent's equivalent")}`);
839
+ console.log(` @node_modules/${PKG_NAME}/llms.txt`);
840
+ console.log(`\nThen: ${bold("foundations doctor")}\n`);
841
+ return 0;
842
+ };
843
+
844
+ /**
845
+ * The old shape: the four entry points written out by the consumer, plus an
846
+ * `@source` line it had to path itself. Still supported, and still repaired —
847
+ * an app on it is not broken, and rewriting a file someone else wrote is not
848
+ * this command's call. `init` only offers the one-line form.
849
+ *
850
+ * Lift the package's own @import lines out, then lay them back down in the
851
+ * one order the cascade accepts. Lifting the whole LINE keeps a trailing
852
+ * comment attached to the import a consumer wrote it against, and makes a file
853
+ * that is merely out of order repairable rather than only diagnosable.
854
+ */
855
+ const legacy = (appRoot, cssFile, { live, rel, lines, present, anchorIn, dryRun }) => {
610
856
  const existing = new Map();
611
- const present = new Set(importsOf(live));
612
857
  const kept = lines.filter((line) => {
613
858
  const [spec] = importsOf(stripComments(line));
614
859
  // `present` keeps a commented-out import where it is instead of reviving it.
@@ -635,9 +880,7 @@ const init = (appRoot, { dryRun }) => {
635
880
  if (!added.length && !reordered && !needsSource) {
636
881
  console.log(`\n${rel} already imports the style layer, in order, and scans the package.`);
637
882
  } else {
638
- // After Tailwind itself: the tokens re-point variables it defines.
639
- const anchor = kept.reduce((at, line, i) => (/@import\s+["']tailwindcss["']/.test(line) ? i : at), -1);
640
- kept.splice(anchor + 1, 0, ...block);
883
+ kept.splice(anchorIn(kept) + 1, 0, ...block);
641
884
 
642
885
  if (dryRun) {
643
886
  console.log(`\n${bold(rel)} ${dim("(dry run — nothing written)")}`);
@@ -652,18 +895,9 @@ const init = (appRoot, { dryRun }) => {
652
895
  if (reordered) console.log(` ${dim("reordered: later files re-point variables the earlier ones define")}`);
653
896
  }
654
897
 
655
- console.log(`\n${bold("Bind the fonts")} in your root layout. The package cannot load them for you:\n`);
656
- console.log(fontSnippet());
657
-
658
- // The package ships an llms.txt for whatever coding agent the app runs. It is
659
- // only useful if the agent is pointed at it, and that is one line in a file
660
- // this command should not edit on its own.
661
- console.log(`\n${bold("If you use a coding agent")}, point it at the API summary:\n`);
662
- console.log(` ${dim("# CLAUDE.md, AGENTS.md, or your agent's equivalent")}`);
663
- console.log(` @node_modules/${PKG_NAME}/llms.txt`);
664
-
665
- console.log(`\nThen: ${bold("foundations doctor")}\n`);
666
- return 0;
898
+ console.log(
899
+ `\n${dim(`These ${cssEntries.filter((e) => !OPTIONAL_CSS.has(e)).length} lines and the @source can now be one: @import "${BUNDLE}";`)}`,
900
+ );
667
901
  };
668
902
 
669
903
  const usage = () => {
@@ -682,7 +916,9 @@ Options
682
916
  /* ------------------------------------------------------------------- entry */
683
917
 
684
918
  const args = process.argv.slice(2);
685
- const command = args.find((a) => !a.startsWith("--")) ?? "help";
919
+ /** Flags that take a value, so the value is not mistaken for the command. */
920
+ const VALUED = new Set(["--cwd"]);
921
+ const command = args.find((a, i) => !a.startsWith("--") && !VALUED.has(args[i - 1])) ?? "help";
686
922
  const flag = (name) => args.includes(`--${name}`);
687
923
  const value = (name) => {
688
924
  const at = args.indexOf(`--${name}`);
@@ -115,7 +115,7 @@ export declare function TypographyLabel({ className, size, as, children, ...prop
115
115
  * the tile around it already sets. Pinned tight, for the reason a badge is.
116
116
  */
117
117
  declare const statVariants: (props?: ({
118
- size?: "display" | "base" | "section" | "inherit" | "page" | "sm" | "xs" | "2xs" | "3xs" | "lg" | "xl" | "2xl" | "card" | "panel" | null | undefined;
118
+ size?: "display" | "base" | "section" | "inherit" | "page" | "sm" | "xs" | "2xs" | "3xs" | "lg" | "xl" | "2xl" | "3xl" | "card" | "panel" | null | undefined;
119
119
  tone?: "muted" | "default" | null | undefined;
120
120
  figures?: "tabular" | "proportional" | null | undefined;
121
121
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
@@ -191,6 +191,7 @@ const statVariants = cva("tracking-tight leading-none", {
191
191
  lg: "text-lg",
192
192
  xl: "text-xl",
193
193
  "2xl": "text-2xl",
194
+ "3xl": "text-3xl",
194
195
  card: "text-h4",
195
196
  panel: "text-h3",
196
197
  section: "text-h2",
package/llms.txt CHANGED
@@ -94,7 +94,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
94
94
  prop each one pins is dropped from its type, so passing it fails to compile.
95
95
  - `TypographyCaption`, `TypographyLabel`, `TypographySmall`: `size?: "sm" | "xs" | "2xs" | "inherit"`, `as?`.
96
96
  - `TypographyEyebrow`: `tone?: "heading" | "label" | "muted" | "subtle"`, `size?: "sm" | "xs" | "2xs" | "3xs"`, `as?`. Each tone carries the rung it is usually set at and `size` overrides it, so omitting it changes nothing. Reach for `muted` for the uppercase micro-label a dense product sets over a group of controls, and `subtle` for a column head or rail marker read on the way past — that shape hand-rolled is the most common way an app ends up spelling type classes.
97
- - `TypographyStat`: `size?: "inherit" | "3xs" | "2xs" | "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "card" | "panel" | "section" | "page" | "display"`, `figures?: "tabular" | "proportional"`, `tone?: "default" | "muted"`. Two ladders in one axis: the rung names are the body ramp, for a figure beside interface copy it should step with, and the surface names ride the heading ladder, for a figure that is the headline. `tone="muted"` is the qualifier after a figure, "of 2,000" beside "1,284": still tight, no longer competing. Keep tabular where a value updates in place.
97
+ - `TypographyStat`: `size?: "inherit" | "3xs" | "2xs" | "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "card" | "panel" | "section" | "page" | "display"`, `figures?: "tabular" | "proportional"`, `tone?: "default" | "muted"`. Two ladders in one axis: the rung names are the body ramp, for a figure beside interface copy it should step with, and the surface names ride the heading ladder, for a figure that is the headline. `tone="muted"` is the qualifier after a figure, "of 2,000" beside "1,284": still tight, no longer competing. Keep tabular where a value updates in place.
98
98
  - `TypographyLink`: `href` (required), `tone?: Tone` (default `muted`), `addArrow?`, `newTab?`. The href decides internal versus external.
99
99
  - `TypographyHighlight`: `tone?: HighlightTone`, one of `"primary" | "success" | "ochre" | "terracotta" | "sage" | "fig"`, plus `seed?: number`. A separate type from `Tone` on purpose: this axis is categorical (which one it is) where `Tone` is semantic (what it means), the same split theme.css draws between the earth swatches and the status tokens.
100
100
  - `Card`: `href`, `title`, `description`, `icon`, `external`. An href makes the whole card a link.
@@ -187,14 +187,16 @@ Code samples and commit messages stay conventional. This is about prose.
187
187
  - `<TypographyMuted tone="default">`: a type error. Use `TypographyP`.
188
188
  - Binding fonts with `font.className` instead of `font.variable`. The className
189
189
  form sets `font-family` on the element and leaves the roles unresolved.
190
- - Omitting `@source '../node_modules/@supertype.ai/foundations/dist/**/*.js'` from
191
- the CSS entry. Tailwind then purges every class the package ships.
192
- - Omitting `@import "@supertype.ai/foundations/theme.css"`. `tokens.css` names the
193
- colour roles but holds no values, so the whole palette resolves to nothing.
190
+ - Writing the style layer out by hand. `@import "@supertype.ai/foundations";` after
191
+ `@import "tailwindcss";` is the whole of it: it carries tokens, theme, type and
192
+ prose in order and registers the package's own `@source`. Split into the granular
193
+ imports, an app owns the order, the palette and the scan path and a wrong
194
+ `@source` path purges every class the package ships, silently.
194
195
  - Using `Accordion` for a static FAQ. `Disclosure` runs on the browser alone.
195
196
  - Adding a second `@custom-variant dark`. `tokens.css` already binds it.
196
197
 
197
- Run `npx foundations doctor` in the app to check the last four.
198
+ Run `npx foundations doctor` in the app to check the font binding, the style layer
199
+ and the dark variant.
198
200
 
199
201
  ## Surfaces
200
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supertype.ai/foundations",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -38,9 +38,11 @@
38
38
  },
39
39
  "exports": {
40
40
  ".": {
41
+ "style": "./src/index.css",
41
42
  "types": "./dist/index.d.ts",
42
43
  "default": "./dist/index.js"
43
44
  },
45
+ "./index.css": "./src/index.css",
44
46
  "./blocks": {
45
47
  "types": "./dist/blocks/index.d.ts",
46
48
  "default": "./dist/blocks/index.js"
@@ -109,7 +111,8 @@
109
111
  "@base-ui/react": ">=1.4",
110
112
  "next": ">=15",
111
113
  "next-view-transitions": ">=0.3",
112
- "react": ">=19"
114
+ "react": ">=19",
115
+ "tailwindcss": ">=4"
113
116
  },
114
117
  "devDependencies": {
115
118
  "@base-ui/react": "^1.4.1",
package/src/index.css ADDED
@@ -0,0 +1,37 @@
1
+ /* The whole style layer, in one import.
2
+ *
3
+ * An app used to write these four imports itself, in this order, plus an
4
+ * `@source` line pointing back into node_modules — and every part of that was
5
+ * something to get wrong. The order is a cascade, so a file out of place
6
+ * re-points variables an earlier one defines. The `@source` path is relative to
7
+ * the app's CSS file, so it moved with the app's layout and broke outright in a
8
+ * workspace, where the package hoists to the repo root: Tailwind then scanned
9
+ * nothing, purged every class this package ships, and rendered the components
10
+ * unstyled with no error anywhere.
11
+ *
12
+ * None of it was ever the app's business. Tailwind v4 resolves `@source`
13
+ * relative to the file that declares it, so the package can register its own
14
+ * content — and then the order, the path and the completeness are all decided
15
+ * here, where they are the same for everyone.
16
+ *
17
+ * @import "tailwindcss";
18
+ * @import "@supertype.ai/foundations";
19
+ *
20
+ * shiki.css is deliberately not here: an app that renders no code fences should
21
+ * not pay for it. Import it separately when you add Shiki.
22
+ *
23
+ * The granular entry points remain exported, for the one app in a hundred that
24
+ * paints every colour role itself and wants tokens.css without theme.css.
25
+ */
26
+ @import "./tokens.css";
27
+ @import "./theme.css";
28
+ @import "./type.css";
29
+ @import "./prose.css";
30
+
31
+ /* Relative to this file, so it is right wherever the package is installed.
32
+ *
33
+ * Narrowed to `.js` rather than pointing at the directory: Tailwind reads every
34
+ * source file as plain text, so scanning all of dist/ hands it the prose in the
35
+ * .d.ts doc comments and it emits rules for words that happen to look like
36
+ * classes. `check-candidates.mjs` guards the same boundary from the other side. */
37
+ @source "../dist/**/*.js";