@domandigital/craft 0.18.2 → 0.19.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,11 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 09b49ac: Fingerprint version 3: the estate comparison now measures ground temperature and section structure (DOM-637). A warm cream and a cool porcelain page used to score as the same "grey ground" because hue barely counts at a ground's chroma; `groundDistance` adds a temperature gap (warm, neutral, cool from hue, scaled by chroma) on top of ΔE, and shared grounds are named with it ("cream ground", "cool grey ground", "warm dark ground"). Two new distance parts, `rhythm` (the page's bands of light and dark in running order, the opening counted twice) and `hero` (text placement and width, headline class and size, which side the picture sits), give structure a quarter of the weight; `DISTANCE_WEIGHTS` is now accent 0.2, type 0.2, ground 0.1, shape 0.1, motion 0.05, effects 0.1, layout 0.1, rhythm 0.1, hero 0.05, and a part only one side measures is left out rather than counted as different, so version 1 and 2 fingerprints in a register or null model compare without being re-snapshotted. Shared traits name the structure too: "light hero then dark band", "left-aligned serif hero, photo right", "same running order". `craft snapshot` and `craft audit` take `--scheme light|dark` to ask the page for one colour scheme, and a snapshot's images record `left`. New exports: `groundDistance`, `groundTemperature`, `temperatureName`, `groundLabel`, `rhythmOf`, `rhythmDistance`, `heroDistance`, `bandOf`, `TEMPERATURE_FULL`, `TEMPERATURE_NAMED`, `TEMPERATURE_WEIGHT`, `OPENING_BANDS` and the `FingerprintHero`, `HeroImage`, `LightnessBand` and `Temperature` types. Calibrated on RMP Electrical against Bellerose Plumbing's stone and porcelain light modes (`calibration/estate/2026-10-09-temperature/`); the existing register's pair ordering keeps a Kendall tau of 0.83 and no pair crosses the sibling line.
8
+
3
9
  ## 0.18.2
4
10
 
5
11
  ### Patch Changes
package/CHARACTER.md CHANGED
@@ -327,6 +327,7 @@ craft copy app content # the prose a visitor reads
327
327
  craft tells list # the catalogue this build judges against
328
328
  craft audit https://example.co.uk --repo . # the rendered page and its source, one report
329
329
  craft snapshot https://example.co.uk --out home.json
