@supertype.ai/foundations 0.1.36 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,10 +43,13 @@ npx @supertype.ai/foundations init # edits your CSS entry, prints the rest
43
43
  npx @supertype.ai/foundations doctor # checks this app against everything below
44
44
  ```
45
45
 
46
- `init` edits one file: the CSS entry that imports Tailwind. It adds the imports
47
- you are missing and reorders anything that is out of place. Run it with `--dry-run` first to preview the patch. Everything else it prints for you to paste is the font binding and the `llms.txt` lines for a coding agent.
46
+ `init` edits one file: the CSS entry that imports Tailwind. It adds any missing
47
+ imports and reorders the existing ones if needed. Run it with `--dry-run` first
48
+ to preview the patch. It also prints the font bindings and the `llms.txt` snippet
49
+ your coding agent should read.
48
50
 
49
- The steps performed by `init` are written out below anyway. See [the CLI](docs/cli.md) for the full list of checks and details.
51
+ The steps performed by `init` are listed below. See [the CLI](docs/cli.md) for
52
+ the full list of checks and details.
50
53
 
51
54
  ### 1. Add the package
52
55
 
@@ -74,7 +77,7 @@ untagged git dependency resolves to a different commit on a fresh install.
74
77
 
75
78
  ```jsonc
76
79
  // package.json
