@supertype.ai/foundations 0.1.28 → 0.1.30
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 +27 -23
- package/bin/foundations.mjs +5 -2
- package/dist/blocks/accordion.js +2 -1
- package/dist/blocks/badge.d.ts +27 -0
- package/dist/blocks/badge.js +86 -0
- package/dist/blocks/button.d.ts +32 -0
- package/dist/blocks/button.js +127 -0
- package/dist/blocks/callout.d.ts +2 -39
- package/dist/blocks/callout.js +13 -38
- package/dist/blocks/card.d.ts +5 -6
- package/dist/blocks/card.js +28 -12
- package/dist/blocks/focus.d.ts +9 -0
- package/dist/blocks/focus.js +9 -0
- package/dist/blocks/index.d.ts +3 -1
- package/dist/blocks/index.js +3 -1
- package/dist/blocks/interactive-accordion.js +2 -1
- package/dist/blocks/render-as.d.ts +11 -0
- package/dist/blocks/render-as.js +22 -0
- package/dist/blocks/segment.d.ts +22 -14
- package/dist/blocks/segment.js +23 -14
- package/dist/blocks/tabs.d.ts +52 -17
- package/dist/blocks/tabs.js +86 -37
- package/dist/cjs/eslint.js +49 -14
- package/dist/contrast.d.ts +56 -0
- package/dist/contrast.js +144 -12
- package/dist/eslint.d.ts +42 -8
- package/dist/eslint.js +47 -14
- package/dist/essay/essay.js +14 -6
- package/dist/essay/index.d.ts +1 -1
- package/dist/essay/index.js +1 -1
- package/dist/essay/layout.d.ts +37 -1
- package/dist/essay/layout.js +33 -1
- package/dist/essay/rail.d.ts +11 -1
- package/dist/essay/reading.d.ts +1 -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 -0
- package/dist/index.js +9 -0
- package/dist/mdx.d.ts +26 -4
- package/dist/mdx.js +30 -4
- package/dist/tone.d.ts +130 -0
- package/dist/tone.js +140 -0
- package/dist/typography/header.d.ts +32 -2
- package/dist/typography/header.js +10 -5
- package/dist/typography/highlight.d.ts +7 -6
- package/dist/typography/highlight.js +15 -9
- package/dist/typography/paragraph.d.ts +12 -25
- package/dist/typography/paragraph.js +40 -29
- package/llms.txt +99 -22
- package/package.json +2 -1
- package/src/theme.css +143 -46
- package/src/tokens.css +5 -4
- package/src/type.css +1 -1
package/dist/cjs/eslint.js
CHANGED
|
@@ -9,7 +9,9 @@ exports.colourRules = colourRules;
|
|
|
9
9
|
exports.themeOverrideRules = themeOverrideRules;
|
|
10
10
|
exports.surfaceAsInkRules = surfaceAsInkRules;
|
|
11
11
|
exports.renamedTokenRules = renamedTokenRules;
|
|
12
|
+
exports.linkRules = linkRules;
|
|
12
13
|
exports.typographyRules = typographyRules;
|
|
14
|
+
exports.designRules = designRules;
|
|
13
15
|
exports.designConfig = designConfig;
|
|
14
16
|
/** A className written as a plain string, or as a chunk of a template literal. */
|
|
15
17
|
const classString = (pattern) => [
|
|
@@ -47,18 +49,48 @@ function themeOverrideRules() {
|
|
|
47
49
|
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.");
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
|
-
* `--muted` is a fill at L92%, so `text-muted`
|
|
51
|
-
* shipped at 17 sites. `text-background`
|
|
52
|
+
* `--muted` is a fill at L92%, so `text-muted` lands at ~1.1:1. Invisible, and
|
|
53
|
+
* it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
|
|
54
|
+
* role.
|
|
52
55
|
*/
|
|
53
56
|
function surfaceAsInkRules() {
|
|
54
57
|
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.");
|
|
55
58
|
}
|
|
56
59
|
/**
|
|
57
60
|
* `-foreground` means the label printed on a fill; `-ink` means the hue as
|
|
58
|
-
* words.
|
|
61
|
+
* words. The eight categorical `-foreground` tokens were always inks, under the
|
|
62
|
+
* other name. The old spellings still resolve, so nothing breaks on the day of
|
|
63
|
+
* the rename; this is what stops them surviving it.
|
|
64
|
+
*
|
|
65
|
+
* `warn` left this list when the status tones gained real on-fill labels:
|
|
66
|
+
* `--warn-foreground` now means what its name says, the ink printed on the warn
|
|
67
|
+
* fill, and `Button tone="warn" variant="solid"` is what reads it.
|
|
59
68
|
*/
|
|
69
|
+
const RENAMED_INKS = "terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
|
|
60
70
|
function renamedTokenRules() {
|
|
61
|
-
return rule(
|
|
71
|
+
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`.");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `render={<a href="…" />}` on a component that takes an `href`. It reads as a
|
|
75
|
+
* styling choice and is a routing one: the cloned anchor skips the router, so
|
|
76
|
+
* the page fully reloads and the view transition is lost, and an off-site href
|
|
77
|
+
* never grows a `rel`. Button, Badge and Card each decide internal vs external
|
|
78
|
+
* from the href itself, so the anchor is never needed and cannot be right more
|
|
79
|
+
* often than the one shared rule is.
|
|
80
|
+
*
|
|
81
|
+
* Narrow on both axes, so it never fires on a line that is correct. Only those
|
|
82
|
+
* three components — `RailLink` deliberately takes a router element through
|
|
83
|
+
* `render`, because its module has to stay importable without Next. And only a
|
|
84
|
+
* bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
|
|
85
|
+
* so it is not a bug.
|
|
86
|
+
*/
|
|
87
|
+
function linkRules() {
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
selector: 'JSXOpeningElement[name.name=/^(Button|Badge|Card)$/] > JSXAttribute[name.name="render"] > JSXExpressionContainer > JSXElement > JSXOpeningElement[name.name="a"]',
|
|
91
|
+
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.",
|
|
92
|
+
},
|
|
93
|
+
];
|
|
62
94
|
}
|
|
63
95
|
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, } = {}) {
|
|
64
96
|
return [
|
|
@@ -105,7 +137,7 @@ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 /
|
|
|
105
137
|
? [
|
|
106
138
|
{
|
|
107
139
|
selector: 'JSXElement:has(>JSXOpeningElement[name.name="TypographyP"]) ~ JSXElement > JSXOpeningElement[name.name="TypographyProseList"]',
|
|
108
|
-
message:
|
|
140
|
+
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".',
|
|
109
141
|
},
|
|
110
142
|
]
|
|
111
143
|
: []),
|
|
@@ -114,6 +146,16 @@ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 /
|
|
|
114
146
|
: []),
|
|
115
147
|
];
|
|
116
148
|
}
|
|
149
|
+
function designRules({ accents, typography = true, ...type } = {}) {
|
|
150
|
+
return [
|
|
151
|
+
...colourRules({ accents }),
|
|
152
|
+
...(typography ? typographyRules(type) : []),
|
|
153
|
+
...linkRules(),
|
|
154
|
+
...themeOverrideRules(),
|
|
155
|
+
...surfaceAsInkRules(),
|
|
156
|
+
...renamedTokenRules(),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
117
159
|
/**
|
|
118
160
|
* Every rule in one flat-config entry, ready to spread into eslint.config.js:
|
|
119
161
|
*
|
|
@@ -126,20 +168,13 @@ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 /
|
|
|
126
168
|
* half the set by accident. If you need a second scope, call this again with a
|
|
127
169
|
* different `files` and no overlap.
|
|
128
170
|
*/
|
|
129
|
-
function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"],
|
|
171
|
+
function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } = {}) {
|
|
130
172
|
return [
|
|
131
173
|
{
|
|
132
174
|
name: "@supertype.ai/foundations/design",
|
|
133
175
|
files,
|
|
134
176
|
rules: {
|
|
135
|
-
"no-restricted-syntax": [
|
|
136
|
-
"error",
|
|
137
|
-
...colourRules({ accents }),
|
|
138
|
-
...typographyRules({ weights, ramp, pairing, axis }),
|
|
139
|
-
...themeOverrideRules(),
|
|
140
|
-
...surfaceAsInkRules(),
|
|
141
|
-
...renamedTokenRules(),
|
|
142
|
-
],
|
|
177
|
+
"no-restricted-syntax": ["error", ...designRules(options)],
|
|
143
178
|
},
|
|
144
179
|
},
|
|
145
180
|
];
|
package/dist/contrast.d.ts
CHANGED
|
@@ -19,6 +19,26 @@ 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;
|
|
@@ -36,6 +56,42 @@ export declare function checkLegibility(css: string, { minimum, inks, surfaces,
|
|
|
36
56
|
surfaces?: string[] | undefined;
|
|
37
57
|
themes?: Theme[] | undefined;
|
|
38
58
|
}): LegibilityFailure[];
|
|
59
|
+
/** What a token is: a surface or mark, a label printed on it, a hue used as words. */
|
|
60
|
+
export interface TokenCuts {
|
|
61
|
+
/** The role itself — a surface, or a mark held to 3:1 against the page. */
|
|
62
|
+
fill: string;
|
|
63
|
+
/**
|
|
64
|
+
* The label printed *on* that fill. The only pair measured one against the
|
|
65
|
+
* other rather than against the page, which is why it is the only pair a
|
|
66
|
+
* swatch may print one on top of the other.
|
|
67
|
+
*/
|
|
68
|
+
onFill?: string;
|
|
69
|
+
/**
|
|
70
|
+
* The same hue used as words, held to 4.5:1 against the page and never printed
|
|
71
|
+
* on the fill — there it measures about 1.2:1. Always `-ink`; the categorical
|
|
72
|
+
* hues shipped under `-foreground` until that was corrected, and the old names
|
|
73
|
+
* survive only as aliases the ESLint rules flag.
|
|
74
|
+
*/
|
|
75
|
+
asInk?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The cuts a token ships, read off the same three sets `checkSignals` measures.
|
|
79
|
+
*
|
|
80
|
+
* Exported because the alternative is every consumer keeping its own idea of
|
|
81
|
+
* which tokens are pairs — the docs site did, and got the categorical hues
|
|
82
|
+
* wrong, rendering `--ochre` as a lone square while its ink, the colour the
|
|
83
|
+
* marker highlight is painted with, appeared nowhere. A palette checked against
|
|
84
|
+
* one taxonomy and documented from another will drift, and the drift shows up as
|
|
85
|
+
* a page that is quietly wrong rather than a build that fails.
|
|
86
|
+
*/
|
|
87
|
+
export declare function tokenCuts(token: string): TokenCuts;
|
|
88
|
+
/**
|
|
89
|
+
* The bar a rule owes, held apart from `checkSignals` because it is not a
|
|
90
|
+
* signal: nothing here carries meaning in its hue, it only has to be seen.
|
|
91
|
+
*/
|
|
92
|
+
export declare function checkHairlines(css: string, { themes }?: {
|
|
93
|
+
themes?: Theme[] | undefined;
|
|
94
|
+
}): LegibilityFailure[];
|
|
39
95
|
/**
|
|
40
96
|
* The three bars a palette owes, run over the same engine as `checkLegibility`.
|
|
41
97
|
* Without this the numbers in a theme's comments are claims, not measurements.
|
package/dist/contrast.js
CHANGED
|
@@ -131,9 +131,7 @@ export function parseColor(value) {
|
|
|
131
131
|
}
|
|
132
132
|
const hex = input.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
133
133
|
if (hex) {
|
|
134
|
-
const digits = hex[1].length === 3
|
|
135
|
-
? [...hex[1]].map((d) => d + d).join("")
|
|
136
|
-
: hex[1];
|
|
134
|
+
const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d).join("") : hex[1];
|
|
137
135
|
return [
|
|
138
136
|
parseInt(digits.slice(0, 2), 16),
|
|
139
137
|
parseInt(digits.slice(2, 4), 16),
|
|
@@ -158,6 +156,47 @@ export function contrast(a, b) {
|
|
|
158
156
|
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
159
157
|
return (hi + 0.05) / (lo + 0.05);
|
|
160
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* APCA lightness contrast (Lc), the perceptual measure WCAG 3 is built on.
|
|
161
|
+
*
|
|
162
|
+
* It sits beside `contrast` because the two answer different questions and an
|
|
163
|
+
* ink ramp needs both. A WCAG ratio is polarity-blind: it reports the same
|
|
164
|
+
* number whether the text is dark on light or light on dark, when in fact dark
|
|
165
|
+
* glyphs on a bright field thin out and light glyphs on a dark field bloat. That
|
|
166
|
+
* blindness is what lets a ramp be ordered by ratio and still read flat — viably
|
|
167
|
+
* shipped a `--muted-foreground` measuring 72.5 Lc in light and 52.1 in dark,
|
|
168
|
+
* the same verdict from `contrast` on both sides and twenty points apart to a
|
|
169
|
+
* reader.
|
|
170
|
+
*
|
|
171
|
+
* Lc also states the term a ratio cannot: legibility is contrast times size, so
|
|
172
|
+
* a floor here is what says an ink comfortable at 16px is or is not comfortable
|
|
173
|
+
* on the 13px rung a dense product actually spends.
|
|
174
|
+
*
|
|
175
|
+
* Returned absolute. It is signed by polarity in the specification, and every
|
|
176
|
+
* caller so far asks "is this legible", never "which way round is it".
|
|
177
|
+
*/
|
|
178
|
+
export function lc(text, background) {
|
|
179
|
+
// Screen luminance on APCA's own curve, which is not WCAG's: exponent 2.4 on
|
|
180
|
+
// the raw channel, with weights of its own.
|
|
181
|
+
const y = ([r, g, b]) => {
|
|
182
|
+
const v = 0.2126729 * (r / 255) ** 2.4 +
|
|
183
|
+
0.7151522 * (g / 255) ** 2.4 +
|
|
184
|
+
0.072175 * (b / 255) ** 2.4;
|
|
185
|
+
// Soft clamp near black, where the power curve stops modelling perception.
|
|
186
|
+
return v < 0.022 ? v + (0.022 - v) ** 1.414 : v;
|
|
187
|
+
};
|
|
188
|
+
const [yText, yBackground] = [y(text), y(background)];
|
|
189
|
+
// Two exponent pairs, one per polarity. This asymmetry is the whole reason Lc
|
|
190
|
+
// says something a ratio cannot.
|
|
191
|
+
const s = yBackground > yText
|
|
192
|
+
? (yBackground ** 0.56 - yText ** 0.57) * 1.14
|
|
193
|
+
: (yBackground ** 0.65 - yText ** 0.62) * 1.14;
|
|
194
|
+
// Below the noise floor the two are the same colour as far as a reader is
|
|
195
|
+
// concerned, and the offset below would report a spurious 2.7.
|
|
196
|
+
if (Math.abs(s) < 0.1)
|
|
197
|
+
return 0;
|
|
198
|
+
return Math.abs(s > 0 ? (s - 0.027) * 100 : (s + 0.027) * 100);
|
|
199
|
+
}
|
|
161
200
|
const INKS = ["--foreground", "--muted-foreground", "--card-foreground"];
|
|
162
201
|
const SURFACES = ["--background", "--card", "--muted"];
|
|
163
202
|
/**
|
|
@@ -200,6 +239,16 @@ const FILLS = [
|
|
|
200
239
|
"--stone",
|
|
201
240
|
"--fig",
|
|
202
241
|
"--cocoa",
|
|
242
|
+
// A chart series is a mark like any other, and docs/cli.md has always said so
|
|
243
|
+
// ("a status dot or a chart bar that cannot be picked out of its background").
|
|
244
|
+
// Leaving them out of this list is how the sand shipped at 2.18:1 in light and
|
|
245
|
+
// the taupe at 2.26:1 in dark: a promise in prose that nothing measured.
|
|
246
|
+
"--chart-1",
|
|
247
|
+
"--chart-2",
|
|
248
|
+
"--chart-3",
|
|
249
|
+
"--chart-4",
|
|
250
|
+
"--chart-5",
|
|
251
|
+
"--chart-6",
|
|
203
252
|
];
|
|
204
253
|
/**
|
|
205
254
|
* A fill has to separate from the page and from a card. Not from `--muted`: a
|
|
@@ -209,41 +258,124 @@ const FILLS = [
|
|
|
209
258
|
const FILL_SURFACES = ["--background", "--card"];
|
|
210
259
|
/** The same hues as words, at the bar body copy is held to. */
|
|
211
260
|
const INKS_TINTED = [
|
|
261
|
+
"--primary-ink",
|
|
212
262
|
"--success-ink",
|
|
213
263
|
"--warn-ink",
|
|
214
264
|
"--info-ink",
|
|
215
265
|
"--danger",
|
|
216
266
|
"--secondary-ink",
|
|
217
|
-
"--terracotta-
|
|
218
|
-
"--ochre-
|
|
219
|
-
"--moss-
|
|
220
|
-
"--fern-
|
|
221
|
-
"--sage-
|
|
222
|
-
"--stone-
|
|
223
|
-
"--fig-
|
|
224
|
-
"--cocoa-
|
|
267
|
+
"--terracotta-ink",
|
|
268
|
+
"--ochre-ink",
|
|
269
|
+
"--moss-ink",
|
|
270
|
+
"--fern-ink",
|
|
271
|
+
"--sage-ink",
|
|
272
|
+
"--stone-ink",
|
|
273
|
+
"--fig-ink",
|
|
274
|
+
"--cocoa-ink",
|
|
225
275
|
];
|
|
276
|
+
/**
|
|
277
|
+
* The tertiary ink, at the 3:1 its own comment in theme.css claims for it —
|
|
278
|
+
* placeholders and disabled labels, never anything load-bearing. Held here
|
|
279
|
+
* rather than in `INKS` because 4.5:1 would fail a token that is correct; held
|
|
280
|
+
* *somewhere* because the sentence stating the bar was the only thing enforcing
|
|
281
|
+
* it, and light sits at 3.14:1 on --muted with nothing watching the gap.
|
|
282
|
+
*/
|
|
283
|
+
const TERTIARY = ["--subtle-foreground"];
|
|
226
284
|
/**
|
|
227
285
|
* shadcn's shape: `-foreground` is the label printed on the fill, so the pair is
|
|
228
286
|
* measured against itself rather than against the page.
|
|
287
|
+
*
|
|
288
|
+
* `--success` and `--warn` joined the list when the tone table stopped making
|
|
289
|
+
* exceptions of them. A filled status control is a real thing, `Button
|
|
290
|
+
* tone="warn" variant="solid"` renders one, and white on amber measured 2.44:1
|
|
291
|
+
* on the dark theme for as long as the pair went unnamed here.
|
|
229
292
|
*/
|
|
230
293
|
const ON_FILL = [
|
|
231
294
|
["--primary", "--primary-foreground"],
|
|
232
295
|
["--secondary", "--secondary-foreground"],
|
|
233
296
|
["--destructive", "--destructive-foreground"],
|
|
297
|
+
["--success", "--success-foreground"],
|
|
298
|
+
["--warn", "--warn-foreground"],
|
|
234
299
|
["--accent", "--accent-foreground"],
|
|
235
300
|
["--card", "--card-foreground"],
|
|
236
301
|
["--popover", "--popover-foreground"],
|
|
237
302
|
["--sidebar", "--sidebar-foreground"],
|
|
238
303
|
];
|
|
304
|
+
/**
|
|
305
|
+
* The cuts a token ships, read off the same three sets `checkSignals` measures.
|
|
306
|
+
*
|
|
307
|
+
* Exported because the alternative is every consumer keeping its own idea of
|
|
308
|
+
* which tokens are pairs — the docs site did, and got the categorical hues
|
|
309
|
+
* wrong, rendering `--ochre` as a lone square while its ink, the colour the
|
|
310
|
+
* marker highlight is painted with, appeared nowhere. A palette checked against
|
|
311
|
+
* one taxonomy and documented from another will drift, and the drift shows up as
|
|
312
|
+
* a page that is quietly wrong rather than a build that fails.
|
|
313
|
+
*/
|
|
314
|
+
export function tokenCuts(token) {
|
|
315
|
+
const fill = token.startsWith("--") ? token : `--${token}`;
|
|
316
|
+
return {
|
|
317
|
+
fill,
|
|
318
|
+
onFill: ON_FILL.find(([surface]) => surface === fill)?.[1],
|
|
319
|
+
asInk: INKS_TINTED.find((ink) => ink === `${fill}-ink`),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A hairline is neither ink nor a mark, so neither bar fits: WCAG exempts a
|
|
324
|
+
* decorative rule outright, and holding one to 3:1 would draw a box, not a
|
|
325
|
+
* border. What it owes is symmetry — the same rule has to read as the same
|
|
326
|
+
* weight in both themes, and it did not: the dark hairline was tuned by hand
|
|
327
|
+
* (L22's 1.34:1 on --card was rejected as too faint) while the light one was
|
|
328
|
+
* never measured at all and shipped under the value dark had turned down.
|
|
329
|
+
*
|
|
330
|
+
* 1.4:1 is that floor, set just under the pair the themes now agree on. Only
|
|
331
|
+
* --background and --card: a rule inside a `muted` well sits on a surface that
|
|
332
|
+
* is itself a wash, and 1.3:1 is the practical floor for that kind of well.
|
|
333
|
+
*/
|
|
334
|
+
const HAIRLINES = ["--border", "--input"];
|
|
335
|
+
const HAIRLINE_SURFACES = ["--background", "--card"];
|
|
336
|
+
/**
|
|
337
|
+
* The sidebar keeps its own pair, because a rule there is drawn on `--sidebar`
|
|
338
|
+
* and never on the page. Measuring it against `--background` would fail a border
|
|
339
|
+
* that is correct and pass one that is not.
|
|
340
|
+
*/
|
|
341
|
+
const SIDEBAR_HAIRLINE = [
|
|
342
|
+
"--sidebar-border",
|
|
343
|
+
"--sidebar",
|
|
344
|
+
];
|
|
345
|
+
/**
|
|
346
|
+
* The bar a rule owes, held apart from `checkSignals` because it is not a
|
|
347
|
+
* signal: nothing here carries meaning in its hue, it only has to be seen.
|
|
348
|
+
*/
|
|
349
|
+
export function checkHairlines(css, { themes = ["light", "dark"] } = {}) {
|
|
350
|
+
return [
|
|
351
|
+
...checkLegibility(css, {
|
|
352
|
+
inks: HAIRLINES,
|
|
353
|
+
surfaces: HAIRLINE_SURFACES,
|
|
354
|
+
minimum: 1.4,
|
|
355
|
+
themes,
|
|
356
|
+
}),
|
|
357
|
+
...checkLegibility(css, {
|
|
358
|
+
inks: [SIDEBAR_HAIRLINE[0]],
|
|
359
|
+
surfaces: [SIDEBAR_HAIRLINE[1]],
|
|
360
|
+
minimum: 1.4,
|
|
361
|
+
themes,
|
|
362
|
+
}),
|
|
363
|
+
];
|
|
364
|
+
}
|
|
239
365
|
/**
|
|
240
366
|
* The three bars a palette owes, run over the same engine as `checkLegibility`.
|
|
241
367
|
* Without this the numbers in a theme's comments are claims, not measurements.
|
|
242
368
|
*/
|
|
243
369
|
export function checkSignals(css, { themes = ["light", "dark"] } = {}) {
|
|
244
370
|
return [
|
|
245
|
-
...checkLegibility(css, {
|
|
371
|
+
...checkLegibility(css, {
|
|
372
|
+
inks: FILLS,
|
|
373
|
+
surfaces: FILL_SURFACES,
|
|
374
|
+
minimum: 3,
|
|
375
|
+
themes,
|
|
376
|
+
}),
|
|
246
377
|
...checkLegibility(css, { inks: INKS_TINTED, themes }),
|
|
378
|
+
...checkLegibility(css, { inks: TERTIARY, minimum: 3, themes }),
|
|
247
379
|
...ON_FILL.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
|
|
248
380
|
];
|
|
249
381
|
}
|
package/dist/eslint.d.ts
CHANGED
|
@@ -20,15 +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[];
|
|
28
|
+
export declare function renamedTokenRules(): RestrictedSyntax[];
|
|
27
29
|
/**
|
|
28
|
-
*
|
|
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.
|
|
30
42
|
*/
|
|
31
|
-
export declare function
|
|
43
|
+
export declare function linkRules(): RestrictedSyntax[];
|
|
32
44
|
export interface TypographyOptions {
|
|
33
45
|
/** Three-weight ramp. Off for editorial, where 700 is a register not a shout. */
|
|
34
46
|
weights?: boolean;
|
|
@@ -44,18 +56,40 @@ export interface TypographyOptions {
|
|
|
44
56
|
/**
|
|
45
57
|
* Flag a size class on a primitive that already owns a size axis. Off by
|
|
46
58
|
* default for the same reason as `pairing`: it fails until the consumer has
|
|
47
|
-
* migrated, and
|
|
59
|
+
* migrated, and that migration is the intended end state.
|
|
48
60
|
*/
|
|
49
61
|
axis?: boolean;
|
|
50
62
|
}
|
|
51
63
|
export declare function typographyRules({ weights, ramp, pairing, axis, }?: TypographyOptions): RestrictedSyntax[];
|
|
64
|
+
/**
|
|
65
|
+
* Every design rule, as one list.
|
|
66
|
+
*
|
|
67
|
+
* The builders below it are still exported, and spreading them by hand is
|
|
68
|
+
* what both consumers were doing — one of them into a flat config, the other
|
|
69
|
+
* into a legacy `.eslintrc`, and *both* of them had quietly left out
|
|
70
|
+
* `renamedTokenRules`, so neither would have flagged a deprecated token name.
|
|
71
|
+
* That is not a mistake either author made; it is what a five-name API costs
|
|
72
|
+
* every time somebody wires it up. Spread this instead, and a rule added here
|
|
73
|
+
* arrives in both apps on their next bump.
|
|
74
|
+
*/
|
|
75
|
+
export interface DesignRuleOptions extends ColourOptions, TypographyOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Off for a surface that sets its own type ramp — a marketing page under
|
|
78
|
+
* `.editorial`, a mockup drawing the product at reduced scale. Everything
|
|
79
|
+
* about colour still applies: a deprecated token name is wrong on every
|
|
80
|
+
* surface, which is why this is a flag rather than an invitation to pick
|
|
81
|
+
* three of the five builders by hand.
|
|
82
|
+
*/
|
|
83
|
+
typography?: boolean;
|
|
84
|
+
}
|
|
85
|
+
export declare function designRules({ accents, typography, ...type }?: DesignRuleOptions): RestrictedSyntax[];
|
|
52
86
|
/** A flat-config entry, described structurally so the package needs no ESLint dependency. */
|
|
53
87
|
export interface FlatConfigEntry {
|
|
54
88
|
name: string;
|
|
55
89
|
files: string[];
|
|
56
90
|
rules: Record<string, unknown>;
|
|
57
91
|
}
|
|
58
|
-
export interface DesignConfigOptions extends
|
|
92
|
+
export interface DesignConfigOptions extends DesignRuleOptions {
|
|
59
93
|
/** What the rules apply to. Narrow it to exclude generated or vendored code. */
|
|
60
94
|
files?: string[];
|
|
61
95
|
}
|
|
@@ -71,4 +105,4 @@ export interface DesignConfigOptions extends ColourOptions, TypographyOptions {
|
|
|
71
105
|
* half the set by accident. If you need a second scope, call this again with a
|
|
72
106
|
* different `files` and no overlap.
|
|
73
107
|
*/
|
|
74
|
-
export declare function designConfig({ files,
|
|
108
|
+
export declare function designConfig({ files, ...options }?: DesignConfigOptions): FlatConfigEntry[];
|
package/dist/eslint.js
CHANGED
|
@@ -39,18 +39,48 @@ 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
|
+
* 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.
|
|
51
58
|
*/
|
|
59
|
+
const RENAMED_INKS = "terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
|
|
52
60
|
export function renamedTokenRules() {
|
|
53
|
-
return rule(
|
|
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`.");
|
|
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
|
+
];
|
|
54
84
|
}
|
|
55
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, } = {}) {
|
|
56
86
|
return [
|
|
@@ -97,7 +127,7 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
|
|
|
97
127
|
? [
|
|
98
128
|
{
|
|
99
129
|
selector: 'JSXElement:has(>JSXOpeningElement[name.name="TypographyP"]) ~ JSXElement > JSXOpeningElement[name.name="TypographyProseList"]',
|
|
100
|
-
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".',
|
|
101
131
|
},
|
|
102
132
|
]
|
|
103
133
|
: []),
|
|
@@ -106,6 +136,16 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
|
|
|
106
136
|
: []),
|
|
107
137
|
];
|
|
108
138
|
}
|
|
139
|
+
export function designRules({ accents, typography = true, ...type } = {}) {
|
|
140
|
+
return [
|
|
141
|
+
...colourRules({ accents }),
|
|
142
|
+
...(typography ? typographyRules(type) : []),
|
|
143
|
+
...linkRules(),
|
|
144
|
+
...themeOverrideRules(),
|
|
145
|
+
...surfaceAsInkRules(),
|
|
146
|
+
...renamedTokenRules(),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
109
149
|
/**
|
|
110
150
|
* Every rule in one flat-config entry, ready to spread into eslint.config.js:
|
|
111
151
|
*
|
|
@@ -118,20 +158,13 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
|
|
|
118
158
|
* half the set by accident. If you need a second scope, call this again with a
|
|
119
159
|
* different `files` and no overlap.
|
|
120
160
|
*/
|
|
121
|
-
export function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"],
|
|
161
|
+
export function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } = {}) {
|
|
122
162
|
return [
|
|
123
163
|
{
|
|
124
164
|
name: "@supertype.ai/foundations/design",
|
|
125
165
|
files,
|
|
126
166
|
rules: {
|
|
127
|
-
"no-restricted-syntax": [
|
|
128
|
-
"error",
|
|
129
|
-
...colourRules({ accents }),
|
|
130
|
-
...typographyRules({ weights, ramp, pairing, axis }),
|
|
131
|
-
...themeOverrideRules(),
|
|
132
|
-
...surfaceAsInkRules(),
|
|
133
|
-
...renamedTokenRules(),
|
|
134
|
-
],
|
|
167
|
+
"no-restricted-syntax": ["error", ...designRules(options)],
|
|
135
168
|
},
|
|
136
169
|
},
|
|
137
170
|
];
|
package/dist/essay/essay.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cn } from "../cn.js";
|
|
3
|
-
import {
|
|
3
|
+
import { headingFace, TypographyEyebrow, TypographyH1, TypographyH2, TypographyH3, } from "../typography/header.js";
|
|
4
4
|
import { TypographyCaption, TypographyMuted, TypographyProse, } from "../typography/paragraph.js";
|
|
5
|
-
import { EssayColumns } from "./layout.js";
|
|
5
|
+
import { EssayAside, EssayBody, EssayColumns, PostMetaRow } from "./layout.js";
|
|
6
6
|
import { TableOfContents } from "./contents.js";
|
|
7
7
|
/** Pass-through: keeps the className the shell relies on for layout. */
|
|
8
8
|
const PlainReveal = ({ children, className, }) => _jsx("div", { className: className, children: children });
|
|
@@ -31,19 +31,27 @@ const anchorIds = (sections) => {
|
|
|
31
31
|
export function createEssay({ Reveal = PlainReveal, Glow = NoGlow, } = {}) {
|
|
32
32
|
/** Left aligned: the eye has to reach the first line of prose either way. */
|
|
33
33
|
function EssayHeader({ eyebrow, title, lede, byline, }) {
|
|
34
|
-
return (_jsxs("header", { className: "relative overflow-hidden pb-12 pt-16 sm:pt-24", children: [_jsx(Glow, { className: "-top-40 left-1/2 -translate-x-1/2", intensity: 0.16 }), _jsx(EssayColumns, { children: _jsxs(Reveal, { eager: true, className: "flex flex-col gap-6", children: [_jsx(TypographyEyebrow, { children: eyebrow }), _jsx(TypographyH1, { variant: "display", className: "text-balance", children: title }), lede && (_jsx(TypographyMuted, { className: "text-pretty text-xl leading-relaxed", children: lede })), byline &&
|
|
34
|
+
return (_jsxs("header", { className: "relative overflow-hidden pb-12 pt-16 sm:pt-24", children: [_jsx(Glow, { className: "-top-40 left-1/2 -translate-x-1/2", intensity: 0.16 }), _jsx(EssayColumns, { children: _jsxs(Reveal, { eager: true, className: "flex flex-col gap-6", children: [_jsx(TypographyEyebrow, { children: eyebrow }), _jsx(TypographyH1, { variant: "display", className: "text-balance", children: title }), lede && (_jsx(TypographyMuted, { className: "text-pretty text-xl leading-relaxed", children: lede })), byline && _jsx(PostMetaRow, { children: byline })] }) })] }));
|
|
35
35
|
}
|
|
36
36
|
/** The reading column, with the sticky index sitting in its left margin. */
|
|
37
37
|
function EssayLayout({ index, children, }) {
|
|
38
|
-
return (_jsx(EssayColumns, { className: "pb-16 sm:pb-24", aside: _jsx(
|
|
38
|
+
return (_jsx(EssayColumns, { className: "pb-16 sm:pb-24", aside: _jsx(EssayAside, { children: _jsx(TableOfContents, { sections: index }) }), children: _jsx(EssayBody, { className: "flex flex-col gap-16", children: children }) }));
|
|
39
39
|
}
|
|
40
40
|
/** The heading carries the anchor, offset so it lands under the sticky nav. */
|
|
41
41
|
function EssaySection({ id, heading, children, }) {
|
|
42
42
|
return (_jsx("section", { id: id, className: "scroll-mt-24", children: _jsxs(Reveal, { className: "flex flex-col gap-5", children: [_jsx(TypographyH2, { className: "text-balance", children: heading }), children] }) }));
|
|
43
43
|
}
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* One per essay: a page with three of them has decided nothing.
|
|
46
|
+
*
|
|
47
|
+
* The face and a rung, not the whole `headingClass()` ramp — see `headingFace` in
|
|
48
|
+
* typography/header.tsx for why a non-heading takes one and not the other. `text-pretty`
|
|
49
|
+
* over `text-balance`: this is prose set large, so it fills the measure rather than being
|
|
50
|
+
* set in even lines like a headline. The rung is the section heading's, so one pull quote
|
|
51
|
+
* stands level with them and retunes with them under `.editorial`.
|
|
52
|
+
*/
|
|
45
53
|
function EssayPullQuote({ children }) {
|
|
46
|
-
return (_jsx(Reveal, { children: _jsx("blockquote", { className: cn(
|
|
54
|
+
return (_jsx(Reveal, { children: _jsx("blockquote", { className: cn(headingFace, "text-h2 leading-snug text-foreground text-pretty", "border-l-2 border-primary/40 py-1 pl-6"), children: children }) }));
|
|
47
55
|
}
|
|
48
56
|
/** Generic on purpose: a page hands it anything; this decides only the fit. */
|
|
49
57
|
function EssayFigure({ children, caption, }) {
|
package/dist/essay/index.d.ts
CHANGED
|
@@ -4,4 +4,4 @@ export { TableOfContents } from "./contents.js";
|
|
|
4
4
|
export { createEssay, EssayHeader, EssayLayout, EssaySection, EssayPullQuote, EssayFigure, EssayMovements, EssayDocument, type EssayDecorations, type EssayIndexEntry, type EssayDocSection, type EssayMovement, } from "./essay.js";
|
|
5
5
|
export { useReadingProgress, useScrollSpy } from "./scroll.js";
|
|
6
6
|
export { ReadingProgressBar, ReadingRail } from "./reading.js";
|
|
7
|
-
export { EssayColumns, MetaDot, PostMetaRow, PostDate, formatPostDate, type PostDateFormat, ReadTime, TagPills, } from "./layout.js";
|
|
7
|
+
export { EssayColumns, EssayAside, EssayBody, ReadingLayout, MetaDot, PostMetaRow, PostDate, formatPostDate, type PostDateFormat, ReadTime, TagPills, } from "./layout.js";
|
package/dist/essay/index.js
CHANGED
|
@@ -6,4 +6,4 @@ export { createEssay,
|
|
|
6
6
|
EssayHeader, EssayLayout, EssaySection, EssayPullQuote, EssayFigure, EssayMovements, EssayDocument, } from "./essay.js";
|
|
7
7
|
export { useReadingProgress, useScrollSpy } from "./scroll.js";
|
|
8
8
|
export { ReadingProgressBar, ReadingRail } from "./reading.js";
|
|
9
|
-
export { EssayColumns, MetaDot, PostMetaRow, PostDate, formatPostDate, ReadTime, TagPills, } from "./layout.js";
|
|
9
|
+
export { EssayColumns, EssayAside, EssayBody, ReadingLayout, MetaDot, PostMetaRow, PostDate, formatPostDate, ReadTime, TagPills, } from "./layout.js";
|