@mtdt/observeops-ds-spec 0.1.6 → 0.1.7

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.
@@ -99,6 +99,24 @@ function buildScale() {
99
99
  }
100
100
  const onScale = (v, scale) => scale.some((s) => Math.abs(s - v) <= 1)
101
101
 
102
+ // ── variant/state fidelity: valid values per component, from the registry ─────
103
+ const TAGMAP = { 'obs-button': 'button', 'obs-input': 'input', 'obs-tag': 'tag', 'obs-radio': 'radio', 'obs-select': 'select', 'obs-severity': 'severity', 'obs-checkbox': 'checkbox', 'obs-switch': 'switch', 'obs-link': 'link', 'obs-tags': 'loose-tags', 'obs-date-time-picker': 'date-time-pickers', 'obs-filters': 'filters' }
104
+ const _regCache = {}
105
+ function validValues(tag) {
106
+ const id = TAGMAP[tag]; if (!id) return null
107
+ if (_regCache[id] !== undefined) return _regCache[id]
108
+ const p = path.join(DS, 'components', 'registry', `${id}.json`)
109
+ if (!fs.existsSync(p)) return (_regCache[id] = null)
110
+ const r = JSON.parse(fs.readFileSync(p, 'utf8'))
111
+ const props = r.props || {}
112
+ const enumOf = (k) => (props[k] && Array.isArray(props[k].enum) && props[k].enum.length ? props[k].enum : null)
113
+ // Validate ONLY against a declared prop `enum` (the reliable accepted-values source). The registry's
114
+ // `variants` field is a doc concept whose names don't always match the prop values (e.g. link), so we
115
+ // do NOT validate against it — that produced false positives. Where a component has no enum, its variant
116
+ // isn't strictly validated here (a registry gap the ai-readiness/validate-registries pass should close).
117
+ return (_regCache[id] = { id, variant: enumOf('variant'), size: enumOf('size'), type: enumOf('type') })
118
+ }
119
+
102
120
  // ── static server (reused from diff.mjs) ─────────────────────────────────────
103
121
  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
122
  function serve(root) {
@@ -144,7 +162,17 @@ const MEASURE = () => {
144
162
  const txt = (el.textContent || '').trim()
145
163
  return hasBg && rad >= 4 && r.width > 0 && r.width < 160 && r.height < 40 && txt.length >= 1 && txt.length <= 24 && el.children.length <= 1
146
164
  }).map((el) => (el.textContent || '').trim().slice(0, 24))
147
- return { colors, spaces, dsInteractive, rawControls, chips: [...new Set(chips)], bodyBg: getComputedStyle(document.body).backgroundColor }
165
+ // capture each DS component instance + its variant/size/type/state attrs (validated vs the registry node-side)
166
+ const dsInstances = [...document.querySelectorAll(DS.join(','))].map((el) => ({
167
+ tag: el.tagName.toLowerCase(),
168
+ variant: el.getAttribute('variant'),
169
+ size: el.getAttribute('size'),
170
+ type: el.getAttribute('type'),
171
+ severity: el.getAttribute('severity'),
172
+ ariaLabel: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'),
173
+ hasText: (el.textContent || '').trim().length > 0,
174
+ }))
175
+ return { colors, spaces, dsInteractive, rawControls, chips: [...new Set(chips)], dsInstances, bodyBg: getComputedStyle(document.body).backgroundColor }
148
176
  }
149
177
 