77
- "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.36"
80
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.2.0"
78
81
  ```
79
82
 
80
83
  </details>
@@ -89,10 +92,10 @@ untagged git dependency resolves to a different commit on a fresh install.
89
92
 
90
93
  That one line carries `tokens.css`, `theme.css`, `type.css` and `prose.css` in
91
94
  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.
95
+ Tailwind scans the components it ships. You do not need to work out a path or
96
+ keep an order: Tailwind v4 resolves `@source` relative to the file that declares
97
+ it, so the package points at its own `dist/`, correctly, wherever it happens to
98
+ be installed.
96
99
 
97
100
  Add `@import "@supertype.ai/foundations/shiki.css";` after it only if you render
98
101
  code fences.
@@ -239,8 +242,8 @@ Two rules cover most of the API:
239
242
  `yarn example` (above) builds the package, syncs it in and starts the dev
240
243
  server. `yarn example:build` is what CI would run.
241
244
 
242
- It installs the package from a git tag rather than from the registry and updates
243
- it with `yarn sync`.
245
+ It installs the package from a git tag rather than the registry and updates it
246
+ with `yarn sync`.
244
247
 
245
248
  `/recipes` holds whole pages rather than single components: a marketing hero, a
246
249
  metrics panel, pricing tiers, a docs page, an article index, and examples of MDX-rendered pages. Each one lives in
@@ -268,19 +271,19 @@ package.
268
271
 
269
272
  ## Entry points
270
273
 
271
- | import | contains | docs |
272
- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------- |
273
- | `@supertype.ai/foundations` | all typography primitives, `cn` | [Typography](docs/typography.md) |
274
+ | import | contains | docs |
275
+ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------- |
276
+ | `@supertype.ai/foundations` | all typography primitives, `cn` | [Typography](docs/typography.md) |
274
277
  | `@supertype.ai/foundations/blocks` | `Button`, `Badge`, `Card`, `Callout`, `Steps`, `TabGroup`, `Accordion`, `SEGMENT` | [Blocks](docs/blocks.md) |
275
- | `@supertype.ai/foundations/mdx` | `proseMdxComponents` — the MDX element map | [In MDX](docs/blocks.md#in-mdx) |
276
- | `@supertype.ai/foundations/essay` | the long-form shell, TOC, reading rail, post meta | [Essay](docs/essay.md) |
277
- | `@supertype.ai/foundations/seo` | `createSeo(...)` — metadata + JSON-LD | [Tooling](docs/tooling.md#seo-and-og-images) |
278
- | `@supertype.ai/foundations/og` | `ogCard`, `OG_SIZE` — an element for `next/og` | [Tooling](docs/tooling.md#seo-and-og-images) |
279
- | `@supertype.ai/foundations/eslint` | the design rules as ESLint selectors | [Tooling](docs/tooling.md#lint-rules) |
280
- | `@supertype.ai/foundations/rehype` | `rehypeProseCode` — **build-time only** | [In MDX](docs/blocks.md#in-mdx) |
281
- | `@supertype.ai/foundations/contrast` | token resolution + legibility checks, build-time only | [Tooling](docs/tooling.md#contrast-checks) |
282
- | `./tokens.css` `./theme.css` `./type.css` `./prose.css` `./shiki.css` | the style layer | [Tokens and theming](#tokens-and-theming) |
283
- | `foundations` (bin) | `init` and `doctor` | [The CLI](docs/cli.md) |
278
+ | `@supertype.ai/foundations/mdx` | `proseMdxComponents` — the MDX element map | [In MDX](docs/blocks.md#in-mdx) |
279
+ | `@supertype.ai/foundations/essay` | the long-form shell, TOC, reading rail, post meta | [Essay](docs/essay.md) |
280
+ | `@supertype.ai/foundations/seo` | `createSeo(...)` — metadata + JSON-LD | [Tooling](docs/tooling.md#seo-and-og-images) |
281
+ | `@supertype.ai/foundations/og` | `ogCard`, `OG_SIZE` — an element for `next/og` | [Tooling](docs/tooling.md#seo-and-og-images) |
282
+ | `@supertype.ai/foundations/eslint` | the design rules as ESLint selectors | [Tooling](docs/tooling.md#lint-rules) |
283
+ | `@supertype.ai/foundations/rehype` | `rehypeProseCode` — **build-time only** | [In MDX](docs/blocks.md#in-mdx) |
284
+ | `@supertype.ai/foundations/contrast` | token resolution + legibility checks, build-time only | [Tooling](docs/tooling.md#contrast-checks) |
285
+ | `./tokens.css` `./theme.css` `./type.css` `./prose.css` `./shiki.css` | the style layer | [Tokens and theming](#tokens-and-theming) |
286
+ | `foundations` (bin) | `init` and `doctor` | [The CLI](docs/cli.md) |
284
287
 
285
288
  ---
286
289
 
@@ -341,12 +344,12 @@ surfaces set body at different sizes: 13px in the product, 18px on `.editorial`.
341
344
 
342
345
  1. **The package owns its final classnames.** Retune with CSS custom properties
343
346
  (the `--text-*`, `--heading-weight`, the colour tokens) rather than by
344
- patching classes. A property the package declares is read by the package
345
- `test/tokens-live.test.ts` fails on one that is not, because a knob that
346
- turns nothing is worse than no knob at all.
347
+ patching classes. A property the package declares is read by the package; if a
348
+ knob turns nothing, `test/tokens-live.test.ts` fails on it; that is worse
349
+ than no knob at all.
347
350
  2. **No variant props on the MDX map.** Elements that MDX renders automatically
348
- take no options, because there is no call site to make the choice. Components
349
- you invoke by hand can have variants.
351
+ take no options. No call site exists to make the choice. Components you invoke
352
+ by hand can have variants.
350
353
  3. **Use the platform first, and a library only where it falls short.**
351
354
  `Disclosure` is a `<details>`/`<summary>` pair: no JavaScript, correct before
352
355
  hydration, and available to an MDX author. `Accordion` and `Tabs` use Base UI,
@@ -360,6 +363,38 @@ surfaces set body at different sizes: 13px in the product, 18px on `.editorial`.
360
363
 
361
364
  ---
362
365
 
366
+ ## Upgrading to 0.2
367
+
368
+ Two entry points lost exports. Both had one function doing the work and several
369
+ more standing beside it, and the extras are what a config got wrong.
370
+
371
+ **`/eslint` is one function.** `designRules` assembles every rule, so replace
372
+ `designConfig({ accents, weights })` with your own flat-config entry around it:
373
+
374
+ ```js
375
+ {
376
+ files: ["app/**/*.tsx", "components/**/*.tsx"],
377
+ rules: {
378
+ "no-restricted-syntax": ["error", ...designRules({ accents: "the brand tints" })],
379
+ },
380
+ }
381
+ ```
382
+
383
+ `colourRules`, `typographyRules`, `linkRules`, `themeOverrideRules`,
384
+ `surfaceAsInkRules` and `renamedTokenRules` are internal now. Spreading a subset
385
+ was how a config came to be running four of the six sets, unaware of the other
386
+ two, so the whole set is what the package hands out. `typography: false` still
387
+ drops the type rules for a surface that sets its own ramp.
388
+
389
+ **`/rehype` is `rehypeProseCode` and `PROSE_THEMES`.** `PROSE_LANGS` and
390
+ `proseCodeOptions` are what that plugin is built from rather than things to pass.
391
+
392
+ New in the same release: `CAP_TRIM` and `ON_FIRST_LINE` for lining a mark up with
393
+ the words beside it, and `/optical`, which reads a face's metrics and names the
394
+ rungs where a centred mark misses the letters.
395
+
396
+ ---
397
+
363
398
  ## In production
364
399
 
365
400
  Sites running the package:
@@ -5,14 +5,7 @@
5
5
  * They see values, never the shape of a class list; a primitive fixes that.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.colourRules = colourRules;
9
- exports.themeOverrideRules = themeOverrideRules;
10
- exports.surfaceAsInkRules = surfaceAsInkRules;
11
- exports.renamedTokenRules = renamedTokenRules;
12
- exports.linkRules = linkRules;
13
- exports.typographyRules = typographyRules;
14
8
  exports.designRules = designRules;
15
- exports.designConfig = designConfig;
16
9
  /** A className written as a plain string, or as a chunk of a template literal. */
17
10
  const classString = (pattern) => [
18
11
  `Literal[value=${pattern}]`,
@@ -182,26 +175,3 @@ function designRules({ accents, typography = true, ...type } = {}) {
182
175
  ...renamedTokenRules(),
183
176
  ];
184
177
  }
185
- /**
186
- * Every rule in one flat-config entry, ready to spread into eslint.config.js:
187
- *
188
- * import { designConfig } from "@supertype.ai/foundations/eslint";
189
- * export default [ ...designConfig({ accents: "the brand tints" }) ];
190
- *
191
- * One entry is not a detail. Flat config replaces a rule's options rather than
192
- * merging them, so two blocks covering overlapping files leave only the last
193
- * one's rules in effect. Combining them here is what stops a consumer losing
194
- * half the set by accident. If you need a second scope, call this again with a
195
- * different `files` and no overlap.
196
- */
197
- function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } = {}) {
198
- return [
199
- {
200
- name: "@supertype.ai/foundations/design",
201
- files,
202
- rules: {
203
- "no-restricted-syntax": ["error", ...designRules(options)],
204
- },
205
- },
206
- ];
207
- }
package/dist/eslint.d.ts CHANGED
@@ -7,41 +7,11 @@ export interface RestrictedSyntax {
7
7
  selector: string;
8
8
  message: string;
9
9
  }
10
- export interface ColourOptions {
10
+ interface ColourOptions {
11
11
  /** Named in the message: "use a token" without naming one sends people hunting. */
12
12
  accents?: string;
13
13
  }
14
- /** Applies everywhere, marketing included — the tints exist for those pages. */
15
- export declare function colourRules({ accents, }?: ColourOptions): RestrictedSyntax[];
16
- /**
17
- * A token already knows what it does in the dark; `dark:` on one says the token
18
- * is wrong. Only the solid form is restricted — `dark:bg-destructive/20` against
19
- * a `/10` is the same token at the density a darker ground needs.
20
- */
21
- export declare function themeOverrideRules(): RestrictedSyntax[];
22
- /**
23
- * `--muted` is a fill at L92%, so `text-muted` lands at ~1.1:1. Invisible, and
24
- * it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
25
- * role.
26
- */
27
- export declare function surfaceAsInkRules(): RestrictedSyntax[];
28
- export declare function renamedTokenRules(): RestrictedSyntax[];
29
- /**
30
- * `render={<a href="…" />}` on a component that takes an `href`. It reads as a
31
- * styling choice and is a routing one: the cloned anchor skips the router, so
32
- * the page fully reloads and the view transition is lost, and an off-site href
33
- * never grows a `rel`. Button, Badge and Card each decide internal vs external
34
- * from the href itself, so the anchor is never needed and cannot be right more
35
- * often than the one shared rule is.
36
- *
37
- * Narrow on both axes, so it never fires on a line that is correct. Only those
38
- * three components — `RailLink` deliberately takes a router element through
39
- * `render`, because its module has to stay importable without Next. And only a
40
- * bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
41
- * so it is not a bug.
42
- */
43
- export declare function linkRules(): RestrictedSyntax[];
44
- export interface TypographyOptions {
14
+ interface TypographyOptions {
45
15
  /** Three-weight ramp. Off for editorial, where 700 is a register not a shout. */
46
16
  weights?: boolean;
47
17
  /** The rungs, named in the message, since they differ per consumer. */
@@ -63,7 +33,6 @@ export interface TypographyOptions {
63
33
  * adopting it has a backlog to clear first. */
64
34
  leading?: boolean;
65
35
  }
66
- export declare function typographyRules({ weights, ramp, pairing, axis, leading, }?: TypographyOptions): RestrictedSyntax[];
67
36
  /**
68
37
  * Every design rule, as one list.
69
38
  *
@@ -86,26 +55,4 @@ export interface DesignRuleOptions extends ColourOptions, TypographyOptions {
86
55
  typography?: boolean;
87
56
  }
88
57
  export declare function designRules({ accents, typography, ...type }?: DesignRuleOptions): RestrictedSyntax[];
89
- /** A flat-config entry, described structurally so the package needs no ESLint dependency. */
90
- export interface FlatConfigEntry {
91
- name: string;
92
- files: string[];
93
- rules: Record<string, unknown>;
94
- }
95
- export interface DesignConfigOptions extends DesignRuleOptions {
96
- /** What the rules apply to. Narrow it to exclude generated or vendored code. */
97
- files?: string[];
98
- }
99
- /**
100
- * Every rule in one flat-config entry, ready to spread into eslint.config.js:
101
- *
102
- * import { designConfig } from "@supertype.ai/foundations/eslint";
103
- * export default [ ...designConfig({ accents: "the brand tints" }) ];
104
- *
105
- * One entry is not a detail. Flat config replaces a rule's options rather than
106
- * merging them, so two blocks covering overlapping files leave only the last
107
- * one's rules in effect. Combining them here is what stops a consumer losing
108
- * half the set by accident. If you need a second scope, call this again with a
109
- * different `files` and no overlap.
110
- */
111
- export declare function designConfig({ files, ...options }?: DesignConfigOptions): FlatConfigEntry[];
58
+ export {};
package/dist/eslint.js CHANGED
@@ -12,7 +12,7 @@ const rule = (pattern, message) => classString(pattern).map((selector) => ({ sel
12
12
  const VARIANTS = "(dark:|hover:|focus:|group-hover:|active:|disabled:|sm:|md:|lg:|xl:)*";
13
13
  const PALETTE = "(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)";
14
14
  /** Applies everywhere, marketing included — the tints exist for those pages. */
15
- export function colourRules({ accents = "a brand accent", } = {}) {
15
+ function colourRules({ accents = "a brand accent", } = {}) {
16
16
  return [
17
17
  ...rule(`/(^| )${VARIANTS}(text|bg|border|ring|from|to|via|fill|stroke|divide|outline|placeholder|shadow|decoration|accent|caret)-${PALETTE}-\\d+/`, `Raw Tailwind palette. Use a token — text-foreground / text-muted-foreground / text-subtle-foreground, bg-background / bg-card / bg-muted, border-border — a status token (success, warn, destructive), or ${accents}.`),
18
18
  // Split by prefix: alpha on a fill is a scrim (the effect), on ink or a
@@ -35,7 +35,7 @@ const COLOUR_PREFIX = "text|bg|border|ring|from|to|via|fill|stroke|divide|placeh
35
35
  * is wrong. Only the solid form is restricted — `dark:bg-destructive/20` against
36
36
  * a `/10` is the same token at the density a darker ground needs.
37
37
  */
38
- export function themeOverrideRules() {
38
+ function themeOverrideRules() {
39
39
  return rule(`/(^| )dark:(${COLOUR_PREFIX})-(${TOKEN})($| )/`, "A `dark:` override on a token means the token is wrong — fix it in theme.css, where one change covers every call site, rather than here. Alpha variants (dark:bg-destructive/20) stay legal: those tune a wash's density, not the token.");
40
40
  }
41
41
  /**
@@ -43,7 +43,7 @@ export function themeOverrideRules() {
43
43
  * it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
44
44
  * role.
45
45
  */
46
- export function surfaceAsInkRules() {
46
+ function surfaceAsInkRules() {
47
47
  return [
48
48
  ...rule("/(^| )(dark:|hover:|focus:|group-hover:)*text-(muted|card|popover|input)($| )/", "That is a surface token, not an ink — as text it has no defined contrast (text-muted measures ~1.1:1 on a light page). Use text-muted-foreground for secondary ink, text-subtle-foreground for tertiary, or text-card-foreground on a card."),
49
49
  // The three fills with no legitimate use as a foreground, glyph or word.
@@ -70,7 +70,7 @@ export function surfaceAsInkRules() {
70
70
  * fill, and `Button tone="warn" variant="solid"` is what reads it.
71
71
  */
72
72
  const RENAMED_INKS = "terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
73
- export function renamedTokenRules() {
73
+ function renamedTokenRules() {
74
74
  return rule(`/(^| )(dark:|hover:|focus:|group-hover:)*(text|bg|border|ring|fill|stroke|decoration)-(${RENAMED_INKS})-foreground($| )/`, "That is the deprecated name for the same hue's `-ink`. In this package `-foreground` is the label printed on a fill and `-ink` is the hue used as words, and none of these hues has a printed-on label — they are checked at 4.5:1 against the page, and printing one on its own fill measures about 1.2:1. Use `-ink`.");
75
75
  }
76
76
  /**
@@ -87,7 +87,7 @@ export function renamedTokenRules() {
87
87
  * bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
88
88
  * so it is not a bug.
89
89
  */
90
- export function linkRules() {
90
+ function linkRules() {
91
91
  return [
92
92
  {
93
93
  selector: 'JSXOpeningElement[name.name=/^(Button|Badge|Card)$/] > JSXAttribute[name.name="render"] > JSXExpressionContainer > JSXElement > JSXOpeningElement[name.name="a"]',
@@ -95,7 +95,7 @@ export function linkRules() {
95
95
  },
96
96
  ];
97
97
  }
98
- export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 / text-xs 12 / text-sm 14 / text-base 16 and up", pairing = false, axis = false, leading = false, } = {}) {
98
+ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 / text-xs 12 / text-sm 14 / text-base 16 and up", pairing = false, axis = false, leading = false, } = {}) {
99
99
  return [
100
100
  // Alpha ink composites against whatever surface it lands on, so its
101
101
  // contrast is unmeasurable. The ink tokens are measured.
@@ -172,26 +172,3 @@ export function designRules({ accents, typography = true, ...type } = {}) {
172
172
  ...renamedTokenRules(),
173
173
  ];
174
174
  }
175
- /**
176
- * Every rule in one flat-config entry, ready to spread into eslint.config.js:
177
- *
178
- * import { designConfig } from "@supertype.ai/foundations/eslint";
179
- * export default [ ...designConfig({ accents: "the brand tints" }) ];
180
- *
181
- * One entry is not a detail. Flat config replaces a rule's options rather than
182
- * merging them, so two blocks covering overlapping files leave only the last
183
- * one's rules in effect. Combining them here is what stops a consumer losing
184
- * half the set by accident. If you need a second scope, call this again with a
185
- * different `files` and no overlap.
186
- */
187
- export function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } = {}) {
188
- return [
189
- {
190
- name: "@supertype.ai/foundations/design",
191
- files,
192
- rules: {
193
- "no-restricted-syntax": ["error", ...designRules(options)],
194
- },
195
- },
196
- ];
197
- }
@@ -0,0 +1,56 @@
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
+ }
44
+ /**
45
+ * Every rung whose centred mark misses the letters by enough to see, worst first.
46
+ *
47
+ * A rung that comes back is one where an icon, a badge or a swatch set beside the
48
+ * text with `items-center` wants `CAP_TRIM` on the text to land on it. Most rungs
49
+ * of a ramp are half a pixel out, so the pixel is not the question: `tolerance` is
50
+ * a share of the cap band, and 0.05 is where a miss stops reading as a rounding
51
+ * artefact and starts reading as two things that do not line up. Raise it for a
52
+ * surface that only sets headlines, lower it to see the whole ramp.
53
+ */
54
+ export declare function checkOptical(metrics: FontMetrics, rungs: readonly TypeRung[], { tolerance }?: {
55
+ tolerance?: number;
56
+ }): OpticalOffset[];
@@ -0,0 +1,56 @@
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
+ 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 offset = capBandOffset(metrics, rung.fontSize);
51
+ const capHeight = px(metrics.capHeight, rung.fontSize, metrics.unitsPerEm);
52
+ return { ...rung, offset, share: Math.abs(offset) / capHeight };
53
+ })
54
+ .filter((rung) => rung.share > tolerance)
55
+ .sort((a, b) => b.share - a.share);
56
+ }
package/dist/rehype.d.ts CHANGED
@@ -1,18 +1,6 @@
1
1
  import rehypeShiki, { type RehypeShikiOptions } from "@shikijs/rehype";
2
- /**
3
- * Explicit rather than Shiki's full set — every grammar is build-time parsing.
4
- * Anything unlisted falls back to `text`: content uses labels that are not real
5
- * grammars (`tree`, `spark-defaults`), and dying over a fence label is a bad trade.
6
- */
7
- export declare const PROSE_LANGS: readonly ["bash", "csv", "diff", "docker", "json", "python", "sql", "toml", "tsx", "typescript", "javascript", "yaml"];
8
2
  export declare const PROSE_THEMES: {
9
3
  readonly light: "github-light";
10
4
  readonly dark: "github-dark";
11
5
  };
12
- /**
13
- * `defaultColor: false` emits `--shiki-light` / `--shiki-dark` per token instead
14
- * of a baked colour, so one compiled document serves both themes; `shiki.css`
15
- * maps them. A `[plugin, options]` tuple, so it drops into `rehypePlugins`.
16
- */
17
- export declare const proseCodeOptions: RehypeShikiOptions;
18
6
  export declare const rehypeProseCode: [typeof rehypeShiki, RehypeShikiOptions];
package/dist/rehype.js CHANGED
@@ -4,7 +4,7 @@ import rehypeShiki, {} from "@shikijs/rehype";
4
4
  * Anything unlisted falls back to `text`: content uses labels that are not real
5
5
  * grammars (`tree`, `spark-defaults`), and dying over a fence label is a bad trade.
6
6
  */
7
- export const PROSE_LANGS = [
7
+ const PROSE_LANGS = [
8
8
  "bash",
9
9
  "csv",
10
10
  "diff",
@@ -27,7 +27,7 @@ export const PROSE_THEMES = {
27
27
  * of a baked colour, so one compiled document serves both themes; `shiki.css`
28
28
  * maps them. A `[plugin, options]` tuple, so it drops into `rehypePlugins`.
29
29
  */
30
- export const proseCodeOptions = {
30
+ const proseCodeOptions = {
31
31
  themes: PROSE_THEMES,
32
32
  defaultColor: false,
33
33
  langs: [...PROSE_LANGS],
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Lining a mark up with the words beside it, from either side.
3
+ *
4
+ * `items-center` centres boxes, and a line box is not the letters: it holds the
5
+ * leading, the ascent and the descent, whatever the string uses of them. There
6
+ * are two ways to close that gap and they apply to opposite elements. `CAP_TRIM`
7
+ * shrinks the text to its ink, for a row whose items are all on one line.
8
+ * `ON_FIRST_LINE` grows the mark to one line, for a row whose text runs on and
9
+ * whose mark belongs to the name at the top of it.
10
+ *
11
+ * Reach for one or the other, never both on the same row.
12
+ */
13
+ /**
14
+ * The optical box: cap top to baseline, with the leading either side removed.
15
+ *
16
+ * A line box reserves room for the ascenders and descenders a string may not
17
+ * use, so `items-center` beside an icon centres that reservation rather than
18
+ * the letters. On an 11px uppercase label the mark next to it renders about a
19
+ * pixel low, which is a whole device pixel against an eight pixel cap band.
20
+ * Trimming makes the element as tall as its own ink, so the row centres what
21
+ * the reader actually sees.
22
+ *
23
+ * Two things follow. It goes on the text element, since `text-box` is not
24
+ * inherited and a row cannot hand it down. And it shortens that element, so a
25
+ * row of trimmed text needs a height floor of its own: without one, the card
26
+ * whose label carries no mark sits shorter than the three beside it and its
27
+ * figure rides high. Chrome and Safari trim, and a browser that does not know
28
+ * `text-box` keeps the untrimmed box, which is the behaviour of every consumer
29
+ * today.
30
+ *
31
+ * Never on a string that also clips. The bottom edge is the baseline, so
32
+ * descenders sit outside the box, and `truncate` or any other overflow hidden
33
+ * cuts the tails off every g and p in it.
34
+ *
35
+ * Uppercase is where it pays. A cap band fills half of an 11px line box and the
36
+ * mark beside it lands a whole device pixel low, where 13px mixed case measures
37
+ * the same trimmed or not: ascenders reach the top of the line box on their own,
38
+ * so there is little leading left to take.
39
+ */
40
+ export declare const CAP_TRIM = "[text-box:trim-both_cap_alphabetic]";
41
+ /**
42
+ * A box one line tall with the mark centred in it, for a mark that belongs to the
43
+ * first line of something taller.
44
+ *
45
+ * The row is `items-start`, so its own height is whatever the text grew to and
46
+ * centring on it would drop the mark into the middle of a paragraph. This gives
47
+ * the mark the first line's height instead, which is the thing it names. It goes
48
+ * on a wrapper rather than the mark: a height on the mark itself would stretch
49
+ * the glyph rather than move it.
50
+ *
51
+ * `1lh` resolves against the wrapper's own inherited leading, so a first line set
52
+ * at a rung the row does not share wants that rung on the wrapper too. The
53
+ * alternative is what this replaces, a top margin holding the mark down by a
54
+ * number that is right at one pairing of rung and mark size and silently wrong at
55
+ * every other.
56
+ */
57
+ export declare const ON_FIRST_LINE = "flex h-[1lh] shrink-0 items-center";
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Lining a mark up with the words beside it, from either side.
3
+ *
4
+ * `items-center` centres boxes, and a line box is not the letters: it holds the
5
+ * leading, the ascent and the descent, whatever the string uses of them. There
6
+ * are two ways to close that gap and they apply to opposite elements. `CAP_TRIM`
7
+ * shrinks the text to its ink, for a row whose items are all on one line.
8
+ * `ON_FIRST_LINE` grows the mark to one line, for a row whose text runs on and
9
+ * whose mark belongs to the name at the top of it.
10
+ *
11
+ * Reach for one or the other, never both on the same row.
12
+ */
13
+ /**
14
+ * The optical box: cap top to baseline, with the leading either side removed.
15
+ *
16
+ * A line box reserves room for the ascenders and descenders a string may not
17
+ * use, so `items-center` beside an icon centres that reservation rather than
18
+ * the letters. On an 11px uppercase label the mark next to it renders about a
19
+ * pixel low, which is a whole device pixel against an eight pixel cap band.
20
+ * Trimming makes the element as tall as its own ink, so the row centres what
21
+ * the reader actually sees.
22
+ *
23
+ * Two things follow. It goes on the text element, since `text-box` is not
24
+ * inherited and a row cannot hand it down. And it shortens that element, so a
25
+ * row of trimmed text needs a height floor of its own: without one, the card
26
+ * whose label carries no mark sits shorter than the three beside it and its
27
+ * figure rides high. Chrome and Safari trim, and a browser that does not know
28
+ * `text-box` keeps the untrimmed box, which is the behaviour of every consumer
29
+ * today.
30
+ *
31
+ * Never on a string that also clips. The bottom edge is the baseline, so
32
+ * descenders sit outside the box, and `truncate` or any other overflow hidden
33
+ * cuts the tails off every g and p in it.
34
+ *
35
+ * Uppercase is where it pays. A cap band fills half of an 11px line box and the
36
+ * mark beside it lands a whole device pixel low, where 13px mixed case measures
37
+ * the same trimmed or not: ascenders reach the top of the line box on their own,
38
+ * so there is little leading left to take.
39
+ */
40
+ export const CAP_TRIM = "[text-box:trim-both_cap_alphabetic]";
41
+ /**
42
+ * A box one line tall with the mark centred in it, for a mark that belongs to the
43
+ * first line of something taller.
44
+ *
45
+ * The row is `items-start`, so its own height is whatever the text grew to and
46
+ * centring on it would drop the mark into the middle of a paragraph. This gives
47
+ * the mark the first line's height instead, which is the thing it names. It goes
48
+ * on a wrapper rather than the mark: a height on the mark itself would stretch
49
+ * the glyph rather than move it.
50
+ *
51
+ * `1lh` resolves against the wrapper's own inherited leading, so a first line set
52
+ * at a rung the row does not share wants that rung on the wrapper too. The
53
+ * alternative is what this replaces, a top margin holding the mark down by a
54
+ * number that is right at one pairing of rung and mark size and silently wrong at
55
+ * every other.
56
+ */
57
+ export const ON_FIRST_LINE = "flex h-[1lh] shrink-0 items-center";
@@ -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 "./align.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 "./align.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
@@ -14,19 +14,18 @@ silently. Full reference: https://github.com/supertypeai/foundations
14
14
  `text-xs`, `font-medium`, `leading-tight`, `text-muted-foreground` or similar
15
15
  on a text element, there is a primitive for it. Use that instead.
16
16
  2. **Retune with CSS variables, not classes.** The package owns its classnames.
17
- Change a `--text-*` rung, `--heading-weight` or a colour token. Do not
18
- override the package's utilities.
17
+ Change a `--text-*` rung, `--heading-weight` or a colour token instead of
18
+ overriding the package's utilities.
19
19
  3. **Import from the right entry point.** `blocks`, `essay`, `mdx`, `seo`, `og`,
20
20
  `eslint`, `rehype` and `contrast` are subpaths, not part of the root barrel.
21
21
  4. **Use colour tokens, never literal colours.** `bg-card`, `text-foreground`,
22
22
  `border-border`. No hex values, no `bg-zinc-800`, no `dark:` overrides that
23
23
  swap one token for another.
24
- 5. **Paint a surface, hand down its ink.** Any element you give a background
24
+ 5. **Paint a surface, hand down its ink.** Any element that gets a background
25
25
  needs `INK_ON_FILL` (a tone fill) or one of `INK_ON_CARD`, `INK_ON_POPOVER`,
26
- `INK_ON_SIDEBAR` (a tinted one), or the type inside it keeps the page's ink
27
- and fails contrast silently. Never build one of these class strings
28
- yourself: Tailwind only generates a class it can read as text, so an
29
- assembled one styles nothing. For a surface with no constant, spread
26
+ `INK_ON_SIDEBAR` (a tinted surface), or the text inside it keeps the page's ink
27
+ and fails contrast silently. Tailwind only generates a class it can read as
28
+ text, so a built string styles nothing. For a surface without a constant, spread
30
29
  `inkOnSurfaceStyle(token)` into `style`.
31
30
  6. **Write the words like a person would.** See Writing copy below. It applies
32
31
  to every string a reader sees and to the comments you leave behind.
@@ -50,7 +49,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
50
49
  | inline code | `TypographyInlineCode` | root |
51
50
  | a link | `TypographyLink` | root |
52
51
  | an anchor around something already drawn | `Anchor` | `/blocks` |
53
- | where an href goes, without a component | `resolveLink` | root |
52
+ | where an `href` goes, without a component | `resolveLink` | root |
54
53
  | a highlighted phrase | `TypographyHighlight` | root |
55
54
  | a button, or a link that looks like one | `Button` | `/blocks` |
56
55
  | a status label, count or chip | `Badge` | `/blocks` |
@@ -67,6 +66,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
67
66
  | an article whose body is prose or MDX | `EssayHeader` + `ReadingLayout` | `/essay` |
68
67
  | a post meta row (date, read time, tags) | `PostMetaRow` and friends | `/essay` |
69
68
  | a table of contents | `TableOfContents`, `ReadingRail` | `/essay` |
69
+ | where a centred mark lands on a rung | `checkOptical` | `/optical` |
70
70
  | page metadata and JSON-LD | `createSeo` | `/seo` |
71
71
  | an OG image | `ogCard`, `OG_SIZE` | `/og` |
72
72
  | to merge classnames | `cn` | root |
@@ -75,15 +75,16 @@ silently. Full reference: https://github.com/supertypeai/foundations
75
75
 
76
76
  | import | exports |
77
77
  |---|---|
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` |
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`, `CAP_TRIM`, `ON_FIRST_LINE`. Types: `TypographyTag`, `ParagraphVariants`, `ListProps`, `CaptionVariants`, `LabelVariants`, `StatVariants`, `Tone`, `HighlightTone`, `LinkBehavior`, `ResolvedLink` |
79
79
  | `@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
