@domandigital/craft 0.17.0 → 0.18.1

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,17 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.18.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 72c3463: `gradientContrast` no longer blends across a hard stop. A gradient such as `linear-gradient(90deg, #000 50%, #fff 50%)` is painted as a jump from black to white, but 0.18.0 sampled the greys between them and could fail text that clears every colour the browser actually paints. Stop positions are now read, and a stop placed at or before an earlier position starts a hard edge that is not sampled, as in CSS. A painted blend is still sampled between its stops. Found by Codex review on Doman-Digital/dd-drift-guards#8, where the same fix landed.
8
+
9
+ ## 0.18.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 1c08793: Two build gates from dd-drift-guards are now part of craft, so that repo can be archived. `gradientContrast(text, gradient, beneath)` scores text on a CSS gradient, the case axe-core and Lighthouse leave unscored, against the worst point of the gradient composited over what sits beneath. It samples between the stops as well as at them: the drift-guards version checked the stops only, and passed mid-grey text on a black-to-white gradient that is about 1:1 in the middle. `checkPhotoReview(text, options)` gates a build on the report `halide review --json` wrote, failing a weak image under a passing average, a truncated report and, unless `required: false`, a missing one.
14
+
3
15
  ## 0.17.0
4
16
 
5
17
  ### Minor Changes
package/CHARACTER.md CHANGED
@@ -894,6 +894,24 @@ same on all seven: the display face. It is the estate's common default, and
894
894
  the one choice the retrofits (phase G) should settle first. Not yet read by a
895
895
  person.
896
896
 
897
+ ### Recalibration on snapshot v2, 2026-09-30
898
+
899
+ The estate and the seven null models were still on snapshot v1 (the AI set and the human set were re-snapshotted on 2026-09-29). This run moves them to v2 and changes nothing else: the same 140 null pages (each `calibration/null/<name>/pages/`, copied into `<name>-v2/`, so no model was called and the model is not a confound; the original build recorded its model as `default`) were re-snapshotted with `craft null build`, and the seven live home pages were re-snapshotted on 2026-09-30 into `calibration/estate/2026-09-30-v2/` and a new register, `estate/estate-v2.json`. The v1 folders and the 2026-09-23 snapshots are untouched. The `-v2` folders hold `null.json` and `snapshots/` only: their `pages/` are byte-for-byte the v1 pages, so to rebuild copy `<name>/pages/` across and rerun the same `craft null build`. Scoring is `calibration/null/score-v2.mjs` (the same method as `score.mjs`; the AI set is read from the v2 snapshots in `calibration/results/2026-09-29-wider/`). Outputs: `null/results-v2.json`, `null/score-v2.txt`, `null/harvest-v2.txt`, `estate/compare-2026-09-30-v2.txt`. The live sites may have changed since 2026-09-23, so the estate rows also move for that reason.
900
+
901
+ | Measure | Before (v1) | After (v2) |
902
+ |---|---|---|
903
+ | Estate typicality against its own brief's null (own): dd, mmm, sensphere, harrison-james, hj-beauty, chair-and-blade, rmp | 0.15, 0.05, 0.00, 0.10, 0.05, 0.05, 0.05 | 0.10, 0.05, 0.00, 0.10, 0.05, 0.05, 0.05 |
904
+ | Estate typicality against all seven pooled (same order) | 0.01, 0.00, 0.00, 0.09, 0.00, 0.01, 0.26 | 0.01, 0.00, 0.00, 0.10, 0.00, 0.01, 0.21 |
905
+ | Distance to own null (same order) | 0.340, 0.450, 0.503, 0.277, 0.442, 0.361, 0.518 | 0.443, 0.420, 0.530, 0.304, 0.479, 0.381, 0.558 |
906
+ | Sibling pairs in the estate (below 0.31) | 1 (dd + hjbeauty, 0.28) | 0 (dd + hjbeauty is now 0.58; closest pair is dd + rmp, 0.48) |
907
+ | Null pages flagged when scored against the other six briefs (dd, mmm, sensphere, harrison-james, hj-beauty, chair-and-blade, rmp) | 20, 20, 19, 20, 19, 18, 15 of 20 | 20, 20, 20, 20, 20, 18, 18 of 20 |
908
+ | Null pages typical against the other six (same order) | 13, 10, 10, 12, 10, 4, 2 | 14, 8, 9, 13, 6, 7, 3 |
909
+ | AI set flagged against the pooled v2 null | 19 of 20 | 20 of 20 (typical 15, tell-heavy 20) |
910
+
911
+ What moved: the estate is still atypical of what the model builds for its own brief (own typicality 0.10 or lower on all seven), and only RMP Electrical is close to the pooled null (0.21, down from 0.26; its shared traits are a hero eyebrow chip and pill buttons). The one sibling pair, Doman Digital and Harrisons Beauty, is no longer one; the pair that stays closest is Doman Digital and RMP Electrical (0.48). The AI set gained one flagged page (the 20 of 20 the flagged-rule decision records). Because the live sites were not frozen, the estate movement mixes the snapshot version with site changes, and this run cannot separate them.
912
+
913
+ `craft tells harvest` on the v2 models gives the same three candidates as before (red accent #e77a63, 66 of 140; square shape, 47 of 140; the opening `hero > hero > cards`, 54 of 140) and 40 recurring phrases (the same count as before; the list was not diffed line by line). The only difference is in the already-caught list, which now includes `no-real-imagery` (117 of 140), `hero-eyebrow-chip` (53), `stats-row` (42) and `unproven-claim` (35): tells that came in with snapshot v2 and later phases, which the v1 harvest could not see. Nothing was added to the catalogue, and no tell or threshold changed.
914
+
897
915
  ## The catalogue
898
916
 
899
917
  Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
package/README.md CHANGED
@@ -200,13 +200,36 @@ the house APCA bar, which is the failure mode a WCAG-only check cannot see. The
200
200
  accent fork emits its measured ratios as a CSS comment, so the choice is
201
201
  checkable rather than asserted.
202
202
 
203
+ ## Build gates folded in from drift-guards
204
+
205
+ Two checks that lived only in `Doman-Digital/dd-drift-guards` (pinned by git tag
206
+ in a few client repos) are part of craft since 0.18.0. The other three guards
207
+ there were already here: restraint (`checkRestraint`), reduced motion (a
208
+ restraint rule) and art direction (`craft direction`, `craft estate`).
209
+
210
+ - **`gradientContrast(text, gradient, beneath)`**: text on a CSS gradient, the
211
+ case axe-core and Lighthouse mark "incomplete" and never score. It composites
212
+ the gradient over what sits beneath and scores the text, WCAG and APCA,
213
+ against the worst point. The worst point is sampled along every segment, not
214
+ only at the stops: mid-grey text on a black-to-white gradient clears 4.5:1
215
+ against both ends and is about 1:1 in the middle, which the drift-guards
216
+ version passed. A hard stop (`#000 50%, #fff 50%`) is a jump the browser
217
+ paints without a blend, so it is not sampled across. WCAG defines no method for gradients, so this is craft's
218
+ rule, not a conformance claim.
219
+ - **`checkPhotoReview(text, { minScore, required })`**: gates a build on the
220
+ report `halide review --json` wrote. It takes the report's text (null when
221
+ there is none) and never runs halide. One weak image fails the build even
222
+ under a passing average, and a truncated report fails rather than reading as
223
+ "no report".
224
+
203
225
  ## API
204
226
 
205
227
  | Area | Exports |
206
228
  | --- | --- |
207
229
  | Colour space | `hexToOklch`, `oklchToHex`, `toGamut`, `inSrgbGamut`, `inP3Gamut`, `deltaEOk`, `deltaEOkHex` |
208
230
  | Ramps | `ramp`, `rampFromAnchors`, `RAMP_STEPS`, `LIGHTNESS_CURVE` |
209
- | Contrast | `wcagContrast`, `apcaContrast`, `checkPair` |
231
+ | Contrast | `wcagContrast`, `apcaContrast`, `checkPair`, `gradientContrast`, `gradientStops` |
232
+ | Photography | `checkPhotoReview` |
210
233
  | Semantic | `semantic`, `accentFork` |
211
234
  | Motion | `EASE`, `EASE_TUPLE`, `DURATION_MS`, `DURATION_S`, `SPRING`, `SCALE`, `exitDuration`, `shouldAnimate` |
212
235
  | Type | `fluidType`, `fluidClamp`, `typeFeatureTokens`, `HOUSE_TYPE`, `TYPE_STEPS` |
