@litfamily/litmuse 0.1.1 → 0.1.2

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 (168) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -0
  3. package/README_ko-KR.md +20 -0
  4. package/bin/litmuse.mjs +4 -0
  5. package/lib/stage-card.mjs +180 -5
  6. package/package.json +1 -1
  7. package/plugin/.muse-plugin/plugin.json +26 -11
  8. package/plugin/commands/lit-status.md +2 -1
  9. package/plugin/commands/lit.md +1 -1
  10. package/plugin/hooks/lib/digest.mjs +1 -1
  11. package/plugin/hooks/lib/next-step.mjs +68 -0
  12. package/plugin/hooks/lib/routes.mjs +59 -0
  13. package/plugin/hooks/lib/workflow-stack.mjs +318 -0
  14. package/plugin/hooks/session-start.mjs +29 -2
  15. package/plugin/hooks/user-prompt-submit.mjs +138 -7
  16. package/plugin/lib/capped-fetch.mjs +25 -0
  17. package/plugin/lib/pinned-fetch.mjs +79 -0
  18. package/plugin/lib/plan-task-rows.mjs +32 -0
  19. package/plugin/lib/render_pages.py +29 -1
  20. package/plugin/lib/rewind/collect.mjs +679 -0
  21. package/plugin/lib/rewind/gauge.mjs +72 -0
  22. package/plugin/lib/rewind/next-step.mjs +91 -0
  23. package/plugin/lib/rewind/redact.mjs +83 -0
  24. package/plugin/lib/rewind/transcripts.mjs +227 -0
  25. package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
  26. package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  27. package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
  28. package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  29. package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  30. package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  31. package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  32. package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  33. package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  34. package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  35. package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  36. package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  37. package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  38. package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
  39. package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
  40. package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
  41. package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
  42. package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
  43. package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  44. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  45. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
  46. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
  47. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
  48. package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
  49. package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  50. package/plugin/skills/lit-adhd/SKILL.md +188 -0
  51. package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
  52. package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
  53. package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
  54. package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
  55. package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
  56. package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
  57. package/plugin/skills/lit-docx/SKILL.md +1 -1
  58. package/plugin/skills/lit-hijack/SKILL.md +589 -0
  59. package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  60. package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
  61. package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  62. package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  63. package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
  64. package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
  65. package/plugin/skills/lit-pptx/SKILL.md +20 -9
  66. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  67. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  68. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  69. package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
  70. package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
  71. package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  72. package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  73. package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  74. package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  75. package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  76. package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
  77. package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
  78. package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
  79. package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
  80. package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
  81. package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
  82. package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
  83. package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  84. package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
  85. package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  86. package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
  87. package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  88. package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  89. package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
  90. package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  91. package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  92. package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  93. package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  94. package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  95. package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  96. package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  97. package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  98. package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  99. package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  100. package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  101. package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  102. package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  103. package/plugin/skills/lit-recap/SKILL.md +3 -0
  104. package/plugin/skills/lit-schematic/SKILL.md +241 -0
  105. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
  106. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
  107. package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
  108. package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
  109. package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
  110. package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
  111. package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
  112. package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
  113. package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
  114. package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
  115. package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
  116. package/plugin/skills/lit-schematic/references/verification.md +24 -0
  117. package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
  118. package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
  119. package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
  120. package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
  121. package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
  122. package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
  123. package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
  124. package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
  125. package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
  126. package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
  127. package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
  128. package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
  129. package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
  130. package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
  131. package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
  132. package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
  133. package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  134. package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
  135. package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
  136. package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
  137. package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
  138. package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
  139. package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
  140. package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
  141. package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
  142. package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
  143. package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
  144. package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
  145. package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
  146. package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
  147. package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
  148. package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
  149. package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
  150. package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
  151. package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  152. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  153. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  154. package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  155. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
  156. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  157. package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
  158. package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
  159. package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
  160. package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
  161. package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
  162. package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
  163. package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  164. package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  165. package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  166. package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  167. package/plugin/skills/readme-studio/SKILL.md +1 -1
  168. package/plugin/skills/start-work/SKILL.md +6 -0