330
+ craft snapshot https://example.co.uk --scheme dark --out home-dark.json # a site's dark mode
330
331
  ```
331
332
 
332
333
  `--json` gives the full report. Exit code is 0 on warnings, 1 on a block (or
@@ -369,11 +370,59 @@ anywhere with `craft audit home.json`. `HTTPS_PROXY` is honoured, and a
369
370
  `localhost` or `NO_PROXY` host is reached directly.
370
371
 
371
372
  Every audit also prints a **fingerprint**: accent and ground in OKLCh, the
372
- display and body faces, button roundness, reveal density, the effects and the
373
- running order. `fingerprintDistance` compares two of them, 0 to 1, weighted
374
- towards accent and type. Signals 2 and 3 are both this distance, measured
373
+ display and body faces, button roundness, reveal density, the effects, the
374
+ running order, the rhythm of light and dark bands, and the hero's
375
+ composition. `fingerprintDistance` compares two of them, 0 to 1, as a
376
+ weighted mean of nine parts. Signals 2 and 3 are both this distance, measured
375
377
  against different sets.
376
378
 
379
+ | Part | Weight | What it measures |
380
+ |---|---|---|
381
+ | accent | 0.20 | ΔE_OK between the accents over 0.3; 1 when only one site has one |
382
+ | type | 0.20 | display face (0.6) and body face (0.4): 0 same family, 0.5 same class, 1 |
383
+ | ground | 0.10 | ΔE_OK over 0.15, plus 0.6 of the temperature gap (below) |
384
+ | shape | 0.10 | button roundness, 0 square to 0.5 pill |
385
+ | motion | 0.05 | share of below-the-fold sections that reveal on scroll |
386
+ | effects | 0.10 | 1 minus the Jaccard overlap of the second-look effects |
387
+ | layout | 0.10 | edit distance over section roles and geometry (kinds before snapshot v2) |
388
+ | rhythm | 0.10 | edit distance over the bands of light, mid and dark, opening counted twice |
389
+ | hero | 0.05 | text centred or left, its width, headline class and size, picture side |
390
+
391
+ Accent and type carry the most because that is where a model's defaults show
392
+ first and where a real business has something of its own. Structure (layout,
393
+ rhythm and hero) carries a quarter between its three parts, because two sites
394
+ that open the same way read as the same site before any colour is compared;
395
+ before 0.19 structure was `layout` alone at a tenth and barely moved a score.
396
+ A part only one side measures (rhythm and hero on a version 1 fingerprint,
397
+ the picture side on a snapshot without image positions) is left out and the
398
+ rest are weighed over what remains, so older registers and null models
399
+ compare without being re-snapshotted.
400
+
401
+ **Ground temperature.** ΔE alone files every light neutral page as one
402
+ paper: RMP's cream (`oklch(0.96 0.010 82)`) and Bellerose's porcelain
403
+ (`#F1F3F4`, `oklch(0.96 0.003 229)`) are ΔE 0.013 apart, a tenth of the
404
+ ground scale, and nobody takes them for the same page. A page ground is the
405
+ largest area on the page and the eye adapts to it, so a tint far below what
406
+ separates two buttons still reads as warm or cool paper. `groundTemperature`
407
+ is the chroma over 0.006 (where a tint is unmistakable: cream sits at 0.010,
408
+ stone at 0.007), signed by hue: red through yellow is warm, teal through
409
+ violet is cool, the greens and magentas between are neither. Warm against
410
+ cool adds 0.6 to the ground part, warm against a neutral white 0.3, two warm
411
+ papers nothing. Shared grounds are named with it: "cream ground", "cool grey
412
+ ground", "warm dark ground", or "warm ground" when one is cream and the other
413
+ a warm grey.
414
+
415
+ **Rhythm** is the page's sequence of light (L 0.75 and over), mid and dark
416
+ (under 0.4) bands, each run of same-lightness sections merged: a cream hero,
417
+ a white logo strip and a near-black band are light then dark, which is what
418
+ a visitor sees. The first three bands are scored once on their own and once
419
+ as part of the whole, so the opening counts double. **Hero** compares the
420
+ hero section's centring and width, the h1's class and size (an octave, 48px
421
+ against 96px, is the whole size distance) and which side of the centre line
422
+ its largest picture sits, when the snapshot records image positions (craft
423
+ 0.19 and later). Shared, they are named "light hero then dark band" and
424
+ "left-aligned serif hero, photo right".
425
+
377
426
  Known limit: a computed font family is the family the stylesheet asked for.
378
427
  A face that failed to load is still reported under its name.
379
428
 
@@ -539,9 +588,15 @@ the same brief usually are: the median of that distance over the null models,
539
588
  0.31 on 2026-09-23 (`--null` measures it again from any null models given).
540
589
  If a barber and an electrician look more alike than two drafts of one barber,
541
590
  neither look was chosen. The report says what the pair shares in plain words
