@domandigital/craft 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7622bd9: Phase K: snapshot version 2.
8
+
9
+ - Every section gains a `role`: `cta-band`, `footer-cta`, `pricing`, `testimonials`, `faq`, `process`, `features`, `team`, `contact`, or its `kind`. `kind` keeps its version 1 meaning, so the rendered tells and the calibrated null models and estate read exactly as before.
10
+ - Every section gains `geometry`: `centredShare`, `mirrorSymmetry`, `whitespaceRatio`, `contentWidthRatio`, `background` and `controls`. The page gains `rhythmVariance` (how much section heights vary).
11
+ - `visual`: colourfulness (Hasler and Süsstrunk), edge density and left-right symmetry, from a screenshot of the first 6000px. None of them passes or fails anything. `snapshotUrl(url, { visual: false })` skips the screenshot; a driver without `page.screenshot` gets no `visual`.
12
+ - `SNAPSHOT_VERSION` is 2. `readSnapshot()` reads version 1 files as version 2 marked `migratedFrom: 1`; every command that loads a saved snapshot uses it, so old snapshots still work.
13
+ - `FINGERPRINT_VERSION` is 2: fingerprints carry `sections` (role, centring, width), and `layoutDistance` compares by role and geometry when both sides have them. Against a version 1 fingerprint it compares by `kind`, exactly as before.
14
+ - New exports: `readSnapshot`, `READABLE_SNAPSHOT_VERSIONS`, `visualMeasures`, `EDGE_STEP`, `layoutDistance`, and the types `SectionRole`, `SectionGeometry`, `SnapshotVisual`, `FingerprintSection`, `RgbaImage`.
15
+
16
+ - 07d1cf5: From the 2026-09-11 AI-look research. A new tell, `shadcn-card-stock`: the registry Card recipe left unchanged (`warn`). Cal Sans joins the second-wave reflex fonts. The icon-tile tells now see Unicons, MUI icons and Material Symbols as well as Lucide, Heroicons, Phosphor and Tabler. `craft direction validate` warns when a display or body face is missing from the new licence register, or is capped, per-site or unverified there. The register ships as `licences.json` and as `LICENCES` and `faceLicence()`. Catalogue `2026.09.9`.
17
+ - c63cded: **Behaviour change:** under `craft copy --gate`, a repo can no longer lower the house blocking tier. A `warn` or `off` in `craft.config.json`, or an `art-direction.json` exception, naming a blocking tell (em dash, contrastive negation, chatbot residue and the rest) is refused and listed as "Exception not applied". Raising a tell still works, runs without `--gate` are unchanged, and a genuine one-off still takes `copy-ok` on its line. `holdHouseBlocks` and `HOUSE_LOCKED` are exported from the house policy.
18
+
19
+ ## 0.11.0
20
+
21
+ ### Minor Changes
22
+
23
+ - e2ea0e8: Phase I: craft in CI.
24
+
25
+ - `craft.config.json`: `ignore` globs, `copyPaths` (where `craft copy` looks when given no path), and `severity` changes per tell. Every severity change needs a `because` of at least a sentence; `off` is applied as an exception, so the report still lists what it silenced. `--config <file>` points elsewhere.
26
+ - `.claude`, `.agents` and `.cursor` are never walked (a path named on the command line is still read).
27
+ - `--baseline <file>` reports only findings the baseline does not hold; `--update-baseline` writes it. Findings are keyed on tell, path and excerpt, not line, and counted.
28
+ - `--sarif <file>` writes SARIF 2.1.0 for GitHub code scanning.
29
+ - Every `--json` output carries `schemaVersion: 1`. The audit JSON adds `pages` and `failed`.
30
+ - `craft audit --pages <sitemap.xml | urls.txt>` and `--viewport 390,768,1440`. A page that will not load is listed as not measured and fails `--strict`.
31
+ - **Behaviour change:** a page that answers with an HTTP error (404, 500) is no longer snapshotted as if it were the page. `snapshotUrls` reports it as an error and `snapshotUrl` throws `HTTP <status>`. Before, a missing page was measured, and reported clean.
32
+ - New exports: `createBaseline`, `applyBaseline`, `parseBaseline`, `findingKey`, `parseCraftConfig`, `applySeverity`, `severityExceptions`, `globToRegExp`, `ignoreMatcher`, `DEFAULT_IGNORE`, `toSarif`, `toJson`, `JSON_SCHEMA_VERSION`.
33
+
34
+ - 3eb1560: CommonJS consumers now get the CommonJS type declarations. The `exports` map put a top-level `types` (the ESM `.d.ts`) ahead of `require`, so TypeScript resolving a `require` under `node16` stopped at the ESM file ("Masquerading as ESM" in @arethetypeswrong/cli). `types` now sits inside `import` and `require` separately. Runtime behaviour is unchanged.
35
+
36
+ Requires Node 22.12 or later (`engines.node`). Node 20 reached end of life on 2026-04-30. Also declares `sideEffects` so bundlers can tree-shake (craft keeps its CSS).
37
+
3
38
  ## 0.10.0
