@supertype.ai/foundations 0.1.29 → 0.1.31

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.
@@ -19,12 +19,39 @@ export declare function parseColor(value: string): Rgb | null;
19
19
  export declare function luminance([r, g, b]: Rgb): number;
20
20
  /** WCAG contrast ratio, 1:1 to 21:1. */
21
21
  export declare function contrast(a: Rgb, b: Rgb): number;
22
+ /**
23
+ * APCA lightness contrast (Lc), the perceptual measure WCAG 3 is built on.
24
+ *
25
+ * It sits beside `contrast` because the two answer different questions and an
26
+ * ink ramp needs both. A WCAG ratio is polarity-blind: it reports the same
27
+ * number whether the text is dark on light or light on dark, when in fact dark
28
+ * glyphs on a bright field thin out and light glyphs on a dark field bloat. That
29
+ * blindness is what lets a ramp be ordered by ratio and still read flat — viably
30
+ * shipped a `--muted-foreground` measuring 72.5 Lc in light and 52.1 in dark,
31
+ * the same verdict from `contrast` on both sides and twenty points apart to a
32
+ * reader.
33
+ *
34
+ * Lc also states the term a ratio cannot: legibility is contrast times size, so
35
+ * a floor here is what says an ink comfortable at 16px is or is not comfortable
36
+ * on the 13px rung a dense product actually spends.
37
+ *
38
+ * Returned absolute. It is signed by polarity in the specification, and every
39
+ * caller so far asks "is this legible", never "which way round is it".
40
+ */
41
+ export declare function lc(text: Rgb, background: Rgb): number;
22
42
  export interface LegibilityFailure {
23
43
  theme: Theme;
24
44
  ink: string;
25
45
  surface: string;
26
46
  ratio: number;
27
47
  required: number;
48
+ /**
49
+ * The token the pair was *supposed* to use, set only when it was undeclared
50
+ * and the cascade fell through to the next link of its `var()` chain. Without
51
+ * it a report reads as though the app chose `--primary-foreground` for its
52
+ * brand fill, when in truth it chose nothing and CSS chose for it.
53
+ */
54
+ via?: string;
28
55
  }
29
56
  /**
30
57
  * Every ink on every surface, both themes. Missing or non-literal tokens are
@@ -65,6 +92,13 @@ export interface TokenCuts {
65
92
  * a page that is quietly wrong rather than a build that fails.
66
93
  */
67
94
  export declare function tokenCuts(token: string): TokenCuts;
95
+ /**
96
+ * The bar a rule owes, held apart from `checkSignals` because it is not a
97
+ * signal: nothing here carries meaning in its hue, it only has to be seen.
98
+ */
99
+ export declare function checkHairlines(css: string, { themes }?: {
100
+ themes?: Theme[] | undefined;
101
+ }): LegibilityFailure[];
68
102
  /**
69
103
  * The three bars a palette owes, run over the same engine as `checkLegibility`.
70
104
  * Without this the numbers in a theme's comments are claims, not measurements.
package/dist/contrast.js CHANGED
@@ -3,6 +3,12 @@
3
3
  * and `.dark` separately measures an intention, and ssite shipped a `.dark` at a
4
4
  * healthy 15.7:1 while the page rendered white on white. Build-time only.
5
5
  */