@@ -0,0 +1,196 @@
1
+ // In-page design-lock conformance (DS-001..DS-008). interface-probe.mjs serialises this function
2
+ // into the page, so it may not import anything or close over module scope; every threshold comes
3
+ // from the rule table through `options.rules`, and the lock arrives as `options.lock`.
4
+ export function measureLock(options) {
5
+ const { rules, limits, lock } = options;
6
+ const findings = [];
7
+ const notVerified = [];
8
+ const push = (rule, selector, value, threshold, severity) => {
9
+ if (findings.filter((f) => f.rule === rule).length >= limits.findingsPerRule) return;
10
+ findings.push({ rule, severity: severity ?? rules[rule].severity, tier: rules[rule].tier, selector, value, threshold });
11
+ };
12
+ const describe = (el) => {
13
+ if (el.id) return `#${el.id}`;
14
+ const cls = [...el.classList].slice(0, 2).map((c) => `.${c}`).join("");
15
+ return `${el.tagName.toLowerCase()}${cls}`;
16
+ };
17
+ const exempt = (el) => Boolean(el.closest("[data-lock-exempt], img, video, canvas, iframe, picture, object"));
18
+ const visible = (el) => {
19
+ if (typeof el.checkVisibility === "function" && !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return false;
20
+ const box = el.getBoundingClientRect();
21
+ return box.width >= 1 && box.height >= 1;
22
+ };
23
+
24
+ // DS-001: the generated tokens.css carries the lock id on :root.
25
+ const applied = getComputedStyle(document.documentElement).getPropertyValue(rules["DS-001"].threshold.property).trim().replace(/^["']|["']$/gu, "");
26
+ if (applied !== lock.style) {
27
+ push("DS-001", ":root", applied ? `${rules["DS-001"].threshold.property}: ${applied}` : `${rules["DS-001"].threshold.property} missing`, `"${lock.style}" from design.lock.json`);
28
+ return { findings, notVerified };
29
+ }
30
+
31
+ const parse = (value) => {
32
+ let match = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/u.exec(value);
33
+ if (match) return { rgb: [Number(match[1]), Number(match[2]), Number(match[3])], alpha: match[4] === undefined ? 1 : Number(match[4]) };
34
+ match = /^color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)(?:\s*\/\s*([\d.]+))?\)$/u.exec(value);
35
+ if (match) return { rgb: [match[1], match[2], match[3]].map((n) => Math.round(Number(n) * 255)), alpha: match[4] === undefined ? 1 : Number(match[4]) };
36
+ return null;
37
+ };
38
+ const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
39
+ const palette = [...Object.values(lock.colors.light), ...Object.values(lock.colors.dark)].map(hex);
40
+ const tolerance = rules["DS-003"].threshold.channelTolerance;
41
+ const onPalette = (rgb) => palette.some((p) => Math.abs(p[0] - rgb[0]) <= tolerance && Math.abs(p[1] - rgb[1]) <= tolerance && Math.abs(p[2] - rgb[2]) <= tolerance);
42
+ const toHex = (rgb) => `#${rgb.map((n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0")).join("")}`;
43
+ const families = new Set(lock.fonts.map((f) => f.toLowerCase()));
44
+ const sizes = lock.type.scale_px;
45
+ const display = lock.type.display_px;
46
+ // Inside the declared signature moment type may run past the display range up to the lock's ceiling.
47
+ const momentMax = lock.type.moment_px?.max ?? display.max;
48
+ const momentAttr = rules["DS-008"].threshold.attribute;
49
+ const sizeTolerance = rules["DS-004"].threshold.tolerancePx;
50
+ const radii = lock.radius_px;
51
+ const radiusTolerance = rules["DS-005"].threshold.tolerancePx;
52
+
53
+ const offColours = new Map();
54
+ const offFamilies = new Map();
55
+ const offSizes = new Map();
56
+ const offRadii = new Map();
57
+ const offShadows = new Map();
58
+ let unparsed = 0;
59
+ // DS-007: the lock names an elevation grammar. "flat" allows only rings (no blur, no offset past a
60
+ // hairline); "offset" allows hard offsets but no blur; "soft" is left to CF-403. Insets never count.
61
+ const elevation = lock.elevation ?? "soft";
62
+ const hairline = rules["DS-007"].threshold.hairlinePx;
63
+ const shadowLengths = (value) => (value === "none" ? [] : value.split(/,(?![^()]*\))/u)).filter((part) => !/\binset\b/u.test(part))
64
+ .map((part) => part.replace(/\b(?:rgba?|color|hsla?|oklch|lab|lch)\([^)]*\)/gu, "").match(/-?[\d.]+px/gu)?.map(Number.parseFloat) ?? []);
65
+ const noteColour = (value, el, where) => {
66
+ const colour = parse(value);
67
+ if (!colour) { if (value !== "transparent" && value !== "none") unparsed += 1; return; }
68
+ if (colour.alpha < rules["DS-003"].threshold.minAlpha || onPalette(colour.rgb)) return;
69
+ const key = toHex(colour.rgb);
70
+ if (!offColours.has(key)) offColours.set(key, `${describe(el)} ${where}`);
71
+ };
72
+
73
+ const elements = [...document.body.querySelectorAll("*")].slice(0, limits.nodes);
74
+ for (const el of elements) {
75
+ if (exempt(el) || !visible(el)) continue;
76
+ const style = getComputedStyle(el);
77
+ const isSvgShape = el instanceof SVGElement && !(el instanceof SVGSVGElement);
78
+ if (isSvgShape) {
79
+ if (["rect", "path", "circle", "ellipse", "polygon", "polyline", "line"].includes(el.tagName.toLowerCase())) {
80
+ if (style.fill !== "none") noteColour(style.fill, el, "fill");
81
+ if (style.stroke !== "none") noteColour(style.stroke, el, "stroke");
82
+ }
83
+ continue;
84
+ }
85
+ const ownText = [...el.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
86
+ if (ownText) {
87
+ noteColour(style.color, el, "text");
88
+ const family = style.fontFamily.split(",")[0].trim().replace(/^["']|["']$/gu, "").toLowerCase();
89
+ if (!families.has(family) && !offFamilies.has(family)) offFamilies.set(family, describe(el));
90
+ const px = Number.parseFloat(style.fontSize);
91
+ const ceiling = el.closest(`[${momentAttr}]`) ? momentMax : display.max;
92
+ const onScale = sizes.some((s) => Math.abs(s - px) <= sizeTolerance) || (px >= display.min - sizeTolerance && px <= ceiling + sizeTolerance);
93
+ if (!onScale && !offSizes.has(px)) offSizes.set(px, describe(el));
94
+ }
95
+ noteColour(style.backgroundColor, el, "background");
96
+ for (const side of ["Top", "Right", "Bottom", "Left"]) {
97
+ if (Number.parseFloat(style[`border${side}Width`]) > 0 && style[`border${side}Style`] !== "none") noteColour(style[`border${side}Color`], el, "border");
98
+ }
99
+ if (elevation !== "soft") {
100
+ for (const [x = 0, y = 0, blur = 0] of shadowLengths(style.boxShadow)) {
101
+ const offset = Math.abs(x) > hairline || Math.abs(y) > hairline;
102
+ if ((blur > 0 || (elevation === "flat" && offset)) && !offShadows.has(describe(el))) offShadows.set(describe(el), `${x}px ${y}px ${blur}px`);
103
+ }
104
+ }
105
+ const box = el.getBoundingClientRect();
106
+ for (const corner of ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]) {
107
+ const raw = style[`border${corner}Radius`];
108
+ if (!raw || raw.includes(" ") || raw.includes("%")) continue;
109
+ const r = Number.parseFloat(raw);
110
+ if (!(r > 0)) continue;
111
+ const round = r >= Math.min(box.width, box.height) / 2 - radiusTolerance;
112
+ if (!round && !radii.some((v) => Math.abs(v - r) <= radiusTolerance) && !offRadii.has(r)) offRadii.set(r, describe(el));
113
+ }
114
+ }
115
+
116
+ for (const [family, where] of offFamilies) push("DS-002", where, `font-family "${family}"`, `one of ${lock.fonts.join(", ")}`);
117
+ if (offColours.size) {
118
+ const list = [...offColours.entries()];
119
+ const severity = list.length >= rules["DS-003"].threshold.highCount ? "HIGH" : rules["DS-003"].threshold.lowSeverity;
120
+ push("DS-003", list.slice(0, 4).map(([, where]) => where).join(" | "), `${list.length} off-palette colour(s): ${list.slice(0, 8).map(([c]) => c).join(", ")}`, `the ${lock.style} palette (light and dark roles)`, severity);
121
+ }
122
+ if (offSizes.size) push("DS-004", [...offSizes.values()].slice(0, 3).join(" | "), `${offSizes.size} size(s) off scale: ${[...offSizes.keys()].slice(0, 8).map((n) => `${n}px`).join(", ")}`, `${sizes.join("/")}px or display ${display.min}–${display.max}px`);
123
+ if (offRadii.size) push("DS-005", [...offRadii.values()].slice(0, 3).join(" | "), `${offRadii.size} radius value(s) off lock: ${[...offRadii.keys()].slice(0, 6).map((n) => `${n}px`).join(", ")}`, `${radii.join("/")}px or fully round`);
124
+ if (offShadows.size) push("DS-007", [...offShadows.keys()].slice(0, 3).join(" | "), `${offShadows.size} element(s) with ${[...offShadows.values()].slice(0, 3).join(", ")} shadows`, elevation === "flat" ? "flat lock: rings only, no blur or offset" : "offset lock: zero-blur offsets only");
125
+ if (unparsed) notVerified.push({ rule: "DS-003", reason: `${unparsed} colour value(s) in a format the probe cannot compare` });
126
+
127
+ // DS-006: a starter-layout slot such as {{Product}} left in visible text or a labelling attribute.
128
+ const slot = new RegExp(rules["DS-006"].threshold.pattern, "u");
129
+ const textHit = slot.exec(document.body.innerText.slice(0, limits.textChars * 10));
130
+ if (textHit) push("DS-006", "page text", textHit[0], "no {{slot}} from the kit layouts");
131
+ for (const el of document.querySelectorAll("[title], [aria-label], [alt], [placeholder]")) {
132
+ for (const attr of ["title", "aria-label", "alt", "placeholder"]) {
133
+ const value = el.getAttribute(attr);
134
+ const hit = value && slot.exec(value);
135
+ if (hit) push("DS-006", `${describe(el)}[${attr}]`, hit[0], "no {{slot}} from the kit layouts");
136
+ }
137
+ }
138
+ const title = slot.exec(document.title);
139
+ if (title) push("DS-006", "title", title[0], "no {{slot}} from the kit layouts");
140
+
141
+ // DS-008: the one signature moment the lock declares. It must be declared, marked, big enough to
142
+ // read, in the first screen on a read-once page, hold what its kind promises, and not be the
143
+ // generic gradient-and-centred-text hero.
144
+ const m = rules["DS-008"].threshold;
145
+ const declared = lock.moment;
146
+ if (!declared) {
147
+ push("DS-008", "design.lock.json", "the lock predates signature moments and records none", "a moment recorded with design-lock.mjs moment");
148
+ return { findings, notVerified };
149
+ }
150
+ if (!declared.kind) {
151
+ push("DS-008", "design.lock.json", "the lock declares no signature moment", `a ${declared.scale} moment recorded with design-lock.mjs moment`);
152
+ return { findings, notVerified };
153
+ }
154
+ const marked = [...document.querySelectorAll(`[${m.attribute}]`)].filter(visible);
155
+ if (!marked.length) {
156
+ push("DS-008", "page", `no [${m.attribute}] element for the declared ${declared.kind}`, `one element marked ${m.attribute}="${declared.kind}"`);
157
+ return { findings, notVerified };
158
+ }
159
+ if (marked.length > 1) push("DS-008", marked.slice(0, 3).map(describe).join(" | "), `${marked.length} elements claim the signature moment`, "one moment per page", "MEDIUM");
160
+ const el = marked.find((node) => node.getAttribute(m.attribute) === declared.kind) ?? marked[0];
161
+ const where = describe(el);
162
+ const kind = el.getAttribute(m.attribute);
163
+ if (kind !== declared.kind) push("DS-008", where, `marked "${kind}" but the lock declares "${declared.kind}"`, "the declared kind");
164
+ const box = el.getBoundingClientRect();
165
+ const area = box.width * box.height;
166
+ const minArea = declared.scale === "bold" ? m.boldMinAreaPx : m.smallMinAreaPx;
167
+ if (area < minArea) push("DS-008", where, `${Math.round(box.width)}×${Math.round(box.height)} px moment is too small to read as one`, `≥ ${minArea} px²`);
168
+ const base = options.pass === "base";
169
+ if (declared.scale === "small" && base && innerWidth >= m.foldMinViewportPx && area > m.smallMaxViewportShare * innerWidth * innerHeight) push("DS-008", where, `a work-surface moment covering ${Math.round((100 * area) / (innerWidth * innerHeight))}% of the screen`, `≤ ${m.smallMaxViewportShare * 100}%: work surfaces stay calm`, "MEDIUM");
170
+ if (declared.above_fold && base) {
171
+ const top = box.top + scrollY;
172
+ const wide = innerWidth >= m.foldMinViewportPx;
173
+ const limit = wide ? innerHeight - m.foldMinVisiblePx : innerHeight * m.phoneScreens;
174
+ if (top > limit) push("DS-008", where, `moment starts ${Math.round(top)} px down, below the fold`, wide ? `starts by ${Math.round(limit)} px (first screen)` : `starts within ${m.phoneScreens} screens (${Math.round(limit)} px)`);
175
+ }
176
+ const has = (selector) => el.matches(selector) || [...el.querySelectorAll(selector)].some(visible);
177
+ const media = has(m.media);
178
+ const inside = [el, ...el.querySelectorAll("*")].filter(visible);
179
+ const texts = inside.filter((node) => [...node.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim()));
180
+ const need = m.needs[declared.kind];
181
+ const wants = {
182
+ media: ["an image, drawing, figure, frame or product view", () => media],
183
+ data: ["chart, table, figure or readout", () => has(m.data)],
184
+ control: ["control a reader can operate", () => has(m.control)],
185
+ "display-type": ["type at display size or above", () => texts.some((node) => Number.parseFloat(getComputedStyle(node).fontSize) >= display.min - sizeTolerance)],
186
+ motion: ["animation or transition", () => options.pass === "reduced-motion" || inside.some((node) => { const cs = getComputedStyle(node); return cs.animationName !== "none" || cs.transitionDuration.split(",").some((d) => Number.parseFloat(d) > 0); })],
187
+ }[need];
188
+ if (wants && !wants[1]()) push("DS-008", where, `declared ${declared.kind} but it holds no ${wants[0]}`, `a ${declared.kind} built from the page's content`);
189
+ const gradient = (node) => /gradient\(/u.test(getComputedStyle(node).backgroundImage) && !node.matches(".ui-scan, .ui-ruler");
190
+ const chain = [];
191
+ for (let node = el; node && node !== document.body && node !== document.documentElement; node = node.parentElement) chain.push(node);
192
+ const behind = chain.some(gradient) || inside.some((node) => { const b = node.getBoundingClientRect(); return gradient(node) && b.width * b.height >= m.coverShare * area; });
193
+ const biggest = texts.reduce((best, node) => (Number.parseFloat(getComputedStyle(node).fontSize) > (best ? Number.parseFloat(getComputedStyle(best).fontSize) : 0) ? node : best), null);
194
+ if (behind && !media && biggest && getComputedStyle(biggest).textAlign === "center") push("DS-008", where, "a generic gradient-and-centred-text hero", "a moment built from this page's content (composition, set piece, data, diagram, motion or object)");
195
+ return { findings, notVerified };
196
+ }
@@ -1,7 +1,7 @@
1
1
  // In-page measurement module for scripts/interface-probe.mjs.
2
2
  //
3
3
  // `measureInterface` runs inside the page under test. The driver serialises it with
4
- // Function.prototype.toString and evaluates it through the DevTools protocol, so the function stays
4
+ // Function.prototype.toString and evaluates it through agent-browser, so the function stays
5
5
  // self-contained: no imports, no closure over module scope, no network. It reads the live DOM,
6
6
  // computed styles and same-origin stylesheets and returns plain JSON. It clicks, presses and
7
7
  // submits nothing: dialogs are stubbed before the first read, and the only state it touches is the
@@ -698,8 +698,10 @@ export function measureInterface(options) {
698
698
  const cluster = clusters.find((entry) => hueDistance(entry.h, tone.h) <= th.hueToleranceDeg);
699
699
  if (cluster) cluster.count += 1; else clusters.push({ h: tone.h, count: 1, el });
700
700
  }
701
- if (clusters.length > th.maxClusters) {
702
- add("CF-205", { selector: clusters.map((entry) => selectorOf(entry.el)).slice(0, 3).join(" | "), value: `${clusters.length} saturated hue clusters (${clusters.map((entry) => `${Math.round(entry.h)}°`).join(", ")})`, threshold: `≤ ${th.maxClusters}` });
701
+ // Rich is the default register; a lock recorded as calm keeps the single-accent limit.
702
+ const limit = options.density === "calm" ? th.calmMaxClusters : th.maxClusters;
703
+ if (clusters.length > limit) {
704
+ add("CF-205", { selector: clusters.map((entry) => selectorOf(entry.el)).slice(0, 3).join(" | "), value: `${clusters.length} saturated hue clusters (${clusters.map((entry) => `${Math.round(entry.h)}°`).join(", ")})`, threshold: `≤ ${limit}` });
703
705
  }
704
706
  }
705
707
 
@@ -800,7 +802,7 @@ export function measureInterface(options) {
800
802
  }
801
803
 
802
804
  // ---- CF-403: hard offset shadow outside an evidenced register ----------------------------------
803
- if (on("CF-403")) {
805
+ if (on("CF-403") && options.register !== "neo-brutalist") {
804
806
  const th = t("CF-403");
805
807
  const hard = shown.filter((el) => shadowsOf(styleOf(el).boxShadow).some((s) => !s.inset && s.blur === 0 && s.spread === 0 && (Math.abs(s.x) > th.hairlinePx || Math.abs(s.y) > th.hairlinePx) && s.color));
806
808
  const bordered = hard.filter((el) => { const style = styleOf(el); return style.borderTopStyle === "solid" && px(style.borderTopWidth) >= th.registerBorderPx; });
@@ -1124,10 +1126,21 @@ export function measureInterface(options) {
1124
1126
 
1125
1127
  // ---- slop register: surfaces and type ---------------------------------------------------------
1126
1128
  const clippedToText = (style) => /text/u.test(`${style.backgroundClip} ${style.webkitBackgroundClip ?? ""}`);
1129
+ // One gradient headline in the palette's own hues is craft. The reflex is a violet stop, the
1130
+ // effect repeated across the page, or a stop the text cannot be read on.
1127
1131
  if (on("SLOP-009")) {
1128
- for (const el of textElements) {
1129
- const style = styleOf(el);
1130
- if (clippedToText(style) && /gradient\(/u.test(style.backgroundImage)) add("SLOP-009", { selector: selectorOf(el), value: `"${snippet(el, 30)}" filled with a gradient`, threshold: "solid text colour" });
1132
+ const th = t("SLOP-009");
1133
+ const violet = t("SLOP-008");
1134
+ const filled = textElements.filter((el) => clippedToText(styleOf(el)) && /gradient\(/u.test(styleOf(el).backgroundImage));
1135
+ for (const el of filled) {
1136
+ const stops = colorsIn(styleOf(el).backgroundImage);
1137
+ const surface = solidBehind(el);
1138
+ const floor = px(styleOf(el).fontSize) >= th.largeFontPx ? th.largeStopContrast : th.minStopContrast;
1139
+ const weakest = surface && stops.length ? Math.min(...stops.map((c) => ratio(blend(c, surface), surface))) : null;
1140
+ const reason = stops.some((c) => spread(c) >= violet.minChannelSpread && hsl(c).h >= violet.hueMin && hsl(c).h <= violet.hueMax) ? "a violet stop"
1141
+ : filled.length > th.maxElements ? `${filled.length} gradient-filled text elements`
1142
+ : weakest !== null && weakest < floor ? `a stop at ${round(weakest)}:1` : null;
1143
+ if (reason) add("SLOP-009", { selector: selectorOf(el), value: `"${snippet(el, 30)}" filled with a gradient (${reason})`, threshold: `≤ ${th.maxElements} gradient headline, palette hues, every stop ≥ ${floor}:1` });
1131
1144
  }
1132
1145
  }
1133
1146
  let bannedHue = false;
@@ -1150,12 +1163,18 @@ export function measureInterface(options) {
1150
1163
  }
1151
1164
  let blob = false;
1152
1165
  if (on("SLOP-011")) {
1166
+ const blobs = [];
1153
1167
  for (const el of shown) {
1154
1168
  const style = styleOf(el);
1155
1169
  if (!/(absolute|fixed)/u.test(style.position) || !/radial-gradient/u.test(style.backgroundImage)) continue;
1156
1170
  if ((el.innerText ?? "").trim() || el.querySelector("img,svg,video,canvas,input,select,textarea,button,a[href]")) continue;
1157
1171
  const faint = Number(style.opacity) < t("SLOP-011").maxOpacity || colorsIn(style.backgroundImage).every((c) => c.a < t("SLOP-011").maxOpacity);
1158
- if (faint) { blob = true; candidate("SLOP-011", { selector: selectorOf(el), value: "unbound faint radial gradient" }); }
1172
+ if (faint) blobs.push(el);
1173
+ }
1174
+ // One atmosphere layer is craft; scattered floating blobs are the generic decoration.
1175
+ if (blobs.length >= t("SLOP-011").minCount) {
1176
+ blob = true;
1177
+ for (const el of blobs) candidate("SLOP-011", { selector: selectorOf(el), value: `unbound faint radial gradient (${blobs.length} on the page)` });
1159
1178
  }
1160
1179
  }
1161
1180
  if (on("SLOP-012") && (blob || bannedHue)) {
@@ -1204,8 +1223,8 @@ export function measureInterface(options) {
1204
1223
  }
1205
1224
  }
1206
1225
  for (const entry of scopes.values()) {
1207
- const hit = entry.b.length >= 1 ? entry.b : entry.a.length >= th.minCount ? entry.a : entry.c.length >= th.minCount ? entry.c : null;
1208
- if (hit) add("SLOP-015", { selector: selectorOf(hit[0]), value: `${hit.length} rounded element(s) with a coloured accent stripe`, threshold: `< ${th.minCount} per scope (inset form: none)` });
1226
+ const hit = entry.b.length >= th.insetMinCount ? entry.b : entry.a.length >= th.minCount ? entry.a : entry.c.length >= th.minCount ? entry.c : null;
1227
+ if (hit) add("SLOP-015", { selector: selectorOf(hit[0]), value: `${hit.length} rounded element(s) with a coloured accent stripe`, threshold: `< ${th.minCount} per scope (inset form: < ${th.insetMinCount})` });
1209
1228
  }
1210
1229
  }
1211
1230
  const declaredFaces = new Map();
@@ -1444,6 +1463,8 @@ export function measureInterface(options) {
1444
1463
  for (const svg of shown.filter((el) => el.matches("svg"))) {
1445
1464
  const r = svg.getBoundingClientRect();
1446
1465
  if (r.width < th.minSizePx || r.height < th.minSizePx || svg.querySelector("pattern")) continue;
1466
+ // A drawing that names what it shows is an authored illustration; the generic stack is unnamed.
1467
+ if (svg.querySelector(":scope > title") || ((svg.getAttribute("role") === "img") && (svg.getAttribute("aria-label")?.trim() || svg.hasAttribute("aria-labelledby")))) continue;
1447
1468
  const shapes = [...svg.querySelectorAll("rect,circle,ellipse,polygon")];
1448
1469
  const fills = new Set(shapes.map((shape) => getComputedStyle(shape).fill).filter((fill) => fill && !/^(none|transparent|rgba\(0, 0, 0, 0\))$/u.test(fill)));
1449
1470
  if (shapes.length >= th.minShapes && fills.size >= th.minFills && svg.querySelectorAll("text,tspan").length <= th.maxText) candidate("SLOP-051", { selector: selectorOf(svg), value: `${shapes.length} primitives in ${fills.size} fills` });
@@ -35,13 +35,13 @@ export const RULES = Object.freeze({
35
35
  "CF-201": { title: "Text contrast below WCAG 2", severity: "HIGH", tier: "derived", threshold: { body: 4.5, large: 3, largeFontPx: 24, boldLargeFontPx: 18.5, boldWeight: 700, largeSeverity: "MEDIUM" }, fix: "Darken or lighten the text or its surface until the ratio clears 4.5:1 (3:1 for large text)." },
36
36
  "CF-202": { title: "Focus indicator missing or weak", severity: "HIGH", tier: "measured", threshold: { minWidthPx: 2, minContrast: 3, weakSeverity: "MEDIUM", sample: 5 }, fix: "Give :focus-visible a visible change; a custom ring needs 2px or more and 3:1 against the colour just outside it." },
37
37
  "CF-204": { title: "Text over a gradient below WCAG 2", severity: "HIGH", tier: "derived", threshold: { body: 4.5, large: 3 }, fix: "Put the text on a solid scrim or move it off the gradient's light end until the worst stop clears the floor." },
38
- "CF-205": { title: "More than one accent colour", severity: "MEDIUM", tier: "measured", threshold: { maxClusters: 1, hueToleranceDeg: 15, minSaturation: 0.4, minSurfacePx: 24, maxPillPx: 32, minLightness: 0.15, maxLightness: 0.85, statusTokens: ["success", "warning", "warn", "error", "danger", "destructive", "info", "critical"] }, fix: "Keep one saturated accent for actions in a view; move the other filled surfaces to neutrals." },
38
+ "CF-205": { title: "More accent hue families than the register holds", severity: "MEDIUM", tier: "measured", threshold: { maxClusters: 4, calmMaxClusters: 1, hueToleranceDeg: 15, minSaturation: 0.4, minSurfacePx: 24, maxPillPx: 32, minLightness: 0.15, maxLightness: 0.85, statusTokens: ["success", "warning", "warn", "error", "danger", "destructive", "info", "critical"] }, fix: "Hold a view to four saturated hue families (one in a calm lock); fold the extra fills into a family the page already uses or a neutral." },
39
39
 
40
40
  // Craft floor: spacing and surfaces
41
41
  "CF-301": { title: "Spacing off the 4px scale", severity: "LOW", tier: "measured", threshold: { stepPx: 4, tolerancePx: 1, minPx: 4, lowMax: 3, manySeverity: "MEDIUM" }, fix: "Snap padding, gaps and margins to the 4px scale (4, 8, 12, 16, 24, 32 …)." },
42
42
  "CF-304": { title: "Heading sits closer to the block above", severity: "LOW", tier: "measured", threshold: { ratio: 0.75, deficitPx: 12, labelMaxPx: 60, labelGapPx: 28 }, fix: "Give the heading more space above than below so it binds to the content it introduces." },
43
43
  "CF-401": { title: "Nested radius not concentric", severity: "LOW", tier: "measured", threshold: { tolerancePx: 2, maxParentPaddingPx: 24, edgeTolerancePx: 2, minChildPx: 32 }, fix: "Set the inner radius to the outer radius minus the padding between them." },
44
- "CF-403": { title: "Hard offset shadow outside a register", severity: "MEDIUM", tier: "measured", threshold: { hairlinePx: 1, registerCount: 3, registerBorderPx: 2 }, fix: "Use a soft neutral shadow or none, unless the direction records a neo-brutalist register.", note: "clears when the recorded direction names a neo-brutalist register" },
44
+ "CF-403": { title: "Hard offset shadow outside a register", severity: "MEDIUM", tier: "measured", threshold: { hairlinePx: 1, registerCount: 3, registerBorderPx: 2 }, fix: "Use a soft neutral shadow or none, unless the direction records a neo-brutalist register.", note: "clears when the recorded direction names a neo-brutalist register: a design.lock.json with elevation \"offset\" (the neo-brutalism house style) records one" },
45
45
  "CF-404": { title: "Glow on the primary action", severity: "LOW", tier: "measured", threshold: { minBlurPx: 16, minSaturation: 0.4, maxOffsetPx: 2, minAlpha: 0.2 }, fix: "Replace the coloured halo with a small neutral shadow or a border; the button's fill already carries the emphasis." },
46
46
  "CF-406": { title: "Blurred modal scrim", severity: "LOW", tier: "measured", threshold: { minCover: 0.9 }, fix: "Use a solid or near-solid scrim (for example rgb(0 0 0 / 0.5)) instead of backdrop-filter: blur." },
47
47
 
@@ -62,14 +62,26 @@ export const RULES = Object.freeze({
62
62
  "CF-806": { title: "Placeholder is the only label", severity: "MEDIUM", tier: "measured", threshold: null, fix: "Add a real <label> (or aria-label) and keep the placeholder as an example value." },
63
63
  "CF-807": { title: "Placeholder contrast below 4.5:1", severity: "MEDIUM", tier: "derived", threshold: { min: 4.5 }, fix: "Darken the placeholder colour until it reaches 4.5:1 against the field." },
64
64
 
65
+ // Design lock (references/house-styles.md). These run only when the page's directory holds a
66
+ // design.lock.json written by scripts/design-lock.mjs, or when --lock names one; the lock is the
67
+ // run's own declaration, so drifting from it is a defect the build introduced.
68
+ "DS-001": { title: "Design lock not applied", severity: "HIGH", tier: "measured", threshold: { property: "--ui-lock" }, fix: "Link ui/tokens.css (written by scripts/design-lock.mjs) before any other stylesheet so the page renders the locked style." },
69
+ "DS-002": { title: "Font family outside the lock", severity: "HIGH", tier: "measured", threshold: null, fix: "Use var(--font-text), var(--font-display) or var(--font-mono); a new family needs a new lock, not a page rule." },
70
+ "DS-003": { title: "Colour outside the locked palette", severity: "HIGH", tier: "measured", threshold: { channelTolerance: 8, minAlpha: 0.05, highCount: 4, lowSeverity: "MEDIUM" }, fix: "Replace the literal colour with the var(--c-*) role that means the same thing; a colour with no role is a decoration to delete." },
71
+ "DS-004": { title: "Font size off the locked scale", severity: "MEDIUM", tier: "measured", threshold: { tolerancePx: 0.5 }, fix: "Use a var(--fs-*) step, or var(--fs-display) for the one display title." },
72
+ "DS-005": { title: "Corner radius off the lock", severity: "LOW", tier: "measured", threshold: { tolerancePx: 0.5 }, fix: "Use a var(--radius-*) value (inner radius one step below its container) or make it fully round." },
73
+ "DS-006": { title: "Starter slot left in the page", severity: "HIGH", tier: "measured", threshold: { pattern: "\\{\\{(?! )[^{}\\n]{1,80}(?<! )\\}\\}" }, fix: "Replace every {{slot}} from the kit layout with real content, or delete the element that holds it." },
74
+ "DS-007": { title: "Elevation outside the lock grammar", severity: "MEDIUM", tier: "measured", threshold: { hairlinePx: 1 }, fix: "Use var(--shadow-raised), var(--shadow-overlay) or var(--shadow-control): a flat lock takes no blurred or offset shadow, an offset lock takes no blurred one." },
75
+ "DS-008": { title: "Signature moment missing, buried or generic", severity: "HIGH", tier: "measured", threshold: { attribute: "data-ui-moment", boldMinAreaPx: 40000, smallMinAreaPx: 4800, smallMaxViewportShare: 0.5, foldMinViewportPx: 1024, foldMinVisiblePx: 120, phoneScreens: 1.5, coverShare: 0.5, needs: { "hero-composition": "media", "type-set-piece": "display-type", "data-moment": "data", diagram: "media", "motion-detail": "motion", "interactive-object": "control", chart: "data", "empty-state": null, "header-detail": null }, media: "svg, img, canvas, video, picture, figure, table, [role=\"img\"], .ui-pixel, .ui-frame, .ui-readout, .ui-panel, .ui-chart, .ui-meter, .ui-sticker, .ui-bignum", data: "svg, canvas, table, [role=\"img\"], .ui-chart, .ui-meter, .ui-readout, .ui-bignum", control: "input, select, textarea, details, [role=\"slider\"], [role=\"switch\"], [role=\"tab\"], [aria-pressed], [aria-expanded]" }, fix: "Record the page's one signature moment with design-lock.mjs (--moment or the moment command), build it from this page's content, mark that element with data-ui-moment=\"<kind>\", and keep a read-once page's moment in the first screen." },
76
+
65
77
  // Slop register. Only SLOP-057, SLOP-058 and SLOP-059 can be HIGH.
66
78
  "SLOP-002": { title: "Identical icon-heading-text tiles", severity: "MEDIUM", tier: "measured", threshold: { minTiles: 4, sizeTolerancePx: 4 }, fix: "Vary tile size or structure, or present the items as a list or table." },
67
79
  "SLOP-008": { title: "Reflex violet accent", severity: "MEDIUM", tier: "measured", threshold: { hueMin: 260, hueMax: 310, minChannelSpread: 50, headingFontPx: 20 }, fix: "Swap the violet for a neutral surface plus one hue from the actual brand." },
68
- "SLOP-009": { title: "Gradient-filled text", severity: "MEDIUM", tier: "measured", threshold: null, fix: "Use a solid colour and a weight or size change for emphasis." },
69
- "SLOP-010": { title: "Glow halo repeated across elements", severity: "MEDIUM", tier: "measured", threshold: { recurCount: 2 }, fix: "Drop the coloured halo; use a neutral offset shadow or none." },
70
- "SLOP-011": { title: "Floating blob decoration", severity: "MEDIUM", tier: "derived", threshold: { maxOpacity: 0.3 }, fix: "Remove the blob or tie it to a real focal point.", judgment: "keep only with a stated atmospheric or focal purpose" },
80
+ "SLOP-009": { title: "Reflex gradient-filled text", severity: "MEDIUM", tier: "measured", threshold: { maxElements: 1, minStopContrast: 4.5, largeStopContrast: 3, largeFontPx: 24 }, fix: "Keep one gradient headline in the palette's own hues with every stop readable, or use a solid colour." },
81
+ "SLOP-010": { title: "Glow halo repeated across elements", severity: "MEDIUM", tier: "measured", threshold: { recurCount: 3 }, fix: "Keep a glow for the one surface it lights; drop the halo from the other controls and headings." },
82
+ "SLOP-011": { title: "Floating blob decoration", severity: "MEDIUM", tier: "derived", threshold: { maxOpacity: 0.3, minCount: 2 }, fix: "Keep one atmosphere layer tied to the focal point; remove the scattered extras.", judgment: "keep only with a stated atmospheric or focal purpose" },
71
83
  "SLOP-012": { title: "Decorative dot or grid background", severity: "MEDIUM", tier: "measured", threshold: { maxCellPx: 48, hairlinePx: 2, dataSvgPx: 48 }, fix: "Remove the pattern unless it stands in for a real chart or map." },
72
- "SLOP-015": { title: "Accent stripe on rounded cards", severity: "MEDIUM", tier: "measured", threshold: { minStripePx: 2, neutralSidePx: 1, minSaturation: 0.3, minCount: 2, barMinPx: 3, barMaxPx: 12, barInsetPx: 20, minChannelSpread: 30, minAlpha: 0.1 }, fix: "Drop the coloured stripe; carry emphasis with the card fill or an icon." },
84
+ "SLOP-015": { title: "Accent stripe on rounded cards", severity: "MEDIUM", tier: "measured", threshold: { minStripePx: 2, neutralSidePx: 1, minSaturation: 0.3, minCount: 3, insetMinCount: 2, barMinPx: 3, barMaxPx: 12, barInsetPx: 20, minChannelSpread: 30, minAlpha: 0.1 }, fix: "Drop the coloured stripe; carry emphasis with the card fill or an icon." },
73
85
  "SLOP-016": { title: "Reflex default font", severity: "MEDIUM", tier: "measured", threshold: { families: ["inter", "roboto", "open sans", "lato", "montserrat", "arial", "helvetica", "fraunces", "mona sans", "plus jakarta sans", "space grotesk", "recoleta", "instrument sans", "instrument serif"] }, fix: "Choose a face that suits the product, or record why the common one was kept.", judgment: "not a finding when the design records a reason for the font" },
74
86
  "SLOP-019": { title: "Too many font families", severity: "MEDIUM", tier: "measured", threshold: { max: 3 }, fix: "Cut the page back to at most three families." },
75
87
  "SLOP-020": { title: "Long all-caps text", severity: "LOW", tier: "measured", threshold: { maxChars: 40 }, fix: "Keep uppercase for short labels; sentence-case the rest." },
@@ -90,7 +102,7 @@ export const RULES = Object.freeze({
90
102
  "SLOP-046": { title: "Decorative gauge or sparkline", severity: "MEDIUM", tier: "derived", threshold: { sparklineMaxPx: 64 }, fix: "Remove it unless it encodes a real, current data point; then label the value.", judgment: "clears when it encodes a real data point" },
91
103
  "SLOP-047": { title: "Illegible logo ticker", severity: "MEDIUM", tier: "measured", threshold: { maxLogos: 6, minLogoPx: 20, viewport: "1440" }, fix: "Slow it down, enlarge the logos, or show them as a static row." },
92
104
  "SLOP-048": { title: "Autoplay dots with no binding", severity: "LOW", tier: "derived", threshold: { dotMaxPx: 16, minDots: 2 }, fix: "Remove the dots or make each one a named, focusable button that picks its slide." },
93
- "SLOP-051": { title: "Illustration stacked from primitives", severity: "MEDIUM", tier: "derived", threshold: { minShapes: 8, minSizePx: 200, minFills: 3, maxText: 2 }, fix: "Use a drawn or licensed illustration, or simplify it.", judgment: "a data graphic or diagram with an honest shape count clears it" },
105
+ "SLOP-051": { title: "Unlabelled illustration stacked from primitives", severity: "MEDIUM", tier: "derived", threshold: { minShapes: 8, minSizePx: 200, minFills: 3, maxText: 2 }, fix: "Name what the drawing shows (role=img with aria-label, or a <title>), or replace a generic primitive stack with a drawing of the subject.", judgment: "a data graphic or diagram with an honest shape count clears it" },
94
106
  "SLOP-052": { title: "Organic clip-path mask", severity: "LOW", tier: "measured", threshold: { minVertices: 10, grid: 25, gridTolerance: 0.5, minCurves: 3 }, fix: "Use a real photographic crop or a simple deliberate shape." },
95
107
  "SLOP-053": { title: "Emoji used as an icon", severity: "LOW", tier: "measured", threshold: { maxControlPx: 32 }, fix: "Use the project's icon set instead of emoji.", judgment: "not a finding when the brief asks for emoji" },
96
108
  "SLOP-057": { title: "Broken image", severity: "HIGH", tier: "measured", threshold: null, fix: "Fix the image source or remove the element." },
@@ -91,9 +91,12 @@ export const DETECTORS = Object.freeze({
91
91
  "SLOP-040": (text) => every(/>[^<>{}]*(?:—|(\S)\s+–\s+(\S))[^<>{}]*</gu, copyOf(text), (m) => m[0].slice(1, -1).trim())
92
92
  .filter(({ value }) => value.includes("—") || [...value.matchAll(/(\S)\s+–\s+(\S)/gu)].some((m) => !(/\d/u.test(m[1]) && /\d/u.test(m[2])))),
93
93
  "SLOP-036": (text) => every(new RegExp(`>[^<>{}]*?(?<![\\p{L}\\p{N}])(${RULES["SLOP-036"].threshold.phrases.map(escapeRegExp).join("|")})(?![\\p{L}\\p{N}])[^<>{}]*<`, "giu"), copyOf(text), (m) => m[1]),
94
+ "DS-006": (text) => every(new RegExp(RULES["DS-006"].threshold.pattern, "gu"), copyOf(text)),
94
95
  });
95
96
 
96
97
  export const STATIC_RULES = Object.freeze(Object.keys(DETECTORS));
98
+ // A detector listed here reads only these file types: framework templates use {{ }} on purpose.
99
+ const DETECTOR_EXTENSIONS = Object.freeze({ "DS-006": [".html", ".htm"] });
97
100
 
98
101
  export function staticProbe(target) {
99
102
  const files = listSourceFiles(target);
@@ -103,6 +106,7 @@ export function staticProbe(target) {
103
106
  for (const file of files) {
104
107
  const text = readFileSync(file, "utf8");
105
108
  for (const id of STATIC_RULES) {
109
+ if (DETECTOR_EXTENSIONS[id] && !DETECTOR_EXTENSIONS[id].includes(extname(file).toLowerCase())) continue;
106
110
  for (const hit of DETECTORS[id](text)) {
107
111
  const selector = `${relative(root, file) || file}:${lineOf(text, hit.index)}`;
108
112
  if (RULES[id].judgment) {
@@ -3,7 +3,7 @@
3
3
  // in-page measurement module, saves one screenshot per viewport, and reports findings.
4
4
  //
5
5
  // node interface-probe.mjs <page.html | directory | http(s) URL> [--out <dir>] [--static]
6
- // [--viewports 320,390,...] [--json]
6
+ // [--viewports 320,390,...] [--lock design.lock.json] [--json]
7
7
  //
8
8
  // Exit 0: all seven matrix rows ran and no measured or derived HIGH finding remains. Exit 1: at
9
9
  // least one such HIGH finding (this wins over an incomplete matrix). Exit 2: BLOCKED, printed as
@@ -13,12 +13,13 @@
13
13
  // chrome-driver.mjs (CHROME_PATH overrides). The browser is only resized, opened, read, hovered
14
14
  // (CF-506) and screenshotted; nothing is clicked, pressed or submitted.
15
15
  import { spawn } from "node:child_process";
16
- import { existsSync, mkdirSync, mkdtempSync, realpathSync, statSync, writeFileSync } from "node:fs";
16
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { basename, dirname, join, resolve } from "node:path";
19
19
  import process from "node:process";
20
20
  import { fileURLToPath, pathToFileURL } from "node:url";
21
21
  import { ChromeSession, probeChrome } from "./chrome-driver.mjs";
22
+ import { measureLock } from "./interface-probe-lock.mjs";
22
23
  import { measureInterface, readHover } from "./interface-probe-page.mjs";
23
24
  import { BLOCKED_REASON, EXIT, LIMITS, MATRIX, RULES, SEVERITY_ORDER } from "./interface-probe-rules.mjs";
24
25
  import { staticProbe } from "./interface-probe-static.mjs";
@@ -27,20 +28,37 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
27
28
  const COMMAND_TIMEOUT_MS = 30_000;
28
29
 
29
30
  export function parseArgs(argv) {
30
- const options = { target: null, out: null, static: false, json: false, viewports: null };
31
+ const options = { target: null, out: null, static: false, json: false, viewports: null, lock: null };
31
32
  for (let index = 0; index < argv.length; index += 1) {
32
33
  const arg = argv[index];
33
34
  if (arg === "--out") options.out = argv[++index];
34
35
  else if (arg === "--static") options.static = true;
35
36
  else if (arg === "--json") options.json = true;
37
+ else if (arg === "--lock") options.lock = argv[++index];
36
38
  else if (arg === "--viewports") options.viewports = argv[++index]?.split(",").map((value) => value.trim()).filter(Boolean);
37
39
  else if (!arg.startsWith("--") && options.target === null) options.target = arg;
38
40
  else throw new Error(`unknown argument ${arg}`);
39
41
  }
40
- if (!options.target) throw new Error("usage: interface-probe.mjs <page.html | directory | URL> [--out <dir>] [--static] [--viewports ids] [--json]");
42
+ if (!options.target) throw new Error("usage: interface-probe.mjs <page.html | directory | URL> [--out <dir>] [--static] [--viewports ids] [--lock design.lock.json] [--json]");
41
43
  return options;
42
44
  }
43
45
 
46
+ // The design lock beside the page (or named by --lock) turns on the DS-* rules. A lock that cannot
47
+ // be read is reported, never silently skipped.
48
+ export function findLock(target, explicit) {
49
+ const candidates = explicit ? [resolve(explicit)] : [target.root, target.sourceRoot && (existsSync(target.sourceRoot) && statSync(target.sourceRoot).isFile() ? dirname(target.sourceRoot) : target.sourceRoot)]
50
+ .filter(Boolean).map((dir) => join(dir, "design.lock.json"));
51
+ const path = candidates.find((candidate) => existsSync(candidate));
52
+ if (!path) return { lock: null, path: null, error: explicit ? `lock not found: ${explicit}` : null };
53
+ try {
54
+ const lock = JSON.parse(readFileSync(path, "utf8"));
55
+ if (lock.schema_id !== "litfamily.design-lock/v1" || typeof lock.style !== "string") return { lock: null, path, error: "design.lock.json is not a litfamily.design-lock/v1 file" };
56
+ return { lock, path, error: null };
57
+ } catch (error) {
58
+ return { lock: null, path, error: `design.lock.json unreadable: ${error.message}` };
59
+ }
60
+ }
61
+
44
62
  // A local target is served from its directory; a directory serves its built or root index.html.
45
63
  export function resolveTarget(target) {
46
64
  if (/^https?:\/\//iu.test(target)) return { url: target, root: null, page: null, sourceRoot: null };
@@ -69,14 +87,15 @@ function startServer(root) {
69
87
  });
70
88
  }
71
89
 
72
- async function probeViewport(chrome, url, viewport, outDir) {
90
+ async function probeViewport(chrome, url, viewport, outDir, lock) {
73
91
  let tab = null;
74
92
  try {
75
93
  tab = await chrome.newTab();
76
94
  await tab.setup(viewport);
77
95
  await tab.open(url, LIMITS.settleMs);
78
96
  const hover = RULES["CF-506"]?.threshold;
79
- const options = { rules: RULES, limits: LIMITS, touch: viewport.touch, pass: viewport.pass, hoverSample: hover && viewport.id === hover.viewport ? hover.sample : 0 };
97
+ // A lock with offset elevation is the recorded neo-brutalist register CF-403 accepts.
98
+ const options = { rules: RULES, limits: LIMITS, touch: viewport.touch, pass: viewport.pass, hoverSample: hover && viewport.id === hover.viewport ? hover.sample : 0, register: lock?.elevation === "offset" ? "neo-brutalist" : null, density: lock?.density ?? null };
80
99
  let measured = await tab.evaluate(measureInterface, options);
81
100
  // A page still loading after the settle wait gets exactly one more wait, then counts as unsettled.
82
101
  if (measured.ready === "loading") {
@@ -84,6 +103,19 @@ async function probeViewport(chrome, url, viewport, outDir) {
84
103
  measured = await tab.evaluate(measureInterface, options);
85
104
  if (measured.ready === "loading") return { viewport: viewport.id, error: "page did not settle" };
86
105
  }
106
+ if (lock) {
107
+ let locked;
108
+ try {
109
+ locked = await tab.evaluate(measureLock, { rules: RULES, limits: LIMITS, lock, pass: viewport.pass });
110
+ } catch (error) {
111
+ locked = { error: error.message };
112
+ }
113
+ if (locked.error) measured.notVerified.push({ rule: "DS-001", reason: `design-lock pass failed: ${locked.error.slice(0, 120)}` });
114
+ else {
115
+ measured.findings.push(...locked.findings);
116
+ measured.notVerified.push(...locked.notVerified);
117
+ }
118
+ }
87
119
  const path = join(outDir, "screenshots", `${viewport.id}.png`);
88
120
  let shot = true;
89
121
  try { await tab.screenshot(path); } catch { shot = false; }
@@ -156,10 +188,26 @@ export function renderTable(report) {
156
188
  if (judgment.length) lines.push("", "Judgment calls (Inferred until a reviewer decides; severity applies only once confirmed):", ...grouped(judgment));
157
189
  if (unrun.length) lines.push("", "Not verified:", ...grouped(unrun));
158
190
  const high = blockingHigh(report.findings).length;
191
+ const shots = (report.manifest.screenshots ?? []).filter(({ viewport }) => ["1440", "390", "320"].includes(viewport));
192
+ if (shots.length) {
193
+ lines.push("", "Look before calling it done (open these screenshots; fix the two worst answers, re-run the probe, two look rounds at most):");
194
+ for (const shot of shots) lines.push(`- ${shot.viewport}: ${shot.path}`);
195
+ lines.push(...LOOK_RUBRIC.map(([id, text]) => `${id} ${text}`));
196
+ }
159
197
  lines.push("", `Verdict: ${report.manifest.blocked_reason ? `BLOCKED: ${report.manifest.blocked_reason}` : high ? `Block (${high} HIGH)` : "Approve (0 HIGH)"}`);
160
198
  return lines.join("\n");
161
199
  }
162
200
 
201
+ // The fixed look rubric printed under every rendered run: concrete questions a screenshot answers.
202
+ export const LOOK_RUBRIC = Object.freeze([
203
+ ["LOOK-1", "One focal point: the signature moment (data-ui-moment) is where the eye lands first at 1440 and at 390; nothing else competes with it."],
204
+ ["LOOK-2", "Shared edges: section content starts on the same left edge at 1440; no block floats on a width of its own."],
205
+ ["LOOK-3", "Grouping by space: gaps between groups are visibly larger than gaps inside them; no empty band separates nothing."],
206
+ ["LOOK-4", "Kit, not costume: every block is a kit part or a page rule on the locked tokens; nothing decorative without a job."],
207
+ ["LOOK-5", "Composed mobile: at 390 and 320 the title breaks well, navigation and tables stay usable, nothing is a squeezed desktop."],
208
+ ["LOOK-6", "Specific content: names, numbers and copy belong to this brief; no filler and no leftover slot."],
209
+ ]);
210
+
163
211
  // C.5: a driver or entry blocker is exit 2; otherwise a measured or derived HIGH is exit 1 (even
164
212
  // with rows missing); otherwise any missing matrix row is exit 2; otherwise exit 0.
165
213
  export function decideExit({ findings, viewportsRun, blockedReason }) {
@@ -185,8 +233,12 @@ export async function runProbe(options) {
185
233
  screenshots: [],
186
234
  exit_code: EXIT.blocked,
187
235
  blocked_reason: null,
236
+ design_lock: null,
188
237
  };
189
238
  const findings = [];
239
+ const lockState = findLock(target, options.lock);
240
+ if (lockState.lock) manifest.design_lock = { path: lockState.path, style: lockState.lock.style, family: lockState.lock.family ?? null };
241
+ else manifest.not_verified.push({ rule: "DS-001", reason: lockState.error ?? "no design.lock.json beside the page; lock conformance not checked" });
190
242
 
191
243
  const driver = options.static || target.missing ? null : probeChrome();
192
244
  const driverReady = driver?.status === "available";
@@ -209,7 +261,7 @@ export async function runProbe(options) {
209
261
  continue;
210
262
  }
211
263
  const passStarted = Date.now();
212
- const result = await probeViewport(chrome, url, viewport, outDir);
264
+ const result = await probeViewport(chrome, url, viewport, outDir, lockState.lock);
213
265
  if (result.error) {
214
266
  manifest.not_verified.push({ rule: "*", viewport: viewport.id, reason: result.error.slice(0, 160) });
215
267
  continue;
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Pixel art for the field-notes style: turns a character grid into an inline SVG whose cells are
3
+ // filled with the locked colour roles, so the motif stays crisp at any size and follows dark mode.
4
+ //
5
+ // node pixel-art.mjs <grid.txt> --label "<what it shows>" [--cell 8]
6
+ //
7
+ // Grid characters: i ink, a accent, b second accent, m support (mist), s sunken, p surface,
8
+ // c canvas, l line-strong, 2 ink-2; `.` or space is empty. Every row must have the same width. The SVG
9
+ // renders at cell × columns CSS px by default; never let it drop below 6 px per cell.
10
+ import { readFileSync, realpathSync } from "node:fs";
11
+ import process from "node:process";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ export const PIXEL_ROLES = Object.freeze({ i: "ink", a: "accent", b: "accent-2", m: "support", s: "sunken", p: "surface", c: "canvas", l: "line-strong", 2: "ink-2" });
15
+
16
+ export function pixelSvg(grid, { label, cell = 8 } = {}) {
17
+ if (!label || !label.trim()) throw new Error("--label is required: say what the motif shows");
18
+ const rows = grid.replace(/\r/gu, "").split("\n").filter((row) => row.trim().length > 0);
19
+ if (rows.length === 0) throw new Error("the grid is empty");
20
+ const width = Math.max(...rows.map((row) => row.length));
21
+ if (rows.some((row) => row.trimEnd().length > width)) throw new Error("rows differ in width");
22
+ if (cell < 6) throw new Error("cells under 6 px stop reading as pixel art");
23
+ const rects = [];
24
+ rows.forEach((row, y) => {
25
+ const padded = row.padEnd(width, ".");
26
+ let x = 0;
27
+ while (x < width) {
28
+ const char = padded[x];
29
+ const role = PIXEL_ROLES[char];
30
+ if (char !== "." && char !== " " && !role) throw new Error(`unknown grid character "${char}" at row ${y + 1}`);
31
+ let end = x + 1;
32
+ while (end < width && padded[end] === char) end += 1;
33
+ if (role) rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" style="fill: var(--c-${role})"/>`);
34
+ x = end;
35
+ }
36
+ });
37
+ const escaped = label.replace(/[&<>"]/gu, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
38
+ return `<svg class="ui-pixel" viewBox="0 0 ${width} ${rows.length}" width="${width * cell}" height="${rows.length * cell}" role="img" aria-label="${escaped}" shape-rendering="crispEdges">${rects.join("")}</svg>`;
39
+ }
40
+
41
+ const startedDirectly = () => {
42
+ try {
43
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
44
+ } catch {
45
+ return false;
46
+ }
47
+ };
48
+
49
+ if (startedDirectly()) {
50
+ const args = process.argv.slice(2);
51
+ const option = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
52
+ const file = args.find((arg, i) => !arg.startsWith("--") && !args[i - 1]?.startsWith("--"));
53
+ try {
54
+ if (!file) throw new Error("usage: pixel-art.mjs <grid.txt> --label \"<what it shows>\" [--cell 8]");
55
+ process.stdout.write(`${pixelSvg(readFileSync(file, "utf8"), { label: option("label"), cell: Number(option("cell") ?? 8) })}\n`);
56
+ } catch (error) {
57
+ console.error(`pixel-art: ${error.message}`);
58
+ process.exitCode = 1;
59
+ }
60
+ }