kaloko 0.4.8 → 0.4.10
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/README.md +92 -5
- package/package.json +1 -1
- package/schemas/config.v1.json +33 -0
- package/schemas/scenario.v1.json +39 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -3
- package/skills/kaloko/reference/acceptance.md +50 -2
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/evaluation.md +46 -3
- package/skills/kaloko/reference/execution.md +74 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +4 -1
- package/src/cli.mjs +428 -99
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +98 -14
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/pr.mjs +24 -2
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +500 -44
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
// Dev mode and the step spec (#42): what a developer or an agent needs to build a design step exactly as it was
|
|
2
|
+
// approved. One engine, `kalokoDev`, runs inside the page: the canvas frame helper (worker/src/frame.js) embeds it
|
|
3
|
+
// for Dev mode, and the CLI capture evaluates it to store `<viewport>.spec.json` next to the screenshot. So the
|
|
4
|
+
// canvas shows what the API, MCP and `kaloko spec` give an agent: elements with roles and names, computed styles
|
|
5
|
+
// mapped to DTCG tokens (exact, or the nearest with a warning), interaction states, contrast and assets.
|
|
6
|
+
//
|
|
7
|
+
// kalokoDev must stay self-contained: it is serialised with Function.prototype.toString and must not reference
|
|
8
|
+
// anything outside its own body (no imports, no module constants). The Node helpers below it (specText, specDrift,
|
|
9
|
+
// driftText) are ordinary code.
|
|
10
|
+
|
|
11
|
+
export const SPEC_SCHEMA = 'qawalk.spec.v1'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The in-page engine. opts: { tokens: a DTCG set or resolver (object) or null, maxElements }.
|
|
15
|
+
* Returns { tokens, describe(el), pageSpec(), usage(), statesOf(el), forceState(el, state), elementsFor(q), loadRules() }.
|
|
16
|
+
*/
|
|
17
|
+
export function kalokoDev (opts) {
|
|
18
|
+
'use strict'
|
|
19
|
+
const o = opts || {}
|
|
20
|
+
|
|
21
|
+
// ---------------- colors and numbers ----------------
|
|
22
|
+
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1
|
|
23
|
+
const g2 = canvas.getContext('2d', { willReadFrequently: true })
|
|
24
|
+
const colorCache = new Map()
|
|
25
|
+
/** Any CSS color (hex, rgb, hsl, oklch, named, color()) as [r, g, b, a 0..1], or null. */
|
|
26
|
+
function rgba (v) {
|
|
27
|
+
if (v == null) return null
|
|
28
|
+
v = String(v).trim()
|
|
29
|
+
if (colorCache.has(v)) return colorCache.get(v)
|
|
30
|
+
let out = null
|
|
31
|
+
const m = v.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/)
|
|
32
|
+
if (m) out = [+m[1], +m[2], +m[3], m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]]
|
|
33
|
+
else if (v && typeof CSS !== 'undefined' && CSS.supports('color', v) && g2) {
|
|
34
|
+
g2.clearRect(0, 0, 1, 1); g2.fillStyle = '#000'; g2.fillStyle = v; g2.fillRect(0, 0, 1, 1)
|
|
35
|
+
const d = g2.getImageData(0, 0, 1, 1).data
|
|
36
|
+
out = d[3] ? [d[0], d[1], d[2], Math.round(d[3] / 255 * 100) / 100] : [0, 0, 0, 0]
|
|
37
|
+
}
|
|
38
|
+
colorCache.set(v, out)
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
const h2 = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')
|
|
42
|
+
const hexOf = (c) => c ? '#' + h2(c[0]) + h2(c[1]) + h2(c[2]) + (c[3] < 1 ? h2(c[3] * 255) : '') : null
|
|
43
|
+
const hex = (v) => hexOf(rgba(v))
|
|
44
|
+
const rootPx = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
|
45
|
+
/** A length as px ("16px", "1rem", 16), or null. em is relative to `em` (letter-spacing tokens). */
|
|
46
|
+
function px (v, em) {
|
|
47
|
+
if (typeof v === 'number') return v
|
|
48
|
+
if (v && typeof v === 'object' && 'value' in v) return px(v.value + (v.unit || 'px'), em)
|
|
49
|
+
const m = String(v == null ? '' : v).trim().match(/^(-?[\d.]+)(px|rem|em)?$/)
|
|
50
|
+
if (!m) return null
|
|
51
|
+
return m[2] === 'rem' ? +m[1] * rootPx() : m[2] === 'em' ? +m[1] * (em || rootPx()) : +m[1]
|
|
52
|
+
}
|
|
53
|
+
const r1 = (n) => Math.round(n * 100) / 100
|
|
54
|
+
const fmtPx = (n) => r1(n) + 'px'
|
|
55
|
+
|
|
56
|
+
// ---------------- tokens (W3C DTCG; the same reading as src/lib/tokens.mjs) ----------------
|
|
57
|
+
const isTok = (v) => v && typeof v === 'object' && !Array.isArray(v) && '$value' in v
|
|
58
|
+
const isGrp = (v) => v && typeof v === 'object' && !Array.isArray(v) && !('$value' in v)
|
|
59
|
+
const clone = (v) => v == null ? v : JSON.parse(JSON.stringify(v))
|
|
60
|
+
function merge (a, b) { const out = clone(a) || {}; for (const k of Object.keys(b || {})) { const v = b[k]; out[k] = isGrp(v) && isGrp(out[k]) ? merge(out[k], v) : clone(v) } return out }
|
|
61
|
+
function pointer (doc, ref) {
|
|
62
|
+
if (typeof ref !== 'string' || ref[0] !== '#') return undefined
|
|
63
|
+
let cur = doc
|
|
64
|
+
for (const raw of ref.slice(1).split('/').slice(1)) { const k = raw.replace(/~1/g, '/').replace(/~0/g, '~'); if (cur == null || typeof cur !== 'object' || !(k in cur)) return undefined; cur = cur[k] }
|
|
65
|
+
return cur
|
|
66
|
+
}
|
|
67
|
+
function byPath (doc, path) { let cur = doc; for (const k of path.split('.')) { if (cur == null || typeof cur !== 'object' || !(k in cur)) return undefined; cur = cur[k] } return cur }
|
|
68
|
+
function applyExtends (doc, node, depth) {
|
|
69
|
+
if (!isGrp(node) || depth > 20) return node
|
|
70
|
+
let out = {}
|
|
71
|
+
for (const k of Object.keys(node)) out[k] = isGrp(node[k]) ? applyExtends(doc, node[k], depth + 1) : node[k]
|
|
72
|
+
const ext = node.$extends
|
|
73
|
+
if (typeof ext === 'string') {
|
|
74
|
+
const target = ext[0] === '#' ? pointer(doc, ext) : byPath(doc, ext.replace(/^\{|\}$/g, ''))
|
|
75
|
+
if (isGrp(target)) { const local = Object.assign({}, out); delete local.$extends; out = merge(applyExtends(doc, target, depth + 1), local) }
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
function resolveResolver (doc, want) {
|
|
80
|
+
const pick = (mod) => { const ctx = mod.contexts || {}; return ctx[(want && ctx[want] ? want : null) || mod.default || Object.keys(ctx)[0]] || [] }
|
|
81
|
+
const sources = (list) => (list || []).map((s) => s && s.$ref ? pointer(doc, s.$ref) : s).filter(isGrp)
|
|
82
|
+
let out = {}
|
|
83
|
+
for (const item of doc.resolutionOrder || []) {
|
|
84
|
+
const ref = (item && item.$ref) || ''
|
|
85
|
+
const target = ref ? pointer(doc, ref) : item
|
|
86
|
+
if (!target) continue
|
|
87
|
+
if (ref.startsWith('#/modifiers/') || (!ref && target.contexts)) { for (const s of sources(pick(target))) out = merge(out, s) } else if (Array.isArray(target.sources)) { for (const s of sources(target.sources)) out = merge(out, s) } else if (isGrp(target)) out = merge(out, target)
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
/** The set per mode: [{ mode, doc }] (mode null when the set has none). */
|
|
92
|
+
function modeDocs (set) {
|
|
93
|
+
if (Array.isArray(set.resolutionOrder)) {
|
|
94
|
+
const ctxs = [...new Set(Object.values(set.modifiers || {}).flatMap((m) => Object.keys((m && m.contexts) || {})))]
|
|
95
|
+
return ctxs.length ? ctxs.map((c) => ({ mode: c, doc: resolveResolver(set, c) })) : [{ mode: null, doc: resolveResolver(set, null) }]
|
|
96
|
+
}
|
|
97
|
+
const declared = set.$extensions && set.$extensions['qawalk.modes']
|
|
98
|
+
const modes = Array.isArray(declared) ? declared.filter((m) => isGrp(set[m])) : ['light', 'dark', 'high-contrast'].filter((m) => isGrp(set[m]))
|
|
99
|
+
const base = {}; for (const k of Object.keys(set)) if (!modes.includes(k)) base[k] = set[k]
|
|
100
|
+
return modes.length ? modes.map((m) => ({ mode: m, doc: merge(base, set[m]) })) : [{ mode: null, doc: base }]
|
|
101
|
+
}
|
|
102
|
+
const WEIGHTS = { thin: 100, hairline: 100, 'extra-light': 200, 'ultra-light': 200, light: 300, normal: 400, regular: 400, book: 400, medium: 500, 'semi-bold': 600, 'demi-bold': 600, bold: 700, 'extra-bold': 800, 'ultra-bold': 800, black: 900, heavy: 900 }
|
|
103
|
+
function colorObject (c) {
|
|
104
|
+
const a = c.alpha == null ? 1 : +c.alpha
|
|
105
|
+
const [x, y, z] = (c.components || []).map((v) => v === 'none' ? 0 : +v)
|
|
106
|
+
const sp = String(c.colorSpace || '').toLowerCase()
|
|
107
|
+
const ok = [x, y, z].every(Number.isFinite)
|
|
108
|
+
const out = (r, g, b) => hexOf([r * 255, g * 255, b * 255, a])
|
|
109
|
+
if (sp === 'srgb' && ok) return out(x, y, z)
|
|
110
|
+
if (sp === 'srgb-linear' && ok) { const f = (v) => v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return out(f(x), f(y), f(z)) }
|
|
111
|
+
if (sp === 'hsl' && ok) { const s = y / 100, l = z / 100, k = (n) => (n + x / 30) % 12, f = (n) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); return out(f(0), f(8), f(4)) }
|
|
112
|
+
return c.hex ? hex(c.hex) : null
|
|
113
|
+
}
|
|
114
|
+
const unit = (v) => v && typeof v === 'object' && 'value' in v ? v.value + (v.unit || '') : v
|
|
115
|
+
function css (type, v) {
|
|
116
|
+
if (v == null) return v
|
|
117
|
+
if (type === 'color' || (typeof v === 'object' && 'colorSpace' in v)) return typeof v === 'string' ? v : colorObject(v)
|
|
118
|
+
if (type === 'dimension' || type === 'duration' || (typeof v === 'object' && 'unit' in v && 'value' in v)) return unit(v)
|
|
119
|
+
if (type === 'fontFamily') return Array.isArray(v) ? v.join(', ') : v
|
|
120
|
+
if (type === 'fontWeight') return typeof v === 'string' ? (WEIGHTS[v.toLowerCase()] || v) : v
|
|
121
|
+
if (type === 'typography' && typeof v === 'object') return { fontFamily: css('fontFamily', v.fontFamily), fontSize: css('dimension', v.fontSize), fontWeight: css('fontWeight', v.fontWeight), lineHeight: unit(v.lineHeight), letterSpacing: css('dimension', v.letterSpacing) }
|
|
122
|
+
if (type === 'border' && typeof v === 'object') return { color: css('color', v.color), width: css('dimension', v.width) }
|
|
123
|
+
if (type === 'shadow') return [].concat(v).map((s) => ({ color: css('color', s.color), offsetX: css('dimension', s.offsetX), offsetY: css('dimension', s.offsetY), blur: css('dimension', s.blur), spread: css('dimension', s.spread), inset: !!s.inset }))
|
|
124
|
+
return v
|
|
125
|
+
}
|
|
126
|
+
function flatOne (doc) {
|
|
127
|
+
doc = applyExtends(doc, doc, 0)
|
|
128
|
+
const raw = new Map()
|
|
129
|
+
const walk = (obj, path, inherited, dep) => {
|
|
130
|
+
for (const k of Object.keys(obj || {})) {
|
|
131
|
+
if (k[0] === '$' && k !== '$root') continue
|
|
132
|
+
const v = obj[k]
|
|
133
|
+
const d = v && typeof v === 'object' && '$deprecated' in v ? v.$deprecated : dep
|
|
134
|
+
if (isTok(v)) raw.set((k === '$root' ? path : path.concat(k)).join('.'), { type: v.$type || inherited || null, raw: v.$value, deprecated: !!d })
|
|
135
|
+
else if (isGrp(v)) walk(v, path.concat(k), v.$type || inherited, d)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
walk(doc, [], null, false)
|
|
139
|
+
const resolve = (value, depth) => {
|
|
140
|
+
if (depth > 10) return value
|
|
141
|
+
if (typeof value === 'string') { const m = value.match(/^\{([^}]+)\}$/); if (!m) return value; const t = raw.get(m[1].replace(/\.\$root$/, '')); return t ? resolve(t.raw, depth + 1) : value }
|
|
142
|
+
if (value && typeof value === 'object' && typeof value.$ref === 'string') { const at = pointer(doc, value.$ref); return resolve(isTok(at) ? at.$value : at, depth + 1) }
|
|
143
|
+
if (Array.isArray(value)) return value.map((x) => resolve(x, depth + 1))
|
|
144
|
+
if (value && typeof value === 'object') { const out = {}; for (const k of Object.keys(value)) out[k] = resolve(value[k], depth + 1); return out }
|
|
145
|
+
return value
|
|
146
|
+
}
|
|
147
|
+
const out = new Map()
|
|
148
|
+
for (const [name, t] of raw) out.set(name, { type: t.type, value: css(t.type, resolve(t.raw, 0)), deprecated: t.deprecated })
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
151
|
+
const shadowKey = (parts) => parts.map((s) => (s.inset ? 'inset ' : '') + [s.offsetX, s.offsetY, s.blur, s.spread].map((x) => r1(px(x) || 0)).join(' ') + ' ' + (hex(s.color) || '')).join(', ')
|
|
152
|
+
/** Primitive entries of a token value, composite types split into their parts (typography.fontSize, …). */
|
|
153
|
+
function primitives (name, type, value) {
|
|
154
|
+
if (Array.isArray(value) && type === 'shadow') return [{ name, type, css: value.map((s) => [s.inset ? 'inset' : '', s.offsetX, s.offsetY, s.blur, s.spread, s.color].filter(Boolean).join(' ')).join(', '), kind: 'shadow', key: shadowKey(value) }]
|
|
155
|
+
if (value && typeof value === 'object') {
|
|
156
|
+
const out = []
|
|
157
|
+
for (const k of Object.keys(value)) {
|
|
158
|
+
if (value[k] == null) continue
|
|
159
|
+
const t = k === 'color' ? 'color' : k === 'fontFamily' ? 'fontFamily' : k === 'fontWeight' ? 'fontWeight' : k === 'lineHeight' ? 'lineHeight' : k === 'letterSpacing' ? 'letterSpacing' : k === 'fontSize' ? 'fontSize' : k === 'width' ? 'borderWidth' : 'dimension'
|
|
160
|
+
out.push(...primitives(name + '.' + k, t, value[k]))
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
const s = value == null ? '' : String(value)
|
|
165
|
+
if (type === 'fontFamily' || /font.?family/i.test(name)) return [{ name, type, css: s, kind: 'font', key: s.split(',')[0].trim().replace(/^["']|["']$/g, '').toLowerCase() }]
|
|
166
|
+
if (type === 'fontWeight' || (/weight/i.test(name) && /^\d+$/.test(s))) return [{ name, type: 'fontWeight', css: s, kind: 'weight', key: +s }]
|
|
167
|
+
if (/^-?[\d.]+$/.test(s) && (type === 'number' || type === 'lineHeight' || /line|leading/i.test(name))) return [{ name, type: type || 'number', css: s, kind: 'number', key: +s }]
|
|
168
|
+
if (type === 'color' || (!type && !/^-?[\d.]/.test(s) && rgba(s))) { const c = hex(s); return c ? [{ name, type: 'color', css: s, kind: 'color', key: c }] : [] }
|
|
169
|
+
if (/em$/.test(s) && !/rem$/.test(s) && (type === 'letterSpacing' || /letter|tracking/i.test(name))) return [{ name, type: 'letterSpacing', css: s, kind: 'em', key: parseFloat(s) }]
|
|
170
|
+
const d = px(s)
|
|
171
|
+
if (d != null && type !== 'duration') return [{ name, type: type || 'dimension', css: s, kind: 'dim', key: d }]
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
/** The token set as entries [{ name, type, css, mode, kind, key, deprecated }] and its modes. */
|
|
175
|
+
function readTokens (set) {
|
|
176
|
+
if (!set || typeof set !== 'object') return { entries: [], modes: [] }
|
|
177
|
+
const docs = modeDocs(set)
|
|
178
|
+
const per = docs.map((d) => ({ mode: d.mode, flat: flatOne(d.doc) }))
|
|
179
|
+
const names = [...new Set(per.flatMap((p) => [...p.flat.keys()]))]
|
|
180
|
+
const entries = []
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
const vals = per.filter((p) => p.flat.has(name)).map((p) => ({ mode: p.mode, t: p.flat.get(name) }))
|
|
183
|
+
const same = vals.every((v) => JSON.stringify(v.t.value) === JSON.stringify(vals[0].t.value))
|
|
184
|
+
for (const v of same ? [{ mode: null, t: vals[0].t }] : vals) for (const e of primitives(name, v.t.type, v.t.value)) entries.push(Object.assign(e, { mode: v.mode, deprecated: v.t.deprecated }))
|
|
185
|
+
}
|
|
186
|
+
return { entries, modes: docs.map((d) => d.mode).filter(Boolean) }
|
|
187
|
+
}
|
|
188
|
+
const TOK = readTokens(o.tokens)
|
|
189
|
+
const live = TOK.entries.filter((e) => !e.deprecated)
|
|
190
|
+
// a token's custom property: color.brand.primary → --color-brand-primary
|
|
191
|
+
const varName = (name) => '--' + name.replace(/\./g, '-').replace(/[^\w-]/g, '-')
|
|
192
|
+
const byVar = new Map()
|
|
193
|
+
for (const e of TOK.entries) byVar.set(varName(e.name).toLowerCase(), e.name)
|
|
194
|
+
|
|
195
|
+
// which token names suit a CSS property (an 8px radius is not an 8px spacing)
|
|
196
|
+
function kindOf (prop) {
|
|
197
|
+
if (/color|^fill$|^stroke$/.test(prop)) return { kind: 'color' }
|
|
198
|
+
if (prop === 'font-family') return { kind: 'font' }
|
|
199
|
+
if (prop === 'font-weight') return { kind: 'weight' }
|
|
200
|
+
if (prop === 'box-shadow') return { kind: 'shadow' }
|
|
201
|
+
if (/radius/.test(prop)) return { kind: 'dim', fit: (e) => /radius|round|corner/i.test(e.name) || e.type === 'borderRadius' }
|
|
202
|
+
if (prop === 'font-size') return { kind: 'dim', fit: (e) => e.type === 'fontSize' || (/font|text|type/i.test(e.name) && !/line|leading|letter|weight/i.test(e.name)) }
|
|
203
|
+
if (prop === 'line-height') return { kind: 'dim', fit: (e) => e.type === 'lineHeight' || /line|leading/i.test(e.name) }
|
|
204
|
+
if (prop === 'letter-spacing') return { kind: 'dim', fit: (e) => e.type === 'letterSpacing' || /letter|tracking/i.test(e.name) }
|
|
205
|
+
if (/^(padding|margin)|gap$/.test(prop)) return { kind: 'dim', fit: (e) => e.type === 'spacing' || /space|spacing|gap|gutter|inset/i.test(e.name) || (/size/i.test(e.name) && !/font|text|type|line|radius/i.test(e.name)) }
|
|
206
|
+
if (/width$/.test(prop)) return { kind: 'dim', fit: (e) => e.type === 'borderWidth' || /border|stroke/i.test(e.name) }
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
const pref = (list) => list.find((e) => !e.mode) || list[0]
|
|
210
|
+
const tokOut = (m, e, extra) => Object.assign({ m, name: e.name, value: e.css }, e.mode ? { mode: e.mode } : {}, extra || {})
|
|
211
|
+
/**
|
|
212
|
+
* The token of one computed value: { m: 'exact', name, value, mode? } or { m: 'nearest', name, value } for a
|
|
213
|
+
* value outside the set, or { m: 'none' }; null when the value needs no token (0, none, transparent).
|
|
214
|
+
* ctx: { fontSize } for line-height and em letter-spacing.
|
|
215
|
+
*/
|
|
216
|
+
function match (prop, value, ctx) {
|
|
217
|
+
const k = kindOf(prop)
|
|
218
|
+
if (!k || value == null) return null
|
|
219
|
+
const v = String(value).trim()
|
|
220
|
+
if (!v || /^(none|normal|auto|0px|0|transparent)$/.test(v)) return null
|
|
221
|
+
if (!live.length) return null // no token set: nothing to say about tokens
|
|
222
|
+
if (k.kind === 'color') {
|
|
223
|
+
const c = rgba(v); if (!c || c[3] === 0) return null
|
|
224
|
+
const key = hexOf(c)
|
|
225
|
+
let best = null, dist = Infinity
|
|
226
|
+
for (const e of live) {
|
|
227
|
+
if (e.kind !== 'color') continue
|
|
228
|
+
if (e.key === key) return tokOut('exact', pref(live.filter((x) => x.kind === 'color' && x.key === key)))
|
|
229
|
+
const t = rgba(e.key); const d = Math.sqrt(Math.pow(t[0] - c[0], 2) + Math.pow(t[1] - c[1], 2) + Math.pow(t[2] - c[2], 2) + Math.pow((t[3] - c[3]) * 255, 2))
|
|
230
|
+
if (d < dist) { dist = d; best = e }
|
|
231
|
+
}
|
|
232
|
+
if (best && dist <= 1.8) return tokOut('exact', best) // rounding of srgb components
|
|
233
|
+
return best ? tokOut('nearest', best) : null
|
|
234
|
+
}
|
|
235
|
+
if (k.kind === 'font') {
|
|
236
|
+
const f = v.split(',')[0].trim().replace(/^["']|["']$/g, '').toLowerCase()
|
|
237
|
+
const fonts = live.filter((e) => e.kind === 'font')
|
|
238
|
+
const hit = fonts.filter((e) => e.key === f)
|
|
239
|
+
return hit.length ? tokOut('exact', pref(hit)) : fonts.length ? tokOut('nearest', fonts[0]) : null
|
|
240
|
+
}
|
|
241
|
+
if (k.kind === 'weight') {
|
|
242
|
+
const w = +v; const ws = live.filter((e) => e.kind === 'weight' || (e.kind === 'number' && /weight/i.test(e.name)))
|
|
243
|
+
const hit = ws.filter((e) => e.key === w)
|
|
244
|
+
if (hit.length) return tokOut('exact', pref(hit))
|
|
245
|
+
let best = null; for (const e of ws) if (!best || Math.abs(e.key - w) < Math.abs(best.key - w)) best = e
|
|
246
|
+
return best ? tokOut('nearest', best) : null
|
|
247
|
+
}
|
|
248
|
+
if (k.kind === 'shadow') {
|
|
249
|
+
const key = shadowOf(v)
|
|
250
|
+
const sh = live.filter((e) => e.kind === 'shadow')
|
|
251
|
+
const hit = sh.filter((e) => e.key === key)
|
|
252
|
+
return hit.length ? tokOut('exact', pref(hit)) : sh.length ? tokOut('nearest', sh[0]) : null
|
|
253
|
+
}
|
|
254
|
+
// dimensions; line-height also matches unitless tokens (1.5 × font size), letter-spacing em tokens
|
|
255
|
+
const n = px(v); if (n == null || n === 0) return null
|
|
256
|
+
const fs = (ctx && ctx.fontSize) || rootPx()
|
|
257
|
+
const valueOf = (e) => e.kind === 'dim' ? e.key : e.kind === 'number' && prop === 'line-height' ? e.key * fs : e.kind === 'em' && prop === 'letter-spacing' ? e.key * fs : null
|
|
258
|
+
let pool = live.filter((e) => valueOf(e) != null)
|
|
259
|
+
// a set without tokens of this kind (no line heights, no border widths) has nothing to say about the value
|
|
260
|
+
const fitting = k.fit ? pool.filter(k.fit) : pool
|
|
261
|
+
if (!fitting.length) return null
|
|
262
|
+
pool = fitting
|
|
263
|
+
const hit = pool.filter((e) => Math.abs(valueOf(e) - n) < 0.05)
|
|
264
|
+
if (hit.length) return tokOut('exact', pref(hit))
|
|
265
|
+
let best = null; for (const e of pool) if (!best || Math.abs(valueOf(e) - n) < Math.abs(valueOf(best) - n)) best = e
|
|
266
|
+
return best ? tokOut('nearest', best) : null
|
|
267
|
+
}
|
|
268
|
+
function shadowOf (v) {
|
|
269
|
+
return splitTop(v, ',').map((layer) => {
|
|
270
|
+
const inset = /\binset\b/.test(layer)
|
|
271
|
+
let color = ''
|
|
272
|
+
const rest = layer.replace(/\binset\b/, '').replace(/(rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-f]{3,8}\b/i, (m) => { color = m; return ' ' })
|
|
273
|
+
const nums = rest.trim().split(/\s+/).filter(Boolean)
|
|
274
|
+
if (!color) { const c = nums.find((x) => px(x) == null); if (c) { color = c; nums.splice(nums.indexOf(c), 1) } }
|
|
275
|
+
const l = nums.map((x) => r1(px(x) || 0)); while (l.length < 4) l.push(0)
|
|
276
|
+
return (inset ? 'inset ' : '') + l.slice(0, 4).join(' ') + ' ' + (hex(color) || '')
|
|
277
|
+
}).join(', ')
|
|
278
|
+
}
|
|
279
|
+
function splitTop (s, sep) {
|
|
280
|
+
const out = []; let depth = 0, cur = ''
|
|
281
|
+
for (const ch of String(s)) { if (ch === '(' || ch === '[') depth++; else if (ch === ')' || ch === ']') depth--; if (ch === sep && depth === 0) { out.push(cur.trim()); cur = '' } else cur += ch }
|
|
282
|
+
if (cur.trim()) out.push(cur.trim())
|
|
283
|
+
return out
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------- stylesheets: authored values and interaction states ----------------
|
|
287
|
+
let rules = null
|
|
288
|
+
/** Style rules of the page in order, @media rules that apply included; linked sheets the CSSOM hides are fetched. */
|
|
289
|
+
async function loadRules () {
|
|
290
|
+
if (rules) return rules
|
|
291
|
+
const out = []
|
|
292
|
+
const walk = (list, media) => {
|
|
293
|
+
for (const r of list) {
|
|
294
|
+
if (r.selectorText != null && r.style) out.push({ sel: r.selectorText, style: r.style, media, i: out.length })
|
|
295
|
+
else if (r.media && r.cssRules) { if (matchMedia(r.media.mediaText).matches) walk(r.cssRules, r.media.mediaText) } else if (r.cssRules && !r.selectorText) walk(r.cssRules, media)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const sheet of [...document.styleSheets]) {
|
|
299
|
+
const own = sheet.ownerNode
|
|
300
|
+
if (own && own.hasAttribute && (own.hasAttribute('data-kaloko-states') || own.hasAttribute('data-qawalk-overlay'))) continue
|
|
301
|
+
let list = null
|
|
302
|
+
try { list = sheet.cssRules } catch (e) {}
|
|
303
|
+
if (!list && sheet.href) {
|
|
304
|
+
try { const text = await (await fetch(sheet.href)).text(); const s = new CSSStyleSheet(); s.replaceSync(text.replace(/@import[^;]+;/g, '')); list = s.cssRules } catch (e) {}
|
|
305
|
+
}
|
|
306
|
+
if (list) walk(list, null)
|
|
307
|
+
}
|
|
308
|
+
rules = out
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
function specificity (sel) {
|
|
312
|
+
const s = sel.replace(/:(not|is|has)\(/g, '(').replace(/:where\([^)]*\)/g, '').replace(/::[\w-]+/g, '')
|
|
313
|
+
const a = (s.match(/#[\w-]+/g) || []).length
|
|
314
|
+
const b = (s.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+/g) || []).length
|
|
315
|
+
const c = (s.match(/(^|[\s>+~(])[a-zA-Z][\w-]*/g) || []).length
|
|
316
|
+
return a * 10000 + b * 100 + c
|
|
317
|
+
}
|
|
318
|
+
const safeMatches = (el, sel) => { try { return el.matches(sel) } catch (e) { return false } }
|
|
319
|
+
const STATE_RE = { hover: /:hover(?![\w-])/, focus: /:focus(-visible|-within)?(?![\w-])/, active: /:active(?![\w-])/, disabled: /:disabled(?![\w-])|\[disabled\]|\[aria-disabled=["']?true["']?\]/ }
|
|
320
|
+
const ANY_STATE = /:(hover|focus-visible|focus-within|focus|active|disabled)(?![\w-])|\[disabled\]|\[aria-disabled=["']?true["']?\]/g
|
|
321
|
+
const HAS_STATE = new RegExp(ANY_STATE.source)
|
|
322
|
+
/** A selector with the state pseudo-classes outside parentheses removed (`button:not(:disabled):hover` → `button:not(:disabled)`). */
|
|
323
|
+
function stripStates (sel, replace) {
|
|
324
|
+
let out = '', depth = 0, i = 0
|
|
325
|
+
while (i < sel.length) {
|
|
326
|
+
const ch = sel[i]
|
|
327
|
+
if (depth === 0 && (ch === ':' || ch === '[')) {
|
|
328
|
+
ANY_STATE.lastIndex = i
|
|
329
|
+
const m = ANY_STATE.exec(sel)
|
|
330
|
+
if (m && m.index === i) { out += replace ? replace(m[0]) : ''; i += m[0].length; continue }
|
|
331
|
+
}
|
|
332
|
+
if (ch === '(') depth++; else if (ch === ')') depth--
|
|
333
|
+
out += ch; i++
|
|
334
|
+
}
|
|
335
|
+
ANY_STATE.lastIndex = 0
|
|
336
|
+
out = out.trim()
|
|
337
|
+
return !out || /[>+~]$/.test(out) ? out + '*' : out.replace(/\s+$/, '')
|
|
338
|
+
}
|
|
339
|
+
const ownState = (part) => { let f = false; stripStates(part, (m) => { f = true; return m }); return f }
|
|
340
|
+
const stateOf = (pseudo) => /hover/.test(pseudo) ? 'hover' : /focus/.test(pseudo) ? 'focus' : /active/.test(pseudo) ? 'active' : 'disabled'
|
|
341
|
+
/** Matching rules of an element (the base state), most specific last. */
|
|
342
|
+
function matched (el) {
|
|
343
|
+
const out = []
|
|
344
|
+
for (const r of (rules || []).concat(forced ? forced.rules : [])) {
|
|
345
|
+
let best = -1
|
|
346
|
+
for (const part of splitTop(r.sel, ',')) { if (HAS_STATE.test(part) && ownState(part)) continue; if (safeMatches(el, part)) best = Math.max(best, specificity(part)) }
|
|
347
|
+
if (best >= 0) out.push({ r, spec: best })
|
|
348
|
+
}
|
|
349
|
+
return out.sort((a, b) => a.spec - b.spec || a.r.i - b.r.i)
|
|
350
|
+
}
|
|
351
|
+
const SHORT = { 'padding-top': ['padding', 0], 'padding-right': ['padding', 1], 'padding-bottom': ['padding', 2], 'padding-left': ['padding', 3], 'margin-top': ['margin', 0], 'margin-right': ['margin', 1], 'margin-bottom': ['margin', 2], 'margin-left': ['margin', 3], 'border-top-left-radius': ['border-radius', 0], 'border-top-right-radius': ['border-radius', 1], 'border-bottom-right-radius': ['border-radius', 2], 'border-bottom-left-radius': ['border-radius', 3], 'border-top-color': ['border-color', 0], 'border-top-width': ['border-width', 0], 'background-color': ['background', -1], 'row-gap': ['gap', 0], 'column-gap': ['gap', 1] }
|
|
352
|
+
const INHERITED = /^(color|font-|line-height|letter-spacing)/
|
|
353
|
+
function sidePart (value, idx) {
|
|
354
|
+
const parts = splitTop(value, ' ')
|
|
355
|
+
if (idx < 0) return (value.match(/var\(--[\w-]+[^)]*\)/) || [null])[0] || value
|
|
356
|
+
if (idx <= 1 && parts.length <= 2 && parts.length > 0) return parts[idx % parts.length] || parts[0]
|
|
357
|
+
const four = parts.length === 1 ? [parts[0], parts[0], parts[0], parts[0]] : parts.length === 2 ? [parts[0], parts[1], parts[0], parts[1]] : parts.length === 3 ? [parts[0], parts[1], parts[2], parts[1]] : parts
|
|
358
|
+
return four[idx]
|
|
359
|
+
}
|
|
360
|
+
/** What the author wrote for a property of an element (inline style, then the cascade, then inherited): a string, or null. */
|
|
361
|
+
function authored (el, prop) {
|
|
362
|
+
const sh = SHORT[prop]
|
|
363
|
+
for (let e = el, hops = 0; e && e.nodeType === 1 && hops < 30; e = e.parentElement, hops++) {
|
|
364
|
+
const decl = (style) => { let v = style.getPropertyValue(prop); if (v) return v.trim(); if (sh) { v = style.getPropertyValue(sh[0]); if (v) return sidePart(v.trim(), sh[1]) } return null }
|
|
365
|
+
const inline = e.style && decl(e.style)
|
|
366
|
+
if (inline) return { v: inline, own: e === el }
|
|
367
|
+
const list = matched(e)
|
|
368
|
+
for (let i = list.length - 1; i >= 0; i--) { const v = decl(list[i].r.style); if (v) return { v, own: e === el } }
|
|
369
|
+
if (!INHERITED.test(prop)) return null
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
/** The token an authored var() names: directly (--color-brand-primary) or through one custom property in between. */
|
|
374
|
+
function tokenOfVar (value) {
|
|
375
|
+
const m = String(value || '').match(/^var\(\s*(--[\w-]+)/)
|
|
376
|
+
if (!m) return null
|
|
377
|
+
const direct = byVar.get(m[1].toLowerCase())
|
|
378
|
+
if (direct) return { name: direct, via: m[1] }
|
|
379
|
+
for (const r of rules || []) { const v = r.style.getPropertyValue(m[1]); const n = v && v.trim().match(/^var\(\s*(--[\w-]+)/); if (n && byVar.get(n[1].toLowerCase())) return { name: byVar.get(n[1].toLowerCase()), via: m[1] } }
|
|
380
|
+
return { name: null, via: m[1] }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Interaction states the page's CSS defines for an element (rules with :hover, :focus, :active, :disabled). */
|
|
384
|
+
function statesOf (el) {
|
|
385
|
+
const out = new Set()
|
|
386
|
+
for (const r of rules || []) {
|
|
387
|
+
if (!HAS_STATE.test(r.sel)) continue
|
|
388
|
+
for (const part of splitTop(r.sel, ',')) {
|
|
389
|
+
// only states of the element itself count: :not(:disabled) inside parentheses is a condition, not a state
|
|
390
|
+
const found = []
|
|
391
|
+
const base = stripStates(part, (m) => { found.push(stateOf(m)); return '' })
|
|
392
|
+
if (found.length && safeMatches(el, base)) found.forEach((s) => out.add(s))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return ['hover', 'focus', 'active', 'disabled'].filter((s) => out.has(s))
|
|
396
|
+
}
|
|
397
|
+
let forced = null
|
|
398
|
+
/** Renders an element in a state: the state's rules rewritten to attribute selectors on the element (and its
|
|
399
|
+
ancestors for hover), transitions off; `default` restores the page. */
|
|
400
|
+
function forceState (el, state) {
|
|
401
|
+
if (forced) {
|
|
402
|
+
forced.style.remove()
|
|
403
|
+
for (const [node, attrs] of forced.saved) for (const [k, v] of attrs) { if (v == null) node.removeAttribute(k); else node.setAttribute(k, v) }
|
|
404
|
+
forced = null
|
|
405
|
+
}
|
|
406
|
+
if (!el || !state || state === 'default') return
|
|
407
|
+
const attr = 'data-kaloko-state'
|
|
408
|
+
const text = [], extra = []
|
|
409
|
+
for (const r of rules || []) {
|
|
410
|
+
if (!STATE_RE[state].test(r.sel)) continue
|
|
411
|
+
const parts = splitTop(r.sel, ',').filter((p) => STATE_RE[state].test(p)).map((p) => stripStates(p, (m) => stateOf(m) === state ? '[' + attr + '~="' + state + '"]' : ''))
|
|
412
|
+
if (!parts.length) continue
|
|
413
|
+
const body = r.style.cssText
|
|
414
|
+
text.push((r.media ? '@media ' + r.media + '{' : '') + parts.join(',') + '{' + body + '}' + (r.media ? '}' : ''))
|
|
415
|
+
extra.push({ sel: parts.join(','), style: r.style, media: r.media, i: 1e6 + r.i })
|
|
416
|
+
}
|
|
417
|
+
const style = document.createElement('style'); style.setAttribute('data-kaloko-states', ''); style.textContent = text.join('\n')
|
|
418
|
+
const saved = new Map()
|
|
419
|
+
const set = (node, k, v) => { if (!saved.has(node)) saved.set(node, new Map()); const m = saved.get(node); if (!m.has(k)) m.set(k, node.getAttribute(k)); node.setAttribute(k, v) }
|
|
420
|
+
const targets = state === 'hover' ? (function () { const a = []; for (let e = el; e && e.nodeType === 1; e = e.parentElement) a.push(e); return a })() : [el]
|
|
421
|
+
for (const t of targets) set(t, attr, ((t.getAttribute(attr) || '') + ' ' + state).trim())
|
|
422
|
+
if (state === 'disabled') { if ('disabled' in el) set(el, 'disabled', ''); set(el, 'aria-disabled', 'true') }
|
|
423
|
+
set(el, 'style', (el.getAttribute('style') || '') + ';transition:none!important;animation:none!important')
|
|
424
|
+
document.head.appendChild(style)
|
|
425
|
+
forced = { style, saved, rules: extra }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------- elements ----------------
|
|
429
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none' && +cs.opacity > 0 }
|
|
430
|
+
const ident = (s) => /^[A-Za-z_][\w-]*$/.test(s)
|
|
431
|
+
function selectorOf (el) {
|
|
432
|
+
if (el.id && ident(el.id)) return '#' + el.id
|
|
433
|
+
const parts = []
|
|
434
|
+
for (let e = el; e && e.nodeType === 1 && e !== document.body && e !== document.documentElement && parts.length < 5; e = e.parentElement) {
|
|
435
|
+
if (e.id && ident(e.id)) { parts.unshift('#' + e.id); break }
|
|
436
|
+
const q = e.getAttribute('data-testid') || e.getAttribute('data-kaloko') || e.getAttribute('data-qawalk')
|
|
437
|
+
let s = e.tagName.toLowerCase()
|
|
438
|
+
if (q) { const a = e.hasAttribute('data-testid') ? 'data-testid' : e.hasAttribute('data-kaloko') ? 'data-kaloko' : 'data-qawalk'; parts.unshift(s + '[' + a + '="' + q.replace(/["\\]/g, '') + '"]'); break }
|
|
439
|
+
const cls = [...e.classList].filter(ident).slice(0, 2)
|
|
440
|
+
if (cls.length) s += '.' + cls.join('.')
|
|
441
|
+
const sib = e.parentElement ? [...e.parentElement.children].filter((x) => x.tagName === e.tagName) : []
|
|
442
|
+
if (sib.length > 1 && !(cls.length && e.parentElement.querySelectorAll(':scope > ' + s).length === 1)) s += ':nth-of-type(' + (sib.indexOf(e) + 1) + ')'
|
|
443
|
+
parts.unshift(s)
|
|
444
|
+
}
|
|
445
|
+
return parts.join(' > ')
|
|
446
|
+
}
|
|
447
|
+
const ROLES = { button: 'button', nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo', aside: 'complementary', form: 'form', ul: 'list', ol: 'list', li: 'listitem', table: 'table', dialog: 'dialog', select: 'combobox', textarea: 'textbox', img: 'img', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', label: 'label', p: 'paragraph', svg: 'img' }
|
|
448
|
+
function roleOf (el) {
|
|
449
|
+
const r = el.getAttribute('role'); if (r) return r.split(/\s+/)[0]
|
|
450
|
+
const t = el.tagName.toLowerCase()
|
|
451
|
+
if (t === 'a') return el.hasAttribute('href') ? 'link' : null
|
|
452
|
+
if (t === 'input') { const ty = (el.getAttribute('type') || 'text').toLowerCase(); return /^(button|submit|reset|image)$/.test(ty) ? 'button' : ty === 'checkbox' ? 'checkbox' : ty === 'radio' ? 'radio' : ty === 'range' ? 'slider' : ty === 'hidden' ? null : 'textbox' }
|
|
453
|
+
if (t === 'section' && (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'))) return 'region'
|
|
454
|
+
return ROLES[t] || null
|
|
455
|
+
}
|
|
456
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim()
|
|
457
|
+
function nameOf (el) {
|
|
458
|
+
const lab = el.getAttribute('aria-label'); if (lab) return clean(lab).slice(0, 80)
|
|
459
|
+
const by = el.getAttribute('aria-labelledby'); if (by) { const t = by.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((x) => x.innerText).join(' '); if (clean(t)) return clean(t).slice(0, 80) }
|
|
460
|
+
const t = el.tagName.toLowerCase()
|
|
461
|
+
if (t === 'img') return clean(el.getAttribute('alt')).slice(0, 80)
|
|
462
|
+
if (/^(input|select|textarea)$/.test(t)) { const l = el.labels && el.labels[0]; return clean((l && l.innerText) || el.getAttribute('placeholder') || (/^(button|submit|reset)$/.test(el.type) ? el.value : '') || el.name).slice(0, 80) }
|
|
463
|
+
if (t === 'svg') { const ti = el.querySelector('title'); return ti ? clean(ti.textContent).slice(0, 80) : '' }
|
|
464
|
+
return clean(el.innerText).slice(0, 80)
|
|
465
|
+
}
|
|
466
|
+
const ownText = (el) => [...el.childNodes].some((x) => x.nodeType === 3 && x.textContent.trim())
|
|
467
|
+
const quad = (a) => a[0] === a[1] && a[1] === a[2] && a[2] === a[3] ? [a[0]] : a[0] === a[2] && a[1] === a[3] ? [a[0], a[1]] : a[1] === a[3] ? [a[0], a[1], a[2]] : a
|
|
468
|
+
/** Luminance contrast of the element's text against what is behind it (background images are ignored). */
|
|
469
|
+
function contrastOf (el, cs) {
|
|
470
|
+
const fg = rgba(cs.color); if (!fg) return null
|
|
471
|
+
const layers = []; let approx = false
|
|
472
|
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const s = getComputedStyle(e); if (s.backgroundImage && s.backgroundImage !== 'none') approx = true; const c = rgba(s.backgroundColor); if (c && c[3] > 0) { layers.push(c); if (c[3] >= 1) break } }
|
|
473
|
+
let bg = [255, 255, 255]
|
|
474
|
+
for (const c of layers.reverse()) bg = [0, 1, 2].map((i) => c[i] * c[3] + bg[i] * (1 - c[3]))
|
|
475
|
+
const text = [0, 1, 2].map((i) => fg[i] * fg[3] + bg[i] * (1 - fg[3]))
|
|
476
|
+
const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
|
|
477
|
+
const [a, b] = [lum(text), lum(bg)].sort((x, y) => y - x)
|
|
478
|
+
const ratio = Math.round((a + 0.05) / (b + 0.05) * 100) / 100
|
|
479
|
+
const size = parseFloat(cs.fontSize), weight = +cs.fontWeight || 400
|
|
480
|
+
const large = size >= 24 || (size >= 18.66 && weight >= 700)
|
|
481
|
+
return Object.assign({ ratio, fg: hexOf(text), bg: hexOf(bg), large, aa: ratio >= (large ? 3 : 4.5), aaa: ratio >= (large ? 4.5 : 7) }, approx ? { approx: true } : {})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* One row per property worth knowing: { g: group, prop, value, tok, parts?, auth? }. `tok` as match() returns;
|
|
486
|
+
* when the author wrote var(--token) the row says so (m 'exact', via); a raw value equal to a token is `hard`.
|
|
487
|
+
*/
|
|
488
|
+
function rowsOf (el, all) {
|
|
489
|
+
const cs = getComputedStyle(el)
|
|
490
|
+
const fs = parseFloat(cs.fontSize)
|
|
491
|
+
const rows = []
|
|
492
|
+
const text = ownText(el) || /^(input|textarea|select|button)$/i.test(el.tagName)
|
|
493
|
+
const tokFor = (prop, value) => {
|
|
494
|
+
let t = match(prop, value, { fontSize: fs })
|
|
495
|
+
if (!t) return null
|
|
496
|
+
const au = rules ? authored(el, prop) : null
|
|
497
|
+
const a = au && au.v
|
|
498
|
+
if (a && /^var\(/.test(a)) {
|
|
499
|
+
const v = tokenOfVar(a)
|
|
500
|
+
if (v && v.name) { const e = TOK.entries.find((x) => x.name === v.name); t = Object.assign({ m: 'exact', name: v.name, value: e ? e.css : value }, e && e.mode ? { mode: e.mode } : {}, { via: v.via }) } else if (v) t = Object.assign({}, t, { via: v.via })
|
|
501
|
+
} else if (a && au.own && t.m === 'exact') t = Object.assign({}, t, { hard: a })
|
|
502
|
+
return t
|
|
503
|
+
}
|
|
504
|
+
const add = (g, prop, value, tok) => rows.push(Object.assign({ g, prop, value: value == null ? '' : String(value) }, tok ? { tok } : {}))
|
|
505
|
+
const one = (g, prop, value) => add(g, prop, value, tokFor(prop, value))
|
|
506
|
+
const sides = (g, prop, longs, fmt) => {
|
|
507
|
+
const vals = longs.map((p) => cs.getPropertyValue(p))
|
|
508
|
+
if (vals.every((v) => !v || parseFloat(v) === 0)) return
|
|
509
|
+
const q = quad(vals)
|
|
510
|
+
const toks = longs.map((p, i) => tokFor(p, vals[i]))
|
|
511
|
+
const idx = q.length === 1 ? [0] : q.length === 2 ? [0, 1] : q.length === 3 ? [0, 1, 2] : [0, 1, 2, 3]
|
|
512
|
+
const parts = idx.map((i) => toks[i])
|
|
513
|
+
add(g, prop, q.map(fmt || ((x) => x)).join(' '), null)
|
|
514
|
+
rows[rows.length - 1].parts = parts.map((t, i) => Object.assign({ value: q[i] }, t ? { tok: t } : {}))
|
|
515
|
+
}
|
|
516
|
+
// typography
|
|
517
|
+
if (text || all) {
|
|
518
|
+
one('type', 'font-family', cs.fontFamily)
|
|
519
|
+
one('type', 'font-size', cs.fontSize)
|
|
520
|
+
one('type', 'font-weight', cs.fontWeight)
|
|
521
|
+
if (cs.lineHeight !== 'normal') one('type', 'line-height', cs.lineHeight)
|
|
522
|
+
if (cs.letterSpacing !== 'normal' && parseFloat(cs.letterSpacing) !== 0) one('type', 'letter-spacing', cs.letterSpacing)
|
|
523
|
+
if (all && cs.textTransform !== 'none') add('type', 'text-transform', cs.textTransform)
|
|
524
|
+
if (all && cs.textAlign !== 'start' && cs.textAlign !== 'left') add('type', 'text-align', cs.textAlign)
|
|
525
|
+
}
|
|
526
|
+
// colors
|
|
527
|
+
if (text || all) add('color', 'color', hex(cs.color), tokFor('color', cs.color))
|
|
528
|
+
const bg = rgba(cs.backgroundColor)
|
|
529
|
+
if (bg && bg[3] > 0) add('color', 'background-color', hexOf(bg), tokFor('background-color', cs.backgroundColor))
|
|
530
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none' && all) add('color', 'background-image', cs.backgroundImage.slice(0, 200))
|
|
531
|
+
const bw = parseFloat(cs.borderTopWidth) || parseFloat(cs.borderRightWidth) || parseFloat(cs.borderBottomWidth) || parseFloat(cs.borderLeftWidth)
|
|
532
|
+
if (bw && cs.borderTopStyle !== 'none') add('color', 'border-color', hex(cs.borderTopColor), tokFor('border-top-color', cs.borderTopColor))
|
|
533
|
+
// spacing
|
|
534
|
+
sides('space', 'padding', ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'])
|
|
535
|
+
if (all) sides('space', 'margin', ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'])
|
|
536
|
+
const flexGrid = /flex|grid/.test(cs.display)
|
|
537
|
+
if (flexGrid && cs.rowGap !== 'normal' && (parseFloat(cs.rowGap) || parseFloat(cs.columnGap))) {
|
|
538
|
+
if (cs.rowGap === cs.columnGap) one('space', 'gap', cs.rowGap)
|
|
539
|
+
else { one('space', 'row-gap', cs.rowGap); one('space', 'column-gap', cs.columnGap) }
|
|
540
|
+
}
|
|
541
|
+
// size and shape
|
|
542
|
+
if (all) { const r = el.getBoundingClientRect(); add('size', 'width', fmtPx(r.width)); add('size', 'height', fmtPx(r.height)) }
|
|
543
|
+
if (bw && cs.borderTopStyle !== 'none') sides('shape', 'border-width', ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'])
|
|
544
|
+
sides('shape', 'border-radius', ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'])
|
|
545
|
+
if (cs.boxShadow && cs.boxShadow !== 'none') { const t = tokFor('box-shadow', cs.boxShadow); add('shape', 'box-shadow', cs.boxShadow.replace(/rgba?\([^)]*\)/g, (m) => hex(m)), t) }
|
|
546
|
+
if (+cs.opacity < 1) add('shape', 'opacity', cs.opacity)
|
|
547
|
+
// layout
|
|
548
|
+
if (all || flexGrid) {
|
|
549
|
+
if (all || flexGrid) add('layout', 'display', cs.display)
|
|
550
|
+
if (/flex/.test(cs.display)) { add('layout', 'flex-direction', cs.flexDirection); if (cs.flexWrap !== 'nowrap') add('layout', 'flex-wrap', cs.flexWrap); add('layout', 'justify-content', cs.justifyContent); add('layout', 'align-items', cs.alignItems) }
|
|
551
|
+
if (/grid/.test(cs.display)) { add('layout', 'grid-template-columns', cs.gridTemplateColumns.slice(0, 160)); if (cs.gridTemplateRows !== 'none') add('layout', 'grid-template-rows', cs.gridTemplateRows.slice(0, 160)); add('layout', 'align-items', cs.alignItems) }
|
|
552
|
+
if (all && cs.position !== 'static') add('layout', 'position', cs.position)
|
|
553
|
+
}
|
|
554
|
+
return rows
|
|
555
|
+
}
|
|
556
|
+
const box = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left + scrollX), Math.round(r.top + scrollY), Math.round(r.width), Math.round(r.height)] }
|
|
557
|
+
function assetsOf (el) {
|
|
558
|
+
const out = []
|
|
559
|
+
const list = [el, ...el.querySelectorAll('img, svg, picture source, video, [style*="url("], *')].slice(0, 400)
|
|
560
|
+
const seen = new Set()
|
|
561
|
+
for (const e of list) {
|
|
562
|
+
if (out.length >= 30) break
|
|
563
|
+
const t = e.tagName.toLowerCase()
|
|
564
|
+
if (t === 'svg' && !(e.parentElement && e.parentElement.closest('svg'))) {
|
|
565
|
+
const markup = e.outerHTML
|
|
566
|
+
if (markup.length <= 200000 && !seen.has(markup)) { seen.add(markup); const r = e.getBoundingClientRect(); out.push({ kind: 'svg', name: (nameOf(e) || 'icon').replace(/[^\w.-]+/g, '-').slice(0, 40) + '.svg', size: [Math.round(r.width), Math.round(r.height)], svg: markup }) }
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
if (t === 'img' && (e.currentSrc || e.src)) { const u = e.currentSrc || e.src; if (!seen.has(u)) { seen.add(u); out.push({ kind: 'img', url: u, name: decodeURIComponent(u.split(/[?#]/)[0].split('/').pop() || 'image').slice(0, 80), size: [e.naturalWidth, e.naturalHeight], alt: clean(e.getAttribute('alt')).slice(0, 120) }) } continue }
|
|
570
|
+
const bgi = getComputedStyle(e).backgroundImage
|
|
571
|
+
if (bgi && bgi !== 'none') for (const m of bgi.matchAll(/url\(["']?([^"')]+)["']?\)/g)) { if (seen.has(m[1]) || /^data:/.test(m[1]) && m[1].length > 200000) continue; seen.add(m[1]); out.push({ kind: 'background', url: m[1], name: decodeURIComponent(m[1].split(/[?#]/)[0].split('/').pop() || 'background').slice(0, 80) }) }
|
|
572
|
+
}
|
|
573
|
+
return out
|
|
574
|
+
}
|
|
575
|
+
/** Full description of one element for the canvas inspector (Dev mode). */
|
|
576
|
+
function describe (el, state) {
|
|
577
|
+
const cs = getComputedStyle(el)
|
|
578
|
+
const parent = el.parentElement && el.parentElement !== document.documentElement ? el.parentElement : null
|
|
579
|
+
const b = el.getBoundingClientRect()
|
|
580
|
+
let inset = null
|
|
581
|
+
if (parent) { const p = parent.getBoundingClientRect(); inset = [b.top - p.top, p.right - b.right, p.bottom - b.bottom, b.left - p.left].map((n) => Math.round(n)) }
|
|
582
|
+
return {
|
|
583
|
+
sel: selectorOf(el), tag: el.tagName.toLowerCase(), role: roleOf(el), name: nameOf(el), box: box(el), state: state || 'default',
|
|
584
|
+
rows: rowsOf(el, true),
|
|
585
|
+
contrast: ownText(el) || /^(input|textarea|select|button)$/i.test(el.tagName) ? contrastOf(el, cs) : null,
|
|
586
|
+
states: statesOf(el),
|
|
587
|
+
parent: parent ? { sel: selectorOf(parent), tag: parent.tagName.toLowerCase(), inset } : null,
|
|
588
|
+
assets: assetsOf(el)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const meaningful = (el, cs) => {
|
|
592
|
+
if (/^(script|style|template|noscript|head|meta|link|br|wbr)$/i.test(el.tagName)) return false
|
|
593
|
+
if (el.closest('[data-qawalk-overlay]')) return false
|
|
594
|
+
const role = roleOf(el)
|
|
595
|
+
if (role && !/^(paragraph|listitem|list|label)$/.test(role)) return true
|
|
596
|
+
if (ownText(el) || /^(img|svg|video|canvas|input|select|textarea)$/i.test(el.tagName)) return true
|
|
597
|
+
const bg = rgba(cs.backgroundColor)
|
|
598
|
+
return !!((bg && bg[3] > 0) || (parseFloat(cs.borderTopWidth) && cs.borderTopStyle !== 'none') || cs.boxShadow !== 'none' || parseFloat(cs.borderTopLeftRadius) || /flex|grid/.test(cs.display))
|
|
599
|
+
}
|
|
600
|
+
/** Rows as the compact spec form: { prop: { value, token?, mode?, nearest?, var?, hard? } }. */
|
|
601
|
+
function compact (rows, inherit) {
|
|
602
|
+
const out = {}
|
|
603
|
+
for (const r of rows) {
|
|
604
|
+
if (r.g === 'size') continue
|
|
605
|
+
const one = (t) => !t ? {} : t.m === 'exact' ? Object.assign({ token: t.name }, t.mode ? { mode: t.mode } : {}, t.hard ? { hard: true } : {}) : t.m === 'nearest' ? Object.assign({ nearest: t.name }, t.via ? { var: t.via } : {}) : t.via ? { var: t.via } : { token: null }
|
|
606
|
+
let entry
|
|
607
|
+
if (r.parts) {
|
|
608
|
+
const ts = r.parts.map((p) => p.tok)
|
|
609
|
+
entry = { value: r.value }
|
|
610
|
+
if (ts.every((t) => !t || t.m === 'exact')) { if (ts.some(Boolean)) { entry.token = ts.map((t) => t ? t.name : '0').join(' '); if (ts.some((t) => t && t.hard)) entry.hard = true } } else if (ts.some((t) => t && t.m === 'nearest')) entry.nearest = ts.map((t) => t ? t.name || '-' : '0').join(' '); else entry.token = null
|
|
611
|
+
} else entry = Object.assign({ value: r.value }, one(r.tok))
|
|
612
|
+
if (inherit && INHERITED.test(r.prop) && inherit[r.prop] && inherit[r.prop].value === entry.value) continue
|
|
613
|
+
out[r.prop] = entry
|
|
614
|
+
}
|
|
615
|
+
return out
|
|
616
|
+
}
|
|
617
|
+
const STATE_PROPS = ['color', 'background-color', 'border-color', 'box-shadow', 'opacity', 'text-decoration-line', 'outline-color', 'outline-width']
|
|
618
|
+
/** The page as the spec agents read: meaningful elements in document order with roles, names, styles → tokens. */
|
|
619
|
+
async function pageSpec () {
|
|
620
|
+
await loadRules()
|
|
621
|
+
const max = o.maxElements || 300
|
|
622
|
+
const elements = []
|
|
623
|
+
const idx = new Map()
|
|
624
|
+
// the page's own typography and text color (body): elements list only what differs from what they inherit
|
|
625
|
+
const page = compact(rowsOf(document.body, true).filter((r) => INHERITED.test(r.prop)))
|
|
626
|
+
const eff = []
|
|
627
|
+
const all = [...document.body.querySelectorAll('*')]
|
|
628
|
+
for (const el of all) {
|
|
629
|
+
if (elements.length >= max) break
|
|
630
|
+
const cs = getComputedStyle(el)
|
|
631
|
+
if (!visible(el) || !meaningful(el, cs)) continue
|
|
632
|
+
let p = el.parentElement; while (p && !idx.has(p)) p = p.parentElement
|
|
633
|
+
const parent = p ? idx.get(p) : -1
|
|
634
|
+
const rows = rowsOf(el, false)
|
|
635
|
+
const inherit = parent >= 0 ? eff[parent] : page
|
|
636
|
+
const styles = compact(rows, inherit)
|
|
637
|
+
const mine = Object.assign({}, inherit); for (const p of Object.keys(styles)) if (INHERITED.test(p)) mine[p] = styles[p]
|
|
638
|
+
eff.push(mine)
|
|
639
|
+
const item = { i: elements.length, parent, tag: el.tagName.toLowerCase(), role: roleOf(el), name: nameOf(el) || null, sel: selectorOf(el), box: box(el), styles }
|
|
640
|
+
if (!ownText(el) && item.role !== 'heading' && item.name && el.children.length) item.name = null // a container's text is its children's
|
|
641
|
+
if (ownText(el) || /^(input|textarea|select|button)$/i.test(el.tagName)) { const c = contrastOf(el, cs); if (c) item.contrast = { ratio: c.ratio, aa: c.aa } }
|
|
642
|
+
const states = /^(a|button|input|select|textarea|summary)$/i.test(el.tagName) || el.hasAttribute('tabindex') || /button|link|tab|menuitem|checkbox|switch/.test(item.role || '') ? statesOf(el) : []
|
|
643
|
+
if (states.length) {
|
|
644
|
+
item.states = {}
|
|
645
|
+
const base = {}; for (const r of rows) base[r.prop] = r.value
|
|
646
|
+
for (const s of states) {
|
|
647
|
+
forceState(el, s)
|
|
648
|
+
const diff = {}
|
|
649
|
+
for (const r of rowsOf(el, false)) if (STATE_PROPS.includes(r.prop) && base[r.prop] !== r.value) diff[r.prop] = compact([r])[r.prop]
|
|
650
|
+
const cs2 = getComputedStyle(el)
|
|
651
|
+
for (const p of ['opacity', 'text-decoration-line', 'outline-width']) { const v = cs2.getPropertyValue(p); if (!(p in base) && v !== cs.getPropertyValue(p)) diff[p] = { value: v } }
|
|
652
|
+
item.states[s] = diff
|
|
653
|
+
}
|
|
654
|
+
forceState(null)
|
|
655
|
+
}
|
|
656
|
+
const t = el.tagName.toLowerCase()
|
|
657
|
+
if (t === 'img') item.asset = { kind: 'img', src: (el.currentSrc || el.src || '').replace(location.origin, '').slice(0, 200), size: [el.naturalWidth, el.naturalHeight], alt: el.getAttribute('alt') }
|
|
658
|
+
if (t === 'svg') item.asset = { kind: 'svg' }
|
|
659
|
+
idx.set(el, item.i)
|
|
660
|
+
elements.push(item)
|
|
661
|
+
}
|
|
662
|
+
const u = usage(elements)
|
|
663
|
+
return {
|
|
664
|
+
schema: 'qawalk.spec.v1',
|
|
665
|
+
title: document.title, lang: document.documentElement.lang || null,
|
|
666
|
+
viewport: [innerWidth, innerHeight], size: [document.documentElement.scrollWidth, document.documentElement.scrollHeight],
|
|
667
|
+
tokens: TOK.entries.length ? { count: new Set(TOK.entries.map((e) => e.name)).size, modes: TOK.modes } : null,
|
|
668
|
+
page, elements, used: u.used, off: u.off
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** Tokens used by the elements (name → count) and raw values outside the set, from spec elements or the live page. */
|
|
672
|
+
function usage (elements) {
|
|
673
|
+
const used = {}, off = new Map()
|
|
674
|
+
const take = (prop, entry, sel) => {
|
|
675
|
+
if (!entry) return
|
|
676
|
+
if (entry.token) for (const n of String(entry.token).split(' ')) { if (n !== '0') used[n] = (used[n] || 0) + 1 }
|
|
677
|
+
if (!entry.token && (entry.nearest || entry.token === null || entry.hard)) {
|
|
678
|
+
const k = prop + '|' + entry.value
|
|
679
|
+
const cur = off.get(k) || { prop, value: entry.value, count: 0, nearest: entry.nearest || null, sel }
|
|
680
|
+
cur.count++; off.set(k, cur)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
for (const e of elements) for (const p of Object.keys(e.styles)) take(p, e.styles[p], e.sel)
|
|
684
|
+
return { used, off: [...off.values()].sort((a, b) => b.count - a.count).slice(0, 60) }
|
|
685
|
+
}
|
|
686
|
+
/** Live page usage for the canvas Tokens tab: tokens used, values outside the set, raw values that equal a token. */
|
|
687
|
+
async function liveUsage () {
|
|
688
|
+
await loadRules()
|
|
689
|
+
const elements = []
|
|
690
|
+
for (const el of [...document.body.querySelectorAll('*')].slice(0, 3000)) {
|
|
691
|
+
const cs = getComputedStyle(el)
|
|
692
|
+
if (!visible(el) || !meaningful(el, cs)) continue
|
|
693
|
+
elements.push({ sel: selectorOf(el), styles: compact(rowsOf(el, false)) })
|
|
694
|
+
}
|
|
695
|
+
const u = usage(elements)
|
|
696
|
+
// the stylesheet itself: raw values the author typed that equal a token (use the custom property instead)
|
|
697
|
+
const hard = []
|
|
698
|
+
const probe = document.createElement('div'); probe.style.display = 'none'; document.documentElement.appendChild(probe)
|
|
699
|
+
for (const r of rules || []) {
|
|
700
|
+
if (hard.length >= 60) break
|
|
701
|
+
for (let i = 0; i < r.style.length; i++) {
|
|
702
|
+
const prop = r.style[i]; const raw = r.style.getPropertyValue(prop)
|
|
703
|
+
if (!raw || /var\(/.test(raw) || prop.startsWith('--') || !kindOf(prop)) continue
|
|
704
|
+
probe.style.cssText = ''; probe.style.setProperty(prop, raw)
|
|
705
|
+
const t = match(prop, getComputedStyle(probe).getPropertyValue(prop))
|
|
706
|
+
if (t && t.m === 'exact') hard.push({ sel: r.sel.slice(0, 160), prop, value: kindOf(prop).kind === 'color' ? hex(raw) || raw.trim() : raw.trim(), token: t.name })
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
probe.remove()
|
|
710
|
+
const all = []; const seen = new Set()
|
|
711
|
+
for (const e of TOK.entries) { const k = e.name + '|' + (e.mode || ''); if (seen.has(k)) continue; seen.add(k); all.push({ name: e.name, value: String(e.css).slice(0, 120), kind: e.kind, mode: e.mode || null, deprecated: e.deprecated || false }) }
|
|
712
|
+
return { used: u.used, off: u.off, hard, all: all.slice(0, 600), modes: TOK.modes }
|
|
713
|
+
}
|
|
714
|
+
/** Elements to outline for a token name, or for a property and raw value. */
|
|
715
|
+
function elementsFor (q) {
|
|
716
|
+
const out = []
|
|
717
|
+
for (const el of [...document.body.querySelectorAll('*')].slice(0, 3000)) {
|
|
718
|
+
if (out.length >= 300) break
|
|
719
|
+
const cs = getComputedStyle(el)
|
|
720
|
+
if (!visible(el) || !meaningful(el, cs)) continue
|
|
721
|
+
const st = compact(rowsOf(el, false))
|
|
722
|
+
const hit = Object.keys(st).some((p) => q.token ? String(st[p].token || '').split(' ').includes(q.token) : p === q.prop && st[p].value === q.value)
|
|
723
|
+
if (hit) out.push(el)
|
|
724
|
+
}
|
|
725
|
+
return out
|
|
726
|
+
}
|
|
727
|
+
return { tokens: TOK, match, describe, pageSpec, liveUsage, elementsFor, statesOf, forceState, loadRules, selectorOf, varName, hex }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ---------------- Node side: reading a spec, and comparing an implementation with it ----------------
|
|
731
|
+
|
|
732
|
+
const show = (s) => s.token ? `${s.value}=${s.token}${s.mode ? `(${s.mode})` : ''}${s.hard ? ' (typed raw; use the custom property)' : ''}` : s.nearest ? `${s.value} (not a token; nearest ${s.nearest})` : s.var ? `${s.value} via ${s.var}` : s.token === null ? `${s.value} (not a token)` : String(s.value)
|
|
733
|
+
const SHORTP = { 'background-color': 'bg', 'font-family': 'font', 'font-size': 'size', 'font-weight': 'weight', 'line-height': 'leading', 'letter-spacing': 'tracking', 'border-radius': 'radius', 'border-color': 'border', 'border-width': 'border-width', 'box-shadow': 'shadow', padding: 'pad' }
|
|
734
|
+
/** An element as one line: role or tag, name, size, styles with tokens. */
|
|
735
|
+
export function elementLine (e) {
|
|
736
|
+
const head = `${e.role && e.role !== e.tag ? `${e.tag}[${e.role}]` : e.tag}${e.name ? ` "${e.name}"` : ''}`
|
|
737
|
+
const st = Object.entries(e.styles || {}).map(([p, s]) => `${SHORTP[p] || p} ${show(s)}`)
|
|
738
|
+
if (e.contrast) st.push(`contrast ${e.contrast.ratio}:1 ${e.contrast.aa ? 'AA' : 'fails AA'}`)
|
|
739
|
+
if (e.asset?.kind === 'img') st.push(`img ${e.asset.src}${e.asset.alt ? ` alt "${e.asset.alt}"` : ''}`)
|
|
740
|
+
return `${head} ${e.box ? `${e.box[2]}×${e.box[3]}` : ''}${st.length ? ` ${st.join(' · ')}` : ''} {${e.sel}}`
|
|
741
|
+
}
|
|
742
|
+
/** The spec as indented text: the format the CLI prints and MCP returns (token-efficient, readable). */
|
|
743
|
+
export function specText (spec, { title = '' } = {}) {
|
|
744
|
+
if (!spec?.elements) return 'No spec for this step: walk it again with the current CLI (design steps, or steps with packs: [design]).'
|
|
745
|
+
const depth = new Map()
|
|
746
|
+
const lines = [`${title ? title + ' · ' : ''}${spec.viewport ? `${spec.viewport[0]}×${spec.viewport[1]}` : ''} · ${spec.elements.length} elements · ${spec.tokens ? `${spec.tokens.count} tokens${spec.tokens.modes?.length ? ` (modes ${spec.tokens.modes.join(', ')})` : ''}` : 'no token set'}`]
|
|
747
|
+
if (spec.page && Object.keys(spec.page).length) lines.push(`page ${Object.entries(spec.page).map(([p, s]) => `${SHORTP[p] || p} ${show(s)}`).join(' · ')} (inherited unless an element says otherwise)`)
|
|
748
|
+
for (const e of spec.elements) {
|
|
749
|
+
const d = e.parent >= 0 ? (depth.get(e.parent) ?? 0) + 1 : 0
|
|
750
|
+
depth.set(e.i, d)
|
|
751
|
+
lines.push(' '.repeat(d) + elementLine(e))
|
|
752
|
+
for (const [s, diff] of Object.entries(e.states || {})) {
|
|
753
|
+
const parts = Object.entries(diff).map(([p, v]) => `${SHORTP[p] || p} ${show(v)}`)
|
|
754
|
+
lines.push(' '.repeat(d + 1) + `:${s} ${parts.length ? parts.join(' · ') : '(no visible change)'}`)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (spec.off?.length) lines.push('', 'Values outside the token set:', ...spec.off.slice(0, 20).map((o) => ` ${o.prop} ${o.value} ×${o.count}${o.nearest ? ` → nearest ${o.nearest}` : ''} {${o.sel}}`))
|
|
758
|
+
return lines.join('\n')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim()
|
|
762
|
+
const keyOf = (e) => e.role && e.name ? `${e.role}|${norm(e.name)}` : e.name && e.tag ? `${e.tag}|${norm(e.name)}` : ['main', 'navigation', 'banner', 'contentinfo', 'form', 'dialog', 'complementary'].includes(e.role) ? `${e.role}|` : null
|
|
763
|
+
const COMPARE = ['color', 'background-color', 'border-color', 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'padding', 'gap', 'row-gap', 'column-gap', 'border-radius', 'border-width', 'box-shadow']
|
|
764
|
+
const sameValue = (a, b) => norm(a) === norm(b) || (/^-?[\d.]+px$/.test(a) && /^-?[\d.]+px$/.test(b) && Math.abs(parseFloat(a) - parseFloat(b)) < 0.6)
|
|
765
|
+
/**
|
|
766
|
+
* Drift of an implementation spec from the approved design spec, in the spec's vocabulary: elements matched by
|
|
767
|
+
* role and accessible name (landmarks by role), then each style the design gives a token compared with what the
|
|
768
|
+
* implementation computes. Returns [{ element, prop, state?, expected: { token, value }, actual: { value, token? }, kind }].
|
|
769
|
+
* kind: missing (the element is not in the implementation), value (different value), raw (same value, typed raw
|
|
770
|
+
* instead of the token), state (an interaction state the design defines is missing or differs).
|
|
771
|
+
*/
|
|
772
|
+
export function specDrift (impl, ref) {
|
|
773
|
+
const out = []
|
|
774
|
+
if (!impl?.elements || !ref?.elements) return out
|
|
775
|
+
// what each element shows, inherited values included (the spec lists only what differs from the parent)
|
|
776
|
+
const effective = (spec) => { const eff = []; for (const e of spec.elements) { const up = e.parent >= 0 && eff[e.parent] ? eff[e.parent] : (spec.page || {}); const own = {}; for (const [p, v] of Object.entries(up)) if (/^(color|font-|line-height|letter-spacing)/.test(p)) own[p] = v; eff[e.i] = Object.assign(own, e.styles || {}) } return eff }
|
|
777
|
+
const implEff = effective(impl), refEff = effective(ref)
|
|
778
|
+
const pool = new Map()
|
|
779
|
+
for (const e of impl.elements) { const k = keyOf(e); if (k) { if (!pool.has(k)) pool.set(k, []); pool.get(k).push(e) } }
|
|
780
|
+
const label = (e) => `${e.role || e.tag}${e.name ? ` "${e.name}"` : ''}`
|
|
781
|
+
const used = new Map()
|
|
782
|
+
for (const r of ref.elements) {
|
|
783
|
+
const k = keyOf(r); if (!k) continue
|
|
784
|
+
const n = used.get(k) || 0; used.set(k, n + 1)
|
|
785
|
+
const m = pool.get(k)?.[n]
|
|
786
|
+
if (!m) { out.push({ element: label(r), ref: r.sel, kind: 'missing' }); continue }
|
|
787
|
+
const cmp = (rs, ms, state) => {
|
|
788
|
+
for (const p of COMPARE) {
|
|
789
|
+
const want = rs[p]; if (!want) continue
|
|
790
|
+
const got = ms[p]
|
|
791
|
+
if (!got) { if (want.token && !state) { /* inherited from a parent in the implementation: compare with nothing to say */ } else if (want.token && state) out.push({ element: label(r), sel: m.sel, prop: p, state, expected: { token: want.token, value: want.value }, actual: null, kind: 'state' }); continue }
|
|
792
|
+
if (!sameValue(want.value, got.value)) out.push({ element: label(r), sel: m.sel, prop: p, ...(state ? { state } : {}), expected: { token: want.token || null, value: want.value }, actual: { value: got.value, token: got.token || null, nearest: got.nearest || null }, kind: state ? 'state' : 'value' })
|
|
793
|
+
else if (want.token && got.hard && !want.hard) out.push({ element: label(r), sel: m.sel, prop: p, ...(state ? { state } : {}), expected: { token: want.token, value: want.value }, actual: { value: got.value }, kind: 'raw' })
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// text elements (they carry a contrast) are compared with what they show, inherited typography and color included
|
|
797
|
+
cmp(r.contrast ? refEff[r.i] || r.styles || {} : r.styles || {}, implEff[m.i] || m.styles || {}, null)
|
|
798
|
+
for (const [s, diff] of Object.entries(r.states || {})) {
|
|
799
|
+
if (!m.states?.[s]) { if (Object.keys(diff).length) out.push({ element: label(r), sel: m.sel, state: s, kind: 'state', expected: null, actual: null }); continue }
|
|
800
|
+
cmp(diff, m.states[s], s)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out
|
|
804
|
+
}
|
|
805
|
+
/** Drift findings as lines an agent can act on. */
|
|
806
|
+
export function driftText (findings, { title = '' } = {}) {
|
|
807
|
+
if (!findings.length) return `${title ? title + ': ' : ''}matches the approved design (roles, names, tokens and states).`
|
|
808
|
+
const lines = [`${title ? title + ': ' : ''}${findings.length} difference${findings.length === 1 ? '' : 's'} from the approved design`]
|
|
809
|
+
for (const f of findings) {
|
|
810
|
+
const exp = f.expected ? `${f.expected.token ? `token ${f.expected.token} (${f.expected.value})` : f.expected.value}` : ''
|
|
811
|
+
const act = f.actual ? `${f.actual.value}${f.actual.token ? `=${f.actual.token}` : f.actual.nearest ? ' (not a token)' : ''}` : ''
|
|
812
|
+
if (f.kind === 'missing') lines.push(`- ${f.element}: not found in the implementation (design: {${f.ref}})`)
|
|
813
|
+
else if (f.kind === 'raw') lines.push(`- ${f.element}${f.state ? ` :${f.state}` : ''}: ${f.prop} has the right value but is typed raw; use var(--${f.expected.token.replace(/\./g, '-')}) {${f.sel}}`)
|
|
814
|
+
else if (f.kind === 'state' && !f.prop) lines.push(`- ${f.element}: the design defines :${f.state}; the implementation does not {${f.sel}}`)
|
|
815
|
+
else lines.push(`- ${f.element}${f.state ? ` :${f.state}` : ''}: ${f.prop} expected ${exp}, got ${act || 'nothing'} {${f.sel}}`)
|
|
816
|
+
}
|
|
817
|
+
return lines.join('\n')
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Expression for page.evaluate that returns the page spec (async: linked stylesheets may need a fetch). */
|
|
821
|
+
export const pageSpecScript = (tokens, maxElements = 300) => `(${kalokoDev.toString()})(${JSON.stringify({ tokens: tokens || null, maxElements })}).pageSpec()`
|