dontmakeitugly 0.1.1 → 0.2.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 +6 -1
- package/dist/check.js +5 -0
- package/dist/checks/tells.js +106 -0
- package/dist/cli.js +8 -7
- package/dist/collect.js +46 -1
- package/dist/init.js +2 -18
- package/dist/report.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ npx dontmakeitugly init first-vibe-coded-app # DESIGN.md + check config + AGEN
|
|
|
8
8
|
npx dontmakeitugly check # .dmiu/report.md for the agent to read
|
|
9
9
|
```
|
|
10
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
|
|
11
|
+
`init` fetches a pack from [dontmakeitugly.com/packs](https://dontmakeitugly.com/packs): a `DESIGN.md` on Google's open format, a `dmiu.config.json`, the reference screens as links in `.dmiu/references/README.md` (never downloaded; they belong to their owners), and `.dmiu/critic.md`. 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, discover a direction before the first layout, run the check before saying it's done, and run the critic once the check is clean.
|
|
12
12
|
|
|
13
13
|
`check` screenshots your routes at desktop and mobile widths with Playwright and runs deterministic checks derived from the DESIGN.md tokens:
|
|
14
14
|
|
|
@@ -16,10 +16,15 @@ npx dontmakeitugly check # .dmiu/report.md for the agent t
|
|
|
16
16
|
- colours off the palette, within a tolerance (error above a handful, else warning)
|
|
17
17
|
- WCAG AA contrast on text (error)
|
|
18
18
|
- purple/blue gradients (error) and any gradient when the contract forbids them (warning)
|
|
19
|
+
- coloured glows: a chromatic box- or text-shadow with a wide blur (error when the contract forbids gradients, else warning)
|
|
20
|
+
- glassmorphism: backdrop blur on a translucent surface (same rule)
|
|
21
|
+
- card soup: three or more sibling cards that each open with an icon, then a heading, then a paragraph (warning; three-column layouts can be legitimate)
|
|
19
22
|
- animations that keep running under `prefers-reduced-motion: reduce` (error)
|
|
20
23
|
- padding and gaps off the spacing scale, radii off the rounded scale (warnings)
|
|
21
24
|
- screenshot baselines with pixel diffs (error above the threshold; `--update-baselines` to accept)
|
|
22
25
|
|
|
23
26
|
Skip an element and its subtree with `data-design-check="ignore"`, or add selectors to `ignore` in `dmiu.config.json`.
|
|
24
27
|
|
|
28
|
+
The report ends with the path of every screenshot it took. When the report has zero errors, the agent hands those screenshots and the pack's reference previews to `.dmiu/critic.md`: a prompt for a subagent in a fresh context that ranks them by polish and names the three biggest gaps. It is advisory and it never calls a model from the CLI; the agent you already run does the critique. The check stays the gate.
|
|
29
|
+
|
|
25
30
|
The first `check` installs a headless Chromium for Playwright (about 200 MB) if it isn't already there.
|
package/dist/check.js
CHANGED
|
@@ -8,6 +8,7 @@ import { loadContract } from "./contract.js";
|
|
|
8
8
|
import { collect } from "./collect.js";
|
|
9
9
|
import { checkColors, checkContrast, checkFonts, checkGradients, checkMotion, checkRadius, checkSpacing } from "./checks/style.js";
|
|
10
10
|
import { checkBaseline } from "./checks/baseline.js";
|
|
11
|
+
import { checkCardSoup, checkGlass, checkGlow } from "./checks/tells.js";
|
|
11
12
|
import { summarise, writeReport } from "./report.js";
|
|
12
13
|
function slugify(s) {
|
|
13
14
|
return s.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home";
|
|
@@ -102,6 +103,9 @@ export async function runCheck(opts) {
|
|
|
102
103
|
checkColors(collected.samples, contract, cfg.tolerance.color),
|
|
103
104
|
checkContrast(collected.samples),
|
|
104
105
|
checkGradients(collected.samples, cfg.forbidGradients),
|
|
106
|
+
checkGlow(collected.samples, cfg.forbidGradients),
|
|
107
|
+
checkGlass(collected.samples, cfg.forbidGradients),
|
|
108
|
+
checkCardSoup(collected.cardGroups),
|
|
105
109
|
checkMotion(reducedCollected.runningAnimations, collected.samples),
|
|
106
110
|
checkSpacing(collected.samples, contract, cfg.tolerance.spacing),
|
|
107
111
|
checkRadius(collected.samples, contract),
|
|
@@ -123,6 +127,7 @@ export async function runCheck(opts) {
|
|
|
123
127
|
designMd: cfg.designMd,
|
|
124
128
|
contract: { name: contract.name, fonts: contract.fonts, palette: contract.palette.length, spacing: contract.spacing, radii: contract.radii },
|
|
125
129
|
summary,
|
|
130
|
+
critic: fs.existsSync(path.join(outDir, "critic.md")) ? path.relative(opts.cwd, path.join(outDir, "critic.md")) : null,
|
|
126
131
|
pages,
|
|
127
132
|
};
|
|
128
133
|
const files = writeReport(outDir, report);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { parse as parseColor, converter, formatHex } from "culori";
|
|
2
|
+
import { result } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The "tells" of a generated interface that the pack contracts forbid in prose:
|
|
5
|
+
* coloured glows, frosted glass and rows of icon cards. The severity of the
|
|
6
|
+
* first two follows the contract's stance on gradients, since the same Don't
|
|
7
|
+
* line lists them together.
|
|
8
|
+
*/
|
|
9
|
+
const toOklch = converter("oklch");
|
|
10
|
+
const toRgb = converter("rgb");
|
|
11
|
+
/** Split a comma-separated CSS list without breaking inside colour functions. */
|
|
12
|
+
function splitList(css) {
|
|
13
|
+
const out = [];
|
|
14
|
+
let depth = 0;
|
|
15
|
+
let cur = "";
|
|
16
|
+
for (const ch of css) {
|
|
17
|
+
if (ch === "(")
|
|
18
|
+
depth++;
|
|
19
|
+
if (ch === ")")
|
|
20
|
+
depth--;
|
|
21
|
+
if (ch === "," && depth === 0) {
|
|
22
|
+
out.push(cur.trim());
|
|
23
|
+
cur = "";
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
cur += ch;
|
|
27
|
+
}
|
|
28
|
+
if (cur.trim())
|
|
29
|
+
out.push(cur.trim());
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Chromium serialises shadows as "<color> <x> <y> <blur> [<spread>] [inset]". */
|
|
33
|
+
function parseShadows(css) {
|
|
34
|
+
if (!css || css === "none")
|
|
35
|
+
return [];
|
|
36
|
+
return splitList(css).map((raw) => {
|
|
37
|
+
const colorMatch = raw.match(/^(rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)|oklab\([^)]*\)|color\([^)]*\)|#[0-9a-f]{3,8}|[a-z]+)\s/i);
|
|
38
|
+
const color = colorMatch ? toRgb(parseColor(colorMatch[1]) ?? "black") ?? null : null;
|
|
39
|
+
const lengths = (raw.match(/-?\d*\.?\d+px/g) ?? []).map((v) => parseFloat(v));
|
|
40
|
+
return { color, lengths, inset: /\binset\b/.test(raw), raw };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Chroma of the shadow colour once its alpha has been laid over the surface it sits on. */
|
|
44
|
+
function compositedChroma(color, surface) {
|
|
45
|
+
const a = color.alpha ?? 1;
|
|
46
|
+
const bg = toRgb(parseColor(surface ?? "white") ?? "white") ?? { mode: "rgb", r: 1, g: 1, b: 1 };
|
|
47
|
+
const mixed = { mode: "rgb", r: color.r * a + bg.r * (1 - a), g: color.g * a + bg.g * (1 - a), b: color.b * a + bg.b * (1 - a), alpha: 1 };
|
|
48
|
+
return toOklch(mixed)?.c ?? 0;
|
|
49
|
+
}
|
|
50
|
+
const GLOW_CHROMA = 0.04;
|
|
51
|
+
const BOX_GLOW_BLUR = 12;
|
|
52
|
+
const TEXT_GLOW_BLUR = 8;
|
|
53
|
+
export function checkGlow(samples, forbid) {
|
|
54
|
+
const items = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const consider = (s, prop, css) => {
|
|
57
|
+
const blurIndex = 2; // x, y, blur[, spread]
|
|
58
|
+
const minBlur = prop === "box-shadow" ? BOX_GLOW_BLUR : TEXT_GLOW_BLUR;
|
|
59
|
+
for (const sh of parseShadows(css)) {
|
|
60
|
+
if (!sh.color || (sh.color.alpha ?? 1) === 0)
|
|
61
|
+
continue;
|
|
62
|
+
const blur = sh.lengths[blurIndex] ?? 0;
|
|
63
|
+
if (blur < minBlur)
|
|
64
|
+
continue;
|
|
65
|
+
if (compositedChroma(sh.color, s.effectiveBackground) <= GLOW_CHROMA)
|
|
66
|
+
continue;
|
|
67
|
+
const key = `${prop}:${sh.raw}`;
|
|
68
|
+
if (seen.has(key))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(key);
|
|
71
|
+
items.push({ selector: s.selector, value: `${prop}: ${sh.raw}`, nearest: `${formatHex(sh.color)} at ${blur}px blur`, text: s.text || undefined });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
for (const s of samples) {
|
|
75
|
+
consider(s, "box-shadow", s.boxShadow);
|
|
76
|
+
if (s.isText)
|
|
77
|
+
consider(s, "text-shadow", s.textShadow);
|
|
78
|
+
}
|
|
79
|
+
return result("glow", "Glows", forbid ? "error" : "warning", items, items.length ? `${items.length} coloured glow(s) found (a chromatic shadow with a wide blur).` : "No coloured glows.", "Remove the coloured shadow. If the element needs lift, use a neutral shadow at low alpha or a hairline border from the palette.");
|
|
80
|
+
}
|
|
81
|
+
function alphaOf(css) {
|
|
82
|
+
const c = parseColor(css);
|
|
83
|
+
if (!c)
|
|
84
|
+
return 1;
|
|
85
|
+
return c.alpha ?? 1;
|
|
86
|
+
}
|
|
87
|
+
export function checkGlass(samples, forbid) {
|
|
88
|
+
const items = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (const s of samples) {
|
|
91
|
+
if (!/blur\(/.test(s.backdropFilter))
|
|
92
|
+
continue;
|
|
93
|
+
if (alphaOf(s.backgroundColor) >= 1)
|
|
94
|
+
continue;
|
|
95
|
+
const key = `${s.backdropFilter}|${s.backgroundColor}`;
|
|
96
|
+
if (seen.has(key))
|
|
97
|
+
continue;
|
|
98
|
+
seen.add(key);
|
|
99
|
+
items.push({ selector: s.selector, value: `backdrop-filter: ${s.backdropFilter} on ${s.backgroundColor}`, text: s.text || undefined });
|
|
100
|
+
}
|
|
101
|
+
return result("glass", "Glassmorphism", forbid ? "error" : "warning", items, items.length ? `${items.length} frosted-glass surface(s) found (backdrop blur on a translucent background).` : "No glassmorphism.", "Give the surface an opaque background token and remove backdrop-filter.");
|
|
102
|
+
}
|
|
103
|
+
export function checkCardSoup(groups) {
|
|
104
|
+
const items = groups.map((g) => ({ selector: g.selector, value: `${g.count} sibling cards, each icon + heading + paragraph`, nearest: `first card: ${g.example}` }));
|
|
105
|
+
return result("cards", "Card soup", "warning", items, items.length ? `${items.length} row(s) of icon cards found. Three matching cards with an icon on top is the most common generated layout.` : "No rows of icon cards.", "Write the features as a list or a two-column section with real content, and keep icons only where they carry meaning. Mark a deliberate grid with data-design-check=\"ignore\".");
|
|
106
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -6,14 +6,16 @@ import { SITE } from "./config.js";
|
|
|
6
6
|
const HELP = `dontmakeitugly — a design contract for your repo, and a check that fails when it drifts.
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
|
-
npx dontmakeitugly init <pack> [--force]
|
|
9
|
+
npx dontmakeitugly init <pack> [--force]
|
|
10
10
|
npx dontmakeitugly check [--routes /,/pricing] [--base-url http://localhost:3000] [--update-baselines] [--json]
|
|
11
11
|
|
|
12
|
-
init Fetches a pack from ${SITE} and writes DESIGN.md, dmiu.config.json
|
|
13
|
-
.dmiu/references/README.md
|
|
12
|
+
init Fetches a pack from ${SITE} and writes DESIGN.md, dmiu.config.json,
|
|
13
|
+
.dmiu/references/README.md and .dmiu/critic.md (a prompt for a fresh-context
|
|
14
|
+
design critic), and adds a block to AGENTS.md (and CLAUDE.md if present).
|
|
14
15
|
check Screenshots the routes in dmiu.config.json at each viewport and checks fonts,
|
|
15
|
-
palette, contrast, gradients,
|
|
16
|
-
against DESIGN.md. Writes .dmiu/report.md and
|
|
16
|
+
palette, contrast, gradients, glows, glassmorphism, card soup, reduced motion,
|
|
17
|
+
spacing, radii and baselines against DESIGN.md. Writes .dmiu/report.md and
|
|
18
|
+
.dmiu/report.json. Exit 1 on errors.
|
|
17
19
|
|
|
18
20
|
Packs: ${SITE}/packs
|
|
19
21
|
`;
|
|
@@ -22,7 +24,6 @@ async function main() {
|
|
|
22
24
|
allowPositionals: true,
|
|
23
25
|
options: {
|
|
24
26
|
force: { type: "boolean", default: false },
|
|
25
|
-
"with-screens": { type: "boolean", default: false },
|
|
26
27
|
"update-baselines": { type: "boolean", default: false },
|
|
27
28
|
json: { type: "boolean", default: false },
|
|
28
29
|
routes: { type: "string" },
|
|
@@ -46,7 +47,7 @@ async function main() {
|
|
|
46
47
|
console.error(`Which pack? Try: npx dontmakeitugly init first-vibe-coded-app\nAll packs: ${SITE}/packs`);
|
|
47
48
|
process.exit(1);
|
|
48
49
|
}
|
|
49
|
-
const { written, skipped } = await runInit({ cwd, pack: arg, force: values.force,
|
|
50
|
+
const { written, skipped } = await runInit({ cwd, pack: arg, force: values.force, site: values.site, log });
|
|
50
51
|
for (const w of written)
|
|
51
52
|
log(` wrote ${w}`);
|
|
52
53
|
for (const s of skipped)
|
package/dist/collect.js
CHANGED
|
@@ -96,6 +96,9 @@ export async function collect(page, ignore) {
|
|
|
96
96
|
cs.margin,
|
|
97
97
|
cs.gap,
|
|
98
98
|
cs.borderRadius,
|
|
99
|
+
cs.boxShadow,
|
|
100
|
+
cs.textShadow,
|
|
101
|
+
cs.getPropertyValue("backdrop-filter"),
|
|
99
102
|
isText ? "t" : "",
|
|
100
103
|
].join("|");
|
|
101
104
|
if (seen.has(key))
|
|
@@ -122,6 +125,9 @@ export async function collect(page, ignore) {
|
|
|
122
125
|
width: Math.round(rect.width),
|
|
123
126
|
height: Math.round(rect.height),
|
|
124
127
|
transitionDuration: Math.max(0, ...cs.transitionDuration.split(",").map((d) => (d.trim().endsWith("ms") ? parseFloat(d) : parseFloat(d) * 1000)).filter(Number.isFinite)),
|
|
128
|
+
boxShadow: cs.boxShadow,
|
|
129
|
+
textShadow: cs.textShadow,
|
|
130
|
+
backdropFilter: cs.getPropertyValue("backdrop-filter"),
|
|
125
131
|
});
|
|
126
132
|
if (seen.size > 4000)
|
|
127
133
|
break;
|
|
@@ -136,6 +142,45 @@ export async function collect(page, ignore) {
|
|
|
136
142
|
return [];
|
|
137
143
|
return [{ selector: shortSelector(target), duration, name: a.animationName ?? a.id ?? "animation" }];
|
|
138
144
|
});
|
|
139
|
-
|
|
145
|
+
// Card soup: three or more siblings that each open with an icon, then a heading, then a paragraph.
|
|
146
|
+
// The icon-first order is what separates the feature-card cliché from an ordinary list of things.
|
|
147
|
+
const visible = (el) => {
|
|
148
|
+
const r = el.getBoundingClientRect();
|
|
149
|
+
return r.width >= 1 && r.height >= 1;
|
|
150
|
+
};
|
|
151
|
+
const isCard = (el) => {
|
|
152
|
+
if (!visible(el))
|
|
153
|
+
return false;
|
|
154
|
+
const headings = el.querySelectorAll("h1, h2, h3, h4, h5, h6, [role='heading']");
|
|
155
|
+
if (headings.length !== 1)
|
|
156
|
+
return false;
|
|
157
|
+
if (!el.querySelector("p"))
|
|
158
|
+
return false;
|
|
159
|
+
// An icon is small; a preview image or illustration above a heading is a different layout.
|
|
160
|
+
const icon = Array.from(el.querySelectorAll("svg, img, [class*='icon' i]")).find((c) => {
|
|
161
|
+
const r = c.getBoundingClientRect();
|
|
162
|
+
return r.width >= 8 && r.height >= 8 && r.width <= 48 && r.height <= 48;
|
|
163
|
+
});
|
|
164
|
+
if (!icon)
|
|
165
|
+
return false;
|
|
166
|
+
const iconFirst = Boolean(icon.compareDocumentPosition(headings[0]) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
167
|
+
return iconFirst && (el.textContent ?? "").trim().length <= 600;
|
|
168
|
+
};
|
|
169
|
+
const cardGroups = [];
|
|
170
|
+
for (const el of Array.from(all)) {
|
|
171
|
+
if (ignored(el) || !visible(el))
|
|
172
|
+
continue;
|
|
173
|
+
const cs = getComputedStyle(el);
|
|
174
|
+
if (!/grid|flex/.test(cs.display))
|
|
175
|
+
continue;
|
|
176
|
+
const kids = Array.from(el.children).filter((k) => !ignored(k) && !SKIP_TAGS.has(k.tagName));
|
|
177
|
+
if (kids.length < 3)
|
|
178
|
+
continue;
|
|
179
|
+
const cards = kids.filter(isCard);
|
|
180
|
+
if (cards.length < 3)
|
|
181
|
+
continue;
|
|
182
|
+
cardGroups.push({ selector: shortSelector(el), count: cards.length, example: shortSelector(cards[0]) });
|
|
183
|
+
}
|
|
184
|
+
return { samples: Array.from(seen.values()), totalElements: total, runningAnimations, cardGroups };
|
|
140
185
|
}, ignore);
|
|
141
186
|
}
|
package/dist/init.js
CHANGED
|
@@ -54,24 +54,8 @@ export async function runInit(opts) {
|
|
|
54
54
|
written.push(name);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
}
|
|
57
|
+
// Reference previews stay as URLs in .dmiu/references/README.md and .dmiu/critic.md.
|
|
58
|
+
// They belong to their owners; init never downloads them into the project.
|
|
75
59
|
// Keep generated output out of git without touching an existing rule.
|
|
76
60
|
const gi = path.join(opts.cwd, ".gitignore");
|
|
77
61
|
const rule = ".dmiu/screenshots/";
|
package/dist/report.js
CHANGED
|
@@ -24,6 +24,17 @@ export function toMarkdown(r) {
|
|
|
24
24
|
out.push("Nothing to fix. Every check passed on every route and viewport.");
|
|
25
25
|
out.push("");
|
|
26
26
|
}
|
|
27
|
+
out.push("## Screenshots");
|
|
28
|
+
out.push("");
|
|
29
|
+
for (const p of r.pages)
|
|
30
|
+
out.push(`- ${p.route} at ${p.viewport}: ${p.screenshot}`);
|
|
31
|
+
out.push("");
|
|
32
|
+
if (r.critic) {
|
|
33
|
+
out.push(r.ok
|
|
34
|
+
? `Zero errors. Next: hand these screenshots and the reference previews to the critic in ${r.critic}, in a fresh context with no code. Apply its three gaps, run the check again, at most twice.`
|
|
35
|
+
: `Fix the errors first. The critic in ${r.critic} runs once this report has none.`);
|
|
36
|
+
out.push("");
|
|
37
|
+
}
|
|
27
38
|
const failing = r.pages.flatMap((p) => p.checks.filter((c) => c.status !== "pass").map((c) => ({ p, c })));
|
|
28
39
|
if (failing.length) {
|
|
29
40
|
out.push("## What to fix");
|