From ecc49131fe293c9e7c0ccc572be7056e936bdc99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:35:01 +0000 Subject: [PATCH 1/9] Add design-token extraction API for button-like elements New, additive /api/design-tokens endpoint: scrapes a URL's HTML+CSS, finds button/.button/.btn/[role=button] elements via linkedom, matches CSS rules against them with element.matches(), and resolves the CSS cascade (specificity, !important, source order) to surface only design-token-relevant declarations (background/color/border/shadow/ font, not spacing) per element and per interaction state (hover/focus/active/disabled). Results are grouped into suggested token combinations. No existing files were modified. --- src/routes/api/design-tokens/+server.ts | 35 +++ .../api/design-tokens/component-presets.ts | 36 +++ src/routes/api/design-tokens/design-tokens.ts | 29 ++ .../api/design-tokens/extract-tokens.test.ts | 106 ++++++++ .../api/design-tokens/extract-tokens.ts | 254 ++++++++++++++++++ 5 files changed, 460 insertions(+) create mode 100644 src/routes/api/design-tokens/+server.ts create mode 100644 src/routes/api/design-tokens/component-presets.ts create mode 100644 src/routes/api/design-tokens/design-tokens.ts create mode 100644 src/routes/api/design-tokens/extract-tokens.test.ts create mode 100644 src/routes/api/design-tokens/extract-tokens.ts diff --git a/src/routes/api/design-tokens/+server.ts b/src/routes/api/design-tokens/+server.ts new file mode 100644 index 00000000..26d0a371 --- /dev/null +++ b/src/routes/api/design-tokens/+server.ts @@ -0,0 +1,35 @@ +export const prerender = false + +import { error, json } from '@sveltejs/kit' +import { get_design_tokens } from './design-tokens' +import { COMPONENT_PRESETS, type Component } from './component-presets' +import type { RequestHandler } from './$types' + +export const GET: RequestHandler = async ({ setHeaders, url }) => { + let analyzeUrl = url.searchParams.get('url') + + if (analyzeUrl === null) { + return json({ error: 'Missing URL' }, { status: 400 }) + } + + let component = url.searchParams.get('component') ?? 'button' + + if (!(component in COMPONENT_PRESETS)) { + return json({ error: `Unsupported component: ${component}` }, { status: 400 }) + } + + try { + let result = await get_design_tokens(analyzeUrl, component as Component) + + if ('error' in result) { + return json({ error: result.error }) + } + + setHeaders({ 'Cache-Control': 's-maxage=600' }) + + return json(result) + } catch (err) { + console.error(err) + error(500, 'An unexpected error occurred.') + } +} diff --git a/src/routes/api/design-tokens/component-presets.ts b/src/routes/api/design-tokens/component-presets.ts new file mode 100644 index 00000000..6c70cb73 --- /dev/null +++ b/src/routes/api/design-tokens/component-presets.ts @@ -0,0 +1,36 @@ +export const COMPONENT_PRESETS = { + button: ['button', '.button', '.btn', '[role="button"]'] +} as const + +export type Component = keyof typeof COMPONENT_PRESETS + +// Prefix-matched against declaration.property. Intentionally excludes +// spacing/layout props (margin, padding, width, gap, position, display, ...). +export const DESIGN_TOKEN_PROPERTY_PREFIXES = [ + 'background', + 'color', + 'border', + 'outline', + 'box-shadow', + 'font', + 'line-height', + 'text-decoration', + 'fill', + 'stroke', + 'accent-color', + 'caret-color' +] + +// Dynamic/interaction pseudo-classes that never structurally "match" a static +// DOM (linkedom has no real hover/focus state) - stripped before .matches(), +// remembered as a "state" bucket for the resulting tokens. +export const STATE_PSEUDO_CLASSES = [ + 'hover', + 'focus', + 'focus-visible', + 'focus-within', + 'active', + 'disabled', + 'visited', + 'target' +] diff --git a/src/routes/api/design-tokens/design-tokens.ts b/src/routes/api/design-tokens/design-tokens.ts new file mode 100644 index 00000000..53dfaefe --- /dev/null +++ b/src/routes/api/design-tokens/design-tokens.ts @@ -0,0 +1,29 @@ +import { parseHTML } from 'linkedom' +import { get_css, USER_AGENT } from '../get-css/get-css' +import { extract_design_tokens, group_suggestions } from './extract-tokens' +import type { Component } from './component-presets' + +export async function get_design_tokens(url: string, component: Component = 'button') { + let [html_response, css_origins] = await Promise.all([ + fetch(url, { headers: { 'User-Agent': USER_AGENT } }), + get_css(url) + ]) + + if ('error' in css_origins) { + return css_origins + } + + if (!html_response.ok) { + return { error: { url, statusCode: html_response.status, message: html_response.statusText } } + } + + let { document } = parseHTML(await html_response.text()) + let css = css_origins.map((origin) => origin.css).join('\n') + let results = extract_design_tokens(document, css, component) + + return { + component, + elements: results.length, + suggestions: group_suggestions(results) + } +} diff --git a/src/routes/api/design-tokens/extract-tokens.test.ts b/src/routes/api/design-tokens/extract-tokens.test.ts new file mode 100644 index 00000000..f9c0072e --- /dev/null +++ b/src/routes/api/design-tokens/extract-tokens.test.ts @@ -0,0 +1,106 @@ +import { test, expect } from 'vitest' +import { parseHTML } from 'linkedom' +import { extract_design_tokens, group_suggestions } from './extract-tokens' + +function make_document(html: string) { + return parseHTML(html).document +} + +test('only design-token properties are extracted, not spacing', () => { + let document = make_document('') + let css = '.btn { background: red; padding: 8px; margin: 4px; }' + + let [result] = extract_design_tokens(document, css, 'button') + + expect(result.tokens.base).toEqual({ background: 'red' }) +}) + +test('higher specificity wins regardless of source order', () => { + let document = make_document('') + let css = ` + #save.btn { background: green; } + .btn { background: blue; } + ` + + let [result] = extract_design_tokens(document, css, 'button') + + expect(result.tokens.base.background).toBe('green') +}) + +test('equal specificity: later rule in source order wins', () => { + let document = make_document('') + let css = ` + .btn { background: blue; } + .btn { background: green; } + ` + + let [result] = extract_design_tokens(document, css, 'button') + + expect(result.tokens.base.background).toBe('green') +}) + +test('!important beats higher specificity', () => { + let document = make_document('') + let css = ` + #save.btn { background: green; } + .btn { background: blue !important; } + ` + + let [result] = extract_design_tokens(document, css, 'button') + + expect(result.tokens.base.background).toBe('blue') +}) + +test(':hover and :disabled produce separate state buckets layered on top of base', () => { + let document = make_document('') + let css = ` + .btn { background: blue; } + .btn:hover { background: darkblue; } + .btn:disabled { background: grey; } + ` + + let [result] = extract_design_tokens(document, css, 'button') + + expect(result.tokens.base.background).toBe('blue') + expect(result.tokens.hover.background).toBe('darkblue') + expect(result.tokens.disabled.background).toBe('grey') +}) + +test('pseudo-element rules are excluded entirely', () => { + let document = make_document('') + let css = '.btn::before { background: red; } .btn { color: black; }' + + let [result] = extract_design_tokens(document, css, 'button') + + expect(result.tokens.base).toEqual({ color: 'black' }) +}) + +test('finds button-like elements via the button/.button/.btn/[role=button] preset', () => { + let document = make_document(` + +
Div button
+ Span btn + Link button +
Not a button
+ `) + let css = 'button, .button, .btn, [role="button"] { color: black; }' + + let results = extract_design_tokens(document, css, 'button') + + expect(results).toHaveLength(4) +}) + +test('group_suggestions dedupes structurally identical buttons', () => { + let document = make_document(` + + + `) + let css = '.btn { background: blue; }' + + let results = extract_design_tokens(document, css, 'button') + let suggestions = group_suggestions(results) + + expect(results).toHaveLength(2) + expect(suggestions).toHaveLength(1) + expect(suggestions[0].count).toBe(2) +}) diff --git a/src/routes/api/design-tokens/extract-tokens.ts b/src/routes/api/design-tokens/extract-tokens.ts new file mode 100644 index 00000000..ab6881ee --- /dev/null +++ b/src/routes/api/design-tokens/extract-tokens.ts @@ -0,0 +1,254 @@ +import { parse, walk, parse_selector_list, STYLE_RULE, DECLARATION } from '@projectwallace/css-parser' +import { calculateSpecificity, compareSpecificity, type Specificity } from '@projectwallace/css-analyzer' +import { COMPONENT_PRESETS, DESIGN_TOKEN_PROPERTY_PREFIXES, STATE_PSEUDO_CLASSES, type Component } from './component-presets' + +export type TokenDeclaration = { + property: string + value: string + important: boolean +} + +export type Rule = { + selector_text: string + declarations: TokenDeclaration[] + order_index: number +} + +/** Walk a stylesheet once and collect every style rule (incl. ones nested in @media/@supports). */ +export function parse_rules(css: string): Rule[] { + let ast + try { + ast = parse(css, { parse_selectors: false }) + } catch { + return [] + } + + let rules: Rule[] = [] + let order_index = 0 + + walk(ast, (node) => { + if (node.type === STYLE_RULE) { + let selector_text = node.has_prelude ? node.prelude.text : '' + let declarations: TokenDeclaration[] = [] + + if (node.has_block) { + for (let child of node.block.children) { + if (child.type === DECLARATION) { + declarations.push({ + property: child.property, + value: child.value?.text ?? '', + important: child.is_important + }) + } + } + } + + rules.push({ selector_text, declarations, order_index: order_index++ }) + } + }) + + return rules +} + +export type ParsedSelector = { + text: string + specificity: Specificity +} + +/** Split a rule's (possibly comma-separated) selector text into individual selectors with their specificity. */ +export function parse_individual_selectors(selector_list_text: string): ParsedSelector[] { + if (!selector_list_text.trim()) return [] + + try { + let ast = parse_selector_list(selector_list_text) + if (!ast.has_children) return [] + + let specificities = calculateSpecificity(ast) + let result: ParsedSelector[] = [] + + for (let i = 0; i < ast.children.length; i++) { + let node = ast.children[i] + let specificity = specificities[i] + if (node && specificity) { + result.push({ text: node.text, specificity }) + } + } + + return result + } catch { + return [] + } +} + +const STATE_PSEUDO_REGEX = new RegExp(`:(?:${STATE_PSEUDO_CLASSES.join('|')})\\b`, 'g') + +/** + * A static DOM has no real :hover/:focus state, so element.matches() can never + * see it. Strip interaction pseudo-classes off before matching and remember + * which state(s) they represented instead. + */ +export function strip_state_pseudo_classes(selector: string): { structural: string; states: string[] } { + let states: string[] = [] + let structural = selector.replace(STATE_PSEUDO_REGEX, (match) => { + let state = match.slice(1) + if (!states.includes(state)) states.push(state) + return '' + }) + return { structural: structural.trim(), states } +} + +export function filter_design_tokens(declarations: TokenDeclaration[]): TokenDeclaration[] { + return declarations.filter((decl) => DESIGN_TOKEN_PROPERTY_PREFIXES.some((prefix) => decl.property.startsWith(prefix))) +} + +export type PropertyMatch = { + /** Empty array = applies unconditionally (base state). */ + states: string[] + selector_text: string + property: string + value: string + important: boolean + specificity: Specificity + order_index: number +} + +/** For every element, find every rule whose selector structurally matches it and collect its design-token declarations. */ +export function match_elements(elements: Element[], rules: Rule[]): Map { + let result = new Map() + + for (let rule of rules) { + let token_declarations = filter_design_tokens(rule.declarations) + if (token_declarations.length === 0) continue + + for (let selector of parse_individual_selectors(rule.selector_text)) { + // Pseudo-elements (::before, ::after, ...) style a generated box, not the element itself - out of scope for v1. + if (selector.text.includes('::')) continue + + let { structural, states } = strip_state_pseudo_classes(selector.text) + if (!structural) continue + + for (let element of elements) { + let matches: boolean + try { + matches = element.matches(structural) + } catch { + continue + } + if (!matches) continue + + let entries = result.get(element) + if (!entries) { + entries = [] + result.set(element, entries) + } + + for (let decl of token_declarations) { + entries.push({ + states, + selector_text: selector.text, + property: decl.property, + value: decl.value, + important: decl.important, + specificity: selector.specificity, + order_index: rule.order_index + }) + } + } + } + } + + return result +} + +export type ResolvedTokens = { base: Record } & Record> + +/** Resolve the CSS cascade (!important > specificity > source order) per state, layering each state's overrides on top of the base state. */ +export function resolve_tokens(matches: PropertyMatch[]): ResolvedTokens { + let all_states = new Set() + for (let match of matches) { + for (let state of match.states) all_states.add(state) + } + + function resolve_for(predicate: (match: PropertyMatch) => boolean): Record { + let sorted = matches.filter(predicate).toSorted((a, b) => { + if (a.important !== b.important) return a.important ? 1 : -1 + // compareSpecificity(x, y) is negative when x is MORE specific than y (descending + // comparator), so args are flipped here to get ascending order - least specific + // first, most specific last, so the last entry in the sorted array "wins". + let specificity_comparison = compareSpecificity(b.specificity, a.specificity) + if (specificity_comparison !== 0) return specificity_comparison + return a.order_index - b.order_index + }) + + let resolved: Record = {} + for (let entry of sorted) { + resolved[entry.property] = entry.value + } + return resolved + } + + let result: ResolvedTokens = { base: resolve_for((match) => match.states.length === 0) } + + for (let state of all_states) { + result[state] = resolve_for((match) => match.states.length === 0 || match.states.includes(state)) + } + + return result +} + +export type ComponentTokenResult = { + tag: string + class_name: string + matched_selectors: string[] + tokens: ResolvedTokens +} + +export function extract_design_tokens(document: Document, css: string, component: Component): ComponentTokenResult[] { + let selector = COMPONENT_PRESETS[component].join(', ') + let elements = Array.from(document.querySelectorAll(selector)) as Element[] + if (elements.length === 0) return [] + + let rules = parse_rules(css) + let matches_by_element = match_elements(elements, rules) + + let results: ComponentTokenResult[] = [] + for (let element of elements) { + let matches = matches_by_element.get(element) + if (!matches || matches.length === 0) continue + + results.push({ + tag: element.tagName.toLowerCase(), + class_name: element.getAttribute('class') ?? '', + matched_selectors: Array.from(new Set(matches.map((match) => match.selector_text))), + tokens: resolve_tokens(matches) + }) + } + + return results +} + +export type Suggestion = { + tokens: ResolvedTokens + count: number + examples: string[] +} + +/** Group elements that resolved to an identical base token set - the simplest possible "suggested combination" list. */ +export function group_suggestions(results: ComponentTokenResult[]): Suggestion[] { + let groups = new Map() + + for (let result of results) { + let key = JSON.stringify(result.tokens.base) + let example = result.class_name ? `${result.tag}.${result.class_name.trim().split(/\s+/).join('.')}` : result.tag + + let group = groups.get(key) + if (group) { + group.count++ + if (group.examples.length < 5) group.examples.push(example) + } else { + groups.set(key, { tokens: result.tokens, count: 1, examples: [example] }) + } + } + + return Array.from(groups.values()).toSorted((a, b) => b.count - a.count) +} From cf34363e163ebf0ef70596c5940fc065bf38052e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 12:42:31 +0000 Subject: [PATCH 2/9] Add debug page to inspect /api/design-tokens output New (public)/component-tokens page reuses the existing CSS-form URL submit flow (same pattern as lint-css), fetches the design-tokens API for the submitted URL, and renders the raw JSON in a
 so the
extracted token combinations can be eyeballed. File/raw-CSS tabs of
the form are present but unused. No existing files were modified.
---
 .../(public)/component-tokens/+page.svelte    | 77 +++++++++++++++++++
 1 file changed, 77 insertions(+)
 create mode 100644 src/routes/(public)/component-tokens/+page.svelte

diff --git a/src/routes/(public)/component-tokens/+page.svelte b/src/routes/(public)/component-tokens/+page.svelte
new file mode 100644
index 00000000..31830e36
--- /dev/null
+++ b/src/routes/(public)/component-tokens/+page.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+
+	
false} external_loading={loading}> + {#snippet title()} +

Component Design Tokens

+ {/snippet} +
+
+ + + {#if loading} +

Loading…

+ {:else if error} +

{error}

+ {:else if result} + + {/if} +
+ + From 059fea402dd170a4ceac28eb810781eddf2bcc67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 12:51:00 +0000 Subject: [PATCH 3/9] Return tokens for every component preset in a single response /api/design-tokens no longer takes a ?component= selector; it now runs the extraction pipeline once per known preset (button, heading) and returns a report keyed by component name. Also adds the heading (h1/.h1/.heading-1) preset, and switches to the non-deprecated compareSpecificity export from @projectwallace/css-analyzer/selectors (the main-index one is documented as its inverse, which is why the cascade sort previously needed flipped arguments). --- .../(public)/component-tokens/+page.svelte | 6 ++- src/routes/api/design-tokens/+server.ts | 9 +--- .../api/design-tokens/component-presets.ts | 3 +- src/routes/api/design-tokens/design-tokens.ts | 12 ++--- .../api/design-tokens/extract-tokens.test.ts | 33 +++++++++++- .../api/design-tokens/extract-tokens.ts | 50 ++++++++++++++----- 6 files changed, 80 insertions(+), 33 deletions(-) diff --git a/src/routes/(public)/component-tokens/+page.svelte b/src/routes/(public)/component-tokens/+page.svelte index 31830e36..55b5a530 100644 --- a/src/routes/(public)/component-tokens/+page.svelte +++ b/src/routes/(public)/component-tokens/+page.svelte @@ -24,7 +24,9 @@ if (!response.ok || (data && typeof data === 'object' && 'error' in data)) { let api_error = (data as { error?: unknown })?.error error = - typeof api_error === 'string' ? api_error : ((api_error as { message?: string })?.message ?? 'Something went wrong.') + typeof api_error === 'string' + ? api_error + : ((api_error as { message?: string })?.message ?? 'Something went wrong.') } else { result = data } @@ -44,7 +46,7 @@ diff --git a/src/routes/api/design-tokens/+server.ts b/src/routes/api/design-tokens/+server.ts index 26d0a371..f373e3a5 100644 --- a/src/routes/api/design-tokens/+server.ts +++ b/src/routes/api/design-tokens/+server.ts @@ -2,7 +2,6 @@ export const prerender = false import { error, json } from '@sveltejs/kit' import { get_design_tokens } from './design-tokens' -import { COMPONENT_PRESETS, type Component } from './component-presets' import type { RequestHandler } from './$types' export const GET: RequestHandler = async ({ setHeaders, url }) => { @@ -12,14 +11,8 @@ export const GET: RequestHandler = async ({ setHeaders, url }) => { return json({ error: 'Missing URL' }, { status: 400 }) } - let component = url.searchParams.get('component') ?? 'button' - - if (!(component in COMPONENT_PRESETS)) { - return json({ error: `Unsupported component: ${component}` }, { status: 400 }) - } - try { - let result = await get_design_tokens(analyzeUrl, component as Component) + let result = await get_design_tokens(analyzeUrl) if ('error' in result) { return json({ error: result.error }) diff --git a/src/routes/api/design-tokens/component-presets.ts b/src/routes/api/design-tokens/component-presets.ts index 6c70cb73..397be4ab 100644 --- a/src/routes/api/design-tokens/component-presets.ts +++ b/src/routes/api/design-tokens/component-presets.ts @@ -1,5 +1,6 @@ export const COMPONENT_PRESETS = { - button: ['button', '.button', '.btn', '[role="button"]'] + button: ['button', '.button', '.btn', '[role="button"]'], + heading: ['h1', '.h1', '.heading-1'] } as const export type Component = keyof typeof COMPONENT_PRESETS diff --git a/src/routes/api/design-tokens/design-tokens.ts b/src/routes/api/design-tokens/design-tokens.ts index 53dfaefe..9391ee5a 100644 --- a/src/routes/api/design-tokens/design-tokens.ts +++ b/src/routes/api/design-tokens/design-tokens.ts @@ -1,9 +1,8 @@ import { parseHTML } from 'linkedom' import { get_css, USER_AGENT } from '../get-css/get-css' -import { extract_design_tokens, group_suggestions } from './extract-tokens' -import type { Component } from './component-presets' +import { extract_all_design_tokens } from './extract-tokens' -export async function get_design_tokens(url: string, component: Component = 'button') { +export async function get_design_tokens(url: string) { let [html_response, css_origins] = await Promise.all([ fetch(url, { headers: { 'User-Agent': USER_AGENT } }), get_css(url) @@ -19,11 +18,6 @@ export async function get_design_tokens(url: string, component: Component = 'but let { document } = parseHTML(await html_response.text()) let css = css_origins.map((origin) => origin.css).join('\n') - let results = extract_design_tokens(document, css, component) - return { - component, - elements: results.length, - suggestions: group_suggestions(results) - } + return extract_all_design_tokens(document, css) } diff --git a/src/routes/api/design-tokens/extract-tokens.test.ts b/src/routes/api/design-tokens/extract-tokens.test.ts index f9c0072e..679b7df8 100644 --- a/src/routes/api/design-tokens/extract-tokens.test.ts +++ b/src/routes/api/design-tokens/extract-tokens.test.ts @@ -1,6 +1,6 @@ import { test, expect } from 'vitest' import { parseHTML } from 'linkedom' -import { extract_design_tokens, group_suggestions } from './extract-tokens' +import { extract_design_tokens, extract_all_design_tokens, group_suggestions } from './extract-tokens' function make_document(html: string) { return parseHTML(html).document @@ -90,6 +90,37 @@ test('finds button-like elements via the button/.button/.btn/[role=button] prese expect(results).toHaveLength(4) }) +test('finds heading-like elements via the h1/.h1/.heading-1 preset', () => { + let document = make_document(` +