80
  | `@supertype.ai/foundations/mdx` | `proseMdxComponents` |
81
81
  | `@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
82
  | `@supertype.ai/foundations/seo` | `createSeo`. Types: `SeoConfig`, `ArticleAuthor`, `ArticleOptions`, `PageMetadata` |
83
83
  | `@supertype.ai/foundations/og` | `ogCard`, `OG_SIZE`. Types: `OgCardOptions` |
84
- | `@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` |
85
- | `@supertype.ai/foundations/rehype` | `rehypeProseCode`, `proseCodeOptions`, `PROSE_LANGS`, `PROSE_THEMES`. Build-time only, must not resolve React |
86
- | `@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 |
84
+ | `@supertype.ai/foundations/eslint` | `designRules`: every rule as one array, to spread into a `no-restricted-syntax` entry. The set is assembled inside, so it cannot drift. Types: `DesignRuleOptions`, `RestrictedSyntax` |
85
+ | `@supertype.ai/foundations/optical` | `checkOptical`: each rung of a ramp whose centred mark misses the letters by enough to see, worst first. Types: `FontMetrics`, `TypeRung`, `OpticalOffset`. Build-time only |
86
+ | `@supertype.ai/foundations/rehype` | `rehypeProseCode`, `PROSE_THEMES`. Build-time only, must not resolve React |
87
+ | `@supertype.ai/foundations/contrast` | `checkLegibility` (inks at 4.5:1), `checkSignals` (fills at 3:1, tinted inks at 4.5:1, `--subtle-foreground` at the 3:1 it is documented for, labels against their own fill), `checkHairlines` (`--border` and `--input` at 1.4:1 on `--background` and `--card`, `--sidebar-border` on `--sidebar`), `resolveTokens`, `formatFailures`, `specificity`, `parseColor`, `luminance`, `contrast` (WCAG ratio), `lc` (APCA lightness contrast), `tokenCuts`. Types: `Rgb`, `Theme`, `LegibilityFailure`, `TokenCuts`. Build-time only |
87
88
 
