@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.
- package/README.md +26 -22
- package/bin/foundations.mjs +5 -2
- package/dist/blocks/accordion.js +2 -1
- package/dist/blocks/anchor.d.ts +27 -0
- package/dist/blocks/anchor.js +28 -0
- package/dist/blocks/badge.d.ts +7 -2
- package/dist/blocks/badge.js +10 -4
- package/dist/blocks/button.d.ts +25 -4
- package/dist/blocks/button.js +16 -6
- package/dist/blocks/card.d.ts +5 -6
- package/dist/blocks/card.js +8 -12
- package/dist/blocks/index.d.ts +1 -0
- package/dist/blocks/index.js +1 -0
- package/dist/blocks/tabs.d.ts +5 -5
- package/dist/blocks/tabs.js +3 -3
- package/dist/cjs/eslint.js +37 -11
- package/dist/contrast.d.ts +34 -0
- package/dist/contrast.js +208 -10
- package/dist/eslint.d.ts +20 -4
- package/dist/eslint.js +36 -11
- package/dist/essay/layout.d.ts +1 -1
- package/dist/essay/layout.js +1 -1
- package/dist/essay/rail.d.ts +11 -1
- package/dist/essay/scroll.js +1 -1
- package/dist/href.d.ts +42 -0
- package/dist/href.js +63 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +6 -2
- package/dist/tone.d.ts +58 -9
- package/dist/tone.js +60 -12
- package/dist/typography/header.d.ts +2 -2
- package/dist/typography/header.js +4 -4
- package/dist/typography/highlight.d.ts +3 -2
- package/dist/typography/highlight.js +11 -5
- package/dist/typography/paragraph.d.ts +9 -12
- package/dist/typography/paragraph.js +29 -22
- package/llms.txt +97 -33
- package/package.json +2 -2
- package/src/theme.css +73 -20
- package/src/tokens.css +5 -4
- package/src/type.css +1 -1
package/dist/contrast.d.ts
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
-
...
|
|
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) =>
|
|
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`
|
|
24
|
-
* shipped at 17 sites. `text-background`
|
|
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
|
|
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
|
|
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`
|
|
43
|
-
* shipped at 17 sites. `text-background`
|
|
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.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 = "
|
|
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:
|
|
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
|
];
|
package/dist/essay/layout.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/essay/layout.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/essay/rail.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/essay/scroll.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
3
|
/**
|
|
4
|
-
* Reading progress 0
|
|
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
|
|
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.
|