@domandigital/craft 0.23.1 → 0.24.0

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +17 -5
  2. package/CHARACTER.md +98 -97
  3. package/COPY.md +3 -2
  4. package/PRINCIPLES.md +5 -5
  5. package/README.md +8 -8
  6. package/REGISTERS.md +3 -3
  7. package/design-review.schema.json +1 -1
  8. package/dist/{audit-DESBVHQJ.js → audit-Q73WGKMS.js} +3 -3
  9. package/dist/audit.cjs +88 -23
  10. package/dist/audit.js +88 -23
  11. package/dist/{chunk-GA3AJN67.js → chunk-2CCL5CO7.js} +17 -15
  12. package/dist/{chunk-M5QSMIT6.js → chunk-4HPAMB52.js} +4 -4
  13. package/dist/{chunk-LRWKKJIW.js → chunk-6TOY3FAT.js} +5 -5
  14. package/dist/{chunk-GDDBCHQR.js → chunk-GJZ4Y5PT.js} +5 -5
  15. package/dist/{chunk-ZV3FPQPM.js → chunk-OA2MBUJJ.js} +1 -1
  16. package/dist/{chunk-V3GJSISR.js → chunk-OOT4QLO3.js} +14 -14
  17. package/dist/{chunk-7DPB5G4G.js → chunk-PXJKYMXF.js} +2 -2
  18. package/dist/{chunk-EHMEMSM2.js → chunk-R4KPE4G5.js} +72 -9
  19. package/dist/{cli-FCFRPPHZ.js → cli-766R2BUR.js} +5 -5
  20. package/dist/{cli-3PFEOEQI.js → cli-CE7ENNNG.js} +6 -6
  21. package/dist/{cli-SOH4QMMW.js → cli-DTJOOTNM.js} +3 -3
  22. package/dist/{cli-36YQA6BP.js → cli-EQJITFVE.js} +4 -4
  23. package/dist/{cli-RXXGSKD6.js → cli-SD2BJSGP.js} +6 -6
  24. package/dist/{cli-TP5ZMJOL.js → cli-TBBXUECM.js} +5 -5
  25. package/dist/{cli-YOSHHLIV.js → cli-TV2IJ5TV.js} +2 -3
  26. package/dist/{cli-7IBKMUMM.js → cli-U3SPFILJ.js} +9 -9
  27. package/dist/cli.js +3 -3
  28. package/dist/index.cjs +89 -25
  29. package/dist/index.d.cts +10 -10
  30. package/dist/index.d.ts +10 -10
  31. package/dist/index.js +89 -25
  32. package/dist/{viewports-cli-ET664I2Y.js → viewports-cli-SOOHAD5D.js} +3 -3
  33. package/package.json +1 -1
  34. package/word-lists.json +1 -1
package/README.md CHANGED
@@ -121,11 +121,11 @@ no change is needed. Evidence only reported by an audit or a stakeholder cannot
121
121
  make a problem observed, and a width nobody looked at stays unknown. See
