@domandigital/craft 0.22.1 → 0.23.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6890c34: <!-- copy-ok: names the shapes the release starts catching --> Contrastive negation catches the shapes a 92EL report passed the gate with (DOM-1358, catalogue 2026.10.7). **This tightens the gate:** `not-just-but`, which blocks, now covers the parallel gerund ("renting Microsoft's services, not running servers") as well as the parallel participle; its 45 estate hits were read first and every one in shipped copy is the real template. A new review-tier tell, `trailing-contrast`, reports the softer forms COPY.md had left to the read-through: a noun after ", not" closing a sentence ("Edge matters for game servers, not registration"), "not a legal one", and a gerund "instead of" a gerund ("renting computing instead of owning it"). It never blocks, because a genuine disclaimer has the same shape, and it stays quiet where `not-just-but` already reports. Plain negation is untouched: "It is not tested at national scale", "not yet automated", "Nothing is copied by hand". A second new review-tier tell, `parallel-pair`, reports verbless parallel pairs, the staccato triplet's two-part cousin: "One form, seven workflows", "Same site, same £8,000.", "Pay per user, per month", and a verbless list used as a tagline ("Service account, match-day reminders, analytics."). A plain list of names, a sentence with a verb, a subtitle and a numeric result line pass. It never blocks, because a short slide label is often fine.
8
+
3
9
  ## 0.22.1
4
10
 
5
11
  ### Patch Changes
package/CHARACTER.md CHANGED
@@ -1470,7 +1470,7 @@ Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
1470
1470
  after changing an entry; a test fails until you do.
1471
1471
 
1472
1472
  <!-- craft:catalogue:start -->
1473
- Catalogue version `2026.10.5`, 74 tells.
1473
+ Catalogue version `2026.10.7`, 76 tells.
1474
1474
 
1475
1475
  | Id | Gen | Surface | Severity | Tell | Why it is a default |
1476
1476
  | --- | --- | --- | --- | --- | --- |
@@ -1514,10 +1514,12 @@ Catalogue version `2026.10.5`, 74 tells.
1514
1514
  | `rhetorical-opener` | 1 | copy | warn | Rhetorical question opener | 'Looking for...?', 'Tired of...?', 'Ready to...?' opens with the model guessing at the reader instead of telling them something. |
1515
1515
  | `em-dash` | 2 | copy | warn | Em dash | Generated copy leans on the em dash to join clauses. House copy does not use it. |
1516
1516
  | `emoji` | 1 | copy | warn | Emoji in copy | A sparkle or a rocket beside a heading is decoration a model adds to seem friendly. It dates the page and reads as a social post. |
1517
- | `not-just-but` | 2 | copy | warn | Contrastive negation | 'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template is the most recognisable construction in generated copy. |
1517
+ | `not-just-but` | 2 | copy | warn | Contrastive negation | 'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template (correctio, antithesis: see COPY.md) is the most recognisable construction in generated copy. |
1518
+ | `trailing-contrast` | 2 | copy | warn | Trailing contrast | 'Edge matters for game servers, not registration', 'the league's own threshold, not a legal one', 'renting computing instead of owning it': the contrast template (correctio, antithesis: see COPY.md) in its softer forms. A genuine disclaimer has the same shape, so a person decides. |
1518
1519
  | `no-x-no-y` | 2 | copy | warn | 'No X, no Y' list | 'No obligation, no spam.' Defining the business by what it is not, itemised, is the same template as contrastive negation. |
1519
1520
  | `no-x-badge` | 2 | copy | warn | 'No X' badge | 'No catch.', 'NO JARGON GUIDE': a short standalone 'No X' reads as a slapped-on kicker, and worse when the same one is reused across pieces. |
1520
- | `staccato-triplet` | 2 | copy | warn | Staccato triplet | Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm. |
1521
+ | `staccato-triplet` | 2 | copy | warn | Staccato triplet | Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') (a tricolon in asyndeton: see COPY.md) is the second wave's favourite rhythm. |
1522
+ | `parallel-pair` | 2 | copy | warn | Verbless parallel pair | 'One form, seven workflows', 'Same site, same £8,000': two matched fragments with no verb (isocolon, often with anaphora or asyndeton: see COPY.md), the staccato triplet's two-part cousin. It sounds decided and says less than a sentence would. |
1521
1523
  | `where-x-meets-y` | 2 | copy | warn | 'Where X meets Y' | 'Where luxury meets comfort' is a tagline shape that fits every business and so describes none. |
1522
1524
  | `chatbot-residue` | 1 | copy | warn | Chatbot residue | 'Certainly! Here's a revised version', 'as an AI language model', a citeturn0search0 token, a link tracked utm_source=chatgpt.com: text that exists only because a chat reply was pasted whole. This is evidence, not style. |
