@domandigital/craft 0.18.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,11 @@
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
+
3
9
  ## 0.18.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -213,7 +213,8 @@ restraint rule) and art direction (`craft direction`, `craft estate`).
213
213
  against the worst point. The worst point is sampled along every segment, not
214
214
  only at the stops: mid-grey text on a black-to-white gradient clears 4.5:1
215
215
  against both ends and is about 1:1 in the middle, which the drift-guards
216
- version passed. WCAG defines no method for gradients, so this is craft's
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
217
218
  rule, not a conformance claim.
218
219
  - **`checkPhotoReview(text, { minScore, required })`**: gates a build on the
219
220
  report `halide review --json` wrote. It takes the report's text (null when
package/dist/index.cjs CHANGED
@@ -726,6 +726,9 @@ function composite(top, under) {
726
726
  };
727
727
  }
728
728
  function gradientStops(gradient) {
729
+ return parseStops(gradient).map((s) => s.colour);
730
+ }
731
+ function parseStops(gradient) {
729
732
  const inner = /gradient\(([\s\S]*)\)\s*$/.exec(gradient.trim());
730
733
  if (!inner) throw new Error(`not a CSS gradient: ${gradient}`);
731
734
  const parts2 = [];
@@ -744,27 +747,57 @@ function gradientStops(gradient) {
744
747
  if (current.trim()) parts2.push(current.trim());
745
748
  const stops = [];
746
749
  for (const part of parts2) {
747
- const colour = part.replace(/(\s+-?[\d.]+(%|px|r?em|deg|turn|vw|vh))+\s*$/i, "").trim();
748
- if (/^(to\s|circle|ellipse|closest|farthest|at\s|from\s|in\s|-?[\d.]+(deg|turn|rad|grad)$)/i.test(colour)) continue;
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();
749
760
  if (!toRgba(colour)) throw new Error(`unreadable gradient stop "${colour}" in ${gradient}`);
750
- stops.push(colour);
761
+ stops.push({ colour, positions });
751
762
  }
752
763
  if (stops.length === 0) throw new Error(`no colour stops in ${gradient}`);
753
764
  return stops;
754
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
+ }
755
784
  function gradientContrast(text, gradient, beneath = "#ffffff") {
756
785
  const under = toRgba(beneath);
757
786
  if (!under || under.a < 1) throw new Error(`beneath must be an opaque colour, got ${beneath}`);
758
787
  const fg = toRgba(text);
759
788
  if (!fg || fg.a < 1) throw new Error(`text must be an opaque colour, got ${text}`);
760
789
  const textHex = formatHex(fg);
761
- const raw = gradientStops(gradient).map((s) => toRgba(s));
762
- const stops = raw.map((s) => formatHex(composite(s, under)));
790
+ const parsed = parseStops(gradient);
791
+ const stops = parsed.map((s) => formatHex(composite(toRgba(s.colour), under)));
763
792
  let worst = stops[0];
764
793
  let worstRatio = wcagContrast(textHex, worst);
765
- for (let i = 0; i + 1 < raw.length; i++) {
766
- for (let k = 1; k <= SAMPLES_PER_SEGMENT; k++) {
767
- const point = formatHex(composite(mix(raw[i], raw[i + 1], k / SAMPLES_PER_SEGMENT), under));
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));
768
801
  const ratio = wcagContrast(textHex, point);
769
802
  if (ratio < worstRatio) [worst, worstRatio] = [point, ratio];
770
803
  }
package/dist/index.d.cts CHANGED
@@ -178,7 +178,8 @@ declare function checkPair(text: string, background: string): ContrastCheck;
178
178
  * the backdrop's luminance comes closest to the text's: mid-grey text on a
179
179
  * black-to-white gradient clears 4.5:1 against both ends and about 1:1 in the
180
180
  * middle. So each segment is sampled, interpolated as browsers paint legacy
181
- * colours (sRGB, premultiplied alpha). Pure.
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.
182
183
  */
183
184
  /** Samples per segment between two stops: under 1/255 of a channel apart for any pair. */
184
185
  declare const SAMPLES_PER_SEGMENT = 256;
package/dist/index.d.ts CHANGED
@@ -178,7 +178,8 @@ declare function checkPair(text: string, background: string): ContrastCheck;
178
178
  * the backdrop's luminance comes closest to the text's: mid-grey text on a
179
179
  * black-to-white gradient clears 4.5:1 against both ends and about 1:1 in the
180
180
  * middle. So each segment is sampled, interpolated as browsers paint legacy
181
- * colours (sRGB, premultiplied alpha). Pure.
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.
182
183
  */
183
184
  /** Samples per segment between two stops: under 1/255 of a channel apart for any pair. */
184
185
  declare const SAMPLES_PER_SEGMENT = 256;
package/dist/index.js CHANGED
@@ -436,6 +436,9 @@ function composite(top, under) {
436
436
  };
437
437
  }
438
438
  function gradientStops(gradient) {
439
+ return parseStops(gradient).map((s) => s.colour);
440
+ }
441
+ function parseStops(gradient) {
439
442
  const inner = /gradient\(([\s\S]*)\)\s*$/.exec(gradient.trim());
440
443
  if (!inner) throw new Error(`not a CSS gradient: ${gradient}`);
441
444
  const parts2 = [];
@@ -454,27 +457,57 @@ function gradientStops(gradient) {
454
457
  if (current.trim()) parts2.push(current.trim());
455
458
  const stops = [];
456
459
  for (const part of parts2) {
457
- const colour = part.replace(/(\s+-?[\d.]+(%|px|r?em|deg|turn|vw|vh))+\s*$/i, "").trim();
458
- if (/^(to\s|circle|ellipse|closest|farthest|at\s|from\s|in\s|-?[\d.]+(deg|turn|rad|grad)$)/i.test(colour)) continue;
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();
459
470
  if (!toRgba(colour)) throw new Error(`unreadable gradient stop "${colour}" in ${gradient}`);
460
- stops.push(colour);
471
+ stops.push({ colour, positions });
461
472
  }
462
473
  if (stops.length === 0) throw new Error(`no colour stops in ${gradient}`);
463
474
  return stops;
464
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
+ }
465
494
  function gradientContrast(text, gradient, beneath = "#ffffff") {
466
495
  const under = toRgba(beneath);
467
496
  if (!under || under.a < 1) throw new Error(`beneath must be an opaque colour, got ${beneath}`);
468
497
  const fg = toRgba(text);
469
498
  if (!fg || fg.a < 1) throw new Error(`text must be an opaque colour, got ${text}`);
470
499
  const textHex = formatHex(fg);
471
- const raw = gradientStops(gradient).map((s) => toRgba(s));
472
- const stops = raw.map((s) => formatHex(composite(s, under)));
500
+ const parsed = parseStops(gradient);
501
+ const stops = parsed.map((s) => formatHex(composite(toRgba(s.colour), under)));
473
502
  let worst = stops[0];
474
503
  let worstRatio = wcagContrast(textHex, worst);
475
- for (let i = 0; i + 1 < raw.length; i++) {
476
- for (let k = 1; k <= SAMPLES_PER_SEGMENT; k++) {
477
- const point = formatHex(composite(mix(raw[i], raw[i + 1], k / SAMPLES_PER_SEGMENT), under));
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));
478
511
  const ratio = wcagContrast(textHex, point);
479
512
  if (ratio < worstRatio) [worst, worstRatio] = [point, ratio];
480
513
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/craft",
3
- "version": "0.18.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",