4
39
 
5
40
  ### Minor Changes
package/CHARACTER.md CHANGED
@@ -88,6 +88,13 @@ clean, premium, elegant and the rest), and a value on the tell catalogue
88
88
  without an exception carrying the same reason. Given a snapshot, it warns
89
89
  where the page does not show what the file declares.
90
90
 
91
+ It also reads the display and body faces against the licence register
92
+ (`src/direction/licences.ts`, exported as `licences.json`) and warns on a face
93
+ the register does not know, or knows only as capped, per-site or unverified. A
94
+ licence warning does not undecide the choice: the reason can be sound and the
95
+ face still unlicensed for this client. The register records facts, checked on
96
+ the licensor's own page. It is not a shortlist, and nothing proposes from it.
97
+
91
98
  `craft direction init` writes what the site does today with every reason
92
99
  empty, so the first thing it produces is the list of things nobody decided.
93
100
  Run on RMP's live home page (2026-09-23) it records Fraunces, Manrope, a cream
@@ -534,7 +541,7 @@ Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
534
541
  after changing an entry; a test fails until you do.
535
542
 
536
543
  <!-- craft:catalogue:start -->
537
- Catalogue version `2026.09.8`, 55 tells.
544
+ Catalogue version `2026.09.9`, 56 tells.
538
545
 
539
546
  | Id | Gen | Surface | Severity | Tell | Why it is a default |
540
547
  | --- | --- | --- | --- | --- | --- |
@@ -549,6 +556,7 @@ Catalogue version `2026.09.8`, 55 tells.
549
556
  | `reveal-everywhere` | 1 | source + rendered | warn | Reveal on every section | When every block fades up on scroll, the motion stops meaning anything and the page feels slow. MMM had 70, DD 40, sen-sphere 27. |
550
557
  | `pill-everything` | 1 | source + rendered | warn | Pills everywhere | rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language. |
551
558
  | `shadcn-dump` | 1 | source | warn | Stock component dump | A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets. |
559
+ | `shadcn-card-stock` | 1 | source | warn | Stock shadcn Card | The registry Card (bg-card, a plain border, rounded-lg or rounded-xl, shadow-sm) is the container every generated page is built from. Left as shipped, every card on the site looks like every other shadcn site's. |
552
560
  | `cream-palette` | 2 | source + rendered | warn | Cream ground | Once white-and-violet was named, models moved to a warm off-white: parchment, linen, oat. It reads as 'tasteful' in the same way everywhere. |
553
561
  | `italic-serif-display` | 2 | source + rendered | warn | Italic serif display | A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move. |
554
562
  | `hero-eyebrow-chip` | 2 | source + rendered | warn | Eyebrow chip above the hero | A small bordered pill ('New · Now booking') above the headline is in almost every generated hero. |
package/COPY.md CHANGED
@@ -256,8 +256,12 @@ For genuine exceptions only, and the reason matters more than the mechanism:
256
256
  - `.copyauditignore` at the repo root: globs, matched on the repo-relative
257
257
  path and the bare filename.
258
258
  - `.nocopycheck` at the repo root: skips the repo.
259
- - An `exceptions` entry in `art-direction.json`, with a `because` of at least
260
- a sentence.
259
+ - An `exceptions` entry in `art-direction.json`, or a `severity` entry in
260
+ `craft.config.json`, with a `because` of at least a sentence. **Never for
261
+ the blocking tier.** Under `craft copy --gate` a repo can raise a rule but
262
+ not lower one that blocks: the gate refuses the entry and says so. A
263
+ genuine one-off in the blocking tier takes `copy-ok` on its line, where a
264
+ reviewer sees it.
261
265
 
262
266
  Reaching for these more than occasionally means a rule is miscalibrated. Fix
