@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 +6 -0
- package/README.md +2 -1
- package/dist/index.cjs +41 -8
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +41 -8
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
748
|
-
|
|
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
|
|
762
|
-
const stops =
|
|
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 (
|
|
766
|
-
|
|
767
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
458
|
-
|
|
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
|
|
472
|
-
const stops =
|
|
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 (
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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",
|