remarque-tokens 0.2.0

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.
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * remarque audit — enforces REMARQUE.md's checklist mechanically.
4
+ *
5
+ * node scripts/audit.mjs [--palette tokens-palette.css] [--src <dir>]
6
+ * npx remarque-audit --palette your-palette.css --src src
7
+ *
8
+ * Checks (exit 1 on any failure):
9
+ * 1. CONTRAST — extracts custom properties from the palette file with a
10
+ * brace-aware scanner (light = top-level :root; dark = :root inside
11
+ * @media (prefers-color-scheme: dark) and/or [data-theme="dark"],
12
+ * cascaded over the light values), resolves var() aliases, and
13
+ * computes WCAG 2.x ratios via OKLCH → linear sRGB (Ottosson
14
+ * matrices, gamut-clipped) → relative luminance. Thresholds come
15
+ * from the spec's Enforcement Checklist. Unresolvable tokens in a
16
+ * required pairing are failures, not skips.
17
+ * Also fails on out-of-sRGB-gamut colors (browsers clip them, so
18
+ * the authored value is not what renders).
19
+ * 2. FONT FLOOR — no font-size declaration below 0.8125rem / 13px in src.
20
+ * 3. NO HARDCODED COLORS — no hex/rgb()/hsl() literals in src styles;
21
+ * oklch() literals are allowed only in token files.
22
+ */
23
+
24
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
25
+ import { join, relative } from 'node:path';
26
+
27
+ const args = process.argv.slice(2);
28
+ function argOf(flag, dflt) {
29
+ const i = args.indexOf(flag);
30
+ return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
31
+ }
32
+ const PALETTE = argOf('--palette', 'tokens-palette.css');
33
+ const SRC = argOf('--src', existsSync('site/src') ? 'site/src' : '.');
34
+
35
+ let failures = 0;
36
+ function fail(msg) {
37
+ failures++;
38
+ console.error(` ✗ ${msg}`);
39
+ }
40
+
41
+ if (!existsSync(PALETTE)) {
42
+ console.error(`palette file not found: ${PALETTE} (use --palette <file>)`);
43
+ process.exit(1);
44
+ }
45
+ if (!existsSync(SRC)) {
46
+ console.error(`src directory not found: ${SRC} (use --src <dir>)`);
47
+ process.exit(1);
48
+ }
49
+
50
+ /* ── OKLCH → sRGB → WCAG luminance ──────────────────────── */
51
+
52
+ function oklchToLinearSrgb(L, C, H) {
53
+ const h = (H * Math.PI) / 180;
54
+ const a = C * Math.cos(h), b = C * Math.sin(h);
55
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
56
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
57
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
58
+ const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
59
+ return [
60
+ +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
61
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
62
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
63
+ ];
64
+ }
65
+ const inGamut = (rgb) => rgb.every((x) => x >= -0.0005 && x <= 1.0005);
66
+ const clip = (x) => Math.min(1, Math.max(0, x));
67
+ const luminance = (rgb) => {
68
+ const [r, g, b] = rgb.map(clip);
69
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
70
+ };
71
+ function ratio(c1, c2) {
72
+ const l1 = luminance(oklchToLinearSrgb(...c1));
73
+ const l2 = luminance(oklchToLinearSrgb(...c2));
74
+ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
75
+ return (hi + 0.05) / (lo + 0.05);
76
+ }
77
+
78
+ /* ── Brace-aware CSS block extraction ───────────────────── */
79
+ /* Returns [{ prelude, body, context }] where context is the enclosing
80
+ at-rule prelude ('' at top level). One level of at-rule nesting is
81
+ supported — enough for token files; deeper nesting is flattened with
82
+ its outermost context. Comments are stripped first. */
83
+
84
+ function extractBlocks(css) {
85
+ const src = css.replace(/\/\*[\s\S]*?\*\//g, '');
86
+ const blocks = [];
87
+ function scan(text, context) {
88
+ let i = 0;
89
+ while (i < text.length) {
90
+ const open = text.indexOf('{', i);
91
+ if (open === -1) break;
92
+ const prelude = text.slice(i, open).trim().replace(/^[;\s]+/, '');
93
+ let depth = 1, j = open + 1;
94
+ while (j < text.length && depth > 0) {
95
+ if (text[j] === '{') depth++;
96
+ else if (text[j] === '}') depth--;
97
+ j++;
98
+ }
99
+ const body = text.slice(open + 1, j - 1);
100
+ if (prelude.startsWith('@')) {
101
+ scan(body, prelude);
102
+ } else {
103
+ blocks.push({ prelude, body, context });
104
+ }
105
+ i = j;
106
+ }
107
+ }
108
+ scan(src, '');
109
+ return blocks;
110
+ }
111
+
112
+ function declsOf(blocks, filterFn) {
113
+ const decls = {};
114
+ for (const b of blocks) {
115
+ if (!filterFn(b)) continue;
116
+ for (const m of b.body.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) {
117
+ decls[m[1]] = m[2].trim();
118
+ }
119
+ }
120
+ return decls;
121
+ }
122
+
123
+ const blocks = extractBlocks(readFileSync(PALETTE, 'utf8'));
124
+ const lightDecls = declsOf(blocks, (b) => b.context === '' && b.prelude.includes(':root'));
125
+ const darkOverrides = declsOf(blocks, (b) =>
126
+ (b.context.includes('prefers-color-scheme') && b.context.includes('dark') && b.prelude.includes(':root')) ||
127
+ b.prelude.includes('[data-theme="dark"]')
128
+ );
129
+ if (Object.keys(lightDecls).length === 0) fail(`no top-level :root declarations found in ${PALETTE}`);
130
+ if (Object.keys(darkOverrides).length === 0) fail(`no dark-theme declarations found in ${PALETTE} (@media prefers-color-scheme: dark or [data-theme="dark"])`);
131
+ // CSS cascade: dark blocks override light; unset dark tokens inherit light values.
132
+ const darkDecls = { ...lightDecls, ...darkOverrides };
133
+
134
+ /* Resolve a token to [L, C, H], following var(--x) chains. Returns null
135
+ (and records why) when unresolvable. */
136
+ function resolveColor(decls, name, seen = new Set()) {
137
+ if (seen.has(name)) return { err: `circular var() chain at --${name}` };
138
+ seen.add(name);
139
+ const raw = decls[name];
140
+ if (raw === undefined) return { err: `--${name} is not defined` };
141
+ const varRef = raw.match(/^var\(\s*--([a-z0-9-]+)\s*\)$/);
142
+ if (varRef) return resolveColor(decls, varRef[1], seen);
143
+ const ok = raw.match(/^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
144
+ if (ok) return { color: [+ok[1], +ok[2], +ok[3]] };
145
+ return { err: `--${name} has unsupported value "${raw}" (expected oklch(L C H) or var(--token))` };
146
+ }
147
+
148
+ /* ── Contrast checks (from REMARQUE.md Enforcement Checklist) ── */
149
+
150
+ const CHECKS = [
151
+ // [fg-token, bg-token, min-ratio, label]
152
+ ['color-fg', 'color-bg', 4.5, 'primary text'],
153
+ ['color-fg', 'color-surface', 4.5, 'primary text on surface'],
154
+ ['color-fg-muted', 'color-bg', 7.0, 'secondary text (spec AAA line)'],
155
+ ['color-muted', 'color-bg', 4.5, 'tertiary text'],
156
+ ['color-muted', 'color-surface', 4.5, 'tertiary text on surface'],
157
+ ['color-accent', 'color-bg', 4.5, 'links'],
158
+ ['color-accent-hover', 'color-bg', 4.5, 'link hover'],
159
+ ['color-code-fg', 'color-code-bg', 4.5, 'code text'],
160
+ ['color-border-bold', 'color-bg', 3.0, 'functional borders (WCAG 1.4.11)'],
161
+ ['color-selection-fg', 'color-selection-bg', 4.5, 'selected text'],
162
+ ];
163
+
164
+ for (const [themeName, decls] of [['light', lightDecls], ['dark', darkDecls]]) {
165
+ console.log(`\n${themeName} theme (${PALETTE})`);
166
+ // gamut check on every color-valued token
167
+ for (const name of Object.keys(decls)) {
168
+ const r = resolveColor(decls, name);
169
+ if (r.color && !inGamut(oklchToLinearSrgb(...r.color))) {
170
+ fail(`--${name} oklch(${r.color.join(' ')}) is outside sRGB gamut — browsers will clip it; author an in-gamut value`);
171
+ }
172
+ }
173
+ for (const [fgName, bgName, min, label] of CHECKS) {
174
+ const fg = resolveColor(decls, fgName);
175
+ const bg = resolveColor(decls, bgName);
176
+ if (fg.err || bg.err) {
177
+ fail(`${fgName}/${bgName} (${label}): ${fg.err || bg.err}`);
178
+ continue;
179
+ }
180
+ const r = ratio(fg.color, bg.color);
181
+ if (r < min) fail(`${fgName}/${bgName} = ${r.toFixed(2)}:1 < ${min}:1 (${label})`);
182
+ else console.log(` ✓ ${fgName}/${bgName} = ${r.toFixed(2)}:1 (${label})`);
183
+ }
184
+ }
185
+
186
+ /* ── Source scans ───────────────────────────────────────── */
187
+
188
+ function* walk(dir) {
189
+ for (const name of readdirSync(dir)) {
190
+ const p = join(dir, name);
191
+ if (name === 'node_modules' || name === 'dist' || name.startsWith('.')) continue;
192
+ if (statSync(p).isDirectory()) yield* walk(p);
193
+ else if (/\.(css|astro|svelte|jsx|tsx|html)$/.test(name)) yield p;
194
+ }
195
+ }
196
+
197
+ console.log(`\nsource scans (${SRC})`);
198
+ const FONT_FLOOR_REM = 0.8125, FONT_FLOOR_PX = 13;
199
+ // tokens.astro is the token *reference page* — its job is displaying literal
200
+ // values (issue #48 tracks generating it from a machine-readable source).
201
+ const isTokenFile = (p) => /tokens(-core|-palette)?\.css$|fonts\.css$|globals\.css$|pages[\/\\]tokens\.astro$/.test(p);
202
+
203
+ for (const file of walk(SRC)) {
204
+ const rel = relative('.', file);
205
+ const text = readFileSync(file, 'utf8');
206
+ const lines = text.split('\n');
207
+ lines.forEach((line, i) => {
208
+ // 2. font-size floor (static rem/px values), plus statically
209
+ // unverifiable sizes (% / clamp) outside token files — those bypass
210
+ // any floor check, so require the type-scale tokens instead.
211
+ for (const m of line.matchAll(/font-size:\s*([\d.]+)(rem|px)/g)) {
212
+ const v = parseFloat(m[1]);
213
+ if ((m[2] === 'rem' && v < FONT_FLOOR_REM) || (m[2] === 'px' && v < FONT_FLOOR_PX)) {
214
+ fail(`${rel}:${i + 1} font-size ${m[1]}${m[2]} below the 13px floor`);
215
+ }
216
+ }
217
+ if (!isTokenFile(rel) && /font-size:\s*(clamp\(|[\d.]+%)/.test(line)) {
218
+ fail(`${rel}:${i + 1} statically unverifiable font-size (clamp/%) — use var(--text-*) tokens: ${line.trim().slice(0, 80)}`);
219
+ }
220
+ // 3. hardcoded colors (hex / rgb / hsl anywhere; oklch outside token files).
221
+ // Note: hex fills/strokes in inline SVG are flagged deliberately —
222
+ // Remarque icons use currentColor, per the no-hardcoded-colors rule.
223
+ if (/(#[0-9a-fA-F]{3,8}\b(?![0-9a-zA-Z]))|(\brgba?\()|(\bhsla?\()/.test(line) && !/xmlns|href|url\(#|\{#/.test(line)) {
224
+ fail(`${rel}:${i + 1} hardcoded hex/rgb/hsl color: ${line.trim().slice(0, 80)}`);
225
+ }
226
+ if (!isTokenFile(rel) && /\boklch\(/.test(line) && !line.includes('var(--')) {
227
+ fail(`${rel}:${i + 1} oklch() literal outside token files: ${line.trim().slice(0, 80)}`);
228
+ }
229
+ });
230
+ }
231
+
232
+ if (failures) {
233
+ console.error(`\naudit FAILED — ${failures} problem(s)\n`);
234
+ process.exit(1);
235
+ }
236
+ console.log('\naudit passed ✓\n');
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Remarque — Tailwind CSS Configuration
3
+ * ──────────────────────────────────────
4
+ * This config extends Tailwind with Remarque's design tokens.
5
+ *
6
+ * TAILWIND VERSION STORY (one canonical answer):
7
+ * - Tailwind v3.x → use this file as your tailwind.config.js.
8
+ * - Tailwind v4.x → do NOT use this file. Write an @theme block in CSS
9
+ * using the values from tokens.css (see site/src/styles/globals.css in
10
+ * this repo for the reference @theme; a shipped theme.css subpath export
11
+ * is tracked in issue #48).
12
+ *
13
+ * Tokens are defined in tokens.css as CSS custom properties; this config
14
+ * maps them into Tailwind utilities (font-display, text-title, etc.).
15
+ */
16
+
17
+ /** @type {import('tailwindcss').Config} */
18
+ module.exports = {
19
+ content: [
20
+ "./src/**/*.{js,ts,jsx,tsx,mdx,astro}",
21
+ "./pages/**/*.{js,ts,jsx,tsx,mdx,astro}",
22
+ "./components/**/*.{js,ts,jsx,tsx,mdx,astro}",
23
+ "./app/**/*.{js,ts,jsx,tsx,mdx,astro}",
24
+ ],
25
+
26
+ // Match Remarque's actual theming mechanism ([data-theme] attribute).
27
+ // The bare "class" strategy only matches a literal `dark` class, which
28
+ // Remarque never sets — dark: utilities would silently never activate.
29
+ darkMode: ["selector", '[data-theme="dark"]'],
30
+
31
+ theme: {
32
+ extend: {
33
+
34
+ /* ─── Font Families ──────────────────────────────── */
35
+ fontFamily: {
36
+ display: ["Newsreader", "Georgia", "Times New Roman", "serif"],
37
+ body: ["Inter", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"],
38
+ mono: ["JetBrains Mono", "ui-monospace", "Cascadia Code", "Fira Code", "monospace"],
39
+ },
40
+
41
+ /* ─── Font Sizes ─────────────────────────────────── */
42
+ /* Maps to: text-display, text-title, text-section, etc. */
43
+ fontSize: {
44
+ "display": ["var(--text-display)", { lineHeight: "var(--leading-display)", letterSpacing: "var(--tracking-display)" }],
45
+ "title": ["var(--text-title)", { lineHeight: "var(--leading-title)", letterSpacing: "var(--tracking-title)" }],
46
+ "section": ["var(--text-section)", { lineHeight: "var(--leading-section)", letterSpacing: "var(--tracking-title)" }],
47
+ "body-lg": ["var(--text-body-lg)", { lineHeight: "var(--leading-body)" }],
48
+ "body": ["var(--text-body)", { lineHeight: "var(--leading-body)" }],
49
+ "meta": ["var(--text-meta)", { lineHeight: "var(--leading-meta)", letterSpacing: "var(--tracking-meta)" }],
50
+ "micro": ["var(--text-micro)", { lineHeight: "var(--leading-meta)" }],
51
+ },
52
+
53
+ /* ─── Spacing ────────────────────────────────────── */
54
+ /*
55
+ * Namespaced under remarque-* so Tailwind's default numeric scale is
56
+ * NEVER overridden (mt-12 stays 3rem). Use mt-remarque-9 (6rem) etc.
57
+ * for Remarque's generous editorial spacings, or var(--space-N)
58
+ * directly in arbitrary values: mt-[var(--space-9)].
59
+ */
60
+ spacing: {
61
+ "remarque-1": "var(--space-1)",
62
+ "remarque-2": "var(--space-2)",
63
+ "remarque-3": "var(--space-3)",
64
+ "remarque-4": "var(--space-4)",
65
+ "remarque-5": "var(--space-5)",
66
+ "remarque-6": "var(--space-6)",
67
+ "remarque-7": "var(--space-7)",
68
+ "remarque-8": "var(--space-8)",
69
+ "remarque-9": "var(--space-9)",
70
+ "remarque-10": "var(--space-10)",
71
+ "remarque-11": "var(--space-11)",
72
+ "remarque-12": "var(--space-12)",
73
+ },
74
+
75
+ /* ─── Max Widths ─────────────────────────────────── */
76
+ /*
77
+ * Width-constraint utilities ONLY — they do not center.
78
+ * Per AGENT_RULES.md, page sections must use the .content-reading /
79
+ * .content-standard classes from tokens.css (which add
80
+ * margin-inline: auto). Reach for max-w-reading only on elements
81
+ * that are intentionally left-aligned inside an already-constrained
82
+ * container (e.g. a lead paragraph).
83
+ */
84
+ maxWidth: {
85
+ "reading": "var(--content-reading)", // 46rem — prose
86
+ "standard": "var(--content-standard)", // 72rem — project pages
87
+ "wide": "var(--content-wide)", // 88rem — outer container
88
+ },
89
+
90
+ /* ─── Colors ─────────────────────────────────────── */
91
+ /* Maps CSS custom properties so Tailwind utilities track theme changes */
92
+ colors: {
93
+ bg: {
94
+ DEFAULT: "var(--color-bg)",
95
+ subtle: "var(--color-bg-subtle)",
96
+ },
97
+ fg: {
98
+ DEFAULT: "var(--color-fg)",
99
+ muted: "var(--color-fg-muted)",
100
+ },
101
+ muted: "var(--color-muted)",
102
+ border: {
103
+ DEFAULT: "var(--color-border)",
104
+ bold: "var(--color-border-bold)",
105
+ },
106
+ surface: "var(--color-surface)",
107
+ accent: {
108
+ DEFAULT: "var(--color-accent)",
109
+ hover: "var(--color-accent-hover)",
110
+ subtle: "var(--color-accent-subtle)",
111
+ },
112
+ code: {
113
+ bg: "var(--color-code-bg)",
114
+ fg: "var(--color-code-fg)",
115
+ },
116
+ },
117
+
118
+ /* ─── Border Radius ──────────────────────────────── */
119
+ borderRadius: {
120
+ sm: "var(--radius-sm)",
121
+ md: "var(--radius-md)",
122
+ none: "0",
123
+ },
124
+
125
+ /* ─── Border Color ───────────────────────────────── */
126
+ borderColor: {
127
+ DEFAULT: "var(--color-border)",
128
+ bold: "var(--color-border-bold)",
129
+ },
130
+
131
+ /* ─── Transitions ────────────────────────────────── */
132
+ transitionDuration: {
133
+ fast: "var(--motion-fast)",
134
+ normal: "var(--motion-normal)",
135
+ },
136
+
137
+ transitionTimingFunction: {
138
+ remarque: "var(--motion-easing)",
139
+ },
140
+ },
141
+ },
142
+
143
+ plugins: [],
144
+ };