6
+ // The tone vocabulary, for the pairs it names. Value import, not just a type:
7
+ // this file measures what `TONE` declares rather than keeping a second list of
8
+ // it. tone.js resolves no React and imports nothing at runtime, so the bare-Node
9
+ // contract this entry point owes still holds — `check-candidates` and the CLI
10
+ // both import it from plain Node.
11
+ import { TONE } from "./tone.js";
6
12
  /**
7
13
  * Specificity over what token blocks use. `:root` and `.dark` both score 1 —
8
14
  * the tie above. `:not(…)` contributes its argument's score, per spec.
@@ -131,9 +137,7 @@ export function parseColor(value) {
131
137
  }
132
138
  const hex = input.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
133
139
  if (hex) {
134
- const digits = hex[1].length === 3
135
- ? [...hex[1]].map((d) => d + d).join("")
136
- : hex[1];
140
+ const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d).join("") : hex[1];
137
141
  return [
138
142
  parseInt(digits.slice(0, 2), 16),
139
143
  parseInt(digits.slice(2, 4), 16),
@@ -158,6 +162,47 @@ export function contrast(a, b) {
158
162
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
159
163
  return (hi + 0.05) / (lo + 0.05);
160
164
  }
165
+ /**
166
+ * APCA lightness contrast (Lc), the perceptual measure WCAG 3 is built on.
167
+ *
168
+ * It sits beside `contrast` because the two answer different questions and an
169
+ * ink ramp needs both. A WCAG ratio is polarity-blind: it reports the same
170
+ * number whether the text is dark on light or light on dark, when in fact dark
171
+ * glyphs on a bright field thin out and light glyphs on a dark field bloat. That
172
+ * blindness is what lets a ramp be ordered by ratio and still read flat — viably
173
+ * shipped a `--muted-foreground` measuring 72.5 Lc in light and 52.1 in dark,
174
+ * the same verdict from `contrast` on both sides and twenty points apart to a
175
+ * reader.
176
+ *
177
+ * Lc also states the term a ratio cannot: legibility is contrast times size, so
178
+ * a floor here is what says an ink comfortable at 16px is or is not comfortable
179
+ * on the 13px rung a dense product actually spends.
180
+ *
181
+ * Returned absolute. It is signed by polarity in the specification, and every
182
+ * caller so far asks "is this legible", never "which way round is it".
183
+ */
184
+ export function lc(text, background) {
185
+ // Screen luminance on APCA's own curve, which is not WCAG's: exponent 2.4 on
186
+ // the raw channel, with weights of its own.
187
+ const y = ([r, g, b]) => {
188
+ const v = 0.2126729 * (r / 255) ** 2.4 +
189
+ 0.7151522 * (g / 255) ** 2.4 +
190
+ 0.072175 * (b / 255) ** 2.4;
191
+ // Soft clamp near black, where the power curve stops modelling perception.
192
+ return v < 0.022 ? v + (0.022 - v) ** 1.414 : v;
193
+ };
194
+ const [yText, yBackground] = [y(text), y(background)];
195
+ // Two exponent pairs, one per polarity. This asymmetry is the whole reason Lc
196
+ // says something a ratio cannot.
197
+ const s = yBackground > yText
198
+ ? (yBackground ** 0.56 - yText ** 0.57) * 1.14
199
+ : (yBackground ** 0.65 - yText ** 0.62) * 1.14;
200
+ // Below the noise floor the two are the same colour as far as a reader is
201
+ // concerned, and the offset below would report a spurious 2.7.
202
+ if (Math.abs(s) < 0.1)
203
+ return 0;
204
+ return Math.abs(s > 0 ? (s - 0.027) * 100 : (s + 0.027) * 100);
205
+ }
161
206
  const INKS = ["--foreground", "--muted-foreground", "--card-foreground"];
162
207
  const SURFACES = ["--background", "--card", "--muted"];
163
208
  /**
@@ -200,6 +245,16 @@ const FILLS = [
200
245
  "--stone",
201
246
  "--fig",
202
247
  "--cocoa",
248
+ // A chart series is a mark like any other, and docs/cli.md has always said so
249
+ // ("a status dot or a chart bar that cannot be picked out of its background").
250
+ // Leaving them out of this list is how the sand shipped at 2.18:1 in light and
251
+ // the taupe at 2.26:1 in dark: a promise in prose that nothing measured.
252
+ "--chart-1",
253
+ "--chart-2",
254
+ "--chart-3",
255
+ "--chart-4",
256
+ "--chart-5",
257
+ "--chart-6",
203
258
  ];
204
259
  /**
205
260
  * A fill has to separate from the page and from a card. Not from `--muted`: a
@@ -209,6 +264,7 @@ const FILLS = [
209
264
  const FILL_SURFACES = ["--background", "--card"];
210
265
  /** The same hues as words, at the bar body copy is held to. */