263
267
  it here and in craft, not in forty repos.
package/README.md CHANGED
@@ -94,6 +94,46 @@ reason, in `art-direction.json`:
94
94
  { "exceptions": [{ "tell": "ai-violet", "because": "Violet is on the van, the cards and the fascia." }] }
95
95
  ```
96
96
 
97
+ ### In CI
98
+
99
+ ```bash
100
+ npx craft scan --baseline craft-baseline.json --update-baseline # once: adopt today's findings
101
+ npx craft scan --baseline craft-baseline.json --strict --sarif craft.sarif
102
+ npx craft audit --pages https://example.com/sitemap.xml --viewport 390,1440
103
+ ```
104
+
105
+ - `--baseline` reports only findings the baseline does not already hold. A
106
+ finding is keyed on its tell, path and excerpt, never its line, so an edit
107
+ above a known finding does not make it new. The output says how many known
108
+ findings it left out.
109
+ - `--sarif <file>` writes SARIF 2.1.0 for GitHub code scanning. Findings on a
110
+ rendered page carry the URL, not a file, so code scanning does not show them.
111
+ - `--json` output always carries `schemaVersion` (currently 1).
112
+ - `--pages` audits every URL in a sitemap (or a sitemap index, one level deep)
113
+ or a file of URLs; `--viewport` audits each at those widths. A page that
114
+ will not load is listed as not measured, and fails `--strict`.
115
+
116
+ `craft.config.json` in the repo root holds what craft should know about the
117
+ repo. `.claude`, `.agents` and `.cursor` are never walked, config or not.
118
+
119
+ ```json
120
+ {
121
+ "ignore": ["docs/archive/**", "*.stories.tsx"],
122
+ "copyPaths": ["content", "src/app"],
123
+ "severity": {
124
+ "em-dash": { "level": "block", "because": "This client's style guide bans them outright." }
125
+ }
126
+ }
127
+ ```
128
+
129
+ A severity change needs a `because`, like an exception. `off` is applied as an
130
+ exception, so the report lists what it silenced.
131
+
132
+ Under `craft copy --gate`, a repo can raise a copy tell but never lower one in
133
+ the house blocking tier. A `warn` or `off` for a blocking tell, here or as an
134
+ exception in `art-direction.json`, is refused and listed in the report. A
135
+ genuine one-off takes `copy-ok` on its line.
136
+
97
137
  ### Copy
98
138
 
99
139
  [COPY.md](./COPY.md) is the house copy standard: the blocking tier, the review
@@ -1,4 +1,5 @@
1
1
  import {
2
+ VISUAL_MAX_HEIGHT,
2
3
  audit,
3
4
  collectInPage,
4
5
  loadPlaywright,
@@ -6,10 +7,12 @@ import {
6
7
  snapshotPage,
7
8
  snapshotUrl,
8
9
  snapshotUrls
9
- } from "./chunk-TSKRBIOC.js";
10
- import "./chunk-QMYERCBB.js";
11
- import "./chunk-3NGWXZZE.js";
10
+ } from "./chunk-ULPI335S.js";
11
+ import "./chunk-JYNXGCYZ.js";
12
+ import "./chunk-PZ4MF2VV.js";
13
+ import "./chunk-AL6MRG6V.js";
12
14
  export {
15
+ VISUAL_MAX_HEIGHT,
13
16
  audit,
14
17
  collectInPage,
15
18
  loadPlaywright,
package/dist/audit.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/audit/index.ts
21
21
  var audit_exports = {};
22
22
  __export(audit_exports, {
23
+ VISUAL_MAX_HEIGHT: () => VISUAL_MAX_HEIGHT,
23
24
  audit: () => audit,
24
25
  collectInPage: () => collectInPage,
25
26
  loadPlaywright: () => loadPlaywright,
@@ -1567,6 +1568,7 @@ var REFLEX_FONTS_2 = [
1567
1568
  "Plus Jakarta Sans",
1568
1569
  "Outfit",
1569
1570
  "Geist",
1571
+ "Cal Sans",
1570
1572
  "Sora",
1571
1573
  "Instrument Sans",
1572
1574
  "Instrument Serif",
@@ -1788,7 +1790,7 @@ function iconTiles(file) {
1788
1790
  return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
1789
1791
  });
1790
1792
  }
1791
- var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
1793
+ var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons|@iconscout\/react-unicons[^"']*|@mui\/icons-material[^"']*|react-material-symbols[^"']*)["']/;
1792
1794
  function iconTileStackHits(ctx) {
1793
1795
  const hits = [];
1794
1796
  for (const file of ctx.files) {
@@ -1935,6 +1937,12 @@ function shadcnHits(ctx) {
1935
1937
  const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
1936
1938
  return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
1937
1939
  }
1940
+ function shadcnCardHits(ctx) {
1941
+ return eachUnit(
1942
+ ctx,
1943
+ (unit) => has(unit, /^bg-card$/) && has(unit, /^text-card-foreground$/) && has(unit, /^border$/) && has(unit, /^rounded-(?:lg|xl)$/) && has(unit, /^shadow(?:-sm)?$/) ? "the registry Card recipe, unchanged" : null
1944
+ );
1945
+ }
1938
1946
  function marqueeHits(ctx) {
1939
1947
  const hits = [];
1940
1948
  const pattern = /@keyframes\s+[\w-]*marquee|animate-(?:marquee|scroll-x|infinite-scroll)\b|<Marquee\b|["']react-fast-marquee["']|ui\/marquee["']|animation(?:-name)?\s*:\s*[^;]*marquee/i;
@@ -2152,6 +2160,23 @@ const inter = Inter({ subsets: ["latin"] });`),
2152
2160
  pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2153
