dontmakeitugly 0.1.0 → 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 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 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.
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
 
25
- Chromium is needed once: `npx playwright install chromium`.
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
+
30
+ The first `check` installs a headless Chromium for Playwright (about 200 MB) if it isn't already there.
package/dist/check.js CHANGED
@@ -1,25 +1,43 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
3
5
  import { chromium } from "playwright";
4
6
  import { loadConfig } from "./config.js";
5
7
  import { loadContract } from "./contract.js";
6
8
  import { collect } from "./collect.js";
7
9
  import { checkColors, checkContrast, checkFonts, checkGradients, checkMotion, checkRadius, checkSpacing } from "./checks/style.js";
8
10
  import { checkBaseline } from "./checks/baseline.js";
11
+ import { checkCardSoup, checkGlass, checkGlow } from "./checks/tells.js";
9
12
  import { summarise, writeReport } from "./report.js";
10
13
  function slugify(s) {
11
14
  return s.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home";
12
15
  }
13
- async function launch() {
16
+ function isMissingBrowser(e) {
17
+ return /Executable doesn't exist|browserType\.launch|playwright install/.test(e.message ?? "");
18
+ }
19
+ /** Installs the Chromium build that this copy of Playwright expects. */
20
+ function installChromium(log) {
21
+ const require = createRequire(import.meta.url);
22
+ // "playwright/cli" is not an exported subpath; go via the package root.
23
+ const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
24
+ log("Chromium is not installed for Playwright yet. Installing the headless build once (about 200 MB)…");
25
+ execFileSync(process.execPath, [cli, "install", "chromium", "--only-shell"], { stdio: "inherit" });
26
+ }
27
+ async function launch(log) {
14
28
  try {
15
29
  return await chromium.launch();
16
30
  }
17
31
  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");
32
+ if (!isMissingBrowser(e))
33
+ throw e;
34
+ try {
35
+ installChromium(log);
36
+ }
37
+ catch (installError) {
38
+ throw new Error(`Chromium could not be installed automatically (${installError.message.split("\n")[0]}). Run: npx playwright install chromium`);
21
39
  }
22
- throw e;
40
+ return chromium.launch();
23
41
  }
24
42
  }
25
43
  async function reachable(url) {
@@ -52,7 +70,7 @@ export async function runCheck(opts) {
52
70
  const outDir = path.resolve(opts.cwd, cfg.outDir);
53
71
  const shotsDir = path.join(outDir, "screenshots");
54
72
  fs.mkdirSync(shotsDir, { recursive: true });
55
- const browser = await launch();
73
+ const browser = await launch(log);
56
74
  const pages = [];
57
75
  try {
58
76
  for (const vp of cfg.viewports) {
@@ -85,6 +103,9 @@ export async function runCheck(opts) {
85
103
  checkColors(collected.samples, contract, cfg.tolerance.color),
86
104
  checkContrast(collected.samples),
87
105
  checkGradients(collected.samples, cfg.forbidGradients),
106
+ checkGlow(collected.samples, cfg.forbidGradients),
107
+ checkGlass(collected.samples, cfg.forbidGradients),
108
+ checkCardSoup(collected.cardGroups),
88
109
  checkMotion(reducedCollected.runningAnimations, collected.samples),
89
110
  checkSpacing(collected.samples, contract, cfg.tolerance.spacing),
90
111
  checkRadius(collected.samples, contract),
@@ -106,6 +127,7 @@ export async function runCheck(opts) {
106
127
  designMd: cfg.designMd,
107
128
  contract: { name: contract.name, fonts: contract.fonts, palette: contract.palette.length, spacing: contract.spacing, radii: contract.radii },
108
129
  summary,
130
+ critic: fs.existsSync(path.join(outDir, "critic.md")) ? path.relative(opts.cwd, path.join(outDir, "critic.md")) : null,
109
131
  pages,
110
132
  };
111
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] [--with-screens]
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 and
13
- .dmiu/references/README.md, and adds a block to AGENTS.md (and CLAUDE.md if present).
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, reduced motion, spacing, radii and baselines
16
- against DESIGN.md. Writes .dmiu/report.md and .dmiu/report.json. Exit 1 on errors.
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,15 +47,14 @@ 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, withScreens: values["with-screens"], site: values.site, log });
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)
53
54
  log(` kept ${s} (already exists; use --force to overwrite)`);
54
55
  log("");
55
56
  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");
57
+ log("The first check installs a headless Chromium for Playwright (about 200 MB) if it isn't there yet.");
58
58
  return;
59
59
  }
60
60
  if (cmd === "check") {
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
- return { samples: Array.from(seen.values()), totalElements: total, runningAnimations };
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
- 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
- }
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dontmakeitugly",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Put a DESIGN.md contract in your repo and check the result against it. From dontmakeitugly.com.",
5
5
  "license": "MIT",
6
6
  "type": "module",