542
- ("Fraunces headline", "no accent", "pill buttons"), which is where to start
543
- pulling them apart. A sibling is a warning; `--strict` exits 1 on one, for a
544
- pipeline that has decided to hold the line.
591
+ ("Fraunces headline", "no accent", "pill buttons", "cream ground", "light hero
592
+ then dark band", "left-aligned serif hero, photo right"), which is where to
593
+ start pulling them apart. A sibling is a warning; `--strict` exits 1 on one,
594
+ for a pipeline that has decided to hold the line.
595
+
596
+ A site with a light and a dark mode is two pages to a visitor. `craft
597
+ snapshot <url> --scheme light|dark` asks the browser for one scheme
598
+ (`prefers-color-scheme`); a site that stores a choice in `localStorage` or on
599
+ `<html data-theme>` needs that set instead. Compare each.
545
600
 
546
601
  `craft direction propose --estate estate.json` reads the register too, so a
547
602
  proposed accent steers away from one a sibling already uses.
@@ -928,6 +983,22 @@ Rules for a direction board, recorded so the next one starts here:
928
983
 
929
984
  The board in question is the DD redesign's Stage 05 artefact (DOM-397, CRO-21); v1.1 applied all five.
930
985
 
986
+ ### Ground temperature and structure, 2026-10-09
987
+
988
+ Bellerose Plumbing's light mode looked, to the founder, too much like RMP Electrical, and craft gave the pair 0.59 sharing only "grey ground". Bellerose then moved its light page from warm stone (`#EEEDE8`) to cool porcelain (`#F1F3F4`, bellerose-plumbing#32), and craft scored the two Bellerose pages 0.01 apart. Two things were missing: hue barely counts at a ground's chroma, and structure moved the score by a tenth at most. Fingerprint version 3 (DOM-637) adds ground temperature to the ground part, a rhythm part and a hero part, with the weights in *The rendered page* above. Measured on RMP live (2026-10-09) and Bellerose built locally at `c62590c` (stone, before #32) and `47f9d79` (porcelain), each snapshotted with `--scheme light` and `--scheme dark`; snapshots, a copy of the redesign workstream's register and the full tables are in `calibration/estate/2026-10-09-temperature/`. "Before" is craft 0.18.1's distance on the same snapshots.
989
+
990
+ | Pair | Before | After | Shares after |
991
+ |---|---|---|---|
992
+ | RMP + Bellerose light on stone | 0.59 (cream ground) | **0.54** | warm ground, left-aligned serif hero, photo right, light hero then dark band |
993
+ | RMP + Bellerose light on porcelain | 0.59 (cream ground) | 0.58 | left-aligned serif hero, photo right, light hero then dark band |
994
+ | RMP + Bellerose dark (either) | 0.77 | 0.72 | left-aligned serif hero, photo right |
995
+ | Bellerose stone light + porcelain light | 0.01 | 0.06 | everything but the ground |
996
+ | RMP + RMP | 0.00 | 0.00 | |
997
+
998
+ Against the register as it stands (stored version 2 fingerprints, not re-added), the stone page's nearest site is RMP (0.54) and the porcelain page's is Harrison James (0.52) with RMP second (0.58): the move away from RMP that the founder made by eye now shows in the numbers, and both Bellerose light pages still share the opening with RMP, which is the next thing to pull apart. Neither pair is a sibling; the faces, the accent and the button shape are their own.
999
+
1000
+ The seven register pairs reshuffle little: Kendall tau 0.83 between the before and after orderings over 21 pairs, the closest pair is still DD + RMP (0.48 to 0.52, now "warm ground" rather than "grey ground"), and no pair crosses the sibling line. Pairs that shared "white ground" and little else move apart by 0.02 to 0.06 as the temperature counts (MMM + Sensphere keep "cool white ground"); Chair and Blade + HJ Beauty move closer (0.74 to 0.66) because both open dark then light. The sibling line measured again on the seven v2 null models is 0.337 with the old distance and 0.339 with the new, so the weights do not move it; `SIBLING_AT` stays at the 0.31 measured on the v1 models in September, and moving it to the v2 line is a separate decision.
1001
+
931
1002
  ## The catalogue
932
1003
 
933
1004
  Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
@@ -9,9 +9,9 @@ import {
9
9
  snapshotPage,
10
10
  snapshotUrl,
11
11
  snapshotUrls
12
- } from "./chunk-SL5VZJ2J.js";
12
+ } from "./chunk-W7PMRSDG.js";
13
13
  import "./chunk-JYNXGCYZ.js";
14
- import "./chunk-WFB6F3BN.js";
14
+ import "./chunk-IHT6CJOM.js";
15
15
  import "./chunk-4QWEAL4G.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
package/dist/audit.cjs CHANGED
@@ -3292,7 +3292,7 @@ var RENDERED_PATHS = {
3292
3292
  };
3293
3293
 
3294
3294
  // src/fingerprint/index.ts
3295
- var FINGERPRINT_VERSION = 2;
3295
+ var FINGERPRINT_VERSION = 3;
3296
3296
  var CHROMATIC = 0.03;
3297
3297
  var MIN_ACCENT_AREA = 2e4;
3298
3298
  var oklch = (value) => {
@@ -3328,6 +3328,24 @@ function median(values) {
3328
3328
  const mid = Math.floor(sorted.length / 2);
3329
3329
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3330
3330
  }
3331
+ var FULL_WIDTH_IMAGE = 0.8;
3332
+ function heroOf(s) {
3333
+ const h1 = s.headings.find((h) => h.level === 1);
3334
+ const inside = (top, x) => top >= x.top && top < x.top + x.height;
3335
+ const index = (() => {
3336
+ const withH1 = h1 ? s.sections.findIndex((x) => x.kind === "hero" && inside(h1.top, x)) : -1;
3337
+ return withH1 >= 0 ? withH1 : s.sections.findIndex((x) => x.kind === "hero");
3338
+ })();
3339
+ if (index < 0) return void 0;
3340
+ const section = s.sections[index];
3341
+ const headline = h1 && inside(h1.top, section) ? { sizePx: h1.sizePx, weight: h1.weight, class: fontClass(normaliseFamily(h1.family)), italic: h1.italic || h1.italicPart } : null;
3342
+ if (!s.images) return { headline };
3343
+ const picture2 = s.images.filter((i) => (i.role === "photo" || i.role === "illustration") && (i.section === index || i.section === null && inside(i.top, section))).sort((a, b) => b.width * b.height - a.width * a.height)[0];
3344
+ if (!picture2) return { headline, image: "none" };
3345
+ if (picture2.left === void 0) return { headline };
3346
+ const image = picture2.width >= FULL_WIDTH_IMAGE * s.viewport.width ? "full" : picture2.left + picture2.width / 2 < s.viewport.width / 2 ? "left" : "right";
3347
+ return { headline, image };
3348
+ }
3331
3349
  function fingerprint(s) {
3332
3350
  const effects = [];
3333
3351
  if (s.effects.glass > 0) effects.push("glass-panel");
@@ -3339,6 +3357,7 @@ function fingerprint(s) {
3339
3357
  if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
3340
3358
  if (s.motion.introOverlay) effects.push("intro-cinematic");
3341
3359
  const below = s.sections.filter((x) => x.top > s.viewport.height).length;
3360
+ const hero = heroOf(s);
3342
3361
  return {
3343
3362
  version: FINGERPRINT_VERSION,
3344
3363
  accent: accentOf(s),
@@ -3362,7 +3381,8 @@ function fingerprint(s) {
3362
3381
  carousel: x.carousel ?? false,
3363
3382
  figures: x.figures ?? 0
3364
3383
  }))
3365
- } : {}
3384
+ } : {},
3385
+ ...hero ? { hero } : {}
3366
3386
  };
