@mtdt/observeops-ds-spec 0.1.4 → 0.1.6
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/conformance/README.md +23 -0
- package/conformance/ds-conformance.mjs +247 -0
- package/package.json +2 -1
- package/spec.manifest.json +6 -6
|
@@ -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,247 @@
|
|
|
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
|
+
// ── component fidelity ─────────────────────────────────────────────────────
|
|
128
|
+
// A DS component (obs-*) renders its real control in SHADOW DOM, so any RAW interactive element in the
|
|
129
|
+
// light DOM is a non-DS control that should be a DS component. And a styled <span>/<div> chip that isn't
|
|
130
|
+
// a DS component is a fabricated look-alike (should be obs-tag / obs-severity).
|
|
131
|
+
const DS = ['obs-button','obs-input','obs-select','obs-switch','obs-checkbox','obs-radio','obs-link','obs-tag','obs-severity','obs-tags','obs-tooltip','obs-date-time-picker','obs-filters','obs-selected-pills']
|
|
132
|
+
const SUGGEST = { button:'obs-button', input:'obs-input', textarea:'obs-input', select:'obs-select', a:'obs-link' }
|
|
133
|
+
const insideCE = (el) => { let n = el.parentElement; while (n) { if (n.tagName.includes('-')) return true; n = n.parentElement } return false }
|
|
134
|
+
const dsInteractive = document.querySelectorAll('obs-button,obs-input,obs-select,obs-switch,obs-checkbox,obs-radio,obs-link').length
|
|
135
|
+
const rawControls = [...document.querySelectorAll('button,input:not([type=hidden]),select,textarea,a[href],[role=button],[role=switch],[role=checkbox],[role=radio],[role=tab]')]
|
|
136
|
+
.filter((el) => !el.tagName.includes('-') && !insideCE(el))
|
|
137
|
+
.map((el) => { const t = el.tagName.toLowerCase(); const role = el.getAttribute('role'); return { tag: t, role, suggest: SUGGEST[t] || (role ? 'obs-' + role.replace('checkbox','checkbox').replace('button','button') : 'a DS component'), text: (el.textContent || el.getAttribute('placeholder') || '').trim().slice(0, 30) } })
|
|
138
|
+
// fabricated chips: light-DOM, non-custom element with a bg tint + radius + short text (a Tag/Badge look-alike)
|
|
139
|
+
const chips = [...document.querySelectorAll('span,div,i')].filter((el) => {
|
|
140
|
+
if (el.tagName.includes('-') || insideCE(el)) return false
|
|
141
|
+
const c = getComputedStyle(el); const r = el.getBoundingClientRect()
|
|
142
|
+
const bg = c.backgroundColor; const hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'
|
|
143
|
+
const rad = parseFloat(c.borderTopLeftRadius) || 0
|
|
144
|
+
const txt = (el.textContent || '').trim()
|
|
145
|
+
return hasBg && rad >= 4 && r.width > 0 && r.width < 160 && r.height < 40 && txt.length >= 1 && txt.length <= 24 && el.children.length <= 1
|
|
146
|
+
}).map((el) => (el.textContent || '').trim().slice(0, 24))
|
|
147
|
+
return { colors, spaces, dsInteractive, rawControls, chips: [...new Set(chips)], bodyBg: getComputedStyle(document.body).backgroundColor }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
;(async () => {
|
|
151
|
+
const pal = buildPalette()
|
|
152
|
+
const scale = buildScale()
|
|
153
|
+
// Portable Playwright resolution: repo path first, then the consumer's own playwright(-core).
|
|
154
|
+
let chromium
|
|
155
|
+
for (const spec of [path.join(UI_ROOT, 'node_modules', 'playwright-core', 'index.js'), 'playwright-core', 'playwright']) {
|
|
156
|
+
try { const pw = await import(spec); chromium = pw.chromium || (pw.default && pw.default.chromium); if (chromium) break } catch { /* try next */ }
|
|
157
|
+
}
|
|
158
|
+
if (!chromium) { console.error('playwright-core/playwright not found — install it to run the conformance check'); process.exit(2) }
|
|
159
|
+
const launchOpts = fs.existsSync(CHROME) ? { executablePath: CHROME } : {} // else Playwright's bundled Chromium
|
|
160
|
+
|
|
161
|
+
// resolve target → URL (serve the file's dir if it's a local file)
|
|
162
|
+
let url = target, srv = null
|
|
163
|
+
if (!/^https?:\/\//.test(target)) {
|
|
164
|
+
const abs = path.resolve(target)
|
|
165
|
+
const dir = path.dirname(abs)
|
|
166
|
+
srv = serve(dir); await new Promise((r) => srv.listen(0, '127.0.0.1', r))
|
|
167
|
+
url = `http://127.0.0.1:${srv.address().port}/${path.basename(abs)}`
|
|
168
|
+
}
|
|
169
|
+
const html = (!/^https?:\/\//.test(target)) ? fs.readFileSync(path.resolve(target), 'utf8') : ''
|
|
170
|
+
|
|
171
|
+
const browser = await chromium.launch(launchOpts)
|
|
172
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 })
|
|
173
|
+
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) }
|
|
174
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => page.goto(url, { waitUntil: 'domcontentloaded' }))
|
|
175
|
+
await page.waitForTimeout(800)
|
|
176
|
+
await setTheme(theme)
|
|
177
|
+
const m = await page.evaluate(MEASURE)
|
|
178
|
+
// theme-awareness: does the body background flip when we toggle the other theme?
|
|
179
|
+
const bodyA = m.bodyBg
|
|
180
|
+
await setTheme(theme === 'light' ? 'dark' : 'light')
|
|
181
|
+
const bodyB = (await page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
|
182
|
+
await browser.close(); if (srv) srv.close()
|
|
183
|
+
|
|
184
|
+
// ── score ────────────────────────────────────────────────────────────────
|
|
185
|
+
const violations = { token: [], layout: [], philosophy: [], component: [] }
|
|
186
|
+
|
|
187
|
+
// 1. token adherence
|
|
188
|
+
let tOk = 0, tTot = 0
|
|
189
|
+
const offAgg = {}
|
|
190
|
+
for (const { prop, val, tag } of m.colors) {
|
|
191
|
+
const c = parseColor(val); if (!c) continue
|
|
192
|
+
if ((c[3] ?? 1) < 0.04) continue // fully transparent
|
|
193
|
+
tTot++
|
|
194
|
+
const n = nearest(c, pal)
|
|
195
|
+
if (n && n.d <= TOL) tOk++
|
|
196
|
+
else { const key = val; offAgg[key] = offAgg[key] || { val, count: 0, nearest: n, prop, tag }; offAgg[key].count++ }
|
|
197
|
+
}
|
|
198
|
+
const tokenScore = tTot ? Math.round((tOk / tTot) * 100) : 100
|
|
199
|
+
violations.token = Object.values(offAgg).sort((a, b) => b.count - a.count).slice(0, 15)
|
|
200
|
+
.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 }))
|
|
201
|
+
|
|
202
|
+
// 2. layout adherence
|
|
203
|
+
let lOk = 0, lTot = 0
|
|
204
|
+
const offScale = {}
|
|
205
|
+
for (const { prop, val } of m.spaces) { lTot++; if (onScale(val, scale)) lOk++; else { const k = val + 'px'; offScale[k] = (offScale[k] || 0) + 1 } }
|
|
206
|
+
const layoutScore = lTot ? Math.round((lOk / lTot) * 100) : 100
|
|
207
|
+
violations.layout = Object.entries(offScale).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([v, count]) => ({ value: v, count }))
|
|
208
|
+
|
|
209
|
+
// 3. philosophy: theme-aware + no mds-* + brand-not-blue
|
|
210
|
+
const bA = parseColor(bodyA), bB = parseColor(bodyB)
|
|
211
|
+
const themeAware = !(bA && bB && dist(bA, bB) <= TOL && (bA[3] ?? 1) > 0.5) // bg must change across themes (unless transparent)
|
|
212
|
+
const usesMds = /\bmds-[a-z-]+/.test(html)
|
|
213
|
+
// brand misuse: a saturated blue that isn't a DS token (blue-dominant, off-token)
|
|
214
|
+
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 })
|
|
215
|
+
let philosophyScore = 100
|
|
216
|
+
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` }) }
|
|
217
|
+
if (usesMds) { philosophyScore -= 45; violations.philosophy.push({ rule: 'no-mds', detail: 'page references future `mds-*` tokens — must emit only runtime --vars' }) }
|
|
218
|
+
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` }) }
|
|
219
|
+
philosophyScore = Math.max(0, philosophyScore)
|
|
220
|
+
|
|
221
|
+
// 4. COMPONENT FIDELITY — every interactive control must be a real DS component (not a raw element),
|
|
222
|
+
// and no fabricated Tag/Badge look-alikes. A DS page renders controls via obs-* (shadow DOM), so any
|
|
223
|
+
// raw light-DOM control is a non-DS element. This is the "use DS components, not look-alikes" check.
|
|
224
|
+
const rawN = m.rawControls.length
|
|
225
|
+
const denom = m.dsInteractive + rawN
|
|
226
|
+
const componentScore = denom === 0 ? 100 : Math.round((m.dsInteractive / denom) * 100)
|
|
227
|
+
for (const rc of m.rawControls) violations.component.push({ detail: `raw <${rc.tag}${rc.role ? ' role=' + rc.role : ''}>${rc.text ? ` "${rc.text}"` : ''} — use ${rc.suggest}, not a raw element` })
|
|
228
|
+
if (m.chips.length) violations.component.push({ advisory: true, detail: `possible fabricated chip(s) — use obs-tag / obs-severity: ${m.chips.slice(0, 6).join(', ')}` })
|
|
229
|
+
|
|
230
|
+
// Component fidelity is weighted heavily — the point of the check is DS components, not just DS colours.
|
|
231
|
+
const overall = Math.round(tokenScore * 0.35 + componentScore * 0.30 + philosophyScore * 0.20 + layoutScore * 0.15)
|
|
232
|
+
const result = { target, theme, overall, dimensions: { token: tokenScore, component: componentScore, philosophy: philosophyScore, layout: layoutScore },
|
|
233
|
+
measured: { colors: tTot, spacings: lTot, dsComponents: m.dsInteractive, rawControls: rawN, fabricatedChips: m.chips.length }, violations }
|
|
234
|
+
|
|
235
|
+
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify(result, null, 2))
|
|
236
|
+
if (!QUIET) {
|
|
237
|
+
console.log(`\n=== DS conformance — ${target} (theme=${theme}) ===`)
|
|
238
|
+
console.log(` OVERALL: ${overall}/100 · token ${tokenScore} component ${componentScore} philosophy ${philosophyScore} layout ${layoutScore}`)
|
|
239
|
+
console.log(` measured: ${tTot} colours · ${lTot} spacings · ${m.dsInteractive} DS components · ${rawN} raw controls · ${m.chips.length} fabricated chip(s)`)
|
|
240
|
+
if (violations.component.length) { console.log('\n COMPONENT fidelity (use DS components, not raw/look-alikes):'); for (const v of violations.component.slice(0, 10)) console.log(` ✗ ${v.detail}`) }
|
|
241
|
+
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})`) }
|
|
242
|
+
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})`) }
|
|
243
|
+
if (violations.philosophy.length) { console.log('\n philosophy:'); for (const v of violations.philosophy) console.log(` ✗ [${v.rule}] ${v.detail}`) }
|
|
244
|
+
console.log('')
|
|
245
|
+
}
|
|
246
|
+
process.exit(overall >= 80 ? 0 : 1)
|
|
247
|
+
})().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.
|
|
3
|
+
"version": "0.1.6",
|
|
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": {
|
package/spec.manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mtdt/observeops-ds-spec",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"generated": "2026-07-
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"generated": "2026-07-05T06:45:18.241Z",
|
|
5
5
|
"entry": "llms.txt",
|
|
6
6
|
"contract": "AGENTS.md",
|
|
7
7
|
"index": "components/index.json",
|
|
@@ -360,8 +360,8 @@
|
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
362
|
"path": "conformance/ds-conformance.mjs",
|
|
363
|
-
"bytes":
|
|
364
|
-
"sha256": "
|
|
363
|
+
"bytes": 17517,
|
|
364
|
+
"sha256": "2ab84ecc68575abf122b85beb3f9a9d4eccde9ffd629f71ea1cae2af50cfd3ac"
|
|
365
365
|
},
|
|
366
366
|
{
|
|
367
367
|
"path": "foundation/README.md",
|
|
@@ -410,8 +410,8 @@
|
|
|
410
410
|
},
|
|
411
411
|
{
|
|
412
412
|
"path": "package.json",
|
|
413
|
-
"bytes":
|
|
414
|
-
"sha256": "
|
|
413
|
+
"bytes": 1072,
|
|
414
|
+
"sha256": "3e5a5e13ada2e15ca887ec4d40f280a0fac033f4dbaba8ac601d6efd46011d2f"
|
|
415
415
|
},
|
|
416
416
|
{
|
|
417
417
|
"path": "tokens/README.md",
|