2161
  }
2154
2162
  },
2163
+ {
2164
+ id: "shadcn-card-stock",
2165
+ name: "Stock shadcn Card",
2166
+ generation: 1,
2167
+ severity: "warn",
2168
+ surface: "source",
2169
+ why: "The registry Card (bg-card, a plain border, rounded-lg or rounded-xl, shadow-sm) is the container every generated page is built from. Left as shipped, every card on the site looks like every other shadcn site's.",
2170
+ fix: "Give the card this site's radius and shadow from the restraint budget, or drop the shadow and let a visible border carry it.",
2171
+ detect: shadcnCardHits,
2172
+ fixtures: {
2173
+ flag: [
2174
+ f3("components/ui/card.tsx", `className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}`),
2175
+ f3("components/ui/card.tsx", `className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}`)
2176
+ ],
2177
+ pass: [f3("components/ui/card.tsx", `className={cn("rounded-sm border-2 border-ink bg-card text-card-foreground", className)}`)]
2178
+ }
2179
+ },
2155
2180
  {
2156
2181
  id: "cream-palette",
2157
2182
  name: "Cream ground",
@@ -2395,7 +2420,7 @@ function excerptAt(text, offset, max = 140) {
2395
2420
  }
2396
2421
 
2397
2422
  // src/snapshot/types.ts
2398
- var SNAPSHOT_VERSION = 1;
2423
+ var SNAPSHOT_VERSION = 2;
2399
2424
 
2400
2425
  // src/snapshot/fixture.ts
2401
2426
  function makeSnapshot(patch = {}) {
@@ -2729,7 +2754,7 @@ var RENDERED_PATHS = {
2729
2754
  };
2730
2755
 
2731
2756
  // src/character/check.ts
2732
- var CATALOGUE_VERSION = "2026.09.8";
2757
+ var CATALOGUE_VERSION = "2026.09.9";
2733
2758
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2734
2759
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2735
2760
  );
@@ -2827,7 +2852,7 @@ function auditSnapshot(snapshot, options = {}) {
2827
2852
  }
2828
2853
 
2829
2854
  // src/fingerprint/index.ts
2830
- var FINGERPRINT_VERSION = 1;
2855
+ var FINGERPRINT_VERSION = 2;
2831
2856
  var CHROMATIC = 0.03;
2832
2857
  var MIN_ACCENT_AREA = 2e4;
2833
2858
  var oklch = (value) => {
@@ -2882,7 +2907,8 @@ function fingerprint(s) {
2882
2907
  roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2883
2908
  motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2884
2909
  effects: effects.sort(),
2885
- layout: s.sections.map((x) => x.kind)
2910
+ layout: s.sections.map((x) => x.kind),
2911
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
2886
2912
  };
2887
2913
  }
2888
2914
 
@@ -2983,18 +3009,26 @@ function collectInPage() {
2983
3009
  top: Math.round(box(h).top)
2984
3010
  };
2985
3011
  });