package/dist/index.cjs CHANGED
@@ -97,6 +97,7 @@ __export(index_exports, {
97
97
  RENDERED_TELLS: () => RENDERED_TELLS,
98
98
  REVEAL_LIMIT: () => REVEAL_LIMIT,
99
99
  REVIEW_PHRASES: () => REVIEW_PHRASES,
100
+ SAMPLES_PER_SEGMENT: () => SAMPLES_PER_SEGMENT,
100
101
  SCALE: () => SCALE,
101
102
  SECTION_ROLES: () => SECTION_ROLES,
102
103
  SHADCN_LIMIT: () => SHADCN_LIMIT,
@@ -134,6 +135,7 @@ __export(index_exports, {
134
135
  checkCopy: () => checkCopy,
135
136
  checkOrder: () => checkOrder,
136
137
  checkPair: () => checkPair,
138
+ checkPhotoReview: () => checkPhotoReview,
137
139
  checkReason: () => checkReason,
138
140
  checkRegister: () => checkRegister,
139
141
  checkRestraint: () => checkRestraint,
@@ -187,6 +189,8 @@ __export(index_exports, {
187
189
  formatReport: () => formatReport,
188
190
  getRegister: () => getRegister,
189
191
  globToRegExp: () => globToRegExp,
192
+ gradientContrast: () => gradientContrast,
193
+ gradientStops: () => gradientStops,
190
194
  groundName: () => groundName,
191
195
  harvest: () => harvest,
192
196
  hedgeCount: () => hedgeCount,
@@ -586,6 +590,274 @@ function checkPair(text, background) {
586
590
  };
587
591
  }
588
592
 
593
+ // src/character/color.ts
594
+ var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
595
+ function num(token, percentOf) {
596
+ const t = token.trim();
597
+ if (t.endsWith("%")) return parseFloat(t) / 100 * percentOf;
598
+ return parseFloat(t);
599
+ }
600
+ function parts(fn) {
601
+ const inner = fn.slice(fn.indexOf("(") + 1, -1).trim();
602
+ const [main, slashAlpha] = inner.split("/");
603
+ const channels = main.split(/[\s,]+/).filter(Boolean);
604
+ let alpha = 1;
605
+ if (slashAlpha !== void 0) alpha = num(slashAlpha, 1);
606
+ else if (channels.length === 4) alpha = num(channels.pop(), 1);
607
+ return { channels, alpha: Number.isFinite(alpha) ? alpha : 1 };
608
+ }
609
+ function hslToRgb(h, s, l) {
610
+ const k = (n) => (n + h / 30) % 12;
611
+ const a = s * Math.min(l, 1 - l);
612
+ const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
613
+ return { r: f4(0), g: f4(8), b: f4(4) };
614
+ }
615
+ var clamp012 = (n) => Math.min(1, Math.max(0, n));
616
+ function parseColour(raw) {
617
+ try {
618
+ if (raw.startsWith("#")) {
619
+ const hex2 = raw.slice(1);
620
+ const alpha2 = hex2.length === 8 ? parseInt(hex2.slice(6), 16) / 255 : hex2.length === 4 ? parseInt(hex2[3] + hex2[3], 16) / 255 : 1;
621
+ return { oklch: hexToOklch(raw), alpha: alpha2 };
622
+ }
623
+ const fn = raw.slice(0, raw.indexOf("(")).toLowerCase();
624
+ if (fn === "color") {
625
+ const { channels: channels2, alpha: alpha2 } = parts(raw.replace(/^color\(\s*srgb\s+/i, "srgb("));
626
+ const [r, g, b] = channels2.map((c) => clamp012(num(c, 1)));
627
+ if (![r, g, b].every(Number.isFinite)) return null;
628
+ return { oklch: hexToOklch(formatHex({ r, g, b })), alpha: alpha2 };
629
+ }
630
+ const { channels, alpha } = parts(raw);
631
+ if (channels.length < 3 || channels.some((c) => c.startsWith("var"))) return null;
632
+ if (fn === "oklch") {
633
+ const l = num(channels[0], 1);
634
+ const c = num(channels[1], 0.4);
635
+ const h = parseFloat(channels[2]);
636
+ if (![l, c].every(Number.isFinite)) return null;
637
+ return { oklch: { l, c, h: Number.isFinite(h) ? h : 0 }, alpha };
638
+ }
639
+ if (fn === "oklab") {
640
+ const l = num(channels[0], 1);
641
+ const a = num(channels[1], 0.4);
642
+ const b = num(channels[2], 0.4);
643
+ if (![l, a, b].every(Number.isFinite)) return null;
644
+ const c = Math.sqrt(a * a + b * b);
645
+ const h = c < 1e-7 ? 0 : (Math.atan2(b, a) * 180 / Math.PI % 360 + 360) % 360;
646
+ return { oklch: { l, c, h }, alpha };
647
+ }
648
+ if (fn === "rgb" || fn === "rgba") {
649
+ const [r, g, b] = channels.map((c) => clamp012(num(c, 255) / 255));
650
+ if (![r, g, b].every(Number.isFinite)) return null;
651
+ return { oklch: hexToOklch(formatHex({ r, g, b })), alpha };
652
+ }
653
+ if (fn === "hsl" || fn === "hsla") {
654
+ const h = parseFloat(channels[0]);
655
+ const s = num(channels[1], 1);
656
+ const l = num(channels[2], 1);
657
+ if (![h, s, l].every(Number.isFinite)) return null;
658
+ const rgb = hslToRgb((h % 360 + 360) % 360, clamp012(s), clamp012(l));
659
+ return { oklch: hexToOklch(formatHex(rgb)), alpha };
660
+ }
661
+ } catch {
662
+ return null;
663
+ }
664
+ return null;
665
+ }
666
+ function findColours(value) {
667
+ const out = [];
668
+ for (const m of value.matchAll(COLOUR)) {
669
+ const parsed = parseColour(m[0]);
670
+ if (parsed) out.push({ raw: m[0], ...parsed, index: m.index ?? 0 });
671
+ }
672
+ return out;
673
+ }
674
+ var AI_VIOLET = { hueMin: 268, hueMax: 312, minChroma: 0.1, minL: 0.3, maxL: 0.85 };
675
+ function isAiViolet({ l, c, h }) {
676
+ return c >= AI_VIOLET.minChroma && h >= AI_VIOLET.hueMin && h <= AI_VIOLET.hueMax && l >= AI_VIOLET.minL && l <= AI_VIOLET.maxL;
677
+ }
678
+ function isCream({ l, c, h }) {
679
+ return l >= 0.9 && l <= 0.985 && c >= 8e-3 && c <= 0.06 && h >= 55 && h <= 105;
680
+ }
681
+ function isBlueToPurple({ c, h }) {
682
+ return c >= 0.08 && h >= 240 && h <= 330;
683
+ }
684
+
685
+ // src/color/gradient.ts
686
+ var KEYWORDS = {
687
+ white: { r: 1, g: 1, b: 1, a: 1 },
688
+ black: { r: 0, g: 0, b: 0, a: 1 },
689
+ transparent: { r: 0, g: 0, b: 0, a: 0 }
690
+ };
691
+ var clamp013 = (n) => Math.min(1, Math.max(0, n));
692
+ function toRgba(raw) {
693
+ const value = raw.trim().toLowerCase();
694
+ if (KEYWORDS[value]) return KEYWORDS[value];
695
+ if (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(value)) {
696
+ const h = value.length <= 5 ? [...value.slice(1)].map((c) => c + c).join("") : value.slice(1);
697
+ const { r: r2, g: g2, b: b2 } = parseHex(h.slice(0, 6));
698
+ return { r: r2, g: g2, b: b2, a: h.length === 8 ? parseInt(h.slice(6), 16) / 255 : 1 };
699
+ }
700
+ const rgb = /^rgba?\(([^)]+)\)$/.exec(value);
701
+ if (rgb) {
702
+ const parts2 = rgb[1].split(/[\s,/]+/).filter(Boolean);
703
+ const channel = (p) => p.endsWith("%") ? parseFloat(p) / 100 : parseFloat(p) / 255;
704
+ const alpha = parts2[3] === void 0 ? 1 : parts2[3].endsWith("%") ? parseFloat(parts2[3]) / 100 : parseFloat(parts2[3]);
705
+ const [r2, g2, b2] = parts2.slice(0, 3).map(channel);
706
+ if (parts2.length >= 3 && [r2, g2, b2, alpha].every(Number.isFinite)) return { r: clamp013(r2), g: clamp013(g2), b: clamp013(b2), a: clamp013(alpha) };
707
+ return null;
708
+ }
709
+ const parsed = parseColour(value);
710
+ if (!parsed) return null;
711
+ const { r, g, b } = oklchToRgb(parsed.oklch);
712
+ return { r: clamp013(r), g: clamp013(g), b: clamp013(b), a: clamp013(parsed.alpha) };
713
+ }
714
+ var SAMPLES_PER_SEGMENT = 256;
715
+ function mix(a, b, t) {
716
+ const alpha = a.a + (b.a - a.a) * t;
717
+ if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
718
+ const ch = (x, y) => (x * a.a + (y * b.a - x * a.a) * t) / alpha;
719
+ return { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: alpha };
720
+ }
721
+ function composite(top, under) {
722
+ return {
723
+ r: top.r * top.a + under.r * (1 - top.a),
724
+ g: top.g * top.a + under.g * (1 - top.a),
725
+ b: top.b * top.a + under.b * (1 - top.a)
726
+ };
727
+ }
728
+ function gradientStops(gradient) {
729
+ return parseStops(gradient).map((s) => s.colour);
730
+ }
731
+ function parseStops(gradient) {
732
+ const inner = /gradient\(([\s\S]*)\)\s*$/.exec(gradient.trim());
733
+ if (!inner) throw new Error(`not a CSS gradient: ${gradient}`);
734
+ const parts2 = [];
735
+ let depth = 0;
736
+ let current = "";
737
+ for (const ch of inner[1]) {
738
+ if (ch === "(") depth += 1;
739
+ if (ch === ")") depth -= 1;
740
+ if (ch === "," && depth === 0) {
741
+ parts2.push(current.trim());
742
+ current = "";
743
+ continue;
744
+ }
745
+ current += ch;
746
+ }
747
+ if (current.trim()) parts2.push(current.trim());
748
+ const stops = [];
749
+ for (const part of parts2) {
750
+ if (/^(to\s|circle|ellipse|closest|farthest|at\s|from\s|in\s|-?[\d.]+(deg|turn|rad|grad)$)/i.test(part)) continue;
751
+ const positions = [];
752
+ let colour = part;
753
+ for (let i = 0; i < 2; i++) {
754
+ const m = /\s+(-?[\d.]+)(%|px|r?em|vw|vh|deg|turn|rad|grad)\s*$/i.exec(colour);
755
+ if (!m) break;
756
+ positions.unshift({ value: parseFloat(m[1]), unit: m[2].toLowerCase() });
757
+ colour = colour.slice(0, m.index);
758
+ }
759
+ colour = colour.trim();
760
+ if (!toRgba(colour)) throw new Error(`unreadable gradient stop "${colour}" in ${gradient}`);
761
+ stops.push({ colour, positions });
762
+ }
763
+ if (stops.length === 0) throw new Error(`no colour stops in ${gradient}`);
764
+ return stops;
765
+ }
766
+ function blendedSegments(stops) {
767
+ const entries = [];
768
+ for (const s of stops) {
769
+ const rgba = toRgba(s.colour);
770
+ if (s.positions.length === 0) entries.push({ rgba, pos: null });
771
+ for (const pos of s.positions) entries.push({ rgba, pos });
772
+ }
773
+ const furthest = {};
774
+ const pairs = [];
775
+ for (let i = 0; i + 1 < entries.length; i++) {
776
+ const pos = entries[i].pos;
777
+ if (pos) furthest[pos.unit] = Math.max(furthest[pos.unit] ?? -Infinity, pos.value);
778
+ const next = entries[i + 1].pos;
779
+ const hard = next !== null && furthest[next.unit] !== void 0 && next.value <= furthest[next.unit];
780
+ if (!hard) pairs.push([entries[i].rgba, entries[i + 1].rgba]);
781
+ }
782
+ return pairs;
783
+ }
784
+ function gradientContrast(text, gradient, beneath = "#ffffff") {
785
+ const under = toRgba(beneath);
786
+ if (!under || under.a < 1) throw new Error(`beneath must be an opaque colour, got ${beneath}`);
787
+ const fg = toRgba(text);
788
+ if (!fg || fg.a < 1) throw new Error(`text must be an opaque colour, got ${text}`);
789
+ const textHex = formatHex(fg);
790
+ const parsed = parseStops(gradient);
791
+ const stops = parsed.map((s) => formatHex(composite(toRgba(s.colour), under)));
792
+ let worst = stops[0];
793
+ let worstRatio = wcagContrast(textHex, worst);
794
+ for (const s of stops) {
795
+ const ratio = wcagContrast(textHex, s);
796
+ if (ratio < worstRatio) [worst, worstRatio] = [s, ratio];
797
+ }
798
+ for (const [a, b] of blendedSegments(parsed)) {
799
+ for (let k = 1; k < SAMPLES_PER_SEGMENT; k++) {
800
+ const point = formatHex(composite(mix(a, b, k / SAMPLES_PER_SEGMENT), under));
801
+ const ratio = wcagContrast(textHex, point);
802
+ if (ratio < worstRatio) [worst, worstRatio] = [point, ratio];
803
+ }
804
+ }
805
+ const wcag = wcagContrast(textHex, worst);
806
+ const lc = apcaContrast(textHex, worst);
807
+ return {
808
+ text,
809
+ gradient,
810
+ beneath,
811
+ stops,
812
+ worst,
813
+ wcag,
814
+ lc,
815
+ passesAA: wcag >= 4.5,
816
+ passesAALarge: wcag >= 3,
817
+ note: `${text} on ${gradient} over ${beneath}: worst point ${worst}, ${wcag.toFixed(2)}:1, Lc ${lc.toFixed(1)}`
818
+ };
819
+ }
820
+
821
+ // src/photo/review.ts
822
+ function checkPhotoReview(text, options = {}) {
823
+ const minScore = options.minScore ?? 0.7;
824
+ const required = options.required ?? true;
825
+ const failures = [];
826
+ const report2 = [];
827
+ const add = (ok, message) => {
828
+ report2.push(`${ok ? "ok " : "FAIL"} ${message}`);
829
+ if (!ok) failures.push(message);
830
+ };
831
+ const done = () => ({ ok: failures.length === 0, failures, report: report2 });
832
+ if (text === null) {
833
+ if (required) add(false, "no photo review: run `halide review --json`");
834
+ else report2.push("ok no photo review, and none required");
835
+ return done();
836
+ }
837
+ let parsed;
838
+ try {
839
+ parsed = JSON.parse(text);
840
+ } catch (error) {
841
+ add(false, `the photo review is not valid JSON: ${error.message}`);
842
+ return done();
843
+ }
844
+ const images = Array.isArray(parsed.images) ? parsed.images : [];
845
+ const overall = typeof parsed.score === "number" ? parsed.score : null;
846
+ if (overall === null && images.length === 0) {
847
+ add(false, "the photo review has neither a score nor an images list");
848
+ return done();
849
+ }
850
+ if (overall !== null) add(overall >= minScore, `overall score ${overall.toFixed(2)} against a minimum of ${minScore.toFixed(2)}`);
851
+ const below = images.filter((i) => typeof i.score === "number" && i.score < minScore);
852
+ if (below.length > 0) {
853
+ const named = below.slice(0, 5).map((i) => `${i.file ?? i.id ?? "?"} (${i.score.toFixed(2)})`).join(", ");
854
+ add(false, `${below.length} of ${images.length} image(s) below ${minScore.toFixed(2)}: ${named}`);
855
+ } else if (images.length > 0) {
856
+ add(true, `all ${images.length} image(s) at or above ${minScore.toFixed(2)}`);
857
+ }
858
+ return done();
859
+ }
860
+
589
861
  // src/color/semantic.ts
590
862
  function accentFork(rampSteps, on, options = {}) {
591
863
  const minWcag = options.minWcag ?? 4.5;
@@ -2833,98 +3105,6 @@ ${filler(520, 11)}`)]
2833
3105
  }
2834
3106
  ];
2835
3107
 
2836
- // src/character/color.ts
2837
- var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
2838
- function num(token, percentOf) {
2839
- const t = token.trim();
2840
- if (t.endsWith("%")) return parseFloat(t) / 100 * percentOf;
2841
- return parseFloat(t);
2842
- }
2843
- function parts(fn) {
2844
- const inner = fn.slice(fn.indexOf("(") + 1, -1).trim();
2845
- const [main, slashAlpha] = inner.split("/");
2846
- const channels = main.split(/[\s,]+/).filter(Boolean);
2847
- let alpha = 1;
2848
- if (slashAlpha !== void 0) alpha = num(slashAlpha, 1);
2849
- else if (channels.length === 4) alpha = num(channels.pop(), 1);
2850
- return { channels, alpha: Number.isFinite(alpha) ? alpha : 1 };
2851
- }
2852
- function hslToRgb(h, s, l) {
2853
- const k = (n) => (n + h / 30) % 12;
2854
- const a = s * Math.min(l, 1 - l);
2855
- const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
2856
- return { r: f4(0), g: f4(8), b: f4(4) };
2857
- }
2858
- var clamp012 = (n) => Math.min(1, Math.max(0, n));
2859
- function parseColour(raw) {
2860
- try {
2861
- if (raw.startsWith("#")) {
2862
- const hex2 = raw.slice(1);
2863
- const alpha2 = hex2.length === 8 ? parseInt(hex2.slice(6), 16) / 255 : hex2.length === 4 ? parseInt(hex2[3] + hex2[3], 16) / 255 : 1;
2864
- return { oklch: hexToOklch(raw), alpha: alpha2 };
2865
- }
2866
- const fn = raw.slice(0, raw.indexOf("(")).toLowerCase();
2867
- if (fn === "color") {
2868
- const { channels: channels2, alpha: alpha2 } = parts(raw.replace(/^color\(\s*srgb\s+/i, "srgb("));
2869
- const [r, g, b] = channels2.map((c) => clamp012(num(c, 1)));
2870
- if (![r, g, b].every(Number.isFinite)) return null;
2871
- return { oklch: hexToOklch(formatHex({ r, g, b })), alpha: alpha2 };
2872
- }
2873
- const { channels, alpha } = parts(raw);
2874
- if (channels.length < 3 || channels.some((c) => c.startsWith("var"))) return null;
2875
- if (fn === "oklch") {
2876
- const l = num(channels[0], 1);
2877
- const c = num(channels[1], 0.4);
2878
- const h = parseFloat(channels[2]);
2879
- if (![l, c].every(Number.isFinite)) return null;
2880
- return { oklch: { l, c, h: Number.isFinite(h) ? h : 0 }, alpha };
2881
- }
2882
- if (fn === "oklab") {
2883
- const l = num(channels[0], 1);
2884
- const a = num(channels[1], 0.4);
2885
- const b = num(channels[2], 0.4);
2886
- if (![l, a, b].every(Number.isFinite)) return null;
2887
- const c = Math.sqrt(a * a + b * b);
2888
- const h = c < 1e-7 ? 0 : (Math.atan2(b, a) * 180 / Math.PI % 360 + 360) % 360;
2889
- return { oklch: { l, c, h }, alpha };
2890
- }
2891
- if (fn === "rgb" || fn === "rgba") {
2892
- const [r, g, b] = channels.map((c) => clamp012(num(c, 255) / 255));
2893
- if (![r, g, b].every(Number.isFinite)) return null;
2894
- return { oklch: hexToOklch(formatHex({ r, g, b })), alpha };
2895
- }
2896
- if (fn === "hsl" || fn === "hsla") {
2897
- const h = parseFloat(channels[0]);
2898
- const s = num(channels[1], 1);
2899
- const l = num(channels[2], 1);
2900
- if (![h, s, l].every(Number.isFinite)) return null;
2901
- const rgb = hslToRgb((h % 360 + 360) % 360, clamp012(s), clamp012(l));
2902
- return { oklch: hexToOklch(formatHex(rgb)), alpha };
2903
- }
2904
- } catch {
2905
- return null;
2906
- }
2907
- return null;
2908
- }
2909
- function findColours(value) {
2910
- const out = [];
2911
- for (const m of value.matchAll(COLOUR)) {
2912
- const parsed = parseColour(m[0]);
2913
- if (parsed) out.push({ raw: m[0], ...parsed, index: m.index ?? 0 });
2914
- }
2915
- return out;
2916
- }
2917
- var AI_VIOLET = { hueMin: 268, hueMax: 312, minChroma: 0.1, minL: 0.3, maxL: 0.85 };
2918
- function isAiViolet({ l, c, h }) {
2919
- return c >= AI_VIOLET.minChroma && h >= AI_VIOLET.hueMin && h <= AI_VIOLET.hueMax && l >= AI_VIOLET.minL && l <= AI_VIOLET.maxL;
2920
- }
2921
- function isCream({ l, c, h }) {
2922
- return l >= 0.9 && l <= 0.985 && c >= 8e-3 && c <= 0.06 && h >= 55 && h <= 105;
2923
- }
2924
- function isBlueToPurple({ c, h }) {
2925
- return c >= 0.08 && h >= 240 && h <= 330;
2926
- }
2927
-
2928
3108
  // src/character/tells/source.ts
2929
3109
  function baseClass(cls) {
2930
3110
  let depth = 0;
@@ -9379,6 +9559,7 @@ function retrofitPlan(report2, direction) {
9379
9559
  RENDERED_TELLS,
9380
9560
  REVEAL_LIMIT,
9381
9561
  REVIEW_PHRASES,
9562
+ SAMPLES_PER_SEGMENT,
9382
9563
  SCALE,
9383
9564
  SECTION_ROLES,
9384
9565
  SHADCN_LIMIT,
@@ -9416,6 +9597,7 @@ function retrofitPlan(report2, direction) {
9416
9597
  checkCopy,
9417
9598
  checkOrder,
9418
9599
  checkPair,
9600
+ checkPhotoReview,
9419
9601
  checkReason,
9420
9602
  checkRegister,
9421
9603
  checkRestraint,
@@ -9469,6 +9651,8 @@ function retrofitPlan(report2, direction) {
9469
9651
  formatReport,
9470
9652
  getRegister,
9471
9653
  globToRegExp,
9654
+ gradientContrast,
9655
+ gradientStops,
9472
9656
  groundName,
9473
9657
  harvest,
9474
9658
  hedgeCount,
package/dist/index.d.cts CHANGED
@@ -159,6 +159,91 @@ interface ContrastCheck {
159
159
  /** Measure one text-on-background pair against both models. */
160
160
  declare function checkPair(text: string, background: string): ContrastCheck;
161
161
 
162
+ /**
163
+ * Contrast against a CSS gradient: the case automated tools do not score.
164
+ *
165
+ * axe-core and Lighthouse mark text on a gradient "incomplete", and it never
166
+ * reaches the result, so a page can report no contrast violations while its
167
+ * body copy fails AA. This was first found on two client sites (4.14:1 to
168
+ * 4.44:1, reported clean by every tool in CI). This walks the gradient's
169
+ * stops, composites each over what sits beneath, and scores the text against
170
+ * the worst one: the backdrop the text has to clear everywhere.
171
+ *
172
+ * WCAG 2.2 defines no method for gradient or translucent backdrops, so the
173
+ * worst point is this package's own rule, not a conformance claim.
174
+ *
175
+ * Folded in from dd-drift-guards (guards/contrast-math.mjs) on 3 October 2026,
176
+ * on craft's own colour parsing and contrast models, with one fix: the guard
177
+ * scored the stops only. The worst point can sit between two stops, wherever
178
+ * the backdrop's luminance comes closest to the text's: mid-grey text on a
179
+ * black-to-white gradient clears 4.5:1 against both ends and about 1:1 in the
180
+ * middle. So each segment is sampled, interpolated as browsers paint legacy
181
+ * colours (sRGB, premultiplied alpha), except across a hard stop
182
+ * (`#000 50%, #fff 50%`), where the browser paints a jump and no blend. Pure.
183
+ */
184
+ /** Samples per segment between two stops: under 1/255 of a channel apart for any pair. */
185
+ declare const SAMPLES_PER_SEGMENT = 256;
186
+ /**
187
+ * The colour stops of a CSS gradient, in order, as written. Splits on
188
+ * top-level commas only, so `rgba(1, 2, 3, 0.4)` survives, and drops the
189
+ * direction or shape argument (`to bottom`, `135deg`, `circle at top`).
190
+ * Throws on a stop it cannot read: a skipped stop could be the worst one.
191
+ */
192
+ declare function gradientStops(gradient: string): string[];
193
+ interface GradientContrast {
194
+ text: string;
195
+ gradient: string;
196
+ beneath: string;
197
+ /** Every stop composited over `beneath`, as hex. */
198
+ stops: string[];
199
+ /** The point of the gradient, stop or between stops, the text contrasts least with. */
200
+ worst: string;
201
+ /** WCAG 2.x ratio against the worst point. */
202
+ wcag: number;
203
+ /** APCA Lc against the worst point, signed. */
204
+ lc: number;
205
+ passesAA: boolean;
206
+ passesAALarge: boolean;
207
+ note: string;
208
+ }
209
+ /**
210
+ * Text on a gradient that sits on `beneath` (an opaque colour; default white).
211
+ * The text colour must be opaque: a translucent text colour depends on the
212
+ * backdrop at each pixel, which is a rendered measurement, not this one.
213
+ */
214
+ declare function gradientContrast(text: string, gradient: string, beneath?: string): GradientContrast;
215
+
216
+ /**
217
+ * The photo-review gate: reads the report `halide review --json` wrote and
218
+ * fails a build whose photography is under the bar.
219
+ *
220
+ * It takes the report's text, not a path, and never runs halide. A check that
221
+ * spawns a binary cannot run where the binary is absent (a CI image, a fresh
222
+ * clone), turns a fast check slow, and depends on a tool version nobody
223
+ * pinned. The pipeline writes the report; the build reads it and hands it here.
224
+ *
225
+ * Folded in from dd-drift-guards (guards/photo-review.mjs) on 3 October 2026,
226
+ * unchanged in what it passes and fails. Pure.
227
+ */
228
+ interface PhotoReviewOptions {
229
+ /** Lowest acceptable score, overall and per image. Default 0.7. */
230
+ minScore?: number;
231
+ /**
232
+ * When false, no report is a pass: for a site with no photography at all.
233
+ * Default true, because a missing report on a site that has photos is the
234
+ * review not having run.
235
+ */
236
+ required?: boolean;
237
+ }
238
+ interface PhotoReviewResult {
239
+ ok: boolean;
240
+ failures: string[];
241
+ /** One line per thing checked, passed or not. */
242
+ report: string[];
243
+ }
244
+ /** `text` is the report's contents, or null when there is no report. */
245
+ declare function checkPhotoReview(text: string | null, options?: PhotoReviewOptions): PhotoReviewResult;
246
+
162
247
  /**
163
248
  * Semantic tokens derived from a small set of anchors, with a contrast report.
164
249
  *
@@ -3260,4 +3345,4 @@ declare function characterReport(input: CharacterInputs, copyTells?: Set<string>
3260
3345
 
3261
3346
  declare function retrofitPlan(report: CharacterReport, direction?: ArtDirection | null): string;
3262
3347
 
3263
- export { ACCENT_SCALE, ACCENT_SHARED, AI_PHRASES, AI_SOURCE_TYPES, AI_VIOLET, AI_WORDS, type AccentFork, type AccentForkOptions, type Action, type ActionPosition, type Area, type ArtDirection, BASELINE_VERSION, BUZZWORDS, type Band, type Baseline, type BaselineEntry, CATALOGUE, CATALOGUE_VERSION, CHOICE_KEYS, COMPONENT_ROLES, CONSIDERATION, CONTENT_FEATURES, CONVENTION, CUSTOMER_KINDS, type Calibration, type CandidateKind, type CharacterInputs, type CharacterReport, type CheckOptions, type CheckReport, type CheckRestraintInput, type ChoiceKey, type Claim, type ClaimsReport, type CompetitorComparison, type ComponentPair, type ComponentTypicality, type Consideration, type ContrastCheck, type ContrastReport, type CopySide, type CopyTell, type CraftConfig, type CraftTokensInput, type CraftTokensResult, type CustomerKind, DEFAULT_IGNORE, DENSITIES, DENSITY_NAMES, DIRECTION_VERSION, DIRECTION_VERSIONS, DISTANCE_WEIGHTS, DURATION_MS, DURATION_S, type DensityCssOptions, type DensityName, type DirectionChoice, type DirectionComparison, type DirectionMatch, type DirectionProblem, type DirectionReport, type DirectionSource, type DirectionSummary, type DurationName, EASE, EASE_TUPLE, EDGE_STEP, ESTATE_VERSION, EXIT_RATIO, type EaseName, type EmitCssOptions, type EstateMatch, type EstatePair, type EstateRegister, type EstateSite, FINGERPRINT_VERSION, FLAGGED_TARGET, FUNCTION_WORDS, type Fact, type FactComparison, type FactKind, type Finding, type Fingerprint, type FingerprintDistance, type FingerprintSection, type FluidSpaceOptions, type FluidTypeOptions, type FluidTypeResult, type FontClass, GROUND_SCALE, GROUND_SHARED, type Gamut, type Generation, HIERARCHY_FIELDS, HOME_FILE, HOME_URL, HOUSE, HOUSE_BUDGET, HOUSE_LOCKED, HOUSE_TYPE, HUMAN_TYPICAL_TARGET, type HarvestCandidate, type HarvestOptions, type Hit, type HouseRule, type HouseTier, IMAGERY_TELLS, type ImageProvenance, type ImageRole, JSON_SCHEMA_VERSION, type JobContext, type JobMap, type JobPart, LABELS, LEXICAL_FEATURES, LICENCES, LIGHTNESS_CURVE, type Label, type LabelSummary, type LexicalFeatures, type LicenceEntry, type LicenceSubject, MIN_DECIDED, MIN_PARAGRAPHS, MIN_RUNS, MOOD, type MeasuredPage, type MotionDecision, type MotionKind, type MultiClient, NEGATIVE_REASSURANCE, NULL_VERSION, type NullModel, type NullRun, type Oklch, type OklchToHexOptions, PAGE_TYPES, PILL_LIMIT, PLAINER_WORDS, PREFERENCE, PROFILE, PROFILE_MEASURED, PROFILE_PARAGRAPHS, PROMO_BADGE, PROOF_NAMES, PROPOSED, PROVENANCE_SCAN_BYTES, type PageCopy, type PageHierarchy, type PageResult, type PageType, type PaletteColour, type Proposal, type ProposeInput, RAMP_STEPS, READABLE_SNAPSHOT_VERSIONS, REFLEX_FONTS_1, REFLEX_FONTS_2, REGISTERS, REGISTER_BASELINE, REGISTER_FEATURES, REGISTER_IDS, RENDERED_PATHS, RENDERED_TELLS, REVEAL_LIMIT, REVIEW_PHRASES, type Ramp, type RampFromAnchorsOptions, type RampOptions, type RampStep, type ReasonInput, type Register, type RegisterCheck, type RegisterFeatures, type RegisterId, type RegisterProfile, type RenderedTell, type ResearchInput, type RestraintBudget, type RestraintReport, type RestraintSeverity, type RestraintViolation, type Rgb, type RgbaImage, SCALE, SECTION_ROLES, SHADCN_LIMIT, SHADCN_PRIMITIVES, SHARED_PART, SIBLING_AT, SNAPSHOT_VERSION, SOURCE_KINDS, SPACE_STEPS, SPECIFICITY_COPY_TELLS, SPECIFICITY_RENDERED_TELLS, SPRING, STANDING_CLAIM, STOCK_AVATAR_HOSTS, STOCK_FIGURES, STOCK_PHOTO_HOSTS, STOCK_PHRASES, STUDY_WORDS, type SarifOptions, type SarifTell, type SectionGeometry, type SectionKind, type SectionRhythmOptions, type SectionRole, type SemanticInput, type SemanticResult, type Severity, type SeverityLevel, type SeverityOverride, type ShapeFeatures, type ShouldAnimateInput, type SignalResult, type Snapshot, type SnapshotColour, type SnapshotControl, type SnapshotEffects, type SnapshotFont, type SnapshotGradient, type SnapshotHeading, type SnapshotImage, type SnapshotMotion, type SnapshotPatch, type SnapshotSection, type SnapshotVisual, type SourceFile, type SourceKind, type SourceTell, type SpaceStep, type Specific, type SpecificKind, type Specificity, type StudyWord, type Surface, TELL_HEAVY, TRADE_NOUNS, TYPE_STEPS, TYPICAL_AT, type TailwindPreset, type TargetCheck, type Tell, type TellException, type TellRate, type Tricolon, type TypeStep, type Typicality, type UnmeasuredPage, VAGUE_WORDS, type ValidateContext, type Verdict, accentFork, addSite, apcaContrast, applyBaseline, applyHouseGate, applySeverity, auditSnapshot, briefTerms, calibrate, catalogueTable, characterReport, checkCopy, checkOrder, checkPair, checkReason, checkRegister, checkRestraint, choicesOf, clauseDepth, closestComponents, compareCompetitor, compareDirections, compareFacts, compareToDeclared, compareToEstate, componentDistance, componentShared, componentTypicalities, componentTypicality, componentsOf, contentWords, countWords, craftTokens, createBaseline, cv, deltaEOk, deltaEOkHex, densityCss, densityTokens, designTells, documentParagraphs, emitCss, emptyEstate, estateComponentPairs, estatePairs, exitDuration, extractHtml, faceLicence, featureLabel, findClaims, findColours, findingKey, fingerprint, fingerprintDistance, fluidClamp, fluidSpace, fluidType, fontClass, formatCalibration, formatClaims, formatComparison, formatCompetitor, formatDirectionMatches, formatHex, formatReport, getRegister, globToRegExp, groundName, harvest, hedgeCount, hedgedClose, hexToOklch, holdHouseBlocks, houseRule, hueName, hueSwatch, ignoreMatcher, inP3Gamut, inSrgbGamut, initDirection, isAiViolet, isBlurb, isCategoryLabel, isCream, isFlagged, jobWords, layoutDistance, lexicalOf, licencesJson, makeSnapshot, motionTokens, namedSources, nearest, nominalisations, normaliseFamily, nullPrompt, oklchToHex, oklchToRgb, pageCopy, paletteFromPixels, paragraphUniformity, parseBaseline, parseColour, parseCraftConfig, parseHex, participialClauses, phrasalCoordinations, propose, protectedFacts, ramp, rampFromAnchors, readSnapshot, registerBrief, registerOf, registersDoc, renderedOrder, researchPrompt, retrofitPlan, runTell, scanProvenance, scanSource, sectionRhythm, semantic, sentencesOf, severityExceptions, shapeMoves, shapeName, shapeOf, words as shapeWords, sharedParts, shouldAnimate, siblingLine, skeleton, skeletonRepeat, specificity, specifics, splitSentences, stackedConditional, stockAvatarSource, stockSource, summariseDirection, tailwindV3Preset, tailwindV4Theme, tellById, toGamut, toJson, toSarif, tradeOf, tricolons, typeFeatureTokens, typicality, unprovenClaims, validateDirection, validateHierarchy, validateJob, visibleText, visualMeasures, vocabulary, vocabularyCounts, wcagContrast, wordListsJson };
3348
+ export { ACCENT_SCALE, ACCENT_SHARED, AI_PHRASES, AI_SOURCE_TYPES, AI_VIOLET, AI_WORDS, type AccentFork, type AccentForkOptions, type Action, type ActionPosition, type Area, type ArtDirection, BASELINE_VERSION, BUZZWORDS, type Band, type Baseline, type BaselineEntry, CATALOGUE, CATALOGUE_VERSION, CHOICE_KEYS, COMPONENT_ROLES, CONSIDERATION, CONTENT_FEATURES, CONVENTION, CUSTOMER_KINDS, type Calibration, type CandidateKind, type CharacterInputs, type CharacterReport, type CheckOptions, type CheckReport, type CheckRestraintInput, type ChoiceKey, type Claim, type ClaimsReport, type CompetitorComparison, type ComponentPair, type ComponentTypicality, type Consideration, type ContrastCheck, type ContrastReport, type CopySide, type CopyTell, type CraftConfig, type CraftTokensInput, type CraftTokensResult, type CustomerKind, DEFAULT_IGNORE, DENSITIES, DENSITY_NAMES, DIRECTION_VERSION, DIRECTION_VERSIONS, DISTANCE_WEIGHTS, DURATION_MS, DURATION_S, type DensityCssOptions, type DensityName, type DirectionChoice, type DirectionComparison, type DirectionMatch, type DirectionProblem, type DirectionReport, type DirectionSource, type DirectionSummary, type DurationName, EASE, EASE_TUPLE, EDGE_STEP, ESTATE_VERSION, EXIT_RATIO, type EaseName, type EmitCssOptions, type EstateMatch, type EstatePair, type EstateRegister, type EstateSite, FINGERPRINT_VERSION, FLAGGED_TARGET, FUNCTION_WORDS, type Fact, type FactComparison, type FactKind, type Finding, type Fingerprint, type FingerprintDistance, type FingerprintSection, type FluidSpaceOptions, type FluidTypeOptions, type FluidTypeResult, type FontClass, GROUND_SCALE, GROUND_SHARED, type Gamut, type Generation, type GradientContrast, HIERARCHY_FIELDS, HOME_FILE, HOME_URL, HOUSE, HOUSE_BUDGET, HOUSE_LOCKED, HOUSE_TYPE, HUMAN_TYPICAL_TARGET, type HarvestCandidate, type HarvestOptions, type Hit, type HouseRule, type HouseTier, IMAGERY_TELLS, type ImageProvenance, type ImageRole, JSON_SCHEMA_VERSION, type JobContext, type JobMap, type JobPart, LABELS, LEXICAL_FEATURES, LICENCES, LIGHTNESS_CURVE, type Label, type LabelSummary, type LexicalFeatures, type LicenceEntry, type LicenceSubject, MIN_DECIDED, MIN_PARAGRAPHS, MIN_RUNS, MOOD, type MeasuredPage, type MotionDecision, type MotionKind, type MultiClient, NEGATIVE_REASSURANCE, NULL_VERSION, type NullModel, type NullRun, type Oklch, type OklchToHexOptions, PAGE_TYPES, PILL_LIMIT, PLAINER_WORDS, PREFERENCE, PROFILE, PROFILE_MEASURED, PROFILE_PARAGRAPHS, PROMO_BADGE, PROOF_NAMES, PROPOSED, PROVENANCE_SCAN_BYTES, type PageCopy, type PageHierarchy, type PageResult, type PageType, type PaletteColour, type PhotoReviewOptions, type PhotoReviewResult, type Proposal, type ProposeInput, RAMP_STEPS, READABLE_SNAPSHOT_VERSIONS, REFLEX_FONTS_1, REFLEX_FONTS_2, REGISTERS, REGISTER_BASELINE, REGISTER_FEATURES, REGISTER_IDS, RENDERED_PATHS, RENDERED_TELLS, REVEAL_LIMIT, REVIEW_PHRASES, type Ramp, type RampFromAnchorsOptions, type RampOptions, type RampStep, type ReasonInput, type Register, type RegisterCheck, type RegisterFeatures, type RegisterId, type RegisterProfile, type RenderedTell, type ResearchInput, type RestraintBudget, type RestraintReport, type RestraintSeverity, type RestraintViolation, type Rgb, type RgbaImage, SAMPLES_PER_SEGMENT, SCALE, SECTION_ROLES, SHADCN_LIMIT, SHADCN_PRIMITIVES, SHARED_PART, SIBLING_AT, SNAPSHOT_VERSION, SOURCE_KINDS, SPACE_STEPS, SPECIFICITY_COPY_TELLS, SPECIFICITY_RENDERED_TELLS, SPRING, STANDING_CLAIM, STOCK_AVATAR_HOSTS, STOCK_FIGURES, STOCK_PHOTO_HOSTS, STOCK_PHRASES, STUDY_WORDS, type SarifOptions, type SarifTell, type SectionGeometry, type SectionKind, type SectionRhythmOptions, type SectionRole, type SemanticInput, type SemanticResult, type Severity, type SeverityLevel, type SeverityOverride, type ShapeFeatures, type ShouldAnimateInput, type SignalResult, type Snapshot, type SnapshotColour, type SnapshotControl, type SnapshotEffects, type SnapshotFont, type SnapshotGradient, type SnapshotHeading, type SnapshotImage, type SnapshotMotion, type SnapshotPatch, type SnapshotSection, type SnapshotVisual, type SourceFile, type SourceKind, type SourceTell, type SpaceStep, type Specific, type SpecificKind, type Specificity, type StudyWord, type Surface, TELL_HEAVY, TRADE_NOUNS, TYPE_STEPS, TYPICAL_AT, type TailwindPreset, type TargetCheck, type Tell, type TellException, type TellRate, type Tricolon, type TypeStep, type Typicality, type UnmeasuredPage, VAGUE_WORDS, type ValidateContext, type Verdict, accentFork, addSite, apcaContrast, applyBaseline, applyHouseGate, applySeverity, auditSnapshot, briefTerms, calibrate, catalogueTable, characterReport, checkCopy, checkOrder, checkPair, checkPhotoReview, checkReason, checkRegister, checkRestraint, choicesOf, clauseDepth, closestComponents, compareCompetitor, compareDirections, compareFacts, compareToDeclared, compareToEstate, componentDistance, componentShared, componentTypicalities, componentTypicality, componentsOf, contentWords, countWords, craftTokens, createBaseline, cv, deltaEOk, deltaEOkHex, densityCss, densityTokens, designTells, documentParagraphs, emitCss, emptyEstate, estateComponentPairs, estatePairs, exitDuration, extractHtml, faceLicence, featureLabel, findClaims, findColours, findingKey, fingerprint, fingerprintDistance, fluidClamp, fluidSpace, fluidType, fontClass, formatCalibration, formatClaims, formatComparison, formatCompetitor, formatDirectionMatches, formatHex, formatReport, getRegister, globToRegExp, gradientContrast, gradientStops, groundName, harvest, hedgeCount, hedgedClose, hexToOklch, holdHouseBlocks, houseRule, hueName, hueSwatch, ignoreMatcher, inP3Gamut, inSrgbGamut, initDirection, isAiViolet, isBlurb, isCategoryLabel, isCream, isFlagged, jobWords, layoutDistance, lexicalOf, licencesJson, makeSnapshot, motionTokens, namedSources, nearest, nominalisations, normaliseFamily, nullPrompt, oklchToHex, oklchToRgb, pageCopy, paletteFromPixels, paragraphUniformity, parseBaseline, parseColour, parseCraftConfig, parseHex, participialClauses, phrasalCoordinations, propose, protectedFacts, ramp, rampFromAnchors, readSnapshot, registerBrief, registerOf, registersDoc, renderedOrder, researchPrompt, retrofitPlan, runTell, scanProvenance, scanSource, sectionRhythm, semantic, sentencesOf, severityExceptions, shapeMoves, shapeName, shapeOf, words as shapeWords, sharedParts, shouldAnimate, siblingLine, skeleton, skeletonRepeat, specificity, specifics, splitSentences, stackedConditional, stockAvatarSource, stockSource, summariseDirection, tailwindV3Preset, tailwindV4Theme, tellById, toGamut, toJson, toSarif, tradeOf, tricolons, typeFeatureTokens, typicality, unprovenClaims, validateDirection, validateHierarchy, validateJob, visibleText, visualMeasures, vocabulary, vocabularyCounts, wcagContrast, wordListsJson };
package/dist/index.d.ts CHANGED
@@ -159,6 +159,91 @@ interface ContrastCheck {
159
159
  /** Measure one text-on-background pair against both models. */
160
160
  declare function checkPair(text: string, background: string): ContrastCheck;
161
161
 
162
+ /**
163
+ * Contrast against a CSS gradient: the case automated tools do not score.
164
+ *
165
+ * axe-core and Lighthouse mark text on a gradient "incomplete", and it never
166
+ * reaches the result, so a page can report no contrast violations while its
167
+ * body copy fails AA. This was first found on two client sites (4.14:1 to
168
+ * 4.44:1, reported clean by every tool in CI). This walks the gradient's
169
+ * stops, composites each over what sits beneath, and scores the text against
170
+ * the worst one: the backdrop the text has to clear everywhere.
171
+ *
172
+ * WCAG 2.2 defines no method for gradient or translucent backdrops, so the
173
+ * worst point is this package's own rule, not a conformance claim.
174
+ *
175
+ * Folded in from dd-drift-guards (guards/contrast-math.mjs) on 3 October 2026,
176
+ * on craft's own colour parsing and contrast models, with one fix: the guard
177
+ * scored the stops only. The worst point can sit between two stops, wherever
178
+ * the backdrop's luminance comes closest to the text's: mid-grey text on a
179
+ * black-to-white gradient clears 4.5:1 against both ends and about 1:1 in the
180
+ * middle. So each segment is sampled, interpolated as browsers paint legacy
181
+ * colours (sRGB, premultiplied alpha), except across a hard stop
182
+ * (`#000 50%, #fff 50%`), where the browser paints a jump and no blend. Pure.
183
+ */
184
+ /** Samples per segment between two stops: under 1/255 of a channel apart for any pair. */
185
+ declare const SAMPLES_PER_SEGMENT = 256;
186
+ /**
187
+ * The colour stops of a CSS gradient, in order, as written. Splits on
188
+ * top-level commas only, so `rgba(1, 2, 3, 0.4)` survives, and drops the
189
+ * direction or shape argument (`to bottom`, `135deg`, `circle at top`).
190
+ * Throws on a stop it cannot read: a skipped stop could be the worst one.
191
+ */
192
+ declare function gradientStops(gradient: string): string[];
193
+ interface GradientContrast {
194
+ text: string;
195
+ gradient: string;
196
+ beneath: string;
197
+ /** Every stop composited over `beneath`, as hex. */
198
+ stops: string[];
199
+ /** The point of the gradient, stop or between stops, the text contrasts least with. */
200
+ worst: string;
201
+ /** WCAG 2.x ratio against the worst point. */
202
+ wcag: number;
203
+ /** APCA Lc against the worst point, signed. */
204
+ lc: number;
205
+ passesAA: boolean;
206
+ passesAALarge: boolean;
207
+ note: string;
208
+ }
209
+ /**
210
+ * Text on a gradient that sits on `beneath` (an opaque colour; default white).
211
+ * The text colour must be opaque: a translucent text colour depends on the
212
+ * backdrop at each pixel, which is a rendered measurement, not this one.
213
+ */
214
+ declare function gradientContrast(text: string, gradient: string, beneath?: string): GradientContrast;
215
+
216
+ /**
217
+ * The photo-review gate: reads the report `halide review --json` wrote and
218
+ * fails a build whose photography is under the bar.
219
+ *
220
+ * It takes the report's text, not a path, and never runs halide. A check that
221
+ * spawns a binary cannot run where the binary is absent (a CI image, a fresh
222
+ * clone), turns a fast check slow, and depends on a tool version nobody
223
+ * pinned. The pipeline writes the report; the build reads it and hands it here.
224
+ *
225
+ * Folded in from dd-drift-guards (guards/photo-review.mjs) on 3 October 2026,
226
+ * unchanged in what it passes and fails. Pure.
227
+ */
228
+ interface PhotoReviewOptions {
229
+ /** Lowest acceptable score, overall and per image. Default 0.7. */
230
+ minScore?: number;
231
+ /**
232
+ * When false, no report is a pass: for a site with no photography at all.
233
+ * Default true, because a missing report on a site that has photos is the
234
+ * review not having run.
235
+ */
236
+ required?: boolean;
237
+ }
238
+ interface PhotoReviewResult {
239
+ ok: boolean;
240
+ failures: string[];
241
+ /** One line per thing checked, passed or not. */
242
+ report: string[];
243
+ }
244
+ /** `text` is the report's contents, or null when there is no report. */
245
+ declare function checkPhotoReview(text: string | null, options?: PhotoReviewOptions): PhotoReviewResult;
246
+
162
247
  /**
163
248
  * Semantic tokens derived from a small set of anchors, with a contrast report.
164
249
  *
@@ -3260,4 +3345,4 @@ declare function characterReport(input: CharacterInputs, copyTells?: Set<string>
3260
3345
 
3261
3346
  declare function retrofitPlan(report: CharacterReport, direction?: ArtDirection | null): string;
3262
3347
 
3263
- export { ACCENT_SCALE, ACCENT_SHARED, AI_PHRASES, AI_SOURCE_TYPES, AI_VIOLET, AI_WORDS, type AccentFork, type AccentForkOptions, type Action, type ActionPosition, type Area, type ArtDirection, BASELINE_VERSION, BUZZWORDS, type Band, type Baseline, type BaselineEntry, CATALOGUE, CATALOGUE_VERSION, CHOICE_KEYS, COMPONENT_ROLES, CONSIDERATION, CONTENT_FEATURES, CONVENTION, CUSTOMER_KINDS, type Calibration, type CandidateKind, type CharacterInputs, type CharacterReport, type CheckOptions, type CheckReport, type CheckRestraintInput, type ChoiceKey, type Claim, type ClaimsReport, type CompetitorComparison, type ComponentPair, type ComponentTypicality, type Consideration, type ContrastCheck, type ContrastReport, type CopySide, type CopyTell, type CraftConfig, type CraftTokensInput, type CraftTokensResult, type CustomerKind, DEFAULT_IGNORE, DENSITIES, DENSITY_NAMES, DIRECTION_VERSION, DIRECTION_VERSIONS, DISTANCE_WEIGHTS, DURATION_MS, DURATION_S, type DensityCssOptions, type DensityName, type DirectionChoice, type DirectionComparison, type DirectionMatch, type DirectionProblem, type DirectionReport, type DirectionSource, type DirectionSummary, type DurationName, EASE, EASE_TUPLE, EDGE_STEP, ESTATE_VERSION, EXIT_RATIO, type EaseName, type EmitCssOptions, type EstateMatch, type EstatePair, type EstateRegister, type EstateSite, FINGERPRINT_VERSION, FLAGGED_TARGET, FUNCTION_WORDS, type Fact, type FactComparison, type FactKind, type Finding, type Fingerprint, type FingerprintDistance, type FingerprintSection, type FluidSpaceOptions, type FluidTypeOptions, type FluidTypeResult, type FontClass, GROUND_SCALE, GROUND_SHARED, type Gamut, type Generation, HIERARCHY_FIELDS, HOME_FILE, HOME_URL, HOUSE, HOUSE_BUDGET, HOUSE_LOCKED, HOUSE_TYPE, HUMAN_TYPICAL_TARGET, type HarvestCandidate, type HarvestOptions, type Hit, type HouseRule, type HouseTier, IMAGERY_TELLS, type ImageProvenance, type ImageRole, JSON_SCHEMA_VERSION, type JobContext, type JobMap, type JobPart, LABELS, LEXICAL_FEATURES, LICENCES, LIGHTNESS_CURVE, type Label, type LabelSummary, type LexicalFeatures, type LicenceEntry, type LicenceSubject, MIN_DECIDED, MIN_PARAGRAPHS, MIN_RUNS, MOOD, type MeasuredPage, type MotionDecision, type MotionKind, type MultiClient, NEGATIVE_REASSURANCE, NULL_VERSION, type NullModel, type NullRun, type Oklch, type OklchToHexOptions, PAGE_TYPES, PILL_LIMIT, PLAINER_WORDS, PREFERENCE, PROFILE, PROFILE_MEASURED, PROFILE_PARAGRAPHS, PROMO_BADGE, PROOF_NAMES, PROPOSED, PROVENANCE_SCAN_BYTES, type PageCopy, type PageHierarchy, type PageResult, type PageType, type PaletteColour, type Proposal, type ProposeInput, RAMP_STEPS, READABLE_SNAPSHOT_VERSIONS, REFLEX_FONTS_1, REFLEX_FONTS_2, REGISTERS, REGISTER_BASELINE, REGISTER_FEATURES, REGISTER_IDS, RENDERED_PATHS, RENDERED_TELLS, REVEAL_LIMIT, REVIEW_PHRASES, type Ramp, type RampFromAnchorsOptions, type RampOptions, type RampStep, type ReasonInput, type Register, type RegisterCheck, type RegisterFeatures, type RegisterId, type RegisterProfile, type RenderedTell, type ResearchInput, type RestraintBudget, type RestraintReport, type RestraintSeverity, type RestraintViolation, type Rgb, type RgbaImage, SCALE, SECTION_ROLES, SHADCN_LIMIT, SHADCN_PRIMITIVES, SHARED_PART, SIBLING_AT, SNAPSHOT_VERSION, SOURCE_KINDS, SPACE_STEPS, SPECIFICITY_COPY_TELLS, SPECIFICITY_RENDERED_TELLS, SPRING, STANDING_CLAIM, STOCK_AVATAR_HOSTS, STOCK_FIGURES, STOCK_PHOTO_HOSTS, STOCK_PHRASES, STUDY_WORDS, type SarifOptions, type SarifTell, type SectionGeometry, type SectionKind, type SectionRhythmOptions, type SectionRole, type SemanticInput, type SemanticResult, type Severity, type SeverityLevel, type SeverityOverride, type ShapeFeatures, type ShouldAnimateInput, type SignalResult, type Snapshot, type SnapshotColour, type SnapshotControl, type SnapshotEffects, type SnapshotFont, type SnapshotGradient, type SnapshotHeading, type SnapshotImage, type SnapshotMotion, type SnapshotPatch, type SnapshotSection, type SnapshotVisual, type SourceFile, type SourceKind, type SourceTell, type SpaceStep, type Specific, type SpecificKind, type Specificity, type StudyWord, type Surface, TELL_HEAVY, TRADE_NOUNS, TYPE_STEPS, TYPICAL_AT, type TailwindPreset, type TargetCheck, type Tell, type TellException, type TellRate, type Tricolon, type TypeStep, type Typicality, type UnmeasuredPage, VAGUE_WORDS, type ValidateContext, type Verdict, accentFork, addSite, apcaContrast, applyBaseline, applyHouseGate, applySeverity, auditSnapshot, briefTerms, calibrate, catalogueTable, characterReport, checkCopy, checkOrder, checkPair, checkReason, checkRegister, checkRestraint, choicesOf, clauseDepth, closestComponents, compareCompetitor, compareDirections, compareFacts, compareToDeclared, compareToEstate, componentDistance, componentShared, componentTypicalities, componentTypicality, componentsOf, contentWords, countWords, craftTokens, createBaseline, cv, deltaEOk, deltaEOkHex, densityCss, densityTokens, designTells, documentParagraphs, emitCss, emptyEstate, estateComponentPairs, estatePairs, exitDuration, extractHtml, faceLicence, featureLabel, findClaims, findColours, findingKey, fingerprint, fingerprintDistance, fluidClamp, fluidSpace, fluidType, fontClass, formatCalibration, formatClaims, formatComparison, formatCompetitor, formatDirectionMatches, formatHex, formatReport, getRegister, globToRegExp, groundName, harvest, hedgeCount, hedgedClose, hexToOklch, holdHouseBlocks, houseRule, hueName, hueSwatch, ignoreMatcher, inP3Gamut, inSrgbGamut, initDirection, isAiViolet, isBlurb, isCategoryLabel, isCream, isFlagged, jobWords, layoutDistance, lexicalOf, licencesJson, makeSnapshot, motionTokens, namedSources, nearest, nominalisations, normaliseFamily, nullPrompt, oklchToHex, oklchToRgb, pageCopy, paletteFromPixels, paragraphUniformity, parseBaseline, parseColour, parseCraftConfig, parseHex, participialClauses, phrasalCoordinations, propose, protectedFacts, ramp, rampFromAnchors, readSnapshot, registerBrief, registerOf, registersDoc, renderedOrder, researchPrompt, retrofitPlan, runTell, scanProvenance, scanSource, sectionRhythm, semantic, sentencesOf, severityExceptions, shapeMoves, shapeName, shapeOf, words as shapeWords, sharedParts, shouldAnimate, siblingLine, skeleton, skeletonRepeat, specificity, specifics, splitSentences, stackedConditional, stockAvatarSource, stockSource, summariseDirection, tailwindV3Preset, tailwindV4Theme, tellById, toGamut, toJson, toSarif, tradeOf, tricolons, typeFeatureTokens, typicality, unprovenClaims, validateDirection, validateHierarchy, validateJob, visibleText, visualMeasures, vocabulary, vocabularyCounts, wcagContrast, wordListsJson };
3348
+ export { ACCENT_SCALE, ACCENT_SHARED, AI_PHRASES, AI_SOURCE_TYPES, AI_VIOLET, AI_WORDS, type AccentFork, type AccentForkOptions, type Action, type ActionPosition, type Area, type ArtDirection, BASELINE_VERSION, BUZZWORDS, type Band, type Baseline, type BaselineEntry, CATALOGUE, CATALOGUE_VERSION, CHOICE_KEYS, COMPONENT_ROLES, CONSIDERATION, CONTENT_FEATURES, CONVENTION, CUSTOMER_KINDS, type Calibration, type CandidateKind, type CharacterInputs, type CharacterReport, type CheckOptions, type CheckReport, type CheckRestraintInput, type ChoiceKey, type Claim, type ClaimsReport, type CompetitorComparison, type ComponentPair, type ComponentTypicality, type Consideration, type ContrastCheck, type ContrastReport, type CopySide, type CopyTell, type CraftConfig, type CraftTokensInput, type CraftTokensResult, type CustomerKind, DEFAULT_IGNORE, DENSITIES, DENSITY_NAMES, DIRECTION_VERSION, DIRECTION_VERSIONS, DISTANCE_WEIGHTS, DURATION_MS, DURATION_S, type DensityCssOptions, type DensityName, type DirectionChoice, type DirectionComparison, type DirectionMatch, type DirectionProblem, type DirectionReport, type DirectionSource, type DirectionSummary, type DurationName, EASE, EASE_TUPLE, EDGE_STEP, ESTATE_VERSION, EXIT_RATIO, type EaseName, type EmitCssOptions, type EstateMatch, type EstatePair, type EstateRegister, type EstateSite, FINGERPRINT_VERSION, FLAGGED_TARGET, FUNCTION_WORDS, type Fact, type FactComparison, type FactKind, type Finding, type Fingerprint, type FingerprintDistance, type FingerprintSection, type FluidSpaceOptions, type FluidTypeOptions, type FluidTypeResult, type FontClass, GROUND_SCALE, GROUND_SHARED, type Gamut, type Generation, type GradientContrast, HIERARCHY_FIELDS, HOME_FILE, HOME_URL, HOUSE, HOUSE_BUDGET, HOUSE_LOCKED, HOUSE_TYPE, HUMAN_TYPICAL_TARGET, type HarvestCandidate, type HarvestOptions, type Hit, type HouseRule, type HouseTier, IMAGERY_TELLS, type ImageProvenance, type ImageRole, JSON_SCHEMA_VERSION, type JobContext, type JobMap, type JobPart, LABELS, LEXICAL_FEATURES, LICENCES, LIGHTNESS_CURVE, type Label, type LabelSummary, type LexicalFeatures, type LicenceEntry, type LicenceSubject, MIN_DECIDED, MIN_PARAGRAPHS, MIN_RUNS, MOOD, type MeasuredPage, type MotionDecision, type MotionKind, type MultiClient, NEGATIVE_REASSURANCE, NULL_VERSION, type NullModel, type NullRun, type Oklch, type OklchToHexOptions, PAGE_TYPES, PILL_LIMIT, PLAINER_WORDS, PREFERENCE, PROFILE, PROFILE_MEASURED, PROFILE_PARAGRAPHS, PROMO_BADGE, PROOF_NAMES, PROPOSED, PROVENANCE_SCAN_BYTES, type PageCopy, type PageHierarchy, type PageResult, type PageType, type PaletteColour, type PhotoReviewOptions, type PhotoReviewResult, type Proposal, type ProposeInput, RAMP_STEPS, READABLE_SNAPSHOT_VERSIONS, REFLEX_FONTS_1, REFLEX_FONTS_2, REGISTERS, REGISTER_BASELINE, REGISTER_FEATURES, REGISTER_IDS, RENDERED_PATHS, RENDERED_TELLS, REVEAL_LIMIT, REVIEW_PHRASES, type Ramp, type RampFromAnchorsOptions, type RampOptions, type RampStep, type ReasonInput, type Register, type RegisterCheck, type RegisterFeatures, type RegisterId, type RegisterProfile, type RenderedTell, type ResearchInput, type RestraintBudget, type RestraintReport, type RestraintSeverity, type RestraintViolation, type Rgb, type RgbaImage, SAMPLES_PER_SEGMENT, SCALE, SECTION_ROLES, SHADCN_LIMIT, SHADCN_PRIMITIVES, SHARED_PART, SIBLING_AT, SNAPSHOT_VERSION, SOURCE_KINDS, SPACE_STEPS, SPECIFICITY_COPY_TELLS, SPECIFICITY_RENDERED_TELLS, SPRING, STANDING_CLAIM, STOCK_AVATAR_HOSTS, STOCK_FIGURES, STOCK_PHOTO_HOSTS, STOCK_PHRASES, STUDY_WORDS, type SarifOptions, type SarifTell, type SectionGeometry, type SectionKind, type SectionRhythmOptions, type SectionRole, type SemanticInput, type SemanticResult, type Severity, type SeverityLevel, type SeverityOverride, type ShapeFeatures, type ShouldAnimateInput, type SignalResult, type Snapshot, type SnapshotColour, type SnapshotControl, type SnapshotEffects, type SnapshotFont, type SnapshotGradient, type SnapshotHeading, type SnapshotImage, type SnapshotMotion, type SnapshotPatch, type SnapshotSection, type SnapshotVisual, type SourceFile, type SourceKind, type SourceTell, type SpaceStep, type Specific, type SpecificKind, type Specificity, type StudyWord, type Surface, TELL_HEAVY, TRADE_NOUNS, TYPE_STEPS, TYPICAL_AT, type TailwindPreset, type TargetCheck, type Tell, type TellException, type TellRate, type Tricolon, type TypeStep, type Typicality, type UnmeasuredPage, VAGUE_WORDS, type ValidateContext, type Verdict, accentFork, addSite, apcaContrast, applyBaseline, applyHouseGate, applySeverity, auditSnapshot, briefTerms, calibrate, catalogueTable, characterReport, checkCopy, checkOrder, checkPair, checkPhotoReview, checkReason, checkRegister, checkRestraint, choicesOf, clauseDepth, closestComponents, compareCompetitor, compareDirections, compareFacts, compareToDeclared, compareToEstate, componentDistance, componentShared, componentTypicalities, componentTypicality, componentsOf, contentWords, countWords, craftTokens, createBaseline, cv, deltaEOk, deltaEOkHex, densityCss, densityTokens, designTells, documentParagraphs, emitCss, emptyEstate, estateComponentPairs, estatePairs, exitDuration, extractHtml, faceLicence, featureLabel, findClaims, findColours, findingKey, fingerprint, fingerprintDistance, fluidClamp, fluidSpace, fluidType, fontClass, formatCalibration, formatClaims, formatComparison, formatCompetitor, formatDirectionMatches, formatHex, formatReport, getRegister, globToRegExp, gradientContrast, gradientStops, groundName, harvest, hedgeCount, hedgedClose, hexToOklch, holdHouseBlocks, houseRule, hueName, hueSwatch, ignoreMatcher, inP3Gamut, inSrgbGamut, initDirection, isAiViolet, isBlurb, isCategoryLabel, isCream, isFlagged, jobWords, layoutDistance, lexicalOf, licencesJson, makeSnapshot, motionTokens, namedSources, nearest, nominalisations, normaliseFamily, nullPrompt, oklchToHex, oklchToRgb, pageCopy, paletteFromPixels, paragraphUniformity, parseBaseline, parseColour, parseCraftConfig, parseHex, participialClauses, phrasalCoordinations, propose, protectedFacts, ramp, rampFromAnchors, readSnapshot, registerBrief, registerOf, registersDoc, renderedOrder, researchPrompt, retrofitPlan, runTell, scanProvenance, scanSource, sectionRhythm, semantic, sentencesOf, severityExceptions, shapeMoves, shapeName, shapeOf, words as shapeWords, sharedParts, shouldAnimate, siblingLine, skeleton, skeletonRepeat, specificity, specifics, splitSentences, stackedConditional, stockAvatarSource, stockSource, summariseDirection, tailwindV3Preset, tailwindV4Theme, tellById, toGamut, toJson, toSarif, tradeOf, tricolons, typeFeatureTokens, typicality, unprovenClaims, validateDirection, validateHierarchy, validateJob, visibleText, visualMeasures, vocabulary, vocabularyCounts, wcagContrast, wordListsJson };
package/dist/index.js CHANGED
@@ -300,6 +300,274 @@ function checkPair(text, background) {
300
300
  };
301
301
  }
302
302
 
303
+ // src/character/color.ts
304
+ var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
305
+ function num(token, percentOf) {
306
+ const t = token.trim();
307
+ if (t.endsWith("%")) return parseFloat(t) / 100 * percentOf;
308
+ return parseFloat(t);
309
+ }
310
+ function parts(fn) {
311
+ const inner = fn.slice(fn.indexOf("(") + 1, -1).trim();
312
+ const [main, slashAlpha] = inner.split("/");
313
+ const channels = main.split(/[\s,]+/).filter(Boolean);
314
+ let alpha = 1;
315
+ if (slashAlpha !== void 0) alpha = num(slashAlpha, 1);
316
+ else if (channels.length === 4) alpha = num(channels.pop(), 1);
317
+ return { channels, alpha: Number.isFinite(alpha) ? alpha : 1 };
318
+ }
319
+ function hslToRgb(h, s, l) {
320
+ const k = (n) => (n + h / 30) % 12;
321
+ const a = s * Math.min(l, 1 - l);
322
+ const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
323
+ return { r: f4(0), g: f4(8), b: f4(4) };
324
+ }
325
+ var clamp012 = (n) => Math.min(1, Math.max(0, n));
326
+ function parseColour(raw) {
327
+ try {
328
+ if (raw.startsWith("#")) {
329
+ const hex2 = raw.slice(1);
330
+ const alpha2 = hex2.length === 8 ? parseInt(hex2.slice(6), 16) / 255 : hex2.length === 4 ? parseInt(hex2[3] + hex2[3], 16) / 255 : 1;
331
+ return { oklch: hexToOklch(raw), alpha: alpha2 };
332
+ }
333
+ const fn = raw.slice(0, raw.indexOf("(")).toLowerCase();
334
+ if (fn === "color") {
335
+ const { channels: channels2, alpha: alpha2 } = parts(raw.replace(/^color\(\s*srgb\s+/i, "srgb("));
336
+ const [r, g, b] = channels2.map((c) => clamp012(num(c, 1)));
337
+ if (![r, g, b].every(Number.isFinite)) return null;
338
+ return { oklch: hexToOklch(formatHex({ r, g, b })), alpha: alpha2 };
339
+ }
340
+ const { channels, alpha } = parts(raw);
341
+ if (channels.length < 3 || channels.some((c) => c.startsWith("var"))) return null;
342
+ if (fn === "oklch") {
343
+ const l = num(channels[0], 1);
344
+ const c = num(channels[1], 0.4);
345
+ const h = parseFloat(channels[2]);
346
+ if (![l, c].every(Number.isFinite)) return null;
347
+ return { oklch: { l, c, h: Number.isFinite(h) ? h : 0 }, alpha };
348
+ }
349
+ if (fn === "oklab") {
350
+ const l = num(channels[0], 1);
351
+ const a = num(channels[1], 0.4);
352
+ const b = num(channels[2], 0.4);
353
+ if (![l, a, b].every(Number.isFinite)) return null;
354
+ const c = Math.sqrt(a * a + b * b);
355
+ const h = c < 1e-7 ? 0 : (Math.atan2(b, a) * 180 / Math.PI % 360 + 360) % 360;
356
+ return { oklch: { l, c, h }, alpha };
357
+ }
358
+ if (fn === "rgb" || fn === "rgba") {
359
+ const [r, g, b] = channels.map((c) => clamp012(num(c, 255) / 255));
360
+ if (![r, g, b].every(Number.isFinite)) return null;
361
+ return { oklch: hexToOklch(formatHex({ r, g, b })), alpha };
362
+ }
363
+ if (fn === "hsl" || fn === "hsla") {
364
+ const h = parseFloat(channels[0]);
365
+ const s = num(channels[1], 1);
366
+ const l = num(channels[2], 1);
367
+ if (![h, s, l].every(Number.isFinite)) return null;
368
+ const rgb = hslToRgb((h % 360 + 360) % 360, clamp012(s), clamp012(l));
369
+ return { oklch: hexToOklch(formatHex(rgb)), alpha };
370
+ }
371
+ } catch {
372
+ return null;
373
+ }
374
+ return null;
375
+ }
376
+ function findColours(value) {
377
+ const out = [];
378
+ for (const m of value.matchAll(COLOUR)) {
379
+ const parsed = parseColour(m[0]);
380
+ if (parsed) out.push({ raw: m[0], ...parsed, index: m.index ?? 0 });
381
+ }
382
+ return out;
383
+ }
384
+ var AI_VIOLET = { hueMin: 268, hueMax: 312, minChroma: 0.1, minL: 0.3, maxL: 0.85 };
385
+ function isAiViolet({ l, c, h }) {
386
+ return c >= AI_VIOLET.minChroma && h >= AI_VIOLET.hueMin && h <= AI_VIOLET.hueMax && l >= AI_VIOLET.minL && l <= AI_VIOLET.maxL;
387
+ }
388
+ function isCream({ l, c, h }) {
389
+ return l >= 0.9 && l <= 0.985 && c >= 8e-3 && c <= 0.06 && h >= 55 && h <= 105;
390
+ }
391
+ function isBlueToPurple({ c, h }) {
392
+ return c >= 0.08 && h >= 240 && h <= 330;
393
+ }
394
+
395
+ // src/color/gradient.ts
396
+ var KEYWORDS = {
397
+ white: { r: 1, g: 1, b: 1, a: 1 },
398
+ black: { r: 0, g: 0, b: 0, a: 1 },
399
+ transparent: { r: 0, g: 0, b: 0, a: 0 }
400
+ };
401
+ var clamp013 = (n) => Math.min(1, Math.max(0, n));
402
+ function toRgba(raw) {
403
+ const value = raw.trim().toLowerCase();
404
+ if (KEYWORDS[value]) return KEYWORDS[value];
405
+ if (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(value)) {
406
+ const h = value.length <= 5 ? [...value.slice(1)].map((c) => c + c).join("") : value.slice(1);
407
+ const { r: r2, g: g2, b: b2 } = parseHex(h.slice(0, 6));
408
+ return { r: r2, g: g2, b: b2, a: h.length === 8 ? parseInt(h.slice(6), 16) / 255 : 1 };
409
+ }
410
+ const rgb = /^rgba?\(([^)]+)\)$/.exec(value);
411
+ if (rgb) {
412
+ const parts2 = rgb[1].split(/[\s,/]+/).filter(Boolean);
413
+ const channel = (p) => p.endsWith("%") ? parseFloat(p) / 100 : parseFloat(p) / 255;
414
+ const alpha = parts2[3] === void 0 ? 1 : parts2[3].endsWith("%") ? parseFloat(parts2[3]) / 100 : parseFloat(parts2[3]);
415
+ const [r2, g2, b2] = parts2.slice(0, 3).map(channel);
416
+ if (parts2.length >= 3 && [r2, g2, b2, alpha].every(Number.isFinite)) return { r: clamp013(r2), g: clamp013(g2), b: clamp013(b2), a: clamp013(alpha) };
417
+ return null;
418
+ }
419
+ const parsed = parseColour(value);
420
+ if (!parsed) return null;
421
+ const { r, g, b } = oklchToRgb(parsed.oklch);
422
+ return { r: clamp013(r), g: clamp013(g), b: clamp013(b), a: clamp013(parsed.alpha) };
423
+ }
424
+ var SAMPLES_PER_SEGMENT = 256;
425
+ function mix(a, b, t) {
426
+ const alpha = a.a + (b.a - a.a) * t;
427
+ if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
428
+ const ch = (x, y) => (x * a.a + (y * b.a - x * a.a) * t) / alpha;
429
+ return { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: alpha };
430
+ }
431
+ function composite(top, under) {
432
+ return {
433
+ r: top.r * top.a + under.r * (1 - top.a),
434
+ g: top.g * top.a + under.g * (1 - top.a),
435
+ b: top.b * top.a + under.b * (1 - top.a)
436
+ };
437
+ }
438
+ function gradientStops(gradient) {
439
+ return parseStops(gradient).map((s) => s.colour);
440
+ }
441
+ function parseStops(gradient) {
442
+ const inner = /gradient\(([\s\S]*)\)\s*$/.exec(gradient.trim());
443
+ if (!inner) throw new Error(`not a CSS gradient: ${gradient}`);
444
+ const parts2 = [];
445
+ let depth = 0;
446
+ let current = "";
447
+ for (const ch of inner[1]) {
448
+ if (ch === "(") depth += 1;
449
+ if (ch === ")") depth -= 1;
450
+ if (ch === "," && depth === 0) {
451
+ parts2.push(current.trim());
452
+ current = "";
453
+ continue;
454
+ }
455
+ current += ch;
456
+ }
457
+ if (current.trim()) parts2.push(current.trim());
458
+ const stops = [];
459
+ for (const part of parts2) {
460
+ if (/^(to\s|circle|ellipse|closest|farthest|at\s|from\s|in\s|-?[\d.]+(deg|turn|rad|grad)$)/i.test(part)) continue;
461
+ const positions = [];
462
+ let colour = part;
463
+ for (let i = 0; i < 2; i++) {
464
+ const m = /\s+(-?[\d.]+)(%|px|r?em|vw|vh|deg|turn|rad|grad)\s*$/i.exec(colour);
465
+ if (!m) break;
466
+ positions.unshift({ value: parseFloat(m[1]), unit: m[2].toLowerCase() });
467
+ colour = colour.slice(0, m.index);
468
+ }
469
+ colour = colour.trim();
470
+ if (!toRgba(colour)) throw new Error(`unreadable gradient stop "${colour}" in ${gradient}`);
471
+ stops.push({ colour, positions });
472
+ }
473
+ if (stops.length === 0) throw new Error(`no colour stops in ${gradient}`);
474
+ return stops;
475
+ }
476
+ function blendedSegments(stops) {
477
+ const entries = [];
478
+ for (const s of stops) {
479
+ const rgba = toRgba(s.colour);
480
+ if (s.positions.length === 0) entries.push({ rgba, pos: null });
481
+ for (const pos of s.positions) entries.push({ rgba, pos });
482
+ }
483
+ const furthest = {};
484
+ const pairs = [];
485
+ for (let i = 0; i + 1 < entries.length; i++) {
486
+ const pos = entries[i].pos;
487
+ if (pos) furthest[pos.unit] = Math.max(furthest[pos.unit] ?? -Infinity, pos.value);
488
+ const next = entries[i + 1].pos;
489
+ const hard = next !== null && furthest[next.unit] !== void 0 && next.value <= furthest[next.unit];
490
+ if (!hard) pairs.push([entries[i].rgba, entries[i + 1].rgba]);
491
+ }
492
+ return pairs;
493
+ }
494
+ function gradientContrast(text, gradient, beneath = "#ffffff") {
495
+ const under = toRgba(beneath);
496
+ if (!under || under.a < 1) throw new Error(`beneath must be an opaque colour, got ${beneath}`);
497
+ const fg = toRgba(text);
498
+ if (!fg || fg.a < 1) throw new Error(`text must be an opaque colour, got ${text}`);
499
+ const textHex = formatHex(fg);
500
+ const parsed = parseStops(gradient);
501
+ const stops = parsed.map((s) => formatHex(composite(toRgba(s.colour), under)));
502
+ let worst = stops[0];
503
+ let worstRatio = wcagContrast(textHex, worst);
504
+ for (const s of stops) {
505
+ const ratio = wcagContrast(textHex, s);
506
+ if (ratio < worstRatio) [worst, worstRatio] = [s, ratio];
507
+ }
508
+ for (const [a, b] of blendedSegments(parsed)) {
509
+ for (let k = 1; k < SAMPLES_PER_SEGMENT; k++) {
510
+ const point = formatHex(composite(mix(a, b, k / SAMPLES_PER_SEGMENT), under));
511
+ const ratio = wcagContrast(textHex, point);
512
+ if (ratio < worstRatio) [worst, worstRatio] = [point, ratio];
513
+ }
514
+ }
515
+ const wcag = wcagContrast(textHex, worst);
516
+ const lc = apcaContrast(textHex, worst);
517
+ return {
518
+ text,
519
+ gradient,
520
+ beneath,
521
+ stops,
522
+ worst,
523
+ wcag,
524
+ lc,
525
+ passesAA: wcag >= 4.5,
526
+ passesAALarge: wcag >= 3,
527
+ note: `${text} on ${gradient} over ${beneath}: worst point ${worst}, ${wcag.toFixed(2)}:1, Lc ${lc.toFixed(1)}`
528
+ };
529
+ }
530
+
531
+ // src/photo/review.ts
532
+ function checkPhotoReview(text, options = {}) {
533
+ const minScore = options.minScore ?? 0.7;
534
+ const required = options.required ?? true;
535
+ const failures = [];
536
+ const report2 = [];
537
+ const add = (ok, message) => {
538
+ report2.push(`${ok ? "ok " : "FAIL"} ${message}`);
539
+ if (!ok) failures.push(message);
540
+ };
541
+ const done = () => ({ ok: failures.length === 0, failures, report: report2 });
542
+ if (text === null) {
543
+ if (required) add(false, "no photo review: run `halide review --json`");
544
+ else report2.push("ok no photo review, and none required");
545
+ return done();
546
+ }
547
+ let parsed;
548
+ try {
549
+ parsed = JSON.parse(text);
550
+ } catch (error) {
551
+ add(false, `the photo review is not valid JSON: ${error.message}`);
552
+ return done();
553
+ }
554
+ const images = Array.isArray(parsed.images) ? parsed.images : [];
555
+ const overall = typeof parsed.score === "number" ? parsed.score : null;
556
+ if (overall === null && images.length === 0) {
557
+ add(false, "the photo review has neither a score nor an images list");
558
+ return done();
559
+ }
560
+ if (overall !== null) add(overall >= minScore, `overall score ${overall.toFixed(2)} against a minimum of ${minScore.toFixed(2)}`);
561
+ const below = images.filter((i) => typeof i.score === "number" && i.score < minScore);
562
+ if (below.length > 0) {
563
+ const named = below.slice(0, 5).map((i) => `${i.file ?? i.id ?? "?"} (${i.score.toFixed(2)})`).join(", ");
564
+ add(false, `${below.length} of ${images.length} image(s) below ${minScore.toFixed(2)}: ${named}`);
565
+ } else if (images.length > 0) {
566
+ add(true, `all ${images.length} image(s) at or above ${minScore.toFixed(2)}`);
567
+ }
568
+ return done();
569
+ }
570
+
303
571
  // src/color/semantic.ts
304
572
  function accentFork(rampSteps, on, options = {}) {
305
573
  const minWcag = options.minWcag ?? 4.5;
@@ -2547,98 +2815,6 @@ ${filler(520, 11)}`)]
2547
2815
  }
2548
2816
  ];
2549
2817
 
2550
- // src/character/color.ts
2551
- var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
2552
- function num(token, percentOf) {
2553
- const t = token.trim();
2554
- if (t.endsWith("%")) return parseFloat(t) / 100 * percentOf;
2555
- return parseFloat(t);
2556
- }
2557
- function parts(fn) {
2558
- const inner = fn.slice(fn.indexOf("(") + 1, -1).trim();
2559
- const [main, slashAlpha] = inner.split("/");
2560
- const channels = main.split(/[\s,]+/).filter(Boolean);
2561
- let alpha = 1;
2562
- if (slashAlpha !== void 0) alpha = num(slashAlpha, 1);
2563
- else if (channels.length === 4) alpha = num(channels.pop(), 1);
2564
- return { channels, alpha: Number.isFinite(alpha) ? alpha : 1 };
2565
- }
2566
- function hslToRgb(h, s, l) {
2567
- const k = (n) => (n + h / 30) % 12;
2568
- const a = s * Math.min(l, 1 - l);
2569
- const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
2570
- return { r: f4(0), g: f4(8), b: f4(4) };
2571
- }
2572
- var clamp012 = (n) => Math.min(1, Math.max(0, n));
2573
- function parseColour(raw) {
2574
- try {
2575
- if (raw.startsWith("#")) {
2576
- const hex2 = raw.slice(1);
2577
- const alpha2 = hex2.length === 8 ? parseInt(hex2.slice(6), 16) / 255 : hex2.length === 4 ? parseInt(hex2[3] + hex2[3], 16) / 255 : 1;
2578
- return { oklch: hexToOklch(raw), alpha: alpha2 };
2579
- }
2580
- const fn = raw.slice(0, raw.indexOf("(")).toLowerCase();
2581
- if (fn === "color") {
2582
- const { channels: channels2, alpha: alpha2 } = parts(raw.replace(/^color\(\s*srgb\s+/i, "srgb("));
2583
- const [r, g, b] = channels2.map((c) => clamp012(num(c, 1)));
2584
- if (![r, g, b].every(Number.isFinite)) return null;
2585
- return { oklch: hexToOklch(formatHex({ r, g, b })), alpha: alpha2 };
2586
- }
2587
- const { channels, alpha } = parts(raw);
2588
- if (channels.length < 3 || channels.some((c) => c.startsWith("var"))) return null;
2589
- if (fn === "oklch") {
2590
- const l = num(channels[0], 1);
2591
- const c = num(channels[1], 0.4);
2592
- const h = parseFloat(channels[2]);
2593
- if (![l, c].every(Number.isFinite)) return null;
2594
- return { oklch: { l, c, h: Number.isFinite(h) ? h : 0 }, alpha };
2595
- }
2596
- if (fn === "oklab") {
2597
- const l = num(channels[0], 1);
2598
- const a = num(channels[1], 0.4);
2599
- const b = num(channels[2], 0.4);
2600
- if (![l, a, b].every(Number.isFinite)) return null;
2601
- const c = Math.sqrt(a * a + b * b);
2602
- const h = c < 1e-7 ? 0 : (Math.atan2(b, a) * 180 / Math.PI % 360 + 360) % 360;
2603
- return { oklch: { l, c, h }, alpha };
2604
- }
2605
- if (fn === "rgb" || fn === "rgba") {
2606
- const [r, g, b] = channels.map((c) => clamp012(num(c, 255) / 255));
2607
- if (![r, g, b].every(Number.isFinite)) return null;
2608
- return { oklch: hexToOklch(formatHex({ r, g, b })), alpha };
2609
- }
2610
- if (fn === "hsl" || fn === "hsla") {
2611
- const h = parseFloat(channels[0]);
2612
- const s = num(channels[1], 1);
2613
- const l = num(channels[2], 1);
2614
- if (![h, s, l].every(Number.isFinite)) return null;
2615
- const rgb = hslToRgb((h % 360 + 360) % 360, clamp012(s), clamp012(l));
2616
- return { oklch: hexToOklch(formatHex(rgb)), alpha };
2617
- }
2618
- } catch {
2619
- return null;
2620
- }
2621
- return null;
2622
- }
2623
- function findColours(value) {
2624
- const out = [];
2625
- for (const m of value.matchAll(COLOUR)) {
2626
- const parsed = parseColour(m[0]);
2627
- if (parsed) out.push({ raw: m[0], ...parsed, index: m.index ?? 0 });
2628
- }
2629
- return out;
2630
- }
2631
- var AI_VIOLET = { hueMin: 268, hueMax: 312, minChroma: 0.1, minL: 0.3, maxL: 0.85 };
2632
- function isAiViolet({ l, c, h }) {
2633
- return c >= AI_VIOLET.minChroma && h >= AI_VIOLET.hueMin && h <= AI_VIOLET.hueMax && l >= AI_VIOLET.minL && l <= AI_VIOLET.maxL;
2634
- }
2635
- function isCream({ l, c, h }) {
2636
- return l >= 0.9 && l <= 0.985 && c >= 8e-3 && c <= 0.06 && h >= 55 && h <= 105;
2637
- }
2638
- function isBlueToPurple({ c, h }) {
2639
- return c >= 0.08 && h >= 240 && h <= 330;
2640
- }
2641
-
2642
2818
  // src/character/tells/source.ts
2643
2819
  function baseClass(cls) {
2644
2820
  let depth = 0;
@@ -9092,6 +9268,7 @@ export {
9092
9268
  RENDERED_TELLS,
9093
9269
  REVEAL_LIMIT,
9094
9270
  REVIEW_PHRASES,
9271
+ SAMPLES_PER_SEGMENT,
9095
9272
  SCALE,
9096
9273
  SECTION_ROLES,
9097
9274
  SHADCN_LIMIT,
@@ -9129,6 +9306,7 @@ export {
9129
9306
  checkCopy,
9130
9307
  checkOrder,
9131
9308
  checkPair,
9309
+ checkPhotoReview,
9132
9310
  checkReason,
9133
9311
  checkRegister,
9134
9312
  checkRestraint,
@@ -9182,6 +9360,8 @@ export {
9182
9360
  formatReport,
9183
9361
  getRegister,
9184
9362
  globToRegExp,
9363
+ gradientContrast,
9364
+ gradientStops,
9185
9365
  groundName,
9186
9366
  harvest,
9187
9367
  hedgeCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/craft",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "private": false,
5
5
  "description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
6
6
  "license": "Apache-2.0",