@domandigital/craft 0.11.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,21 @@
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
+
3
19
  ## 0.11.0
4
20
 
5
21
  ### 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
@@ -129,6 +129,11 @@ repo. `.claude`, `.agents` and `.cursor` are never walked, config or not.
129
129
  A severity change needs a `because`, like an exception. `off` is applied as an
130
130
  exception, so the report lists what it silenced.
131
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
+
132
137
  ### Copy
133
138
 
134
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-JP75P2DU.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) {
@@ -3291,6 +3551,7 @@ async function audit(url, options = {}) {
3291
3551
  }
3292
3552
  // Annotate the CommonJS export names for ESM import in node:
3293
3553
  0 && (module.exports = {
3554
+ VISUAL_MAX_HEIGHT,
3294
3555
  audit,
3295
3556
  collectInPage,
3296
3557
  loadPlaywright,
package/dist/audit.d.cts CHANGED
@@ -11,7 +11,13 @@
11
11
  * to a report and judged again later without a browser. Capturing one needs a
12
12
  * browser (see `@domandigital/craft/audit`); judging one does not.
13
13
  */
14
- declare const SNAPSHOT_VERSION = 1;
14
+ /**
15
+ * 2 adds `role` and `geometry` per section, `rhythmVariance` for the page and
16
+ * the screenshot measures in `visual`. All four are optional: a version 1
17
+ * snapshot reads as version 2 without them (see `readSnapshot`), and nothing
18
+ * that judged a v1 snapshot judges it differently.
19
+ */
20
+ declare const SNAPSHOT_VERSION = 2;
15
21
  interface SnapshotColour {
16
22
  /** `rgb(r, g, b)` or `rgba(r, g, b, a)`, as the browser computed it. */
17
23
  value: string;
@@ -50,6 +56,27 @@ interface SnapshotControl {
50
56
  text: string;
51
57
  }
52
58
  type SectionKind = "hero" | "logos" | "stats" | "cards" | "marquee" | "text" | "other";
59
+ /**
60
+ * What a section is for, finer than `kind`. `kind` keeps its version 1
61
+ * meaning because the rendered tells, the null models and the estate were
62
+ * calibrated on it; `role` is what component-level comparison reads.
63
+ */
64
+ type SectionRole = SectionKind | "cta-band" | "footer-cta" | "pricing" | "testimonials" | "faq" | "process" | "features" | "team" | "contact";
65
+ /** How a section is laid out, measured, not judged. */
66
+ interface SectionGeometry {
67
+ /** Share of the section's text blocks that are centred, 0 to 1. */
68
+ centredShare: number;
69
+ /** Content area balance about the vertical centre line: 1 mirror-even, 0 all on one side. */
70
+ mirrorSymmetry: number;
71
+ /** Share of the section's area with no text, image or control in it, 0 to 1. */
72
+ whitespaceRatio: number;
73
+ /** Width the content spans, over the viewport width. */
74
+ contentWidthRatio: number;
75
+ /** The painted background behind the section, as computed. */
76
+ background: string;
77
+ /** Buttons and button-styled links in the section. */
78
+ controls: number;
79
+ }
53
80
  interface SnapshotSection {
54
81
  top: number;
55
82
  height: number;
@@ -62,6 +89,26 @@ interface SnapshotSection {
62
89
  iconCards: number;
63
90
  /** Hidden at load, waiting for a scroll to reveal it. */
64
91
  hiddenAtLoad: boolean;
92
+ /** Version 2. */
93
+ role?: SectionRole;
94
+ /** Version 2. */
95
+ geometry?: SectionGeometry;
96
+ }
97
+ /**
98
+ * Measures of the rendered pixels, from a full-page screenshot (capped at
99
+ * `height`). None of them passes or fails anything: they describe.
100
+ */
101
+ interface SnapshotVisual {
102
+ /** Hasler and Süsstrunk (2003) colourfulness, M = σ_rgyb + 0.3 μ_rgyb, on 0-255 channels. Grey is 0. */
103
+ colourfulness: number;
104
+ /** Share of sampled pixels on a luminance edge: a complexity proxy, 0 to 1. */
105
+ edgeDensity: number;
106
+ /** 1 minus the mean luminance difference between each pixel and its left-right mirror, 0 to 1. */
107
+ symmetry: number;
108
+ width: number;
109
+ height: number;
110
+ /** Pixels actually read: large pages are sampled on a grid. */
111
+ sampled: number;
65
112
  }
66
113
  interface SnapshotGradient {
67
114
  /** Colour stops, as computed. */
@@ -116,6 +163,12 @@ interface Snapshot {
116
163
  sections: SnapshotSection[];
117
164
  effects: SnapshotEffects;
118
165
  motion: SnapshotMotion;
166
+ /** Version 2: coefficient of variation of the section heights. Null with fewer than two sections. */
167
+ rhythmVariance?: number | null;
168
+ /** Version 2: absent when the page could not be screenshotted. */
169
+ visual?: SnapshotVisual;
170
+ /** Set when a version 1 snapshot was read and upgraded in memory. */
171
+ migratedFrom?: 1;
119
172
  /** The design-system measures trawl has collected since v7, kept so it can switch to this. */
120
173
  metrics: {
121
174
  fontSizes: number[];
@@ -266,9 +319,16 @@ type FontClass = "serif" | "sans" | "mono" | "script" | "system";
266
319
  * Pure. Built from a Snapshot, so it describes what a visitor gets.
267
320
  */
268
321
 
269
- declare const FINGERPRINT_VERSION = 1;
322
+ /** One section as layout comparison sees it. */
323
+ interface FingerprintSection {
324
+ role: SectionRole;
325
+ /** Share of centred text, 0 to 1. */
326
+ centred: number;
327
+ /** Content width over viewport width, 0 to 1. */
328
+ width: number;
329
+ }
270
330
  interface Fingerprint {
271
- version: typeof FINGERPRINT_VERSION;
331
+ version: 1 | 2;
272
332
  /** The most prominent chromatic colour: button fills first, then painted area, then text. */
273
333
  accent: Oklch | null;
274
334
  ground: Oklch;
@@ -288,6 +348,8 @@ interface Fingerprint {
288
348
  effects: string[];
289
349
  /** Section kinds in running order, hero first. */
290
350
  layout: SectionKind[];
351
+ /** Version 2: present when every section carries a role and geometry. */
352
+ sections?: FingerprintSection[];
291
353
  }
292
354
 
293
355
  /**
@@ -329,6 +391,8 @@ interface SnapshotOptions {
329
391
  /** A Chromium to launch instead of Playwright's own. Also read from CRAFT_CHROMIUM. */
330
392
  executablePath?: string;
331
393
  timeoutMs?: number;
394
+ /** Take a screenshot and measure its pixels (`visual`). Default true. */
395
+ visual?: boolean;
332
396
  }
333
397
  interface MinimalPage {
334
398
  close?(): Promise<void>;
@@ -338,7 +402,20 @@ interface MinimalPage {
338
402
  }): Promise<unknown>;
339
403
  evaluate<R>(fn: () => R): Promise<R>;
340
404
  waitForTimeout(ms: number): Promise<void>;
405
+ /** Optional: a driver without it gets a snapshot with no `visual` measures. */
406
+ screenshot?(options: {
407
+ fullPage?: boolean;
408
+ type?: "png";
409
+ clip?: {
410
+ x: number;
411
+ y: number;
412
+ width: number;
413
+ height: number;
414
+ };
415
+ }): Promise<Uint8Array>;
341
416
  }
417
+ /** The most of a page the screenshot measures read, in CSS px. A long page is judged on its opening. */
418
+ declare const VISUAL_MAX_HEIGHT = 6000;
342
419
  interface MinimalBrowser {
343
420
  newPage(options?: {
344
421
  viewport?: {
@@ -388,4 +465,4 @@ interface AuditResult {
388
465
  /** Snapshot a URL, judge it against the rendered tells, and fingerprint it. */
389
466
  declare function audit(url: string, options?: SnapshotOptions & CheckOptions): Promise<AuditResult>;
390
467
 
391
- export { type AuditResult, type SnapshotOptions, type SnapshotResult, audit, collectInPage, loadPlaywright, overlayInPage, snapshotPage, snapshotUrl, snapshotUrls };
468
+ export { type AuditResult, type SnapshotOptions, type SnapshotResult, VISUAL_MAX_HEIGHT, audit, collectInPage, loadPlaywright, overlayInPage, snapshotPage, snapshotUrl, snapshotUrls };