Native

+
Div heading
+ Span heading +

Not an h1

+ `) + let css = 'h1, .h1, .heading-1 { color: black; }' + + let results = extract_design_tokens(document, css, 'heading') + + expect(results).toHaveLength(3) +}) + +test('extract_all_design_tokens reports every component preset in a single pass', () => { + let document = make_document(` +

Title

+ + + `) + let css = '.h1 { color: black; } .btn { background: blue; }' + + let report = extract_all_design_tokens(document, css) + + expect(Object.keys(report)).toEqual(['button', 'heading']) + expect(report.button.elements).toBe(2) + expect(report.button.suggestions[0].tokens.base).toEqual({ background: 'blue' }) + expect(report.heading.elements).toBe(1) + expect(report.heading.suggestions[0].tokens.base).toEqual({ color: 'black' }) +}) + test('group_suggestions dedupes structurally identical buttons', () => { let document = make_document(` diff --git a/src/routes/api/design-tokens/extract-tokens.ts b/src/routes/api/design-tokens/extract-tokens.ts index ab6881ee..a5f56797 100644 --- a/src/routes/api/design-tokens/extract-tokens.ts +++ b/src/routes/api/design-tokens/extract-tokens.ts @@ -1,6 +1,12 @@ import { parse, walk, parse_selector_list, STYLE_RULE, DECLARATION } from '@projectwallace/css-parser' -import { calculateSpecificity, compareSpecificity, type Specificity } from '@projectwallace/css-analyzer' -import { COMPONENT_PRESETS, DESIGN_TOKEN_PROPERTY_PREFIXES, STATE_PSEUDO_CLASSES, type Component } from './component-presets' +import { calculateSpecificity, type Specificity } from '@projectwallace/css-analyzer' +import { compareSpecificity } from '@projectwallace/css-analyzer/selectors' +import { + COMPONENT_PRESETS, + DESIGN_TOKEN_PROPERTY_PREFIXES, + STATE_PSEUDO_CLASSES, + type Component +} from './component-presets' export type TokenDeclaration = { property: string @@ -98,7 +104,9 @@ export function strip_state_pseudo_classes(selector: string): { structural: stri } export function filter_design_tokens(declarations: TokenDeclaration[]): TokenDeclaration[] { - return declarations.filter((decl) => DESIGN_TOKEN_PROPERTY_PREFIXES.some((prefix) => decl.property.startsWith(prefix))) + return declarations.filter((decl) => + DESIGN_TOKEN_PROPERTY_PREFIXES.some((prefix) => decl.property.startsWith(prefix)) + ) } export type PropertyMatch = { @@ -170,15 +178,16 @@ export function resolve_tokens(matches: PropertyMatch[]): ResolvedTokens { } function resolve_for(predicate: (match: PropertyMatch) => boolean): Record { - let sorted = matches.filter(predicate).toSorted((a, b) => { - if (a.important !== b.important) return a.important ? 1 : -1 - // compareSpecificity(x, y) is negative when x is MORE specific than y (descending - // comparator), so args are flipped here to get ascending order - least specific - // first, most specific last, so the last entry in the sorted array "wins". - let specificity_comparison = compareSpecificity(b.specificity, a.specificity) - if (specificity_comparison !== 0) return specificity_comparison - return a.order_index - b.order_index - }) + let sorted = matches + .filter((match) => predicate(match)) + .toSorted((a, b) => { + if (a.important !== b.important) return a.important ? 1 : -1 + // Ascending order: least specific first, most specific last, so the + // last entry in the sorted array "wins" the cascade. + let specificity_comparison = compareSpecificity(a.specificity, b.specificity) + if (specificity_comparison !== 0) return specificity_comparison + return a.order_index - b.order_index + }) let resolved: Record = {} for (let entry of sorted) { @@ -252,3 +261,20 @@ export function group_suggestions(results: ComponentTokenResult[]): Suggestion[] return Array.from(groups.values()).toSorted((a, b) => b.count - a.count) } + +export type ComponentReport = { elements: number; suggestions: Suggestion[] } + +/** Run the full extraction pipeline for every known component preset, keyed by component name. */ +export function extract_all_design_tokens(document: Document, css: string): Record { + let report = {} as Record + + for (let component of Object.keys(COMPONENT_PRESETS) as Component[]) { + let results = extract_design_tokens(document, css, component) + report[component] = { + elements: results.length, + suggestions: group_suggestions(results) + } + } + + return report +} From 868ed9be03fc0f6d394047b913c463614bdb6345 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 13:00:36 +0000 Subject: [PATCH 4/9] Fix "Something went wrong" on every URL by resolving it before fetch get_design_tokens fetched the raw, unresolved url string directly for the page's HTML, unlike get_css which normalizes it through resolve_url() first. Any protocol-less input (e.g. "example.com" - literally the form's own placeholder text) made the native fetch() throw "Invalid URL" inside the Promise.all, which propagated past the try/catch in +server.ts as a generic 500 that the page always reported as "Something went wrong." Now the url is validated/normalized via resolve_url() up front (same as get_css), and the HTML fetch failure is caught and turned into a proper structured error instead of an uncaught rejection. --- src/routes/api/design-tokens/design-tokens.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/routes/api/design-tokens/design-tokens.ts b/src/routes/api/design-tokens/design-tokens.ts index 9391ee5a..84a3b3f4 100644 --- a/src/routes/api/design-tokens/design-tokens.ts +++ b/src/routes/api/design-tokens/design-tokens.ts @@ -1,10 +1,19 @@ import { parseHTML } from 'linkedom' import { get_css, USER_AGENT } from '../get-css/get-css' import { extract_all_design_tokens } from './extract-tokens' +import { resolve_url } from '../../../lib/resolve-url.js' export async function get_design_tokens(url: string) { + let resolved_url = resolve_url(url) + + if (resolved_url === undefined) { + return { + error: { url, statusCode: 400, message: 'The URL is not valid. Are you sure you entered a URL and not CSS?' } + } + } + let [html_response, css_origins] = await Promise.all([ - fetch(url, { headers: { 'User-Agent': USER_AGENT } }), + fetch(resolved_url, { headers: { 'User-Agent': USER_AGENT } }).catch(() => undefined), get_css(url) ]) @@ -12,8 +21,14 @@ export async function get_design_tokens(url: string) { return css_origins } - if (!html_response.ok) { - return { error: { url, statusCode: html_response.status, message: html_response.statusText } } + if (html_response === undefined || !html_response.ok) { + return { + error: { + url, + statusCode: html_response?.status ?? 502, + message: html_response?.statusText ?? 'Could not fetch the page HTML to find components on.' + } + } } let { document } = parseHTML(await html_response.text()) From b319bed4faeb9f476d1f91c90ffe6ef67a2de003 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:05:23 +0000 Subject: [PATCH 5/9] Treat padding as a design token property padding is part of a component's visual shape, not just layout noise like margin/width/gap - add it to the design-token property allowlist alongside the already-present outline. --- src/routes/api/design-tokens/component-presets.ts | 5 +++-- src/routes/api/design-tokens/extract-tokens.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/routes/api/design-tokens/component-presets.ts b/src/routes/api/design-tokens/component-presets.ts index 397be4ab..c1da83fd 100644 --- a/src/routes/api/design-tokens/component-presets.ts +++ b/src/routes/api/design-tokens/component-presets.ts @@ -6,7 +6,7 @@ export const COMPONENT_PRESETS = { export type Component = keyof typeof COMPONENT_PRESETS // Prefix-matched against declaration.property. Intentionally excludes -// spacing/layout props (margin, padding, width, gap, position, display, ...). +// layout props (margin, width, gap, position, display, ...). export const DESIGN_TOKEN_PROPERTY_PREFIXES = [ 'background', 'color', @@ -19,7 +19,8 @@ export const DESIGN_TOKEN_PROPERTY_PREFIXES = [ 'fill', 'stroke', 'accent-color', - 'caret-color' + 'caret-color', + 'padding' ] // Dynamic/interaction pseudo-classes that never structurally "match" a static diff --git a/src/routes/api/design-tokens/extract-tokens.test.ts b/src/routes/api/design-tokens/extract-tokens.test.ts index 679b7df8..61fbeab6 100644 --- a/src/routes/api/design-tokens/extract-tokens.test.ts +++ b/src/routes/api/design-tokens/extract-tokens.test.ts @@ -6,13 +6,13 @@ function make_document(html: string) { return parseHTML(html).document } -test('only design-token properties are extracted, not spacing', () => { +test('design-token properties are extracted, layout properties like margin are not', () => { let document = make_document('') let css = '.btn { background: red; padding: 8px; margin: 4px; }' let [result] = extract_design_tokens(document, css, 'button') - expect(result.tokens.base).toEqual({ background: 'red' }) + expect(result.tokens.base).toEqual({ background: 'red', padding: '8px' }) }) test('higher specificity wins regardless of source order', () => { From 1d8f6b2275c2ebff27afd4c16f61e0d367a0b11c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:14:18 +0000 Subject: [PATCH 6/9] Tighten the button preset selectors Drop the bare "button" tag selector in favor of more specific matches: button[type="submit"], input[type="button"], .button/.btn classes, [role="button"], and suffix classes like [class*="-button"] and [class*="-btn"]. --- src/routes/api/design-tokens/component-presets.ts | 10 +++++++++- .../api/design-tokens/extract-tokens.test.ts | 15 +++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/routes/api/design-tokens/component-presets.ts b/src/routes/api/design-tokens/component-presets.ts index c1da83fd..8220d022 100644 --- a/src/routes/api/design-tokens/component-presets.ts +++ b/src/routes/api/design-tokens/component-presets.ts @@ -1,5 +1,13 @@ export const COMPONENT_PRESETS = { - button: ['button', '.button', '.btn', '[role="button"]'], + button: [ + 'button[type="submit"]', + 'input[type="button"]', + '.button', + '.btn', + '[role="button"]', + '[class*="-button"]', + '[class*="-btn"]' + ], heading: ['h1', '.h1', '.heading-1'] } as const diff --git a/src/routes/api/design-tokens/extract-tokens.test.ts b/src/routes/api/design-tokens/extract-tokens.test.ts index 61fbeab6..017a862e 100644 --- a/src/routes/api/design-tokens/extract-tokens.test.ts +++ b/src/routes/api/design-tokens/extract-tokens.test.ts @@ -75,19 +75,26 @@ test('pseudo-element rules are excluded entirely', () => { expect(result.tokens.base).toEqual({ color: 'black' }) }) -test('finds button-like elements via the button/.button/.btn/[role=button] preset', () => { +test('finds button-like elements via the button preset, but not a plain + + +
Div button
Span btn Link button +
Primary
+
CTA
Not a button
`) - let css = 'button, .button, .btn, [role="button"] { color: black; }' + let css = ` + button[type="submit"], input[type="button"], .button, .btn, [role="button"], + [class*="-button"], [class*="-btn"] { color: black; } + ` let results = extract_design_tokens(document, css, 'button') - expect(results).toHaveLength(4) + expect(results).toHaveLength(7) }) test('finds heading-like elements via the h1/.h1/.heading-1 preset', () => { From 1d48ef259c3b7717991d266aa135de6eff79de2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:21:30 +0000 Subject: [PATCH 7/9] Exclude link-styled elements from the button preset Wrap the button preset's selectors in :is(...):not([class*="-link"]) so fuzzy matchers like [class*="-button"] don't pick up elements whose class also identifies them as a link (e.g. "nav-link"). Component presets are now single compound selector strings instead of arrays joined with a comma. --- src/routes/api/design-tokens/component-presets.ts | 15 +++++---------- .../api/design-tokens/extract-tokens.test.ts | 14 ++++++++++++++ src/routes/api/design-tokens/extract-tokens.ts | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/routes/api/design-tokens/component-presets.ts b/src/routes/api/design-tokens/component-presets.ts index 8220d022..6b8f442e 100644 --- a/src/routes/api/design-tokens/component-presets.ts +++ b/src/routes/api/design-tokens/component-presets.ts @@ -1,14 +1,9 @@ export const COMPONENT_PRESETS = { - button: [ - 'button[type="submit"]', - 'input[type="button"]', - '.button', - '.btn', - '[role="button"]', - '[class*="-button"]', - '[class*="-btn"]' - ], - heading: ['h1', '.h1', '.heading-1'] + // [class*="-button"]/[class*="-btn"] are broad enough to also catch links styled + // as buttons, so exclude anything whose class also says it's a link (e.g. "nav-link"). + button: + ':is(button[type="submit"], input[type="button"], .button, .btn, [role="button"], [class*="-button"], [class*="-btn"]):not([class*="-link"])', + heading: 'h1, .h1, .heading-1' } as const export type Component = keyof typeof COMPONENT_PRESETS diff --git a/src/routes/api/design-tokens/extract-tokens.test.ts b/src/routes/api/design-tokens/extract-tokens.test.ts index 017a862e..2bafc245 100644 --- a/src/routes/api/design-tokens/extract-tokens.test.ts +++ b/src/routes/api/design-tokens/extract-tokens.test.ts @@ -97,6 +97,20 @@ test('finds button-like elements via the button preset, but not a plain