designlang 7.1.0 → 8.0.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.
- package/CHANGELOG.md +49 -0
- package/README.md +46 -4
- package/bin/design-extract.js +28 -2
- package/package.json +1 -1
- package/src/config.js +4 -1
- package/src/crawler.js +376 -6
- package/src/extractors/accessibility.js +44 -1
- package/src/extractors/colors.js +50 -12
- package/src/extractors/interaction-states.js +57 -0
- package/src/extractors/modern-css.js +100 -0
- package/src/extractors/scoring.js +49 -30
- package/src/extractors/token-sources.js +65 -0
- package/src/extractors/wide-gamut.js +47 -0
- package/src/formatters/routes-reconciliation.js +160 -0
- package/src/index.js +29 -0
- package/src/utils/color-gamut.js +82 -0
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
- package/chrome-extension/README.md +0 -41
- package/chrome-extension/icons/favicon.svg +0 -7
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +0 -26
- package/chrome-extension/popup.html +0 -167
- package/chrome-extension/popup.js +0 -59
- package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
- package/tests/cli.test.js +0 -84
- package/tests/cookies.test.js +0 -98
- package/tests/extractors.test.js +0 -792
- package/tests/formatters.test.js +0 -709
- package/tests/mcp.test.js +0 -68
- package/tests/utils.test.js +0 -413
- package/website/.claude/launch.json +0 -11
- package/website/AGENTS.md +0 -5
- package/website/CLAUDE.md +0 -1
- package/website/README.md +0 -36
- package/website/app/api/extract/route.js +0 -245
- package/website/app/components/A11ySlider.js +0 -369
- package/website/app/components/Comparison.js +0 -286
- package/website/app/components/CssHealth.js +0 -243
- package/website/app/components/Extractor.js +0 -184
- package/website/app/components/HeroExtractor.js +0 -455
- package/website/app/components/Marginalia.js +0 -3
- package/website/app/components/McpSection.js +0 -223
- package/website/app/components/PlatformTabs.js +0 -250
- package/website/app/components/RegionsComponents.js +0 -429
- package/website/app/components/Rule.js +0 -13
- package/website/app/components/Specimens.js +0 -237
- package/website/app/components/StructuredData.js +0 -144
- package/website/app/components/TokenBrowser.js +0 -344
- package/website/app/components/token-browser-sample.js +0 -65
- package/website/app/globals.css +0 -505
- package/website/app/icon.svg +0 -7
- package/website/app/layout.js +0 -126
- package/website/app/opengraph-image.js +0 -170
- package/website/app/page.js +0 -352
- package/website/app/robots.js +0 -15
- package/website/app/seo-config.js +0 -82
- package/website/app/sitemap.js +0 -18
- package/website/jsconfig.json +0 -7
- package/website/lib/cache.js +0 -73
- package/website/lib/rate-limit.js +0 -30
- package/website/lib/rate-limit.test.js +0 -55
- package/website/lib/specimens.json +0 -86
- package/website/lib/token-helpers.js +0 -70
- package/website/lib/url-safety.js +0 -103
- package/website/lib/url-safety.test.js +0 -116
- package/website/lib/zip-files.js +0 -15
- package/website/next.config.mjs +0 -15
- package/website/package-lock.json +0 -1353
- package/website/package.json +0 -19
- package/website/public/favicon.svg +0 -7
- package/website/public/logo-specimen.svg +0 -76
- package/website/public/mark.svg +0 -12
- package/website/public/site.webmanifest +0 -13
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
// POST /api/extract
|
|
2
|
-
// Streaming NDJSON: one JSON event per line.
|
|
3
|
-
// Events:
|
|
4
|
-
// { type:'cache', cached:true } — if served from Blob
|
|
5
|
-
// { type:'stage', name:'crawl'|... } — progress markers
|
|
6
|
-
// { type:'token', category, path, value, $type } — one per semantic token
|
|
7
|
-
// { type:'summary', summary }
|
|
8
|
-
// { type:'files', files } — final, full file map
|
|
9
|
-
// { type:'error', error } — terminal failure
|
|
10
|
-
|
|
11
|
-
import { extractDesignLanguage } from '../../../../src/index.js';
|
|
12
|
-
import { formatMarkdown } from '../../../../src/formatters/markdown.js';
|
|
13
|
-
import { formatTailwind } from '../../../../src/formatters/tailwind.js';
|
|
14
|
-
import { formatCssVars } from '../../../../src/formatters/css-vars.js';
|
|
15
|
-
import { formatPreview } from '../../../../src/formatters/preview.js';
|
|
16
|
-
import { formatFigma } from '../../../../src/formatters/figma.js';
|
|
17
|
-
import { formatReactTheme, formatShadcnTheme } from '../../../../src/formatters/theme.js';
|
|
18
|
-
import { formatWordPress, formatWordPressTheme } from '../../../../src/formatters/wordpress.js';
|
|
19
|
-
import { formatDtcgTokens } from '../../../../src/formatters/dtcg-tokens.js';
|
|
20
|
-
import { formatIosSwiftUI } from '../../../../src/formatters/ios-swiftui.js';
|
|
21
|
-
import { formatAndroidCompose } from '../../../../src/formatters/android-compose.js';
|
|
22
|
-
import { formatFlutterDart } from '../../../../src/formatters/flutter-dart.js';
|
|
23
|
-
import { formatAgentRules } from '../../../../src/formatters/agent-rules.js';
|
|
24
|
-
import { nameFromUrl } from '../../../../src/utils.js';
|
|
25
|
-
|
|
26
|
-
import { validateTargetUrl } from '../../../../website/lib/url-safety.js';
|
|
27
|
-
import { checkRate } from '../../../../website/lib/rate-limit.js';
|
|
28
|
-
import { cacheKey, getCached, putCached } from '../../../../website/lib/cache.js';
|
|
29
|
-
|
|
30
|
-
export const runtime = 'nodejs';
|
|
31
|
-
export const dynamic = 'force-dynamic';
|
|
32
|
-
export const maxDuration = 60;
|
|
33
|
-
|
|
34
|
-
const STAGES = [
|
|
35
|
-
'crawl',
|
|
36
|
-
'colors',
|
|
37
|
-
'typography',
|
|
38
|
-
'spacing',
|
|
39
|
-
'shadows',
|
|
40
|
-
'borders',
|
|
41
|
-
'components',
|
|
42
|
-
'regions',
|
|
43
|
-
'a11y',
|
|
44
|
-
'score',
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
async function getBrowserOptions() {
|
|
48
|
-
if (process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
49
|
-
const chromium = (await import('@sparticuz/chromium')).default;
|
|
50
|
-
return {
|
|
51
|
-
executablePath: await chromium.executablePath(),
|
|
52
|
-
browserArgs: chromium.args,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
return {};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function ndjson(obj) {
|
|
59
|
-
return new TextEncoder().encode(JSON.stringify(obj) + '\n');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Walk a DTCG token tree and yield every leaf ({ $value, $type }).
|
|
63
|
-
function* walkDtcgTokens(tree, path = []) {
|
|
64
|
-
if (!tree || typeof tree !== 'object') return;
|
|
65
|
-
if (tree.$value !== undefined && tree.$type !== undefined) {
|
|
66
|
-
yield { path: path.join('.'), value: tree.$value, $type: tree.$type };
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
for (const key of Object.keys(tree)) {
|
|
70
|
-
if (key.startsWith('$')) continue;
|
|
71
|
-
yield* walkDtcgTokens(tree[key], [...path, key]);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function buildSummary(design) {
|
|
76
|
-
return {
|
|
77
|
-
url: design.meta?.url,
|
|
78
|
-
title: design.meta?.title,
|
|
79
|
-
colors: design.colors?.all?.length ?? 0,
|
|
80
|
-
colorList: (design.colors?.all || []).slice(0, 20).map((c) => c.hex),
|
|
81
|
-
fonts: design.typography?.families?.map((f) => f.name).join(', ') || 'none detected',
|
|
82
|
-
spacingCount: design.spacing?.scale?.length ?? 0,
|
|
83
|
-
spacingBase: design.spacing?.base ?? null,
|
|
84
|
-
shadowCount: design.shadows?.values?.length ?? 0,
|
|
85
|
-
radiiCount: design.borders?.radii?.length ?? 0,
|
|
86
|
-
componentCount: Object.keys(design.components || {}).length,
|
|
87
|
-
cssVarCount: Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v).length, 0),
|
|
88
|
-
a11yScore: design.accessibility?.score ?? null,
|
|
89
|
-
a11yFailCount: design.accessibility?.failCount ?? 0,
|
|
90
|
-
score: design.score,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function buildFiles(design, targetUrl) {
|
|
95
|
-
const prefix = nameFromUrl(targetUrl);
|
|
96
|
-
const dtcg = formatDtcgTokens(design);
|
|
97
|
-
const dtcgJson = JSON.stringify(dtcg, null, 2);
|
|
98
|
-
|
|
99
|
-
const files = {
|
|
100
|
-
[`${prefix}-design-language.md`]: formatMarkdown(design),
|
|
101
|
-
[`${prefix}-design-tokens.json`]: dtcgJson,
|
|
102
|
-
[`${prefix}-tailwind.config.js`]: formatTailwind(design),
|
|
103
|
-
[`${prefix}-variables.css`]: formatCssVars(design),
|
|
104
|
-
[`${prefix}-preview.html`]: formatPreview(design),
|
|
105
|
-
[`${prefix}-figma-variables.json`]: formatFigma(design),
|
|
106
|
-
[`${prefix}-theme.js`]: formatReactTheme(design),
|
|
107
|
-
[`${prefix}-shadcn-theme.css`]: formatShadcnTheme(design),
|
|
108
|
-
[`${prefix}-wordpress-theme.json`]: formatWordPress(design),
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
// MCP companion JSON — same subset the CLI writes.
|
|
112
|
-
files[`${prefix}-mcp.json`] = JSON.stringify({
|
|
113
|
-
colors: { all: design.colors?.all || [] },
|
|
114
|
-
regions: design.regions || [],
|
|
115
|
-
componentClusters: design.componentClusters || [],
|
|
116
|
-
accessibility: { remediation: design.accessibility?.remediation || [] },
|
|
117
|
-
cssHealth: design.cssHealth || null,
|
|
118
|
-
}, null, 2);
|
|
119
|
-
|
|
120
|
-
// iOS
|
|
121
|
-
files['ios/DesignTokens.swift'] = formatIosSwiftUI(dtcg);
|
|
122
|
-
|
|
123
|
-
// Android (returns { filename: content })
|
|
124
|
-
const android = formatAndroidCompose(dtcg);
|
|
125
|
-
for (const name of Object.keys(android)) {
|
|
126
|
-
files[`android/${name}`] = android[name];
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Flutter
|
|
130
|
-
files['flutter/design_tokens.dart'] = formatFlutterDart(dtcg);
|
|
131
|
-
|
|
132
|
-
// WordPress block theme (5 files)
|
|
133
|
-
const wpTheme = formatWordPressTheme(dtcg, design);
|
|
134
|
-
for (const name of Object.keys(wpTheme)) {
|
|
135
|
-
files[`wordpress-theme/${name}`] = wpTheme[name];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Agent rules
|
|
139
|
-
const agentFiles = formatAgentRules({ design, tokens: dtcg, url: targetUrl });
|
|
140
|
-
for (const name of Object.keys(agentFiles)) {
|
|
141
|
-
files[name] = agentFiles[name];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return { files, dtcg };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function extractIp(request) {
|
|
148
|
-
const xff = request.headers.get('x-forwarded-for');
|
|
149
|
-
if (xff) return xff.split(',')[0].trim();
|
|
150
|
-
const real = request.headers.get('x-real-ip');
|
|
151
|
-
if (real) return real.trim();
|
|
152
|
-
return 'unknown';
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Emit cached payload as a simulated stream so the hero paints consistently.
|
|
156
|
-
async function streamCached(controller, cached, targetUrl) {
|
|
157
|
-
controller.enqueue(ndjson({ type: 'cache', cached: true }));
|
|
158
|
-
for (const stage of STAGES) {
|
|
159
|
-
controller.enqueue(ndjson({ type: 'stage', name: stage }));
|
|
160
|
-
await new Promise((r) => setTimeout(r, 40));
|
|
161
|
-
}
|
|
162
|
-
// Re-derive DTCG tokens from the cached design so the token-by-token paint
|
|
163
|
-
// still happens on a cache hit.
|
|
164
|
-
const { files, dtcg } = buildFiles(cached.design, targetUrl);
|
|
165
|
-
for (const { path, value, $type } of walkDtcgTokens(dtcg)) {
|
|
166
|
-
const category = path.split('.')[1] || 'misc';
|
|
167
|
-
controller.enqueue(ndjson({ type: 'token', category, path, value, $type }));
|
|
168
|
-
}
|
|
169
|
-
controller.enqueue(ndjson({ type: 'summary', summary: buildSummary(cached.design) }));
|
|
170
|
-
controller.enqueue(ndjson({ type: 'files', files }));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export async function POST(request) {
|
|
174
|
-
let body;
|
|
175
|
-
try { body = await request.json(); } catch {
|
|
176
|
-
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const validation = validateTargetUrl(body?.url);
|
|
180
|
-
if (!validation.ok) {
|
|
181
|
-
return Response.json({ error: validation.reason }, { status: validation.status });
|
|
182
|
-
}
|
|
183
|
-
const targetUrl = validation.url;
|
|
184
|
-
|
|
185
|
-
const ip = extractIp(request);
|
|
186
|
-
const rate = checkRate(`extract:${ip}`);
|
|
187
|
-
if (!rate.allowed) {
|
|
188
|
-
return Response.json(
|
|
189
|
-
{ error: 'Rate limit — 3 extractions per day. Try again later.', resetAt: rate.resetAt },
|
|
190
|
-
{ status: 429 }
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const stream = new ReadableStream({
|
|
195
|
-
async start(controller) {
|
|
196
|
-
try {
|
|
197
|
-
const key = cacheKey(targetUrl);
|
|
198
|
-
const cached = await getCached(key);
|
|
199
|
-
if (cached) {
|
|
200
|
-
await streamCached(controller, cached, targetUrl);
|
|
201
|
-
controller.close();
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Pre-stage markers — best-effort progress since extraction is atomic.
|
|
206
|
-
controller.enqueue(ndjson({ type: 'stage', name: 'crawl' }));
|
|
207
|
-
|
|
208
|
-
const browserOpts = await getBrowserOptions();
|
|
209
|
-
const design = await extractDesignLanguage(targetUrl, browserOpts);
|
|
210
|
-
|
|
211
|
-
// Post-stage markers once extraction resolves.
|
|
212
|
-
for (const stage of STAGES.slice(1)) {
|
|
213
|
-
controller.enqueue(ndjson({ type: 'stage', name: stage }));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const { files, dtcg } = buildFiles(design, targetUrl);
|
|
217
|
-
|
|
218
|
-
// Token-by-token paint — every DTCG leaf becomes its own event.
|
|
219
|
-
for (const { path, value, $type } of walkDtcgTokens(dtcg)) {
|
|
220
|
-
const category = path.split('.')[1] || 'misc';
|
|
221
|
-
controller.enqueue(ndjson({ type: 'token', category, path, value, $type }));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
controller.enqueue(ndjson({ type: 'summary', summary: buildSummary(design) }));
|
|
225
|
-
controller.enqueue(ndjson({ type: 'files', files }));
|
|
226
|
-
|
|
227
|
-
// Fire-and-forget cache write.
|
|
228
|
-
putCached(key, { design }).catch(() => {});
|
|
229
|
-
} catch (err) {
|
|
230
|
-
console.error('[extract] failed', { url: targetUrl, ip, message: err?.message });
|
|
231
|
-
controller.enqueue(ndjson({ type: 'error', error: err?.message || 'Extraction failed' }));
|
|
232
|
-
} finally {
|
|
233
|
-
controller.close();
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
return new Response(stream, {
|
|
239
|
-
headers: {
|
|
240
|
-
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
241
|
-
'cache-control': 'no-store',
|
|
242
|
-
'x-accel-buffering': 'no',
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
}
|
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
// §05 A11y remediation — interactive contrast slider.
|
|
4
|
-
// WCAG relative-luminance formula copied here (~12 lines) so this stays
|
|
5
|
-
// client-side and doesn't import from the CLI package.
|
|
6
|
-
|
|
7
|
-
import { useMemo, useState } from 'react';
|
|
8
|
-
import Rule from './Rule';
|
|
9
|
-
import Marginalia from './Marginalia';
|
|
10
|
-
|
|
11
|
-
// ── WCAG helpers ────────────────────────────────────────────────
|
|
12
|
-
function toRgb(hex) {
|
|
13
|
-
const h = String(hex || '').replace('#', '');
|
|
14
|
-
const n = h.length === 3 ? h.split('').map((x) => x + x).join('') : h;
|
|
15
|
-
const i = parseInt(n, 16);
|
|
16
|
-
return [(i >> 16) & 255, (i >> 8) & 255, i & 255];
|
|
17
|
-
}
|
|
18
|
-
function relLum([r, g, b]) {
|
|
19
|
-
const f = (c) => {
|
|
20
|
-
const s = c / 255;
|
|
21
|
-
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
22
|
-
};
|
|
23
|
-
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
24
|
-
}
|
|
25
|
-
function contrast(a, b) {
|
|
26
|
-
const la = relLum(toRgb(a));
|
|
27
|
-
const lb = relLum(toRgb(b));
|
|
28
|
-
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
29
|
-
}
|
|
30
|
-
function lerpHex(a, b, t) {
|
|
31
|
-
const [ar, ag, ab] = toRgb(a);
|
|
32
|
-
const [br, bg, bb] = toRgb(b);
|
|
33
|
-
const r = Math.round(ar + (br - ar) * t);
|
|
34
|
-
const g = Math.round(ag + (bg - ag) * t);
|
|
35
|
-
const bl = Math.round(ab + (bb - ab) * t);
|
|
36
|
-
return (
|
|
37
|
-
'#' +
|
|
38
|
-
[r, g, bl].map((x) => x.toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── Demo data (shaped like remediateFailingPairs output) ────────
|
|
43
|
-
const BG = '#F3F1EA';
|
|
44
|
-
const FAIL_FG = '#B8B199';
|
|
45
|
-
const PASS_FG = '#403C34';
|
|
46
|
-
|
|
47
|
-
const REMEDIATIONS = [
|
|
48
|
-
{
|
|
49
|
-
fg: '#B8B199',
|
|
50
|
-
bg: '#F3F1EA',
|
|
51
|
-
ratio: 2.11,
|
|
52
|
-
rule: 'AA-normal',
|
|
53
|
-
suggestion: { replace: 'fg', color: '#403C34', newRatio: 7.84 },
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
fg: '#8B8778',
|
|
57
|
-
bg: '#F3F1EA',
|
|
58
|
-
ratio: 3.28,
|
|
59
|
-
rule: 'AA-normal',
|
|
60
|
-
suggestion: { replace: 'fg', color: '#0A0908', newRatio: 15.92 },
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
fg: '#FF4800',
|
|
64
|
-
bg: '#ECE8DD',
|
|
65
|
-
ratio: 3.51,
|
|
66
|
-
rule: 'AAA-normal',
|
|
67
|
-
suggestion: { replace: 'fg', color: '#0A0908', newRatio: 15.31 },
|
|
68
|
-
},
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
function tagFor(ratio) {
|
|
72
|
-
if (ratio >= 7) return { label: 'AAA', color: 'var(--ink)', underline: true };
|
|
73
|
-
if (ratio >= 4.5) return { label: 'AA', color: 'var(--accent)', underline: false };
|
|
74
|
-
if (ratio >= 3) return { label: 'AA large', color: 'var(--accent)', underline: false };
|
|
75
|
-
return { label: 'FAIL', color: 'var(--ink)', underline: false };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export default function A11ySlider() {
|
|
79
|
-
const [t, setT] = useState(0);
|
|
80
|
-
const fg = useMemo(() => lerpHex(FAIL_FG, PASS_FG, t / 100), [t]);
|
|
81
|
-
const ratio = useMemo(() => contrast(fg, BG), [fg]);
|
|
82
|
-
const tag = tagFor(ratio);
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<>
|
|
86
|
-
<Rule number="05" label="A11y remediation" />
|
|
87
|
-
<div className="with-margin" style={{ marginTop: 'var(--r5)' }}>
|
|
88
|
-
<div>
|
|
89
|
-
<div className="eyebrow" style={{ marginBottom: 'var(--r3)' }}>§05 A11y remediation</div>
|
|
90
|
-
<h2 className="display" style={{ marginBottom: 'var(--r4)' }}>
|
|
91
|
-
From score to fix.
|
|
92
|
-
</h2>
|
|
93
|
-
<p className="prose" style={{ fontSize: 18, maxWidth: '62ch', color: 'var(--ink-2)' }}>
|
|
94
|
-
Most tools hand you a failing contrast ratio. designlang hands you the next color in
|
|
95
|
-
your own palette that passes AA. Drag to see the difference.
|
|
96
|
-
</p>
|
|
97
|
-
|
|
98
|
-
<div
|
|
99
|
-
className="grid-12"
|
|
100
|
-
style={{ marginTop: 'var(--r7)', alignItems: 'start' }}
|
|
101
|
-
>
|
|
102
|
-
{/* LEFT — slider tiles (span 6) */}
|
|
103
|
-
<div style={{ gridColumn: 'span 6' }}>
|
|
104
|
-
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--r4)' }}>
|
|
105
|
-
{/* Failing tile (static reference) */}
|
|
106
|
-
<Tile
|
|
107
|
-
bg={BG}
|
|
108
|
-
fg={FAIL_FG}
|
|
109
|
-
ratio={contrast(FAIL_FG, BG)}
|
|
110
|
-
label="failing pair"
|
|
111
|
-
staticTag={{ label: 'FAIL', color: 'var(--ink)', underline: false }}
|
|
112
|
-
/>
|
|
113
|
-
{/* Live tile driven by slider */}
|
|
114
|
-
<Tile
|
|
115
|
-
bg={BG}
|
|
116
|
-
fg={fg}
|
|
117
|
-
ratio={ratio}
|
|
118
|
-
label="remediated"
|
|
119
|
-
staticTag={tag}
|
|
120
|
-
/>
|
|
121
|
-
</div>
|
|
122
|
-
|
|
123
|
-
<div style={{ marginTop: 'var(--r5)' }}>
|
|
124
|
-
<label
|
|
125
|
-
htmlFor="a11y-range"
|
|
126
|
-
className="mono"
|
|
127
|
-
style={{
|
|
128
|
-
fontSize: 11,
|
|
129
|
-
letterSpacing: '0.14em',
|
|
130
|
-
textTransform: 'uppercase',
|
|
131
|
-
color: 'var(--ink-2)',
|
|
132
|
-
display: 'block',
|
|
133
|
-
marginBottom: 8,
|
|
134
|
-
}}
|
|
135
|
-
>
|
|
136
|
-
drag: failing → remediated
|
|
137
|
-
</label>
|
|
138
|
-
<input
|
|
139
|
-
id="a11y-range"
|
|
140
|
-
type="range"
|
|
141
|
-
min="0"
|
|
142
|
-
max="100"
|
|
143
|
-
value={t}
|
|
144
|
-
onChange={(e) => setT(Number(e.target.value))}
|
|
145
|
-
aria-valuetext={`contrast ratio ${ratio.toFixed(2)} to 1`}
|
|
146
|
-
className="a11y-range"
|
|
147
|
-
style={{ width: '100%' }}
|
|
148
|
-
/>
|
|
149
|
-
<div
|
|
150
|
-
className="mono"
|
|
151
|
-
style={{
|
|
152
|
-
display: 'flex',
|
|
153
|
-
justifyContent: 'space-between',
|
|
154
|
-
marginTop: 6,
|
|
155
|
-
fontSize: 11,
|
|
156
|
-
color: 'var(--ink-3)',
|
|
157
|
-
}}
|
|
158
|
-
>
|
|
159
|
-
<span>{FAIL_FG}</span>
|
|
160
|
-
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{fg}</span>
|
|
161
|
-
<span>{PASS_FG}</span>
|
|
162
|
-
</div>
|
|
163
|
-
</div>
|
|
164
|
-
</div>
|
|
165
|
-
|
|
166
|
-
{/* RIGHT — remediation list (span 6) */}
|
|
167
|
-
<div style={{ gridColumn: 'span 6' }}>
|
|
168
|
-
<div
|
|
169
|
-
className="mono"
|
|
170
|
-
style={{
|
|
171
|
-
fontSize: 11,
|
|
172
|
-
letterSpacing: '0.14em',
|
|
173
|
-
textTransform: 'uppercase',
|
|
174
|
-
color: 'var(--ink-2)',
|
|
175
|
-
marginBottom: 'var(--r3)',
|
|
176
|
-
}}
|
|
177
|
-
>
|
|
178
|
-
remediateFailingPairs() output
|
|
179
|
-
</div>
|
|
180
|
-
<div
|
|
181
|
-
role="table"
|
|
182
|
-
style={{
|
|
183
|
-
border: '1px solid var(--ink)',
|
|
184
|
-
fontFamily: 'var(--font-mono)',
|
|
185
|
-
fontSize: 12,
|
|
186
|
-
}}
|
|
187
|
-
>
|
|
188
|
-
<div
|
|
189
|
-
role="row"
|
|
190
|
-
style={{
|
|
191
|
-
display: 'grid',
|
|
192
|
-
gridTemplateColumns: '1.3fr 0.3fr 1.3fr 0.9fr 0.7fr',
|
|
193
|
-
padding: '8px 12px',
|
|
194
|
-
borderBottom: '1px solid var(--ink)',
|
|
195
|
-
color: 'var(--ink-3)',
|
|
196
|
-
letterSpacing: '0.08em',
|
|
197
|
-
textTransform: 'uppercase',
|
|
198
|
-
fontSize: 10,
|
|
199
|
-
}}
|
|
200
|
-
>
|
|
201
|
-
<span>original</span>
|
|
202
|
-
<span />
|
|
203
|
-
<span>suggested</span>
|
|
204
|
-
<span>ratio</span>
|
|
205
|
-
<span>rule</span>
|
|
206
|
-
</div>
|
|
207
|
-
{REMEDIATIONS.map((r, i) => (
|
|
208
|
-
<div
|
|
209
|
-
key={i}
|
|
210
|
-
role="row"
|
|
211
|
-
style={{
|
|
212
|
-
display: 'grid',
|
|
213
|
-
gridTemplateColumns: '1.3fr 0.3fr 1.3fr 0.9fr 0.7fr',
|
|
214
|
-
padding: '10px 12px',
|
|
215
|
-
borderTop: i === 0 ? 0 : '1px solid var(--ink-3)',
|
|
216
|
-
alignItems: 'center',
|
|
217
|
-
}}
|
|
218
|
-
>
|
|
219
|
-
<span>
|
|
220
|
-
<Swatch color={r.fg} /> {r.fg}{' '}
|
|
221
|
-
<span style={{ color: 'var(--ink-3)' }}>on</span>{' '}
|
|
222
|
-
<Swatch color={r.bg} />
|
|
223
|
-
</span>
|
|
224
|
-
<span style={{ color: 'var(--ink-3)', textAlign: 'center' }}>→</span>
|
|
225
|
-
<span>
|
|
226
|
-
<Swatch color={r.suggestion.color} /> {r.suggestion.color}
|
|
227
|
-
</span>
|
|
228
|
-
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
229
|
-
<span style={{ color: 'var(--ink-3)' }}>{r.ratio.toFixed(2)}</span>
|
|
230
|
-
<span style={{ color: 'var(--ink-3)' }}> → </span>
|
|
231
|
-
<span style={{ color: 'var(--accent)' }}>
|
|
232
|
-
{r.suggestion.newRatio.toFixed(2)}
|
|
233
|
-
</span>
|
|
234
|
-
</span>
|
|
235
|
-
<span style={{ color: 'var(--ink-2)' }}>{r.rule}</span>
|
|
236
|
-
</div>
|
|
237
|
-
))}
|
|
238
|
-
</div>
|
|
239
|
-
<p
|
|
240
|
-
className="mono"
|
|
241
|
-
style={{
|
|
242
|
-
marginTop: 'var(--r3)',
|
|
243
|
-
fontSize: 11,
|
|
244
|
-
color: 'var(--ink-3)',
|
|
245
|
-
maxWidth: '52ch',
|
|
246
|
-
}}
|
|
247
|
-
>
|
|
248
|
-
designlang only suggests colors that already exist in the extracted palette. No
|
|
249
|
-
invented tokens.
|
|
250
|
-
</p>
|
|
251
|
-
</div>
|
|
252
|
-
</div>
|
|
253
|
-
</div>
|
|
254
|
-
|
|
255
|
-
<Marginalia>
|
|
256
|
-
<div>WCAG thresholds</div>
|
|
257
|
-
<div style={{ marginTop: 6 }}>
|
|
258
|
-
<div><code>AA normal 4.5:1</code></div>
|
|
259
|
-
<div><code>AA large 3:1</code></div>
|
|
260
|
-
<div><code>AAA normal 7:1</code></div>
|
|
261
|
-
<div><code>AAA large 4.5:1</code></div>
|
|
262
|
-
</div>
|
|
263
|
-
<hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid var(--ink-3)' }} />
|
|
264
|
-
<p className="foot">
|
|
265
|
-
Run <code>designlang <url></code> and every failing pair ships with a drop-in fix.
|
|
266
|
-
</p>
|
|
267
|
-
</Marginalia>
|
|
268
|
-
</div>
|
|
269
|
-
|
|
270
|
-
<style jsx>{`
|
|
271
|
-
.a11y-range {
|
|
272
|
-
-webkit-appearance: none;
|
|
273
|
-
appearance: none;
|
|
274
|
-
height: 2px;
|
|
275
|
-
background: var(--ink);
|
|
276
|
-
outline: 0;
|
|
277
|
-
cursor: pointer;
|
|
278
|
-
}
|
|
279
|
-
.a11y-range::-webkit-slider-thumb {
|
|
280
|
-
-webkit-appearance: none;
|
|
281
|
-
appearance: none;
|
|
282
|
-
width: 18px;
|
|
283
|
-
height: 18px;
|
|
284
|
-
background: var(--paper);
|
|
285
|
-
border: 2px solid var(--ink);
|
|
286
|
-
border-radius: 0;
|
|
287
|
-
cursor: grab;
|
|
288
|
-
}
|
|
289
|
-
.a11y-range::-moz-range-thumb {
|
|
290
|
-
width: 18px;
|
|
291
|
-
height: 18px;
|
|
292
|
-
background: var(--paper);
|
|
293
|
-
border: 2px solid var(--ink);
|
|
294
|
-
border-radius: 0;
|
|
295
|
-
cursor: grab;
|
|
296
|
-
}
|
|
297
|
-
.a11y-range:focus-visible {
|
|
298
|
-
outline: 2px solid var(--accent);
|
|
299
|
-
outline-offset: 3px;
|
|
300
|
-
}
|
|
301
|
-
`}</style>
|
|
302
|
-
</>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function Swatch({ color }) {
|
|
307
|
-
return (
|
|
308
|
-
<span
|
|
309
|
-
aria-hidden="true"
|
|
310
|
-
style={{
|
|
311
|
-
display: 'inline-block',
|
|
312
|
-
width: 10,
|
|
313
|
-
height: 10,
|
|
314
|
-
background: color,
|
|
315
|
-
border: '1px solid var(--ink)',
|
|
316
|
-
verticalAlign: 'middle',
|
|
317
|
-
marginRight: 4,
|
|
318
|
-
}}
|
|
319
|
-
/>
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function Tile({ bg, fg, ratio, label, staticTag }) {
|
|
324
|
-
return (
|
|
325
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--r3)' }}>
|
|
326
|
-
<div
|
|
327
|
-
style={{
|
|
328
|
-
width: '100%',
|
|
329
|
-
aspectRatio: '1 / 1',
|
|
330
|
-
maxWidth: 220,
|
|
331
|
-
maxHeight: 220,
|
|
332
|
-
background: bg,
|
|
333
|
-
border: '1px solid var(--ink)',
|
|
334
|
-
display: 'flex',
|
|
335
|
-
alignItems: 'center',
|
|
336
|
-
justifyContent: 'center',
|
|
337
|
-
}}
|
|
338
|
-
>
|
|
339
|
-
<span
|
|
340
|
-
className="display"
|
|
341
|
-
style={{ color: fg, fontSize: 'clamp(36px, 4vw, 64px)', letterSpacing: '-0.03em' }}
|
|
342
|
-
>
|
|
343
|
-
Aa
|
|
344
|
-
</span>
|
|
345
|
-
</div>
|
|
346
|
-
<div className="mono" style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
347
|
-
<span style={{ color: 'var(--ink-3)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
|
348
|
-
{label}
|
|
349
|
-
</span>
|
|
350
|
-
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
351
|
-
{fg.toUpperCase()} <span style={{ color: 'var(--ink-3)' }}>on</span> {bg.toUpperCase()}
|
|
352
|
-
</span>
|
|
353
|
-
<span
|
|
354
|
-
style={{
|
|
355
|
-
color: staticTag.color,
|
|
356
|
-
fontVariantNumeric: 'tabular-nums',
|
|
357
|
-
letterSpacing: '0.06em',
|
|
358
|
-
textTransform: 'uppercase',
|
|
359
|
-
textDecoration: staticTag.underline ? 'underline' : 'none',
|
|
360
|
-
textDecorationColor: 'var(--accent)',
|
|
361
|
-
textUnderlineOffset: 3,
|
|
362
|
-
}}
|
|
363
|
-
>
|
|
364
|
-
{staticTag.label} {ratio.toFixed(2)}:1
|
|
365
|
-
</span>
|
|
366
|
-
</div>
|
|
367
|
-
</div>
|
|
368
|
-
);
|
|
369
|
-
}
|