@supertype.ai/foundations 0.1.35 → 0.1.37

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.35"
77
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.37"
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}`);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * How far a centred mark misses the letters, per rung of a type ramp.
3
+ *
4
+ * `items-center` centres boxes, and the box is the line box: leading, ascent and
5
+ * descent, only some of which the letters use. The mark beside a label therefore
6
+ * centres on the font's box rather than on the band of ink a reader sees, and
7
+ * whether those two agree is a property of the font, not of the design.
8
+ *
9
+ * The whole tool is one line of arithmetic, and the rounding is the reason it is
10
+ * worth shipping. Ratios alone give one constant tilt for a face, 0.0235em for
11
+ * Ubuntu Sans, which reads as every rung being equally out. Browsers quantise
12
+ * ascent, descent and cap height to whole pixels before they lay a line out, and
13
+ * rounded, that same face is half a pixel out at 11px, flat at 13px and half a
14
+ * pixel out again at 22px. The rendered pages agree, so the rounding is what
15
+ * separates a rung that needs the trim from one where it buys nothing.
16
+ *
17
+ * Build-time only, and only as good as the metrics handed to it. Verify cap
18
+ * height against the browser rather than a table: `next/font` ships 693 for
19
+ * Ubuntu Sans where canvas measures 727, and the wrong one flips the answer at
20
+ * every rung.
21
+ */
22
+ /** A font's vertical metrics, in font units. The four numbers every metrics
23
+ * table carries, `next/font`'s and capsize's alike. */
24
+ export interface FontMetrics {
25
+ unitsPerEm: number;
26
+ ascent: number;
27
+ descent: number;
28
+ capHeight: number;
29
+ }
30
+ /** One step of a ramp: the name an app knows it by, and its size in px. */
31
+ export interface TypeRung {
32
+ name: string;
33
+ fontSize: number;
34
+ }
35
+ export interface OpticalOffset extends TypeRung {
36
+ /** How far below the cap band's centre a centred mark sits, in px. Positive is
37
+ * low, which is the direction rounding takes it. */
38
+ offset: number;
39
+ /** The same miss against the height of the letters it misses. Half a pixel is
40
+ * a twelfth of an 11px cap band and a thirty-second of a 36px one, so this is
41
+ * the number that says whether a reader sees it. */
42
+ share: number;
43
+ /** The rounded metrics the offset came out of, for a message worth reading. */
44
+ used: {
45
+ ascent: number;
46
+ descent: number;
47
+ capHeight: number;
48
+ };
49
+ }
50
+ /**
51
+ * The gap between the line box's centre and the cap band's, at one size.
52
+ *
53
+ * Half-leading cancels, so line height does not appear: a rung that is out stays
54
+ * out however loosely it is set, and no retune of the ramp's leading fixes it.
55
+ * Paint rounds the baseline a second time, in the same direction, so treat this
56
+ * as the floor of the error rather than the whole of it.
57
+ */
58
+ export declare function capBandOffset(metrics: FontMetrics, fontSize: number): number;
59
+ /**
60
+ * Every rung whose centred mark misses the letters by enough to see, worst first.
61
+ *
62
+ * A rung that comes back is one where an icon, a badge or a swatch set beside the
63
+ * text with `items-center` wants `CAP_TRIM` on the text to land on it. Most rungs
64
+ * of a ramp are half a pixel out, so the pixel is not the question: `tolerance` is
65
+ * a share of the cap band, and 0.05 is where a miss stops reading as a rounding
66
+ * artefact and starts reading as two things that do not line up. Raise it for a
67
+ * surface that only sets headlines, lower it to see the whole ramp.
68
+ */
69
+ export declare function checkOptical(metrics: FontMetrics, rungs: readonly TypeRung[], { tolerance }?: {
70
+ tolerance?: number;
71
+ }): OpticalOffset[];
72
+ /** The failures as lines, on the model of `formatFailures` in contrast.ts. */
73
+ export declare function formatOffsets(offsets: readonly OpticalOffset[]): string;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * How far a centred mark misses the letters, per rung of a type ramp.
3
+ *
4
+ * `items-center` centres boxes, and the box is the line box: leading, ascent and
5
+ * descent, only some of which the letters use. The mark beside a label therefore
6
+ * centres on the font's box rather than on the band of ink a reader sees, and
7
+ * whether those two agree is a property of the font, not of the design.
8
+ *
9
+ * The whole tool is one line of arithmetic, and the rounding is the reason it is
10
+ * worth shipping. Ratios alone give one constant tilt for a face, 0.0235em for
11
+ * Ubuntu Sans, which reads as every rung being equally out. Browsers quantise
12
+ * ascent, descent and cap height to whole pixels before they lay a line out, and
13
+ * rounded, that same face is half a pixel out at 11px, flat at 13px and half a
14
+ * pixel out again at 22px. The rendered pages agree, so the rounding is what
15
+ * separates a rung that needs the trim from one where it buys nothing.
16
+ *
17
+ * Build-time only, and only as good as the metrics handed to it. Verify cap
18
+ * height against the browser rather than a table: `next/font` ships 693 for
19
+ * Ubuntu Sans where canvas measures 727, and the wrong one flips the answer at
20
+ * every rung.
21
+ */
22
+ const px = (value, fontSize, unitsPerEm) => Math.round((value * fontSize) / unitsPerEm);
23
+ /**
24
+ * The gap between the line box's centre and the cap band's, at one size.
25
+ *
26
+ * Half-leading cancels, so line height does not appear: a rung that is out stays
27
+ * out however loosely it is set, and no retune of the ramp's leading fixes it.
28
+ * Paint rounds the baseline a second time, in the same direction, so treat this
29
+ * as the floor of the error rather than the whole of it.
30
+ */
31
+ export function capBandOffset(metrics, fontSize) {
32
+ const ascent = px(metrics.ascent, fontSize, metrics.unitsPerEm);
33
+ const descent = px(metrics.descent, fontSize, metrics.unitsPerEm);
34
+ const capHeight = px(metrics.capHeight, fontSize, metrics.unitsPerEm);
35
+ return capHeight / 2 - (ascent - descent) / 2;
36
+ }
37
+ /**
38
+ * Every rung whose centred mark misses the letters by enough to see, worst first.
39
+ *
40
+ * A rung that comes back is one where an icon, a badge or a swatch set beside the
41
+ * text with `items-center` wants `CAP_TRIM` on the text to land on it. Most rungs
42
+ * of a ramp are half a pixel out, so the pixel is not the question: `tolerance` is
43
+ * a share of the cap band, and 0.05 is where a miss stops reading as a rounding
44
+ * artefact and starts reading as two things that do not line up. Raise it for a
45
+ * surface that only sets headlines, lower it to see the whole ramp.
46
+ */
47
+ export function checkOptical(metrics, rungs, { tolerance = 0.05 } = {}) {
48
+ return rungs
49
+ .map((rung) => {
50
+ const used = {
51
+ ascent: px(metrics.ascent, rung.fontSize, metrics.unitsPerEm),
52
+ descent: px(metrics.descent, rung.fontSize, metrics.unitsPerEm),
53
+ capHeight: px(metrics.capHeight, rung.fontSize, metrics.unitsPerEm),
54
+ };
55
+ const offset = used.capHeight / 2 - (used.ascent - used.descent) / 2;
56
+ return { ...rung, offset, share: Math.abs(offset) / used.capHeight, used };
57
+ })
58
+ .filter((rung) => rung.share > tolerance)
59
+ .sort((a, b) => b.share - a.share);
60
+ }
61
+ /** The failures as lines, on the model of `formatFailures` in contrast.ts. */
62
+ export function formatOffsets(offsets) {
63
+ return offsets
64
+ .map(({ name, fontSize, offset, used }) => ` ${name} (${fontSize}px) mark sits ${Math.abs(offset)}px ${offset > 0 ? "below" : "above"} the cap band` +
65
+ `, ${Math.round((Math.abs(offset) / used.capHeight) * 100)}% of it` +
66
+ ` [ascent ${used.ascent}, descent ${used.descent}, cap ${used.capHeight}]`)
67
+ .join("\n");
68
+ }
@@ -2,3 +2,4 @@ export type { TypographyTag } from "./as.js";
2
2
  export * from "./header.js";
3
3
  export * from "./paragraph.js";
4
4
  export * from "./highlight.js";
5
+ export * from "./trim.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./header.js";
2
2
  export * from "./paragraph.js";
3
3
  export * from "./highlight.js";
4
+ export * from "./trim.js";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The optical box: cap top to baseline, with the leading either side removed.
3
+ *
4
+ * A line box reserves room for the ascenders and descenders a string may not
5
+ * use, so `items-center` beside an icon centres that reservation rather than
6
+ * the letters. On an 11px uppercase label the mark next to it renders about a
7
+ * pixel low, which is a whole device pixel against an eight pixel cap band.
8
+ * Trimming makes the element as tall as its own ink, so the row centres what
9
+ * the reader actually sees.
10
+ *
11
+ * Two things follow. It goes on the text element, since `text-box` is not
12
+ * inherited and a row cannot hand it down. And it shortens that element, so a
13
+ * row of trimmed text needs a height floor of its own: without one, the card
14
+ * whose label carries no mark sits shorter than the three beside it and its
15
+ * figure rides high. Chrome and Safari trim, and a browser that does not know
16
+ * `text-box` keeps the untrimmed box, which is the behaviour of every consumer
17
+ * today.
18
+ *
19
+ * Never on a string that also clips. The bottom edge is the baseline, so
20
+ * descenders sit outside the box, and `truncate` or any other overflow hidden
21
+ * cuts the tails off every g and p in it.
22
+ *
23
+ * Uppercase is where it pays. A cap band fills half of an 11px line box and the
24
+ * mark beside it lands a whole device pixel low, where 13px mixed case measures
25
+ * the same trimmed or not: ascenders reach the top of the line box on their own,
26
+ * so there is little leading left to take.
27
+ */
28
+ export declare const CAP_TRIM = "[text-box:trim-both_cap_alphabetic]";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The optical box: cap top to baseline, with the leading either side removed.
3
+ *
4
+ * A line box reserves room for the ascenders and descenders a string may not
5
+ * use, so `items-center` beside an icon centres that reservation rather than
6
+ * the letters. On an 11px uppercase label the mark next to it renders about a
7
+ * pixel low, which is a whole device pixel against an eight pixel cap band.
8
+ * Trimming makes the element as tall as its own ink, so the row centres what
9
+ * the reader actually sees.
10
+ *
11
+ * Two things follow. It goes on the text element, since `text-box` is not
12
+ * inherited and a row cannot hand it down. And it shortens that element, so a
13
+ * row of trimmed text needs a height floor of its own: without one, the card
14
+ * whose label carries no mark sits shorter than the three beside it and its
15
+ * figure rides high. Chrome and Safari trim, and a browser that does not know
16
+ * `text-box` keeps the untrimmed box, which is the behaviour of every consumer
17
+ * today.
18
+ *
19
+ * Never on a string that also clips. The bottom edge is the baseline, so
20
+ * descenders sit outside the box, and `truncate` or any other overflow hidden
21
+ * cuts the tails off every g and p in it.
22
+ *
23
+ * Uppercase is where it pays. A cap band fills half of an 11px line box and the
24
+ * mark beside it lands a whole device pixel low, where 13px mixed case measures
25
+ * the same trimmed or not: ascenders reach the top of the line box on their own,
26
+ * so there is little leading left to take.
27
+ */
28
+ export const CAP_TRIM = "[text-box:trim-both_cap_alphabetic]";
package/llms.txt CHANGED
@@ -67,6 +67,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
67
67
  | an article whose body is prose or MDX | `EssayHeader` + `ReadingLayout` | `/essay` |
68
68
  | a post meta row (date, read time, tags) | `PostMetaRow` and friends | `/essay` |
69
69
  | a table of contents | `TableOfContents`, `ReadingRail` | `/essay` |
70
+ | where a centred mark lands on a rung | `checkOptical` | `/optical` |
70
71
  | page metadata and JSON-LD | `createSeo` | `/seo` |
71
72
  | an OG image | `ogCard`, `OG_SIZE` | `/og` |
72
73
  | to merge classnames | `cn` | root |
@@ -75,13 +76,14 @@ silently. Full reference: https://github.com/supertypeai/foundations
75
76
 
76
77
  | import | exports |
77
78
  |---|---|
78
- | `@supertype.ai/foundations` | `cn`, `TypographyH1`, `TypographyH2`, `TypographyH3`, `TypographyH4`, `TypographyEyebrow`, `TypographyP`, `TypographyMuted`, `TypographyProse`, `TypographyList`, `TypographyProseList`, `TypographyCaption`, `TypographySmall`, `TypographyLabel`, `TypographyStat`, `TypographyInlineCode`, `TypographyLink`, `TypographyHighlight`, `headingClass`, `headingFace`, `eyebrowClass`, `toneClass`, `impliedTone`, `resolveLink`, `isExternalHref`. Types: `TypographyTag`, `ParagraphVariants`, `ListProps`, `CaptionVariants`, `LabelVariants`, `StatVariants`, `Tone`, `HighlightTone`, `LinkBehavior`, `ResolvedLink` |
79
+ | `@supertype.ai/foundations` | `cn`, `TypographyH1`, `TypographyH2`, `TypographyH3`, `TypographyH4`, `TypographyEyebrow`, `TypographyP`, `TypographyMuted`, `TypographyProse`, `TypographyList`, `TypographyProseList`, `TypographyCaption`, `TypographySmall`, `TypographyLabel`, `TypographyStat`, `TypographyInlineCode`, `TypographyLink`, `TypographyHighlight`, `headingClass`, `headingFace`, `eyebrowClass`, `toneClass`, `impliedTone`, `resolveLink`, `isExternalHref`, `CAP_TRIM`. Types: `TypographyTag`, `ParagraphVariants`, `ListProps`, `CaptionVariants`, `LabelVariants`, `StatVariants`, `Tone`, `HighlightTone`, `LinkBehavior`, `ResolvedLink` |
79
80
  | `@supertype.ai/foundations/blocks` | `Anchor`, `Cards`, `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `Callout`, `Button`, `buttonVariants`, `Badge`, `badgeVariants`, `Steps`, `Step`, `Disclosure`, `DisclosureGroup`, `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`, `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`, `TabGroup`, `SEGMENT`, `Bulletin`, `Ribbon`, `EDITORIAL_INKS`, `Colophon`, `BuiltWithFoundations`, `FoundationsMark`, `FOUNDATIONS_URL`. Types: `ButtonLook`, `BadgeLook`, `TabItem`, `BulletinProps`, `BulletinPoint`, `RibbonHue`, `ColophonProps` |