88
89
  ## Props worth knowing
89
90
 
@@ -93,110 +94,74 @@ silently. Full reference: https://github.com/supertypeai/foundations
93
94
  - `TypographyMuted`, `TypographyProse`, `TypographyProseList` are presets. The
94
95
  prop each one pins is dropped from its type, so passing it fails to compile.
95
96
  - `TypographyCaption`, `TypographyLabel`, `TypographySmall`: `size?: "sm" | "xs" | "2xs" | "inherit"`, `as?`.
96
- - `TypographyEyebrow`: `tone?: "heading" | "label" | "muted" | "subtle"`, `size?: "sm" | "xs" | "2xs" | "3xs"`, `as?`. Each tone carries the rung it is usually set at and `size` overrides it, so omitting it changes nothing. Reach for `muted` for the uppercase micro-label a dense product sets over a group of controls, and `subtle` for a column head or rail marker read on the way past — that shape hand-rolled is the most common way an app ends up spelling type classes.
97
- - `TypographyStat`: `size?: "inherit" | "3xs" | "2xs" | "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "card" | "panel" | "section" | "page" | "display"`, `figures?: "tabular" | "proportional"`, `tone?: "default" | "muted"`. Two ladders in one axis: the rung names are the body ramp, for a figure beside interface copy it should step with, and the surface names ride the heading ladder, for a figure that is the headline. `tone="muted"` is the qualifier after a figure, "of 2,000" beside "1,284": still tight, no longer competing. Keep tabular where a value updates in place.
98
- - `TypographyLink`: `href` (required), `tone?: Tone` (default `muted`), `addArrow?`, `newTab?`. The href decides internal versus external.
99
- - `TypographyHighlight`: `tone?: HighlightTone`, one of `"primary" | "success" | "ochre" | "terracotta" | "sage" | "fig"`, plus `seed?: number`. A separate type from `Tone` on purpose: this axis is categorical (which one it is) where `Tone` is semantic (what it means), the same split theme.css draws between the earth swatches and the status tokens.
100
- - `Card`: `href`, `title`, `description`, `icon`, `external`. An href makes the whole card a link.
101
- - `Anchor`: `href` (required), plus `external`/`newTab`. An unstyled anchor for a
102
- link that is not typography and not a control a thumbnail, a chip, a tooltip
103
- trigger. It exists so `target`/`rel` are never written at a call site; `external`
104
- is for a same-origin path that is not a route, which the router would prefetch.
105
- - `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`.
106
- - **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
- - `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
- - `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.
97
+ - `TypographyEyebrow`: `tone?: "heading" | "label" | "muted" | "subtle"`, `size?: "sm" | "xs" | "2xs" | "3xs"`, `as?`. Each tone carries the rung it is usually set at and `size` overrides it, so omitting it changes nothing. Use `muted` for the uppercase micro-label over a group of controls, and `subtle` for a column head or rail marker read on the way past.
98
+ - `TypographyStat`: `size?: "inherit" | "3xs" | "2xs" | "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "card" | "panel" | "section" | "page" | "display"`, `figures?: "tabular" | "proportional"`, `tone?: "default" | "muted"`.
99
+ - `TypographyLink`: `href` (required), `tone?: Tone` (default `muted`), `addArrow?`, `newTab?`. The `href` decides whether the link is internal or external.
100
+ - `TypographyHighlight`: `tone?: HighlightTone`, one of `"primary" | "success" | "ochre" | "terracotta" | "sage" | "fig"`, plus `seed?: number`.
101
+ - `Card`: `href`, `title`, `description`, `icon`, `external`. An `href` makes the whole card a link.
102
+ - `Anchor`: `href` (required), plus `external`/`newTab`.
103
+ - `Tone` is the 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`.
104
+ - **`CAP_TRIM` centres a mark on the letters.** `items-center` centres line boxes, which reserve room for ascenders and descenders the string may not use. Put `CAP_TRIM` on the text element, never on the row.
105
+ - **`ON_FIRST_LINE` is the same fix from the mark's side.** For rows where a mark names the first line, use `items-start` and `ON_FIRST_LINE` so the mark sits in a wrapper one line tall.
106
+ - **Ink is handed down by whatever paints.** `toneClass(tone)` is a palette and sets no ink. A filled surface adds `INK_ON_FILL`; a tinted one adds `INK_ON_CARD`, `INK_ON_POPOVER` or `INK_ON_SIDEBAR`. If a surface does not declare them, spread `inkOnSurfaceStyle(token)` into `style` instead of building a class.
107
+ - `Button`: `variant?: "solid" | "soft" | "outline" | "ghost" | "link"` (default `solid`), `tone?: Tone`, `size?: "xs" | "sm" | "md" | "lg" | "xl"` (default `md`), `icon?: boolean`, `pill?: boolean`, `href` to make it a link, `render` for an element that is neither a button nor a link.
108
+ - `Badge`: `variant?: "solid" | "soft" | "outline" | "ghost"` (default `solid`), `tone?: Tone`, `size?: "xs" | "sm"` (default `sm`), `pill?: boolean`, `href` for a badge that leads somewhere.
109
109
  - `Callout`: `tone?: Tone`, `density?: "compact" | "editorial"`, `title`, `icon`, `action`.
110
- - `Bulletin`: the panel shape with no copy in it — `variant?: "card" | "line"` (default `card`), `accent` (drawn along the top edge, full width, before the padding; `<Ribbon />` is the one that ships), `eyebrow`, `headline`, `lede`, `points` (a `BulletinPoint[]` of `{ title, body, mark?, ink? }`, where `mark` and `ink` are class names like `bg-fern`/`text-fern-ink`; one point takes the width, two or more go two-up from `sm`), `action` (the control on the footer rule), `footnote` (the line opposite it), `children` (under the rule) and `className`. Every slot is optional and an omitted one renders nothing, so the same component covers a credits panel and a headline with one button under it. Reach for it for an announcement, a release note, a status bulletin. `Ribbon` takes `hues?: RibbonHue[]` (default `EDITORIAL_INKS`, the eight categorical hues as data) and states no size of its own — the caller passes `h-1.5 w-full` on a panel and `h-1 min-w-20 flex-1 rounded-full` in a row.
111
- - `Colophon`: `Bulletin` with this package's words in it, and the words are not props. `variant?: "card" | "line"` (default `card`), plus `label` (the link's words, for translation) and `children` (the consumer's own credit, rendered under the rule on `card`). A compact statement block for a page (`card`) or a footer row (`line`). There is nothing to configure, so `<Colophon />` is the whole call. Its copy is deliberately not props: this is the package's only content component — every other block is an empty container whose words come from the call site — which makes it a mark rather than a block, and a mark every site rewrites is not a mark. An app wanting to say something in its own words wants a `Card`. It draws the eight categorical hues and inks two of them, all declared in theme.css: an app that imported tokens.css alone renders a panel with an invisible ribbon and no error.
112
- - **`Colophon` or `BuiltWithFoundations`.** A colophon is a block with something to say and needs a place to say it — a credits page, an about page, the foot of a long article. `BuiltWithFoundations` is one item in a row somebody else laid out, beside a privacy link and a copyright line, and says only where the site came from. The test is what the surface is for: somewhere with its own columns already takes the link, and somewhere the block itself is being placed takes the colophon. There is no `Colophon` variant rendering the link alone, since that would be a second name for a component that already has one. `BuiltWithFoundations` is a `Button variant="outline" size="sm"` with an `href`, so it sits on the same hairline and the same radius rung as every other control on the page. `FoundationsMark` is the eight-hue chip alone and `FOUNDATIONS_URL` the bare href.
110
+ - `Bulletin`: `variant?: "card" | "line"` (default `card`), `accent`, `eyebrow`, `headline`, `lede`, `points`, `action`, `footnote`, `children`, `className`.
113
111
  - `DisclosureGroup`: `type?: "multiple" | "single"`, `defaultValue` (matches the title string).
114
- - `TabsList`: `variant?: "default" | "line"`, `tone?: Tone` (default `primary`). The tone
115
- inks the marker alone, which on `line` means the underline and the active icon. The
116
- boxed track keeps its marker flat, so a tone there paints nothing.
117
- - `TabGroup`: `tabs: readonly TabItem[]` (`{ value, label, icon?, content }`), `defaultValue?`,
118
- `value?`, `onValueChange?`, `variant?`, `tone?`, `iconPosition?: "inline-start" | "inline-end"`.
119
- `icon` is an element (`<Icons.Mic />`) that the trigger sizes and inks. Reach for it
120
- whenever the tabs are data, and compose `Tabs`/`TabsTrigger` by hand only for a shape
121
- it cannot express.
112
+ - `TabsList`: `variant?: "default" | "line"`, `tone?: Tone` (default `primary`).
113
+ - `TabGroup`: `tabs: readonly TabItem[]` (`{ value, label, icon?, content }`), `defaultValue?`, `value?`, `onValueChange?`, `variant?`, `tone?`, `iconPosition?: "inline-start" | "inline-end"`.
122
114
 
123
115
  ## Writing copy
124
116
 
125
117
  Every string a reader sees goes through this: page copy, section notes, button
126
- labels, callout titles, error messages, and the comments you leave in the file.
127
- Write the way a person writes when they know the subject and respect the reader's
128
- time.
129
-
130
- - **Say the thing.** State what is true and move on. Skip the setup sentence, the
131
- restatement, and the closing summary of what you just said.
132
- - **Define forwards.** "A tone sets three variables and the component paints from
133
- them" over "the component does not branch on the tone". Reach for a negative
134
- only when the absence is the point.
135
- - **One em dash per page, at most.** Prefer a full stop, a comma, or a colon. The
136
- same goes for a parenthetical aside mid-sentence.
137
- - **No arrow glyphs in prose.** Write "3.44:1 in light, 6.75:1 in dark" rather
138
- than joining the two with an arrow. Where a UI genuinely points at something,
139
- render a real icon.
140
- - **Watch the tacked-on clause.** "…, and it is the shape to reach for", "…, and
141
- it exists so that…". The second half is usually the point: lead with it, or
142
- make it its own sentence.
143
- - **Break the triples.** "X, Y, and Z" as a rhetorical flourish, three clauses
144
- joined by "and", and the "not just A, but B" shape all read as machine prose.
145
- Two items, or a list.
146
- - **Cut the throat-clearing.** No "it is worth noting", "it is important to",
147
- "simply", "just", "of course", "in other words". No "this is not X, it is Y".
148
- - **Skip the rationale.** A note explains what the reader needs to use the thing.
149
- Design history, near-misses, and the case for a decision go in the commit
150
- message or the docs, not in the copy.
151
- - **Concrete over abstract.** Name the class, the prop, the file. "Five
152
- components take the prop" beats "several components".
153
- - **Vary the sentences.** A paragraph of same-length clauses reads as generated
154
- even when every sentence is true.
155
- - **Headings are labels.** "How it stays current", not "Why it cannot go stale".
156
-
157
- Code samples and commit messages stay conventional. This is about prose.
118
+ labels, callout titles, error messages, and comments in the code. Write as a
119
+ person would: direct, clear, and respectful of the reader's time.
120
+
121
+ - **Say the thing.** State what is true and move on. Skip the setup sentence and
122
+ the closing summary of what you just said.
123
+ - **Define forwards.** Prefer a direct description over a negative form unless the
124
+ absence is the point.
125
+ - **One em dash per page, at most.** Prefer a full stop, a comma, or a colon.
126
+ - **No arrow glyphs in prose.** Write “3.44:1 in light, 6.75:1 in dark” instead of
127
+ joining the two with an arrow.
128
+ - **Lead with the point.** If a clause explains the reason, put it up front or make
129
+ it its own sentence.
130
+ - **Cut the AI scaffolding.** Avoid “it is not…”, “there is no…”, “this is not…”,
131
+ “what this does is…”, “the reason is…”, and “because a…” patterns when a
132
+ direct statement does the work.
133
+ - **Write the actual rule.** Prefer “Badges are labels, not links” to “This is not
134
+ a link, because a badge is not a link.” Prefer “One function builds the set” to
135
+ “There is one function because the package builds the whole set in one place.”
136
+ - **Break the triples.** Long “X, Y, and Z” rhythms read like machine prose.
137
+ - **Cut the throat-clearing.** Avoid “it is worth noting”, “it is important to”,
138
+ “simply”, “just”, “of course”, and “in other words”.
139
+ - **Skip the rationale.** Explain what the reader needs to use, not the design
140
+ history behind it.
141
+ - **Concrete over abstract.** Name the class, the prop, or the file.
142
+ - **Vary the sentences.** A paragraph of same-length clauses reads as generated.
143
+ - **Headings are labels.** “How it stays current” is clearer than “Why it cannot go stale”.
144
+
145
+ Code samples and commit messages should stay conventional. This is about prose.
158
146
 
159
147
  ## Common mistakes
160
148
 
161
149
  - `<p className="text-sm text-muted-foreground">`: use `<TypographyMuted>`.
162
- - `<h2 className="text-lg font-semibold">`: use `<TypographyH2>`, or
163
- `headingClass()` to pass classes into someone else's component.
164
- - Importing `Card`, `Callout` or `Button` from `@supertype.ai/foundations`. They
165
- live in `@supertype.ai/foundations/blocks`.
166
- - `<Button variant="destructive">` or `variant="secondary"`: those are tones.
167
- Write `variant="soft" tone="destructive"`, `variant="solid" tone="secondary"`.
150
+ - `<h2 className="text-lg font-semibold">`: use `<TypographyH2>`, or `headingClass()`.
151
+ - Importing `Card`, `Callout` or `Button` from `@supertype.ai/foundations`. They live in `@supertype.ai/foundations/blocks`.
152
+ - `<Button variant="destructive">` or `variant="secondary"`: those are tones. Write `variant="soft" tone="destructive"`, `variant="solid" tone="secondary"`.
168
153
  - `<Button size="icon-sm">`: one size ladder. Write `size="sm" icon`.
169
- - `<Badge variant="warning">` or `variant="supertype"`: tones, and misspelled ones.
170
- Write `variant="outline" tone="warn"` and `variant="solid" tone="brand"`.
171
- - `tone="neutral"`, `tone="foreground"` or `tone="accent"`: all three went when the
172
- tone lists merged. Write `tone="muted"` for the first two, `tone="primary"` for
173
- the third.
174
- - An `<a>` or `<Link>` styled by hand to look like a button, and equally
175
- `render={<a href="/x" />}` or `render={<Link href="/x" />}` on anything that
176
- takes an `href`. Write `<Button href="/x">`. `Button`, `Badge`, `Card` and
177
- `TypographyLink` all take `href` and route it through one rule
178
- (`resolveLink`): a scheme leaves the app and gets `target`/`rel`, a `#hash`
179
- stays a plain anchor, everything else goes through the router's `Link` and
180
- keeps the view transition. A hand-passed anchor skips all of that. `render` is
181
- for an element that is genuinely not a link — and for `RailLink`, which takes a
182
- router `Link` that way on purpose, since `essay/rail.tsx` has to import without
183
- Next.
184
- - Exporting a constant from a `"use client"` module and importing it into a server
185
- component. It crosses the boundary as a stub, not a string, so an `href` built that
186
- way arrives as a function. Keep shared constants in a plain module.
154
+ - `<Badge variant="warning">` or `variant="supertype"`: tones, and misspelled ones. Write `variant="outline" tone="warn"` and `variant="solid" tone="brand"`.
155
+ - `tone="neutral"`, `tone="foreground"` or `tone="accent"`: all three were replaced when the tone lists merged. Write `tone="muted"` for the first two, `tone="primary"` for the third.
156
+ - Hand-styling an `<a>` or `<Link>` to look like a button. Write `<Button href="/x">` instead.
157
+ - Exporting a constant from a `"use client"` module and importing it into a server component.
187
158
  - `<TypographyMuted tone="default">`: a type error. Use `TypographyP`.
