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.
Files changed (51) hide show
  1. package/README.md +92 -5
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +33 -0
  4. package/schemas/scenario.v1.json +39 -0
  5. package/schemas/spec.v1.json +60 -0
  6. package/skills/kaloko/SKILL.md +24 -3
  7. package/skills/kaloko/reference/acceptance.md +50 -2
  8. package/skills/kaloko/reference/ci.md +70 -0
  9. package/skills/kaloko/reference/collaboration.md +9 -1
  10. package/skills/kaloko/reference/design.md +35 -2
  11. package/skills/kaloko/reference/environments.md +5 -0
  12. package/skills/kaloko/reference/evaluation.md +46 -3
  13. package/skills/kaloko/reference/execution.md +74 -0
  14. package/skills/kaloko/reference/import.md +66 -0
  15. package/skills/kaloko/reference/mcp.md +4 -1
  16. package/src/cli.mjs +428 -99
  17. package/src/lib/affected-core.mjs +242 -0
  18. package/src/lib/affected.mjs +233 -0
  19. package/src/lib/build.mjs +30 -5
  20. package/src/lib/capture.mjs +43 -1
  21. package/src/lib/ci.mjs +207 -0
  22. package/src/lib/config.mjs +2 -0
  23. package/src/lib/design-cli.mjs +7 -3
  24. package/src/lib/devspec.mjs +821 -0
  25. package/src/lib/doctor.mjs +7 -0
  26. package/src/lib/drift.mjs +16 -8
  27. package/src/lib/evaluate.mjs +98 -14
  28. package/src/lib/git.mjs +6 -2
  29. package/src/lib/heal.mjs +207 -0
  30. package/src/lib/import.mjs +374 -0
  31. package/src/lib/init-detect.mjs +219 -0
  32. package/src/lib/keyboard.mjs +261 -0
  33. package/src/lib/manifest-core.mjs +16 -5
  34. package/src/lib/onboarding.mjs +15 -8
  35. package/src/lib/pack-help.mjs +7 -0
  36. package/src/lib/packs.mjs +9 -2
  37. package/src/lib/png.mjs +61 -0
  38. package/src/lib/pr.mjs +24 -2
  39. package/src/lib/probe.mjs +57 -0
  40. package/src/lib/recheck.mjs +3 -1
  41. package/src/lib/scenario.mjs +2 -1
  42. package/src/lib/service.mjs +7 -1
  43. package/src/lib/spec-cli.mjs +90 -0
  44. package/src/lib/stability-core.mjs +114 -0
  45. package/src/lib/stability.mjs +249 -0
  46. package/src/lib/unzip.mjs +31 -0
  47. package/src/lib/upload.mjs +114 -0
  48. package/src/lib/vision-core.mjs +132 -0
  49. package/src/lib/vision.mjs +127 -0
  50. package/templates/kaloko.config.yml +6 -0
  51. 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()`