@supertype.ai/foundations 0.1.24
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/LICENSE +21 -0
- package/README.md +369 -0
- package/bin/foundations.mjs +713 -0
- package/dist/blocks/accordion.d.ts +23 -0
- package/dist/blocks/accordion.js +59 -0
- package/dist/blocks/callout.d.ts +57 -0
- package/dist/blocks/callout.js +61 -0
- package/dist/blocks/card.d.ts +34 -0
- package/dist/blocks/card.js +56 -0
- package/dist/blocks/index.d.ts +7 -0
- package/dist/blocks/index.js +7 -0
- package/dist/blocks/interactive-accordion.d.ts +13 -0
- package/dist/blocks/interactive-accordion.js +27 -0
- package/dist/blocks/segment.d.ts +37 -0
- package/dist/blocks/segment.js +37 -0
- package/dist/blocks/steps.d.ts +10 -0
- package/dist/blocks/steps.js +13 -0
- package/dist/blocks/tabs.d.ts +32 -0
- package/dist/blocks/tabs.js +69 -0
- package/dist/cjs/eslint.js +146 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cn.d.ts +2 -0
- package/dist/cn.js +5 -0
- package/dist/contrast.d.ts +47 -0
- package/dist/contrast.js +255 -0
- package/dist/eslint.d.ts +74 -0
- package/dist/eslint.js +138 -0
- package/dist/essay/contents.d.ts +10 -0
- package/dist/essay/contents.js +17 -0
- package/dist/essay/essay.d.ts +125 -0
- package/dist/essay/essay.js +92 -0
- package/dist/essay/index.d.ts +7 -0
- package/dist/essay/index.js +9 -0
- package/dist/essay/layout.d.ts +72 -0
- package/dist/essay/layout.js +77 -0
- package/dist/essay/rail.d.ts +15 -0
- package/dist/essay/rail.js +26 -0
- package/dist/essay/reading.d.ts +17 -0
- package/dist/essay/reading.js +31 -0
- package/dist/essay/scroll.d.ts +8 -0
- package/dist/essay/scroll.js +78 -0
- package/dist/essay/toc.d.ts +23 -0
- package/dist/essay/toc.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +33 -0
- package/dist/injection.d.ts +8 -0
- package/dist/injection.js +1 -0
- package/dist/mdx.d.ts +47 -0
- package/dist/mdx.js +68 -0
- package/dist/og.d.ts +18 -0
- package/dist/og.js +50 -0
- package/dist/rehype.d.ts +18 -0
- package/dist/rehype.js +41 -0
- package/dist/seo.d.ts +174 -0
- package/dist/seo.js +152 -0
- package/dist/typography/as.d.ts +15 -0
- package/dist/typography/as.js +8 -0
- package/dist/typography/header.d.ts +44 -0
- package/dist/typography/header.js +119 -0
- package/dist/typography/highlight.d.ts +33 -0
- package/dist/typography/highlight.js +98 -0
- package/dist/typography/index.d.ts +4 -0
- package/dist/typography/index.js +3 -0
- package/dist/typography/paragraph.d.ts +157 -0
- package/dist/typography/paragraph.js +229 -0
- package/llms.txt +125 -0
- package/package.json +140 -0
- package/src/prose.css +12 -0
- package/src/shiki.css +23 -0
- package/src/theme.css +272 -0
- package/src/tokens.css +43 -0
- package/src/type.css +73 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The design rules, as ESLint selectors, shared so the two apps cannot drift.
|
|
4
|
+
* Plain data — esquery matches these against className strings, so no plugin.
|
|
5
|
+
* They see values, never the shape of a class list; a primitive fixes that.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.colourRules = colourRules;
|
|
9
|
+
exports.themeOverrideRules = themeOverrideRules;
|
|
10
|
+
exports.surfaceAsInkRules = surfaceAsInkRules;
|
|
11
|
+
exports.renamedTokenRules = renamedTokenRules;
|
|
12
|
+
exports.typographyRules = typographyRules;
|
|
13
|
+
exports.designConfig = designConfig;
|
|
14
|
+
/** A className written as a plain string, or as a chunk of a template literal. */
|
|
15
|
+
const classString = (pattern) => [
|
|
16
|
+
`Literal[value=${pattern}]`,
|
|
17
|
+
`TemplateElement[value.raw=${pattern}]`,
|
|
18
|
+
];
|
|
19
|
+
const rule = (pattern, message) => classString(pattern).map((selector) => ({ selector, message }));
|
|
20
|
+
const VARIANTS = "(dark:|hover:|focus:|group-hover:|active:|disabled:|sm:|md:|lg:|xl:)*";
|
|
21
|
+
const PALETTE = "(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)";
|
|
22
|
+
/** Applies everywhere, marketing included — the tints exist for those pages. */
|
|
23
|
+
function colourRules({ accents = "a brand accent", } = {}) {
|
|
24
|
+
return [
|
|
25
|
+
...rule(`/(^| )${VARIANTS}(text|bg|border|ring|from|to|via|fill|stroke|divide|outline|placeholder|shadow|decoration|accent|caret)-${PALETTE}-\\d+/`, `Raw Tailwind palette. Use a token — text-foreground / text-muted-foreground / text-subtle-foreground, bg-background / bg-card / bg-muted, border-border — a status token (success, warn, destructive), or ${accents}.`),
|
|
26
|
+
// Split by prefix: alpha on a fill is a scrim (the effect), on ink or a
|
|
27
|
+
// hairline it is just an undeclared colour.
|
|
28
|
+
...rule(`/(^| )${VARIANTS}(bg|from|to|via|shadow)-(white|black)($| )/`, "Solid white/black is a hand-rolled surface. Use bg-card / bg-background, or bg-tint for a tinted panel. Alpha scrims (bg-black/50) stay legal — there the point is the transparency, not the hue."),
|
|
29
|
+
...rule(`/(^| )${VARIANTS}(text|border|ring|divide|fill|stroke|decoration|outline|placeholder)-(white|black)($| |\\x2f)/`, "White/black ink and hairlines are hand-rolled colour, alpha or not. Use text-foreground / text-background for ink, text-tint-foreground for ink on a tinted surface, and border-border for a hairline — the token already carries the alpha the theme wants."),
|
|
30
|
+
...rule("/(^| )(text|bg|border|fill|stroke|ring)-\\[#/", "Hex colours bypass the token system entirely. Add a token if the colour is real; use an existing one if it is not."),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The semantic tokens, for the rules that care which token it is rather than
|
|
35
|
+
* whether one was used at all.
|
|
36
|
+
*/
|
|
37
|
+
const TOKEN = "foreground|muted-foreground|subtle-foreground|muted|card-foreground|card|popover-foreground|popover|" +
|
|
38
|
+
"secondary-foreground|secondary|accent-foreground|accent|primary-foreground|primary|border|input|ring|" +
|
|
39
|
+
"background|destructive-foreground|destructive|success|warn|info|brand|tint-foreground";
|
|
40
|
+
const COLOUR_PREFIX = "text|bg|border|ring|from|to|via|fill|stroke|divide|placeholder|decoration|shadow|outline";
|
|
41
|
+
/**
|
|
42
|
+
* A token already knows what it does in the dark; `dark:` on one says the token
|
|
43
|
+
* is wrong. Only the solid form is restricted — `dark:bg-destructive/20` against
|
|
44
|
+
* a `/10` is the same token at the density a darker ground needs.
|
|
45
|
+
*/
|
|
46
|
+
function themeOverrideRules() {
|
|
47
|
+
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
|
+
}
|
|
49
|
+
/**
|
|
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
|
+
*/
|
|
53
|
+
function surfaceAsInkRules() {
|
|
54
|
+
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
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `-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.
|
|
59
|
+
*/
|
|
60
|
+
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.");
|
|
62
|
+
}
|
|
63
|
+
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
|
+
return [
|
|
65
|
+
// Alpha ink composites against whatever surface it lands on, so its
|
|
66
|
+
// contrast is unmeasurable. The ink tokens are measured.
|
|
67
|
+
// `\x2f`, never a literal slash: ESLint 8's esquery ends the regex at the
|
|
68
|
+
// first `/` it sees, however escaped, and hands RegExp the truncated half.
|
|
69
|
+
...rule("/text-(foreground|muted-foreground|subtle-foreground)\\x2f\\d+/", "Alpha on a text token has unmeasurable contrast (it composites against whatever surface it lands on). Use text-foreground (primary), text-muted-foreground (secondary), or text-subtle-foreground (tertiary)."),
|
|
70
|
+
// px only: a rem value at display scale is an ornament, not a rung.
|
|
71
|
+
...rule("/(^| )text-\\[\\d+px\\]/", `Arbitrary font sizes bypass the type ramp. Use a rung (${ramp}).`),
|
|
72
|
+
// A primitive that owns a size axis, reached past for a class that does the
|
|
73
|
+
// same thing. The class wins on the page, so nothing looks wrong — what is
|
|
74
|
+
// lost is everything else the axis carries: `TypographyCaption` pins leading
|
|
75
|
+
// per rung because a wrapped caption sets cramped at the ramp's own setting,
|
|
76
|
+
// and `TypographyStat` pairs its rungs with the heading ladder so a figure
|
|
77
|
+
// and the heading beside it retune together on an editorial surface. A
|
|
78
|
+
// literal gets the size and silently drops the rest.
|
|
79
|
+
//
|
|
80
|
+
// Matching the class node INSIDE the attribute, rather than the className
|
|
81
|
+
// string on its own, lets this name the component. It reaches into `cn()`
|
|
82
|
+
// for free, since the argument sits in the same subtree.
|
|
83
|
+
...(axis
|
|
84
|
+
? [
|
|
85
|
+
// Both node kinds, for the same reason `classString` above covers both: a
|
|
86
|
+
// class list assembled in a template literal is the shape a call site
|
|
87
|
+
// reaches for precisely when it is doing something conditional, which
|
|
88
|
+
// is where a stray rung is most likely to be hiding.
|
|
89
|
+
...["Literal[value", "TemplateElement[value.raw"].map((node) => ({
|
|
90
|
+
selector: `JSXOpeningElement[name.name=/^Typography(Small|Caption|Stat|Eyebrow)$/] JSXAttribute[name.name="className"] ${node}=/(^| )text-(3xs|2xs|xs|sm|base|lg|xl|[2-9]xl|h[1-4])( |$)/]`,
|
|
91
|
+
message: "This primitive owns its size: pass the axis (TypographySmall/Caption size=, TypographyStat size=, TypographyEyebrow tone=) rather than a text-* class, which takes the size and drops the leading and ladder that come with the rung.",
|
|
92
|
+
})),
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
// Two valid primitives forming an invalid pair, which the value rules above
|
|
96
|
+
// cannot see: `<TypographyP>` is the 14px interface rung, and the list under
|
|
97
|
+
// it reads at the prose rung, so one passage lands two rungs apart.
|
|
98
|
+
//
|
|
99
|
+
// `~` and never `+`: JSX puts a whitespace text node between sibling
|
|
100
|
+
// elements, and an adjacent-sibling selector will not cross it — measured,
|
|
101
|
+
// `+` matches nothing at all here. The cost of `~` is that it means "any
|
|
102
|
+
// later sibling", so it can reach past an intervening paragraph; on a corpus
|
|
103
|
+
// of 168 files it fired four times and was right four times.
|
|
104
|
+
...(pairing
|
|
105
|
+
? [
|
|
106
|
+
{
|
|
107
|
+
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\".",
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
: []),
|
|
112
|
+
...(weights
|
|
113
|
+
? rule("/(^| )font-(bold|extrabold|black)( |$)/", "The product weight ramp is 400 body / 500 label / 600 heading. Use font-semibold for headings, or a label primitive for a label.")
|
|
114
|
+
: []),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Every rule in one flat-config entry, ready to spread into eslint.config.js:
|
|
119
|
+
*
|
|
120
|
+
* import { designConfig } from "@supertype.ai/foundations/eslint";
|
|
121
|
+
* export default [ ...designConfig({ accents: "the brand tints" }) ];
|
|
122
|
+
*
|
|
123
|
+
* One entry is not a detail. Flat config replaces a rule's options rather than
|
|
124
|
+
* merging them, so two blocks covering overlapping files leave only the last
|
|
125
|
+
* one's rules in effect. Combining them here is what stops a consumer losing
|
|
126
|
+
* half the set by accident. If you need a second scope, call this again with a
|
|
127
|
+
* different `files` and no overlap.
|
|
128
|
+
*/
|
|
129
|
+
function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], accents, weights, ramp, pairing, axis, } = {}) {
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
name: "@supertype.ai/foundations/design",
|
|
133
|
+
files,
|
|
134
|
+
rules: {
|
|
135
|
+
"no-restricted-syntax": [
|
|
136
|
+
"error",
|
|
137
|
+
...colourRules({ accents }),
|
|
138
|
+
...typographyRules({ weights, ramp, pairing, axis }),
|
|
139
|
+
...themeOverrideRules(),
|
|
140
|
+
...surfaceAsInkRules(),
|
|
141
|
+
...renamedTokenRules(),
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
}
|
package/dist/cn.d.ts
ADDED
package/dist/cn.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legibility measured against the cascade, not the declarations: reading `:root`
|
|
3
|
+
* and `.dark` separately measures an intention, and ssite shipped a `.dark` at a
|
|
4
|
+
* healthy 15.7:1 while the page rendered white on white. Build-time only.
|
|
5
|
+
*/
|
|
6
|
+
export type Rgb = [number, number, number];
|
|
7
|
+
/** The themes a stylesheet is measured in. `dark` is the `.dark` class. */
|
|
8
|
+
export type Theme = "light" | "dark";
|
|
9
|
+
/**
|
|
10
|
+
* Specificity over what token blocks use. `:root` and `.dark` both score 1 —
|
|
11
|
+
* the tie above. `:not(…)` contributes its argument's score, per spec.
|
|
12
|
+
*/
|
|
13
|
+
export declare function specificity(selector: string): number;
|
|
14
|
+
/** Properties surviving the cascade on :root. `css` is concatenated in import order. */
|
|
15
|
+
export declare function resolveTokens(css: string, theme: Theme): Record<string, string>;
|
|
16
|
+
/** Parses `hsl(...)`, `#rgb`, `#rrggbb` and `rgb(...)`. Returns null otherwise. */
|
|
17
|
+
export declare function parseColor(value: string): Rgb | null;
|
|
18
|
+
/** WCAG relative luminance. */
|
|
19
|
+
export declare function luminance([r, g, b]: Rgb): number;
|
|
20
|
+
/** WCAG contrast ratio, 1:1 to 21:1. */
|
|
21
|
+
export declare function contrast(a: Rgb, b: Rgb): number;
|
|
22
|
+
export interface LegibilityFailure {
|
|
23
|
+
theme: Theme;
|
|
24
|
+
ink: string;
|
|
25
|
+
surface: string;
|
|
26
|
+
ratio: number;
|
|
27
|
+
required: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Every ink on every surface, both themes. Missing or non-literal tokens are
|
|
31
|
+
* skipped — a default or an aliased variable is not a contrast failure.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkLegibility(css: string, { minimum, inks, surfaces, themes, }?: {
|
|
34
|
+
minimum?: number | undefined;
|
|
35
|
+
inks?: string[] | undefined;
|
|
36
|
+
surfaces?: string[] | undefined;
|
|
37
|
+
themes?: Theme[] | undefined;
|
|
38
|
+
}): LegibilityFailure[];
|
|
39
|
+
/**
|
|
40
|
+
* The three bars a palette owes, run over the same engine as `checkLegibility`.
|
|
41
|
+
* Without this the numbers in a theme's comments are claims, not measurements.
|
|
42
|
+
*/
|
|
43
|
+
export declare function checkSignals(css: string, { themes }?: {
|
|
44
|
+
themes?: Theme[] | undefined;
|
|
45
|
+
}): LegibilityFailure[];
|
|
46
|
+
/** A one-line report per failure, for a test's assertion message. */
|
|
47
|
+
export declare function formatFailures(failures: LegibilityFailure[]): string;
|
package/dist/contrast.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legibility measured against the cascade, not the declarations: reading `:root`
|
|
3
|
+
* and `.dark` separately measures an intention, and ssite shipped a `.dark` at a
|
|
4
|
+
* healthy 15.7:1 while the page rendered white on white. Build-time only.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Specificity over what token blocks use. `:root` and `.dark` both score 1 —
|
|
8
|
+
* the tie above. `:not(…)` contributes its argument's score, per spec.
|
|
9
|
+
*/
|
|
10
|
+
export function specificity(selector) {
|
|
11
|
+
const ids = selector.match(/#[\w-]+/g)?.length ?? 0;
|
|
12
|
+
const notArgs = [...selector.matchAll(/:not\(([^)]*)\)/g)].map((m) => m[1]);
|
|
13
|
+
const bare = selector.replace(/:not\([^)]*\)/g, "");
|
|
14
|
+
const classes = bare.match(/\.[\w-]+/g)?.length ?? 0;
|
|
15
|
+
const pseudos = bare.match(/(?<!:):[\w-]+/g)?.length ?? 0;
|
|
16
|
+
const inner = notArgs.reduce((sum, arg) => sum + specificity(arg), 0);
|
|
17
|
+
return ids * 100 + classes + pseudos + inner;
|
|
18
|
+
}
|
|
19
|
+
/** Whether a selector applies to the document root in the given theme. */
|
|
20
|
+
function matches(selector, theme) {
|
|
21
|
+
const trimmed = selector.trim();
|
|
22
|
+
// Only rules targeting the root element itself carry the token palette; a
|
|
23
|
+
// descendant selector (`.dark .notes-route`) styles something else.
|
|
24
|
+
if (/[\s>+~]/.test(trimmed))
|
|
25
|
+
return false;
|
|
26
|
+
if (!/^(:root|html|\.dark)/.test(trimmed))
|
|
27
|
+
return false;
|
|
28
|
+
const hasDark = /(^|[^(])\.dark\b/.test(trimmed.replace(/:not\([^)]*\)/g, ""));
|
|
29
|
+
const excludesDark = [...trimmed.matchAll(/:not\(([^)]*)\)/g)].some((m) => m[1].includes(".dark"));
|
|
30
|
+
if (theme === "dark")
|
|
31
|
+
return !excludesDark;
|
|
32
|
+
return !hasDark;
|
|
33
|
+
}
|
|
34
|
+
/** Properties surviving the cascade on :root. `css` is concatenated in import order. */
|
|
35
|
+
export function resolveTokens(css, theme) {
|
|
36
|
+
const declarations = [];
|
|
37
|
+
let order = 0;
|
|
38
|
+
// Comments are stripped first. A selector is read back to the previous `}`,
|
|
39
|
+
// so a comment sitting above one becomes part of it and the rule is silently
|
|
40
|
+
// skipped — which is how a stylesheet that documents its overrides gets less
|
|
41
|
+
// checking than one that does not.
|
|
42
|
+
// A statement at-rule (`@import`, `@source`, `@custom-variant`, `@apply`) ends
|
|
43
|
+
// in a semicolon, and a selector is read back to the previous `}` — so leaving
|
|
44
|
+
// one in makes the block that follows it look like an at-rule and be skipped.
|
|
45
|
+
const source0 = css
|
|
46
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
47
|
+
.replace(/(^|[;{}])\s*@[\w-]+[^;{}]*;/g, "$1");
|
|
48
|
+
// Walk the text, tracking whether the current block sits inside `@layer`.
|
|
49
|
+
const walk = (source, layered) => {
|
|
50
|
+
for (let i = 0; i < source.length; i++) {
|
|
51
|
+
if (source[i] !== "{")
|
|
52
|
+
continue;
|
|
53
|
+
const head = source.slice(0, i);
|
|
54
|
+
const selector = head.slice(head.lastIndexOf("}") + 1).trim();
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let end = i;
|
|
57
|
+
for (let j = i; j < source.length; j++) {
|
|
58
|
+
if (source[j] === "{")
|
|
59
|
+
depth++;
|
|
60
|
+
else if (source[j] === "}" && --depth === 0) {
|
|
61
|
+
end = j;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const body = source.slice(i + 1, end);
|
|
66
|
+
if (selector.startsWith("@layer") || selector.startsWith("@media")) {
|
|
67
|
+
walk(body, layered || selector.startsWith("@layer"));
|
|
68
|
+
}
|
|
69
|
+
else if (selector.startsWith("@")) {
|
|
70
|
+
// @theme, @custom-variant and friends declare no root palette.
|
|
71
|
+
}
|
|
72
|
+
else if (matches(selector, theme)) {
|
|
73
|
+
for (const [, property, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
|
|
74
|
+
declarations.push({
|
|
75
|
+
property,
|
|
76
|
+
value: value.trim(),
|
|
77
|
+
layered,
|
|
78
|
+
specificity: specificity(selector),
|
|
79
|
+
order: order++,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Skip past the block just consumed.
|
|
84
|
+
i = end;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
walk(source0, false);
|
|
88
|
+
const winners = {};
|
|
89
|
+
for (const decl of declarations) {
|
|
90
|
+
const held = winners[decl.property];
|
|
91
|
+
if (!held || outranks(decl, held))
|
|
92
|
+
winners[decl.property] = decl;
|
|
93
|
+
}
|
|
94
|
+
const resolved = Object.fromEntries(Object.entries(winners).map(([property, decl]) => [property, decl.value]));
|
|
95
|
+
// `--input: var(--border)` is an alias, not an unreadable value. Follow the
|
|
96
|
+
// whole-value form so a check measures what the browser paints; a fallback or
|
|
97
|
+
// an embedded var() stays unresolved, which the callers already skip.
|
|
98
|
+
const ALIAS = /^var\(\s*(--[\w-]+)\s*\)$/;
|
|
99
|
+
for (const property of Object.keys(resolved)) {
|
|
100
|
+
const seen = new Set([property]);
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = ALIAS.exec(resolved[property]))) {
|
|
103
|
+
const target = match[1];
|
|
104
|
+
if (seen.has(target) || resolved[target] === undefined)
|
|
105
|
+
break;
|
|
106
|
+
seen.add(target);
|
|
107
|
+
resolved[property] = resolved[target];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function outranks(next, held) {
|
|
113
|
+
if (next.layered !== held.layered)
|
|
114
|
+
return !next.layered;
|
|
115
|
+
if (next.specificity !== held.specificity)
|
|
116
|
+
return next.specificity > held.specificity;
|
|
117
|
+
return next.order > held.order;
|
|
118
|
+
}
|
|
119
|
+
/** Parses `hsl(...)`, `#rgb`, `#rrggbb` and `rgb(...)`. Returns null otherwise. */
|
|
120
|
+
export function parseColor(value) {
|
|
121
|
+
const input = value.trim();
|
|
122
|
+
const hsl = input.match(/hsla?\(\s*([\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%/i);
|
|
123
|
+
if (hsl) {
|
|
124
|
+
const [h, s, l] = [+hsl[1], +hsl[2] / 100, +hsl[3] / 100];
|
|
125
|
+
const a = s * Math.min(l, 1 - l);
|
|
126
|
+
const f = (n) => {
|
|
127
|
+
const k = (n + h / 30) % 12;
|
|
128
|
+
return (l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))) * 255;
|
|
129
|
+
};
|
|
130
|
+
return [f(0), f(8), f(4)];
|
|
131
|
+
}
|
|
132
|
+
const hex = input.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
133
|
+
if (hex) {
|
|
134
|
+
const digits = hex[1].length === 3
|
|
135
|
+
? [...hex[1]].map((d) => d + d).join("")
|
|
136
|
+
: hex[1];
|
|
137
|
+
return [
|
|
138
|
+
parseInt(digits.slice(0, 2), 16),
|
|
139
|
+
parseInt(digits.slice(2, 4), 16),
|
|
140
|
+
parseInt(digits.slice(4, 6), 16),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
const rgb = input.match(/rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
|
|
144
|
+
if (rgb)
|
|
145
|
+
return [+rgb[1], +rgb[2], +rgb[3]];
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** WCAG relative luminance. */
|
|
149
|
+
export function luminance([r, g, b]) {
|
|
150
|
+
const [rl, gl, bl] = [r, g, b].map((channel) => {
|
|
151
|
+
const s = channel / 255;
|
|
152
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
153
|
+
});
|
|
154
|
+
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl;
|
|
155
|
+
}
|
|
156
|
+
/** WCAG contrast ratio, 1:1 to 21:1. */
|
|
157
|
+
export function contrast(a, b) {
|
|
158
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
159
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
160
|
+
}
|
|
161
|
+
const INKS = ["--foreground", "--muted-foreground", "--card-foreground"];
|
|
162
|
+
const SURFACES = ["--background", "--card", "--muted"];
|
|
163
|
+
/**
|
|
164
|
+
* Every ink on every surface, both themes. Missing or non-literal tokens are
|
|
165
|
+
* skipped — a default or an aliased variable is not a contrast failure.
|
|
166
|
+
*/
|
|
167
|
+
export function checkLegibility(css, { minimum = 4.5, inks = INKS, surfaces = SURFACES, themes = ["light", "dark"], } = {}) {
|
|
168
|
+
const failures = [];
|
|
169
|
+
for (const theme of themes) {
|
|
170
|
+
const tokens = resolveTokens(css, theme);
|
|
171
|
+
for (const ink of inks) {
|
|
172
|
+
const inkColor = parseColor(tokens[ink] ?? "");
|
|
173
|
+
if (!inkColor)
|
|
174
|
+
continue;
|
|
175
|
+
for (const surface of surfaces) {
|
|
176
|
+
const surfaceColor = parseColor(tokens[surface] ?? "");
|
|
177
|
+
if (!surfaceColor)
|
|
178
|
+
continue;
|
|
179
|
+
const ratio = contrast(inkColor, surfaceColor);
|
|
180
|
+
if (ratio < minimum)
|
|
181
|
+
failures.push({ theme, ink, surface, ratio, required: minimum });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return failures;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Marks rather than words: status and categorical fills, which carry meaning as
|
|
189
|
+
* an 8px dot or a 2px bar. WCAG 1.4.11 asks 3:1 of them, not 4.5:1.
|
|
190
|
+
*/
|
|
191
|
+
const FILLS = [
|
|
192
|
+
"--success",
|
|
193
|
+
"--warn",
|
|
194
|
+
"--info",
|
|
195
|
+
"--terracotta",
|
|
196
|
+
"--ochre",
|
|
197
|
+
"--moss",
|
|
198
|
+
"--fern",
|
|
199
|
+
"--sage",
|
|
200
|
+
"--stone",
|
|
201
|
+
"--fig",
|
|
202
|
+
"--cocoa",
|
|
203
|
+
];
|
|
204
|
+
/**
|
|
205
|
+
* A fill has to separate from the page and from a card. Not from `--muted`: a
|
|
206
|
+
* wash of the hue is where its ink goes, and the fills sit at 2.7:1 there by
|
|
207
|
+
* design.
|
|
208
|
+
*/
|
|
209
|
+
const FILL_SURFACES = ["--background", "--card"];
|
|
210
|
+
/** The same hues as words, at the bar body copy is held to. */
|
|
211
|
+
const INKS_TINTED = [
|
|
212
|
+
"--success-ink",
|
|
213
|
+
"--warn-ink",
|
|
214
|
+
"--info-ink",
|
|
215
|
+
"--danger",
|
|
216
|
+
"--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",
|
|
225
|
+
];
|
|
226
|
+
/**
|
|
227
|
+
* shadcn's shape: `-foreground` is the label printed on the fill, so the pair is
|
|
228
|
+
* measured against itself rather than against the page.
|
|
229
|
+
*/
|
|
230
|
+
const ON_FILL = [
|
|
231
|
+
["--primary", "--primary-foreground"],
|
|
232
|
+
["--secondary", "--secondary-foreground"],
|
|
233
|
+
["--destructive", "--destructive-foreground"],
|
|
234
|
+
["--accent", "--accent-foreground"],
|
|
235
|
+
["--card", "--card-foreground"],
|
|
236
|
+
["--popover", "--popover-foreground"],
|
|
237
|
+
["--sidebar", "--sidebar-foreground"],
|
|
238
|
+
];
|
|
239
|
+
/**
|
|
240
|
+
* The three bars a palette owes, run over the same engine as `checkLegibility`.
|
|
241
|
+
* Without this the numbers in a theme's comments are claims, not measurements.
|
|
242
|
+
*/
|
|
243
|
+
export function checkSignals(css, { themes = ["light", "dark"] } = {}) {
|
|
244
|
+
return [
|
|
245
|
+
...checkLegibility(css, { inks: FILLS, surfaces: FILL_SURFACES, minimum: 3, themes }),
|
|
246
|
+
...checkLegibility(css, { inks: INKS_TINTED, themes }),
|
|
247
|
+
...ON_FILL.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
/** A one-line report per failure, for a test's assertion message. */
|
|
251
|
+
export function formatFailures(failures) {
|
|
252
|
+
return failures
|
|
253
|
+
.map((f) => `${f.theme}: ${f.ink} on ${f.surface} is ${f.ratio.toFixed(2)}:1, below ${f.required}:1`)
|
|
254
|
+
.join("\n");
|
|
255
|
+
}
|
package/dist/eslint.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design rules, as ESLint selectors, shared so the two apps cannot drift.
|
|
3
|
+
* Plain data — esquery matches these against className strings, so no plugin.
|
|
4
|
+
* They see values, never the shape of a class list; a primitive fixes that.
|
|
5
|
+
*/
|
|
6
|
+
export interface RestrictedSyntax {
|
|
7
|
+
selector: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ColourOptions {
|
|
11
|
+
/** Named in the message: "use a token" without naming one sends people hunting. */
|
|
12
|
+
accents?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Applies everywhere, marketing included — the tints exist for those pages. */
|
|
15
|
+
export declare function colourRules({ accents, }?: ColourOptions): RestrictedSyntax[];
|
|
16
|
+
/**
|
|
17
|
+
* A token already knows what it does in the dark; `dark:` on one says the token
|
|
18
|
+
* is wrong. Only the solid form is restricted — `dark:bg-destructive/20` against
|
|
19
|
+
* a `/10` is the same token at the density a darker ground needs.
|
|
20
|
+
*/
|
|
21
|
+
export declare function themeOverrideRules(): RestrictedSyntax[];
|
|
22
|
+
/**
|
|
23
|
+
* `--muted` is a fill at L92%, so `text-muted` is ~1.1:1 — invisible, and it
|
|
24
|
+
* shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
|
|
25
|
+
*/
|
|
26
|
+
export declare function surfaceAsInkRules(): RestrictedSyntax[];
|
|
27
|
+
/**
|
|
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
|
+
*/
|
|
31
|
+
export declare function renamedTokenRules(): RestrictedSyntax[];
|
|
32
|
+
export interface TypographyOptions {
|
|
33
|
+
/** Three-weight ramp. Off for editorial, where 700 is a register not a shout. */
|
|
34
|
+
weights?: boolean;
|
|
35
|
+
/** The rungs, named in the message, since they differ per consumer. */
|
|
36
|
+
ramp?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Flag a `ui` paragraph standing over a prose list. Off by default: it is the
|
|
39
|
+
* one rule here that reads shape rather than a value, and a consumer has to
|
|
40
|
+
* have migrated its dense-card lists to `TypographyList variant="ui"` before
|
|
41
|
+
* it can pass. Turn it on once that is done.
|
|
42
|
+
*/
|
|
43
|
+
pairing?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Flag a size class on a primitive that already owns a size axis. Off by
|
|
46
|
+
* default for the same reason as `pairing`: it fails until the consumer has
|
|
47
|
+
* migrated, and the migration is the point.
|
|
48
|
+
*/
|
|
49
|
+
axis?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function typographyRules({ weights, ramp, pairing, axis, }?: TypographyOptions): RestrictedSyntax[];
|
|
52
|
+
/** A flat-config entry, described structurally so the package needs no ESLint dependency. */
|
|
53
|
+
export interface FlatConfigEntry {
|
|
54
|
+
name: string;
|
|
55
|
+
files: string[];
|
|
56
|
+
rules: Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
export interface DesignConfigOptions extends ColourOptions, TypographyOptions {
|
|
59
|
+
/** What the rules apply to. Narrow it to exclude generated or vendored code. */
|
|
60
|
+
files?: string[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every rule in one flat-config entry, ready to spread into eslint.config.js:
|
|
64
|
+
*
|
|
65
|
+
* import { designConfig } from "@supertype.ai/foundations/eslint";
|
|
66
|
+
* export default [ ...designConfig({ accents: "the brand tints" }) ];
|
|
67
|
+
*
|
|
68
|
+
* One entry is not a detail. Flat config replaces a rule's options rather than
|
|
69
|
+
* merging them, so two blocks covering overlapping files leave only the last
|
|
70
|
+
* one's rules in effect. Combining them here is what stops a consumer losing
|
|
71
|
+
* half the set by accident. If you need a second scope, call this again with a
|
|
72
|
+
* different `files` and no overlap.
|
|
73
|
+
*/
|
|
74
|
+
export declare function designConfig({ files, accents, weights, ramp, pairing, axis, }?: DesignConfigOptions): FlatConfigEntry[];
|