150
178
  ;(async () => {
@@ -222,21 +250,39 @@ const MEASURE = () => {
222
250
  // and no fabricated Tag/Badge look-alikes. A DS page renders controls via obs-* (shadow DOM), so any
223
251
  // raw light-DOM control is a non-DS element. This is the "use DS components, not look-alikes" check.
224
252
  const rawN = m.rawControls.length
225
- const denom = m.dsInteractive + rawN
226
- const componentScore = denom === 0 ? 100 : Math.round((m.dsInteractive / denom) * 100)
227
253
  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
254
  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
255
 
256
+ // variant/state fidelity — every variant/size/type used must be a REAL registry value (not invented),
257
+ // and required state must be present (icon-only control needs an aria-label).
258
+ let checked = 0, invalid = 0
259
+ for (const inst of (m.dsInstances || [])) {
260
+ const vv = validValues(inst.tag); if (!vv) continue
261
+ const bad = (attr, val, allowed) => { if (val == null || !allowed || !allowed.length) return; checked++; if (!allowed.includes(val)) { invalid++; violations.component.push({ detail: `invented ${attr} "${val}" on <${inst.tag}> — valid: ${allowed.slice(0, 8).join(', ')}` }) } }
262
+ // Validate `size`/`type` against the prop enum (reliable). NOT `variant`: components reflect a default
263
+ // `variant` attr that often isn't in the registry's variant list (e.g. obs-link reflects "primary"),
264
+ // and the registry's variant names don't always match the component's accepted values — so validating
265
+ // it here produces false positives. Enabling variant validation needs a clean per-component variant
266
+ // enum in the registry (tracked as a spec-hardening TODO; see validate-registries / ai-readiness).
267
+ bad('size', inst.size, vv.size)
268
+ bad('type', inst.type, vv.type)
269
+ // icon-only interactive control (no text) should carry an aria-label
270
+ if ((inst.tag === 'obs-button' || inst.tag === 'obs-link') && !inst.hasText && !inst.ariaLabel) { checked++; invalid++; violations.component.push({ detail: `icon-only <${inst.tag}> has no aria-label (required state)` }) }
271
+ }
272
+ const presence = (m.dsInteractive + rawN) === 0 ? 1 : m.dsInteractive / (m.dsInteractive + rawN)
273
+ const validity = checked === 0 ? 1 : (checked - invalid) / checked
274
+ const componentScore = Math.round(presence * validity * 100)
275
+
230
276
  // Component fidelity is weighted heavily — the point of the check is DS components, not just DS colours.
231
277
  const overall = Math.round(tokenScore * 0.35 + componentScore * 0.30 + philosophyScore * 0.20 + layoutScore * 0.15)
232
278
  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 }
279
+ measured: { colors: tTot, spacings: lTot, dsComponents: m.dsInteractive, rawControls: rawN, fabricatedChips: m.chips.length, variantChecks: checked, invalidVariants: invalid }, violations }
234
280
 
235
281
  if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify(result, null, 2))
236
282
  if (!QUIET) {
237
283
  console.log(`\n=== DS conformance — ${target} (theme=${theme}) ===`)
238
284
  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)`)
285
+ console.log(` measured: ${tTot} colours · ${lTot} spacings · ${m.dsInteractive} DS components · ${rawN} raw controls · ${m.chips.length} fabricated chip(s) · ${checked} variant checks (${invalid} invalid)`)
240
286
  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
287
  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
288
  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})`) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtdt/observeops-ds-spec",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mtdt/observeops-ds-spec",
3
- "version": "0.1.6",
4
- "generated": "2026-07-05T06:45:18.241Z",
3
+ "version": "0.1.7",
4
+ "generated": "2026-07-05T06:59:15.142Z",
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": 17517,
364
- "sha256": "2ab84ecc68575abf122b85beb3f9a9d4eccde9ffd629f71ea1cae2af50cfd3ac"
363
+ "bytes": 21201,
364
+ "sha256": "625ab74a9f0b788a6721432cf8b8688a128c03e832fe001300f8a24ad176f9b2"
365
365
  },
366
366
  {
367
367
  "path": "foundation/README.md",
@@ -411,7 +411,7 @@
411
411
  {
412
412
  "path": "package.json",
413
413
  "bytes": 1072,
414
- "sha256": "3e5a5e13ada2e15ca887ec4d40f280a0fac033f4dbaba8ac601d6efd46011d2f"
414
+ "sha256": "19f2e139d6c911d1801219acf6ecbd6f029ed596d8484e8bbf4adc4ae61c78b9"
415
415
  },
416
416
  {
417
417
  "path": "tokens/README.md",