3012
+ const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
3013
+ const isControl = (el) => {
3014
+ const isButton = isButtonEl(el);
3015
+ if (!isButton && el.tagName !== "A") return false;
3016
+ if (!shown(el)) return false;
3017
+ const cs = style(el);
3018
+ const b = box(el);
3019
+ const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
3020
+ if (!label || b.height < 24 || b.height > 90) return false;
3021
+ if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
3022
+ if (!isButton && px(cs.paddingLeft) < 8) return false;
3023
+ return true;
3024
+ };
2986
3025
  const controls = [];
2987
3026
  for (const el of all) {
2988
- const tag = el.tagName;
2989
- const isButton = tag === "BUTTON" || tag === "INPUT" && /^(submit|button)$/i.test(el.type);
2990
- if (!isButton && tag !== "A") continue;
2991
- if (!shown(el)) continue;
3027
+ if (!isControl(el)) continue;
3028
+ const isButton = isButtonEl(el);
2992
3029
  const cs = style(el);
2993
3030
  const b = box(el);
2994
3031
  const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
2995
- if (!label || b.height < 24 || b.height > 90) continue;
2996
- if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
2997
- if (!isButton && px(cs.paddingLeft) < 8) continue;
2998
3032
  const r = cs.borderTopLeftRadius;
2999
3033
  const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
3000
3034
  controls.push({
@@ -3074,7 +3108,80 @@ function collectInPage() {
3074
3108
  }
3075
3109
  return best;
3076
3110
  };
3077
- const sections = blocks.slice(0, 40).map((el, index) => {
3111
+ const backgroundOf = (el) => {
3112
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
3113
+ const c = style(n).backgroundColor;
3114
+ if (painted(c)) return c;
3115
+ }
3116
+ return ground;
3117
+ };
3118
+ const geometryOf = (el, b) => {
3119
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter((n) => (ownText(n).length > 1 || /^(IMG|SVG|VIDEO|PICTURE|BUTTON|INPUT|TEXTAREA|SELECT)$/i.test(n.tagName)) && shown(n)).slice(0, 400);
3120
+ const centreX = b.left + b.width / 2;
3121
+ let left = 0;
3122
+ let right = 0;
3123
+ let covered = 0;
3124
+ let minX = Infinity;
3125
+ let maxX = -Infinity;
3126
+ let textBlocks = 0;
3127
+ let centred = 0;
3128
+ for (const n of leaves) {
3129
+ const r = box(n);
3130
+ const area = r.width * r.height;
3131
+ covered += area;
3132
+ minX = Math.min(minX, r.left);
3133
+ maxX = Math.max(maxX, r.left + r.width);
3134
+ const split = Math.max(0, Math.min(r.width, centreX - r.left));
3135
+ left += split * r.height;
3136
+ right += (r.width - split) * r.height;
3137
+ if (ownText(n).length > 1) {
3138
+ textBlocks += 1;
3139
+ const mid = r.left + r.width / 2;
3140
+ if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3141
+ }
3142
+ }
3143
+ const round = (v) => Math.round(v * 1e3) / 1e3;
3144
+ return {
3145
+ centredShare: textBlocks ? round(centred / textBlocks) : 0,
3146
+ mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3147
+ whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3148
+ contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
3149
+ background: backgroundOf(el),
3150
+ controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
3151
+ };
3152
+ };
3153
+ const CURRENCY = /[£$€]\s?\d/;
3154
+ const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
3155
+ if (kind === "marquee" || kind === "hero") return kind;
3156
+ const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
3157
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
3158
+ const own = leaves.map(ownText);
3159
+ const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
3160
+ if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
3161
+ const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
3162
+ if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
3163
+ const quotes = el.querySelectorAll("blockquote, q").length;
3164
+ if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
3165
+ const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
3166
+ const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
3167
+ if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
3168
+ const form = el.querySelector("form");
3169
+ const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
3170
+ if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
3171
+ const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
3172
+ const r = box(i);
3173
+ return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
3174
+ }).length;
3175
+ if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
3176
+ if (kind === "stats" || kind === "logos") return kind;
3177
+ const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
3178
+ const setApart = geo.background !== ground || geo.centredShare >= 0.6;
3179
+ if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
3180
+ if (cards >= 3 && iconCards >= 2) return "features";
3181
+ return kind;
3182
+ };
3183
+ const kept = blocks.slice(0, 40);
3184
+ const sections = kept.map((el, index) => {
3078
3185
  const b = box(el);
3079
3186
  const heading = el.querySelector("h1, h2, h3");
3080
3187
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
@@ -3088,6 +3195,7 @@ function collectInPage() {
3088
3195
  else if (images.length >= 4 && textChars < 300) kind = "logos";
3089
3196
  else if (numbers >= 3 && textChars < 600) kind = "stats";
3090
3197
  else if (cards >= 3) kind = "cards";
3198
+ const geometry = geometryOf(el, b);
3091
3199
  return {
3092
3200
  top: Math.round(b.top),
3093
3201
  height: Math.round(b.height),
@@ -3095,7 +3203,9 @@ function collectInPage() {
3095
3203
  label,
3096
3204
  cards,
3097
3205
  iconCards,
3098
- hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
3206
+ hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
3207
+ role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3208
+ geometry
3099
3209
  };
3100
3210
  });
3101
3211
  if (header && sections.length === 0) {
@@ -3188,6 +3298,13 @@ function collectInPage() {
3188
3298
  headings: headings2,
3189
3299
  controls,
3190
3300
  sections,
3301
+ rhythmVariance: (() => {
3302
+ const hs = sections.map((x) => x.height);
3303
+ if (hs.length < 2) return null;
3304
+ const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
3305
+ const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
3306
+ return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
3307
+ })(),
3191
3308
  effects,
3192
3309
  motion: {
3193
3310
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
@@ -3221,7 +3338,139 @@ function overlayInPage() {
3221
3338
  return false;
3222
3339
  }
3223
3340
 
3341
+ // src/direction/png.ts
3342
+ var import_node_zlib = require("zlib");
3343
+ function decodePng(buf) {
3344
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
3345
+ if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
3346
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
3347
+ let pos = 8;
3348
+ let width = 0;
3349
+ let height = 0;
3350
+ let depth = 0;
3351
+ let colour = 0;
3352
+ let interlace = 0;
3353
+ const idat = [];
3354
+ while (pos < buf.length) {
3355
+ const len = view.getUint32(pos);
3356
+ const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
3357
+ const data = buf.subarray(pos + 8, pos + 8 + len);
3358
+ if (type === "IHDR") {
3359
+ width = view.getUint32(pos + 8);
3360
+ height = view.getUint32(pos + 12);
3361
+ depth = data[8];
3362
+ colour = data[9];
3363
+ interlace = data[12];
3364
+ } else if (type === "IDAT") idat.push(data);
3365
+ else if (type === "IEND") break;
3366
+ pos += 12 + len;
3367
+ }
3368
+ const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
3369
+ if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
3370
+ const raw = (0, import_node_zlib.inflateSync)(Buffer.concat(idat.map((d) => Buffer.from(d))));
3371
+ const stride = width * channels;
3372
+ const out = new Uint8Array(width * height * 4);
3373
+ const prev = new Uint8Array(stride);
3374
+ const line = new Uint8Array(stride);
3375
+ for (let y = 0; y < height; y += 1) {
3376
+ const filter = raw[y * (stride + 1)];
3377
+ const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
3378
+ for (let x = 0; x < stride; x += 1) {
3379
+ const a = x >= channels ? line[x - channels] : 0;
3380
+ const b = prev[x];
3381
+ const c = x >= channels ? prev[x - channels] : 0;
3382
+ let v = src[x];
3383
+ if (filter === 1) v += a;
3384
+ else if (filter === 2) v += b;
3385
+ else if (filter === 3) v += a + b >> 1;
3386
+ else if (filter === 4) {
3387
+ const p = a + b - c;
3388
+ const pa = Math.abs(p - a);
3389
+ const pb = Math.abs(p - b);
3390
+ const pc = Math.abs(p - c);
3391
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
3392
+ }
3393
+ line[x] = v & 255;
3394
+ }
3395
+ for (let x = 0; x < width; x += 1) {
3396
+ const i = x * channels;
3397
+ const o = (y * width + x) * 4;
3398
+ if (channels >= 3) {
3399
+ out[o] = line[i];
3400
+ out[o + 1] = line[i + 1];
3401
+ out[o + 2] = line[i + 2];
3402
+ out[o + 3] = channels === 4 ? line[i + 3] : 255;
3403
+ } else {
3404
+ out[o] = out[o + 1] = out[o + 2] = line[i];
3405
+ out[o + 3] = channels === 2 ? line[i + 1] : 255;
3406
+ }
3407
+ }
3408
+ prev.set(line);
3409
+ }
3410
+ return { width, height, rgba: out };
3411
+ }
3412
+
3413
+ // src/snapshot/visual.ts
3414
+ var EDGE_STEP = 32;
3415
+ function visualMeasures(img, maxSamples = 1e6) {
3416
+ const { width: w, height: h, rgba } = img;
3417
+ if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
3418
+ const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
3419
+ const lum = (x, y) => {
3420
+ const i = (y * w + x) * 4;
3421
+ return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
3422
+ };
3423
+ let n = 0;
3424
+ let sumRg = 0;
3425
+ let sumYb = 0;
3426
+ let sumRg2 = 0;
3427
+ let sumYb2 = 0;
3428
+ let edges = 0;
3429
+ let edgeN = 0;
3430
+ let asym = 0;
3431
+ let asymN = 0;
3432
+ for (let y = 0; y < h; y += step) {
3433
+ for (let x = 0; x < w; x += step) {
3434
+ const i = (y * w + x) * 4;
3435
+ const r = rgba[i];
3436
+ const g = rgba[i + 1];
3437
+ const b = rgba[i + 2];
3438
+ const rg = r - g;
3439
+ const yb = 0.5 * (r + g) - b;
3440
+ sumRg += rg;
3441
+ sumYb += yb;
3442
+ sumRg2 += rg * rg;
3443
+ sumYb2 += yb * yb;
3444
+ n += 1;
3445
+ const l = lum(x, y);
3446
+ if (x + step < w && y + step < h) {
3447
+ if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
3448
+ edgeN += 1;
3449
+ }
3450
+ if (x < w / 2) {
3451
+ asym += Math.abs(l - lum(w - 1 - x, y));
3452
+ asymN += 1;
3453
+ }
3454
+ }
3455
+ }
3456
+ const meanRg = sumRg / n;
3457
+ const meanYb = sumYb / n;
3458
+ const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
3459
+ const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
3460
+ const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
3461
+ const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
3462
+ return {
3463
+ colourfulness: round(colourfulness, 1),
3464
+ edgeDensity: round(edgeN ? edges / edgeN : 0),
3465
+ symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
3466
+ width: w,
3467
+ height: h,
3468
+ sampled: n
3469
+ };
3470
+ }
3471
+
3224
3472
  // src/audit/index.ts
3473
+ var VISUAL_MAX_HEIGHT = 6e3;
3225
3474
  async function loadPlaywright() {
3226
3475
  for (const name of ["playwright", "playwright-core"]) {
3227
3476
  try {
@@ -3239,12 +3488,23 @@ async function snapshotPage(page, url, options = {}) {
3239
3488
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
3240
3489
  const late = await page.evaluate(overlayInPage).catch(() => false);
3241
3490
  const data = await page.evaluate(collectInPage);
3491
+ let visual;
3492
+ if (page.screenshot && options.visual !== false) {
3493
+ try {
3494
+ const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
3495
+ const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
3496
+ visual = visualMeasures(decodePng(png));
3497
+ } catch {
3498
+ visual = void 0;
3499
+ }
3500
+ }
3242
3501
  return {
3243
3502
  version: SNAPSHOT_VERSION,
3244
3503
  url,
3245
3504
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
3246
3505
  ...data,
3247
- motion: { ...data.motion, introOverlay: early && !late }
3506
+ motion: { ...data.motion, introOverlay: early && !late },
3507
+ ...visual ? { visual } : {}
3248
3508
  };
3249
3509
  }
3250
3510
  async function launch(pw, url, options) {
@@ -3270,7 +3530,9 @@ async function snapshotUrls(urls, options = {}) {
3270
3530
  for (const url of urls) {
3271
3531
  const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
3272
3532
  try {
3273
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3533
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3534
+ const status = typeof response?.status === "function" ? response.status() : 200;
3535
+ if (status >= 400) throw new Error(`HTTP ${status}`);
3274
3536
  out.push({ url, snapshot: await snapshotPage(page, url, options) });
3275
3537
  } catch (error) {
3276
3538
  out.push({ url, error: error.message });
@@ -3289,6 +3551,7 @@ async function audit(url, options = {}) {
3289
3551
  }
3290
3552
  // Annotate the CommonJS export names for ESM import in node:
3291
3553
  0 && (module.exports = {
3554
+ VISUAL_MAX_HEIGHT,
3292
3555
  audit,
3293
3556
  collectInPage,
3294
3557
  loadPlaywright,