1523
1525
  | `stock-email-line` | 1 | copy | warn | Stock email line | 'I hope this email finds you well', 'let me know if you have any questions', 'don't hesitate to contact us': the opener and closer of a form email. A client reads past them, and a model drafting an email writes them every time. |
package/COPY.md CHANGED
@@ -38,7 +38,10 @@ A single hit fails the check. These are not judgment calls.
38
38
  - "less about X, more about Y"
39
39
  - parallel participle: "shipped from here, not imported from Seoul". Both
40
40
  sides must be real participles, so "cosmetics, not medicines" (a plain
41
- factual contrast) passes.
41
+ factual contrast) passes here and is left to `trailing-contrast`.
42
+ - parallel gerund: "renting Microsoft's services, not running servers",
43
+ "delivering services, not managing bookings". "Something" and "nothing"
44
+ are not gerunds.
42
45
  - **A "No X" claim as the whole string.** "NO JARGON GUIDE", "No catch." A
43
46
  short standalone "No X" reads as a slapped-on badge. Empty-state and error
44
47
  text is exempt ("No results found", "No bookings yet"), as is anything under
@@ -97,6 +100,7 @@ person reading decides which instance earns its place.
97
100
  | `sales-tagline` | advert lines that say nothing a reader can check: "so your budget goes where it counts", "real results", "results that matter", "work harder for you", "pages that convert" |
98
101
  | `matey-register` | the put-on British voice: "Alright Dave,", "cheers mate", "no worries", "we'll get you sorted", "proper good", "give me a shout", "chuffed", "Right then,"; and clipped speech that drops the small words: "have look at", "give it go", "Quick one:", "built you a site". Write plain, complete British English |
99
102
  | `confession-line` | scoping the business by what it has not done, will not do or is not: "What this is and is not", "we have not yet built", "our clinic experience so far is one practice", "what we would and wouldn't take on", "who we don't work with". It's an open shop: be honest by being precise ("the portal we run for Sensphere") and keep the door open. Contractual facts (eligibility, who runs what) stay, said as what the buyer gets |
103
+ | `trailing-contrast` | the softer contrast template: a noun or phrase after ", not" closing a sentence ("Edge matters for game servers, not registration"), "not a legal one", and "renting computing instead of owning it". A genuine disclaimer has the same shape ("general information, not a recommendation"), so it is reported, never blocked. Say the positive claim; keep the contrast only where the reader would otherwise assume the other thing |
100
104
  | `outcome-promise` | a hoped-for result written as delivered: "we'll turn that into enquiries", "once the first areas are working", "competitors you can outdo", "exactly what convinces", "guaranteed rankings", "you'll get more". Promise the work; measure and report the results |
101
105
  | `ai-vocabulary` | tapestry, testament, realm, elevate, nestled, meticulous, unparalleled, moreover; and underscore, showcasing, pivotal, measured 10 to 14 times over-used in post-2022 text |
102
106
  | `stock-email-line` | the form-email opener and closer: "I hope this email finds you well", "hope you're having a great week", "let me know if you have any questions", "don't hesitate to contact us". Start with the reason for writing; end with the next step and who to ask |
@@ -104,6 +108,7 @@ person reading decides which instance earns its place.
104
108
  | `hollow-imperative` | Discover, Experience, Transform, Elevate as the opening verb of a heading or button |
105
109
  | `rhetorical-opener` | "Looking for...?", "Tired of...?", "Ready to...?" |
106
110
  | `staccato-triplet` | "Fast. Friendly. Local." |
111
+ | `parallel-pair` | the staccato triplet's two-part cousin, two matched fragments with no verb: "One form, seven workflows", "Same site, same £8,000.", "Pay per user, per month", and a verbless list used as a tagline ("Service account, match-day reminders, analytics."). A plain list of names ("Forms, SharePoint, Outlook and Teams") and a sentence with a verb pass. Write the sentence the pair stands for; a short slide label can stay once a person has read it |
107
112
  | `where-x-meets-y` | "Where luxury meets comfort" |
108
113
  | `ing-tail` | "..., ensuring peace of mind", "..., highlighting our commitment": significance claimed, no mechanism |
109
114
  | `vague-attribution` | "studies show", "experts agree", "it is widely known": a claim credited to nobody |
@@ -116,6 +121,23 @@ person reading decides which instance earns its place.
116
121
  | `placeholder` | "[Insert client name]", lorem ipsum, `TODO_PLACEHOLDER`. Fine in a draft commit; **zero in anything delivered** |
