@domandigital/craft 0.18.0 → 0.18.2
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 +12 -0
- package/CHARACTER.md +16 -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 +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @domandigital/craft
|
|
2
2
|
|
|
3
|
+
## 0.18.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 61387cf: CHARACTER.md: a dated calibration entry, "Direction boards, 2026-10-09: fit before distance", with the rules a direction board follows after the DD boards optimised for distance from the null set instead of fit. Docs only.
|
|
8
|
+
|
|
9
|
+
## 0.18.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 0.18.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/CHARACTER.md
CHANGED
|
@@ -912,6 +912,22 @@ What moved: the estate is still atypical of what the model builds for its own br
|
|
|
912
912
|
|
|
913
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
914
|
|
|
915
|
+
### Direction boards, 2026-10-09: fit before distance
|
|
916
|
+
|
|
917
|
+
The first visual-direction boards for Doman Digital's own site were built in the hour after `craft null build` finished, with the harvest in front of the session. Every one of the seven choices on all three boards was argued from what the null set did (17 of 20 Fraunces, 20 of 20 cream, 20 of 20 terracotta) and what the estate already had. The reasons were real and each cited a source, and the boards were still wrong in the way that matters: placeholder boxes where the client's interface should have been, a ledger explained in a block beside the proof instead of on it, a legend whose line types only read after their labels, and three headlines where one constant line was needed. A reviewer reading this document caught it in one pass: craft asks for fit with a reason and says never to reward strangeness, and the boards had optimised for distance.
|
|
918
|
+
|
|
919
|
+
What the tool did and did not do. `null build`, `tells harvest` and the estate register gave the session a precise picture of the default and nothing that said "show the work". `direction validate` would have passed a file whose seven choices cite only `reference`, `estate` and the null, and whose sources hold no `product` or `photo` at all. Those are the two gaps, tracked as a warning pair in Linear (DOM, 9 October).
|
|
920
|
+
|
|
921
|
+
Rules for a direction board, recorded so the next one starts here:
|
|
922
|
+
|
|
923
|
+
- **The null is a floor, never the argument.** A choice clears the null set and the estate; its reason comes from the client's world. A reason of the form "the null never picks this" is not a reason.
|
|
924
|
+
- **One line of copy, one image, every board.** The headline, subline, routes and the work shown are identical across directions, so the look is the only variable. The headline is checked against every null page before it goes on a board.
|
|
925
|
+
- **The work is on the board, large, and real.** A placeholder where the interface should be is a board about a site that does not exist. Use the client's own published interface with its period, and put the responsibility labels on the work: this is run, this is built and connected, this is yours. The ledger explained beside the proof is the weaker form of the ledger shown on it.
|
|
926
|
+
- **A legend is learned where it points.** Line types, colours and marks carry meaning only at the moment they point at something; a key read first is a key forgotten.
|
|
927
|
+
- **A generated concept image is a composition study.** It can show a layout worth adopting. It is never evidence, never ships, and its headline is checked like any other: the one produced in this review had the null set's own shape.
|
|
928
|
+
|
|
929
|
+
The board in question is the DD redesign's Stage 05 artefact (DOM-397, CRO-21); v1.1 applied all five.
|
|
930
|
+
|
|
915
931
|
## The catalogue
|
|
916
932
|
|
|
917
933
|
Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
|
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.2",
|
|
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",
|
|
@@ -56,9 +56,10 @@
|
|
|
56
56
|
"dist"
|
|
57
57
|
],
|
|
58
58
|
"devDependencies": {
|
|
59
|
+
"@types/node": "^20.19.43",
|
|
59
60
|
"tsup": "^8.3.5",
|
|
60
61
|
"typescript": "^5.6.3",
|
|
61
|
-
"vitest": "^
|
|
62
|
+
"vitest": "^4.0.0"
|
|
62
63
|
},
|
|
63
64
|
"engines": {
|
|
64
65
|
"node": ">=22.12"
|