80
81
  | `@supertype.ai/foundations/mdx` | `proseMdxComponents` |
81
82
  | `@supertype.ai/foundations/essay` | `createEssay`, `EssayHeader`, `EssayLayout`, `EssaySection`, `EssayPullQuote`, `EssayFigure`, `EssayMovements`, `EssayDocument`, `EssayColumns`, `EssayAside`, `EssayBody`, `ReadingLayout`, `TableOfContents`, `ReadingRail`, `ReadingProgressBar`, `Rail`, `RailLink`, `PostMetaRow`, `PostDate`, `ReadTime`, `TagPills`, `MetaDot`, `formatPostDate`, `extractHeadings`, `readingTime`, `createSlugger`, `useReadingProgress`, `useScrollSpy`. Types: `TocHeading`, `EssayDecorations`, `EssayIndexEntry`, `EssayDocSection`, `EssayMovement`, `PostDateFormat` |
82
83
  | `@supertype.ai/foundations/seo` | `createSeo`. Types: `SeoConfig`, `ArticleAuthor`, `ArticleOptions`, `PageMetadata` |
83
84
  | `@supertype.ai/foundations/og` | `ogCard`, `OG_SIZE`. Types: `OgCardOptions` |
84
85
  | `@supertype.ai/foundations/eslint` | `designRules` (every rule as one array, the one to spread), `designConfig` (the same set wrapped as a flat-config entry). The builders `colourRules`, `typographyRules`, `linkRules`, `themeOverrideRules`, `surfaceAsInkRules`, `renamedTokenRules` are exported too, though spreading them by hand is how a consumer ends up missing one. Types: `FlatConfigEntry`, `DesignRuleOptions`, `DesignConfigOptions`, `RestrictedSyntax`, `ColourOptions`, `TypographyOptions` |