211
266
  const INKS_TINTED = [
267
+ "--primary-ink",
212
268
  "--success-ink",
213
269
  "--warn-ink",
214
270
  "--info-ink",
@@ -223,19 +279,106 @@ const INKS_TINTED = [
223
279
  "--fig-ink",
224
280
  "--cocoa-ink",
225
281
  ];
282
+ /**
283
+ * The tertiary ink, at the 3:1 its own comment in theme.css claims for it —
284
+ * placeholders and disabled labels, never anything load-bearing. Held here
285
+ * rather than in `INKS` because 4.5:1 would fail a token that is correct; held
286
+ * *somewhere* because the sentence stating the bar was the only thing enforcing
287
+ * it, and light sits at 3.14:1 on --muted with nothing watching the gap.
288
+ */
289
+ const TERTIARY = ["--subtle-foreground"];
226
290
  /**
227
291
  * shadcn's shape: `-foreground` is the label printed on the fill, so the pair is
228
292
  * measured against itself rather than against the page.
293
+ *
294
+ * `--success` and `--warn` joined the list when the tone table stopped making
295
+ * exceptions of them. A filled status control is a real thing, `Button
296
+ * tone="warn" variant="solid"` renders one, and white on amber measured 2.44:1
297
+ * on the dark theme for as long as the pair went unnamed here.
229
298
  */
230
- const ON_FILL = [
231
- ["--primary", "--primary-foreground"],
232
- ["--secondary", "--secondary-foreground"],
233
- ["--destructive", "--destructive-foreground"],
299
+ const ON_SURFACE = [
234
300
  ["--accent", "--accent-foreground"],
235
301
  ["--card", "--card-foreground"],
236
302
  ["--popover", "--popover-foreground"],
237
303
  ["--sidebar", "--sidebar-foreground"],
238
304
  ];
305
+ /**
306
+ * Every cut a tone names, read off `TONE` instead of restated here.
307
+ *
308
+ * The tone rows used to be five hand-written pairs in the list above, which is
309
+ * the arrangement the comment on `tokenCuts` warns about: a palette checked
310
+ * against one taxonomy and declared from another drifts, and `brand` is the
311
+ * proof. It was in `TONE` from the day the vocabulary landed and never in this
312
+ * file, so the one tone whose tokens an app supplies was the one tone nothing
313
+ * measured.
314
+ *
315
+ * Parsed rather than shared as data because `TONE` has to stay a table of
316
+ * literal class strings — Tailwind scans this package as text and generates
317
+ * only the classes it can read, so a row assembled from a record would style
318
+ * nothing. Parsing the literal keeps one declaration; a second table would be
319
+ * the drift all over again.
320
+ */
321
+ const TONE_CUT = /\[--tone-(fill|ink|hue):([^\]]*)\]/g;
322
+ /**
323
+ * A cut's `var()` fallback chain, outermost first: `var(--brand,var(--primary))`
324
+ * is `["--brand", "--primary"]`. The order is the order CSS tries them in.
325
+ */
326
+ const toneChains = (classes) => {
327
+ const chains = { fill: [], ink: [], hue: [] };
328
+ for (const [, cut, value] of classes.matchAll(TONE_CUT))
329
+ chains[cut] = [...value.matchAll(/--[a-z0-9-]+/g)].map((m) => m[0]);
330
+ return chains;
331
+ };
332
+ const TONE_CHAINS = Object.entries(TONE).map(([tone, classes]) => [tone, toneChains(classes)]);
333
+ /** The pair a tone names, for the taxonomy `tokenCuts` reports. */
334
+ const ON_FILL = [
335
+ ...TONE_CHAINS.map(([, c]) => [c.fill[0], c.ink[0]]),
336
+ ...ON_SURFACE,
337
+ ];
338
+ /**
339
+ * A tone's label on a tone's fill, resolved the way a browser resolves it.
340
+ *
341
+ * `checkLegibility` skips a token it cannot find, and that is right: an app that
342
+ * declares no `--sidebar` has not failed a bar, it has declined a role. But a
343
+ * token missing *while its siblings are present* is a different animal. The tone
344
+ * still renders — `var(--brand-foreground,var(--primary-foreground))` simply
345
+ * moves to the next link — so the control is painted in a pair the app never
346
+ * chose and skipping it reads as a pass.
347
+ *
348
+ * That is how a bronze `--brand` shipped with a white label at 2.80:1: the fill
349
+ * was declared, the label was not, and nothing measured the pair that actually
350
+ * reached the screen. So this follows the chain to whichever link is really
351
+ * there, and measures that. An app declaring a whole tone is measured on its own
352
+ * tokens; one declaring none falls through to the package's, which are measured
353
+ * anyway; one declaring half hears about it in the only terms that matter, the
354
+ * two colours a reader is going to see.
355
+ */
356
+ function checkToneCuts(css, { themes = ["light", "dark"] } = {}) {
357
+ const failures = [];
358
+ for (const theme of themes) {
359
+ const tokens = resolveTokens(css, theme);
360
+ const colorOf = (token) => parseColor(tokens[token] ?? "");
361
+ for (const [, chains] of TONE_CHAINS) {
362
+ const fill = chains.fill.find((token) => colorOf(token));
363
+ const ink = chains.ink.find((token) => colorOf(token));
364
+ if (!fill || !ink)
365
+ continue;
366
+ const ratio = contrast(colorOf(ink), colorOf(fill));
367
+ if (ratio >= 4.5)
368
+ continue;
369
+ failures.push({
370
+ theme,
371
+ ink,
372
+ surface: fill,
373
+ ratio,
374
+ required: 4.5,
375
+ // Only when the tone's own ink was not the one that answered.
376
+ ...(ink === chains.ink[0] ? {} : { via: chains.ink[0] }),
377
+ });
378
+ }
379
+ }
380
+ return failures;
381
+ }
239
382
  /**
240
383
  * The cuts a token ships, read off the same three sets `checkSignals` measures.
241
384
  *
@@ -254,20 +397,75 @@ export function tokenCuts(token) {
254
397
  asInk: INKS_TINTED.find((ink) => ink === `${fill}-ink`),
255
398
  };
256
399
  }
400
+ /**
401
+ * A hairline is neither ink nor a mark, so neither bar fits: WCAG exempts a
402
+ * decorative rule outright, and holding one to 3:1 would draw a box, not a
403
+ * border. What it owes is symmetry — the same rule has to read as the same
404
+ * weight in both themes, and it did not: the dark hairline was tuned by hand
405
+ * (L22's 1.34:1 on --card was rejected as too faint) while the light one was
406
+ * never measured at all and shipped under the value dark had turned down.
407
+ *
408
+ * 1.4:1 is that floor, set just under the pair the themes now agree on. Only
409
+ * --background and --card: a rule inside a `muted` well sits on a surface that
410
+ * is itself a wash, and 1.3:1 is the practical floor for that kind of well.
411
+ */
412
+ const HAIRLINES = ["--border", "--input"];
413
+ const HAIRLINE_SURFACES = ["--background", "--card"];
414
+ /**
415
+ * The sidebar keeps its own pair, because a rule there is drawn on `--sidebar`
416
+ * and never on the page. Measuring it against `--background` would fail a border
417
+ * that is correct and pass one that is not.
418
+ */
419
+ const SIDEBAR_HAIRLINE = [
420
+ "--sidebar-border",
421
+ "--sidebar",
422
+ ];
423
+ /**
424
+ * The bar a rule owes, held apart from `checkSignals` because it is not a
425
+ * signal: nothing here carries meaning in its hue, it only has to be seen.
426
+ */
427
+ export function checkHairlines(css, { themes = ["light", "dark"] } = {}) {
428
+ return [
429
+ ...checkLegibility(css, {
430
+ inks: HAIRLINES,
431
+ surfaces: HAIRLINE_SURFACES,
432
+ minimum: 1.4,
433
+ themes,
434
+ }),
435
+ ...checkLegibility(css, {
436
+ inks: [SIDEBAR_HAIRLINE[0]],
437
+ surfaces: [SIDEBAR_HAIRLINE[1]],
438
+ minimum: 1.4,
439
+ themes,
440
+ }),
441
+ ];
442
+ }
257
443
  /**
258
444
  * The three bars a palette owes, run over the same engine as `checkLegibility`.
259
445
  * Without this the numbers in a theme's comments are claims, not measurements.
260
446
  */
261
447
  export function checkSignals(css, { themes = ["light", "dark"] } = {}) {
262
448
  return [
263
- ...checkLegibility(css, { inks: FILLS, surfaces: FILL_SURFACES, minimum: 3, themes }),
449
+ ...checkLegibility(css, {
450
+ inks: FILLS,
451
+ surfaces: FILL_SURFACES,
452
+ minimum: 3,
453
+ themes,
454
+ }),
264
455
  ...checkLegibility(css, { inks: INKS_TINTED, themes }),
265
- ...ON_FILL.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
456
+ ...checkLegibility(css, { inks: TERTIARY, minimum: 3, themes }),
457
+ ...ON_SURFACE.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
458
+ ...checkToneCuts(css, { themes }),
266
459
  ];
267
460
  }
268
461
  /** A one-line report per failure, for a test's assertion message. */
269
462
  export function formatFailures(failures) {
270
463
  return failures
271
- .map((f) => `${f.theme}: ${f.ink} on ${f.surface} is ${f.ratio.toFixed(2)}:1, below ${f.required}:1`)
464
+ .map((f) => {
465
+ const measured = `${f.theme}: ${f.ink} on ${f.surface} is ${f.ratio.toFixed(2)}:1, below ${f.required}:1`;
466
+ return f.via
467
+ ? `${measured} — ${f.via} is not declared, so ${f.surface} took ${f.ink} from the fallback`
468
+ : measured;
469
+ })
272
470
  .join("\n");
273
471
  }
package/dist/eslint.d.ts CHANGED
@@ -20,11 +20,27 @@ export declare function colourRules({ accents, }?: ColourOptions): RestrictedSyn
20
20
  */
21
21
  export declare function themeOverrideRules(): RestrictedSyntax[];
22
22
  /**
23
- * `--muted` is a fill at L92%, so `text-muted` is ~1.1:1 — invisible, and it
24
- * shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
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.
25
26
  */
26
27
  export declare function surfaceAsInkRules(): RestrictedSyntax[];
27
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[];
28
44
  export interface TypographyOptions {
29
45
  /** Three-weight ramp. Off for editorial, where 700 is a register not a shout. */
30
46
  weights?: boolean;
@@ -40,7 +56,7 @@ export interface TypographyOptions {
40
56
  /**
41
57
  * Flag a size class on a primitive that already owns a size axis. Off by
42
58
  * default for the same reason as `pairing`: it fails until the consumer has
43
- * migrated, and the migration is the point.
59
+ * migrated, and that migration is the intended end state.
44
60
  */
45
61
  axis?: boolean;
46
62
  }
@@ -48,7 +64,7 @@ export declare function typographyRules({ weights, ramp, pairing, axis, }?: Typo
48
64
  /**
49
65
  * Every design rule, as one list.
50
66
  *
51
- * The five builders below it are still exported, and spreading them by hand is
67
+ * The builders below it are still exported, and spreading them by hand is
52
68
  * what both consumers were doing — one of them into a flat config, the other
53
69
  * into a legacy `.eslintrc`, and *both* of them had quietly left out
54
70
  * `renamedTokenRules`, so neither would have flagged a deprecated token name.
package/dist/eslint.js CHANGED
@@ -39,22 +39,49 @@ export 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
  /**
42
- * `--muted` is a fill at L92%, so `text-muted` is ~1.1:1 — invisible, and it
43
- * shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
42
+ * `--muted` is a fill at L92%, so `text-muted` lands at ~1.1:1. Invisible, and
43
+ * it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
44
+ * role.
44
45
  */
45
46
  export function surfaceAsInkRules() {
46
47
  return 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.");
47
48
  }
48
49
  /**
49
50
  * `-foreground` means the label printed on a fill; `-ink` means the hue as
50
- * words. `warn-foreground` and the eight categorical `-foreground` tokens were
51
- * always inks, under the other name. The old spellings still resolve, so nothing
52
- * breaks on the day of the rename; this is what stops them surviving it.
51
+ * words. The eight categorical `-foreground` tokens were always inks, under the
52
+ * other name. The old spellings still resolve, so nothing breaks on the day of
53
+ * the rename; this is what stops them surviving it.
54
+ *
55
+ * `warn` left this list when the status tones gained real on-fill labels:
56
+ * `--warn-foreground` now means what its name says, the ink printed on the warn
57
+ * fill, and `Button tone="warn" variant="solid"` is what reads it.
53
58
  */
54
- const RENAMED_INKS = "warn|terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
59
+ const RENAMED_INKS = "terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
55
60
  export function renamedTokenRules() {
56
61
  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`.");
57
62
  }
63
+ /**
64
+ * `render={<a href="…" />}` on a component that takes an `href`. It reads as a
65
+ * styling choice and is a routing one: the cloned anchor skips the router, so
66
+ * the page fully reloads and the view transition is lost, and an off-site href
67
+ * never grows a `rel`. Button, Badge and Card each decide internal vs external
68
+ * from the href itself, so the anchor is never needed and cannot be right more
69
+ * often than the one shared rule is.
70
+ *
71
+ * Narrow on both axes, so it never fires on a line that is correct. Only those
72
+ * three components — `RailLink` deliberately takes a router element through
73
+ * `render`, because its module has to stay importable without Next. And only a
74
+ * bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
75
+ * so it is not a bug.
76
+ */
77
+ export function linkRules() {
78
+ return [
79
+ {
80
+ selector: 'JSXOpeningElement[name.name=/^(Button|Badge|Card)$/] > JSXAttribute[name.name="render"] > JSXExpressionContainer > JSXElement > JSXOpeningElement[name.name="a"]',
81
+ message: "Pass `href` instead of rendering an anchor. A cloned <a> bypasses the router (full page load, no view transition) and gets no rel on an off-site href; `href` routes through the package's one rule. `render` is for an element that is not a link.",
82
+ },
83
+ ];
84
+ }
58
85
  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, } = {}) {
59
86
  return [
60
87
  // Alpha ink composites against whatever surface it lands on, so its
@@ -100,7 +127,7 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
100
127
  ? [
101
128
  {
102
129
  selector: 'JSXElement:has(>JSXOpeningElement[name.name="TypographyP"]) ~ JSXElement > JSXOpeningElement[name.name="TypographyProseList"]',
103
- message: "A ui paragraph over a prose list splits one passage across two rungs. Promote the paragraph with TypographyProse, or drop the list to the paragraph's rung with TypographyList variant=\"ui\".",
130
+ message: 'A ui paragraph over a prose list splits one passage across two rungs. Promote the paragraph with TypographyProse, or drop the list to the paragraph\'s rung with TypographyList variant="ui".',
104
131
  },
105
132
  ]
106
133
  : []),
@@ -113,6 +140,7 @@ export function designRules({ accents, typography = true, ...type } = {}) {
113
140
  return [
114
141
  ...colourRules({ accents }),
115
142
  ...(typography ? typographyRules(type) : []),
143
+ ...linkRules(),
116
144
  ...themeOverrideRules(),
117
145
  ...surfaceAsInkRules(),
118
146
  ...renamedTokenRules(),
@@ -136,10 +164,7 @@ export function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } =
136
164
  name: "@supertype.ai/foundations/design",
137
165
  files,
138
166
  rules: {
139
- "no-restricted-syntax": [
140
- "error",
141
- ...designRules(options),
142
- ],
167
+ "no-restricted-syntax": ["error", ...designRules(options)],
143
168
  },
144
169
  },
145
170
  ];
@@ -5,7 +5,7 @@ import type { TocHeading } from "./toc.js";
5
5
  * moment an aside appeared, setting body copy on a different axis per page.
6
6
  * The measure grows per step — a comfortable line length is a range.
7
7
  *
8
- * The margin track is what the third one buys, and it only exists if the
8
+ * The margin track is what the third one buys, and appears only where the
9
9
  * container can pay for it:
10
10
  *
11
11
  * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
@@ -6,7 +6,7 @@ import { ReadingRail } from "./reading.js";
6
6
  * moment an aside appeared, setting body copy on a different axis per page.
7
7
  * The measure grows per step — a comfortable line length is a range.
8
8
  *
9
- * The margin track is what the third one buys, and it only exists if the
9
+ * The margin track is what the third one buys, and appears only where the
10
10
  * container can pay for it:
11
11
  *
12
12
  * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
@@ -10,6 +10,16 @@ export declare function RailLink({ active, nested, className, children, render,
10
10
  /** A sub-heading under the item above it, indented a step further in. */
11
11
  nested?: boolean;
12
12
  children: ReactNode;
13
- /** Swap the anchor for another link element, e.g. `<Link href={…} />`. */
13
+ /**
14
+ * Swap the anchor for another link element, e.g. `<Link href={…} />`.
15
+ *
16
+ * The one link in the package that does NOT take an `href` and route it
17
+ * itself, and deliberately: this module is reached from `contents.tsx`,
18
+ * `reading.tsx` and `layout.tsx`, which a consumer imports in bare Node and in
19
+ * a test runner with no Next installed. Importing ../href.ts here would put
20
+ * `next/link` on that path — test/essay-toc.test.ts is what holds the line.
21
+ * The rail's own links are `#hash` anchors, which want no router anyway; a
22
+ * rail of routes passes the router's Link through `render`.
23
+ */
14
24
  render?: ReactElement<ComponentProps<"a">>;
15
25
  }): import("react").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useState, useSyncExternalStore } from "react";
3
3
  /**
4
- * Reading progress 01 from one shared listener: a page mounts both the bar and
4
+ * Reading progress, 0 to 1, from one shared listener: a page mounts both the bar and
5
5
  * the rail, and hook-local state would double every subscription. Reads coalesce
6
6
  * to a frame, since `scrollHeight` forces layout.
7
7
  */
package/dist/href.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { ComponentProps, ReactElement } from "react";
2
+ /**
3
+ * Where a link goes, decided once.
4
+ *
5
+ * This branch — scheme test, router `Link` or plain `<a>`, `rel` on the way out
6
+ * — was written three times: Card, TypographyLink, and (by omission) every call
7
+ * site that reached for `render={<a href="…" />}` because the component it was
8
+ * calling had no `href` of its own. The last of those is the expensive copy: it
9
+ * looks like a styling escape hatch and is actually a routing decision, made at
10
+ * the call site, wrongly. A hero CTA written that way full-page-reloads past the
11
+ * router and drops the view transition, and nothing in the type system says so.
12
+ *
13
+ * So components take `href`, not an anchor. `render` stays for what it is for:
14
+ * an element that is genuinely not an anchor.
15
+ */
16
+ /** A scheme (`mailto:`, `https:`) means the href leaves the app entirely. */
17
+ export declare function isExternalHref(href: string): boolean;
18
+ export type LinkBehavior = {
19
+ /** Override the scheme sniff: an absolute URL that is home, or a relative one that is not. */
20
+ external?: boolean;
21
+ /**
22
+ * Defaults on for an http(s) href, off for everything else — `mailto:` and
23
+ * `tel:` hand off to another app and have no tab to open.
24
+ */
25
+ newTab?: boolean;
26
+ };
27
+ /** `Link` requires its own href; as far as a call site here goes it is an anchor. */
28
+ type AnchorComponent = (props: ComponentProps<"a">) => ReactElement | null;
29
+ export type ResolvedLink = {
30
+ Component: AnchorComponent | "a";
31
+ /** Spread onto the element: the href, plus `target`/`rel` when it opens away. */
32
+ props: ComponentProps<"a">;
33
+ /** For a caller that renders differently for an off-site link — an arrow glyph, an icon. */
34
+ external: boolean;
35
+ };
36
+ /**
37
+ * A same-page hash is the one internal href that stays a plain anchor: routing
38
+ * `#section` through the router asks for a navigation and a view transition to
39
+ * reach a place the browser can already scroll to.
40
+ */
41
+ export declare function resolveLink(href: string, { external, newTab }?: LinkBehavior): ResolvedLink;
42
+ export {};
package/dist/href.js ADDED
@@ -0,0 +1,63 @@
1
+ import { Link } from "next-view-transitions";
2
+ /**
3
+ * Where a link goes, decided once.
4
+ *
5
+ * This branch — scheme test, router `Link` or plain `<a>`, `rel` on the way out
6
+ * — was written three times: Card, TypographyLink, and (by omission) every call
7
+ * site that reached for `render={<a href="…" />}` because the component it was
8
+ * calling had no `href` of its own. The last of those is the expensive copy: it
9
+ * looks like a styling escape hatch and is actually a routing decision, made at
10
+ * the call site, wrongly. A hero CTA written that way full-page-reloads past the
11
+ * router and drops the view transition, and nothing in the type system says so.
12
+ *
13
+ * So components take `href`, not an anchor. `render` stays for what it is for:
14
+ * an element that is genuinely not an anchor.
15
+ */
16
+ /** A scheme (`mailto:`, `https:`) means the href leaves the app entirely. */
17
+ export function isExternalHref(href) {
18
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
19
+ }
20
+ /**
21
+ * What a wrong `href` is worth saying out loud.
22
+ *
23
+ * A value exported from a `"use client"` module and imported by a server
24
+ * component arrives as a boundary stub — a function that throws when called —
25
+ * rather than the string it is in the client bundle. Passing one here read as
26
+ * `TypeError: href.startsWith is not a function`, which React reported with an
27
+ * empty stack: no component, no file, and every route in the app failing at once
28
+ * because the offending link sat in a layout.
29
+ *
30
+ * The value is the diagnosis, so it goes in the message. `String()` on that stub
31
+ * prints the "Attempted to call X() from the server" text React put there, which
32
+ * names the export and the boundary in one line.
33
+ */
34
+ function assertHref(href) {
35
+ if (typeof href === "string")
36
+ return;
37
+ const seen = typeof href === "function"
38
+ ? `a function: ${String(href).replace(/\s+/g, " ").slice(0, 160)}`
39
+ : `${typeof href}: ${JSON.stringify(href)}`;
40
+ throw new TypeError(`href must be a string, and this one is ${seen}. A function here is usually ` +
41
+ `a value exported from a "use client" module and imported by a server ` +
42
+ `component, which crosses the boundary as a stub rather than a string. ` +
43
+ `Move the constant to a plain module and import it from both sides.`);
44
+ }
45
+ /**
46
+ * A same-page hash is the one internal href that stays a plain anchor: routing
47
+ * `#section` through the router asks for a navigation and a view transition to
48
+ * reach a place the browser can already scroll to.
49
+ */
50
+ export function resolveLink(href, { external, newTab } = {}) {
51
+ assertHref(href);
52
+ const leavesApp = external ?? isExternalHref(href);
53
+ const away = leavesApp && (newTab ?? href.startsWith("http"));
54
+ const inPage = !leavesApp && href.startsWith("#");
55
+ return {
56
+ Component: leavesApp || inPage ? "a" : Link,
57
+ props: {
58
+ href,
59
+ ...(away ? { target: "_blank", rel: "noopener noreferrer" } : {}),
60
+ },
61
+ external: leavesApp,
62
+ };
63
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { cn } from "./cn.js";
2
- export { toneClass, impliedTone, type Tone } from "./tone.js";
2
+ export { toneClass, impliedTone, INK_ON_FILL, INK_ON_CARD, INK_ON_POPOVER, INK_ON_SIDEBAR, inkOnSurfaceStyle, type Tone, } from "./tone.js";
3
+ export { resolveLink, isExternalHref, type LinkBehavior, type ResolvedLink, } from "./href.js";
3
4
  export * from "./typography/index.js";
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  export { cn } from "./cn.js";
2
2
  // The semantic colour vocabulary. Exported from the root because typography
3
- // takes it too a link has a tone, and it is the same seven a button has.
3
+ // takes it too: a link has a tone, drawn from the same seven a button has.
4
4
  // `toneClass` only: the raw table and its derived half used to ship separately,
5
5
  // and the order they were combined in was load-bearing.
6
- export { toneClass, impliedTone } from "./tone.js";
6
+ export { toneClass, impliedTone, INK_ON_FILL, INK_ON_CARD, INK_ON_POPOVER, INK_ON_SIDEBAR, inkOnSurfaceStyle, } from "./tone.js";
7
+ // Where an href goes, for the rare call site that styles someone else's element
8
+ // and cannot render a Card/Button/TypographyLink — the same pairing with
9
+ // `buttonVariants`. Prefer passing `href` to a component over calling this.
10
+ export { resolveLink, isExternalHref, } from "./href.js";
7
11
  export * from "./typography/index.js";
8
12
  // NOTE: blocks, the MDX map, and the Shiki plugin are all deliberately absent
9
13
  // from this barrel.