@mtdt/observeops-ds-spec 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # DS conformance checker
2
+
3
+ Verify that a page you built with the ObserveOps design system actually **renders** like the DS — not just that
4
+ you cited the right rules. It renders the page and scores it 0–100 on four dimensions, with a violations list:
5
+
6
+ - **Token adherence** — every rendered colour maps to a DS token (`tokens/variables.json`). Off-token colours are
7
+ reported with the nearest DS token, so `#4F6EF5`-instead-of-navy is caught.
8
+ - **Layout adherence** — paddings / radii land on the DS structural scale (`tokens/structural.json`).
9
+ - **Philosophy** — brand = `--primary` navy (not blue/cyan), no future `mds-*` tokens, theme-aware (surfaces flip
10
+ light↔dark).
11
+ - **Component adherence** — interactive controls carry a DS component/class signature (advisory).
12
+
13
+ ## Run it
14
+
15
+ ```bash
16
+ # from a tool that has Playwright installed (npm i -D playwright-core, or use `playwright`)
17
+ node conformance/ds-conformance.mjs ./your-page.html # or a URL
18
+ node conformance/ds-conformance.mjs ./your-page.html --json out.json --quiet
19
+ ```
20
+
21
+ Exit code `0` when the overall score ≥ 80, else `1`. Reads the token/scale sets from the sibling `tokens/`
22
+ directory shipped in this package. This is the render-time counterpart to the MCP `validate_usage` /
23
+ `validate_render` static checks — run it as the last step of the AI build workflow (`AGENTS.md` self-check).
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ds-conformance.mjs — score how well a rendered page conforms to the ObserveOps design system.
4
+ *
5
+ * Unlike match-component/diff.mjs (a 1:1 A/B comparator driven by hand-authored selectors), this is a
6
+ * SPEC-FREE membership checker: it renders ANY page (a URL or an HTML file), auto-enumerates every element,
7
+ * and measures conformance against the DS reference sets — the token palette (tokens/variables.json), the
8
+ * structural scale (tokens/structural.json), and the philosophy rules (tokens/purpose-map.json $rules).
9
+ *
10
+ * Four dimensions → a 0–100 score + a violations list:
11
+ * 1. Token adherence — every rendered colour maps to a DS token (else: off-token + nearest token).
12
+ * 2. Layout adherence — paddings / radii land on the structural scale.
13
+ * 3. Philosophy adherence — theme-aware (surfaces flip light↔dark), no `mds-*`, brand not blue/cyan.
14
+ * 4. Component adherence — interactive controls carry a DS component/class signature (advisory).
15
+ *
16
+ * Usage: node ds-conformance.mjs <url-or-file.html> [--theme light|dark] [--json <out>] [--quiet]
17
+ * env: CHROME (Chrome executable), UI_ROOT
18
+ *
19
+ * Reuses the diff.mjs engine idea: a tiny static server + Chromium + getComputedStyle extraction.
20
+ * Shipped in the spec package (conformance/) so external AI tools can self-verify their render.
21
+ */
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import http from 'node:http'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
28
+ const UI_ROOT = process.env.UI_ROOT || path.resolve(HERE, '..', '..')
29
+ const DS = path.resolve(HERE, '..')
30
+ const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
31
+
32
+ const args = process.argv.slice(2)
33
+ const target = args.find((a) => !a.startsWith('--'))
34
+ const theme = (args[args.indexOf('--theme') + 1] && args.includes('--theme')) ? args[args.indexOf('--theme') + 1] : 'light'
35
+ const jsonOut = args.includes('--json') ? args[args.indexOf('--json') + 1] : null
36
+ const QUIET = args.includes('--quiet')
37
+ if (!target) { console.error('usage: node ds-conformance.mjs <url-or-file.html> [--theme light|dark] [--json out] [--quiet]'); process.exit(2) }
38
+
39
+ // ── colour parsing / palette ─────────────────────────────────────────────────
40
+ const NAMED = { white: [255, 255, 255, 1], black: [0, 0, 0, 1], red: [255, 0, 0, 1], transparent: [0, 0, 0, 0] }
41
+ function parseColor(s) {
42
+ if (!s) return null
43
+ s = String(s).trim().toLowerCase()
44
+ if (NAMED[s]) return NAMED[s]
45
+ let m = s.match(/^#([0-9a-f]{3,8})$/)
46
+ if (m) {
47
+ let h = m[1]
48
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('')
49
+ if (h.length === 4) h = h.split('').map((c) => c + c).join('')
50
+ const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16)
51
+ const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1
52
+ return [r, g, b, a]
53
+ }
54
+ m = s.match(/^rgba?\(([^)]+)\)/)
55
+ if (m) {
56
+ const p = m[1].split(',').map((x) => parseFloat(x.trim()))
57
+ return [p[0] | 0, p[1] | 0, p[2] | 0, p[3] == null ? 1 : p[3]]
58
+ }
59
+ return null // gradients, fade(), none, shadows, non-colours
60
+ }
61
+ function buildPalette() {
62
+ const V = JSON.parse(fs.readFileSync(path.join(DS, 'tokens', 'variables.json'), 'utf8'))
63
+ const pal = []
64
+ for (const [name, v] of Object.entries(V)) {
65
+ if (name.startsWith('$')) continue
66
+ for (const k of ['light', 'dark']) {
67
+ const c = parseColor(v[k])
68
+ if (c) pal.push({ name, theme: k, c })
69
+ }
70
+ }
71
+ // kit-accents.json — the sanctioned Ant form-control accent (cyan @primary-color #099dd9, radio dot /
72
+ // checkbox check / select). These are legitimate DS colours; without them the cyan false-flags as off-token.
73
+ try {
74
+ const K = JSON.parse(fs.readFileSync(path.join(DS, 'tokens', 'kit-accents.json'), 'utf8'))
75
+ const walk = (o, name) => { for (const [k, v] of Object.entries(o)) { if (v && typeof v === 'object') walk(v, k); else { const c = parseColor(v); if (c) pal.push({ name: name || k, theme: 'kit', c }) } } }
76
+ walk(K, null)
77
+ } catch { /* optional */ }
78
+ return pal
79
+ }
80
+ const CYAN = [9, 157, 217] // @primary-color — the ONLY sanctioned accent-blue; not a brand-navy breach
81
+ const dist = (a, b) => Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]))
82
+ const TOL = 5
83
+ function nearest(c, pal) {
84
+ let best = null
85
+ for (const p of pal) {
86
+ const d = dist(c, p.c) + Math.abs((c[3] ?? 1) - (p.c[3] ?? 1)) * 40
87
+ if (!best || d < best.d) best = { d, name: p.name, val: p.c, alphaOk: Math.abs((c[3] ?? 1) - (p.c[3] ?? 1)) <= 0.1 }
88
+ }
89
+ return best
90
+ }
91
+
92
+ // ── structural scale ─────────────────────────────────────────────────────────
93
+ function buildScale() {
94
+ const S = JSON.parse(fs.readFileSync(path.join(DS, 'tokens', 'structural.json'), 'utf8'))
95
+ const set = new Set([0])
96
+ const walk = (o) => { for (const v of Object.values(o)) { if (v && typeof v === 'object') walk(v); else if (typeof v === 'string') for (const m of v.matchAll(/(\d+(?:\.\d+)?)px/g)) set.add(parseFloat(m[1])) } }
97
+ walk(S)
98
+ return [...set]
99
+ }
100
+ const onScale = (v, scale) => scale.some((s) => Math.abs(s - v) <= 1)
101
+
102
+ // ── static server (reused from diff.mjs) ─────────────────────────────────────
103
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff': 'font/woff', '.woff2': 'font/woff2' }
104
+ function serve(root) {
105
+ return http.createServer((q, r) => {
106
+ let u = decodeURIComponent(q.url.split('?')[0]); if (u === '/') u = '/index.html'
107
+ fs.readFile(path.join(root, u), (e, b) => { if (e) { r.writeHead(404); r.end() } else { r.writeHead(200, { 'Content-Type': MIME[path.extname(u).toLowerCase()] || 'application/octet-stream' }); r.end(b) } })
108
+ })
109
+ }
110
+
111
+ // browser-side measurement — auto-enumerate every element
112
+ const MEASURE = () => {
113
+ const colors = [], spaces = []
114
+ for (const el of document.querySelectorAll('*')) {
115
+ const c = getComputedStyle(el)
116
+ const tag = el.tagName.toLowerCase()
117
+ const add = (prop, val) => { if (val && val !== 'none' && val !== 'rgba(0, 0, 0, 0)') colors.push({ prop, val, tag }) }
118
+ add('background', c.backgroundColor)
119
+ add('color', c.color)
120
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
121
+ if (parseFloat(c['border' + side + 'Width']) > 0 && c['border' + side + 'Style'] !== 'none') add('border', c['border' + side + 'Color'])
122
+ }
123
+ for (const p of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius']) {
124
+ const v = parseFloat(c[p]); if (v > 0) spaces.push({ prop: p, val: Math.round(v * 10) / 10 })
125
+ }
126
+ }
127
+ const ctrlSel = 'button,input,select,textarea,a[href],[role=button],[role=checkbox],[role=switch],[role=radio],[role=tab]'
128
+ const controls = [...document.querySelectorAll(ctrlSel)].map((el) => ({ tag: el.tagName.toLowerCase(), cls: (el.getAttribute('class') || ''), ce: el.tagName.includes('-') }))
129
+ return { colors, spaces, controls, bodyBg: getComputedStyle(document.body).backgroundColor }
130
+ }
131
+
132
+ ;(async () => {
133
+ const pal = buildPalette()
134
+ const scale = buildScale()
135
+ // Portable Playwright resolution: repo path first, then the consumer's own playwright(-core).
136
+ let chromium
137
+ for (const spec of [path.join(UI_ROOT, 'node_modules', 'playwright-core', 'index.js'), 'playwright-core', 'playwright']) {
138
+ try { const pw = await import(spec); chromium = pw.chromium || (pw.default && pw.default.chromium); if (chromium) break } catch { /* try next */ }
139
+ }
140
+ if (!chromium) { console.error('playwright-core/playwright not found — install it to run the conformance check'); process.exit(2) }
141
+ const launchOpts = fs.existsSync(CHROME) ? { executablePath: CHROME } : {} // else Playwright's bundled Chromium
142
+
143
+ // resolve target → URL (serve the file's dir if it's a local file)
144
+ let url = target, srv = null
145
+ if (!/^https?:\/\//.test(target)) {
146
+ const abs = path.resolve(target)
147
+ const dir = path.dirname(abs)
148
+ srv = serve(dir); await new Promise((r) => srv.listen(0, '127.0.0.1', r))
149
+ url = `http://127.0.0.1:${srv.address().port}/${path.basename(abs)}`
150
+ }
151
+ const html = (!/^https?:\/\//.test(target)) ? fs.readFileSync(path.resolve(target), 'utf8') : ''
152
+
153
+ const browser = await chromium.launch(launchOpts)
154
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 })
155
+ const setTheme = async (t) => { await page.evaluate((th) => { if (th === 'dark') { document.documentElement.setAttribute('data-theme', 'dark-theme'); document.body && document.body.setAttribute('data-theme', 'dark-theme') } else { document.documentElement.removeAttribute('data-theme'); document.body && document.body.removeAttribute('data-theme') } }, t); await page.waitForTimeout(400) }
156
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => page.goto(url, { waitUntil: 'domcontentloaded' }))
157
+ await page.waitForTimeout(800)
158
+ await setTheme(theme)
159
+ const m = await page.evaluate(MEASURE)
160
+ // theme-awareness: does the body background flip when we toggle the other theme?
161
+ const bodyA = m.bodyBg
162
+ await setTheme(theme === 'light' ? 'dark' : 'light')
163
+ const bodyB = (await page.evaluate(() => getComputedStyle(document.body).backgroundColor))
164
+ await browser.close(); if (srv) srv.close()
165
+
166
+ // ── score ────────────────────────────────────────────────────────────────
167
+ const violations = { token: [], layout: [], philosophy: [], component: [] }
168
+
169
+ // 1. token adherence
170
+ let tOk = 0, tTot = 0
171
+ const offAgg = {}
172
+ for (const { prop, val, tag } of m.colors) {
173
+ const c = parseColor(val); if (!c) continue
174
+ if ((c[3] ?? 1) < 0.04) continue // fully transparent
175
+ tTot++
176
+ const n = nearest(c, pal)
177
+ if (n && n.d <= TOL) tOk++
178
+ else { const key = val; offAgg[key] = offAgg[key] || { val, count: 0, nearest: n, prop, tag }; offAgg[key].count++ }
179
+ }
180
+ const tokenScore = tTot ? Math.round((tOk / tTot) * 100) : 100
181
+ violations.token = Object.values(offAgg).sort((a, b) => b.count - a.count).slice(0, 15)
182
+ .map((o) => ({ value: o.val, prop: o.prop, count: o.count, nearestToken: o.nearest ? o.nearest.name : null, nearestVal: o.nearest ? `rgb(${o.nearest.val.slice(0, 3).join(',')})` : null, delta: o.nearest ? o.nearest.d : null }))
183
+
184
+ // 2. layout adherence
185
+ let lOk = 0, lTot = 0
186
+ const offScale = {}
187
+ for (const { prop, val } of m.spaces) { lTot++; if (onScale(val, scale)) lOk++; else { const k = val + 'px'; offScale[k] = (offScale[k] || 0) + 1 } }
188
+ const layoutScore = lTot ? Math.round((lOk / lTot) * 100) : 100
189
+ violations.layout = Object.entries(offScale).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([v, count]) => ({ value: v, count }))
190
+
191
+ // 3. philosophy: theme-aware + no mds-* + brand-not-blue
192
+ const bA = parseColor(bodyA), bB = parseColor(bodyB)
193
+ const themeAware = !(bA && bB && dist(bA, bB) <= TOL && (bA[3] ?? 1) > 0.5) // bg must change across themes (unless transparent)
194
+ const usesMds = /\bmds-[a-z-]+/.test(html)
195
+ // brand misuse: a saturated blue that isn't a DS token (blue-dominant, off-token)
196
+ const brandMisuse = violations.token.filter((v) => { const c = parseColor(v.value); return c && c[2] > c[0] + 40 && c[2] > 150 && c[1] < c[2] - 20 && dist(c, CYAN) > 12 })
197
+ let philosophyScore = 100
198
+ if (!themeAware) { philosophyScore -= 45; violations.philosophy.push({ rule: 'theme-aware', detail: `body background did not change light↔dark (${bodyA} vs ${bodyB}) — likely a hardcoded surface, not a DS token` }) }
199
+ if (usesMds) { philosophyScore -= 45; violations.philosophy.push({ rule: 'no-mds', detail: 'page references future `mds-*` tokens — must emit only runtime --vars' }) }
200
+ if (brandMisuse.length) { philosophyScore -= 20; violations.philosophy.push({ rule: 'brand-navy', detail: `off-token saturated blue used (${brandMisuse.map((b) => b.value).join(', ')}) — brand must be --primary navy, not blue/cyan` }) }
201
+ philosophyScore = Math.max(0, philosophyScore)
202
+
203
+ // 4. component adherence (advisory): interactive controls should carry a DS signature
204
+ const DS_SIG = /\b(ant-|obs-|m-|floto|tag-|squared-button|sel|trig)/i
205
+ let cOk = 0
206
+ for (const ctrl of m.controls) { if (ctrl.ce || DS_SIG.test(ctrl.cls) || ctrl.tag === 'a') cOk++ }
207
+ const componentScore = m.controls.length ? Math.round((cOk / m.controls.length) * 100) : 100
208
+ if (m.controls.length && cOk < m.controls.length) violations.component.push({ detail: `${m.controls.length - cOk} of ${m.controls.length} interactive controls have no DS component/class signature (raw element?)` })
209
+
210
+ const overall = Math.round(tokenScore * 0.45 + layoutScore * 0.15 + philosophyScore * 0.25 + componentScore * 0.15)
211
+ const result = { target, theme, overall, dimensions: { token: tokenScore, layout: layoutScore, philosophy: philosophyScore, component: componentScore },
212
+ measured: { colors: tTot, spacings: lTot, controls: m.controls.length }, violations }
213
+
214
+ if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify(result, null, 2))
215
+ if (!QUIET) {
216
+ console.log(`\n=== DS conformance — ${target} (theme=${theme}) ===`)
217
+ console.log(` OVERALL: ${overall}/100 · token ${tokenScore} layout ${layoutScore} philosophy ${philosophyScore} component ${componentScore}`)
218
+ console.log(` measured: ${tTot} colours · ${lTot} spacings · ${m.controls.length} controls`)
219
+ if (violations.token.length) { console.log('\n off-token colours (top):'); for (const v of violations.token.slice(0, 8)) console.log(` ✗ ${v.value} (${v.prop}, ×${v.count}) → nearest DS token ${v.nearestToken} (${v.nearestVal}, Δ${v.delta})`) }
220
+ if (violations.layout.length) { console.log('\n off-scale spacing/radii:'); for (const v of violations.layout.slice(0, 6)) console.log(` ~ ${v.value} (×${v.count})`) }
221
+ if (violations.philosophy.length) { console.log('\n philosophy:'); for (const v of violations.philosophy) console.log(` ✗ [${v.rule}] ${v.detail}`) }
222
+ if (violations.component.length) { console.log('\n component:'); for (const v of violations.component) console.log(` ~ ${v.detail}`) }
223
+ console.log('')
224
+ }
225
+ process.exit(overall >= 80 ? 0 : 1)
226
+ })().catch((e) => { console.error('ds-conformance ERROR', e.stack || e.message); process.exit(2) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtdt/observeops-ds-spec",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Machine-readable spec and AI operating contract for the Motadata ObserveOps design system \u2014 components, tokens, page recipes, layout structure, and the rules an AI tool follows to build ObserveOps UI faithfully.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "index.js",
@@ -13,6 +13,7 @@
13
13
  "tokens/",
14
14
  "layout/",
15
15
  "foundation/",
16
+ "conformance/",
16
17
  "README.md"
17
18
  ],
18
19
  "exports": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mtdt/observeops-ds-spec",
3
- "version": "0.1.4",
4
- "generated": "2026-07-05T05:26:44.368Z",
3
+ "version": "0.1.5",
4
+ "generated": "2026-07-05T05:55:32.800Z",
5
5
  "entry": "llms.txt",
6
6
  "contract": "AGENTS.md",
7
7
  "index": "components/index.json",
@@ -410,8 +410,8 @@
410
410
  },
411
411
  {
412
412
  "path": "package.json",
413
- "bytes": 1052,
414
- "sha256": "2532a62d6b53789f2b85f8352aa55160808022b5ad68fe46994ccff5e14376a7"
413
+ "bytes": 1072,
414
+ "sha256": "a498b96846cd6731099d56e25658eedd15b1c3af8ee49bd969b5dcd2de9876f5"
415
415
  },
416
416
  {
417
417
  "path": "tokens/README.md",