86
+ | `@supertype.ai/foundations/optical` | `capBandOffset` (the gap between a line box's centre and its cap band's, at one size), `checkOptical` (every rung of a ramp whose centred mark misses the letters, worst first), `formatOffsets`. Types: `FontMetrics`, `TypeRung`, `OpticalOffset`. Build-time only |
85
87
  | `@supertype.ai/foundations/rehype` | `rehypeProseCode`, `proseCodeOptions`, `PROSE_LANGS`, `PROSE_THEMES`. Build-time only, must not resolve React |
86
88
  | `@supertype.ai/foundations/contrast` | `checkLegibility` (inks at 4.5:1), `checkSignals` (fills at 3:1 — status hues, the categorical earth hues and the six chart series alike — tinted inks at 4.5:1, `--subtle-foreground` at the 3:1 it is documented for, labels against their own fill: the tone rows are read off `TONE` and each cut is resolved along its `var()` fallback chain, so an app that declares `--brand` without `--brand-foreground` is measured on the label the cascade really reaches for rather than skipped), `checkHairlines` (`--border` and `--input` at 1.4:1 on `--background` and `--card`, `--sidebar-border` on `--sidebar`: a rule is exempt from the ink and mark bars, but it still has to read as the same weight in both themes), `resolveTokens`, `formatFailures`, `specificity`, `parseColor`, `luminance`, `contrast` (WCAG ratio), `lc` (APCA lightness contrast: polarity-aware, for checking that an ink ramp is perceptually ordered rather than merely ordered by ratio), `tokenCuts` (which cuts a token ships: fill, the label printed on it, the hue as words, the taxonomy `checkSignals` measures against). Types: `Rgb`, `Theme`, `LegibilityFailure`, `TokenCuts`. Build-time only |
