@gemboss/website-ui 0.0.0-stage → 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.
Files changed (44) hide show
  1. package/AGENTS.md +191 -0
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +118 -2
  4. package/bin/gemboss-website.mjs +78 -0
  5. package/bin/lib/check.mjs +695 -0
  6. package/bin/lib/gap.mjs +189 -0
  7. package/bin/lib/init.mjs +99 -0
  8. package/bin/lib/upgrade.mjs +155 -0
  9. package/bin/lib/util.mjs +147 -0
  10. package/css/base.css +107 -0
  11. package/css/components.css +324 -0
  12. package/css/footer.css +53 -0
  13. package/css/header.css +231 -0
  14. package/css/layout.css +59 -0
  15. package/css/prose.css +119 -0
  16. package/css/sections.css +173 -0
  17. package/css/tokens.css +200 -0
  18. package/demo/index.html +703 -0
  19. package/demo/theme-example.html +133 -0
  20. package/docs/components.md +407 -0
  21. package/docs/markup-contract.md +148 -0
  22. package/docs/philosophy.md +69 -0
  23. package/docs/theming.md +96 -0
  24. package/js/website.js +186 -0
  25. package/package.json +50 -4
  26. package/skill/SKILL.md +49 -0
  27. package/system.json +122 -0
  28. package/templates/footer.html +40 -0
  29. package/templates/header.html +60 -0
  30. package/templates/page.html +134 -0
  31. package/templates/sections/cta.html +15 -0
  32. package/templates/sections/faq.html +24 -0
  33. package/templates/sections/features.html +32 -0
  34. package/templates/sections/hero.html +27 -0
  35. package/templates/sections/logos.html +15 -0
  36. package/templates/sections/pricing.html +51 -0
  37. package/templates/sections/prose.html +36 -0
  38. package/templates/sections/quote.html +13 -0
  39. package/templates/sections/showcase.html +25 -0
  40. package/templates/sections/split.html +27 -0
  41. package/templates/sections/stats.html +26 -0
  42. package/templates/sections/steps.html +17 -0
  43. package/website.css +1274 -0
  44. package/website.min.css +1 -0
