@domandigital/craft 0.22.0 → 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 +12 -0
- package/CHARACTER.md +138 -5
- package/COPY.md +35 -9
- package/PRINCIPLES.md +9 -0
- package/README.md +6 -0
- package/design-review.schema.json +156 -0
- package/dist/{audit-LQ2TDISV.js → audit-CRTC7JSF.js} +3 -3
- package/dist/audit.cjs +365 -15
- package/dist/audit.js +365 -15
- package/dist/{chunk-DE2ZAV4G.js → chunk-3LCBLDAO.js} +171 -3
- package/dist/{chunk-6PZG4MJS.js → chunk-3LHIKF5L.js} +5 -5
- package/dist/{chunk-G47ZCBD5.js → chunk-4NON4PVK.js} +2 -2
- package/dist/{chunk-6DRBFDWT.js → chunk-A7KUKNX6.js} +1 -1
- package/dist/{chunk-OVWVKMPS.js → chunk-KGEJ2TGT.js} +14 -14
- package/dist/{chunk-ONA73THJ.js → chunk-KIBDFRHT.js} +195 -13
- package/dist/{chunk-ZS7PQNJU.js → chunk-OTQQTXXK.js} +4 -4
- package/dist/{chunk-RBQCGDQQ.js → chunk-Y2LGYHJ5.js} +5 -5
- package/dist/{cli-R2HBESHI.js → cli-E2CFVIGZ.js} +5 -5
- package/dist/{cli-SZ4SZLUN.js → cli-HWGSNSDD.js} +4 -4
- package/dist/{cli-7DL4BKA7.js → cli-IYRJZWMN.js} +6 -6
- package/dist/{cli-YNIJOEJK.js → cli-RHU3MAZY.js} +3 -3
- package/dist/{cli-AM4FLXGM.js → cli-ROVZDHVR.js} +5 -5
- package/dist/{cli-T7A3BU5J.js → cli-TPV2EIIN.js} +9 -9
- package/dist/{cli-WZA5IXSR.js → cli-X3RZE4GM.js} +6 -6
- package/dist/{cli-7QL26YPR.js → cli-YRRBBU27.js} +1 -1
- package/dist/cli.js +3 -3
- package/dist/index.cjs +368 -15
- package/dist/index.d.cts +49 -3
- package/dist/index.d.ts +49 -3
- package/dist/index.js +367 -15
- package/dist/{viewports-cli-F7KJIVZL.js → viewports-cli-EAUVLHFJ.js} +3 -3
- package/package.json +1 -1
- package/word-lists.json +11 -2
package/dist/audit.js
CHANGED
|
@@ -193,7 +193,17 @@ var REVIEW_PHRASES = [
|
|
|
193
193
|
"by far the most",
|
|
194
194
|
"hands down the",
|
|
195
195
|
"arguably the best",
|
|
196
|
-
"bar none"
|
|
196
|
+
"bar none",
|
|
197
|
+
// "plain" as a self-description, the way "clear" is: say what the reader gets.
|
|
198
|
+
"plain answer",
|
|
199
|
+
"plain answers",
|
|
200
|
+
"plain recommendation",
|
|
201
|
+
"plain explanation",
|
|
202
|
+
"plain explanations",
|
|
203
|
+
"plain advice",
|
|
204
|
+
"plain guidance",
|
|
205
|
+
"plain-speaking",
|
|
206
|
+
"plain speaking"
|
|
197
207
|
];
|
|
198
208
|
var SALES_TAGLINES = [
|
|
199
209
|
/\bwhere it (?:really |truly )?(?:counts|matters)(?: most)?\b/i,
|
|
@@ -249,6 +259,34 @@ var CLIPPED = [
|
|
|
249
259
|
// "Alright Carlo, built you a proper site": the "I" left out
|
|
250
260
|
/(?<=^|[\n>"“]|[.!?—–-]\s*|,\s+)(?:[Bb]uilt|[Mm]ade|[Pp]ut together|[Kk]nocked up)\s+(?:you|your\s+\w+|the\s+\w+)\s+an?\b/
|
|
251
261
|
];
|
|
262
|
+
var PLAIN_FAMILY = [
|
|
263
|
+
/\b(?:plain english|explained plainly|plainly explained)\b/i,
|
|
264
|
+
/\bplainly\b(?!\s+marked\b)/i,
|
|
265
|
+
/\bin\s+plain\s+(?:terms|language|words|speak)\b/i
|
|
266
|
+
];
|
|
267
|
+
var CONFESSION = [
|
|
268
|
+
// "What this is and is not:", "what we are and aren't"
|
|
269
|
+
/\bwhat\s+(?:this|it|we|our\s+\w+)\s+(?:is|are)\s+and\s+(?:is\s+not|isn['’]t|are\s+not|aren['’]t)\b/i,
|
|
270
|
+
// "What we are not", a label on its own; not "what we are not responsible for"
|
|
271
|
+
/(?<!\band\s)\bwhat\s+we(?:\s+are|['’]re)\s+not\b(?!\s+(?:responsible|liable|able|allowed|sure)\b)/i,
|
|
272
|
+
/\bwhat\s+we\s+(?:don['’]t|do\s+not)\s+do\b/i,
|
|
273
|
+
// "We have not yet built and run", "we haven't yet worked with"
|
|
274
|
+
/\bwe(?:\s+have|['’]ve)\s+not\s+yet\s+(?:built|run|done|worked|delivered|shipped|launched)\b/i,
|
|
275
|
+
/\bwe\s+haven['’]t\s+(?:yet\s+)?(?:built|run|done|worked\s+with|delivered)\b/i,
|
|
276
|
+
/\bwhat\s+we\s+have\s+and\s+(?:haven['’]t|have\s+not)\s+(?:done|built|run)\b/i,
|
|
277
|
+
// "Our clinic experience so far is one practice"
|
|
278
|
+
/\bour\s+(?:[\w-]+\s+){0,3}experience\s+so\s+far\s+(?:is|has\s+been|covers)\b/i,
|
|
279
|
+
/\bso\s+far\s+(?:is|has\s+been)\s+(?:one|a\s+single|just\s+one)\b/i,
|
|
280
|
+
/\bwhere\s+our\s+experience\s+(?:applies|ends|stops|runs\s+out)\b/i,
|
|
281
|
+
/\bwhat\s+we\s+(?:would|['’]d|will|still)\s+need\s+to\s+learn\b/i,
|
|
282
|
+
// "what we would and wouldn't take on", "what we won't take on"
|
|
283
|
+
/\b(?:would|will)\s+and\s+(?:wouldn['’]t|would\s+not|won['’]t|will\s+not)\s+take\s+on\b/i,
|
|
284
|
+
/\bwhat\s+we\s+(?:won['’]t|wouldn['’]t|would\s+not|will\s+not)\s+take\s+on\b/i,
|
|
285
|
+
// "Who we don't work with", "who this is not for", "we're not for everyone"
|
|
286
|
+
/\bwho\s+we\s+(?:don['’]t|do\s+not|won['’]t|will\s+not)\s+(?:work\s+with|serve|help|take\s+on)\b/i,
|
|
287
|
+
/\bwho\s+(?:this|it|we)\s+(?:is|are|['’]re)\s+not\s+for\b|\bwho\s+(?:this|it)\s+isn['’]t\s+for\b/i,
|
|
288
|
+
/\b(?:we|this)\s+(?:are|is|['’]re|['’]s)\s+not\s+(?:for\s+everyone|the\s+right\s+fit\s+for)\b/i
|
|
289
|
+
];
|
|
252
290
|
var NEGATION = [
|
|
253
291
|
// "It's not just a haircut, it's an experience."
|
|
254
292
|
/\b(?:it['’]?s|it is|this is|this isn['’]t|we['’]re|we are|they['’]re)\s+(?:not|more than)\s+(?:just|only|merely|simply)?\s*[^.!?\n]{1,60}?[,;.—-]\s*(?:it['’]?s|it is|this is|we['’]re|we are|they['’]re)\b/i,
|
|
@@ -262,8 +300,33 @@ var NEGATION = [
|
|
|
262
300
|
/\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
|
|
263
301
|
/\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
|
|
264
302
|
// parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
|
|
265
|
-
/\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
|
|
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
|
|
266
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
|
+
};
|
|
267
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;
|
|
268
331
|
var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
|
|
269
332
|
function lineAround(text, index) {
|
|
@@ -378,12 +441,25 @@ var COPY_TELLS = [
|
|
|
378
441
|
generation: 2,
|
|
379
442
|
severity: "warn",
|
|
380
443
|
surface: "copy",
|
|
381
|
-
why: "Told to avoid jargon, a model announces that it is avoiding jargon
|
|
382
|
-
fix: "
|
|
383
|
-
detect: phrases(
|
|
444
|
+
why: "Told to avoid jargon, a model announces that it is avoiding jargon: 'in plain English', 'stated plainly', 'we'll tell you plainly', 'in plain terms'. The founder bans the whole family (20 October 2026); a reader takes it as filler.",
|
|
445
|
+
fix: "Drop the adverb and say the thing: 'Our prices, stated plainly' becomes 'Our prices'. Where the claim matters, the house replacement for 'plain English' is 'properly explained'.",
|
|
446
|
+
detect: phrases(PLAIN_FAMILY, quoted),
|
|
384
447
|
fixtures: {
|
|
385
|
-
flag: [
|
|
386
|
-
|
|
448
|
+
flag: [
|
|
449
|
+
f("content/home.md", "Your options, in plain English."),
|
|
450
|
+
// Eunice Ordor's draft lines, Pastel 986559 #12 and #13 (her diagnosis taken, her wording not).
|
|
451
|
+
f("content/clinics.md", "Tell us what's happening and we'll say plainly what we would take on."),
|
|
452
|
+
f("content/pricing.md", "DD's payment terms, plainly stated."),
|
|
453
|
+
f("content/clinics.md", "We explain our findings in plain terms."),
|
|
454
|
+
f("content/clinics.md", "We share findings in plain language.")
|
|
455
|
+
],
|
|
456
|
+
pass: [
|
|
457
|
+
f("content/home.md", "Your options, properly explained."),
|
|
458
|
+
f("content/pricing.md", "Our payment terms: 30% when you sign, the rest at launch."),
|
|
459
|
+
// The Apache and zlib licence wording, copied into a repo as it is.
|
|
460
|
+
f("LICENSE.txt", "Altered source versions must be plainly marked as such."),
|
|
461
|
+
f("content/menu.md", "Plain or seeded bagels, and plain-language allergen notes.")
|
|
462
|
+
]
|
|
387
463
|
}
|
|
388
464
|
},
|
|
389
465
|
{
|
|
@@ -434,11 +510,11 @@ var COPY_TELLS = [
|
|
|
434
510
|
generation: 1,
|
|
435
511
|
severity: "warn",
|
|
436
512
|
surface: "copy",
|
|
437
|
-
why: "Truly, genuinely, clear, ultimately, 'the single most', 'bar none': normal English once, and a tell when they stack. The house rules keep them for review, never blocking.",
|
|
513
|
+
why: "Truly, genuinely, clear, ultimately, 'a plain recommendation', 'the single most', 'bar none': normal English once, and a tell when they stack. The house rules keep them for review, never blocking.",
|
|
438
514
|
fix: "Keep the one that lands hardest and cut the rest. 'What is genuinely excellent' reads better as 'what is excellent'.",
|
|
439
515
|
detect: phrases([new RegExp(`(?:^|\\b)(?:${[...REVIEW_PHRASES].sort((a, b) => b.length - a.length).map((p) => escape(p).replace(/^(\w)/, "\\b$1").replace(/(\w)$/, "$1\\b")).join("|")})`, "i")], quoted),
|
|
440
516
|
fixtures: {
|
|
441
|
-
flag: [f("content/home.md", "We are truly proud of it."), f("content/home.md", "You leave with a clear next step.")],
|
|
517
|
+
flag: [f("content/home.md", "We are truly proud of it."), f("content/home.md", "You leave with a clear next step."), f("content/process.md", "You get a plain recommendation and someone who knows your systems.")],
|
|
442
518
|
pass: [f("content/home.md", "We have fitted 400 boilers since 2009."), f("content/home.md", "Keep a safe clearance around the meter.")]
|
|
443
519
|
}
|
|
444
520
|
},
|
|
@@ -519,6 +595,40 @@ var COPY_TELLS = [
|
|
|
519
595
|
]
|
|
520
596
|
}
|
|
521
597
|
},
|
|
598
|
+
{
|
|
599
|
+
id: "confession-line",
|
|
600
|
+
name: "Confession line",
|
|
601
|
+
generation: 2,
|
|
602
|
+
severity: "warn",
|
|
603
|
+
surface: "copy",
|
|
604
|
+
why: "'What this is and is not', 'we have not yet built', 'our experience so far is one practice', 'what we would and wouldn't take on': a model scopes a business by confessing its limits. It reads as small print, and it turns a buyer away before they make contact.",
|
|
605
|
+
fix: "Scope by being precise about what is true, and leave the door open: 'Built and run by us for Sensphere, a practice whose clinicians work across several sites.' Keep contractual facts (eligibility, who runs what) and say them as what the buyer gets.",
|
|
606
|
+
detect: phrases(CONFESSION, quoted),
|
|
607
|
+
fixtures: {
|
|
608
|
+
flag: [
|
|
609
|
+
// Each one from the DD site, as Eunice Ordor flagged it (Pastel 986559 #8, #12, #13).
|
|
610
|
+
f("content/clinics.md", "What this is and is not: a practice portal we built and run for one practice."),
|
|
611
|
+
f("content/clinics.md", "We have not yet built and run the digital systems of a practice with several clinicians."),
|
|
612
|
+
f("content/home.md", "Including the client portal we run for Sensphere. Our clinic experience so far is one practice."),
|
|
613
|
+
f("content/clinics.md", "Tell us what's happening and we'll say what we would and wouldn't take on."),
|
|
614
|
+
f("content/clinics.md", "We will say what carries over from this work and what we would need to learn first."),
|
|
615
|
+
f("content/clinics.md", "We will tell you where our experience applies directly and where it is close."),
|
|
616
|
+
f("content/about.md", "What we are not"),
|
|
617
|
+
f("content/about.md", "Who we don't work with: anyone who needs a site this week."),
|
|
618
|
+
f("content/mobile.md", "Mobile is our newest line, so we tell you what we have and haven't done.")
|
|
619
|
+
],
|
|
620
|
+
pass: [
|
|
621
|
+
f("content/clinics.md", "Built and run by us for Sensphere, a growing practice whose clinicians work remotely."),
|
|
622
|
+
f("content/home.md", "The client portal we run for Sensphere."),
|
|
623
|
+
// Contractual facts, said as they are.
|
|
624
|
+
f("content/pricing.md", "Pay monthly is open to UK residents aged 18 and over, subject to status."),
|
|
625
|
+
f("content/pricing.md", "Your monthly payments do not buy the site; the buy-out price does."),
|
|
626
|
+
f("content/terms.md", "What we are responsible for, and what we are not responsible for, is in clause 4."),
|
|
627
|
+
f("content/insurance.md", "Ask what your policy does and does not cover."),
|
|
628
|
+
f("emails/deposit.md", "We have not yet received your deposit, so the date is still open.")
|
|
629
|
+
]
|
|
630
|
+
}
|
|
631
|
+
},
|
|
522
632
|
{
|
|
523
633
|
id: "hollow-imperative",
|
|
524
634
|
name: "Hollow imperative",
|
|
@@ -597,7 +707,7 @@ var COPY_TELLS = [
|
|
|
597
707
|
generation: 2,
|
|
598
708
|
severity: "warn",
|
|
599
709
|
surface: "copy",
|
|
600
|
-
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.",
|
|
601
711
|
fix: "Say the positive claim and drop the contrast.",
|
|
602
712
|
detect: phrases(NEGATION, quoted, allText),
|
|
603
713
|
fixtures: {
|
|
@@ -608,12 +718,54 @@ var COPY_TELLS = [
|
|
|
608
718
|
f("content/home.md", "Hairdressing isn't a job, it's art."),
|
|
609
719
|
f("content/home.md", "It is less about the tools and more about the eye."),
|
|
610
720
|
f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
|
|
611
|
-
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.")
|
|
612
727
|
],
|
|
613
728
|
pass: [
|
|
614
729
|
f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
|
|
615
730
|
f("content/home.md", "They are cosmetics, not medicines."),
|
|
616
|
-
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.")
|
|
617
769
|
]
|
|
618
770
|
}
|
|
619
771
|
},
|
|
@@ -663,7 +815,7 @@ var COPY_TELLS = [
|
|
|
663
815
|
generation: 2,
|
|
664
816
|
severity: "warn",
|
|
665
817
|
surface: "copy",
|
|
666
|
-
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.",
|
|
667
819
|
fix: "Write one sentence that says which of the three is true, and how you know.",
|
|
668
820
|
// The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
|
|
669
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),
|
|
@@ -675,6 +827,36 @@ var COPY_TELLS = [
|
|
|
675
827
|
]
|
|
676
828
|
}
|
|
677
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
|
+
},
|
|
678
860
|
{
|
|
679
861
|
id: "where-x-meets-y",
|
|
680
862
|
name: "'Where X meets Y'",
|
|
@@ -1920,6 +2102,69 @@ function eyebrowChipHits(ctx) {
|
|
|
1920
2102
|
}
|
|
1921
2103
|
return hits;
|
|
1922
2104
|
}
|
|
2105
|
+
var HERO_OPEN = /<(section|header|div|main|article|Section|Container)\b[^>]*?\b(?:class|className|id)\s*=\s*(?:"|'|\{\s*[`"']?)[^"'`>]*?(?<![a-z])hero(?![a-z])[^>]*>/gi;
|
|
2106
|
+
function closeOf(text, tag, from) {
|
|
2107
|
+
const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, "g");
|
|
2108
|
+
re.lastIndex = from;
|
|
2109
|
+
let depth = 0;
|
|
2110
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
2111
|
+
if (m[2] === "/") continue;
|
|
2112
|
+
depth += m[1] === "/" ? -1 : 1;
|
|
2113
|
+
if (depth === 0) return m.index + m[0].length;
|
|
2114
|
+
if (m.index - from > 8e3) break;
|
|
2115
|
+
}
|
|
2116
|
+
return Math.min(text.length, from + 4e3);
|
|
2117
|
+
}
|
|
2118
|
+
function heroSpans(path, text) {
|
|
2119
|
+
if (/(?:^|[\\/])[\w-]*hero[\w-]*\.(?:astro|tsx|jsx|vue|svelte)$/i.test(path)) return [[0, text.length]];
|
|
2120
|
+
const spans = [];
|
|
2121
|
+
for (const m of text.matchAll(HERO_OPEN)) {
|
|
2122
|
+
const at = m.index ?? 0;
|
|
2123
|
+
if (spans.some(([a, b]) => at >= a && at < b)) continue;
|
|
2124
|
+
spans.push([at, closeOf(text, m[1], at)]);
|
|
2125
|
+
}
|
|
2126
|
+
if (spans.length > 0) return spans;
|
|
2127
|
+
const h1 = text.search(/<h1\b/);
|
|
2128
|
+
if (h1 === -1) return [];
|
|
2129
|
+
const rest = text.slice(h1);
|
|
2130
|
+
const end = rest.search(/<\/(?:section|header)>|<section\b/);
|
|
2131
|
+
return [[h1, h1 + Math.min(end === -1 ? rest.length : end, 1500)]];
|
|
2132
|
+
}
|
|
2133
|
+
var CONTROL = /<(a|button|Button|ButtonLink|LinkButton|Link|Cta|CTA|CallToAction)\b([^>]*)>([\s\S]{0,300}?)<\/\1>/g;
|
|
2134
|
+
var BUTTON_COMPONENT = /^(?:Button|ButtonLink|LinkButton|Cta|CTA|CallToAction)$/;
|
|
2135
|
+
var BUTTON_CLASS = /(?<![a-z])(?:btn|button|cta)(?![a-z])/i;
|
|
2136
|
+
var TOGGLE = /\baria-(?:expanded|controls|haspopup)\b|\btype\s*=\s*["']submit["']/;
|
|
2137
|
+
var NOT_A_LANDING = /(?:^|[\\/])(?:error|global-error|not-found|404|500)\.\w+$|[\\/](?:admin|portal|dashboard|account|\(qa\)|\(app\)|qa)[\\/]/i;
|
|
2138
|
+
function buttonLabel(attrs, inner) {
|
|
2139
|
+
const text = inner.replace(/<[^>]*>/g, " ").replace(/\{[^}]*\}/g, " ").replace(/\s+/g, " ").trim();
|
|
2140
|
+
if (text && !/[{}()=]/.test(text)) return text.slice(0, 40);
|
|
2141
|
+
return attrs.match(/\bhref\s*=\s*["'{`$]*\{?([^"'}`\s]+)/)?.[1] ?? "a button";
|
|
2142
|
+
}
|
|
2143
|
+
function heroCtaPairHits(ctx) {
|
|
2144
|
+
const hits = [];
|
|
2145
|
+
for (const file of ctx.files) {
|
|
2146
|
+
if (file.kind !== "markup" || NOT_A_LANDING.test(file.path)) continue;
|
|
2147
|
+
const text = file.text.replace(/\\"/g, ' "');
|
|
2148
|
+
for (const [start, end] of heroSpans(file.path, text)) {
|
|
2149
|
+
const span = text.slice(start, end).replace(/<(nav|form)\b[\s\S]*?<\/\1>/gi, (s) => " ".repeat(s.length));
|
|
2150
|
+
const buttons = [];
|
|
2151
|
+
for (const m of span.matchAll(CONTROL)) {
|
|
2152
|
+
const [, tag, attrs, inner] = m;
|
|
2153
|
+
if (TOGGLE.test(attrs)) continue;
|
|
2154
|
+
const cls = attrs.match(/\b(?:class|className|variant)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g)?.join(" ") ?? "";
|
|
2155
|
+
const tw = /\bpx-\d/.test(cls) && /\bpy-\d/.test(cls) && /\b(?:bg-|border\b|border-|rounded)/.test(cls);
|
|
2156
|
+
if (BUTTON_COMPONENT.test(tag) || BUTTON_CLASS.test(cls) || tw || /\brole\s*=\s*["']button["']/.test(attrs)) {
|
|
2157
|
+
buttons.push({ label: buttonLabel(attrs, inner), offset: start + (m.index ?? 0) });
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
if (buttons.length >= 2) {
|
|
2161
|
+
const names = buttons.slice(0, 3).map((b) => `"${b.label}"`).join(", ");
|
|
2162
|
+
hits.push({ path: file.path, offset: buttons[1].offset, message: `${buttons.length} buttons side by side in the hero: ${names}` });
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return hits;
|
|
2167
|
+
}
|
|
1923
2168
|
function iconTiles(file) {
|
|
1924
2169
|
return file.classUnits.filter((unit) => {
|
|
1925
2170
|
const sized = has(unit, /^size-(?:8|9|10|11|12|14)$/) || (find(unit, /^h-(?:8|9|10|11|12|14)$/) ?? "x").slice(2) === (find(unit, /^w-(?:8|9|10|11|12|14)$/) ?? "y").slice(2);
|
|
@@ -2370,6 +2615,75 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2370
2615
|
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
2371
2616
|
}
|
|
2372
2617
|
},
|
|
2618
|
+
{
|
|
2619
|
+
id: "hero-cta-pair",
|
|
2620
|
+
name: "Two buttons in the hero",
|
|
2621
|
+
generation: 1,
|
|
2622
|
+
severity: "warn",
|
|
2623
|
+
surface: "source",
|
|
2624
|
+
why: "A filled button beside an outline or ghost one ('Get a free quote' / 'See our plans') is in 140 of 140 heroes Claude builds for a brief. Two equal asks make the visitor choose before they know anything, and say nobody decided what the page is for.",
|
|
2625
|
+
fix: "Keep one button, the ask the page exists for. Put the second route in the copy as a text link ('or see the work first'), or lower down where it answers a question.",
|
|
2626
|
+
detect: heroCtaPairHits,
|
|
2627
|
+
fixtures: {
|
|
2628
|
+
flag: [
|
|
2629
|
+
// Eunice Ordor's #5 and #36 (Pastel 986559): the DD site's own hero and process close.
|
|
2630
|
+
f3(
|
|
2631
|
+
"src/components/Hero.astro",
|
|
2632
|
+
`<section class="hero">
|
|
2633
|
+
<h1>We run the systems behind your website</h1>
|
|
2634
|
+
<a href="/contact" class="btn btn-primary">Get in touch</a>
|
|
2635
|
+
<a href="/work" class="btn btn-ghost">See our work</a>
|
|
2636
|
+
</section>`
|
|
2637
|
+
),
|
|
2638
|
+
// The null build's hero, verbatim in shape (calibration/null/rmp/pages/05.html).
|
|
2639
|
+
f3(
|
|
2640
|
+
"index.html",
|
|
2641
|
+
`<nav><a class="btn" href="#contact">Quote</a></nav>
|
|
2642
|
+
<section id="hero" class="hero">
|
|
2643
|
+
<h1>Electricians in Uxbridge</h1>
|
|
2644
|
+
<a href="#contact" class="btn btn-primary">Book a Free Quote</a>
|
|
2645
|
+
<a href="tel:+441895555210" class="btn btn-outline">Call 01895 555 210</a>
|
|
2646
|
+
</section>`
|
|
2647
|
+
),
|
|
2648
|
+
f3(
|
|
2649
|
+
"app/page.tsx",
|
|
2650
|
+
`<section className="relative py-24">
|
|
2651
|
+
<h1 className="text-5xl">Nails in Brackley</h1>
|
|
2652
|
+
<div className="flex gap-4">
|
|
2653
|
+
<Button asChild><a href="/book">Book now</a></Button>
|
|
2654
|
+
<Button variant="outline">View treatments</Button>
|
|
2655
|
+
</div>
|
|
2656
|
+
</section>`
|
|
2657
|
+
)
|
|
2658
|
+
],
|
|
2659
|
+
pass: [
|
|
2660
|
+
// One button, and the second route as a text link in the sentence.
|
|
2661
|
+
f3(
|
|
2662
|
+
"src/components/Hero.astro",
|
|
2663
|
+
`<section class="hero">
|
|
2664
|
+
<h1>We run the systems behind your website</h1>
|
|
2665
|
+
<p>Tell us what's going on, or <a href="/work">see the work first</a>.</p>
|
|
2666
|
+
<a href="/contact" class="btn btn-primary">Get in touch</a>
|
|
2667
|
+
</section>`
|
|
2668
|
+
),
|
|
2669
|
+
// A button in the nav and a search form are not the hero's ask.
|
|
2670
|
+
f3(
|
|
2671
|
+
"index.html",
|
|
2672
|
+
`<header class="hero">
|
|
2673
|
+
<nav><a class="btn" href="/book">Book</a></nav>
|
|
2674
|
+
<h1>Barbers in Manchester</h1>
|
|
2675
|
+
<a class="btn" href="/book">Book a cut</a>
|
|
2676
|
+
<form><button class="btn" type="submit">Search</button></form>
|
|
2677
|
+
</header>`
|
|
2678
|
+
),
|
|
2679
|
+
f3("app/page.tsx", `<section className="py-24">
|
|
2680
|
+
<h1 className="text-5xl">Nails in Brackley</h1>
|
|
2681
|
+
<a className="rounded bg-ink px-5 py-3" href="/book">Book now</a>
|
|
2682
|
+
</section>
|
|
2683
|
+
<section><a className="rounded bg-ink px-5 py-3" href="/prices">Prices</a></section>`)
|
|
2684
|
+
]
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2373
2687
|
{
|
|
2374
2688
|
id: "icon-tile-stack",
|
|
2375
2689
|
name: "Icon in a tinted tile",
|
|
@@ -3123,7 +3437,9 @@ var HOUSE = {
|
|
|
3123
3437
|
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
3124
3438
|
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
3125
3439
|
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
3126
|
-
|
|
3440
|
+
// Widened 2026-10-20 (founder) to bare "plainly" and "in plain terms/language/words";
|
|
3441
|
+
// estate hits read first (DOM-1058), nearly all the stock "stated plainly".
|
|
3442
|
+
"plain-english": { tier: "block", label: 'the plain-English family. Drop the adverb and say the thing, or use "properly explained"' },
|
|
3127
3443
|
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
3128
3444
|
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
3129
3445
|
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
@@ -3137,6 +3453,13 @@ var HOUSE = {
|
|
|
3137
3453
|
"stock-email-line": { tier: "review", label: "stock email line, start with the reason for writing and end with the next step" },
|
|
3138
3454
|
// New 2026-10-19 (founder): review first, like every new tell, until estate hits are read.
|
|
3139
3455
|
"matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
|
|
3456
|
+
// New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
|
|
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" },
|
|
3140
3463
|
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3141
3464
|
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3142
3465
|
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
@@ -3362,6 +3685,33 @@ var RENDERED_PATHS = {
|
|
|
3362
3685
|
detect: (s) => s.effects.eyebrowChip ? [hit(s, "a small bordered pill sits above the headline")] : [],
|
|
3363
3686
|
fixtures: { flag: [makeSnapshot({ effects: { eyebrowChip: true } })], pass: [makeSnapshot()] }
|
|
3364
3687
|
},
|
|
3688
|
+
"hero-cta-pair": {
|
|
3689
|
+
// Version 2 geometry counts buttons and button-styled links per section. A
|
|
3690
|
+
// header painted inside the hero's box adds its own button, which the
|
|
3691
|
+
// source path can tell apart and this one cannot; a ghost button with no
|
|
3692
|
+
// fill or border is not counted, so this path under-reports.
|
|
3693
|
+
detect: (s) => {
|
|
3694
|
+
const first = s.sections[0];
|
|
3695
|
+
const controls = first?.geometry?.controls ?? 0;
|
|
3696
|
+
return first && (first.role ?? first.kind) === "hero" && controls >= 2 ? [hit(s, `${controls} buttons in the hero`, first.label)] : [];
|
|
3697
|
+
},
|
|
3698
|
+
fixtures: {
|
|
3699
|
+
flag: [
|
|
3700
|
+
{
|
|
3701
|
+
...makeSnapshot(),
|
|
3702
|
+
sections: makeSnapshot().sections.map((x, i) => i === 0 ? { ...x, role: "hero", geometry: { centredShare: 0.2, mirrorSymmetry: 0.6, whitespaceRatio: 0.6, contentWidthRatio: 0.8, background: "rgb(255, 255, 255)", controls: 2 } } : x)
|
|
3703
|
+
}
|
|
3704
|
+
],
|
|
3705
|
+
pass: [
|
|
3706
|
+
makeSnapshot(),
|
|
3707
|
+
// One button, with the second route as a text link: one control.
|
|
3708
|
+
{
|
|
3709
|
+
...makeSnapshot(),
|
|
3710
|
+
sections: makeSnapshot().sections.map((x, i) => i === 0 ? { ...x, role: "hero", geometry: { centredShare: 0.2, mirrorSymmetry: 0.6, whitespaceRatio: 0.6, contentWidthRatio: 0.8, background: "rgb(255, 255, 255)", controls: 1 } } : x)
|
|
3711
|
+
}
|
|
3712
|
+
]
|
|
3713
|
+
}
|
|
3714
|
+
},
|
|
3365
3715
|
"icon-tile-grid": {
|
|
3366
3716
|
detect: (s) => s.sections.filter((x) => x.cards >= 3 && x.iconCards >= 3).map((x) => hit(s, `${x.iconCards} icon cards in a row`, x.label)),
|
|
3367
3717
|
fixtures: {
|
|
@@ -3878,7 +4228,7 @@ var IMAGERY_TELLS = [
|
|
|
3878
4228
|
];
|
|
3879
4229
|
|
|
3880
4230
|
// src/character/check.ts
|
|
3881
|
-
var CATALOGUE_VERSION = "2026.10.
|
|
4231
|
+
var CATALOGUE_VERSION = "2026.10.7";
|
|
3882
4232
|
var CATALOGUE = [
|
|
3883
4233
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3884
4234
|
...SPECIFICITY_COPY_TELLS,
|