dontmakeitugly 0.1.0

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/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # dontmakeitugly
2
+
3
+ A design contract for your repo, and a check that fails when it drifts.
4
+
5
+ ```sh
6
+ npx dontmakeitugly init first-vibe-coded-app # DESIGN.md + check config + AGENTS.md block
7
+ # build with your agent, start the dev server, then:
8
+ npx dontmakeitugly check # .dmiu/report.md for the agent to read
9
+ ```
10
+
11
+ `init` fetches a pack from [dontmakeitugly.com/packs](https://dontmakeitugly.com/packs): a `DESIGN.md` on Google's open format, reference screens, and a `dmiu.config.json`. It appends an instruction block to `AGENTS.md` (and `CLAUDE.md` if you have one) telling the agent to read the contract before writing UI and to run the check before saying it's done.
12
+
13
+ `check` screenshots your routes at desktop and mobile widths with Playwright and runs deterministic checks derived from the DESIGN.md tokens:
14
+
15
+ - fonts outside the contract (error)
16
+ - colours off the palette, within a tolerance (error above a handful, else warning)
17
+ - WCAG AA contrast on text (error)
18
+ - purple/blue gradients (error) and any gradient when the contract forbids them (warning)
19
+ - animations that keep running under `prefers-reduced-motion: reduce` (error)
20
+ - padding and gaps off the spacing scale, radii off the rounded scale (warnings)
21
+ - screenshot baselines with pixel diffs (error above the threshold; `--update-baselines` to accept)
22
+
23
+ Skip an element and its subtree with `data-design-check="ignore"`, or add selectors to `ignore` in `dmiu.config.json`.
24
+
25
+ Chromium is needed once: `npx playwright install chromium`.
package/dist/check.js ADDED
@@ -0,0 +1,113 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { chromium } from "playwright";
4
+ import { loadConfig } from "./config.js";
5
+ import { loadContract } from "./contract.js";
6
+ import { collect } from "./collect.js";
7
+ import { checkColors, checkContrast, checkFonts, checkGradients, checkMotion, checkRadius, checkSpacing } from "./checks/style.js";
8
+ import { checkBaseline } from "./checks/baseline.js";
9
+ import { summarise, writeReport } from "./report.js";
10
+ function slugify(s) {
11
+ return s.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home";
12
+ }
13
+ async function launch() {
14
+ try {
15
+ return await chromium.launch();
16
+ }
17
+ catch (e) {
18
+ const msg = e.message;
19
+ if (/Executable doesn't exist|browserType.launch/.test(msg)) {
20
+ throw new Error("Chromium is not installed for Playwright. Run: npx playwright install chromium");
21
+ }
22
+ throw e;
23
+ }
24
+ }
25
+ async function reachable(url) {
26
+ try {
27
+ const res = await fetch(url, { method: "GET", redirect: "follow" });
28
+ return res.ok || res.status === 304;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export async function runCheck(opts) {
35
+ const log = opts.log ?? (() => { });
36
+ const overrides = {};
37
+ if (opts.routes?.length)
38
+ overrides.routes = opts.routes;
39
+ if (opts.baseUrl)
40
+ overrides.baseUrl = opts.baseUrl;
41
+ const cfg = loadConfig(opts.cwd, overrides);
42
+ const designFile = path.resolve(opts.cwd, cfg.designMd);
43
+ if (!fs.existsSync(designFile)) {
44
+ throw new Error(`${cfg.designMd} not found. Run "npx dontmakeitugly init <pack>" first, or point dmiu.config.json at your DESIGN.md.`);
45
+ }
46
+ const contract = loadContract(designFile);
47
+ log(`Contract: ${contract.name} — ${contract.fonts.length} font(s), ${contract.palette.length} colour(s), ${contract.spacing.length} spacing step(s)`);
48
+ const first = new URL(cfg.routes[0], cfg.baseUrl).toString();
49
+ if (!(await reachable(first))) {
50
+ throw new Error(`Nothing is answering at ${cfg.baseUrl}. Start the dev server (or pass --base-url) and run the check again.`);
51
+ }
52
+ const outDir = path.resolve(opts.cwd, cfg.outDir);
53
+ const shotsDir = path.join(outDir, "screenshots");
54
+ fs.mkdirSync(shotsDir, { recursive: true });
55
+ const browser = await launch();
56
+ const pages = [];
57
+ try {
58
+ for (const vp of cfg.viewports) {
59
+ const viewport = `${vp.width}x${vp.height}`;
60
+ const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: 1, reducedMotion: "no-preference" });
61
+ const reducedCtx = await browser.newContext({ viewport: vp, deviceScaleFactor: 1, reducedMotion: "reduce" });
62
+ for (const route of cfg.routes) {
63
+ const url = new URL(route, cfg.baseUrl).toString();
64
+ const name = `${slugify(route)}@${vp.width}`;
65
+ log(`Checking ${route} at ${viewport}`);
66
+ const page = await ctx.newPage();
67
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
68
+ await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => { });
69
+ await page.evaluate(() => document.fonts?.ready).catch(() => { });
70
+ await page.waitForTimeout(600);
71
+ const collected = await collect(page, cfg.ignore);
72
+ const screenshotPath = path.join(shotsDir, `${name}.png`);
73
+ // Settle animations before the pixel comparison.
74
+ await page.waitForTimeout(1200);
75
+ const shot = await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" });
76
+ await page.close();
77
+ const reduced = await reducedCtx.newPage();
78
+ await reduced.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
79
+ await reduced.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => { });
80
+ await reduced.waitForTimeout(400);
81
+ const reducedCollected = await collect(reduced, cfg.ignore);
82
+ await reduced.close();
83
+ const checks = [
84
+ checkFonts(collected.samples, contract),
85
+ checkColors(collected.samples, contract, cfg.tolerance.color),
86
+ checkContrast(collected.samples),
87
+ checkGradients(collected.samples, cfg.forbidGradients),
88
+ checkMotion(reducedCollected.runningAnimations, collected.samples),
89
+ checkSpacing(collected.samples, contract, cfg.tolerance.spacing),
90
+ checkRadius(collected.samples, contract),
91
+ checkBaseline(shot, name, path.resolve(opts.cwd, cfg.baselines.dir), cfg.baselines.threshold, Boolean(opts.updateBaselines)).check,
92
+ ];
93
+ pages.push({ route, viewport, url, screenshot: path.relative(opts.cwd, screenshotPath), checks });
94
+ }
95
+ await ctx.close();
96
+ await reducedCtx.close();
97
+ }
98
+ }
99
+ finally {
100
+ await browser.close();
101
+ }
102
+ const summary = summarise(pages);
103
+ const report = {
104
+ ok: summary.errors === 0,
105
+ generatedAt: new Date().toISOString(),
106
+ designMd: cfg.designMd,
107
+ contract: { name: contract.name, fonts: contract.fonts, palette: contract.palette.length, spacing: contract.spacing, radii: contract.radii },
108
+ summary,
109
+ pages,
110
+ };
111
+ const files = writeReport(outDir, report);
112
+ return { report, files };
113
+ }
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { PNG } from "pngjs";
4
+ import pixelmatch from "pixelmatch";
5
+ import { result } from "./types.js";
6
+ /**
7
+ * Compares a screenshot against the committed baseline. A missing baseline
8
+ * is written and reported as a pass, so the first run seeds the set.
9
+ */
10
+ export function checkBaseline(shot, name, dir, threshold, update) {
11
+ fs.mkdirSync(dir, { recursive: true });
12
+ const file = path.join(dir, `${name}.png`);
13
+ if (!fs.existsSync(file) || update) {
14
+ fs.writeFileSync(file, shot);
15
+ return { check: result("baseline", "Screenshot baseline", "error", [], update ? `Baseline updated: ${file}` : `Baseline created: ${file}`), created: true };
16
+ }
17
+ const a = PNG.sync.read(fs.readFileSync(file));
18
+ const b = PNG.sync.read(shot);
19
+ if (a.width !== b.width || a.height !== b.height) {
20
+ return {
21
+ check: result("baseline", "Screenshot baseline", "error", [{ selector: name, value: `page size changed: ${a.width}×${a.height} → ${b.width}×${b.height}` }], "The page size differs from the baseline, so the pixels were not compared.", "If the change is intended, run with --update-baselines."),
22
+ created: false,
23
+ };
24
+ }
25
+ const diff = new PNG({ width: a.width, height: a.height });
26
+ const changed = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold: 0.1 });
27
+ const ratio = changed / (a.width * a.height);
28
+ const diffPath = path.join(dir, `${name}.diff.png`);
29
+ if (ratio > threshold) {
30
+ fs.writeFileSync(diffPath, PNG.sync.write(diff));
31
+ return {
32
+ check: result("baseline", "Screenshot baseline", "error", [{ selector: name, value: `${(ratio * 100).toFixed(2)}% of pixels changed (limit ${(threshold * 100).toFixed(1)}%)`, nearest: diffPath }], "The page drifted from its baseline.", "Open the diff image; if the change is intended, run with --update-baselines."),
33
+ created: false,
34
+ diffPath,
35
+ };
36
+ }
37
+ if (fs.existsSync(diffPath))
38
+ fs.rmSync(diffPath);
39
+ return { check: result("baseline", "Screenshot baseline", "error", [], `Matches baseline (${(ratio * 100).toFixed(2)}% changed).`), created: false };
40
+ }
@@ -0,0 +1,228 @@
1
+ import { parse as parseColor, converter, differenceEuclidean, wcagContrast, formatHex } from "culori";
2
+ import { result } from "./types.js";
3
+ const toOklab = converter("oklab");
4
+ const toOklch = converter("oklch");
5
+ const toRgb = converter("rgb");
6
+ const deltaE = differenceEuclidean("oklab");
7
+ const GENERIC_FAMILIES = new Set([
8
+ "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif",
9
+ "ui-monospace", "ui-rounded", "math", "emoji", "fangsong", "-apple-system", "blinkmacsystemfont",
10
+ "segoe ui", "roboto", "helvetica neue", "arial", "apple color emoji", "segoe ui emoji", "segoe ui symbol",
11
+ ]);
12
+ /** next/font names look like "__Inter_a1b2c3" or "Inter Fallback"; normalise both. */
13
+ function normaliseFamily(f) {
14
+ return f
15
+ .replace(/^["']|["']$/g, "")
16
+ .replace(/^__/, "")
17
+ .replace(/_[a-f0-9]{6}$/i, "")
18
+ .replace(/_/g, " ")
19
+ .replace(/\s+fallback$/i, "")
20
+ .trim()
21
+ .toLowerCase();
22
+ }
23
+ export function checkFonts(samples, contract) {
24
+ const allowed = new Set(contract.fonts);
25
+ const items = [];
26
+ if (allowed.size === 0)
27
+ return result("fonts", "Fonts", "warning", [], "DESIGN.md defines no typography, so fonts were not checked.");
28
+ for (const s of samples) {
29
+ if (!s.isText)
30
+ continue;
31
+ const first = normaliseFamily(s.fontFamily.split(",")[0] ?? "");
32
+ if (!first || allowed.has(first))
33
+ continue;
34
+ if (GENERIC_FAMILIES.has(first)) {
35
+ items.push({ selector: s.selector, value: first, text: s.text, nearest: "no contract font applied" });
36
+ }
37
+ else {
38
+ items.push({ selector: s.selector, value: first, text: s.text });
39
+ }
40
+ }
41
+ return result("fonts", "Fonts", "error", dedupe(items), items.length ? `${items.length} text element(s) use a typeface outside the contract (${contract.fonts.join(", ")}).` : `Every text element uses a contract typeface (${contract.fonts.join(", ")}).`, "Load the contract fonts and set them on body and headings; remove any other font-family declarations.");
42
+ }
43
+ /** Composite an rgba colour onto an opaque background. */
44
+ function composite(fg, bg) {
45
+ const a = fg.alpha ?? 1;
46
+ if (a >= 1 || !bg)
47
+ return { ...fg, alpha: 1 };
48
+ return { mode: "rgb", r: fg.r * a + bg.r * (1 - a), g: fg.g * a + bg.g * (1 - a), b: fg.b * a + bg.b * (1 - a), alpha: 1 };
49
+ }
50
+ function effective(css, bgCss) {
51
+ const c = parseColor(css);
52
+ if (!c)
53
+ return null;
54
+ const rgb = toRgb(c);
55
+ if (!rgb)
56
+ return null;
57
+ if ((rgb.alpha ?? 1) === 0)
58
+ return null;
59
+ const bg = bgCss ? toRgb(parseColor(bgCss) ?? "white") ?? null : null;
60
+ return composite(rgb, bg);
61
+ }
62
+ export function checkColors(samples, contract, tolerance) {
63
+ if (contract.palette.length === 0)
64
+ return result("colors", "Palette", "warning", [], "DESIGN.md defines no colours, so the palette was not checked.");
65
+ const items = [];
66
+ const seen = new Set();
67
+ const consider = (s, css, role) => {
68
+ const rgb = effective(css, s.effectiveBackground);
69
+ if (!rgb)
70
+ return;
71
+ const hex = formatHex(rgb);
72
+ const lab = toOklab(rgb);
73
+ if (!lab)
74
+ return;
75
+ let best = { name: "", d: Infinity };
76
+ for (const p of contract.palette) {
77
+ const d = deltaE(lab, p.lab) * 100;
78
+ if (d < best.d)
79
+ best = { name: p.name, d };
80
+ }
81
+ if (best.d <= tolerance)
82
+ return;
83
+ const key = `${role}:${hex}`;
84
+ if (seen.has(key))
85
+ return;
86
+ seen.add(key);
87
+ items.push({ selector: s.selector, value: `${role} ${hex}`, nearest: `${best.name} (ΔE ${best.d.toFixed(1)})`, text: s.text || undefined });
88
+ };
89
+ for (const s of samples) {
90
+ if (s.isText)
91
+ consider(s, s.color, "text");
92
+ consider(s, s.backgroundColor, "background");
93
+ if (s.hasBorder)
94
+ consider(s, s.borderColor, "border");
95
+ }
96
+ return result("colors", "Palette", items.length > 6 ? "error" : "warning", items, items.length ? `${items.length} colour(s) in use are not in the palette (tolerance ΔE ${tolerance}).` : "Every colour in use is in the palette.", "Replace each off-palette value with the nearest token named here, or add the token to DESIGN.md if it was a deliberate omission.");
97
+ }
98
+ export function checkContrast(samples) {
99
+ const items = [];
100
+ for (const s of samples) {
101
+ if (!s.isText || s.effectiveBackground === null)
102
+ continue;
103
+ const fg = effective(s.color, s.effectiveBackground);
104
+ const bg = effective(s.backgroundColor, s.effectiveBackground) ?? toRgb(parseColor(s.effectiveBackground) ?? "white");
105
+ if (!fg || !bg)
106
+ continue;
107
+ const ratio = wcagContrast(fg, bg);
108
+ const large = s.fontSize >= 24 || (s.fontSize >= 18.66 && s.fontWeight >= 700);
109
+ const min = large ? 3 : 4.5;
110
+ if (ratio < min) {
111
+ items.push({ selector: s.selector, value: `${ratio.toFixed(2)}:1 (${formatHex(fg)} on ${formatHex(bg)}, ${s.fontSize}px)`, nearest: `needs ${min}:1`, text: s.text });
112
+ }
113
+ }
114
+ return result("contrast", "Contrast (WCAG AA)", "error", items, items.length ? `${items.length} text element(s) fall below WCAG AA contrast.` : "All text meets WCAG AA contrast.", "Use a darker text token or a lighter background for the elements listed; 4.5:1 for body text, 3:1 for text 24px and up.");
115
+ }
116
+ export function checkGradients(samples, forbid) {
117
+ const ai = [];
118
+ const other = [];
119
+ for (const s of samples) {
120
+ if (!s.backgroundImage.includes("gradient("))
121
+ continue;
122
+ const stops = (s.backgroundImage.match(/(rgba?\([^)]*\)|#[0-9a-f]{3,8}|oklch\([^)]*\)|hsla?\([^)]*\))/gi) ?? [])
123
+ .map((c) => parseColor(c))
124
+ .filter((c) => Boolean(c));
125
+ const hexes = new Set(stops.map((c) => formatHex(c)));
126
+ if (hexes.size <= 1)
127
+ continue; // single-colour "gradient" hairline hack, effectively flat
128
+ const purpleBlue = stops.some((c) => {
129
+ const lch = toOklch(c);
130
+ return lch && (lch.c ?? 0) > 0.08 && lch.h !== undefined && lch.h >= 220 && lch.h <= 300;
131
+ });
132
+ const item = { selector: s.selector, value: s.backgroundImage.slice(0, 90) };
133
+ if (purpleBlue)
134
+ ai.push(item);
135
+ else
136
+ other.push(item);
137
+ }
138
+ const items = [...ai, ...(forbid ? other : [])];
139
+ return result("gradients", "Gradients", ai.length ? "error" : "warning", items, ai.length
140
+ ? `${ai.length} purple/blue gradient(s) found. This is the signature of a generated interface.`
141
+ : items.length
142
+ ? `${items.length} gradient(s) found; the contract forbids gradients.`
143
+ : "No gradients.", "Replace the gradient with a flat token colour, or a photograph if the section needs texture.");
144
+ }
145
+ function nearestOnScale(v, scale, base, tol) {
146
+ let nearest = scale[0] ?? 0;
147
+ let best = Infinity;
148
+ for (const s of scale) {
149
+ const d = Math.abs(s - v);
150
+ if (d < best) {
151
+ best = d;
152
+ nearest = s;
153
+ }
154
+ }
155
+ if (best <= tol)
156
+ return { ok: true, nearest };
157
+ if (base && base > 0 && Math.abs(v / base - Math.round(v / base)) * base <= tol)
158
+ return { ok: true, nearest: Math.round(v / base) * base };
159
+ return { ok: false, nearest };
160
+ }
161
+ export function checkSpacing(samples, contract, tolerance) {
162
+ if (contract.spacing.length === 0)
163
+ return result("spacing", "Spacing scale", "warning", [], "DESIGN.md defines no spacing scale, so spacing was not checked.");
164
+ const items = [];
165
+ const seen = new Set();
166
+ for (const s of samples) {
167
+ const values = [
168
+ ...s.padding.map((v, i) => [`padding-${["top", "right", "bottom", "left"][i]}`, v]),
169
+ ...s.gap.map((v, i) => [i === 0 ? "row-gap" : "column-gap", v]),
170
+ ];
171
+ for (const [prop, v] of values) {
172
+ if (v <= 0 || v > 160)
173
+ continue;
174
+ const { ok, nearest } = nearestOnScale(v, contract.spacing, contract.spacingBase, tolerance);
175
+ if (ok)
176
+ continue;
177
+ const key = `${prop}:${v}`;
178
+ if (seen.has(key))
179
+ continue;
180
+ seen.add(key);
181
+ items.push({ selector: s.selector, value: `${prop}: ${v}px`, nearest: `${nearest}px` });
182
+ }
183
+ }
184
+ return result("spacing", "Spacing scale", "warning", items, items.length ? `${items.length} padding/gap value(s) are off the spacing scale (${contract.spacing.join(", ")}px).` : "All padding and gap values sit on the spacing scale.", "Snap each value to the nearest step named here.");
185
+ }
186
+ export function checkRadius(samples, contract) {
187
+ if (contract.radii.length === 0)
188
+ return result("radius", "Corner radii", "warning", [], "DESIGN.md defines no radii, so corners were not checked.");
189
+ const items = [];
190
+ const seen = new Set();
191
+ for (const s of samples) {
192
+ for (const r of s.borderRadius) {
193
+ if (r <= 0)
194
+ continue;
195
+ const pill = r >= Math.min(s.width, s.height) / 2;
196
+ if (pill)
197
+ continue;
198
+ const ok = contract.radii.some((x) => Math.abs(x - r) <= 0.5);
199
+ if (ok || seen.has(r))
200
+ continue;
201
+ seen.add(r);
202
+ const nearest = contract.radii.reduce((a, b) => (Math.abs(b - r) < Math.abs(a - r) ? b : a));
203
+ items.push({ selector: s.selector, value: `border-radius: ${r}px`, nearest: `${nearest}px` });
204
+ }
205
+ }
206
+ return result("radius", "Corner radii", "warning", items, items.length ? `${items.length} radius value(s) are not in the contract (${contract.radii.join(", ")}px).` : "All corner radii are in the contract.", "Use the rounded tokens from DESIGN.md; pills (fully rounded) are always allowed.");
207
+ }
208
+ export function checkMotion(running, samples) {
209
+ const items = running
210
+ .filter((a) => a.duration > 100)
211
+ .map((a) => ({ selector: a.selector, value: `${a.name} (${Math.round(a.duration)}ms) still running with prefers-reduced-motion: reduce` }));
212
+ const slow = samples.filter((s) => s.transitionDuration > 200).slice(0, 4);
213
+ return result("motion", "Reduced motion", "error", items, items.length
214
+ ? `${items.length} animation(s) keep running when the user asks for reduced motion.`
215
+ : slow.length
216
+ ? `Reduced motion is respected. Note: ${slow.length} element(s) have transitions over 200ms.`
217
+ : "Reduced motion is respected.", "Wrap animations in a prefers-reduced-motion media query or set their duration to 0.01ms under it.");
218
+ }
219
+ function dedupe(items) {
220
+ const seen = new Set();
221
+ return items.filter((i) => {
222
+ const k = `${i.value}|${i.selector.split(" > ").pop()}`;
223
+ if (seen.has(k))
224
+ return false;
225
+ seen.add(k);
226
+ return true;
227
+ });
228
+ }
@@ -0,0 +1,5 @@
1
+ export const MAX_ITEMS = 12;
2
+ export function result(id, title, severity, items, message, fix) {
3
+ const status = items.length === 0 ? "pass" : severity === "error" ? "fail" : "warn";
4
+ return { id, title, status, message, fix, items: items.slice(0, MAX_ITEMS), count: items.length };
5
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { runInit } from "./init.js";
4
+ import { runCheck } from "./check.js";
5
+ import { SITE } from "./config.js";
6
+ const HELP = `dontmakeitugly — a design contract for your repo, and a check that fails when it drifts.
7
+
8
+ Usage:
9
+ npx dontmakeitugly init <pack> [--force] [--with-screens]
10
+ npx dontmakeitugly check [--routes /,/pricing] [--base-url http://localhost:3000] [--update-baselines] [--json]
11
+
12
+ init Fetches a pack from ${SITE} and writes DESIGN.md, dmiu.config.json and
13
+ .dmiu/references/README.md, and adds a block to AGENTS.md (and CLAUDE.md if present).
14
+ check Screenshots the routes in dmiu.config.json at each viewport and checks fonts,
15
+ palette, contrast, gradients, reduced motion, spacing, radii and baselines
16
+ against DESIGN.md. Writes .dmiu/report.md and .dmiu/report.json. Exit 1 on errors.
17
+
18
+ Packs: ${SITE}/packs
19
+ `;
20
+ async function main() {
21
+ const { values, positionals } = parseArgs({
22
+ allowPositionals: true,
23
+ options: {
24
+ force: { type: "boolean", default: false },
25
+ "with-screens": { type: "boolean", default: false },
26
+ "update-baselines": { type: "boolean", default: false },
27
+ json: { type: "boolean", default: false },
28
+ routes: { type: "string" },
29
+ "base-url": { type: "string" },
30
+ site: { type: "string" },
31
+ help: { type: "boolean", short: "h", default: false },
32
+ },
33
+ });
34
+ const [cmd, arg] = positionals;
35
+ const cwd = process.cwd();
36
+ const log = (l) => {
37
+ if (!values.json)
38
+ console.log(l);
39
+ };
40
+ if (values.help || !cmd) {
41
+ console.log(HELP);
42
+ process.exit(cmd ? 0 : 1);
43
+ }
44
+ if (cmd === "init") {
45
+ if (!arg) {
46
+ console.error(`Which pack? Try: npx dontmakeitugly init first-vibe-coded-app\nAll packs: ${SITE}/packs`);
47
+ process.exit(1);
48
+ }
49
+ const { written, skipped } = await runInit({ cwd, pack: arg, force: values.force, withScreens: values["with-screens"], site: values.site, log });
50
+ for (const w of written)
51
+ log(` wrote ${w}`);
52
+ for (const s of skipped)
53
+ log(` kept ${s} (already exists; use --force to overwrite)`);
54
+ log("");
55
+ log("Next: build with your agent, then start the dev server and run `npx dontmakeitugly check`.");
56
+ if (!process.env.CI)
57
+ log("If Chromium isn't installed for Playwright yet: npx playwright install chromium");
58
+ return;
59
+ }
60
+ if (cmd === "check") {
61
+ const routes = values.routes?.split(",").map((r) => r.trim()).filter(Boolean);
62
+ const { report, files } = await runCheck({ cwd, updateBaselines: values["update-baselines"], routes, baseUrl: values["base-url"], log });
63
+ if (values.json) {
64
+ console.log(JSON.stringify(report, null, 2));
65
+ }
66
+ else {
67
+ console.log("");
68
+ for (const p of report.pages) {
69
+ console.log(`${p.route} at ${p.viewport}`);
70
+ for (const c of p.checks)
71
+ console.log(` ${c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✗"} ${c.title}: ${c.message}`);
72
+ }
73
+ console.log("");
74
+ console.log(`${report.ok ? "✓ passed" : "✗ failed"} — ${report.summary.errors} error(s), ${report.summary.warnings} warning(s). Report: ${files.md}`);
75
+ }
76
+ process.exit(report.ok ? 0 : 1);
77
+ }
78
+ console.error(`Unknown command "${cmd}".\n\n${HELP}`);
79
+ process.exit(1);
80
+ }
81
+ main().catch((e) => {
82
+ console.error(`✗ ${e.message}`);
83
+ process.exit(1);
84
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Walks the visible DOM and returns one sample per distinct (tag, class,
3
+ * computed subset) so a 200-row table costs one entry, not 200.
4
+ */
5
+ export async function collect(page, ignore) {
6
+ return page.evaluate((ignoreSelectors) => {
7
+ const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "BR", "WBR", "svg", "path", "IMG", "VIDEO", "CANVAS", "IFRAME", "PICTURE", "SOURCE"]);
8
+ const ignored = (el) => {
9
+ if (el.closest('[data-design-check="ignore"]'))
10
+ return true;
11
+ for (const s of ignoreSelectors) {
12
+ try {
13
+ if (el.closest(s))
14
+ return true;
15
+ }
16
+ catch {
17
+ /* bad selector; ignore */
18
+ }
19
+ }
20
+ return false;
21
+ };
22
+ const px = (v) => {
23
+ const n = parseFloat(v);
24
+ return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
25
+ };
26
+ const transparent = (c) => /^(transparent|rgba?\(\s*\d+,?\s*\d+,?\s*\d+,?\s*(\/\s*)?0\))$/.test(c.replace(/\s+/g, "")) || c === "rgba(0, 0, 0, 0)";
27
+ const shortSelector = (el) => {
28
+ const parts = [];
29
+ let cur = el;
30
+ let depth = 0;
31
+ while (cur && cur !== document.documentElement && depth < 4) {
32
+ let part = cur.tagName.toLowerCase();
33
+ if (cur.id) {
34
+ parts.unshift(`#${cur.id}`);
35
+ break;
36
+ }
37
+ const cls = Array.from(cur.classList).filter((c) => !/[\[\]:/]/.test(c)).slice(0, 2);
38
+ if (cls.length)
39
+ part += "." + cls.join(".");
40
+ const parent = cur.parentElement;
41
+ if (parent) {
42
+ const same = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
43
+ if (same.length > 1)
44
+ part += `:nth-of-type(${same.indexOf(cur) + 1})`;
45
+ }
46
+ parts.unshift(part);
47
+ cur = cur.parentElement;
48
+ depth++;
49
+ }
50
+ return parts.join(" > ");
51
+ };
52
+ const effectiveBg = (el) => {
53
+ let cur = el.parentElement;
54
+ while (cur) {
55
+ const cs = getComputedStyle(cur);
56
+ if (cs.backgroundImage && cs.backgroundImage !== "none")
57
+ return null;
58
+ if (!transparent(cs.backgroundColor))
59
+ return cs.backgroundColor;
60
+ cur = cur.parentElement;
61
+ }
62
+ return "rgb(255, 255, 255)";
63
+ };
64
+ const seen = new Map();
65
+ const all = document.body.querySelectorAll("*");
66
+ let total = 0;
67
+ for (const el of Array.from(all)) {
68
+ if (SKIP_TAGS.has(el.tagName) || SKIP_TAGS.has(el.tagName.toLowerCase()))
69
+ continue;
70
+ if (ignored(el))
71
+ continue;
72
+ const cs = getComputedStyle(el);
73
+ if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0)
74
+ continue;
75
+ const rect = el.getBoundingClientRect();
76
+ if (rect.width < 1 || rect.height < 1)
77
+ continue;
78
+ total++;
79
+ const ownText = Array.from(el.childNodes)
80
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
81
+ .map((n) => n.textContent ?? "")
82
+ .join(" ")
83
+ .replace(/\s+/g, " ")
84
+ .trim();
85
+ const isText = ownText.length > 0;
86
+ const key = [
87
+ el.tagName,
88
+ Array.from(el.classList).sort().join("."),
89
+ cs.fontFamily,
90
+ cs.fontSize,
91
+ cs.fontWeight,
92
+ cs.color,
93
+ cs.backgroundColor,
94
+ cs.backgroundImage,
95
+ cs.padding,
96
+ cs.margin,
97
+ cs.gap,
98
+ cs.borderRadius,
99
+ isText ? "t" : "",
100
+ ].join("|");
101
+ if (seen.has(key))
102
+ continue;
103
+ const hasBorder = ["Top", "Right", "Bottom", "Left"].some((s) => px(cs.getPropertyValue(`border-${s.toLowerCase()}-width`)) > 0 && cs.getPropertyValue(`border-${s.toLowerCase()}-style`) !== "none");
104
+ seen.set(key, {
105
+ selector: shortSelector(el),
106
+ tag: el.tagName.toLowerCase(),
107
+ text: ownText.slice(0, 60),
108
+ isText,
109
+ fontFamily: cs.fontFamily,
110
+ fontSize: px(cs.fontSize),
111
+ fontWeight: parseInt(cs.fontWeight, 10) || 400,
112
+ color: cs.color,
113
+ backgroundColor: cs.backgroundColor,
114
+ effectiveBackground: effectiveBg(el),
115
+ backgroundImage: cs.backgroundImage,
116
+ borderColor: cs.borderTopColor,
117
+ hasBorder,
118
+ padding: [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(px),
119
+ margin: [cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft].map(px),
120
+ gap: [cs.rowGap, cs.columnGap].map((g) => (g === "normal" ? 0 : px(g))),
121
+ borderRadius: [cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius].map(px),
122
+ width: Math.round(rect.width),
123
+ height: Math.round(rect.height),
124
+ transitionDuration: Math.max(0, ...cs.transitionDuration.split(",").map((d) => (d.trim().endsWith("ms") ? parseFloat(d) : parseFloat(d) * 1000)).filter(Number.isFinite)),
125
+ });
126
+ if (seen.size > 4000)
127
+ break;
128
+ }
129
+ const runningAnimations = document.getAnimations().flatMap((a) => {
130
+ if (a.playState !== "running")
131
+ return [];
132
+ const timing = a.effect?.getTiming();
133
+ const duration = typeof timing?.duration === "number" ? timing.duration : 0;
134
+ const target = a.effect?.target;
135
+ if (!target || ignored(target))
136
+ return [];
137
+ return [{ selector: shortSelector(target), duration, name: a.animationName ?? a.id ?? "animation" }];
138
+ });
139
+ return { samples: Array.from(seen.values()), totalElements: total, runningAnimations };
140
+ }, ignore);
141
+ }
package/dist/config.js ADDED
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const CONFIG_FILE = "dmiu.config.json";
4
+ export const SITE = process.env.DMIU_SITE ?? "https://dontmakeitugly.com";
5
+ const DEFAULTS = {
6
+ designMd: "DESIGN.md",
7
+ baseUrl: "http://localhost:3000",
8
+ routes: ["/"],
9
+ viewports: [
10
+ { width: 1280, height: 800 },
11
+ { width: 390, height: 844 },
12
+ ],
13
+ tolerance: { color: 6, spacing: 1 },
14
+ forbidGradients: true,
15
+ ignore: [],
16
+ baselines: { dir: ".dmiu/baselines", threshold: 0.02 },
17
+ outDir: ".dmiu",
18
+ };
19
+ export function loadConfig(cwd, overrides = {}) {
20
+ const file = path.join(cwd, CONFIG_FILE);
21
+ let fromFile = {};
22
+ if (fs.existsSync(file)) {
23
+ try {
24
+ fromFile = JSON.parse(fs.readFileSync(file, "utf8"));
25
+ }
26
+ catch (e) {
27
+ throw new Error(`${CONFIG_FILE} is not valid JSON: ${e.message}`);
28
+ }
29
+ }
30
+ const cfg = {
31
+ ...DEFAULTS,
32
+ ...fromFile,
33
+ ...overrides,
34
+ tolerance: { ...DEFAULTS.tolerance, ...fromFile.tolerance, ...overrides.tolerance },
35
+ baselines: { ...DEFAULTS.baselines, ...fromFile.baselines, ...overrides.baselines },
36
+ };
37
+ if (!Array.isArray(cfg.routes) || cfg.routes.length === 0)
38
+ cfg.routes = ["/"];
39
+ return cfg;
40
+ }
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import { parse as parseYaml } from "yaml";
3
+ import { parse as parseColor, converter } from "culori";
4
+ const toOklab = converter("oklab");
5
+ export function primaryFamily(fontFamily) {
6
+ return fontFamily.split(",")[0].trim().replace(/^["']|["']$/g, "");
7
+ }
8
+ /** "16px" | "1rem" | 16 -> px number, or null when not a length. */
9
+ export function toPx(v) {
10
+ if (typeof v === "number")
11
+ return v;
12
+ if (typeof v !== "string")
13
+ return null;
14
+ const m = v.trim().match(/^(-?\d*\.?\d+)(px|rem|em)?$/);
15
+ if (!m)
16
+ return null;
17
+ const n = parseFloat(m[1]);
18
+ if (m[2] === "rem" || m[2] === "em")
19
+ return n * 16;
20
+ return n;
21
+ }
22
+ export function loadContract(file) {
23
+ const raw = fs.readFileSync(file, "utf8");
24
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
25
+ if (!m)
26
+ throw new Error(`${file}: expected a YAML front-matter block`);
27
+ const fm = (parseYaml(m[1]) ?? {});
28
+ const body = m[2];
29
+ const fonts = new Set();
30
+ for (const t of Object.values(fm.typography ?? {})) {
31
+ if (t?.fontFamily)
32
+ fonts.add(primaryFamily(String(t.fontFamily)).toLowerCase());
33
+ }
34
+ const palette = [];
35
+ for (const [name, css] of Object.entries(fm.colors ?? {})) {
36
+ const parsed = parseColor(String(css));
37
+ if (!parsed)
38
+ continue;
39
+ const lab = toOklab(parsed);
40
+ if (lab)
41
+ palette.push({ name, css: String(css), lab });
42
+ }
43
+ const spacingEntries = Object.entries(fm.spacing ?? {});
44
+ const spacing = new Set();
45
+ let spacingBase = null;
46
+ for (const [k, v] of spacingEntries) {
47
+ const px = toPx(v);
48
+ if (px === null)
49
+ continue;
50
+ if (k === "base")
51
+ spacingBase = px;
52
+ // Layout dimensions (sidebar, measure, container) are not rhythm values.
53
+ if (px <= 160)
54
+ spacing.add(px);
55
+ }
56
+ const radii = new Set();
57
+ for (const v of Object.values(fm.rounded ?? {})) {
58
+ const px = toPx(v);
59
+ if (px !== null)
60
+ radii.add(px);
61
+ }
62
+ const dontsSection = body.split(/^## +/m).find((s) => /^do'?s and don'?ts/i.test(s.replace(/[’]/g, "'")));
63
+ const donts = dontsSection
64
+ ? dontsSection
65
+ .split("\n")
66
+ .slice(1)
67
+ .map((l) => l.replace(/^\s*[-*]\s*/, "").trim())
68
+ .filter(Boolean)
69
+ : [];
70
+ return {
71
+ name: String(fm.name ?? "DESIGN.md"),
72
+ fonts: [...fonts],
73
+ palette,
74
+ spacing: [...spacing].sort((a, b) => a - b),
75
+ spacingBase,
76
+ radii: [...radii].sort((a, b) => a - b),
77
+ donts,
78
+ };
79
+ }
package/dist/init.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { SITE, CONFIG_FILE } from "./config.js";
4
+ const BEGIN = "<!-- BEGIN:dontmakeitugly -->";
5
+ const END = "<!-- END:dontmakeitugly -->";
6
+ /** Inserts or replaces the marker block so re-running init is safe. */
7
+ export function upsertBlock(existing, block) {
8
+ const start = existing.indexOf(BEGIN);
9
+ const end = existing.indexOf(END);
10
+ if (start !== -1 && end !== -1 && end > start) {
11
+ return existing.slice(0, start) + block + existing.slice(end + END.length);
12
+ }
13
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
14
+ return existing + sep + block + "\n";
15
+ }
16
+ export async function fetchManifest(site, pack) {
17
+ const url = `${site.replace(/\/$/, "")}/packs/${pack}.json`;
18
+ const res = await fetch(url);
19
+ if (res.status === 404)
20
+ throw new Error(`No pack called "${pack}". See ${site}/packs for the list.`);
21
+ if (!res.ok)
22
+ throw new Error(`Could not fetch ${url} (${res.status}).`);
23
+ return (await res.json());
24
+ }
25
+ export async function runInit(opts) {
26
+ const log = opts.log ?? (() => { });
27
+ const site = opts.site ?? SITE;
28
+ const manifest = await fetchManifest(site, opts.pack);
29
+ log(`Pack: ${manifest.name} (${manifest.version}) from ${manifest.url}`);
30
+ const written = [];
31
+ const skipped = [];
32
+ for (const f of manifest.files) {
33
+ const target = path.resolve(opts.cwd, f.path);
34
+ if (!target.startsWith(path.resolve(opts.cwd)))
35
+ throw new Error(`Refusing to write outside the project: ${f.path}`);
36
+ if (fs.existsSync(target) && f.overwrite === "never" && !opts.force) {
37
+ skipped.push(f.path);
38
+ continue;
39
+ }
40
+ fs.mkdirSync(path.dirname(target), { recursive: true });
41
+ fs.writeFileSync(target, f.content);
42
+ written.push(f.path);
43
+ }
44
+ // Agent instructions: create AGENTS.md if missing, only touch CLAUDE.md if it exists.
45
+ for (const [name, create] of [["AGENTS.md", true], ["CLAUDE.md", false]]) {
46
+ const file = path.join(opts.cwd, name);
47
+ const exists = fs.existsSync(file);
48
+ if (!exists && !create)
49
+ continue;
50
+ const before = exists ? fs.readFileSync(file, "utf8") : "";
51
+ const after = upsertBlock(before, manifest.agentsSnippet);
52
+ if (after !== before) {
53
+ fs.writeFileSync(file, after);
54
+ written.push(name);
55
+ }
56
+ }
57
+ if (opts.withScreens) {
58
+ const dir = path.join(opts.cwd, ".dmiu", "references");
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ for (const r of manifest.references) {
61
+ try {
62
+ const res = await fetch(r.image);
63
+ if (!res.ok)
64
+ continue;
65
+ const buf = Buffer.from(await res.arrayBuffer());
66
+ const file = path.join(dir, `${r.resource}.jpg`);
67
+ fs.writeFileSync(file, buf);
68
+ written.push(path.relative(opts.cwd, file));
69
+ }
70
+ catch {
71
+ /* a missing preview is not fatal */
72
+ }
73
+ }
74
+ }
75
+ // Keep generated output out of git without touching an existing rule.
76
+ const gi = path.join(opts.cwd, ".gitignore");
77
+ const rule = ".dmiu/screenshots/";
78
+ if (fs.existsSync(gi)) {
79
+ const g = fs.readFileSync(gi, "utf8");
80
+ if (!g.split("\n").some((l) => l.trim() === rule || l.trim() === ".dmiu/")) {
81
+ fs.writeFileSync(gi, g + (g.endsWith("\n") ? "" : "\n") + `\n# dontmakeitugly check output (baselines and report stay)\n${rule}\n`);
82
+ written.push(".gitignore");
83
+ }
84
+ }
85
+ return { written, skipped };
86
+ }
87
+ export { CONFIG_FILE };
package/dist/report.js ADDED
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function summarise(pages) {
4
+ let errors = 0;
5
+ let warnings = 0;
6
+ for (const p of pages) {
7
+ for (const c of p.checks) {
8
+ if (c.status === "fail")
9
+ errors++;
10
+ else if (c.status === "warn")
11
+ warnings++;
12
+ }
13
+ }
14
+ return { errors, warnings, pages: pages.length };
15
+ }
16
+ /** The markdown report is written for the agent: what failed, where, what to change. */
17
+ export function toMarkdown(r) {
18
+ const out = [];
19
+ out.push(`# Design check: ${r.ok ? "passed" : "failed"}`);
20
+ out.push("");
21
+ out.push(`Contract: ${r.designMd} (${r.contract.name}). ${r.summary.errors} error(s), ${r.summary.warnings} warning(s) across ${r.summary.pages} page render(s). Generated ${r.generatedAt}.`);
22
+ out.push("");
23
+ if (r.ok && r.summary.warnings === 0) {
24
+ out.push("Nothing to fix. Every check passed on every route and viewport.");
25
+ out.push("");
26
+ }
27
+ const failing = r.pages.flatMap((p) => p.checks.filter((c) => c.status !== "pass").map((c) => ({ p, c })));
28
+ if (failing.length) {
29
+ out.push("## What to fix");
30
+ out.push("");
31
+ out.push("Errors must be fixed. Warnings are worth a look. Do not edit DESIGN.md to make a check pass; change the code.");
32
+ out.push("");
33
+ for (const { p, c } of failing) {
34
+ out.push(`### ${c.status === "fail" ? "Error" : "Warning"}: ${c.title} on ${p.route} at ${p.viewport}`);
35
+ out.push("");
36
+ out.push(c.message);
37
+ if (c.fix)
38
+ out.push(`\nFix: ${c.fix}`);
39
+ out.push("");
40
+ for (const i of c.items) {
41
+ const bits = [`\`${i.selector}\``, i.value, i.nearest ? `→ ${i.nearest}` : "", i.text ? `("${i.text}")` : ""].filter(Boolean);
42
+ out.push(`- ${bits.join(" ")}`);
43
+ }
44
+ if (c.count > c.items.length)
45
+ out.push(`- …and ${c.count - c.items.length} more`);
46
+ out.push("");
47
+ }
48
+ }
49
+ out.push("## Every check");
50
+ out.push("");
51
+ for (const p of r.pages) {
52
+ out.push(`### ${p.route} at ${p.viewport}`);
53
+ out.push("");
54
+ for (const c of p.checks)
55
+ out.push(`- ${c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✗"} ${c.title}: ${c.message}`);
56
+ out.push(`- Screenshot: ${p.screenshot}`);
57
+ out.push("");
58
+ }
59
+ return out.join("\n");
60
+ }
61
+ export function writeReport(outDir, report) {
62
+ fs.mkdirSync(outDir, { recursive: true });
63
+ const json = path.join(outDir, "report.json");
64
+ const md = path.join(outDir, "report.md");
65
+ fs.writeFileSync(json, JSON.stringify(report, null, 2) + "\n");
66
+ fs.writeFileSync(md, toMarkdown(report));
67
+ return { json, md };
68
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "dontmakeitugly",
3
+ "version": "0.1.0",
4
+ "description": "Put a DESIGN.md contract in your repo and check the result against it. From dontmakeitugly.com.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "dontmakeitugly": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "test": "npm run build && node --test test/*.test.mjs"
20
+ },
21
+ "dependencies": {
22
+ "culori": "^4.0.2",
23
+ "pixelmatch": "^7.1.0",
24
+ "playwright": "^1.62.1",
25
+ "pngjs": "^7.0.0",
26
+ "yaml": "^2.8.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/culori": "^4.0.0",
30
+ "@types/node": "^20",
31
+ "@types/pixelmatch": "^5.2.6",
32
+ "@types/pngjs": "^6.0.5",
33
+ "typescript": "^5"
34
+ },
35
+ "keywords": [
36
+ "design",
37
+ "design-system",
38
+ "DESIGN.md",
39
+ "coding-agents",
40
+ "playwright",
41
+ "visual-regression"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/gorock007/dontmakeitugly.git",
46
+ "directory": "packages/dontmakeitugly"
47
+ },
48
+ "homepage": "https://dontmakeitugly.com/packs"
49
+ }