117
122
  | `generic-hero-claim` | on the home page (a root route file, a top-level `index.html`, or a hero or home component), a literal `<h1>` and the paragraph under it that name no place, person, number, price or job: "Quality you can trust", "Your vision, our expertise". One trade noun is not enough ("quality plumbing"); two are ("boiler and radiator repairs"). Also read on the rendered first screen by `craft audit` |
118
123
 
124
+ ### The figures these tells name
125
+
126
+ Several tells are classical figures of speech used as a template. Before
127
+ ruling on a finding you are unsure of, read the definition at the source:
128
+ the figure is the shape, and a line is a hit only when it has the shape.
129
+ Silva Rhetoricae (Brigham Young University) is the reference; Wikipedia
130
+ gives more examples.
131
+
132
+ | Figure | What it is | Tells | Sources |
133
+ | --- | --- | --- | --- |
134
+ | Correctio | defining a thing by saying what it is not, before or after naming it: "dispatched from here, not shipped in" | `not-just-but`, `trailing-contrast` | [Silva](http://rhetoric.byu.edu/Figures/C/correctio.htm) |
135
+ | Antithesis | contrasting words or ideas set side by side, often in parallel structure: "renting services, not running servers" | `not-just-but`, `trailing-contrast` | [Silva](http://rhetoric.byu.edu/Figures/A/antithesis.htm), [Wikipedia](https://en.wikipedia.org/wiki/Antithesis) |
136
+ | Isocolon | a series of elements with the same structure and length: "One form, seven workflows" | `parallel-pair` | [Silva](http://rhetoric.byu.edu/Figures/I/isocolon.htm), [Wikipedia](https://en.wikipedia.org/wiki/Isocolon) |
137
+ | Anaphora | the same word or words opening each of successive phrases: "Same site, same £8,000" | `parallel-pair`, `no-x-no-y` | [Silva](http://rhetoric.byu.edu/Figures/A/anaphora.htm), [Wikipedia](https://en.wikipedia.org/wiki/Anaphora_(rhetoric)) |
138
+ | Asyndeton | leaving out the conjunctions between items: "Service account, match-day reminders, analytics." | `parallel-pair`, `staccato-triplet` | [Silva](http://rhetoric.byu.edu/Figures/A/asyndeton.htm), [Wikipedia](https://en.wikipedia.org/wiki/Asyndeton) |
139
+ | Tricolon | three parallel elements of the same length in a row: "Fast. Friendly. Local." | `staccato-triplet` | [Silva](http://rhetoric.byu.edu/Figures/T/tricolon.htm), [Wikipedia](https://en.wikipedia.org/wiki/Tricolon) |
140
+
119
141
  ### Style is not evidence
120
142
 
121
143
  Most tells here are house style: they make copy generic, whoever wrote it. The
@@ -126,12 +148,9 @@ was pasted. Report findings as an editorial diagnosis ("three benefit claims
126
148
  lack a mechanism; two stock phrases recur"), never as a score or an
127
149
  accusation.
128
150
 
129
- Two more review patterns no regex can separate from honest English, so they
130
- are for the read-through only:
151
+ One more review pattern no regex can separate from honest English, so it is
152
+ for the read-through only:
131
153
 
132
- - **Trailing contrastive negation**: "Real Square data, not a vanity metric."
133
- The same shape is how a genuine disclaimer is written ("general information,
134
- not a recommendation").
135
154
  - **Abstract noun triads** with no concrete referent: "clarity, consistency,
136
155
  confidence".
137
156
 
@@ -9,10 +9,10 @@ import {
9
9
  snapshotPage,
10
10
  snapshotUrl,
11
11
  snapshotUrls
12
- } from "./chunk-D6TAYXOS.js";
12
+ } from "./chunk-A7KUKNX6.js";
13
13
  import "./chunk-JYNXGCYZ.js";
14
- import "./chunk-MLYHSPF7.js";
15
- import "./chunk-NV6FKMID.js";
14
+ import "./chunk-3LCBLDAO.js";
15
+ import "./chunk-KIBDFRHT.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
18
18
  PROVENANCE_BUDGET_MS,
package/dist/audit.cjs CHANGED
@@ -335,8 +335,33 @@ var NEGATION = [
335
335
  /\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
336
336
  /\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
337
337
  // parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
338
- /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
338
+ /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i,
339
+ // parallel gerund: "renting Microsoft's services, not running servers". The
340
+ // -thing pronouns and the -ing prepositions are not verbs.
341
+ /\b(?!(?:some|no|any|every)thing\b)\w{3,}ing\b[^,.;:]{0,40},\s*not\s+(?!(?:some|no|any|every)thing\b|(?:during|according|including|regarding|morning|evening)\b)\w{3,}ing\b/i
339
342
  ];
343
+ var SOFT_CONTRAST = [
344
+ // ", not registration." closing the sentence. Not when "not" negates what follows.
345
+ /,\s*not\s+(?!(?:yet|least|only|just|merely|simply|to|all|always|even|ever|necessarily|really|quite|exactly|enough|sure|that|because|so|much|many|once|often|every|including|counting|unlike|until|if|when|unless|though|although|or|and|but)\b)(?:(?:a|an|the|your|our|their|its|his|her|my|any)\s+)?[\w'’-]+(?:\s+[\w'’-]+){0,3}(?=\s*(?:[.!?)"”]|$))/im,
346
+ // "the league's own threshold, not a legal one"
347
+ /,\s*not\s+(?:a|an|the)\s+(?:[\w'’-]+\s+)?one\b/i,
348
+ // "renting computing over the internet instead of owning it"
349
+ /\b(?!(?:some|no|any|every)thing\b)\w{3,}ing\b[^.;:!?]{0,60}?\binstead\s+of\s+(?!(?:some|no|any|every)thing\b)\w{3,}ing\b/i
350
+ ];
351
+ var PAIR_TOKEN = String.raw`(?:[£$€]?\d[\d,.]*%?|[\w'’£$€-]+)`;
352
+ var PAIR_NUMBER = String.raw`(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\d[\d,]*)`;
353
+ var PARALLEL_PAIR = [
354
+ // anaphora: "same site, same £8,000", "per user, per month". "No X, no Y" is no-x-no-y's.
355
+ new RegExp(String.raw`\b(?!no\b)(\w+)[ \t]+${PAIR_TOKEN},[ \t]+\1[ \t]+${PAIR_TOKEN}(?=[ \t]*(?:[.!?]|$))`, "im"),
356
+ // isocolon on its own line: "One form, seven workflows"
357
+ new RegExp(String.raw`^[ \t]*(?:#{1,6}[ \t]+)?${PAIR_NUMBER}[ \t]+[\w'’-]+,[ \t]+${PAIR_NUMBER}[ \t]+[\w'’-]+[.!]?[ \t]*$`, "im"),
358
+ // a verbless list as a tagline: "Service account, match-day reminders, analytics."
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
+ ];
361
+ var alreadyBlocked = (block, match, index) => {
362
+ const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
363
+ return NEGATION.some((pattern) => pattern.test(sentence));
364
+ };
340
365
  var DATA_ABSENCE = /\b(?:yet|found|available|configured|selected|assigned|scheduled|recorded|on file|to show|to display|results|match|matches|matching|data|in this period)\b/i;
341
366
  var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
342
367
  function lineAround(text, index) {
@@ -717,7 +742,7 @@ var COPY_TELLS = [
717
742
  generation: 2,
718
743
  severity: "warn",
719
744
  surface: "copy",
720
- why: "'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template is the most recognisable construction in generated copy.",
745
+ why: "'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template (correctio, antithesis: see COPY.md) is the most recognisable construction in generated copy.",
721
746
  fix: "Say the positive claim and drop the contrast.",
722
747
  detect: phrases(NEGATION, quoted, allText),
723
748
  fixtures: {
@@ -728,12 +753,54 @@ var COPY_TELLS = [
728
753
  f("content/home.md", "Hairdressing isn't a job, it's art."),
729
754
  f("content/home.md", "It is less about the tools and more about the eye."),
730
755
  f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
731
- f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
756
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`),
757
+ // From a 92EL report that passed the gate, October 2026.
758
+ f("content/report.md", "These are reported, not assumed to pass."),
759
+ f("content/report.md", "The deletion is proposed, not automated."),
760
+ f("content/report.md", "For 92EL, that means renting Microsoft's services, not running servers."),
761
+ f("content/home.md", "Your team should spend time delivering services, not managing bookings.")
732
762
  ],
733
763
  pass: [
734
764
  f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
735
765
  f("content/home.md", "They are cosmetics, not medicines."),
736
- f("content/home.md", "The box arrived red, not led astray by a dented corner.")
766
+ f("content/home.md", "The box arrived red, not led astray by a dented corner."),
767
+ f("content/home.md", "We fix boilers in the morning, not in the evening."),
768
+ f("content/report.md", "Players never open the lists."),
769
+ f("content/report.md", "It is not tested at national scale."),
770
+ f("content/report.md", "Do not invent anything."),
771
+ f("content/report.md", "Nothing is copied by hand."),
772
+ f("content/report.md", "Results are checked by a person and not yet automated.")
773
+ ]
774
+ }
775
+ },
776
+ {
777
+ id: "trailing-contrast",
778
+ name: "Trailing contrast",
779
+ generation: 2,
780
+ severity: "warn",
781
+ surface: "copy",
782
+ why: "'Edge matters for game servers, not registration', 'the league's own threshold, not a legal one', 'renting computing instead of owning it': the contrast template (correctio, antithesis: see COPY.md) in its softer forms. A genuine disclaimer has the same shape, so a person decides.",
783
+ fix: "Say the positive claim on its own: 'Edge matters for game servers.' Keep the contrast only where the reader would otherwise assume the other thing, such as a legal disclaimer.",
784
+ detect: phrases(SOFT_CONTRAST, quoted, allText, alreadyBlocked),
785
+ fixtures: {
786
+ flag: [
787
+ // From a 92EL report that passed the gate, October 2026.
788
+ f("content/report.md", "Edge matters for game servers, not registration."),
789
+ f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
790
+ f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
791
+ f("content/home.md", "Real Square data, not a vanity metric.")
792
+ ],
793
+ pass: [
794
+ f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
795
+ f("content/report.md", "Players never open the lists."),
796
+ f("content/report.md", "It is not tested at national scale."),
797
+ f("content/report.md", "Do not invent anything."),
798
+ f("content/report.md", "Nothing is copied by hand."),
799
+ f("content/report.md", "Results are checked by a person and not yet automated."),
800
+ f("content/home.md", "Pay by card, not to mention Apple Pay."),
801
+ f("content/home.md", "Card instead of cash is fine."),
802
+ // The blocking rule reports this one; it is not reported twice.
803
+ f("content/report.md", "The deletion is proposed, not automated.")
737
804
  ]
738
805
  }
739
806
  },
@@ -783,7 +850,7 @@ var COPY_TELLS = [
783
850
  generation: 2,
784
851
  severity: "warn",
785
852
  surface: "copy",
786
- why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
853
+ why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') (a tricolon in asyndeton: see COPY.md) is the second wave's favourite rhythm.",
787
854
  fix: "Write one sentence that says which of the three is true, and how you know.",
788
855
  // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
789
856
  detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
@@ -795,6 +862,36 @@ var COPY_TELLS = [
795
862
  ]
796
863
  }
797
864
  },
865
+ {
866
+ id: "parallel-pair",
867
+ name: "Verbless parallel pair",
868
+ generation: 2,
869
+ severity: "warn",
870
+ surface: "copy",
871
+ why: "'One form, seven workflows', 'Same site, same \xA38,000': two matched fragments with no verb (isocolon, often with anaphora or asyndeton: see COPY.md), the staccato triplet's two-part cousin. It sounds decided and says less than a sentence would.",
872
+ fix: "Write the sentence the pair stands for: 'One form and seven workflows replace the spreadsheets.' A short label on a slide can stay if a person reads it and it earns its place.",
873
+ detect: phrases(PARALLEL_PAIR, quoted),
874
+ fixtures: {
875
+ flag: [
876
+ // From 92EL deliverables, October 2026.
877
+ f("content/deck.md", "# One form, seven workflows"),
878
+ f("content/deck.md", "One form, seven workflows."),
879
+ f("content/deck.md", "Same site, same \xA38,000."),
880
+ f("content/deck.md", "Pay per user, per month"),
881
+ f("content/deck.md", "Service account, match-day reminders, analytics.")
882
+ ],
883
+ pass: [
884
+ f("content/deck.md", "Forms, SharePoint, Outlook and Teams"),
885
+ f("content/deck.md", "One form and seven workflows replace 92EL's spreadsheets and email chains."),
886
+ f("content/deck.md", "PulseCloud Technologies, for 92EL senior management"),
887
+ f("content/deck.md", "41 of 50 passed, 12 after a fix."),
888
+ f("content/deck.md", "Pay monthly for each user"),
889
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
890
+ // The blocking no-x-no-y reports this one.
891
+ f("content/home.md", "We reply within a day. No obligation, no spam.")
892
+ ]
893
+ }
894
+ },
798
895
  {
799
896
  id: "where-x-meets-y",
800
897
  name: "'Where X meets Y'",
@@ -3393,6 +3490,11 @@ var HOUSE = {
3393
3490
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
3394
3491
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
3395
3492
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
3493
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
3494
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
3495
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3496
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3497
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3396
3498
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3397
3499
  "stock-phrase": { tier: "review", label: "stock phrase" },
3398
3500
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4161,7 +4263,7 @@ var IMAGERY_TELLS = [
4161
4263
  ];
4162
4264
 
4163
4265
  // src/character/check.ts
4164
- var CATALOGUE_VERSION = "2026.10.5";
4266
+ var CATALOGUE_VERSION = "2026.10.7";
4165
4267
  var CATALOGUE = [
4166
4268
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4167
4269
  ...SPECIFICITY_COPY_TELLS,
package/dist/audit.js CHANGED
@@ -300,8 +300,33 @@ var NEGATION = [
300
300
  /\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
301
301
  /\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
302
302
  // parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
303
- /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
303
+ /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i,
304
+ // parallel gerund: "renting Microsoft's services, not running servers". The
305
+ // -thing pronouns and the -ing prepositions are not verbs.
306
+ /\b(?!(?:some|no|any|every)thing\b)\w{3,}ing\b[^,.;:]{0,40},\s*not\s+(?!(?:some|no|any|every)thing\b|(?:during|according|including|regarding|morning|evening)\b)\w{3,}ing\b/i
304
307
  ];
308
+ var SOFT_CONTRAST = [
309
+ // ", not registration." closing the sentence. Not when "not" negates what follows.
310
+ /,\s*not\s+(?!(?:yet|least|only|just|merely|simply|to|all|always|even|ever|necessarily|really|quite|exactly|enough|sure|that|because|so|much|many|once|often|every|including|counting|unlike|until|if|when|unless|though|although|or|and|but)\b)(?:(?:a|an|the|your|our|their|its|his|her|my|any)\s+)?[\w'’-]+(?:\s+[\w'’-]+){0,3}(?=\s*(?:[.!?)"”]|$))/im,
311
+ // "the league's own threshold, not a legal one"
312
+ /,\s*not\s+(?:a|an|the)\s+(?:[\w'’-]+\s+)?one\b/i,
313
+ // "renting computing over the internet instead of owning it"
314
+ /\b(?!(?:some|no|any|every)thing\b)\w{3,}ing\b[^.;:!?]{0,60}?\binstead\s+of\s+(?!(?:some|no|any|every)thing\b)\w{3,}ing\b/i
315
+ ];
316
+ var PAIR_TOKEN = String.raw`(?:[£$€]?\d[\d,.]*%?|[\w'’£$€-]+)`;
317
+ var PAIR_NUMBER = String.raw`(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\d[\d,]*)`;
318
+ var PARALLEL_PAIR = [
319
+ // anaphora: "same site, same £8,000", "per user, per month". "No X, no Y" is no-x-no-y's.
320
+ new RegExp(String.raw`\b(?!no\b)(\w+)[ \t]+${PAIR_TOKEN},[ \t]+\1[ \t]+${PAIR_TOKEN}(?=[ \t]*(?:[.!?]|$))`, "im"),
321
+ // isocolon on its own line: "One form, seven workflows"
322
+ new RegExp(String.raw`^[ \t]*(?:#{1,6}[ \t]+)?${PAIR_NUMBER}[ \t]+[\w'’-]+,[ \t]+${PAIR_NUMBER}[ \t]+[\w'’-]+[.!]?[ \t]*$`, "im"),
323
+ // a verbless list as a tagline: "Service account, match-day reminders, analytics."
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
+ ];
326
+ var alreadyBlocked = (block, match, index) => {
327
+ const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
328
+ return NEGATION.some((pattern) => pattern.test(sentence));
329
+ };
305
330
  var DATA_ABSENCE = /\b(?:yet|found|available|configured|selected|assigned|scheduled|recorded|on file|to show|to display|results|match|matches|matching|data|in this period)\b/i;
306
331
  var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
307
332
  function lineAround(text, index) {
@@ -682,7 +707,7 @@ var COPY_TELLS = [
682
707
  generation: 2,
683
708
  severity: "warn",
684
709
  surface: "copy",
685
- why: "'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template is the most recognisable construction in generated copy.",
710
+ why: "'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template (correctio, antithesis: see COPY.md) is the most recognisable construction in generated copy.",
686
711
  fix: "Say the positive claim and drop the contrast.",
687
712
  detect: phrases(NEGATION, quoted, allText),
688
713
  fixtures: {
@@ -693,12 +718,54 @@ var COPY_TELLS = [
693
718
  f("content/home.md", "Hairdressing isn't a job, it's art."),
694
719
  f("content/home.md", "It is less about the tools and more about the eye."),
695
720
  f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
696
- f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
721
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`),
722
+ // From a 92EL report that passed the gate, October 2026.
723
+ f("content/report.md", "These are reported, not assumed to pass."),
724
+ f("content/report.md", "The deletion is proposed, not automated."),
725
+ f("content/report.md", "For 92EL, that means renting Microsoft's services, not running servers."),
726
+ f("content/home.md", "Your team should spend time delivering services, not managing bookings.")
697
727
  ],
698
728
  pass: [
699
729
  f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
700
730
  f("content/home.md", "They are cosmetics, not medicines."),
701
- f("content/home.md", "The box arrived red, not led astray by a dented corner.")
731
+ f("content/home.md", "The box arrived red, not led astray by a dented corner."),
732
+ f("content/home.md", "We fix boilers in the morning, not in the evening."),
733
+ f("content/report.md", "Players never open the lists."),
734
+ f("content/report.md", "It is not tested at national scale."),
735
+ f("content/report.md", "Do not invent anything."),
736
+ f("content/report.md", "Nothing is copied by hand."),
737
+ f("content/report.md", "Results are checked by a person and not yet automated.")
738
+ ]
739
+ }
740
+ },
741
+ {
742
+ id: "trailing-contrast",
743
+ name: "Trailing contrast",
744
+ generation: 2,
745
+ severity: "warn",
746
+ surface: "copy",
747
+ why: "'Edge matters for game servers, not registration', 'the league's own threshold, not a legal one', 'renting computing instead of owning it': the contrast template (correctio, antithesis: see COPY.md) in its softer forms. A genuine disclaimer has the same shape, so a person decides.",
748
+ fix: "Say the positive claim on its own: 'Edge matters for game servers.' Keep the contrast only where the reader would otherwise assume the other thing, such as a legal disclaimer.",
749
+ detect: phrases(SOFT_CONTRAST, quoted, allText, alreadyBlocked),
750
+ fixtures: {
751
+ flag: [
752
+ // From a 92EL report that passed the gate, October 2026.
753
+ f("content/report.md", "Edge matters for game servers, not registration."),
754
+ f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
755
+ f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
756
+ f("content/home.md", "Real Square data, not a vanity metric.")
757
+ ],
758
+ pass: [
759
+ f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
760
+ f("content/report.md", "Players never open the lists."),
761
+ f("content/report.md", "It is not tested at national scale."),
762
+ f("content/report.md", "Do not invent anything."),
763
+ f("content/report.md", "Nothing is copied by hand."),
764
+ f("content/report.md", "Results are checked by a person and not yet automated."),
765
+ f("content/home.md", "Pay by card, not to mention Apple Pay."),
766
+ f("content/home.md", "Card instead of cash is fine."),
767
+ // The blocking rule reports this one; it is not reported twice.
768
+ f("content/report.md", "The deletion is proposed, not automated.")
702
769
  ]
703
770
  }
704
771
  },
@@ -748,7 +815,7 @@ var COPY_TELLS = [
748
815
  generation: 2,
749
816
  severity: "warn",
750
817
  surface: "copy",
751
- why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
818
+ why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') (a tricolon in asyndeton: see COPY.md) is the second wave's favourite rhythm.",
752
819
  fix: "Write one sentence that says which of the three is true, and how you know.",
753
820
  // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
754
821
  detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
@@ -760,6 +827,36 @@ var COPY_TELLS = [
760
827
  ]
761
828
  }
762
829
  },
830
+ {
831
+ id: "parallel-pair",
832
+ name: "Verbless parallel pair",
833
+ generation: 2,
834
+ severity: "warn",
835
+ surface: "copy",
836
+ why: "'One form, seven workflows', 'Same site, same \xA38,000': two matched fragments with no verb (isocolon, often with anaphora or asyndeton: see COPY.md), the staccato triplet's two-part cousin. It sounds decided and says less than a sentence would.",
837
+ fix: "Write the sentence the pair stands for: 'One form and seven workflows replace the spreadsheets.' A short label on a slide can stay if a person reads it and it earns its place.",
838
+ detect: phrases(PARALLEL_PAIR, quoted),
839
+ fixtures: {
840
+ flag: [
841
+ // From 92EL deliverables, October 2026.
842
+ f("content/deck.md", "# One form, seven workflows"),
843
+ f("content/deck.md", "One form, seven workflows."),
844
+ f("content/deck.md", "Same site, same \xA38,000."),
845
+ f("content/deck.md", "Pay per user, per month"),
846
+ f("content/deck.md", "Service account, match-day reminders, analytics.")
847
+ ],
848
+ pass: [
849
+ f("content/deck.md", "Forms, SharePoint, Outlook and Teams"),
850
+ f("content/deck.md", "One form and seven workflows replace 92EL's spreadsheets and email chains."),
851
+ f("content/deck.md", "PulseCloud Technologies, for 92EL senior management"),
852
+ f("content/deck.md", "41 of 50 passed, 12 after a fix."),
853
+ f("content/deck.md", "Pay monthly for each user"),
854
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
855
+ // The blocking no-x-no-y reports this one.
856
+ f("content/home.md", "We reply within a day. No obligation, no spam.")
857
+ ]
858
+ }
859
+ },
763
860
  {
764
861
  id: "where-x-meets-y",
765
862
  name: "'Where X meets Y'",
@@ -3358,6 +3455,11 @@ var HOUSE = {
3358
3455
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
3359
3456
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
3360
3457
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
3458
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
3459
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
3460
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3461
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3462
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3361
3463
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3362
3464
  "stock-phrase": { tier: "review", label: "stock phrase" },
3363
3465
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4126,7 +4228,7 @@ var IMAGERY_TELLS = [
4126
4228
  ];
4127
4229
 
4128
4230
  // src/character/check.ts
4129
- var CATALOGUE_VERSION = "2026.10.5";
4231
+ var CATALOGUE_VERSION = "2026.10.7";
4130
4232
  var CATALOGUE = [
4131
4233
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4132
4234
  ...SPECIFICITY_COPY_TELLS,
@@ -9,7 +9,7 @@ import {
9
9
  parseFile,
10
10
  sentencesOf,
11
11
  specificity
12
- } from "./chunk-NV6FKMID.js";
12
+ } from "./chunk-KIBDFRHT.js";
13
13
  import {
14
14
  SNAPSHOT_VERSION
15
15
  } from "./chunk-QYBAVU3P.js";
@@ -2568,6 +2568,11 @@ var HOUSE = {
2568
2568
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
2569
2569
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
2570
2570
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
2571
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
2572
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
2573
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
2574
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
2575
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
2571
2576
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2572
2577
  "stock-phrase": { tier: "review", label: "stock phrase" },
2573
2578
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -3190,7 +3195,7 @@ var IMAGERY_TELLS = [
3190
3195
  ];
3191
3196
 
3192
3197
  // src/character/check.ts
3193
- var CATALOGUE_VERSION = "2026.10.5";
3198
+ var CATALOGUE_VERSION = "2026.10.7";
3194
3199
  var CATALOGUE = [
3195
3200
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
3196
3201
  ...SPECIFICITY_COPY_TELLS,
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-OQXTQQAU.js";
4
4
  import {
5
5
  parseFlags
6
- } from "./chunk-WTR77PEM.js";
6
+ } from "./chunk-KGEJ2TGT.js";
7
7
  import {
8
8
  toJson
9
9
  } from "./chunk-QIEDBL4J.js";
@@ -22,10 +22,10 @@ import {
22
22
  hueSwatch,
23
23
  nullPrompt,
24
24
  scanSource
25
- } from "./chunk-MLYHSPF7.js";
25
+ } from "./chunk-3LCBLDAO.js";
26
26
  import {
27
27
  extractStrings
28
- } from "./chunk-NV6FKMID.js";
28
+ } from "./chunk-KIBDFRHT.js";
29
29
 
30
30
  // src/null/cli.ts
31
31
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
@@ -120,7 +120,7 @@ async function build(flags, io) {
120
120
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
121
121
  if (toShoot.length) {
122
122
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
123
- const { snapshotUrls } = await import("./audit-CDMZ6JHT.js");
123
+ const { snapshotUrls } = await import("./audit-CRTC7JSF.js");
124
124
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
125
125
  results.forEach((r, i) => {
126
126
  const id = toShoot[i];
@@ -241,7 +241,7 @@ async function importPages(flags, io) {
241
241
  const toShoot = pages.filter((p) => !existsSync(join(shots, `${p.id}.json`)));
242
242
  if (toShoot.length) {
243
243
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"} from ${flags.builder}`);
244
- const { snapshotUrls } = await import("./audit-CDMZ6JHT.js");
244
+ const { snapshotUrls } = await import("./audit-CRTC7JSF.js");
245
245
  const servers = /* @__PURE__ */ new Map();
246
246
  try {
247
247
  for (const p of toShoot) if (!servers.has(p.root)) servers.set(p.root, await serveDir(p.root));
@@ -10,10 +10,10 @@ import {
10
10
  levenshtein,
11
11
  normaliseFamily,
12
12
  parseColour
13
- } from "./chunk-MLYHSPF7.js";
13
+ } from "./chunk-3LCBLDAO.js";
14
14
  import {
15
15
  TRADE_NOUNS
16
- } from "./chunk-NV6FKMID.js";
16
+ } from "./chunk-KIBDFRHT.js";
17
17
 
18
18
  // src/direction/types.ts
19
19
  var DIRECTION_VERSION = 2;
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  auditSnapshot,
6
6
  fingerprint
7
- } from "./chunk-MLYHSPF7.js";
7
+ } from "./chunk-3LCBLDAO.js";
8
8
  import {
9
9
  SNAPSHOT_VERSION
10
10
  } from "./chunk-QYBAVU3P.js";