122
122
  [Reviewing a proposed change](./CHARACTER.md#reviewing-a-proposed-change).
123
123
 
124
- A reviewer's own review is kept whole in the same shape, in the repository's
125
- reviewer corpus (`packages/craft/reviews/`, left out of the npm package), with
126
- what was done with each note and the principle it teaches. Eunice Ordor's
127
- review of the DD site is the first. See
128
- [Eunice's review of the DD site](./CHARACTER.md#eunices-review-of-the-dd-site-2026-10-19).
124
+ A reviewer's own review is kept whole in the same shape, in a private
125
+ reviewer corpus kept outside this repository, with what was done with each
126
+ note and the principle it teaches. A studio reviewer's review of the studio's
127
+ own site is the first. See
128
+ [Review of the studio's own site](./CHARACTER.md#review-of-the-studios-own-site-2026-10-19).
129
129
 
130
130
  `--snapshot` accepts saved files and preview or production URLs. Drift fails
131
131
  with `--strict`; declared estate comparisons stay advisory. Version 1 files
@@ -378,8 +378,8 @@ the one loaded page in place:
378
378
  - **On a landscape phone (844 x 390):** header and fixed layers leave at least
379
379
  half the height.
380
380
  - **Page length (phone screens):** page height at 375 x 667 over 667, to the
381
- footer (lazy images loaded). A phone page has a length budget (the Bellerose
382
- home page ran 12.7 screens against a target of 6.5). Set one per route or
381
+ footer (lazy images loaded). A phone page has a length budget (one client home
382
+ page ran 12.7 screens against a target of 6.5). Set one per route or
383
383
  route pattern with `screens`; a route over its budget fails the `screens`
384
384
  check, and with no budget the count is only reported. Keys are a route
385
385
  (`/`), a pattern (`/areas/[town]`), a prefix (`/services/*`, the route and
@@ -454,4 +454,4 @@ select a better design automatically. Protected staging reviews may reserve
454
454
  explicitly marked publication assets while reporting `publicationReady: false`.
455
455
  See CHARACTER.md, “Context and learning from a later result”.
456
456
 
457
- Opening photographs are selected as a rendered sequence: compare their quality and crop before filling a slide count or covering every service. See [Select photographs for their position in the sequence](./CHARACTER.md#select-photographs-for-their-position-in-the-sequence) for the Bellerose follow-up and its limits.
457
+ Opening photographs are selected as a rendered sequence: compare their quality and crop before filling a slide count or covering every service. See [Select photographs for their position in the sequence](./CHARACTER.md#select-photographs-for-their-position-in-the-sequence) for the follow-up on one client's opening photographs and its limits.
package/REGISTERS.md CHANGED
@@ -29,8 +29,8 @@ nominalisations, pronoun openers, paired phrases, contractions, person and
29
29
  passive voice. Names, figures and specifics are left out, because they say
30
30
  what a text is about, not how it is written.
31
31
 
32
- It needs five paragraphs of two to eight sentences each. In
33
- `calibration/copy-shape/report-registers.md`, single passages from different
32
+ It needs five paragraphs of two to eight sentences each. In the register
33
+ separation report (kept in the private craft corpus), single passages from different
34
34
  registers overlap too much to place (26 of 258 pair tests separate), while
35
35
  five-paragraph averages separate every register pair (78 of 258). Anything
36
36
  shorter gets the brief, not a measurement.
@@ -157,7 +157,7 @@ Generated from `src/register/registers.ts` by
157
157
  **Sources:**
158
158
 
159
159
  - house judgement: No public style authority exists for premium-brand prose. These directives are house judgement, to be revised as the estate grows.
160
- - [Register separation, 2026-09-29](calibration/copy-shape/report-registers.md): Literary prose (Gutenberg) carries about twice the comma density of the other human registers, the one feature that separated it from all four. Longer, jointed sentences are normal here.
160
+ - Register separation, 2026-09-29 (report in the private craft corpus): Literary prose (Gutenberg) carries about twice the comma density of the other human registers, the one feature that separated it from all four. Longer, jointed sentences are normal here.
161
161
 
162
162
  **Always:** Every register keeps the COPY.md blocking tier and the proof rule. Draft first, then run `craft copy <file>` on the draft.
163
163
  <!-- craft:registers:end -->
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "source": {
34
34
  "type": "object",
35
- "description": "Where the review came from, when it is a reviewer's own record (a reviewer corpus under reviews/).",
35
+ "description": "Where the review came from, when it is a reviewer's own record (a reviewer corpus, such as the one kept in the private craft corpus).",
36
36
  "required": [
37
37
  "tool"
38
38
  ],
@@ -9,10 +9,10 @@ import {
9
9
  snapshotPage,
10
10
  snapshotUrl,
11
11
  snapshotUrls
12
- } from "./chunk-ZV3FPQPM.js";
12
+ } from "./chunk-OA2MBUJJ.js";
13
13
  import "./chunk-JYNXGCYZ.js";
14
- import "./chunk-GA3AJN67.js";
15
- import "./chunk-EHMEMSM2.js";
14
+ import "./chunk-2CCL5CO7.js";
15
+ import "./chunk-R4KPE4G5.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
18
18
  PROVENANCE_BUDGET_MS,
package/dist/audit.cjs CHANGED
@@ -358,6 +358,29 @@ var PARALLEL_PAIR = [
358
358
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
359
359
  /^[ \t]*(?:#{1,6}[ \t]+)?[A-Z][\w'’-]*(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}(?:,[ \t]+(?!(?:and|or)\b)[\w'’-]+(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}){2,3}\.[ \t]*$/m
360
360
  ];
361
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
362
+ var ABSOLUTE_NEGATION = new RegExp(
363
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
364
+ "im"
365
+ );
366
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
367
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
368
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
369
+ function notAbsoluteFragment(block, match, index) {
370
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
371
+ let words = rest;
372
+ const lead = head.toLowerCase();
373
+ if (lead === "no" && words[0]?.toLowerCase() === "one") words = words.slice(1);
374
+ else if (lead === "no" && NO_IDIOM.test(words[0] ?? "")) return true;
375
+ if (words.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
376
+ if (lead === "never" && !PARTICIPLE.test(words[0] ?? "")) return true;
377
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words[0] ?? "")) return true;
378
+ const sentence = lineAround(block.text, index);
379
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
380
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
381
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
382
+ return emptyState(block, match, index);
383
+ }
361
384
  var alreadyBlocked = (block, match, index) => {
362
385
  const before = block.text.slice(0, index);
363
386
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -484,7 +507,7 @@ var COPY_TELLS = [
484
507
  fixtures: {
485
508
  flag: [
486
509
  f("content/home.md", "Your options, in plain English."),
487
- // Eunice Ordor's draft lines, Pastel 986559 #12 and #13 (her diagnosis taken, her wording not).
510
+ // A reviewer's draft lines, Pastel 986559 #12 and #13 (the diagnosis taken, the wording not).
488
511
  f("content/clinics.md", "Tell us what's happening and we'll say plainly what we would take on."),
489
512
  f("content/pricing.md", "DD's payment terms, plainly stated."),
490
513
  f("content/clinics.md", "We explain our findings in plain terms."),
@@ -610,7 +633,7 @@ var COPY_TELLS = [
610
633
  fixtures: {
611
634
  flag: [
612
635
  // From the portal's seeded prospect follow-ups, 2026-10-19.
613
- f("emails/followup.md", "Alright Dave, it's Dmitri. No pressure, have a look when you get a min."),
636
+ f("emails/followup.md", "Alright Dave, it's Sam. No pressure, have a look when you get a min."),
614
637
  f("emails/followup.md", "All good if you're not fussed mate, I'll leave it there."),
615
638
  f("emails/followup.md", "Carlo, built the shop a proper site."),
616
639
  f("content/contact.md", "No worries, we'll get you sorted."),
@@ -623,11 +646,11 @@ var COPY_TELLS = [
623
646
  f("content/home.md", "Proper planning saves a second visit."),
624
647
  f("content/certificates.md", "Download a sorted list of certificates, sorted by date."),
625
648
  f("content/directions.md", "At the junction, take the right then left turn."),
626
- // Chair and Blade's own approved line, and "mate" as a noun.
649
+ // A barber client's own approved line, and "mate" as a noun.
627
650
  f("content/home.md", "Every cut starts with a proper chat about what you actually want."),
628
651
  f("content/home.md", "It feels like getting a cut from a mate who knows what he is doing."),
629
652
  f("emails/quote.md", "Have a look at the quote and ring me if Thursday does not work."),
630
- f("content/contact.md", "Give us a ring on 01280 700123. We take a look at the fault before quoting."),
653
+ f("content/contact.md", "Give us a ring on 01632 960123. We take a look at the fault before quoting."),
631
654
  f("content/home.md", "Built to last, and made for older houses.")
632
655
  ]
633
656
  }
@@ -639,14 +662,14 @@ var COPY_TELLS = [
639
662
  severity: "warn",
640
663
  surface: "copy",
641
664
  why: "'What this is and is not', 'we have not yet built', 'our experience so far is one practice', 'what we would and wouldn't take on': a model scopes a business by confessing its limits. It reads as small print, and it turns a buyer away before they make contact.",
642
- fix: "Scope by being precise about what is true, and leave the door open: 'Built and run by us for Sensphere, a practice whose clinicians work across several sites.' Keep contractual facts (eligibility, who runs what) and say them as what the buyer gets.",
665
+ fix: "Scope by being precise about what is true, and leave the door open: 'Built and run by us for a children's therapy practice whose clinicians work across several sites.' Keep contractual facts (eligibility, who runs what) and say them as what the buyer gets.",
643
666
  detect: phrases(CONFESSION, quoted),
644
667
  fixtures: {
645
668
  flag: [
646
- // Each one from the DD site, as Eunice Ordor flagged it (Pastel 986559 #8, #12, #13).
669
+ // Each one from the studio's own site, as a reviewer flagged it (Pastel 986559 #8, #12, #13).
647
670
  f("content/clinics.md", "What this is and is not: a practice portal we built and run for one practice."),
648
671
  f("content/clinics.md", "We have not yet built and run the digital systems of a practice with several clinicians."),
649
- f("content/home.md", "Including the client portal we run for Sensphere. Our clinic experience so far is one practice."),
672
+ f("content/home.md", "Including the client portal we run for a therapy practice. Our clinic experience so far is one practice."),
650
673
  f("content/clinics.md", "Tell us what's happening and we'll say what we would and wouldn't take on."),
651
674
  f("content/clinics.md", "We will say what carries over from this work and what we would need to learn first."),
652
675
  f("content/clinics.md", "We will tell you where our experience applies directly and where it is close."),
@@ -655,8 +678,8 @@ var COPY_TELLS = [
655
678
  f("content/mobile.md", "Mobile is our newest line, so we tell you what we have and haven't done.")
656
679
  ],
657
680
  pass: [
658
- f("content/clinics.md", "Built and run by us for Sensphere, a growing practice whose clinicians work remotely."),
659
- f("content/home.md", "The client portal we run for Sensphere."),
681
+ f("content/clinics.md", "Built and run by us for a therapy practice, a growing one whose clinicians work remotely."),
682
+ f("content/home.md", "The client portal we run for a children's therapy practice."),
660
683
  // Contractual facts, said as they are.
661
684
  f("content/pricing.md", "Pay monthly is open to UK residents aged 18 and over, subject to status."),
662
685
  f("content/pricing.md", "Your monthly payments do not buy the site; the buy-out price does."),
@@ -896,6 +919,46 @@ var COPY_TELLS = [
896
919
  ]
897
920
  }
898
921
  },
922
+ {
923
+ id: "absolute-negation",
924
+ name: "Absolute negation fragment",
925
+ generation: 2,
926
+ severity: "warn",
927
+ surface: "copy",
928
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
929
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
930
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
931
+ fixtures: {
932
+ flag: [
933
+ // From a 92EL deck that passed the gate, October 2026.
934
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
935
+ f("content/deck.md", "Pay per user per month; no servers."),
936
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
937
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
938
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
939
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
940
+ ],
941
+ pass: [
942
+ f("content/report.md", "Players never open the lists."),
943
+ f("content/report.md", "Nothing is copied by hand."),
944
+ f("content/report.md", "It is not tested at national scale."),
945
+ f("content/report.md", "Never share your password with anyone."),
946
+ f("content/report.md", "No one knows the password but the club secretary."),
947
+ // A Harvard citation with no date.
948
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
949
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
950
+ // A form answer option.
951
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
952
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
953
+ f("content/form.md", "No, the player is 18 or over."),
954
+ f("content/home.md", "We no longer post paper forms."),
955
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
956
+ // The blocking tells report these.
957
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
958
+ f("app/list.tsx", `export const empty = "No results found.";`)
959
+ ]
960
+ }
961
+ },
899
962
  {
900
963
  id: "where-x-meets-y",
901
964
  name: "'Where X meets Y'",
@@ -2541,7 +2604,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2541
2604
  generation: 1,
2542
2605
  severity: "warn",
2543
2606
  surface: "source",
2544
- why: "When every block fades up on scroll, the motion stops meaning anything and the page feels slow. MMM had 70, DD 40, sen-sphere 27.",
2607
+ why: "When every block fades up on scroll, the motion stops meaning anything and the page feels slow. One salon client's site had 70, the studio's own 40, a therapy practice's 27.",
2545
2608
  fix: `Keep one gated signature moment per page (STANDARD section 7) and let the rest arrive still. The limit here is ${REVEAL_LIMIT} per scan.`,
2546
2609
  detect: revealHits,
2547
2610
  fixtures: {
@@ -2557,7 +2620,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2557
2620
  generation: 1,
2558
2621
  severity: "warn",
2559
2622
  surface: "source",
2560
- why: "rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language.",
2623
+ why: "rounded-full on every button, badge and tag is the framework's friendliest default. One salon client's site had 162, the studio's own 125. A shape used everywhere is not a shape language.",
2561
2624
  fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
2562
2625
  detect: pillHits,
2563
2626
  fixtures: {
@@ -2572,7 +2635,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2572
2635
  generation: 1,
2573
2636
  severity: "warn",
2574
2637
  surface: "source",
2575
- why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets.",
2638
+ why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. One client's journal site ships a full set under its own book, vine and petal assets.",
2576
2639
  fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
2577
2640
  detect: shadcnHits,
2578
2641
  fixtures: {
@@ -2630,7 +2693,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2630
2693
  f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
2631
2694
  f3("app/globals.css", `.hero h1 { font-style: italic; }`)
2632
2695
  ],
2633
- pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
2696
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Kettering</h1>
2634
2697
  <p><em>Walk-ins</em> welcome.</p>`)]
2635
2698
  }
2636
2699
  },
@@ -2646,11 +2709,11 @@ const inter = Inter({ subsets: ["latin"] });`),
2646
2709
  fixtures: {
2647
2710
  flag: [
2648
2711
  f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
2649
- <h1>Nails in Brackley</h1>`),
2712
+ <h1>Nails in Kettering</h1>`),
2650
2713
  f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
2651
2714
  <h1>Nails</h1>`)
2652
2715
  ],
2653
- pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
2716
+ pass: [f3("components/Hero.tsx", `<h1>Nails in Kettering</h1>
2654
2717
  <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
2655
2718
  }
2656
2719
  },
@@ -2665,7 +2728,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2665
2728
  detect: heroCtaPairHits,
2666
2729
  fixtures: {
2667
2730
  flag: [
2668
- // Eunice Ordor's #5 and #36 (Pastel 986559): the DD site's own hero and process close.
2731
+ // A reviewer's #5 and #36 (Pastel 986559): the studio's own hero and process close.
2669
2732
  f3(
2670
2733
  "src/components/Hero.astro",
2671
2734
  `<section class="hero">
@@ -2674,20 +2737,20 @@ const inter = Inter({ subsets: ["latin"] });`),
2674
2737
  <a href="/work" class="btn btn-ghost">See our work</a>
2675
2738
  </section>`
2676
2739
  ),
2677
- // The null build's hero, verbatim in shape (calibration/null/rmp/pages/05.html).
2740
+ // The null build's hero, verbatim in shape (an electrician brief's null pages, in the private dd-craft-corpus).
2678
2741
  f3(
2679
2742
  "index.html",
2680
2743
  `<nav><a class="btn" href="#contact">Quote</a></nav>
2681
2744
  <section id="hero" class="hero">
2682
- <h1>Electricians in Uxbridge</h1>
2745
+ <h1>Electricians in Reading</h1>
2683
2746
  <a href="#contact" class="btn btn-primary">Book a Free Quote</a>
2684
- <a href="tel:+441895555210" class="btn btn-outline">Call 01895 555 210</a>
2747
+ <a href="tel:+441632960210" class="btn btn-outline">Call 01632 960 210</a>
2685
2748
  </section>`
2686
2749
  ),
2687
2750
  f3(
2688
2751
  "app/page.tsx",
2689
2752
  `<section className="relative py-24">
2690
- <h1 className="text-5xl">Nails in Brackley</h1>
2753
+ <h1 className="text-5xl">Nails in Kettering</h1>
2691
2754
  <div className="flex gap-4">
2692
2755
  <Button asChild><a href="/book">Book now</a></Button>
2693
2756
  <Button variant="outline">View treatments</Button>
@@ -2716,7 +2779,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2716
2779
  </header>`
2717
2780
  ),
2718
2781
  f3("app/page.tsx", `<section className="py-24">
2719
- <h1 className="text-5xl">Nails in Brackley</h1>
2782
+ <h1 className="text-5xl">Nails in Kettering</h1>
2720
2783
  <a className="rounded bg-ink px-5 py-3" href="/book">Book now</a>
2721
2784
  </section>
2722
2785
  <section><a className="rounded bg-ink px-5 py-3" href="/prices">Prices</a></section>`)
@@ -3492,13 +3555,15 @@ var HOUSE = {
3492
3555
  "stock-email-line": { tier: "review", label: "stock email line, start with the reason for writing and end with the next step" },
3493
3556
  // New 2026-10-19 (founder): review first, like every new tell, until estate hits are read.
3494
3557
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
3495
- // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
3558
+ // New 2026-10-20 (founder, from a reviewer's notes): review first, like every new tell.
3496
3559
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
3497
3560
  // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
3498
3561
  // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
3499
3562
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3500
3563
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3501
3564
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3565
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
3566
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
3502
3567
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3503
3568
  "stock-phrase": { tier: "review", label: "stock phrase" },
3504
3569
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4267,7 +4332,7 @@ var IMAGERY_TELLS = [
4267
4332
  ];
4268
4333
 
4269
4334
  // src/character/check.ts
4270
- var CATALOGUE_VERSION = "2026.10.8";
4335
+ var CATALOGUE_VERSION = "2026.10.9";
4271
4336
  var CATALOGUE = [
4272
4337
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4273
4338
  ...SPECIFICITY_COPY_TELLS,
package/dist/audit.js CHANGED
@@ -323,6 +323,29 @@ var PARALLEL_PAIR = [
323
323
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
324
324
  /^[ \t]*(?:#{1,6}[ \t]+)?[A-Z][\w'’-]*(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}(?:,[ \t]+(?!(?:and|or)\b)[\w'’-]+(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}){2,3}\.[ \t]*$/m
325
325
  ];
326
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
327
+ var ABSOLUTE_NEGATION = new RegExp(
328
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
329
+ "im"
330
+ );
331
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
332
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
333
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
334
+ function notAbsoluteFragment(block, match, index) {
335
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
336
+ let words = rest;
337
+ const lead = head.toLowerCase();
338
+ if (lead === "no" && words[0]?.toLowerCase() === "one") words = words.slice(1);
339
+ else if (lead === "no" && NO_IDIOM.test(words[0] ?? "")) return true;
340
+ if (words.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
341
+ if (lead === "never" && !PARTICIPLE.test(words[0] ?? "")) return true;
342
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words[0] ?? "")) return true;
343
+ const sentence = lineAround(block.text, index);
344
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
345
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
346
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
347
+ return emptyState(block, match, index);
348
+ }
326
349
  var alreadyBlocked = (block, match, index) => {
327
350
  const before = block.text.slice(0, index);
328
351
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -449,7 +472,7 @@ var COPY_TELLS = [
449
472
  fixtures: {
450
473
  flag: [
451
474
  f("content/home.md", "Your options, in plain English."),
452
- // Eunice Ordor's draft lines, Pastel 986559 #12 and #13 (her diagnosis taken, her wording not).
475
+ // A reviewer's draft lines, Pastel 986559 #12 and #13 (the diagnosis taken, the wording not).
453
476
  f("content/clinics.md", "Tell us what's happening and we'll say plainly what we would take on."),
454
477
  f("content/pricing.md", "DD's payment terms, plainly stated."),
455
478
  f("content/clinics.md", "We explain our findings in plain terms."),
@@ -575,7 +598,7 @@ var COPY_TELLS = [
575
598
  fixtures: {
576
599
  flag: [
577
600
  // From the portal's seeded prospect follow-ups, 2026-10-19.
578
- f("emails/followup.md", "Alright Dave, it's Dmitri. No pressure, have a look when you get a min."),
601
+ f("emails/followup.md", "Alright Dave, it's Sam. No pressure, have a look when you get a min."),
579
602
  f("emails/followup.md", "All good if you're not fussed mate, I'll leave it there."),
580
603
  f("emails/followup.md", "Carlo, built the shop a proper site."),
581
604
  f("content/contact.md", "No worries, we'll get you sorted."),
@@ -588,11 +611,11 @@ var COPY_TELLS = [
588
611
  f("content/home.md", "Proper planning saves a second visit."),
589
612
  f("content/certificates.md", "Download a sorted list of certificates, sorted by date."),
590
613
  f("content/directions.md", "At the junction, take the right then left turn."),
591
- // Chair and Blade's own approved line, and "mate" as a noun.
614
+ // A barber client's own approved line, and "mate" as a noun.
592
615
  f("content/home.md", "Every cut starts with a proper chat about what you actually want."),
593
616
  f("content/home.md", "It feels like getting a cut from a mate who knows what he is doing."),
594
617
  f("emails/quote.md", "Have a look at the quote and ring me if Thursday does not work."),
595
- f("content/contact.md", "Give us a ring on 01280 700123. We take a look at the fault before quoting."),
618
+ f("content/contact.md", "Give us a ring on 01632 960123. We take a look at the fault before quoting."),
596
619
  f("content/home.md", "Built to last, and made for older houses.")
597
620
  ]
598
621
  }
@@ -604,14 +627,14 @@ var COPY_TELLS = [
604
627
  severity: "warn",
605
628
  surface: "copy",
606
629
  why: "'What this is and is not', 'we have not yet built', 'our experience so far is one practice', 'what we would and wouldn't take on': a model scopes a business by confessing its limits. It reads as small print, and it turns a buyer away before they make contact.",
607
- fix: "Scope by being precise about what is true, and leave the door open: 'Built and run by us for Sensphere, a practice whose clinicians work across several sites.' Keep contractual facts (eligibility, who runs what) and say them as what the buyer gets.",
630
+ fix: "Scope by being precise about what is true, and leave the door open: 'Built and run by us for a children's therapy practice whose clinicians work across several sites.' Keep contractual facts (eligibility, who runs what) and say them as what the buyer gets.",
608
631
  detect: phrases(CONFESSION, quoted),
609
632
  fixtures: {
610
633
  flag: [
611
- // Each one from the DD site, as Eunice Ordor flagged it (Pastel 986559 #8, #12, #13).
634
+ // Each one from the studio's own site, as a reviewer flagged it (Pastel 986559 #8, #12, #13).
612
635
  f("content/clinics.md", "What this is and is not: a practice portal we built and run for one practice."),
613
636
  f("content/clinics.md", "We have not yet built and run the digital systems of a practice with several clinicians."),
614
- f("content/home.md", "Including the client portal we run for Sensphere. Our clinic experience so far is one practice."),
637
+ f("content/home.md", "Including the client portal we run for a therapy practice. Our clinic experience so far is one practice."),
615
638
  f("content/clinics.md", "Tell us what's happening and we'll say what we would and wouldn't take on."),
616
639
  f("content/clinics.md", "We will say what carries over from this work and what we would need to learn first."),
617
640
  f("content/clinics.md", "We will tell you where our experience applies directly and where it is close."),
@@ -620,8 +643,8 @@ var COPY_TELLS = [
620
643
  f("content/mobile.md", "Mobile is our newest line, so we tell you what we have and haven't done.")
621
644
  ],
622
645
  pass: [
623
- f("content/clinics.md", "Built and run by us for Sensphere, a growing practice whose clinicians work remotely."),
624
- f("content/home.md", "The client portal we run for Sensphere."),
646
+ f("content/clinics.md", "Built and run by us for a therapy practice, a growing one whose clinicians work remotely."),
647
+ f("content/home.md", "The client portal we run for a children's therapy practice."),
625
648
  // Contractual facts, said as they are.
626
649
  f("content/pricing.md", "Pay monthly is open to UK residents aged 18 and over, subject to status."),
627
650
  f("content/pricing.md", "Your monthly payments do not buy the site; the buy-out price does."),
@@ -861,6 +884,46 @@ var COPY_TELLS = [
861
884
  ]
862
885
  }
863
886
  },
887
+ {
888
+ id: "absolute-negation",
889
+ name: "Absolute negation fragment",
890
+ generation: 2,
891
+ severity: "warn",
892
+ surface: "copy",
893
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
894
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
895
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
896
+ fixtures: {
897
+ flag: [
898
+ // From a 92EL deck that passed the gate, October 2026.
899
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
900
+ f("content/deck.md", "Pay per user per month; no servers."),
901
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
902
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
903
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
904
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
905
+ ],
906
+ pass: [
907
+ f("content/report.md", "Players never open the lists."),
908
+ f("content/report.md", "Nothing is copied by hand."),
909
+ f("content/report.md", "It is not tested at national scale."),
910
+ f("content/report.md", "Never share your password with anyone."),
911
+ f("content/report.md", "No one knows the password but the club secretary."),
912
+ // A Harvard citation with no date.
913
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
914
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
915
+ // A form answer option.
916
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
917
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
918
+ f("content/form.md", "No, the player is 18 or over."),
919
+ f("content/home.md", "We no longer post paper forms."),
920
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
921
+ // The blocking tells report these.
922
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
923
+ f("app/list.tsx", `export const empty = "No results found.";`)
924
+ ]
925
+ }
926
+ },
864
927
  {
865
928
  id: "where-x-meets-y",
866
929
  name: "'Where X meets Y'",
@@ -2506,7 +2569,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2506
2569
  generation: 1,
2507
2570
  severity: "warn",
2508
2571
  surface: "source",
2509
- why: "When every block fades up on scroll, the motion stops meaning anything and the page feels slow. MMM had 70, DD 40, sen-sphere 27.",
2572
+ why: "When every block fades up on scroll, the motion stops meaning anything and the page feels slow. One salon client's site had 70, the studio's own 40, a therapy practice's 27.",
2510
2573
  fix: `Keep one gated signature moment per page (STANDARD section 7) and let the rest arrive still. The limit here is ${REVEAL_LIMIT} per scan.`,
2511
2574
  detect: revealHits,
2512
2575
  fixtures: {
@@ -2522,7 +2585,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2522
2585
  generation: 1,
2523
2586
  severity: "warn",
2524
2587
  surface: "source",
2525
- why: "rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language.",
2588
+ why: "rounded-full on every button, badge and tag is the framework's friendliest default. One salon client's site had 162, the studio's own 125. A shape used everywhere is not a shape language.",
2526
2589
  fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
2527
2590
  detect: pillHits,
2528
2591
  fixtures: {
@@ -2537,7 +2600,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2537
2600
  generation: 1,
2538
2601
  severity: "warn",
2539
2602
  surface: "source",
2540
- why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets.",
2603
+ why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. One client's journal site ships a full set under its own book, vine and petal assets.",
2541
2604
  fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
2542
2605
  detect: shadcnHits,
2543
2606
  fixtures: {
@@ -2595,7 +2658,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2595
2658
  f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
2596
2659
  f3("app/globals.css", `.hero h1 { font-style: italic; }`)
2597
2660
  ],
2598
- pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
2661
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Kettering</h1>
2599
2662
  <p><em>Walk-ins</em> welcome.</p>`)]
2600
2663
  }
2601
2664
  },
@@ -2611,11 +2674,11 @@ const inter = Inter({ subsets: ["latin"] });`),
2611
2674
  fixtures: {
2612
2675
  flag: [
2613
2676
  f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
2614
- <h1>Nails in Brackley</h1>`),
2677
+ <h1>Nails in Kettering</h1>`),
2615
2678
  f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
2616
2679
  <h1>Nails</h1>`)
2617
2680
  ],
2618
- pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
2681
+ pass: [f3("components/Hero.tsx", `<h1>Nails in Kettering</h1>
2619
2682
  <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
2620
2683
  }
2621
2684
  },
@@ -2630,7 +2693,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2630
2693
  detect: heroCtaPairHits,
2631
2694
  fixtures: {
2632
2695
  flag: [
2633
- // Eunice Ordor's #5 and #36 (Pastel 986559): the DD site's own hero and process close.
2696
+ // A reviewer's #5 and #36 (Pastel 986559): the studio's own hero and process close.
2634
2697
  f3(
2635
2698
  "src/components/Hero.astro",
2636
2699
  `<section class="hero">
@@ -2639,20 +2702,20 @@ const inter = Inter({ subsets: ["latin"] });`),
2639
2702
  <a href="/work" class="btn btn-ghost">See our work</a>
2640
2703
  </section>`
2641
2704
  ),
2642
- // The null build's hero, verbatim in shape (calibration/null/rmp/pages/05.html).
2705
+ // The null build's hero, verbatim in shape (an electrician brief's null pages, in the private dd-craft-corpus).
2643
2706
  f3(
2644
2707
  "index.html",
2645
2708
  `<nav><a class="btn" href="#contact">Quote</a></nav>
2646
2709
  <section id="hero" class="hero">
2647
- <h1>Electricians in Uxbridge</h1>
2710
+ <h1>Electricians in Reading</h1>
2648
2711
  <a href="#contact" class="btn btn-primary">Book a Free Quote</a>
2649
- <a href="tel:+441895555210" class="btn btn-outline">Call 01895 555 210</a>
2712
+ <a href="tel:+441632960210" class="btn btn-outline">Call 01632 960 210</a>
2650
2713
  </section>`
2651
2714
  ),
2652
2715
  f3(
2653
2716
  "app/page.tsx",
2654
2717
  `<section className="relative py-24">
2655
- <h1 className="text-5xl">Nails in Brackley</h1>
2718
+ <h1 className="text-5xl">Nails in Kettering</h1>
2656
2719
  <div className="flex gap-4">
2657
2720
  <Button asChild><a href="/book">Book now</a></Button>
2658
2721
  <Button variant="outline">View treatments</Button>
@@ -2681,7 +2744,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2681
2744
  </header>`
2682
2745
  ),
2683
2746
  f3("app/page.tsx", `<section className="py-24">
2684
- <h1 className="text-5xl">Nails in Brackley</h1>
2747
+ <h1 className="text-5xl">Nails in Kettering</h1>
2685
2748
  <a className="rounded bg-ink px-5 py-3" href="/book">Book now</a>
2686
2749
  </section>
2687
2750
  <section><a className="rounded bg-ink px-5 py-3" href="/prices">Prices</a></section>`)
@@ -3457,13 +3520,15 @@ var HOUSE = {
3457
3520
  "stock-email-line": { tier: "review", label: "stock email line, start with the reason for writing and end with the next step" },
3458
3521
  // New 2026-10-19 (founder): review first, like every new tell, until estate hits are read.
3459
3522
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
3460
- // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
3523
+ // New 2026-10-20 (founder, from a reviewer's notes): review first, like every new tell.
3461
3524
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
3462
3525
  // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
3463
3526
  // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
3464
3527
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3465
3528
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3466
3529
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3530
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
3531
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
3467
3532
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3468
3533
  "stock-phrase": { tier: "review", label: "stock phrase" },
3469
3534
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4232,7 +4297,7 @@ var IMAGERY_TELLS = [
4232
4297
  ];
4233
4298
 
4234
4299
  // src/character/check.ts
4235
- var CATALOGUE_VERSION = "2026.10.8";
4300
+ var CATALOGUE_VERSION = "2026.10.9";
4236
4301
  var CATALOGUE = [
4237
4302
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4238
4303
  ...SPECIFICITY_COPY_TELLS,