@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.
Files changed (54) hide show
  1. package/README.md +27 -23
  2. package/bin/foundations.mjs +5 -2
  3. package/dist/blocks/accordion.js +2 -1
  4. package/dist/blocks/badge.d.ts +27 -0
  5. package/dist/blocks/badge.js +86 -0
  6. package/dist/blocks/button.d.ts +32 -0
  7. package/dist/blocks/button.js +127 -0
  8. package/dist/blocks/callout.d.ts +2 -39
  9. package/dist/blocks/callout.js +13 -38
  10. package/dist/blocks/card.d.ts +5 -6
  11. package/dist/blocks/card.js +28 -12
  12. package/dist/blocks/focus.d.ts +9 -0
  13. package/dist/blocks/focus.js +9 -0
  14. package/dist/blocks/index.d.ts +3 -1
  15. package/dist/blocks/index.js +3 -1
  16. package/dist/blocks/interactive-accordion.js +2 -1
  17. package/dist/blocks/render-as.d.ts +11 -0
  18. package/dist/blocks/render-as.js +22 -0
  19. package/dist/blocks/segment.d.ts +22 -14
  20. package/dist/blocks/segment.js +23 -14
  21. package/dist/blocks/tabs.d.ts +52 -17
  22. package/dist/blocks/tabs.js +86 -37
  23. package/dist/cjs/eslint.js +49 -14
  24. package/dist/contrast.d.ts +56 -0
  25. package/dist/contrast.js +144 -12
  26. package/dist/eslint.d.ts +42 -8
  27. package/dist/eslint.js +47 -14
  28. package/dist/essay/essay.js +14 -6
  29. package/dist/essay/index.d.ts +1 -1
  30. package/dist/essay/index.js +1 -1
  31. package/dist/essay/layout.d.ts +37 -1
  32. package/dist/essay/layout.js +33 -1
  33. package/dist/essay/rail.d.ts +11 -1
  34. package/dist/essay/reading.d.ts +1 -1
  35. package/dist/essay/scroll.js +1 -1
  36. package/dist/href.d.ts +42 -0
  37. package/dist/href.js +63 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +9 -0
  40. package/dist/mdx.d.ts +26 -4
  41. package/dist/mdx.js +30 -4
  42. package/dist/tone.d.ts +130 -0
  43. package/dist/tone.js +140 -0
  44. package/dist/typography/header.d.ts +32 -2
  45. package/dist/typography/header.js +10 -5
  46. package/dist/typography/highlight.d.ts +7 -6
  47. package/dist/typography/highlight.js +15 -9
  48. package/dist/typography/paragraph.d.ts +12 -25
  49. package/dist/typography/paragraph.js +40 -29
  50. package/llms.txt +99 -22
  51. package/package.json +2 -1
  52. package/src/theme.css +143 -46
  53. package/src/tokens.css +5 -4
  54. package/src/type.css +1 -1
@@ -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` is ~1.1:1 — invisible, and it
51
- * shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
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. `warn-foreground` was always the ink, under the other name.
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("/(^| )(dark:|hover:|focus:|group-hover:)*(text|bg|border|ring|fill|stroke)-warn-foreground($| )/", "`warn-foreground` is the deprecated name for `warn-ink`. In this package `-foreground` is the label printed on a fill and `-ink` is the hue used as text; warn has no printed-on label, because orange carries white text at no lightness. Use warn-ink.");
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: "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\".",
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}"], accents, weights, ramp, pairing, axis, } = {}) {
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
  ];
@@ -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-foreground",
218
- "--ochre-foreground",
219
- "--moss-foreground",
220
- "--fern-foreground",
221
- "--sage-foreground",
222
- "--stone-foreground",
223
- "--fig-foreground",
224
- "--cocoa-foreground",
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, { inks: FILLS, surfaces: FILL_SURFACES, minimum: 3, themes }),
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` 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[];
28
+ export declare function renamedTokenRules(): RestrictedSyntax[];
27
29
  /**
28
- * `-foreground` means the label printed on a fill; `-ink` means the hue as
29
- * words. `warn-foreground` was always the ink, under the other name.
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 renamedTokenRules(): RestrictedSyntax[];
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 the migration is the point.
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 ColourOptions, TypographyOptions {
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, accents, weights, ramp, pairing, axis, }?: DesignConfigOptions): FlatConfigEntry[];
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` 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` was always the ink, under the other name.
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("/(^| )(dark:|hover:|focus:|group-hover:)*(text|bg|border|ring|fill|stroke)-warn-foreground($| )/", "`warn-foreground` is the deprecated name for `warn-ink`. In this package `-foreground` is the label printed on a fill and `-ink` is the hue used as text; warn has no printed-on label, because orange carries white text at no lightness. Use warn-ink.");
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: "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".',
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}"], accents, weights, ramp, pairing, axis, } = {}) {
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
  ];
@@ -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 { headingClass, TypographyEyebrow, TypographyH1, TypographyH2, TypographyH3, } from "../typography/header.js";
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 && (_jsx(TypographyMuted, { className: "border-t border-border/60 pt-5 text-base", children: 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("div", { className: "sticky top-24", children: _jsx(TableOfContents, { sections: index }) }), children: _jsx("div", { className: "flex flex-col gap-16 border-t border-border pt-12 @6xl:border-t-0 @6xl:pt-0", children: children }) }));
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
- /** One per essay: a page with three of them has decided nothing. */
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(headingClass(), "text-balance border-l-2 border-primary/40 py-1 pl-6 leading-snug"), children: children }) }));
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, }) {
@@ -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";
@@ -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";