@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/dist/index.cjs CHANGED
@@ -2137,8 +2137,35 @@ var NEGATION = [
2137
2137
  /\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
2138
2138
  /\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
2139
2139
  // parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
2140
- /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
2140
+ /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i,
2141
+ // parallel gerund: "renting Microsoft's services, not running servers". The
2142
+ // -thing pronouns and the -ing prepositions are not verbs.
2143
+ /\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
2141
2144
  ];
2145
+ var SOFT_CONTRAST = [
2146
+ // ", not registration." closing the sentence. Not when "not" negates what follows.
2147
+ /,\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,
2148
+ // "the league's own threshold, not a legal one"
2149
+ /,\s*not\s+(?:a|an|the)\s+(?:[\w'’-]+\s+)?one\b/i,
2150
+ // "renting computing over the internet instead of owning it"
2151
+ /\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
2152
+ ];
2153
+ var PAIR_TOKEN = String.raw`(?:[£$€]?\d[\d,.]*%?|[\w'’£$€-]+)`;
2154
+ var PAIR_NUMBER = String.raw`(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\d[\d,]*)`;
2155
+ var PARALLEL_PAIR = [
2156
+ // anaphora: "same site, same £8,000", "per user, per month". "No X, no Y" is no-x-no-y's.
2157
+ new RegExp(String.raw`\b(?!no\b)(\w+)[ \t]+${PAIR_TOKEN},[ \t]+\1[ \t]+${PAIR_TOKEN}(?=[ \t]*(?:[.!?]|$))`, "im"),
2158
+ // isocolon on its own line: "One form, seven workflows"
2159
+ new RegExp(String.raw`^[ \t]*(?:#{1,6}[ \t]+)?${PAIR_NUMBER}[ \t]+[\w'’-]+,[ \t]+${PAIR_NUMBER}[ \t]+[\w'’-]+[.!]?[ \t]*$`, "im"),
2160
+ // a verbless list as a tagline: "Service account, match-day reminders, analytics."
2161
+ /^[ \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
2162
+ ];
2163
+ var alreadyBlocked = (block, match, index) => {
2164
+ const before = block.text.slice(0, index);
2165
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
2166
+ const sentence = block.text.slice(start, index + match.length);
2167
+ return NEGATION.some((pattern2) => pattern2.test(sentence));
2168
+ };
2142
2169
  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;
2143
2170
  var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
2144
2171
  function lineAround(text, index) {
@@ -2519,7 +2546,7 @@ var COPY_TELLS = [
2519
2546
  generation: 2,
2520
2547
  severity: "warn",
2521
2548
  surface: "copy",
2522
- 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.",
2549
+ 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.",
2523
2550
  fix: "Say the positive claim and drop the contrast.",
2524
2551
  detect: phrases(NEGATION, quoted, allText),
2525
2552
  fixtures: {
@@ -2530,12 +2557,56 @@ var COPY_TELLS = [
2530
2557
  f("content/home.md", "Hairdressing isn't a job, it's art."),
2531
2558
  f("content/home.md", "It is less about the tools and more about the eye."),
2532
2559
  f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
2533
- f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
2560
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`),
2561
+ // From a 92EL report that passed the gate, October 2026.
2562
+ f("content/report.md", "These are reported, not assumed to pass."),
2563
+ f("content/report.md", "The deletion is proposed, not automated."),
2564
+ f("content/report.md", "For 92EL, that means renting Microsoft's services, not running servers."),
2565
+ f("content/home.md", "Your team should spend time delivering services, not managing bookings.")
2534
2566
  ],
2535
2567
  pass: [
2536
2568
  f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
2537
2569
  f("content/home.md", "They are cosmetics, not medicines."),
2538
- f("content/home.md", "The box arrived red, not led astray by a dented corner.")
2570
+ f("content/home.md", "The box arrived red, not led astray by a dented corner."),
2571
+ f("content/home.md", "We fix boilers in the morning, not in the evening."),
2572
+ f("content/report.md", "Players never open the lists."),
2573
+ f("content/report.md", "It is not tested at national scale."),
2574
+ f("content/report.md", "Do not invent anything."),
2575
+ f("content/report.md", "Nothing is copied by hand."),
2576
+ f("content/report.md", "Results are checked by a person and not yet automated.")
2577
+ ]
2578
+ }
2579
+ },
2580
+ {
2581
+ id: "trailing-contrast",
2582
+ name: "Trailing contrast",
2583
+ generation: 2,
2584
+ severity: "warn",
2585
+ surface: "copy",
2586
+ 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.",
2587
+ 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.",
2588
+ detect: phrases(SOFT_CONTRAST, quoted, allText, alreadyBlocked),
2589
+ fixtures: {
2590
+ flag: [
2591
+ // From a 92EL report that passed the gate, October 2026.
2592
+ f("content/report.md", "Edge matters for game servers, not registration."),
2593
+ f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
2594
+ f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
2595
+ f("content/home.md", "Real Square data, not a vanity metric."),
2596
+ // Reported even straight after a sentence the blocking rule reports.
2597
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
2598
+ ],
2599
+ pass: [
2600
+ f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
2601
+ f("content/report.md", "Players never open the lists."),
2602
+ f("content/report.md", "It is not tested at national scale."),
2603
+ f("content/report.md", "Do not invent anything."),
2604
+ f("content/report.md", "Nothing is copied by hand."),
2605
+ f("content/report.md", "Results are checked by a person and not yet automated."),
2606
+ f("content/home.md", "Pay by card, not to mention Apple Pay."),
2607
+ f("content/home.md", "Card instead of cash is fine."),
2608
+ // The blocking rule reports this one; it is not reported twice.
2609
+ f("content/report.md", "The deletion is proposed, not automated.")
2539
2610
  ]
2540
2611
  }
2541
2612
  },
@@ -2585,7 +2656,7 @@ var COPY_TELLS = [
2585
2656
  generation: 2,
2586
2657
  severity: "warn",
2587
2658
  surface: "copy",
2588
- why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
2659
+ 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.",
2589
2660
  fix: "Write one sentence that says which of the three is true, and how you know.",
2590
2661
  // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
2591
2662
  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),
@@ -2597,6 +2668,36 @@ var COPY_TELLS = [
2597
2668
  ]
2598
2669
  }
2599
2670
  },
2671
+ {
2672
+ id: "parallel-pair",
2673
+ name: "Verbless parallel pair",
2674
+ generation: 2,
2675
+ severity: "warn",
2676
+ surface: "copy",
2677
+ 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.",
2678
+ 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.",
2679
+ detect: phrases(PARALLEL_PAIR, quoted),
2680
+ fixtures: {
2681
+ flag: [
2682
+ // From 92EL deliverables, October 2026.
2683
+ f("content/deck.md", "# One form, seven workflows"),
2684
+ f("content/deck.md", "One form, seven workflows."),
2685
+ f("content/deck.md", "Same site, same \xA38,000."),
2686
+ f("content/deck.md", "Pay per user, per month"),
2687
+ f("content/deck.md", "Service account, match-day reminders, analytics.")
2688
+ ],
2689
+ pass: [
2690
+ f("content/deck.md", "Forms, SharePoint, Outlook and Teams"),
2691
+ f("content/deck.md", "One form and seven workflows replace 92EL's spreadsheets and email chains."),
2692
+ f("content/deck.md", "PulseCloud Technologies, for 92EL senior management"),
2693
+ f("content/deck.md", "41 of 50 passed, 12 after a fix."),
2694
+ f("content/deck.md", "Pay monthly for each user"),
2695
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
2696
+ // The blocking no-x-no-y reports this one.
2697
+ f("content/home.md", "We reply within a day. No obligation, no spam.")
2698
+ ]
2699
+ }
2700
+ },
2600
2701
  {
2601
2702
  id: "where-x-meets-y",
2602
2703
  name: "'Where X meets Y'",
@@ -5532,6 +5633,11 @@ var HOUSE = {
5532
5633
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
5533
5634
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
5534
5635
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
5636
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
5637
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
5638
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
5639
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
5640
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
5535
5641
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5536
5642
  "stock-phrase": { tier: "review", label: "stock phrase" },
5537
5643
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6764,7 +6870,7 @@ var IMAGERY_TELLS = [
6764
6870
  ];
6765
6871
 
6766
6872
  // src/character/check.ts
6767
- var CATALOGUE_VERSION = "2026.10.5";
6873
+ var CATALOGUE_VERSION = "2026.10.8";
6768
6874
  var CATALOGUE = [
6769
6875
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6770
6876
  ...SPECIFICITY_COPY_TELLS,
package/dist/index.d.cts CHANGED
@@ -1252,7 +1252,7 @@ interface CheckReport {
1252
1252
  * Bumped whenever an entry is added, removed or its detection changes, so a
1253
1253
  * report can say which list it was judged against.
1254
1254
  */
1255
- declare const CATALOGUE_VERSION = "2026.10.5";
1255
+ declare const CATALOGUE_VERSION = "2026.10.8";
1256
1256
  declare const CATALOGUE: readonly Tell[];
1257
1257
  declare function tellById(id: string): Tell | undefined;
1258
1258
  /**
package/dist/index.d.ts CHANGED
@@ -1252,7 +1252,7 @@ interface CheckReport {
1252
1252
  * Bumped whenever an entry is added, removed or its detection changes, so a
1253
1253
  * report can say which list it was judged against.
1254
1254
  */
1255
- declare const CATALOGUE_VERSION = "2026.10.5";
1255
+ declare const CATALOGUE_VERSION = "2026.10.8";
1256
1256
  declare const CATALOGUE: readonly Tell[];
1257
1257
  declare function tellById(id: string): Tell | undefined;
1258
1258
  /**
package/dist/index.js CHANGED
@@ -1818,8 +1818,35 @@ var NEGATION = [
1818
1818
  /\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
1819
1819
  /\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
1820
1820
  // parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
1821
- /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
1821
+ /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i,
1822
+ // parallel gerund: "renting Microsoft's services, not running servers". The
1823
+ // -thing pronouns and the -ing prepositions are not verbs.
1824
+ /\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
1822
1825
  ];
1826
+ var SOFT_CONTRAST = [
1827
+ // ", not registration." closing the sentence. Not when "not" negates what follows.
1828
+ /,\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,
1829
+ // "the league's own threshold, not a legal one"
1830
+ /,\s*not\s+(?:a|an|the)\s+(?:[\w'’-]+\s+)?one\b/i,
1831
+ // "renting computing over the internet instead of owning it"
1832
+ /\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
1833
+ ];
1834
+ var PAIR_TOKEN = String.raw`(?:[£$€]?\d[\d,.]*%?|[\w'’£$€-]+)`;
1835
+ var PAIR_NUMBER = String.raw`(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\d[\d,]*)`;
1836
+ var PARALLEL_PAIR = [
1837
+ // anaphora: "same site, same £8,000", "per user, per month". "No X, no Y" is no-x-no-y's.
1838
+ new RegExp(String.raw`\b(?!no\b)(\w+)[ \t]+${PAIR_TOKEN},[ \t]+\1[ \t]+${PAIR_TOKEN}(?=[ \t]*(?:[.!?]|$))`, "im"),
1839
+ // isocolon on its own line: "One form, seven workflows"
1840
+ new RegExp(String.raw`^[ \t]*(?:#{1,6}[ \t]+)?${PAIR_NUMBER}[ \t]+[\w'’-]+,[ \t]+${PAIR_NUMBER}[ \t]+[\w'’-]+[.!]?[ \t]*$`, "im"),
1841
+ // a verbless list as a tagline: "Service account, match-day reminders, analytics."
1842
+ /^[ \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
1843
+ ];
1844
+ var alreadyBlocked = (block, match, index) => {
1845
+ const before = block.text.slice(0, index);
1846
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
1847
+ const sentence = block.text.slice(start, index + match.length);
1848
+ return NEGATION.some((pattern2) => pattern2.test(sentence));
1849
+ };
1823
1850
  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;
1824
1851
  var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
1825
1852
  function lineAround(text, index) {
@@ -2200,7 +2227,7 @@ var COPY_TELLS = [
2200
2227
  generation: 2,
2201
2228
  severity: "warn",
2202
2229
  surface: "copy",
2203
- 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.",
2230
+ 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.",
2204
2231
  fix: "Say the positive claim and drop the contrast.",
2205
2232
  detect: phrases(NEGATION, quoted, allText),
2206
2233
  fixtures: {
@@ -2211,12 +2238,56 @@ var COPY_TELLS = [
2211
2238
  f("content/home.md", "Hairdressing isn't a job, it's art."),
2212
2239
  f("content/home.md", "It is less about the tools and more about the eye."),
2213
2240
  f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
2214
- f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
2241
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`),
2242
+ // From a 92EL report that passed the gate, October 2026.
2243
+ f("content/report.md", "These are reported, not assumed to pass."),
2244
+ f("content/report.md", "The deletion is proposed, not automated."),
2245
+ f("content/report.md", "For 92EL, that means renting Microsoft's services, not running servers."),
2246
+ f("content/home.md", "Your team should spend time delivering services, not managing bookings.")
2215
2247
  ],
2216
2248
  pass: [
2217
2249
  f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
2218
2250
  f("content/home.md", "They are cosmetics, not medicines."),
2219
- f("content/home.md", "The box arrived red, not led astray by a dented corner.")
2251
+ f("content/home.md", "The box arrived red, not led astray by a dented corner."),
2252
+ f("content/home.md", "We fix boilers in the morning, not in the evening."),
2253
+ f("content/report.md", "Players never open the lists."),
2254
+ f("content/report.md", "It is not tested at national scale."),
2255
+ f("content/report.md", "Do not invent anything."),
2256
+ f("content/report.md", "Nothing is copied by hand."),
2257
+ f("content/report.md", "Results are checked by a person and not yet automated.")
2258
+ ]
2259
+ }
2260
+ },
2261
+ {
2262
+ id: "trailing-contrast",
2263
+ name: "Trailing contrast",
2264
+ generation: 2,
2265
+ severity: "warn",
2266
+ surface: "copy",
2267
+ 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.",
2268
+ 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.",
2269
+ detect: phrases(SOFT_CONTRAST, quoted, allText, alreadyBlocked),
2270
+ fixtures: {
2271
+ flag: [
2272
+ // From a 92EL report that passed the gate, October 2026.
2273
+ f("content/report.md", "Edge matters for game servers, not registration."),
2274
+ f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
2275
+ f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
2276
+ f("content/home.md", "Real Square data, not a vanity metric."),
2277
+ // Reported even straight after a sentence the blocking rule reports.
2278
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
2279
+ ],
2280
+ pass: [
2281
+ f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
2282
+ f("content/report.md", "Players never open the lists."),
2283
+ f("content/report.md", "It is not tested at national scale."),
2284
+ f("content/report.md", "Do not invent anything."),
2285
+ f("content/report.md", "Nothing is copied by hand."),
2286
+ f("content/report.md", "Results are checked by a person and not yet automated."),
2287
+ f("content/home.md", "Pay by card, not to mention Apple Pay."),
2288
+ f("content/home.md", "Card instead of cash is fine."),
2289
+ // The blocking rule reports this one; it is not reported twice.
2290
+ f("content/report.md", "The deletion is proposed, not automated.")
2220
2291
  ]
2221
2292
  }
2222
2293
  },
@@ -2266,7 +2337,7 @@ var COPY_TELLS = [
2266
2337
  generation: 2,
2267
2338
  severity: "warn",
2268
2339
  surface: "copy",
2269
- why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
2340
+ 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.",
2270
2341
  fix: "Write one sentence that says which of the three is true, and how you know.",
2271
2342
  // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
2272
2343
  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),
@@ -2278,6 +2349,36 @@ var COPY_TELLS = [
2278
2349
  ]
2279
2350
  }
2280
2351
  },
2352
+ {
2353
+ id: "parallel-pair",
2354
+ name: "Verbless parallel pair",
2355
+ generation: 2,
2356
+ severity: "warn",
2357
+ surface: "copy",
2358
+ 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.",
2359
+ 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.",
2360
+ detect: phrases(PARALLEL_PAIR, quoted),
2361
+ fixtures: {
2362
+ flag: [
2363
+ // From 92EL deliverables, October 2026.
2364
+ f("content/deck.md", "# One form, seven workflows"),
2365
+ f("content/deck.md", "One form, seven workflows."),
2366
+ f("content/deck.md", "Same site, same \xA38,000."),
2367
+ f("content/deck.md", "Pay per user, per month"),
2368
+ f("content/deck.md", "Service account, match-day reminders, analytics.")
2369
+ ],
2370
+ pass: [
2371
+ f("content/deck.md", "Forms, SharePoint, Outlook and Teams"),
2372
+ f("content/deck.md", "One form and seven workflows replace 92EL's spreadsheets and email chains."),
2373
+ f("content/deck.md", "PulseCloud Technologies, for 92EL senior management"),
2374
+ f("content/deck.md", "41 of 50 passed, 12 after a fix."),
2375
+ f("content/deck.md", "Pay monthly for each user"),
2376
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
2377
+ // The blocking no-x-no-y reports this one.
2378
+ f("content/home.md", "We reply within a day. No obligation, no spam.")
2379
+ ]
2380
+ }
2381
+ },
2281
2382
  {
2282
2383
  id: "where-x-meets-y",
2283
2384
  name: "'Where X meets Y'",
@@ -5213,6 +5314,11 @@ var HOUSE = {
5213
5314
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
5214
5315
  // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
5215
5316
  "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
5317
+ // New 2026-10-23 (a 92EL report passed the gate with three): review, because a
5318
+ // genuine disclaimer has the same shape. The gerund pair joined not-just-but.
5319
+ // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
5320
+ "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
5321
+ "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
5216
5322
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5217
5323
  "stock-phrase": { tier: "review", label: "stock phrase" },
5218
5324
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6445,7 +6551,7 @@ var IMAGERY_TELLS = [
6445
6551
  ];
6446
6552
 
6447
6553
  // src/character/check.ts
6448
- var CATALOGUE_VERSION = "2026.10.5";
6554
+ var CATALOGUE_VERSION = "2026.10.8";
6449
6555
  var CATALOGUE = [
6450
6556
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6451
6557
  ...SPECIFICITY_COPY_TELLS,
@@ -16,13 +16,13 @@ import {
16
16
  } from "./chunk-XSKLVEI6.js";
17
17
  import {
18
18
  loadPlaywright
19
- } from "./chunk-D6TAYXOS.js";
19
+ } from "./chunk-ZV3FPQPM.js";
20
20
  import "./chunk-JYNXGCYZ.js";
21
21
  import {
22
22
  toJson
23
23
  } from "./chunk-QIEDBL4J.js";
24
- import "./chunk-MLYHSPF7.js";
25
- import "./chunk-NV6FKMID.js";
24
+ import "./chunk-GA3AJN67.js";
25
+ import "./chunk-EHMEMSM2.js";
26
26
  import "./chunk-QYBAVU3P.js";
27
27
 
28
28
  // src/phone/viewports-cli.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/craft",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "private": false,
5
5
  "description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
6
6
  "license": "Apache-2.0",
package/word-lists.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "catalogueVersion": "2026.10.5",
2
+ "catalogueVersion": "2026.10.8",
3
3
  "lists": {
4
4
  "aiWords": [
5
5
  "tapestry",