@litfamily/litmuse 0.1.1 → 0.1.3
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/CHANGELOG.md +21 -0
- package/README.md +23 -1
- package/README_ko-KR.md +23 -1
- package/TRADEMARK.md +12 -0
- package/bin/litmuse.mjs +4 -0
- package/lib/stage-card.mjs +249 -6
- package/package.json +3 -2
- package/plugin/.muse-plugin/plugin.json +26 -11
- package/plugin/commands/lit-status.md +3 -1
- package/plugin/commands/lit.md +1 -1
- package/plugin/hooks/lib/digest.mjs +1 -1
- package/plugin/hooks/lib/next-step.mjs +112 -0
- package/plugin/hooks/lib/routes.mjs +59 -0
- package/plugin/hooks/lib/stage.mjs +53 -6
- package/plugin/hooks/lib/workflow-stack.mjs +336 -0
- package/plugin/hooks/post-tool-use-failure.mjs +2 -0
- package/plugin/hooks/post-tool-use.mjs +2 -0
- package/plugin/hooks/session-end.mjs +2 -1
- package/plugin/hooks/session-start.mjs +32 -2
- package/plugin/hooks/stop.mjs +23 -3
- package/plugin/hooks/user-prompt-submit.mjs +138 -7
- package/plugin/lib/capped-fetch.mjs +25 -0
- package/plugin/lib/pinned-fetch.mjs +79 -0
- package/plugin/lib/plan-task-rows.mjs +32 -0
- package/plugin/lib/render_pages.py +29 -1
- package/plugin/lib/rewind/collect.mjs +808 -0
- package/plugin/lib/rewind/gauge.mjs +72 -0
- package/plugin/lib/rewind/next-step.mjs +91 -0
- package/plugin/lib/rewind/redact.mjs +83 -0
- package/plugin/lib/rewind/transcripts.mjs +229 -0
- package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
- package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
- package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
- package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
- package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
- package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
- package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
- package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/plugin/skills/lit-adhd/SKILL.md +195 -0
- package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
- package/plugin/skills/lit-adhd/references/status-taxonomy.md +45 -0
- package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
- package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
- package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
- package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
- package/plugin/skills/lit-docx/SKILL.md +1 -1
- package/plugin/skills/lit-hijack/SKILL.md +589 -0
- package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
- package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
- package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
- package/plugin/skills/lit-plan/SKILL.md +12 -1
- package/plugin/skills/lit-plan/scripts/check-plan.mjs +71 -4
- package/plugin/skills/lit-pptx/SKILL.md +20 -9
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
- package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
- package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
- package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
- package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
- package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
- package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
- package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
- package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
- package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
- package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
- package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/plugin/skills/lit-recap/SKILL.md +3 -0
- package/plugin/skills/lit-schematic/SKILL.md +241 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
- package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
- package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
- package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
- package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
- package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
- package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
- package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
- package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
- package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
- package/plugin/skills/lit-schematic/references/verification.md +24 -0
- package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
- package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
- package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
- package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
- package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
- package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
- package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
- package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
- package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
- package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
- package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
- package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
- package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
- package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
- package/plugin/skills/lit-team/scripts/state-root.mjs +5 -18
- package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
- package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
- package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
- package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
- package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
- package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
- package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
- package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
- package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
- package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
- package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
- package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
- package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
- package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
- package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
- package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
- package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
- package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
- package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
- package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
- package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
- package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
- package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
- package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
- package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/plugin/skills/readme-studio/SKILL.md +1 -1
- package/plugin/skills/start-work/SKILL.md +10 -1
|
@@ -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
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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)
|
|
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 >=
|
|
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:
|
|
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
|
|
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: "
|
|
69
|
-
"SLOP-010": { title: "Glow halo repeated across elements", severity: "MEDIUM", tier: "measured", threshold: { recurCount:
|
|
70
|
-
"SLOP-011": { title: "Floating blob decoration", severity: "MEDIUM", tier: "derived", threshold: { maxOpacity: 0.3 }, fix: "
|
|
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: "
|
|
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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
+
}
|