@domandigital/craft 0.22.1 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.23.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 297ee1b: `trailing-contrast` no longer goes quiet on a sentence that follows one `not-just-but` blocks (DOM-1358, catalogue 2026.10.8). The check that keeps one sentence from being reported twice looked back a fixed 80 characters and could reach the sentence before; it now reads only the sentence the match sits in. Found by running the released `copy-check` over the 92EL lines: "Edge matters for game servers, not registration." went unreported when it came straight after the gerund pair.
8
+
9
+ ## 0.23.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.22.1
4
16
 
5
17
  ### 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.8`, 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-ZV3FPQPM.js";
13
13
  import "./chunk-JYNXGCYZ.js";
14
- import "./chunk-MLYHSPF7.js";
15
- import "./chunk-NV6FKMID.js";
14
+ import "./chunk-GA3AJN67.js";
15
+ import "./chunk-EHMEMSM2.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
18
18
  PROVENANCE_BUDGET_MS,
package/dist/audit.cjs CHANGED
@@ -335,8 +335,35 @@ 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 before = block.text.slice(0, index);
363
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
364
+ const sentence = block.text.slice(start, index + match.length);
365
+ return NEGATION.some((pattern) => pattern.test(sentence));
366
+ };
340
367
  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
368
  var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
342
369
  function lineAround(text, index) {
@@ -717,7 +744,7 @@ var COPY_TELLS = [
717
744
  generation: 2,
718
745
  severity: "warn",
719
746
  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.",
747
+ 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
748
  fix: "Say the positive claim and drop the contrast.",
722
749
  detect: phrases(NEGATION, quoted, allText),
723
750
  fixtures: {
@@ -728,12 +755,56 @@ var COPY_TELLS = [
728
755
  f("content/home.md", "Hairdressing isn't a job, it's art."),
729
756
  f("content/home.md", "It is less about the tools and more about the eye."),
730
757
  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.";`)
758
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`),
759
+ // From a 92EL report that passed the gate, October 2026.
760
+ f("content/report.md", "These are reported, not assumed to pass."),
761
+ f("content/report.md", "The deletion is proposed, not automated."),
762
+ f("content/report.md", "For 92EL, that means renting Microsoft's services, not running servers."),
763
+ f("content/home.md", "Your team should spend time delivering services, not managing bookings.")
732
764
  ],
733
765
  pass: [
734
766
  f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
735
767
  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.")
768
+ f("content/home.md", "The box arrived red, not led astray by a dented corner."),
769
+ f("content/home.md", "We fix boilers in the morning, not in the evening."),
770
+ f("content/report.md", "Players never open the lists."),
771
+ f("content/report.md", "It is not tested at national scale."),
772
+ f("content/report.md", "Do not invent anything."),
773
+ f("content/report.md", "Nothing is copied by hand."),
774
+ f("content/report.md", "Results are checked by a person and not yet automated.")
775
+ ]
776
+ }
777
+ },
778
+ {
779
+ id: "trailing-contrast",
780
+ name: "Trailing contrast",
781
+ generation: 2,
782
+ severity: "warn",
783
+ surface: "copy",
784
+ 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.",
785
+ 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.",
786
+ detect: phrases(SOFT_CONTRAST, quoted, allText, alreadyBlocked),
787
+ fixtures: {
788
+ flag: [
789
+ // From a 92EL report that passed the gate, October 2026.
790
+ f("content/report.md", "Edge matters for game servers, not registration."),
791
+ f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
792
+ f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
793
+ f("content/home.md", "Real Square data, not a vanity metric."),
794
+ // Reported even straight after a sentence the blocking rule reports.
795
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
796
+ ],
797
+ pass: [
798
+ f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
799
+ f("content/report.md", "Players never open the lists."),
800
+ f("content/report.md", "It is not tested at national scale."),
801
+ f("content/report.md", "Do not invent anything."),
802
+ f("content/report.md", "Nothing is copied by hand."),
803
+ f("content/report.md", "Results are checked by a person and not yet automated."),
804
+ f("content/home.md", "Pay by card, not to mention Apple Pay."),
805
+ f("content/home.md", "Card instead of cash is fine."),
806
+ // The blocking rule reports this one; it is not reported twice.
807
+ f("content/report.md", "The deletion is proposed, not automated.")
737
808
  ]
738
809
  }
739
810
  },
@@ -783,7 +854,7 @@ var COPY_TELLS = [
783
854
  generation: 2,
784
855
  severity: "warn",
785
856
  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.",
857
+ 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
858
  fix: "Write one sentence that says which of the three is true, and how you know.",
788
859
  // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
789
860
  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 +866,36 @@ var COPY_TELLS = [
795
866
  ]
796
867
  }
797
868
  },
869
+ {
870
+ id: "parallel-pair",
871
+ name: "Verbless parallel pair",
872
+ generation: 2,
873
+ severity: "warn",
874
+ surface: "copy",
875
+ 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.",
876
+ 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.",
877
+ detect: phrases(PARALLEL_PAIR, quoted),
878
+ fixtures: {
879
+ flag: [
880
+ // From 92EL deliverables, October 2026.
881
+ f("content/deck.md", "# One form, seven workflows"),
882
+ f("content/deck.md", "One form, seven workflows."),
883
+ f("content/deck.md", "Same site, same \xA38,000."),
884
+ f("content/deck.md", "Pay per user, per month"),
885
+ f("content/deck.md", "Service account, match-day reminders, analytics.")
886
+ ],
887
+ pass: [
888
+ f("content/deck.md", "Forms, SharePoint, Outlook and Teams"),
889
+ f("content/deck.md", "One form and seven workflows replace 92EL's spreadsheets and email chains."),
890
+ f("content/deck.md", "PulseCloud Technologies, for 92EL senior management"),
891
+ f("content/deck.md", "41 of 50 passed, 12 after a fix."),
892
+ f("content/deck.md", "Pay monthly for each user"),
893
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
894
+ // The blocking no-x-no-y reports this one.
895
+ f("content/home.md", "We reply within a day. No obligation, no spam.")
896
+ ]
897
+ }
898
+ },
798
899
  {
799
900
  id: "where-x-meets-y",
800
901
  name: "'Where X meets Y'",
@@ -3393,6 +3494,11 @@ var HOUSE = {
3393
3494
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
3394
3495
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
3395
3496
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
3497
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
3498
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
3499
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3500
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3501
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3396
3502
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3397
3503
  "stock-phrase": { tier: "review", label: "stock phrase" },
3398
3504
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4161,7 +4267,7 @@ var IMAGERY_TELLS = [
4161
4267
  ];
4162
4268
 
4163
4269
  // src/character/check.ts
4164
- var CATALOGUE_VERSION = "2026.10.5";
4270
+ var CATALOGUE_VERSION = "2026.10.8";
4165
4271
  var CATALOGUE = [
4166
4272
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4167
4273
  ...SPECIFICITY_COPY_TELLS,
package/dist/audit.js CHANGED
@@ -300,8 +300,35 @@ 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 before = block.text.slice(0, index);
328
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
329
+ const sentence = block.text.slice(start, index + match.length);
330
+ return NEGATION.some((pattern) => pattern.test(sentence));
331
+ };
305
332
  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
333
  var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
307
334
  function lineAround(text, index) {
@@ -682,7 +709,7 @@ var COPY_TELLS = [
682
709
  generation: 2,
683
710
  severity: "warn",
684
711
  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.",
712
+ 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
713
  fix: "Say the positive claim and drop the contrast.",
687
714
  detect: phrases(NEGATION, quoted, allText),
688
715
  fixtures: {
@@ -693,12 +720,56 @@ var COPY_TELLS = [
693
720
  f("content/home.md", "Hairdressing isn't a job, it's art."),
694
721
  f("content/home.md", "It is less about the tools and more about the eye."),
695
722
  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.";`)
723
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`),
724
+ // From a 92EL report that passed the gate, October 2026.
725
+ f("content/report.md", "These are reported, not assumed to pass."),
726
+ f("content/report.md", "The deletion is proposed, not automated."),
727
+ f("content/report.md", "For 92EL, that means renting Microsoft's services, not running servers."),
728
+ f("content/home.md", "Your team should spend time delivering services, not managing bookings.")
697
729
  ],
698
730
  pass: [
699
731
  f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
700
732
  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.")
733
+ f("content/home.md", "The box arrived red, not led astray by a dented corner."),
734
+ f("content/home.md", "We fix boilers in the morning, not in the evening."),
735
+ f("content/report.md", "Players never open the lists."),
736
+ f("content/report.md", "It is not tested at national scale."),
737
+ f("content/report.md", "Do not invent anything."),
738
+ f("content/report.md", "Nothing is copied by hand."),
739
+ f("content/report.md", "Results are checked by a person and not yet automated.")
740
+ ]
741
+ }
742
+ },
743
+ {
744
+ id: "trailing-contrast",
745
+ name: "Trailing contrast",
746
+ generation: 2,
747
+ severity: "warn",
748
+ surface: "copy",
749
+ 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.",
750
+ 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.",
751
+ detect: phrases(SOFT_CONTRAST, quoted, allText, alreadyBlocked),
752
+ fixtures: {
753
+ flag: [
754
+ // From a 92EL report that passed the gate, October 2026.
755
+ f("content/report.md", "Edge matters for game servers, not registration."),
756
+ f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
757
+ f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
758
+ f("content/home.md", "Real Square data, not a vanity metric."),
759
+ // Reported even straight after a sentence the blocking rule reports.
760
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
761
+ ],
762
+ pass: [
763
+ f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
764
+ f("content/report.md", "Players never open the lists."),
765
+ f("content/report.md", "It is not tested at national scale."),
766
+ f("content/report.md", "Do not invent anything."),
767
+ f("content/report.md", "Nothing is copied by hand."),
768
+ f("content/report.md", "Results are checked by a person and not yet automated."),
769
+ f("content/home.md", "Pay by card, not to mention Apple Pay."),
770
+ f("content/home.md", "Card instead of cash is fine."),
771
+ // The blocking rule reports this one; it is not reported twice.
772
+ f("content/report.md", "The deletion is proposed, not automated.")
702
773
  ]
703
774
  }
704
775
  },
@@ -748,7 +819,7 @@ var COPY_TELLS = [
748
819
  generation: 2,
749
820
  severity: "warn",
750
821
  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.",
822
+ 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
823
  fix: "Write one sentence that says which of the three is true, and how you know.",
753
824
  // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
754
825
  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 +831,36 @@ var COPY_TELLS = [
760
831
  ]
761
832
  }
762
833
  },
834
+ {
835
+ id: "parallel-pair",
836
+ name: "Verbless parallel pair",
837
+ generation: 2,
838
+ severity: "warn",
839
+ surface: "copy",
840
+ 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.",
841
+ 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.",
842
+ detect: phrases(PARALLEL_PAIR, quoted),
843
+ fixtures: {
844
+ flag: [
845
+ // From 92EL deliverables, October 2026.
846
+ f("content/deck.md", "# One form, seven workflows"),
847
+ f("content/deck.md", "One form, seven workflows."),
848
+ f("content/deck.md", "Same site, same \xA38,000."),
849
+ f("content/deck.md", "Pay per user, per month"),
850
+ f("content/deck.md", "Service account, match-day reminders, analytics.")
851
+ ],
852
+ pass: [
853
+ f("content/deck.md", "Forms, SharePoint, Outlook and Teams"),
854
+ f("content/deck.md", "One form and seven workflows replace 92EL's spreadsheets and email chains."),
855
+ f("content/deck.md", "PulseCloud Technologies, for 92EL senior management"),
856
+ f("content/deck.md", "41 of 50 passed, 12 after a fix."),
857
+ f("content/deck.md", "Pay monthly for each user"),
858
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
859
+ // The blocking no-x-no-y reports this one.
860
+ f("content/home.md", "We reply within a day. No obligation, no spam.")
861
+ ]
862
+ }
863
+ },
763
864
  {
764
865
  id: "where-x-meets-y",
765
866
  name: "'Where X meets Y'",
@@ -3358,6 +3459,11 @@ var HOUSE = {
3358
3459
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
3359
3460
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
3360
3461
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
3462
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
3463
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
3464
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3465
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3466
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3361
3467
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3362
3468
  "stock-phrase": { tier: "review", label: "stock phrase" },
3363
3469
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4126,7 +4232,7 @@ var IMAGERY_TELLS = [
4126
4232
  ];
4127
4233
 
4128
4234
  // src/character/check.ts
4129
- var CATALOGUE_VERSION = "2026.10.5";
4235
+ var CATALOGUE_VERSION = "2026.10.8";
4130
4236
  var CATALOGUE = [
4131
4237
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4132
4238
  ...SPECIFICITY_COPY_TELLS,
@@ -10,10 +10,10 @@ import {
10
10
  levenshtein,
11
11
  normaliseFamily,
12
12
  parseColour
13
- } from "./chunk-MLYHSPF7.js";
13
+ } from "./chunk-GA3AJN67.js";
14
14
  import {
15
15
  TRADE_NOUNS
16
- } from "./chunk-NV6FKMID.js";
16
+ } from "./chunk-EHMEMSM2.js";
17
17
 
18
18
  // src/direction/types.ts
19
19
  var DIRECTION_VERSION = 2;