87
89
 
@@ -103,6 +105,16 @@ silently. Full reference: https://github.com/supertypeai/foundations
103
105
  trigger. It exists so `target`/`rel` are never written at a call site; `external`
104
106
  is for a same-origin path that is not a route, which the router would prefetch.
105
107
  - `Tone` is the one semantic colour vocabulary, shared by `Button`, `Badge`, `Callout`, `TypographyLink` and `TabsList`: `"muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive"`, defaulting to `muted` everywhere except a solid `Button`. Seven tones, seven tokens, one to one, which is the bar for adding one. Four names map onto others: `neutral` and `foreground` are `muted`, the word the rest of the package uses; `accent` is `--primary`'s hover tint, so a washed `primary` renders the same thing; `info` is covered by the `success | warn | destructive` triad. `brand` falls back to `--primary` in an app that defines no `--brand`.
108
+ - **`CAP_TRIM` centres a mark on the letters.** `items-center` centres line
109
+ boxes, and a line box reserves room for ascenders and descenders the string may
110
+ not use, so an (i) or a badge beside an uppercase micro-label renders about a
111
+ pixel low. Put `CAP_TRIM` on the text element, never on the row, because
112
+ `text-box` is not inherited. It shortens the element, so give the row a height
113
+ floor from whatever sits beside it: a trimmed label with no mark next to it
114
+ makes a shorter row than its neighbours and the figure under it rides high.
115
+ Never put it on a string that clips: the box ends at the baseline, so `truncate`
116
+ cuts the tail off every descender. Which rungs need it is computed rather than
117
+ eyeballed: `checkOptical` from `/optical` reads a face's metrics and names them.
106
118
  - **Ink is handed down by whatever paints.** `toneClass(tone)` is a palette and sets no ink. A surface that fills adds `INK_ON_FILL`; a tinted one adds `INK_ON_CARD`, `INK_ON_POPOVER` or `INK_ON_SIDEBAR`, and one the package does not name spreads `inkOnSurfaceStyle(token)` into `style` rather than building a class. Both declare `--ink` and `--ink-muted`, which every type primitive reads, falling back to the page. Paint a background without them and a nested `TypographyLabel` prints `--foreground` on your fill, which measures 2.34:1 on `--primary`. On a hue fill `--ink-muted` equals `--ink`: a filled control has one ink, and wanting a second rung means wanting a tinted surface.