@@ -0,0 +1,695 @@
1
+ // `gemboss-website check`: a site's HTML and CSS against the kit's rules (AGENTS.md), with every value read from
2
+ // system.json. Generic on purpose: nothing here knows about one brand, one domain or one product's claims.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { basename, resolve } from "node:path";
5
+ import { PKG, SYSTEM, UsageError, blank, c, isKitPath, lineIndex, parseArgs, rel, walkFiles } from "./util.mjs";
6
+
7
+ const RADII = new Set(SYSTEM.radius.scale);
8
+ const TYPE = new Set(SYSTEM.type.scale);
9
+ // The page gutter (layout.gutter) is a system value too: horizontal padding and the negative bleed that undoes it.
10
+ const SPACE = new Set([...SYSTEM.space.scale, SYSTEM.layout.gutter]);
11
+ const DISPLAY_ABOVE = SYSTEM.type.display_above;
12
+ const list = (s) => [...s].join(" ");
13
+
14
+ /** Every rule: level, a one-line title, why it matters and how to fix it. Order = report order. */
15
+ export const RULES = {
16
+ "color-literal": {
17
+ level: "error", title: "Colour outside the tokens",
18
+ why: "A hard-coded colour does not follow dark mode or the brand's theme, and drifts from the system.",
19
+ fix: "Use a kit colour variable (var(--gw-ink), var(--gw-muted), var(--gw-line), var(--gw-accent)…). A brand sets its accent once, in a :root block that redefines only --gw-accent*/--gw-wash-* (docs/theming.md).",
20
+ },
21
+ "token-redefined": {
22
+ level: "error", title: "Kit token redefined",
23
+ why: "Only the accent family and the wash hues are themable. Changing another --gw-* token changes every component on the site.",
24
+ fix: "Remove it. If the brand really needs a different value, report a gap: npx gemboss-website gap --kind token.",
25
+ },
26
+ "radius-off-scale": {
27
+ level: "error", title: "Radius not on the scale",
28
+ why: "Actions are 8px rectangles, cards 12px, panels 16px, chips 999px. Other radii make a page look improvised.",
29
+ fix: `Use one of ${list(RADII)} (px), or a kit class that already has the shape.`,
30
+ },
31
+ "type-off-scale": {
32
+ level: "error", title: "Font size not on the type scale",
33
+ why: "Every page reads the same when sizes come from one scale; one 15px next to 14px and 16px looks like a mistake.",
34
+ fix: `Use ${list(TYPE)} (px). Headings above ${DISPLAY_ABOVE}px use the kit's h1/h2 display sizes.`,
35
+ },
36
+ "space-off-scale": {
37
+ level: "error", title: "Spacing not on the scale",
38
+ why: "Spacing comes from one 4px scale; off-scale gaps are what makes a layout feel slightly wrong.",
39
+ fix: `Use ${list(SYSTEM.space.scale)} (px), or the ${SYSTEM.layout.gutter}px page gutter. Vertical rhythm between sections comes from .gw-section.`,
40
+ },
41
+ "half-pixel": {
42
+ level: "error", title: "Half-pixel size",
43
+ why: "Fractional pixels render blurry and are never on a scale.",
44
+ fix: "Round to a whole value on the scale (type and tracking in the kit use em for letter-spacing).",
45
+ },
46
+ "shadow-not-token": {
47
+ level: "error", title: "Shadow that is not a kit shadow",
48
+ why: "Cards are separated by a hairline, not a shadow. The kit has three shadows and that is the whole budget.",
49
+ fix: "Use a 1px var(--gw-line) border, or var(--gw-shadow), var(--gw-shadow-sm), var(--gw-shadow-raised). An illustration may light itself: put illus, mock or visual in its class name.",
50
+ },
51
+ "uppercase-label": {
52
+ level: "error", title: "Uppercase label with letter spacing",
53
+ why: "Labels are sentence case. Uppercase with tracking (\"FEATURES\") is the one look the kit rules out.",
54
+ fix: "Remove text-transform and letter-spacing and use .gw-label.",
55
+ },
56
+ "accent-button": {
57
+ level: "error", title: "Button filled with the accent, a wash or a gradient",
58
+ why: "The thing you click is ink. The accent marks links, focus and the active item; it is never a button fill.",
59
+ fix: "Use .gw-btn (ink) or .gw-btn--secondary as they are. Do not restyle buttons.",
60
+ },
61
+ "gw-override": {
62
+ level: "error", title: "Site CSS overrides a kit class",
63
+ why: "Restyling a .gw-* class changes the component for this site only and breaks on the next kit release.",
64
+ fix: "Remove the override. If the kit's version does not fit, build a kitgap- stopgap in kit-gaps.css and report it: npx gemboss-website gap.",
65
+ },
66
+ "em-dash": {
67
+ level: "error", title: "Em dash in visible text",
68
+ why: "The em dash reads as machine-written copy; the kit's copy rules ban it in anything a visitor sees.",
69
+ fix: "Use a comma, a colon or a full stop.",
70
+ },
71
+ "img-alt": {
72
+ level: "error", title: "Image without alt",
73
+ why: "A screen reader reads out the file name of an image that has no alt.",
74
+ fix: "Add alt=\"what it shows\", or alt=\"\" when it is decoration.",
75
+ },
76
+ "vague-link": {
77
+ level: "error", title: "Link text that does not say where it goes",
78
+ why: "\"Learn more\" or \"click here\" means nothing out of context, for screen readers and for people scanning.",
79
+ fix: "Make the destination the link text, e.g. \"See pricing\" or \"Read the setup guide\".",
80
+ },
81
+ "multiple-h1": {
82
+ level: "error", title: "More than one h1",
83
+ why: "One h1 per page: it is the page's title for search engines and screen readers.",
84
+ fix: "Keep one h1 and make the others h2.",
85
+ },
86
+ "missing-lang": {
87
+ level: "error", title: "<html> without lang",
88
+ why: "Without lang, screen readers, browsers and translation tools guess the language.",
89
+ fix: "Add it, e.g. <html lang=\"en\">.",
90
+ },
91
+ "clickable-div": {
92
+ level: "error", title: "Clickable div or span",
93
+ why: "A div or span with onclick cannot be reached by keyboard and is not announced as a control.",
94
+ fix: "Use <a href> to go somewhere and <button type=\"button\"> to do something.",
95
+ },
96
+ "busy-button": {
97
+ level: "error", title: "Loading button not marked with aria-busy",
98
+ why: "The kit draws a waiting button from aria-busy=\"true\": the label keeps the width and the accessible name, the ring turns (still under reduced motion) and website.js ignores a second press. A loading class or a hand-made spinner does none of that, and disabled throws the keyboard focus back to the top of the page.",
99
+ fix: "While it waits, set aria-busy=\"true\" on the <button class=\"gw-btn\"> and remove it when the answer comes. No loading class, no spinner element, no disabled.",
100
+ },
101
+ "segmented-markup": {
102
+ level: "error", title: "Segmented control without its radio-group markup",
103
+ why: "website.js and screen readers find the choice through the markup: a radiogroup with a name and data-gw-segmented, two to four <button role=\"radio\"> options, exactly one aria-checked=\"true\". Without it the arrows do nothing and the choice is not announced.",
104
+ fix: "Copy the markup from docs/components.md (Segmented control) or templates/sections/pricing.html.",
105
+ },
106
+ "uppercase-text": {
107
+ level: "warning", title: "Uppercase text",
108
+ why: "Headings, buttons, labels and menu items are sentence case.",
109
+ fix: "Write it in sentence case instead of capitals or text-transform: uppercase.",
110
+ },
111
+ "illustrative-caption": {
112
+ level: "warning", title: "Numbers in a drawn mock-up with no caption",
113
+ why: "A drawn mock-up's numbers are made up. Without a caption a visitor reads them as a claim, and a claim found to be made up makes every other number on the page doubtful.",
114
+ fix: "Add <p class=\"gw-window__caption\">Illustrative data.</p> as the last child of the .gw-window (vi: Dữ liệu minh hoạ. zh: 示意数据。), or the source when the numbers are real.",
115
+ },
116
+ "possible-gap": {
117
+ level: "note", title: "Possible kit gap: a site class used on several pages",
118
+ why: "A site class styled in site CSS and used on 3 or more pages is often a reusable part the kit does not have yet.",
119
+ fix: "If it is reusable (a section shape, a component), move it to kit-gaps.css as kitgap-… and report it: npx gemboss-website gap. If it is page content (an illustration, a mock-up), ignore this note.",
120
+ },
121
+ "kitgap": {
122
+ level: "note", title: "Open stopgap (kitgap-*)",
123
+ why: "Site code standing in for something the kit should have.",
124
+ fix: "Make sure it is reported (npx gemboss-website gap) and that a comment names the gap number (/* gap #N */); upgrade tells you when the kit has it.",
125
+ },
126
+ };
127
+ const LEVEL_ORDER = { error: 0, warning: 1, note: 2 };
128
+ const RULE_ORDER = Object.fromEntries(Object.keys(RULES).map((k, i) => [k, i]));
129
+
130
+ // ---------------------------------------------------------------------------------------------- CSS parsing
131
+
132
+ /** Split on commas at depth 0 (outside (), [] and strings). */
133
+ export function splitTop(s, sepChar = ",") {
134
+ const out = [];
135
+ let depth = 0, q = null, start = 0;
136
+ for (let i = 0; i < s.length; i++) {
137
+ const ch = s[i];
138
+ if (q) { if (ch === "\\") i++; else if (ch === q) q = null; continue; }
139
+ if (ch === '"' || ch === "'") q = ch;
140
+ else if (ch === "(" || ch === "[") depth++;
141
+ else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
142
+ else if (ch === sepChar && depth === 0) { out.push(s.slice(start, i)); start = i + 1; }
143
+ }
144
+ out.push(s.slice(start));
145
+ return out.map((x) => x.trim()).filter(Boolean);
146
+ }
147
+
148
+ /** Remove fn(...) calls (with nested parens) for the given names. */
149
+ function stripFns(value, names) {
150
+ const re = new RegExp(`\\b(?:${names.join("|")})\\(`, "gi");
151
+ let out = "", i = 0, m;
152
+ re.lastIndex = 0;
153
+ while ((m = re.exec(value))) {
154
+ out += value.slice(i, m.index);
155
+ let depth = 1, j = m.index + m[0].length;
156
+ for (; j < value.length && depth; j++) {
157
+ if (value[j] === "(") depth++;
158
+ else if (value[j] === ")") depth--;
159
+ }
160
+ out += " ";
161
+ i = j;
162
+ re.lastIndex = j;
163
+ }
164
+ return out + value.slice(i);
165
+ }
166
+
167
+ function resolveNested(sel, parent) {
168
+ if (!parent) return sel;
169
+ const p = splitTop(parent).length > 1 ? `:is(${parent})` : parent;
170
+ return splitTop(sel).map((s) => (s.includes("&") ? s.replace(/&/g, p) : `${p} ${s}`)).join(", ");
171
+ }
172
+
173
+ /**
174
+ * Parse CSS into flat rules { selector, at: [at-rule preludes], decls: [{ prop, value, line }], line }.
175
+ * Handles comments, strings, at-rule blocks and CSS nesting. `baseLine` offsets lines for <style> blocks.
176
+ */
177
+ export function parseCss(css, baseLine = 1) {
178
+ const text = css.replace(/\/\*[\s\S]*?\*\//g, blank);
179
+ const lineAt = lineIndex(text);
180
+ const L = (off) => lineAt(off) + baseLine - 1;
181
+ const rules = [];
182
+ let i = 0;
183
+ const n = text.length;
184
+ function scan() {
185
+ let depth = 0, q = null;
186
+ const start = i;
187
+ for (; i < n; i++) {
188
+ const ch = text[i];
189
+ if (q) { if (ch === "\\") i++; else if (ch === q) q = null; continue; }
190
+ if (ch === '"' || ch === "'") q = ch;
191
+ else if (ch === "(") depth++;
192
+ else if (ch === ")") depth = Math.max(0, depth - 1);
193
+ else if (depth === 0 && (ch === "{" || ch === "}" || ch === ";")) return { start, end: i, ch };
194
+ }
195
+ return { start, end: n, ch: null };
196
+ }
197
+ function block(parentSel, at, rule) {
198
+ for (;;) {
199
+ const seg = scan();
200
+ const raw = text.slice(seg.start, seg.end);
201
+ const trimmed = raw.trim();
202
+ const off = seg.start + (raw.length - raw.trimStart().length);
203
+ if (seg.ch === "{") {
204
+ i++;
205
+ if (trimmed.startsWith("@")) {
206
+ const name = (/^@([\w-]+)/.exec(trimmed) || [, ""])[1].toLowerCase();
207
+ if (/^(media|supports|layer|container|document|scope|starting-style)$/.test(name)) block(parentSel, [...at, trimmed], null);
208
+ else if (/keyframes$/.test(name)) block(null, [...at, trimmed], null);
209
+ else {
210
+ const r = { selector: trimmed, at, decls: [], line: L(off), atBlock: true };
211
+ rules.push(r);
212
+ block(null, at, r);
213
+ }
214
+ } else {
215
+ const r = { selector: resolveNested(trimmed, parentSel), at, decls: [], line: L(off) };
216
+ rules.push(r);
217
+ block(r.selector, at, r);
218
+ }
219
+ continue;
220
+ }
221
+ if (trimmed && rule && !trimmed.startsWith("@")) {
222
+ const colon = trimmed.indexOf(":");
223
+ if (colon > 0) rule.decls.push({ prop: trimmed.slice(0, colon).trim().toLowerCase(), value: trimmed.slice(colon + 1).trim(), line: L(off) });
224
+ }
225
+ if (seg.ch === ";") { i++; continue; }
226
+ if (seg.ch === "}") i++;
227
+ return;
228
+ }
229
+ }
230
+ while (i < n) block(null, [], null);
231
+ return rules;
232
+ }
233
+
234
+ /** Declarations of a style="" attribute. */
235
+ function parseInline(style, line) {
236
+ return splitTop(style, ";").map((d) => {
237
+ const colon = d.indexOf(":");
238
+ return colon > 0 ? { prop: d.slice(0, colon).trim().toLowerCase(), value: d.slice(colon + 1).trim(), line } : null;
239
+ }).filter(Boolean);
240
+ }
241
+
242
+ /** The rightmost compound selector, with :not()/:has() arguments removed (they do not style the subject). */
243
+ export function subjectOf(sel) {
244
+ let s = sel;
245
+ for (let k; (k = s.search(/:(?:not|has)\(/i)) !== -1;) {
246
+ let depth = 0, j = s.indexOf("(", k);
247
+ for (; j < s.length; j++) {
248
+ if (s[j] === "(") depth++;
249
+ else if (s[j] === ")" && --depth === 0) break;
250
+ }
251
+ s = s.slice(0, k) + s.slice(j + 1);
252
+ }
253
+ s = s.trim();
254
+ let depth = 0, start = 0;
255
+ for (let i = 0; i < s.length; i++) {
256
+ const ch = s[i];
257
+ if (ch === "(" || ch === "[") depth++;
258
+ else if (ch === ")" || ch === "]") depth--;
259
+ else if (depth === 0 && /[\s>+~]/.test(ch)) start = i + 1;
260
+ }
261
+ return s.slice(start).trim();
262
+ }
263
+
264
+ const ATTR_THEME = String.raw`\[\s*data-theme(?:\s*[~|^$*]?=\s*(?:"[^"]*"|'[^']*'|[\w-]+))?\s*\]`;
265
+ const ROOTISH = new RegExp(`^(?::root|html)?(?:${ATTR_THEME}|:not\\(\\s*${ATTR_THEME}\\s*\\))*$`, "i");
266
+ const isRootish = (sel) => splitTop(sel).every((s) => s && ROOTISH.test(s));
267
+ const THEMABLE = /^--gw-(?:accent(?:-[\w-]+)?|on-accent(?:-(?:light|dark))?|wash(?:-[\w-]+)?)$/; // docs/theming.md: the eight accent variables (-light/-dark) + wash
268
+
269
+ const COLOR_PROPS = /^(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?(?:-color)?|outline(?:-color)?|fill|stroke|text-decoration(?:-color)?|caret-color|accent-color|column-rule(?:-color)?|text-emphasis(?:-color)?|-webkit-text-fill-color|-webkit-text-stroke(?:-color)?|scrollbar-color|stop-color|flood-color|lighting-color)$/;
270
+ const NAMED = new Set(("white black red green blue yellow orange purple pink gray grey silver gold navy teal maroon olive lime aqua " +
271
+ "cyan magenta fuchsia beige ivory linen wheat tan brown coral salmon crimson indigo violet lavender khaki whitesmoke " +
272
+ "gainsboro lightgray lightgrey darkgray darkgrey dimgray dimgrey snow ghostwhite floralwhite oldlace antiquewhite seashell " +
273
+ "cornsilk honeydew mintcream azure aliceblue mistyrose papayawhip bisque moccasin peachpuff lemonchiffon lightyellow").split(" "));
274
+
275
+ function colorLiterals(value) {
276
+ const v = stripFns(value, ["var", "url", "env", "image-set"]).replace(/"[^"]*"|'[^']*'/g, " ");
277
+ const found = [];
278
+ for (const m of v.matchAll(/#[0-9a-f]{3,8}\b/gi)) if ([4, 5, 7, 9].includes(m[0].length)) found.push(m[0]);
279
+ for (const m of v.matchAll(/\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)/gi)) found.push(m[0]);
280
+ for (const m of v.matchAll(/(?<![\w-])[a-z]+(?![\w-])/gi)) if (NAMED.has(m[0].toLowerCase())) found.push(m[0]);
281
+ return found;
282
+ }
283
+
284
+ const RADIUS_PROPS = /^border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius$/;
285
+ const SPACE_PROPS = /^(?:(?:margin|padding)(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?|(?:grid-)?(?:row-|column-)?gap)$/;
286
+ const HALF_PX_PROPS = /^(?:width|height|(?:min|max)-(?:width|height)|inset(?:-[\w-]+)?|top|right|bottom|left|line-height|letter-spacing|word-spacing|border(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?(?:-width)?|outline(?:-width|-offset)?|flex-basis|text-indent|text-underline-offset|text-decoration-thickness)$/;
287
+ const ILLUSTRATION = /illus|illo|artwork|mock|visual|graphic|scene|decor|drawing|figure|(?:^|[-_.\s])art(?:$|[-_\s])/i;
288
+ const BUTTONISH = /btn|button/i;
289
+ const pxNumbers = (v) => [...v.matchAll(/(-?\d*\.?\d+)(px|rem)\b/gi)].map((m) => ({ raw: m[0], px: Math.abs(parseFloat(m[1])) * (m[2].toLowerCase() === "rem" ? 16 : 1), unit: m[2].toLowerCase() }));
290
+ const isFraction = (n) => Math.abs(n - Math.round(n)) > 1e-6;
291
+ const normShadow = (s) => s.toLowerCase().replace(/\b0\./g, ".").replace(/\s*([,()])\s*/g, "$1").replace(/\s+/g, " ").trim();
292
+ const TOKEN_SHADOWS = new Set(Object.entries(SYSTEM.shadow).filter(([k]) => !k.startsWith("$")).map(([, v]) => normShadow(v)));
293
+
294
+ function okShadowPart(p) {
295
+ const v = p.trim();
296
+ if (/^var\(\s*--gw-shadow[\w-]*\s*\)$/i.test(v)) return true;
297
+ // A focus or hairline ring (0 0 0 Npx) is state, not elevation.
298
+ if (/^(?:inset\s+)?0(?:px)?\s+0(?:px)?\s+0(?:px)?\s+\d+(?:\.\d+)?px(?:\s|$)/i.test(v)) return true;
299
+ return false;
300
+ }
301
+
302
+ function excerptOf(selector, d) {
303
+ const s = `${selector ? selector + " { " : ""}${d.prop}: ${d.value}${selector ? " }" : ""}`.replace(/\s+/g, " ");
304
+ return s.length > 140 ? s.slice(0, 137) + "…" : s;
305
+ }
306
+
307
+ /**
308
+ * Check one rule and its declarations. `ctx`: { file, svg, inline } where svg = the CSS sits inside an <svg> (an
309
+ * illustration's own styles, where colours are allowed) and inline = a style="" attribute (no selector of its own).
310
+ */
311
+ function checkRule(rule, ctx, add) {
312
+ if (rule.atBlock) return;
313
+ const sel = rule.selector || "";
314
+ const selNoNot = withoutNot(sel);
315
+ const illustration = ctx.svg || ILLUSTRATION.test(sel);
316
+ const rootish = sel && isRootish(sel);
317
+ let upper = null, tracking = false;
318
+ for (const d of rule.decls) {
319
+ const value = d.value.replace(/!important\s*$/i, "").trim();
320
+ const where = { file: ctx.file, line: d.line, excerpt: excerptOf(sel, d) };
321
+ if (d.prop.startsWith("--")) {
322
+ if (d.prop.startsWith("--gw-")) {
323
+ if (!(THEMABLE.test(d.prop) && rootish)) add("token-redefined", where);
324
+ } else if (!ctx.svg && colorLiterals(value).length) add("color-literal", where);
325
+ continue;
326
+ }
327
+ if (COLOR_PROPS.test(d.prop) && !ctx.svg && colorLiterals(value).length) add("color-literal", where);
328
+ if (RADIUS_PROPS.test(d.prop)) {
329
+ const v = stripFns(value, ["var", "calc"]);
330
+ for (const tok of v.split(/[\s/]+/).filter(Boolean)) {
331
+ const m = /^(-?\d*\.?\d+)(px|rem|%)?$/i.exec(tok);
332
+ if (!m) continue;
333
+ const unit = (m[2] || "px").toLowerCase();
334
+ if (unit === "%") { if (parseFloat(m[1]) !== 50) add("radius-off-scale", where); continue; }
335
+ const px = parseFloat(m[1]) * (unit === "rem" ? 16 : 1);
336
+ if (unit === "px" && isFraction(px)) add("half-pixel", where);
337
+ else if (!RADII.has(px)) add("radius-off-scale", where);
338
+ }
339
+ }
340
+ if (d.prop === "font-size" || d.prop === "font") checkFontSize(d, value, where, add);
341
+ if (SPACE_PROPS.test(d.prop)) {
342
+ for (const n of pxNumbers(stripFns(value, ["var", "calc"]))) {
343
+ if (n.unit === "px" && isFraction(n.px)) { add("half-pixel", where); break; }
344
+ if (n.px >= 1000) continue; // an off-screen trick, not spacing
345
+ if (!SPACE.has(n.px)) { add("space-off-scale", where); break; }
346
+ }
347
+ }
348
+ if (HALF_PX_PROPS.test(d.prop) && pxNumbers(value).some((n) => n.unit === "px" && isFraction(n.px))) add("half-pixel", where);
349
+ if (d.prop === "box-shadow" && !illustration && !/^(?:none|initial|inherit|unset|revert(?:-layer)?)$/i.test(value)) {
350
+ if (!TOKEN_SHADOWS.has(normShadow(value)) && !splitTop(value).every(okShadowPart)) add("shadow-not-token", where);
351
+ }
352
+ if (/^background(?:-color|-image)?$/.test(d.prop) && BUTTONISH.test(selNoNot) &&
353
+ (/var\(\s*--gw-(?:accent|wash)/i.test(value) || /gradient\(/i.test(value))) add("accent-button", where);
354
+ if (d.prop === "text-transform" && /\buppercase\b/i.test(value) && !/::?first-letter/i.test(sel)) upper = where;
355
+ if (d.prop === "letter-spacing" && !/^(?:normal|0(?:\.0+)?(?:px|em|rem)?|initial|inherit|unset)$/i.test(value)) tracking = true;
356
+ }
357
+ if (upper) add(tracking ? "uppercase-label" : "uppercase-text", upper);
358
+ if (!ctx.inline) {
359
+ for (const s of splitTop(sel)) {
360
+ if (/\.gw-[\w-]/.test(subjectOf(s))) { add("gw-override", { file: ctx.file, line: rule.line, excerpt: `${s} { … }` }); break; }
361
+ }
362
+ }
363
+ }
364
+ const withoutNot = (sel) => sel.replace(/:not\([^)]*\)/gi, "");
365
+
366
+ function checkFontSize(d, value, where, add) {
367
+ let v = value;
368
+ if (d.prop === "font") {
369
+ // The size is the token with a unit right before "/line-height" or the family: font: 600 15px/1.4 var(--gw-font)
370
+ const m = /(?:^|\s)((?:clamp|min|max|calc)\([^)]*\)|-?\d*\.?\d+(?:px|rem|em|%|vw|vh|pt))(?=\s*\/|\s|$)/i.exec(stripFns(v, ["var"]));
371
+ if (!m) return;
372
+ v = m[1];
373
+ }
374
+ const nums = pxNumbers(stripFns(v, ["var"]));
375
+ if (!nums.length) return; // em, %, keywords, vw-only or a variable: not ours to judge
376
+ if (nums.some((n) => n.unit === "px" && isFraction(n.px))) { add("half-pixel", where); return; }
377
+ const fluid = /\b(?:clamp|min|max|calc)\(/i.test(v);
378
+ if (fluid && Math.max(...nums.map((n) => n.px)) > DISPLAY_ABOVE) return; // display sizes are fluid by design
379
+ for (const n of nums) {
380
+ if (n.px > DISPLAY_ABOVE) continue;
381
+ if (isFraction(n.px) || !TYPE.has(n.px)) { add("type-off-scale", where); return; }
382
+ }
383
+ }
384
+
385
+ // --------------------------------------------------------------------------------------------- HTML scanning
386
+
387
+ const TAG_RE = /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
388
+ const ATTR_RE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
389
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", mdash: "—", ndash: "–", rarr: "→", larr: "←", hellip: "…", rsquo: "’", lsquo: "‘", ldquo: "“", rdquo: "”" };
390
+ const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
391
+ if (e[0] === "#") { const cp = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(cp) ? String.fromCodePoint(cp) : m; }
392
+ return ENTITIES[e.toLowerCase()] ?? m;
393
+ });
394
+ const EM_DASH_RE = /—|&mdash;|&#8212;|&#x0*2014;/gi;
395
+ const hasEmDash = (s) => /—|&mdash;|&#8212;|&#x0*2014;/i.test(s);
396
+ const VAGUE = new Set(["click here", "here", "learn more", "read more", "more", "see more", "find out more", "click", "this link", "link", "details", "more info"]);
397
+ const LABELISH = /label|eyebrow|kicker|overline|badge|chip|btn|button|pill/i;
398
+ const SKIP_CLASS = /^(?:gw-|is-|has-|js-|kitgap-)/;
399
+
400
+ /** The end offset of the element whose start tag is at `start`, counting nested tags of the same name; -1 if unclosed. */
401
+ function elementEnd(text, start, tag) {
402
+ const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
403
+ re.lastIndex = start;
404
+ let depth = 0;
405
+ for (let m; (m = re.exec(text));) {
406
+ if (m[1]) { if (--depth <= 0) return m.index + m[0].length; } else if (!m[0].endsWith("/>")) depth++;
407
+ }
408
+ return -1;
409
+ }
410
+ const LOADING_CLASS = /^(?:is-)?(?:loading|busy)$|--(?:loading|busy)$/;
411
+ const SPINNER_CLASS = /\bclass\s*=\s*["'][^"']*(?:spinner|loader|loading)/i;
412
+
413
+ function parseAttrs(s) {
414
+ const a = {};
415
+ for (const m of s.matchAll(ATTR_RE)) a[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
416
+ return a;
417
+ }
418
+ function ranges(text, re) { return [...text.matchAll(re)].map((m) => [m.index, m.index + m[0].length]); }
419
+ const within = (rs, off) => rs.some(([a, b]) => off >= a && off < b);
420
+
421
+ function shouty(text) {
422
+ const t = decode(text).trim();
423
+ if (!t || /\p{Ll}/u.test(t)) return false;
424
+ const words = t.match(/\p{Lu}{2,}/gu) || [];
425
+ return words.some((w) => w.length >= 5) || words.filter((w) => w.length >= 3).length >= 2;
426
+ }
427
+
428
+ function analyzeHtml(file, text, out) {
429
+ const relFile = rel(file);
430
+ const add = out.add;
431
+ const noComments = text.replace(/<!--[\s\S]*?-->/g, blank);
432
+ const lineAt = lineIndex(text);
433
+ // Raw-text elements: their content is code, never tags or visible copy.
434
+ const raw = [...noComments.matchAll(/<(script|style)\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi)].map((m) => {
435
+ const start = m.index + m[0].indexOf(">") + 1;
436
+ return { tag: m[1].toLowerCase(), start, end: start + m[3].length };
437
+ });
438
+ let scanText = noComments;
439
+ for (const r of raw) scanText = scanText.slice(0, r.start) + blank(scanText.slice(r.start, r.end)) + scanText.slice(r.end);
440
+ const svg = ranges(scanText, /<svg\b[\s\S]*?<\/svg\s*>/gi);
441
+ const code = ranges(scanText, /<(pre|code|kbd|samp|template|svg)\b[\s\S]*?<\/\1\s*>/gi);
442
+
443
+ // <style> blocks: CSS rules apply, and lines point into the HTML file.
444
+ for (const r of raw.filter((x) => x.tag === "style")) {
445
+ const css = noComments.slice(r.start, r.end);
446
+ const rules = parseCss(css, lineAt(r.start));
447
+ const isSvg = within(svg, r.start);
448
+ for (const rule of rules) checkRule(rule, { file: relFile, svg: isSvg }, add);
449
+ out.cssRules.push({ file: relFile, rules, text, inline: true });
450
+ }
451
+
452
+ // Visible text: tags and code blanked, offsets kept.
453
+ let visible = scanText;
454
+ for (const [a, b] of code) visible = visible.slice(0, a) + blank(visible.slice(a, b)) + visible.slice(b);
455
+ visible = visible.replace(TAG_RE, blank).replace(/<\/[a-zA-Z][\w:-]*\s*>/g, blank).replace(/<![^>]*>/g, blank);
456
+ for (const m of visible.matchAll(EM_DASH_RE)) {
457
+ const ls = visible.lastIndexOf("\n", m.index) + 1, le = visible.indexOf("\n", m.index);
458
+ const around = decode(visible.slice(Math.max(ls, m.index - 60), Math.min(le === -1 ? visible.length : le, m.index + 60))).replace(/\s+/g, " ").trim();
459
+ add("em-dash", { file: relFile, line: lineAt(m.index), col: m.index, excerpt: around });
460
+ }
461
+
462
+ const classes = new Set();
463
+ let h1 = 0;
464
+ for (const m of scanText.matchAll(TAG_RE)) {
465
+ const tag = m[1].toLowerCase();
466
+ const a = parseAttrs(m[2] || "");
467
+ const line = lineAt(m.index);
468
+ const where = (excerpt) => ({ file: relFile, line, excerpt: excerpt ?? m[0].replace(/\s+/g, " ").slice(0, 140) });
469
+ if (tag === "img" && !("alt" in a)) add("img-alt", where());
470
+ if (tag === "h1" && ++h1 === 2) add("multiple-h1", where());
471
+ if (tag === "html" && !(a.lang || "").trim()) add("missing-lang", where());
472
+ if ((tag === "div" || tag === "span") && "onclick" in a) add("clickable-div", where());
473
+ for (const k of ["alt", "title", "aria-label", "placeholder"]) {
474
+ if (a[k] && hasEmDash(a[k])) add("em-dash", where(`${k}="${a[k]}"`));
475
+ }
476
+ if (tag === "meta" && /^(?:description|og:description|twitter:description)$/i.test(a.name || a.property || "") && hasEmDash(a.content || "")) {
477
+ add("em-dash", where(`content="${a.content}"`));
478
+ }
479
+ const cls = (a.class || "").split(/\s+/).filter(Boolean);
480
+ for (const k of cls) {
481
+ classes.add(k);
482
+ if (k.startsWith("kitgap-") && !out.kitgapUse.has(gapBlock(k))) out.kitgapUse.set(gapBlock(k), { file: relFile, line });
483
+ }
484
+ const inner = () => { const end = elementEnd(scanText, m.index, tag); return end < 0 ? "" : scanText.slice(m.index + m[0].length, end); };
485
+ if (cls.includes("gw-btn")) {
486
+ const busy = (a["aria-busy"] || "").trim() === "true";
487
+ if (busy ? "disabled" in a : cls.some((k) => LOADING_CLASS.test(k)) || SPINNER_CLASS.test(inner())) add("busy-button", where());
488
+ }
489
+ if (cls.includes("gw-segmented")) {
490
+ const opts = [...inner().matchAll(/<button\b([^>]*)>/gi)].map((x) => parseAttrs(x[1]));
491
+ const missing = [
492
+ a.role !== "radiogroup" && 'role="radiogroup"',
493
+ !(a["aria-label"] || a["aria-labelledby"]) && "aria-label",
494
+ !(a["data-gw-segmented"] || "").trim() && "data-gw-segmented=\"name\"",
495
+ (opts.length < 2 || opts.length > 4) && "two to four <button> options",
496
+ opts.some((o) => o.role !== "radio" || !("aria-checked" in o)) && 'role="radio" and aria-checked on each option',
497
+ opts.filter((o) => o["aria-checked"] === "true").length !== 1 && 'exactly one aria-checked="true"',
498
+ ].filter(Boolean);
499
+ if (missing.length) add("segmented-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 100)} missing: ${missing.join(", ")}`));
500
+ }
501
+ if (cls.includes("gw-window")) {
502
+ const end = elementEnd(scanText, m.index, tag);
503
+ const body = end < 0 ? "" : scanText.slice(m.index, end);
504
+ const words = end < 0 ? "" : decode(visible.slice(m.index, end)).replace(/\s+/g, " ").trim();
505
+ if (/\d/.test(words) && !/<(?:img|picture|video|iframe|canvas)\b/i.test(body) && !/\bgw-window__caption\b/.test(body)) {
506
+ add("illustrative-caption", where(`${m[0].replace(/\s+/g, " ").slice(0, 60)} ${words.slice(0, 70)}`));
507
+ }
508
+ }
509
+ if ("style" in a && a.style.trim()) {
510
+ const selector = tag + (a.id ? `#${a.id}` : "") + cls.map((k) => "." + k).join("");
511
+ checkRule({ selector, decls: parseInline(decode(a.style), line), line }, { file: relFile, svg: within(svg, m.index), inline: true }, add);
512
+ }
513
+ if (tag === "a" && "href" in a) {
514
+ const end = m.index + m[0].length;
515
+ const close = scanText.slice(end).search(/<\/a\s*>/i);
516
+ if (close !== -1) {
517
+ const inner = scanText.slice(end, end + close);
518
+ const alts = [...inner.matchAll(/\balt\s*=\s*"([^"]*)"/gi)].map((x) => x[1]).join(" ");
519
+ let words = decode(inner.replace(/<[^>]*>/g, " ")).trim() || alts;
520
+ words = words.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
521
+ if (VAGUE.has(words)) add("vague-link", where(`<a href="${a.href}">${decode(inner.replace(/<[^>]*>/g, "")).trim()}</a>`));
522
+ }
523
+ }
524
+ if (cls.some((k) => LABELISH.test(k)) && tag !== "html" && tag !== "body") {
525
+ const after = scanText.slice(m.index + m[0].length, m.index + m[0].length + 200);
526
+ const txt = after.slice(0, after.search(/<|$/));
527
+ if (shouty(txt)) add("uppercase-text", where(`${m[0].slice(0, 60)}${txt.trim()}`));
528
+ }
529
+ }
530
+ out.pageClasses.push({ file: relFile, classes });
531
+ }
532
+
533
+ // ------------------------------------------------------------------------------------------------- running
534
+
535
+ function isKitCopy(file, text) {
536
+ if (/^\s*\/\*!?\s*@gemboss\/website-ui\b/.test(text.slice(0, 400))) return true;
537
+ return /^website(?:\.min)?\.css$/.test(basename(file)) && /--gw-bg\s*:/.test(text) && /\.gw-btn\b/.test(text);
538
+ }
539
+
540
+ /** kitgap-tabs__tab--active -> kitgap-tabs: one stopgap per block. */
541
+ const gapBlock = (k) => k.replace(/(__|--).*$/, "");
542
+
543
+ /**
544
+ * Gap numbers (#N, or an issues/N URL) named on a stopgap's definition line or in the comment block right above it
545
+ * (up to a blank line or the end of the previous rule).
546
+ */
547
+ export function gapRefsNear(text, line) {
548
+ const lines = text.split("\n");
549
+ const block = [lines[line - 1] || ""];
550
+ for (let i = line - 2; i >= 0 && i >= line - 12; i--) {
551
+ const l = lines[i];
552
+ if (!l.trim() || /[};]\s*$/.test(l.replace(/\/\*.*?\*\//g, ""))) break;
553
+ block.push(l);
554
+ }
555
+ const refs = new Set();
556
+ for (const m of block.join("\n").matchAll(/gap\s*#\s*(\d+)|\/issues\/(\d+)/gi)) refs.add(Number(m[1] || m[2]));
557
+ return [...refs];
558
+ }
559
+
560
+ /** Scan files; returns { files: {html, css}, findings }. */
561
+ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
562
+ const files = walkFiles(paths, /\.(?:html?|css)$/i, { ignore, includeKit });
563
+ const findings = [];
564
+ const seen = new Set();
565
+ const add = (rule, w) => {
566
+ const key = `${rule}|${w.file}|${w.line}|${w.col ?? ""}|${w.excerpt}`;
567
+ if (seen.has(key)) return;
568
+ seen.add(key);
569
+ const r = RULES[rule];
570
+ findings.push({ rule, level: r.level, file: w.file, line: w.line, excerpt: w.excerpt, title: r.title, why: r.why, fix: r.fix });
571
+ };
572
+ const out = { add, cssRules: [], pageClasses: [], kitgapUse: new Map() };
573
+ const counts = { html: 0, css: 0, skipped: [] };
574
+ for (const f of files) {
575
+ let text;
576
+ try { text = readFileSync(f, "utf8"); } catch { continue; }
577
+ if (/\.css$/i.test(f)) {
578
+ // The kit's own CSS defines the .gw-* classes and the tokens: never site CSS, even with --include-kit.
579
+ if (isKitPath(f) || isKitCopy(f, text)) { counts.skipped.push(rel(f)); continue; }
580
+ counts.css++;
581
+ const rules = parseCss(text);
582
+ for (const rule of rules) checkRule(rule, { file: rel(f) }, add);
583
+ out.cssRules.push({ file: rel(f), rules, text });
584
+ } else {
585
+ counts.html++;
586
+ analyzeHtml(f, text, out);
587
+ }
588
+ }
589
+
590
+ // Classes styled in site CSS: first definition of each.
591
+ const styled = new Map();
592
+ for (const { file, rules, text } of [...out.cssRules].sort((a, b) => (a.inline ? 1 : 0) - (b.inline ? 1 : 0))) {
593
+ for (const r of rules) {
594
+ if (r.atBlock) continue;
595
+ for (const m of (r.selector || "").matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)) {
596
+ const k = m[1].startsWith("kitgap-") ? gapBlock(m[1]) : m[1];
597
+ if (!styled.has(k)) styled.set(k, { file, line: r.line, text });
598
+ }
599
+ }
600
+ }
601
+ // Possible gaps: a site class on 3+ pages that site CSS styles.
602
+ const pagesOf = new Map();
603
+ for (const { file, classes } of out.pageClasses) {
604
+ for (const k of classes) {
605
+ if (SKIP_CLASS.test(k) || !styled.has(k)) continue;
606
+ if (!pagesOf.has(k)) pagesOf.set(k, []);
607
+ pagesOf.get(k).push(file);
608
+ }
609
+ }
610
+ [...pagesOf].filter(([, p]) => p.length >= 3).sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])).slice(0, 25)
611
+ .forEach(([k, pages]) => {
612
+ const d = styled.get(k);
613
+ add("possible-gap", { file: d.file, line: d.line, excerpt: `.${k} on ${pages.length} pages (${pages.slice(0, 3).join(", ")}${pages.length > 3 ? ", …" : ""})` });
614
+ });
615
+ // Open stopgaps.
616
+ const kitgaps = [];
617
+ const names = new Set([...styled.keys(), ...out.kitgapUse.keys()].filter((k) => k.startsWith("kitgap-")));
618
+ for (const k of [...names].sort()) {
619
+ const d = styled.get(k);
620
+ const at = d || out.kitgapUse.get(k);
621
+ const refs = d ? gapRefsNear(d.text, d.line) : [];
622
+ kitgaps.push({ class: k, file: at.file, line: at.line, refs });
623
+ add("kitgap", { file: at.file, line: at.line, excerpt: `.${k}${refs.length ? ` (gap ${refs.map((n) => "#" + n).join(", ")})` : " (no gap number in a comment above it)"}${d ? "" : " used in HTML, not styled in site CSS"}` });
624
+ }
625
+ findings.sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level] || RULE_ORDER[a.rule] - RULE_ORDER[b.rule] ||
626
+ a.file.localeCompare(b.file) || a.line - b.line);
627
+ return { files: counts, findings, kitgaps };
628
+ }
629
+
630
+ const HELP_LIMIT = 40;
631
+
632
+ export function cmdCheck(argv) {
633
+ const opts = parseArgs(argv, { json: "bool", "fix-list": "bool", ignore: "list", "include-kit": "bool" });
634
+ const paths = opts._.length ? opts._ : ["."];
635
+ for (const p of paths) if (!existsSync(p)) throw new UsageError(`no such file or folder: ${p}`);
636
+ const res = runCheck(paths.map((p) => resolve(p)), { ignore: opts.ignore, includeKit: opts["include-kit"] });
637
+ const n = { error: 0, warning: 0, note: 0 };
638
+ for (const f of res.findings) n[f.level]++;
639
+ const exit = n.error ? 1 : 0;
640
+ const plural = (k, w) => `${k} ${w}${k === 1 ? "" : "s"}`;
641
+ const summary = `${plural(n.error, "error")}, ${plural(n.warning, "warning")}, ${plural(n.note, "note")}`;
642
+
643
+ if (opts.json) {
644
+ process.stdout.write(JSON.stringify({
645
+ tool: "gemboss-website check", kit: `${PKG.name}@${PKG.version}`, cwd: process.cwd(), paths,
646
+ files: { html: res.files.html, css: res.files.css, skippedKitCopies: res.files.skipped },
647
+ summary: { errors: n.error, warnings: n.warning, notes: n.note, ok: !exit },
648
+ findings: res.findings, kitgaps: res.kitgaps,
649
+ }, null, 2) + "\n");
650
+ return exit;
651
+ }
652
+ const groups = new Map();
653
+ for (const f of res.findings) {
654
+ if (!groups.has(f.rule)) groups.set(f.rule, []);
655
+ groups.get(f.rule).push(f);
656
+ }
657
+ const head = `${res.files.html} HTML and ${res.files.css} CSS files checked against ${PKG.name} ${PKG.version}`;
658
+
659
+ if (opts["fix-list"]) {
660
+ const md = [`# Website check: to-do`, "", `${head}: ${summary}.`, ""];
661
+ for (const [level, heading] of [["error", "Errors (fix all of these)"], ["warning", "Warnings"], ["note", "Notes: possible gaps and open stopgaps"]]) {
662
+ const rules = [...groups].filter(([, fs]) => fs[0].level === level);
663
+ if (!rules.length) continue;
664
+ md.push(`## ${heading}`, "");
665
+ for (const [rule, fs] of rules) {
666
+ const r = RULES[rule];
667
+ md.push(`### ${rule}: ${r.title} (${fs.length})`, "", `Why: ${r.why}`, "", `Fix: ${r.fix}`, "");
668
+ for (const f of fs) md.push(`- [ ] \`${f.file}:${f.line}\` ${f.excerpt.replace(/`/g, "'")}`);
669
+ md.push("");
670
+ }
671
+ }
672
+ if (!res.findings.length) md.push("Nothing to do.", "");
673
+ md.push("Run `npx gemboss-website check` again when done. It does not replace looking at each page at 320px and 1366px.");
674
+ process.stdout.write(md.join("\n") + "\n");
675
+ return exit;
676
+ }
677
+
678
+ const lines = [c.bold("gemboss-website check") + ` ${head}`];
679
+ if (res.files.skipped.length) lines.push(c.dim(`(skipped kit copies: ${res.files.skipped.join(", ")})`));
680
+ if (!res.files.html && !res.files.css) lines.push("", `No .html, .htm or .css files under ${paths.join(", ")}.`);
681
+ for (const [rule, fs] of groups) {
682
+ const r = RULES[rule];
683
+ const tag = r.level === "error" ? c.red("error") : r.level === "warning" ? c.yellow("warning") : c.cyan("note");
684
+ lines.push("", `${tag} ${c.bold(rule)} (${fs.length}): ${r.title}`, ` why: ${r.why}`, ` fix: ${r.fix}`);
685
+ const width = Math.min(48, Math.max(...fs.map((f) => `${f.file}:${f.line}`.length)));
686
+ for (const f of fs.slice(0, HELP_LIMIT)) lines.push(` ${`${f.file}:${f.line}`.padEnd(width)} ${f.excerpt}`);
687
+ if (fs.length > HELP_LIMIT) lines.push(` … ${fs.length - HELP_LIMIT} more (all of them: --fix-list or --json)`);
688
+ }
689
+ lines.push("", exit
690
+ ? `${c.red(summary)}. Fix the errors and run the check again.`
691
+ : `${c.green(summary)}. ${n.warning ? "No errors; look at the warnings." : "No errors."} Now look at each page at 320px and 1366px.`);
692
+ if (n.note) lines.push(c.dim("Notes do not fail the check. A real gap: npx gemboss-website gap (AGENTS.md section 9)."));
693
+ process.stdout.write(lines.join("\n") + "\n");
694
+ return exit;
695
+ }