3367
3387
  }
3368
3388
  var ACCENT_SCALE = 0.3;
@@ -4314,6 +4334,7 @@ function collectInPage() {
4314
4334
  width: Math.round(b.width),
4315
4335
  height: Math.round(b.height),
4316
4336
  top: Math.round(b.top),
4337
+ left: Math.round(b.left),
4317
4338
  role: roleOfImage(el, src, b.width, b.height, alt),
4318
4339
  alt,
4319
4340
  decorative,
@@ -4633,7 +4654,7 @@ async function snapshotUrls(urls, options = {}) {
4633
4654
  const out = [];
4634
4655
  try {
4635
4656
  for (const url of urls) {
4636
- const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
4657
+ const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 }, ...options.colorScheme ? { colorScheme: options.colorScheme } : {} });
4637
4658
  try {
4638
4659
  const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
4639
4660
  const status = typeof response?.status === "function" ? response.status() : 200;
package/dist/audit.d.cts CHANGED
@@ -134,6 +134,8 @@ interface SnapshotImage {
134
134
  width: number;
135
135
  height: number;
136
136
  top: number;
137
+ /** Version 2, craft 0.19 and later: distance from the viewport's left edge, so a hero can say which side its picture sits. */
138
+ left?: number;
137
139
  role: ImageRole;
138
140
  alt: string | null;
139
141
  /** Empty alt, `aria-hidden` or `role="presentation"`. */
@@ -412,8 +414,26 @@ interface FingerprintSection {
412
414
  /** Large standalone figures. */
413
415
  figures?: number;
414
416
  }
417
+ /** Where the hero's largest picture sits, relative to the headline. */
418
+ type HeroImage = "left" | "right" | "full" | "none";
419
+ /** The hero's composition: the first thing two sites are compared on by eye. */
420
+ interface FingerprintHero {
421
+ /** The headline (the first h1 inside the hero), or null when the hero has none. */
422
+ headline: {
423
+ sizePx: number;
424
+ weight: number;
425
+ class: FontClass;
426
+ italic: boolean;
427
+ } | null;
428
+ /**
429
+ * The largest photo or illustration in the hero: beside the text (left or
430
+ * right of the viewport's centre line), `full` when it spans the width,
431
+ * `none` without one. Absent when the snapshot predates image positions.
432
+ */
433
+ image?: HeroImage;
434
+ }
415
435
  interface Fingerprint {
416
- version: 1 | 2;
436
+ version: 1 | 2 | 3;
417
437
  /** The most prominent chromatic colour: button fills first, then painted area, then text. */
418
438
  accent: Oklch | null;
419
439
  ground: Oklch;
@@ -435,6 +455,8 @@ interface Fingerprint {
435
455
  layout: SectionKind[];
436
456
  /** Version 2: present when every section carries a role and geometry. */
437
457
  sections?: FingerprintSection[];
458
+ /** Version 3: present when the page has a hero section. */
459
+ hero?: FingerprintHero;
438
460
  }
439
461
 
440
462
  /**
@@ -480,6 +502,12 @@ interface SnapshotOptions {
480
502
  visual?: boolean;
481
503
  /** Read the first bytes of the page's larger images for AI provenance markers. Default true. */
482
504
  provenance?: boolean;
505
+ /**
506
+ * The colour scheme the page is asked for (`prefers-color-scheme`). A site
507
+ * with a light and a dark mode is two pages to the visitor; snapshot each.
508
+ * Default: the browser's own, which is light in headless Chromium.
509
+ */
510
+ colorScheme?: "light" | "dark";
483
511
  }
484
512
  interface MinimalPage {
485
513
  close?(): Promise<void>;
@@ -523,6 +551,7 @@ interface MinimalBrowser {
523
551
  width: number;
524
552
  height: number;
525
553
  };
554
+ colorScheme?: "light" | "dark";
526
555
  }): Promise<MinimalPage>;
527
556
  close(): Promise<void>;
528
557
  }
package/dist/audit.d.ts CHANGED
@@ -134,6 +134,8 @@ interface SnapshotImage {
134
134
  width: number;
135
135
  height: number;
136
136
  top: number;
137
+ /** Version 2, craft 0.19 and later: distance from the viewport's left edge, so a hero can say which side its picture sits. */
138
+ left?: number;
137
139
  role: ImageRole;
138
140
  alt: string | null;
139
141
  /** Empty alt, `aria-hidden` or `role="presentation"`. */
@@ -412,8 +414,26 @@ interface FingerprintSection {
412
414
  /** Large standalone figures. */
413
415
  figures?: number;
414
416
  }
417
+ /** Where the hero's largest picture sits, relative to the headline. */
418
+ type HeroImage = "left" | "right" | "full" | "none";
419
+ /** The hero's composition: the first thing two sites are compared on by eye. */
420
+ interface FingerprintHero {
421
+ /** The headline (the first h1 inside the hero), or null when the hero has none. */
422
+ headline: {
423
+ sizePx: number;
424
+ weight: number;
425
+ class: FontClass;
426
+ italic: boolean;
427
+ } | null;
428
+ /**
429
+ * The largest photo or illustration in the hero: beside the text (left or
430
+ * right of the viewport's centre line), `full` when it spans the width,
431
+ * `none` without one. Absent when the snapshot predates image positions.
432
+ */
433
+ image?: HeroImage;
434
+ }
415
435
  interface Fingerprint {
416
- version: 1 | 2;
436
+ version: 1 | 2 | 3;
417
437
  /** The most prominent chromatic colour: button fills first, then painted area, then text. */
418
438
  accent: Oklch | null;
419
439
  ground: Oklch;
@@ -435,6 +455,8 @@ interface Fingerprint {
435
455
  layout: SectionKind[];
436
456
  /** Version 2: present when every section carries a role and geometry. */
437
457
  sections?: FingerprintSection[];
458
+ /** Version 3: present when the page has a hero section. */
459
+ hero?: FingerprintHero;
438
460
  }
439
461
 
440
462
  /**
@@ -480,6 +502,12 @@ interface SnapshotOptions {
480
502
  visual?: boolean;
481
503
  /** Read the first bytes of the page's larger images for AI provenance markers. Default true. */
482
504
  provenance?: boolean;
505
+ /**
506
+ * The colour scheme the page is asked for (`prefers-color-scheme`). A site
507
+ * with a light and a dark mode is two pages to the visitor; snapshot each.
508
+ * Default: the browser's own, which is light in headless Chromium.
509
+ */
510
+ colorScheme?: "light" | "dark";
483
511
  }
484
512
  interface MinimalPage {
485
513
  close?(): Promise<void>;
@@ -523,6 +551,7 @@ interface MinimalBrowser {
523
551
  width: number;
524
552
  height: number;
525
553
  };
554
+ colorScheme?: "light" | "dark";
526
555
  }): Promise<MinimalPage>;
527
556
  close(): Promise<void>;
528
557
  }