107
119
  - `Button`: `variant?: "solid" | "soft" | "outline" | "ghost" | "link"` (default `solid`), `tone?: Tone` (defaults to `primary` on a solid button and `muted` on every other variant — filling a button in is how a page says this is the action), `size?: "xs" | "sm" | "md" | "lg" | "xl"` (default `md`), `icon?: boolean` for a square glyph box, `pill?: boolean` for full-round corners, `href` to make it a link, `render` for an element that is neither a button nor a link. Variant is how much ink it spends and tone is what the ink means, on separate axes, so a quiet delete is `variant="ghost" tone="destructive"`.
108
120
  - `Badge`: `variant?: "solid" | "soft" | "outline" | "ghost"` (default `solid`), `tone?: Tone`, `size?: "xs" | "sm"` (default `sm`), `pill?: boolean`, `href` for a badge that leads somewhere. Same axes and same spellings as `Button`, minus `link`, which belongs to things you click. `warning` and `supertype` were `tone="warn"` and `tone="brand"` under invented names.
@@ -187,14 +199,16 @@ Code samples and commit messages stay conventional. This is about prose.
187
199
  - `<TypographyMuted tone="default">`: a type error. Use `TypographyP`.
188
200
  - Binding fonts with `font.className` instead of `font.variable`. The className
