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 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
 
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] [--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,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, 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)
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.1",
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",