@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 +42 -8
- package/bin/foundations.mjs +294 -58
- package/dist/optical.d.ts +73 -0
- package/dist/optical.js +68 -0
- package/dist/typography/index.d.ts +1 -0
- package/dist/typography/index.js +1 -0
- package/dist/typography/trim.d.ts +28 -0
- package/dist/typography/trim.js +28 -0
- package/llms.txt +20 -6
- package/package.json +9 -2
- package/src/index.css +37 -0
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
|
|
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.
|
|
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
|
|
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’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/
|
|
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
|
|
89
|
-
default, so without it the package’s classes get purged
|
|
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’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
|
package/bin/foundations.mjs
CHANGED
|
@@ -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
|
|
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
|
|
127
|
-
*
|
|
128
|
-
* the
|
|
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
|
-
/**
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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(
|
|
656
|
-
|
|
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
|
-
|
|
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;
|
package/dist/optical.js
ADDED
|
@@ -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
|
+
}
|
package/dist/typography/index.js
CHANGED
|
@@ -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
|
-
-
|
|
191
|
-
the
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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.
|
|
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";
|