189
201
  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.
202
+ - Writing the style layer out by hand. `@import "@supertype.ai/foundations";` after
203
+ `@import "tailwindcss";` is the whole of it: it carries tokens, theme, type and
204
+ prose in order and registers the package's own `@source`. Split into the granular
205
+ imports, an app owns the order, the palette and the scan path and a wrong
206
+ `@source` path purges every class the package ships, silently.
194
207
  - Using `Accordion` for a static FAQ. `Disclosure` runs on the browser alone.
195
208
  - Adding a second `@custom-variant dark`. `tokens.css` already binds it.
196
209
 
197
- Run `npx foundations doctor` in the app to check the last four.
210
+ Run `npx foundations doctor` in the app to check the font binding, the style layer
211
+ and the dark variant.
198
212
 
199
213
  ## Surfaces
200
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supertype.ai/foundations",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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"
@@ -66,6 +68,10 @@
66
68
  "require": "./dist/cjs/eslint.js",
67
69
  "default": "./dist/eslint.js"
68
70
  },
71
+ "./optical": {
72
+ "types": "./dist/optical.d.ts",
73
+ "default": "./dist/optical.js"
74
+ },
69
75
  "./rehype": {
70
76
  "types": "./dist/rehype.d.ts",
71
77
  "default": "./dist/rehype.js"
@@ -109,7 +115,8 @@
109
115
  "@base-ui/react": ">=1.4",
110
116
  "next": ">=15",
111
117
  "next-view-transitions": ">=0.3",
112
- "react": ">=19"
118
+ "react": ">=19",
119
+ "tailwindcss": ">=4"
113
120
  },
114
121
  "devDependencies": {
115
122
  "@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";