package/dist/audit.js CHANGED
@@ -3257,7 +3257,7 @@ var RENDERED_PATHS = {
3257
3257
  };
3258
3258
 
3259
3259
  // src/fingerprint/index.ts
3260
- var FINGERPRINT_VERSION = 2;
3260
+ var FINGERPRINT_VERSION = 3;
3261
3261
  var CHROMATIC = 0.03;
3262
3262
  var MIN_ACCENT_AREA = 2e4;
3263
3263
  var oklch = (value) => {
@@ -3293,6 +3293,24 @@ function median(values) {
3293
3293
  const mid = Math.floor(sorted.length / 2);
3294
3294
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3295
3295
  }
3296
+ var FULL_WIDTH_IMAGE = 0.8;
3297
+ function heroOf(s) {
3298
+ const h1 = s.headings.find((h) => h.level === 1);
3299
+ const inside = (top, x) => top >= x.top && top < x.top + x.height;
3300
+ const index = (() => {
3301
+ const withH1 = h1 ? s.sections.findIndex((x) => x.kind === "hero" && inside(h1.top, x)) : -1;
3302
+ return withH1 >= 0 ? withH1 : s.sections.findIndex((x) => x.kind === "hero");
3303
+ })();
3304
+ if (index < 0) return void 0;
3305
+ const section = s.sections[index];
3306
+ const headline = h1 && inside(h1.top, section) ? { sizePx: h1.sizePx, weight: h1.weight, class: fontClass(normaliseFamily(h1.family)), italic: h1.italic || h1.italicPart } : null;
3307
+ if (!s.images) return { headline };
3308
+ const picture2 = s.images.filter((i) => (i.role === "photo" || i.role === "illustration") && (i.section === index || i.section === null && inside(i.top, section))).sort((a, b) => b.width * b.height - a.width * a.height)[0];
3309
+ if (!picture2) return { headline, image: "none" };
3310
+ if (picture2.left === void 0) return { headline };
3311
+ const image = picture2.width >= FULL_WIDTH_IMAGE * s.viewport.width ? "full" : picture2.left + picture2.width / 2 < s.viewport.width / 2 ? "left" : "right";
3312
+ return { headline, image };
3313
+ }
3296
3314
  function fingerprint(s) {
3297
3315
  const effects = [];
3298
3316
  if (s.effects.glass > 0) effects.push("glass-panel");
@@ -3304,6 +3322,7 @@ function fingerprint(s) {
3304
3322
  if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
3305
3323
  if (s.motion.introOverlay) effects.push("intro-cinematic");
3306
3324
  const below = s.sections.filter((x) => x.top > s.viewport.height).length;
3325
+ const hero = heroOf(s);
3307
3326
  return {
3308
3327
  version: FINGERPRINT_VERSION,
3309
3328
  accent: accentOf(s),
@@ -3327,7 +3346,8 @@ function fingerprint(s) {
3327
3346
  carousel: x.carousel ?? false,
3328
3347
  figures: x.figures ?? 0
3329
3348
  }))
3330
- } : {}
3349
+ } : {},
3350
+ ...hero ? { hero } : {}
3331
3351
  };