188
- - Binding fonts with `font.className` instead of `font.variable`. The className
189
- form sets `font-family` on the element and leaves the roles unresolved.
190
- - Writing the style layer out by hand. `@import "@supertype.ai/foundations";` after
191
- `@import "tailwindcss";` is the whole of it: it carries tokens, theme, type and
192
- prose in order and registers the package's own `@source`. Split into the granular
193
- imports, an app owns the order, the palette and the scan path — and a wrong
194
- `@source` path purges every class the package ships, silently.
195
- - Using `Accordion` for a static FAQ. `Disclosure` runs on the browser alone.
159
+ - Binding fonts with `font.className` instead of `font.variable`.
160
+ - Writing the style layer out by hand. `@import "@supertype.ai/foundations";` after `@import "tailwindcss";` is the whole of it.
161
+ - Using `Accordion` for a static FAQ. `Disclosure` is the browser-native version.
196
162
  - Adding a second `@custom-variant dark`. `tokens.css` already binds it.
197
163
 
198
- Run `npx foundations doctor` in the app to check the font binding, the style layer
199
- and the dark variant.
164
+ Run `npx foundations doctor` in the app to check the font binding, the style layer and the dark variant.
200
165
 
201
166
  ## Surfaces
202
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supertype.ai/foundations",
3
- "version": "0.1.36",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -68,6 +68,10 @@
68
68
  "require": "./dist/cjs/eslint.js",
69
69
  "default": "./dist/eslint.js"
70
70
  },
71
+ "./optical": {
72
+ "types": "./dist/optical.d.ts",
73
+ "default": "./dist/optical.js"
74
+ },
71
75
  "./rehype": {
72
76
  "types": "./dist/rehype.d.ts",
73
77
  "default": "./dist/rehype.js"
@@ -89,6 +93,7 @@
89
93
  "test": "yarn build && vitest run",
90
94
  "test:watch": "vitest",
91
95
  "check:llms": "node scripts/check-llms.mjs",
96
+ "check:consumers": "node scripts/consumers.mjs",
92
97
  "check:pins": "node scripts/pins.mjs",
93
98
  "release": "node scripts/release.mjs",
94
99
  "sync": "node scripts/sync.mjs",