@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 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. 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.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": "^2.1.9"
62
+ "vitest": "^4.0.0"
62
63
  },
63
64
  "engines": {
64
65
  "node": ">=22.12"