3332
3352
  }
3333
3353
  var ACCENT_SCALE = 0.3;
@@ -4279,6 +4299,7 @@ function collectInPage() {
4279
4299
  width: Math.round(b.width),
4280
4300
  height: Math.round(b.height),
4281
4301
  top: Math.round(b.top),
4302
+ left: Math.round(b.left),
4282
4303
  role: roleOfImage(el, src, b.width, b.height, alt),
4283
4304
  alt,
4284
4305
  decorative,
@@ -4598,7 +4619,7 @@ async function snapshotUrls(urls, options = {}) {
4598
4619
  const out = [];
4599
4620
  try {
4600
4621
  for (const url of urls) {
4601
- const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
4622
+ const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 }, ...options.colorScheme ? { colorScheme: options.colorScheme } : {} });
4602
4623
  try {
4603
4624
  const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
4604
4625
  const status = typeof response?.status === "function" ? response.status() : 200;
@@ -10,7 +10,7 @@ import {
10
10
  holdHouseBlocks,
11
11
  houseRule,
12
12
  scanSource
13
- } from "./chunk-WFB6F3BN.js";
13
+ } from "./chunk-IHT6CJOM.js";
14
14
  import {
15
15
  compareCompetitor,
16
16
  compareFacts,
@@ -482,7 +482,8 @@ var VALUE_FLAGS = {
482
482
  "--component": "component",
483
483
  "--competitor": "competitor",
484
484
  "--page": "page",
485
- "--builder": "builder"
485
+ "--builder": "builder",
486
+ "--scheme": "scheme"
486
487
  };
487
488
  function parseFlags(args) {
488
489
  const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false, directions: false, visual: false, fresh: false };
@@ -600,7 +601,7 @@ async function pageSnapshot(target, cwd) {
600
601
  return readSnapshot(JSON.parse(readFileSync(local, "utf8")), target);
601
602
  }
602
603
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL nor a snapshot file`);
603
- const { snapshotUrl } = await import("./audit-4SFSLCKY.js");
604
+ const { snapshotUrl } = await import("./audit-X7LGATAQ.js");
604
605
  return snapshotUrl(target);
605
606
  }
606
607
  async function briefFor(flags, cwd) {
@@ -611,7 +612,7 @@ async function briefFor(flags, cwd) {
611
612
  if (typeof brief === "string" && brief.trim()) return brief;
612
613
  } else if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
613
614
  if (flags.null) {
614
- const { loadNull } = await import("./cli-BN4IT6N2.js");
615
+ const { loadNull } = await import("./cli-3WWHRJRG.js");
615
616
  return loadNull(flags.null, cwd).brief;
616
617
  }
617
618
  return void 0;
@@ -637,31 +638,31 @@ async function compareWithCompetitor(flags, io) {
637
638
  function run(argv, io) {
638
639
  const [command, ...rest] = argv;
639
640
  if (command === "direction") {
640
- return import("./cli-Y7Y5IROK.js").then((m) => m.runDirection(rest, io));
641
+ return import("./cli-DKI37OXK.js").then((m) => m.runDirection(rest, io));
641
642
  }
642
643
  if (command === "report" || command === "retrofit") {
643
- return import("./cli-V44QEVS6.js").then((m) => m.runReport(command, rest, io));
644
+ return import("./cli-KXVJJDN5.js").then((m) => m.runReport(command, rest, io));
644
645
  }
645
646
  if (command === "register") {
646
647
  return import("./cli-JB4X5LAG.js").then((m) => m.runRegister(rest, io));
647
648
  }
648
649
  if (command === "estate") {
649
- return import("./cli-GXIW7F3E.js").then((m) => m.runEstate(rest, io));
650
+ return import("./cli-U7WEVPZL.js").then((m) => m.runEstate(rest, io));
650
651
  }
651
652
  if (command === "null") {
652
- return import("./cli-BN4IT6N2.js").then((m) => m.runNull(rest, io));
653
+ return import("./cli-3WWHRJRG.js").then((m) => m.runNull(rest, io));
653
654
  }
654
655
  if (command === "calibrate") {
655
- return import("./cli-MDW7JTEF.js").then((m) => m.runCalibrate(rest, io));
656
+ return import("./cli-YD3VN52R.js").then((m) => m.runCalibrate(rest, io));
656
657
  }
657
658
  if (command === "tells" && rest[0] === "harvest") {
658
- return import("./cli-BN4IT6N2.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
659
+ return import("./cli-3WWHRJRG.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
659
660
  io.err(`craft: ${error.message}`);
660
661
  return 2;
661
662
  });
662
663
  }
663
664
  if (command === "snapshot" || command === "audit") {
664
- return import("./cli-JZRQBCD4.js").then((m) => m.runAudit(command, rest, io));
665
+ return import("./cli-ZGB6MUN2.js").then((m) => m.runAudit(command, rest, io));
665
666
  }
666
667
  if (!command || command === "help" || command === "--help" || command === "-h") {
667
668
  io.out(HELP);