@domandigital/craft 0.23.1 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.23.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1e4bcb5: <!-- copy-ok: names the shapes the release starts reporting --> A new review-tier copy tell, `absolute-negation` (catalogue 2026.10.9), reports a verbless fragment that opens on never, nothing, nobody or "no" + noun after a clause break: "A guardian consents by email; never pre-ticked", "Pay per user per month; no servers", "nothing to buy or run", "Nothing on-site to join", "no duplicates". A 92EL deck passed the gate with them. It never blocks. A sentence with a subject and a verb ("Players never open the lists", "Nothing is copied by hand"), an instruction ("Never share your password"), a Harvard citation with no date ("ICO, no date") and a "No" form answer pass, and a fragment the blocking `no-x-no-y`, `no-x-badge` or `negative-reassurance` already reports is not reported twice.
8
+
3
9
  ## 0.23.1
4
10
 
5
11
  ### Patch Changes
package/CHARACTER.md CHANGED
@@ -1470,7 +1470,7 @@ Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
1470
1470
  after changing an entry; a test fails until you do.
1471
1471
 
1472
1472
  <!-- craft:catalogue:start -->
1473
- Catalogue version `2026.10.8`, 76 tells.
1473
+ Catalogue version `2026.10.9`, 77 tells.
1474
1474
 
1475
1475
  | Id | Gen | Surface | Severity | Tell | Why it is a default |
1476
1476
  | --- | --- | --- | --- | --- | --- |
@@ -1520,6 +1520,7 @@ Catalogue version `2026.10.8`, 76 tells.
1520
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. |
1521
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
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. |
1523
+ | `absolute-negation` | 2 | copy | warn | Absolute negation fragment | 'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not. |
1523
1524
  | `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. |
1524
1525
  | `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. |
1525
1526
  | `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
@@ -101,6 +101,7 @@ person reading decides which instance earns its place.
101
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 |
102
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
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 |
104
+ | `absolute-negation` | a verbless fragment that opens on never, nothing, nobody or "no" + noun, after a clause break: "A guardian consents by email; never pre-ticked", "Pay per user per month; no servers", "nothing to buy or run", "Nothing on-site to join", "no duplicates". Read aloud it sounds rigid and promises an absolute. A sentence with a subject and a verb ("Players never open the lists", "Nothing is copied by hand"), an instruction ("Never share your password"), a citation with no date ("ICO, no date") and a "No" form answer pass. Say what happens as a sentence; keep the absolute only where it is literally true and the reader needs it |
104
105
  | `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 |
105
106
  | `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 |
106
107
  | `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 |
@@ -9,10 +9,10 @@ import {
9
9
  snapshotPage,
10
10
  snapshotUrl,
11
11
  snapshotUrls
12
- } from "./chunk-ZV3FPQPM.js";
12
+ } from "./chunk-NK6AAY7O.js";
13
13
  import "./chunk-JYNXGCYZ.js";
14
- import "./chunk-GA3AJN67.js";
15
- import "./chunk-EHMEMSM2.js";
14
+ import "./chunk-A5M4AOMY.js";
15
+ import "./chunk-ZTHVOXZF.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
18
18
  PROVENANCE_BUDGET_MS,
package/dist/audit.cjs CHANGED
@@ -358,6 +358,29 @@ var PARALLEL_PAIR = [
358
358
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
359
359
  /^[ \t]*(?:#{1,6}[ \t]+)?[A-Z][\w'’-]*(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}(?:,[ \t]+(?!(?:and|or)\b)[\w'’-]+(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}){2,3}\.[ \t]*$/m
360
360
  ];
361
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
362
+ var ABSOLUTE_NEGATION = new RegExp(
363
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
364
+ "im"
365
+ );
366
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
367
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
368
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
369
+ function notAbsoluteFragment(block, match, index) {
370
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
371
+ let words = rest;
372
+ const lead = head.toLowerCase();
373
+ if (lead === "no" && words[0]?.toLowerCase() === "one") words = words.slice(1);
374
+ else if (lead === "no" && NO_IDIOM.test(words[0] ?? "")) return true;
375
+ if (words.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
376
+ if (lead === "never" && !PARTICIPLE.test(words[0] ?? "")) return true;
377
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words[0] ?? "")) return true;
378
+ const sentence = lineAround(block.text, index);
379
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
380
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
381
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
382
+ return emptyState(block, match, index);
383
+ }
361
384
  var alreadyBlocked = (block, match, index) => {
362
385
  const before = block.text.slice(0, index);
363
386
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -896,6 +919,46 @@ var COPY_TELLS = [
896
919
  ]
897
920
  }
898
921
  },
922
+ {
923
+ id: "absolute-negation",
924
+ name: "Absolute negation fragment",
925
+ generation: 2,
926
+ severity: "warn",
927
+ surface: "copy",
928
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
929
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
930
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
931
+ fixtures: {
932
+ flag: [
933
+ // From a 92EL deck that passed the gate, October 2026.
934
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
935
+ f("content/deck.md", "Pay per user per month; no servers."),
936
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
937
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
938
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
939
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
940
+ ],
941
+ pass: [
942
+ f("content/report.md", "Players never open the lists."),
943
+ f("content/report.md", "Nothing is copied by hand."),
944
+ f("content/report.md", "It is not tested at national scale."),
945
+ f("content/report.md", "Never share your password with anyone."),
946
+ f("content/report.md", "No one knows the password but the club secretary."),
947
+ // A Harvard citation with no date.
948
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
949
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
950
+ // A form answer option.
951
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
952
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
953
+ f("content/form.md", "No, the player is 18 or over."),
954
+ f("content/home.md", "We no longer post paper forms."),
955
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
956
+ // The blocking tells report these.
957
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
958
+ f("app/list.tsx", `export const empty = "No results found.";`)
959
+ ]
960
+ }
961
+ },
899
962
  {
900
963
  id: "where-x-meets-y",
901
964
  name: "'Where X meets Y'",
@@ -3499,6 +3562,8 @@ var HOUSE = {
3499
3562
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3500
3563
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3501
3564
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3565
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
3566
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
3502
3567
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3503
3568
  "stock-phrase": { tier: "review", label: "stock phrase" },
3504
3569
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4267,7 +4332,7 @@ var IMAGERY_TELLS = [
4267
4332
  ];
4268
4333
 
4269
4334
  // src/character/check.ts
4270
- var CATALOGUE_VERSION = "2026.10.8";
4335
+ var CATALOGUE_VERSION = "2026.10.9";
4271
4336
  var CATALOGUE = [
4272
4337
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4273
4338
  ...SPECIFICITY_COPY_TELLS,
package/dist/audit.js CHANGED
@@ -323,6 +323,29 @@ var PARALLEL_PAIR = [
323
323
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
324
324
  /^[ \t]*(?:#{1,6}[ \t]+)?[A-Z][\w'’-]*(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}(?:,[ \t]+(?!(?:and|or)\b)[\w'’-]+(?:[ \t]+(?!(?:and|or)\b)[\w'’-]+){0,2}){2,3}\.[ \t]*$/m
325
325
  ];
326
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
327
+ var ABSOLUTE_NEGATION = new RegExp(
328
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
329
+ "im"
330
+ );
331
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
332
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
333
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
334
+ function notAbsoluteFragment(block, match, index) {
335
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
336
+ let words = rest;
337
+ const lead = head.toLowerCase();
338
+ if (lead === "no" && words[0]?.toLowerCase() === "one") words = words.slice(1);
339
+ else if (lead === "no" && NO_IDIOM.test(words[0] ?? "")) return true;
340
+ if (words.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
341
+ if (lead === "never" && !PARTICIPLE.test(words[0] ?? "")) return true;
342
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words[0] ?? "")) return true;
343
+ const sentence = lineAround(block.text, index);
344
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
345
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
346
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
347
+ return emptyState(block, match, index);
348
+ }
326
349
  var alreadyBlocked = (block, match, index) => {
327
350
  const before = block.text.slice(0, index);
328
351
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -861,6 +884,46 @@ var COPY_TELLS = [
861
884
  ]
862
885
  }
863
886
  },
887
+ {
888
+ id: "absolute-negation",
889
+ name: "Absolute negation fragment",
890
+ generation: 2,
891
+ severity: "warn",
892
+ surface: "copy",
893
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
894
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
895
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
896
+ fixtures: {
897
+ flag: [
898
+ // From a 92EL deck that passed the gate, October 2026.
899
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
900
+ f("content/deck.md", "Pay per user per month; no servers."),
901
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
902
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
903
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
904
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
905
+ ],
906
+ pass: [
907
+ f("content/report.md", "Players never open the lists."),
908
+ f("content/report.md", "Nothing is copied by hand."),
909
+ f("content/report.md", "It is not tested at national scale."),
910
+ f("content/report.md", "Never share your password with anyone."),
911
+ f("content/report.md", "No one knows the password but the club secretary."),
912
+ // A Harvard citation with no date.
913
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
914
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
915
+ // A form answer option.
916
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
917
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
918
+ f("content/form.md", "No, the player is 18 or over."),
919
+ f("content/home.md", "We no longer post paper forms."),
920
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
921
+ // The blocking tells report these.
922
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
923
+ f("app/list.tsx", `export const empty = "No results found.";`)
924
+ ]
925
+ }
926
+ },
864
927
  {
865
928
  id: "where-x-meets-y",
866
929
  name: "'Where X meets Y'",
@@ -3464,6 +3527,8 @@ var HOUSE = {
3464
3527
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3465
3528
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3466
3529
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
3530
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
3531
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
3467
3532
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3468
3533
  "stock-phrase": { tier: "review", label: "stock phrase" },
3469
3534
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4232,7 +4297,7 @@ var IMAGERY_TELLS = [
4232
4297
  ];
4233
4298
 
4234
4299
  // src/character/check.ts
4235
- var CATALOGUE_VERSION = "2026.10.8";
4300
+ var CATALOGUE_VERSION = "2026.10.9";
4236
4301
  var CATALOGUE = [
4237
4302
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4238
4303
  ...SPECIFICITY_COPY_TELLS,
@@ -9,7 +9,7 @@ import {
9
9
  parseFile,
10
10
  sentencesOf,
11
11
  specificity
12
- } from "./chunk-EHMEMSM2.js";
12
+ } from "./chunk-ZTHVOXZF.js";
13
13
  import {
14
14
  SNAPSHOT_VERSION
15
15
  } from "./chunk-QYBAVU3P.js";
@@ -2573,6 +2573,8 @@ var HOUSE = {
2573
2573
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
2574
2574
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
2575
2575
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
2576
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
2577
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
2576
2578
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2577
2579
  "stock-phrase": { tier: "review", label: "stock phrase" },
2578
2580
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -3195,7 +3197,7 @@ var IMAGERY_TELLS = [
3195
3197
  ];
3196
3198
 
3197
3199
  // src/character/check.ts
3198
- var CATALOGUE_VERSION = "2026.10.8";
3200
+ var CATALOGUE_VERSION = "2026.10.9";
3199
3201
  var CATALOGUE = [
3200
3202
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
3201
3203
  ...SPECIFICITY_COPY_TELLS,
@@ -4,13 +4,13 @@ import {
4
4
  formatDirectionMatches,
5
5
  summariseDirection,
6
6
  validateDirection
7
- } from "./chunk-7DPB5G4G.js";
7
+ } from "./chunk-QNX7CROK.js";
8
8
  import {
9
9
  loadNull
10
- } from "./chunk-GDDBCHQR.js";
10
+ } from "./chunk-E2ZYFBQX.js";
11
11
  import {
12
12
  parseFlags
13
- } from "./chunk-V3GJSISR.js";
13
+ } from "./chunk-CB4QDMH2.js";
14
14
  import {
15
15
  toJson
16
16
  } from "./chunk-QIEDBL4J.js";
@@ -30,7 +30,7 @@ import {
30
30
  rhythmOf,
31
31
  shapeName,
32
32
  temperatureName
33
- } from "./chunk-GA3AJN67.js";
33
+ } from "./chunk-A5M4AOMY.js";
34
34
 
35
35
  // src/estate/cli.ts
36
36
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -133,7 +133,7 @@ async function snapshotOf(target, cwd) {
133
133
  return snap;
134
134
  }
135
135
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
136
- const { snapshotUrl } = await import("./audit-DESBVHQJ.js");
136
+ const { snapshotUrl } = await import("./audit-SWLTAEDO.js");
137
137
  return snapshotUrl(target);
138
138
  }
139
139
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -10,7 +10,7 @@ import {
10
10
  holdHouseBlocks,
11
11
  houseRule,
12
12
  scanSource
13
- } from "./chunk-GA3AJN67.js";
13
+ } from "./chunk-A5M4AOMY.js";
14
14
  import {
15
15
  compareCompetitor,
16
16
  compareFacts,
@@ -21,7 +21,7 @@ import {
21
21
  pageCopy,
22
22
  protectedFacts,
23
23
  visibleText
24
- } from "./chunk-EHMEMSM2.js";
24
+ } from "./chunk-ZTHVOXZF.js";
25
25
 
26
26
  // src/character/config.ts
27
27
  var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
@@ -622,7 +622,7 @@ async function pageSnapshot(target, cwd) {
622
622
  return readSnapshot(JSON.parse(readFileSync(local, "utf8")), target);
623
623
  }
624
624
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL nor a snapshot file`);
625
- const { snapshotUrl } = await import("./audit-DESBVHQJ.js");
625
+ const { snapshotUrl } = await import("./audit-SWLTAEDO.js");
626
626
  return snapshotUrl(target);
627
627
  }
628
628
  async function briefFor(flags, cwd) {
@@ -633,7 +633,7 @@ async function briefFor(flags, cwd) {
633
633
  if (typeof brief === "string" && brief.trim()) return brief;
634
634
  } else if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
635
635
  if (flags.null) {
636
- const { loadNull } = await import("./cli-36YQA6BP.js");
636
+ const { loadNull } = await import("./cli-O2IEHB5Q.js");
637
637
  return loadNull(flags.null, cwd).brief;
638
638
  }
639
639
  return void 0;
@@ -659,37 +659,37 @@ async function compareWithCompetitor(flags, io) {
659
659
  function run(argv, io) {
660
660
  const [command, ...rest] = argv;
661
661
  if (command === "direction") {
662
- return import("./cli-TP5ZMJOL.js").then((m) => m.runDirection(rest, io));
662
+ return import("./cli-ZMLKL2NT.js").then((m) => m.runDirection(rest, io));
663
663
  }
664
664
  if (command === "report" || command === "retrofit") {
665
- return import("./cli-7IBKMUMM.js").then((m) => m.runReport(command, rest, io));
665
+ return import("./cli-7IAFZ6CV.js").then((m) => m.runReport(command, rest, io));
666
666
  }
667
667
  if (command === "register") {
668
- return import("./cli-YOSHHLIV.js").then((m) => m.runRegister(rest, io));
668
+ return import("./cli-BP4JLNAU.js").then((m) => m.runRegister(rest, io));
669
669
  }
670
670
  if (command === "estate") {
671
- return import("./cli-RXXGSKD6.js").then((m) => m.runEstate(rest, io));
671
+ return import("./cli-ATLKYXPU.js").then((m) => m.runEstate(rest, io));
672
672
  }
673
673
  if (command === "null") {
674
- return import("./cli-36YQA6BP.js").then((m) => m.runNull(rest, io));
674
+ return import("./cli-O2IEHB5Q.js").then((m) => m.runNull(rest, io));
675
675
  }
676
676
  if (command === "calibrate") {
677
- return import("./cli-FCFRPPHZ.js").then((m) => m.runCalibrate(rest, io));
677
+ return import("./cli-BA3IVSMR.js").then((m) => m.runCalibrate(rest, io));
678
678
  }
679
679
  if (command === "tells" && rest[0] === "harvest") {
680
- return import("./cli-36YQA6BP.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
680
+ return import("./cli-O2IEHB5Q.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
681
681
  io.err(`craft: ${error.message}`);
682
682
  return 2;
683
683
  });
684
684
  }
685
685
  if (command === "phone-first") {
686
- return import("./cli-SOH4QMMW.js").then((m) => m.runPhoneFirst(rest, io));
686
+ return import("./cli-UPO4PR3Z.js").then((m) => m.runPhoneFirst(rest, io));
687
687
  }
688
688
  if (command === "viewports") {
689
- return import("./viewports-cli-ET664I2Y.js").then((m) => m.runViewports(rest, io));
689
+ return import("./viewports-cli-Y4HPKZLI.js").then((m) => m.runViewports(rest, io));
690
690
  }
691
691
  if (command === "snapshot" || command === "audit") {
692
- return import("./cli-3PFEOEQI.js").then((m) => m.runAudit(command, rest, io));
692
+ return import("./cli-T5L55IWF.js").then((m) => m.runAudit(command, rest, io));
693
693
  }
694
694
  if (!command || command === "help" || command === "--help" || command === "-h") {
695
695
  io.out(HELP);
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-OQXTQQAU.js";
4
4
  import {
5
5
  parseFlags
6
- } from "./chunk-V3GJSISR.js";
6
+ } from "./chunk-CB4QDMH2.js";
7
7
  import {
8
8
  toJson
9
9
  } from "./chunk-QIEDBL4J.js";
@@ -22,10 +22,10 @@ import {
22
22
  hueSwatch,
23
23
  nullPrompt,
24
24
  scanSource
25
- } from "./chunk-GA3AJN67.js";
25
+ } from "./chunk-A5M4AOMY.js";
26
26
  import {
27
27
  extractStrings
28
- } from "./chunk-EHMEMSM2.js";
28
+ } from "./chunk-ZTHVOXZF.js";
29
29
 
30
30
  // src/null/cli.ts
31
31
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
@@ -120,7 +120,7 @@ async function build(flags, io) {
120
120
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
121
121
  if (toShoot.length) {
122
122
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
123
- const { snapshotUrls } = await import("./audit-DESBVHQJ.js");
123
+ const { snapshotUrls } = await import("./audit-SWLTAEDO.js");
124
124
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
125
125
  results.forEach((r, i) => {
126
126
  const id = toShoot[i];
@@ -241,7 +241,7 @@ async function importPages(flags, io) {
241
241
  const toShoot = pages.filter((p) => !existsSync(join(shots, `${p.id}.json`)));
242
242
  if (toShoot.length) {
243
243
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"} from ${flags.builder}`);
244
- const { snapshotUrls } = await import("./audit-DESBVHQJ.js");
244
+ const { snapshotUrls } = await import("./audit-SWLTAEDO.js");
245
245
  const servers = /* @__PURE__ */ new Map();
246
246
  try {
247
247
  for (const p of toShoot) if (!servers.has(p.root)) servers.set(p.root, await serveDir(p.root));
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  snapshotUrl,
3
3
  snapshotUrls
4
- } from "./chunk-ZV3FPQPM.js";
4
+ } from "./chunk-NK6AAY7O.js";
5
5
  import {
6
6
  describeTypicality,
7
7
  loadNull
8
- } from "./chunk-GDDBCHQR.js";
8
+ } from "./chunk-E2ZYFBQX.js";
9
9
  import {
10
10
  SOURCE_FILE,
11
11
  finish,
@@ -14,7 +14,7 @@ import {
14
14
  parseFlags,
15
15
  prepareReport,
16
16
  readPaths
17
- } from "./chunk-V3GJSISR.js";
17
+ } from "./chunk-CB4QDMH2.js";
18
18
  import {
19
19
  toJson
20
20
  } from "./chunk-QIEDBL4J.js";
@@ -28,7 +28,7 @@ import {
28
28
  rhythmOf,
29
29
  scanSource,
30
30
  typicality
31
- } from "./chunk-GA3AJN67.js";
31
+ } from "./chunk-A5M4AOMY.js";
32
32
 
33
33
  // src/audit/cli.ts
34
34
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  auditSnapshot,
6
6
  fingerprint
7
- } from "./chunk-GA3AJN67.js";
7
+ } from "./chunk-A5M4AOMY.js";
8
8
  import {
9
9
  SNAPSHOT_VERSION
10
10
  } from "./chunk-QYBAVU3P.js";
@@ -10,10 +10,10 @@ import {
10
10
  levenshtein,
11
11
  normaliseFamily,
12
12
  parseColour
13
- } from "./chunk-GA3AJN67.js";
13
+ } from "./chunk-A5M4AOMY.js";
14
14
  import {
15
15
  TRADE_NOUNS
16
- } from "./chunk-EHMEMSM2.js";
16
+ } from "./chunk-ZTHVOXZF.js";
17
17
 
18
18
  // src/direction/types.ts
19
19
  var DIRECTION_VERSION = 2;
@@ -746,6 +746,29 @@ var PARALLEL_PAIR = [
746
746
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
747
747
  /^[ \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
748
748
  ];
749
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
750
+ var ABSOLUTE_NEGATION = new RegExp(
751
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
752
+ "im"
753
+ );
754
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
755
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
756
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
757
+ function notAbsoluteFragment(block, match, index) {
758
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
759
+ let words = rest;
760
+ const lead = head.toLowerCase();
761
+ if (lead === "no" && words[0]?.toLowerCase() === "one") words = words.slice(1);
762
+ else if (lead === "no" && NO_IDIOM.test(words[0] ?? "")) return true;
763
+ if (words.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
764
+ if (lead === "never" && !PARTICIPLE.test(words[0] ?? "")) return true;
765
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words[0] ?? "")) return true;
766
+ const sentence = lineAround(block.text, index);
767
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
768
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
769
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
770
+ return emptyState(block, match, index);
771
+ }
749
772
  var alreadyBlocked = (block, match, index) => {
750
773
  const before = block.text.slice(0, index);
751
774
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -1284,6 +1307,46 @@ var COPY_TELLS = [
1284
1307
  ]
1285
1308
  }
1286
1309
  },
1310
+ {
1311
+ id: "absolute-negation",
1312
+ name: "Absolute negation fragment",
1313
+ generation: 2,
1314
+ severity: "warn",
1315
+ surface: "copy",
1316
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
1317
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
1318
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
1319
+ fixtures: {
1320
+ flag: [
1321
+ // From a 92EL deck that passed the gate, October 2026.
1322
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
1323
+ f("content/deck.md", "Pay per user per month; no servers."),
1324
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
1325
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
1326
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
1327
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
1328
+ ],
1329
+ pass: [
1330
+ f("content/report.md", "Players never open the lists."),
1331
+ f("content/report.md", "Nothing is copied by hand."),
1332
+ f("content/report.md", "It is not tested at national scale."),
1333
+ f("content/report.md", "Never share your password with anyone."),
1334
+ f("content/report.md", "No one knows the password but the club secretary."),
1335
+ // A Harvard citation with no date.
1336
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
1337
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
1338
+ // A form answer option.
1339
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
1340
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
1341
+ f("content/form.md", "No, the player is 18 or over."),
1342
+ f("content/home.md", "We no longer post paper forms."),
1343
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
1344
+ // The blocking tells report these.
1345
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
1346
+ f("app/list.tsx", `export const empty = "No results found.";`)
1347
+ ]
1348
+ }
1349
+ },
1287
1350
  {
1288
1351
  id: "where-x-meets-y",
1289
1352
  name: "'Where X meets Y'",
@@ -3,21 +3,21 @@ import {
3
3
  } from "./chunk-OKVHWXYV.js";
4
4
  import {
5
5
  merge
6
- } from "./chunk-M5QSMIT6.js";
7
- import "./chunk-ZV3FPQPM.js";
6
+ } from "./chunk-FBGXUVZU.js";
7
+ import "./chunk-NK6AAY7O.js";
8
8
  import "./chunk-JYNXGCYZ.js";
9
9
  import {
10
10
  SIBLING_AT,
11
11
  compareToEstate,
12
12
  loadEstate
13
- } from "./chunk-LRWKKJIW.js";
13
+ } from "./chunk-A5OYA2BG.js";
14
14
  import {
15
15
  PAGE_TYPES,
16
16
  validateDirection
17
- } from "./chunk-7DPB5G4G.js";
17
+ } from "./chunk-QNX7CROK.js";
18
18
  import {
19
19
  loadNull
20
- } from "./chunk-GDDBCHQR.js";
20
+ } from "./chunk-E2ZYFBQX.js";
21
21
  import "./chunk-OQXTQQAU.js";
22
22
  import {
23
23
  COPY_FILE,
@@ -28,7 +28,7 @@ import {
28
28
  loadExceptions,
29
29
  parseFlags,
30
30
  readPaths
31
- } from "./chunk-V3GJSISR.js";
31
+ } from "./chunk-CB4QDMH2.js";
32
32
  import {
33
33
  toJson
34
34
  } from "./chunk-QIEDBL4J.js";
@@ -42,8 +42,8 @@ import {
42
42
  fingerprint,
43
43
  scanSource,
44
44
  typicality
45
- } from "./chunk-GA3AJN67.js";
46
- import "./chunk-EHMEMSM2.js";
45
+ } from "./chunk-A5M4AOMY.js";
46
+ import "./chunk-ZTHVOXZF.js";
47
47
  import "./chunk-QYBAVU3P.js";
48
48
 
49
49
  // src/report/cli.ts
@@ -126,7 +126,7 @@ async function runReport(command, args, io) {
126
126
  if (/\.json$/i.test(target2) && existsSync(local)) {
127
127
  snap = readSnapshot(JSON.parse(readFileSync(local, "utf8")), target2);
128
128
  } else {
129
- const { snapshotUrl } = await import("./audit-DESBVHQJ.js");
129
+ const { snapshotUrl } = await import("./audit-SWLTAEDO.js");
130
130
  snap = await snapshotUrl(target2);
131
131
  }
132
132
  const fp = fingerprint(snap);
@@ -4,15 +4,15 @@ import {
4
4
  formatPairs,
5
5
  loadEstate,
6
6
  runEstate
7
- } from "./chunk-LRWKKJIW.js";
8
- import "./chunk-7DPB5G4G.js";
9
- import "./chunk-GDDBCHQR.js";
7
+ } from "./chunk-A5OYA2BG.js";
8
+ import "./chunk-QNX7CROK.js";
9
+ import "./chunk-E2ZYFBQX.js";
10
10
  import "./chunk-OQXTQQAU.js";
11
- import "./chunk-V3GJSISR.js";
11
+ import "./chunk-CB4QDMH2.js";
12
12
  import "./chunk-QIEDBL4J.js";
13
13
  import "./chunk-WUACK2HX.js";
14
- import "./chunk-GA3AJN67.js";
15
- import "./chunk-EHMEMSM2.js";
14
+ import "./chunk-A5M4AOMY.js";
15
+ import "./chunk-ZTHVOXZF.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
18
18
  formatComponentPairs,
@@ -4,11 +4,11 @@ import {
4
4
  import {
5
5
  expandNullTargets,
6
6
  loadNull
7
- } from "./chunk-GDDBCHQR.js";
7
+ } from "./chunk-E2ZYFBQX.js";
8
8
  import "./chunk-OQXTQQAU.js";
9
9
  import {
10
10
  parseFlags
11
- } from "./chunk-V3GJSISR.js";
11
+ } from "./chunk-CB4QDMH2.js";
12
12
  import {
13
13
  toJson
14
14
  } from "./chunk-QIEDBL4J.js";
@@ -21,8 +21,8 @@ import {
21
21
  fingerprint,
22
22
  scanSource,
23
23
  typicality
24
- } from "./chunk-GA3AJN67.js";
25
- import "./chunk-EHMEMSM2.js";
24
+ } from "./chunk-A5M4AOMY.js";
25
+ import "./chunk-ZTHVOXZF.js";
26
26
  import {
27
27
  SNAPSHOT_VERSION
28
28
  } from "./chunk-QYBAVU3P.js";
@@ -188,7 +188,7 @@ async function runCalibrate(args, io) {
188
188
  if (toTake.length) {
189
189
  io.err(`craft calibrate: taking ${toTake.length} snapshot${toTake.length === 1 ? "" : "s"}`);
190
190
  try {
191
- const { snapshotUrls } = await import("./audit-DESBVHQJ.js");
191
+ const { snapshotUrls } = await import("./audit-SWLTAEDO.js");
192
192
  const results = await snapshotUrls(toTake.map((t) => t.url));
193
193
  results.forEach((r, i) => {
194
194
  const { entry, keep } = toTake[i];
@@ -9,7 +9,7 @@ import {
9
9
  STOCK_PHRASES,
10
10
  countWords,
11
11
  specificity
12
- } from "./chunk-EHMEMSM2.js";
12
+ } from "./chunk-ZTHVOXZF.js";
13
13
 
14
14
  // src/register/cli.ts
15
15
  import { existsSync, readFileSync } from "fs";
@@ -9,13 +9,13 @@ import {
9
9
  runHarvest,
10
10
  runNull,
11
11
  serveDir
12
- } from "./chunk-GDDBCHQR.js";
12
+ } from "./chunk-E2ZYFBQX.js";
13
13
  import "./chunk-OQXTQQAU.js";
14
- import "./chunk-V3GJSISR.js";
14
+ import "./chunk-CB4QDMH2.js";
15
15
  import "./chunk-QIEDBL4J.js";
16
16
  import "./chunk-WUACK2HX.js";
17
- import "./chunk-GA3AJN67.js";
18
- import "./chunk-EHMEMSM2.js";
17
+ import "./chunk-A5M4AOMY.js";
18
+ import "./chunk-ZTHVOXZF.js";
19
19
  import "./chunk-QYBAVU3P.js";
20
20
  export {
21
21
  describeTypicality,
@@ -4,16 +4,16 @@ import {
4
4
  merge,
5
5
  resolvePages,
6
6
  runAudit
7
- } from "./chunk-M5QSMIT6.js";
8
- import "./chunk-ZV3FPQPM.js";
7
+ } from "./chunk-FBGXUVZU.js";
8
+ import "./chunk-NK6AAY7O.js";
9
9
  import "./chunk-JYNXGCYZ.js";
10
- import "./chunk-GDDBCHQR.js";
10
+ import "./chunk-E2ZYFBQX.js";
11
11
  import "./chunk-OQXTQQAU.js";
12
- import "./chunk-V3GJSISR.js";
12
+ import "./chunk-CB4QDMH2.js";
13
13
  import "./chunk-QIEDBL4J.js";
14
14
  import "./chunk-WUACK2HX.js";
15
- import "./chunk-GA3AJN67.js";
16
- import "./chunk-EHMEMSM2.js";
15
+ import "./chunk-A5M4AOMY.js";
16
+ import "./chunk-ZTHVOXZF.js";
17
17
  import "./chunk-QYBAVU3P.js";
18
18
  export {
19
19
  describeComponents,
@@ -16,12 +16,12 @@ import {
16
16
  import {
17
17
  SOURCE_FILE,
18
18
  readPaths
19
- } from "./chunk-V3GJSISR.js";
19
+ } from "./chunk-CB4QDMH2.js";
20
20
  import {
21
21
  toJson
22
22
  } from "./chunk-QIEDBL4J.js";
23
- import "./chunk-GA3AJN67.js";
24
- import "./chunk-EHMEMSM2.js";
23
+ import "./chunk-A5M4AOMY.js";
24
+ import "./chunk-ZTHVOXZF.js";
25
25
  import "./chunk-QYBAVU3P.js";
26
26
 
27
27
  // src/phone/cli.ts
@@ -19,10 +19,10 @@ import {
19
19
  checkReason,
20
20
  researchPrompt,
21
21
  validateDirection
22
- } from "./chunk-7DPB5G4G.js";
22
+ } from "./chunk-QNX7CROK.js";
23
23
  import {
24
24
  parseFlags
25
- } from "./chunk-V3GJSISR.js";
25
+ } from "./chunk-CB4QDMH2.js";
26
26
  import {
27
27
  toJson
28
28
  } from "./chunk-QIEDBL4J.js";
@@ -38,8 +38,8 @@ import {
38
38
  isCream,
39
39
  oklchToRgb,
40
40
  parseColour
41
- } from "./chunk-GA3AJN67.js";
42
- import "./chunk-EHMEMSM2.js";
41
+ } from "./chunk-A5M4AOMY.js";
42
+ import "./chunk-ZTHVOXZF.js";
43
43
  import "./chunk-QYBAVU3P.js";
44
44
 
45
45
  // src/direction/cli.ts
@@ -484,7 +484,7 @@ var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
484
484
  async function snapshotFrom(target, cwd) {
485
485
  if (!target) return void 0;
486
486
  if (/^https?:\/\//.test(target)) {
487
- const { snapshotUrl } = await import("./audit-DESBVHQJ.js");
487
+ const { snapshotUrl } = await import("./audit-SWLTAEDO.js");
488
488
  return snapshotUrl(target);
489
489
  }
490
490
  return readSnapshot(readJson(resolve(cwd, target)), target);
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  run
4
- } from "./chunk-V3GJSISR.js";
4
+ } from "./chunk-CB4QDMH2.js";
5
5
  import "./chunk-QIEDBL4J.js";
6
- import "./chunk-GA3AJN67.js";
7
- import "./chunk-EHMEMSM2.js";
6
+ import "./chunk-A5M4AOMY.js";
7
+ import "./chunk-ZTHVOXZF.js";
8
8
  import "./chunk-QYBAVU3P.js";
9
9
 
10
10
  // src/cli.ts
package/dist/index.cjs CHANGED
@@ -2160,6 +2160,29 @@ var PARALLEL_PAIR = [
2160
2160
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
2161
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
2162
  ];
2163
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
2164
+ var ABSOLUTE_NEGATION = new RegExp(
2165
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
2166
+ "im"
2167
+ );
2168
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
2169
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
2170
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
2171
+ function notAbsoluteFragment(block, match, index) {
2172
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
2173
+ let words3 = rest;
2174
+ const lead = head.toLowerCase();
2175
+ if (lead === "no" && words3[0]?.toLowerCase() === "one") words3 = words3.slice(1);
2176
+ else if (lead === "no" && NO_IDIOM.test(words3[0] ?? "")) return true;
2177
+ if (words3.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
2178
+ if (lead === "never" && !PARTICIPLE.test(words3[0] ?? "")) return true;
2179
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words3[0] ?? "")) return true;
2180
+ const sentence = lineAround(block.text, index);
2181
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
2182
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
2183
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
2184
+ return emptyState(block, match, index);
2185
+ }
2163
2186
  var alreadyBlocked = (block, match, index) => {
2164
2187
  const before = block.text.slice(0, index);
2165
2188
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -2698,6 +2721,46 @@ var COPY_TELLS = [
2698
2721
  ]
2699
2722
  }
2700
2723
  },
2724
+ {
2725
+ id: "absolute-negation",
2726
+ name: "Absolute negation fragment",
2727
+ generation: 2,
2728
+ severity: "warn",
2729
+ surface: "copy",
2730
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
2731
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
2732
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
2733
+ fixtures: {
2734
+ flag: [
2735
+ // From a 92EL deck that passed the gate, October 2026.
2736
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
2737
+ f("content/deck.md", "Pay per user per month; no servers."),
2738
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
2739
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
2740
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
2741
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
2742
+ ],
2743
+ pass: [
2744
+ f("content/report.md", "Players never open the lists."),
2745
+ f("content/report.md", "Nothing is copied by hand."),
2746
+ f("content/report.md", "It is not tested at national scale."),
2747
+ f("content/report.md", "Never share your password with anyone."),
2748
+ f("content/report.md", "No one knows the password but the club secretary."),
2749
+ // A Harvard citation with no date.
2750
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
2751
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
2752
+ // A form answer option.
2753
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
2754
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
2755
+ f("content/form.md", "No, the player is 18 or over."),
2756
+ f("content/home.md", "We no longer post paper forms."),
2757
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
2758
+ // The blocking tells report these.
2759
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
2760
+ f("app/list.tsx", `export const empty = "No results found.";`)
2761
+ ]
2762
+ }
2763
+ },
2701
2764
  {
2702
2765
  id: "where-x-meets-y",
2703
2766
  name: "'Where X meets Y'",
@@ -5638,6 +5701,8 @@ var HOUSE = {
5638
5701
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
5639
5702
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
5640
5703
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
5704
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
5705
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
5641
5706
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5642
5707
  "stock-phrase": { tier: "review", label: "stock phrase" },
5643
5708
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6870,7 +6935,7 @@ var IMAGERY_TELLS = [
6870
6935
  ];
6871
6936
 
6872
6937
  // src/character/check.ts
6873
- var CATALOGUE_VERSION = "2026.10.8";
6938
+ var CATALOGUE_VERSION = "2026.10.9";
6874
6939
  var CATALOGUE = [
6875
6940
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6876
6941
  ...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.8";
1255
+ declare const CATALOGUE_VERSION = "2026.10.9";
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.8";
1255
+ declare const CATALOGUE_VERSION = "2026.10.9";
1256
1256
  declare const CATALOGUE: readonly Tell[];
1257
1257
  declare function tellById(id: string): Tell | undefined;
1258
1258
  /**
package/dist/index.js CHANGED
@@ -1841,6 +1841,29 @@ var PARALLEL_PAIR = [
1841
1841
  // a verbless list as a tagline: "Service account, match-day reminders, analytics."
1842
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
1843
  ];
1844
+ var CLAUSE_START = String.raw`(?<=^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?|[;:,(—–][ \t]*|[.!?][ \t]+)`;
1845
+ var ABSOLUTE_NEGATION = new RegExp(
1846
+ `${CLAUSE_START}(?:never|nothing|nobody|no[ \\t]+one|no)(?:[ \\t]+[\\w'\u2019\xA3$\u20AC-]+){1,6}(?=[ \\t]*(?:[.,;:!?)"\u201D\u2014\u2013]|$))`,
1847
+ "im"
1848
+ );
1849
+ var FINITE = /^(?:is|are|was|were|am|be|been|being|has|have|had|do|does|did|can|could|will|would|shall|should|may|might|must|need|needs|gets|got|goes|went|comes|came|makes|means|matters|happens|stays|changes|works|takes|says|knows|seems|feels|looks|leaves|left|appears|uses|runs|shows|sends|holds|reads|writes|opens|stores|reaches|applies|isn['’]t|aren['’]t|wasn['’]t)$/i;
1850
+ var NO_IDIOM = /^(?:date|longer|later|sooner|matter|more|less|fewer|other|one|doubt|wonder|thanks|problem)$/i;
1851
+ var PARTICIPLE = /^[\w-]*(?:ed|en|wn|ld|lt|nt|pt|ne|id)$/i;
1852
+ function notAbsoluteFragment(block, match, index) {
1853
+ const [head, ...rest] = match.trim().split(/[ \t]+/);
1854
+ let words3 = rest;
1855
+ const lead = head.toLowerCase();
1856
+ if (lead === "no" && words3[0]?.toLowerCase() === "one") words3 = words3.slice(1);
1857
+ else if (lead === "no" && NO_IDIOM.test(words3[0] ?? "")) return true;
1858
+ if (words3.some((w) => FINITE.test(w) || /['’](?:s|re|ve|ll|d)$/i.test(w))) return true;
1859
+ if (lead === "never" && !PARTICIPLE.test(words3[0] ?? "")) return true;
1860
+ if (lead !== "no" && lead !== "never" && /^(?!else$|less$|across$)[a-z]+s$/i.test(words3[0] ?? "")) return true;
1861
+ const sentence = lineAround(block.text, index);
1862
+ const line = sentence.trim().replace(/^[-*]\s+/, "");
1863
+ if (NO_X_NO_Y.test(sentence) || NO_X_BADGE.test(line)) return true;
1864
+ if (NEGATIVE_REASSURANCE.some((p) => wordList([p]).test(match))) return true;
1865
+ return emptyState(block, match, index);
1866
+ }
1844
1867
  var alreadyBlocked = (block, match, index) => {
1845
1868
  const before = block.text.slice(0, index);
1846
1869
  const start = Math.max(before.search(/[^.!?\n]*$/), 0);
@@ -2379,6 +2402,46 @@ var COPY_TELLS = [
2379
2402
  ]
2380
2403
  }
2381
2404
  },
2405
+ {
2406
+ id: "absolute-negation",
2407
+ name: "Absolute negation fragment",
2408
+ generation: 2,
2409
+ severity: "warn",
2410
+ surface: "copy",
2411
+ why: "'never pre-ticked', 'no servers', 'nothing to buy or run': a verbless fragment that opens on never, nothing, nobody or 'no' + noun. Read aloud it sounds rigid, it promises an absolute the copy rarely means, and it defines the thing by what it is not.",
2412
+ fix: "Say what happens, as a sentence: 'A guardian ticks the consent box in the email.', 'Microsoft runs the servers.' Keep the absolute only where it is literally true and the reader needs it, and give it a subject and a verb.",
2413
+ detect: phrases([ABSOLUTE_NEGATION], quoted, allText, notAbsoluteFragment),
2414
+ fixtures: {
2415
+ flag: [
2416
+ // From a 92EL deck that passed the gate, October 2026.
2417
+ f("content/deck.md", "A guardian consents by email; never pre-ticked."),
2418
+ f("content/deck.md", "Pay per user per month; no servers."),
2419
+ f("content/deck.md", "Microsoft runs it, so there is less to manage: nothing to buy or run."),
2420
+ f("content/deck.md", "Players register on their phone. Nothing on-site to join."),
2421
+ f("content/deck.md", "Each player is checked against the list once, no duplicates."),
2422
+ f("app/slide.tsx", `export const note = "Nobody to chase for a form.";`)
2423
+ ],
2424
+ pass: [
2425
+ f("content/report.md", "Players never open the lists."),
2426
+ f("content/report.md", "Nothing is copied by hand."),
2427
+ f("content/report.md", "It is not tested at national scale."),
2428
+ f("content/report.md", "Never share your password with anyone."),
2429
+ f("content/report.md", "No one knows the password but the club secretary."),
2430
+ // A Harvard citation with no date.
2431
+ f("content/report.md", "Children's data needs a lawful basis (ICO, no date)."),
2432
+ f("content/report.md", "- Information Commissioner's Office (ICO), no date. Children and the UK GDPR."),
2433
+ // A form answer option.
2434
+ f("content/form.md", "Is the player under 18?\n\n- Yes\n- No"),
2435
+ f("app/form.tsx", `export const options = ["Yes", "No"];`),
2436
+ f("content/form.md", "No, the player is 18 or over."),
2437
+ f("content/home.md", "We no longer post paper forms."),
2438
+ f("content/report.md", "If the check fails, nothing new appears in the list."),
2439
+ // The blocking tells report these.
2440
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
2441
+ f("app/list.tsx", `export const empty = "No results found.";`)
2442
+ ]
2443
+ }
2444
+ },
2382
2445
  {
2383
2446
  id: "where-x-meets-y",
2384
2447
  name: "'Where X meets Y'",
@@ -5319,6 +5382,8 @@ var HOUSE = {
5319
5382
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
5320
5383
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
5321
5384
  "trailing-contrast": { tier: "review", label: "trailing contrast. Say the positive claim, unless it is a disclaimer the reader needs" },
5385
+ // New 2026-10-26 (founder, from a 92EL deck): review first, like every new tell, until estate hits are read.
5386
+ "absolute-negation": { tier: "review", label: "absolute negation fragment. Say what happens, as a sentence with a subject and a verb" },
5322
5387
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5323
5388
  "stock-phrase": { tier: "review", label: "stock phrase" },
5324
5389
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6551,7 +6616,7 @@ var IMAGERY_TELLS = [
6551
6616
  ];
6552
6617
 
6553
6618
  // src/character/check.ts
6554
- var CATALOGUE_VERSION = "2026.10.8";
6619
+ var CATALOGUE_VERSION = "2026.10.9";
6555
6620
  var CATALOGUE = [
6556
6621
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6557
6622
  ...SPECIFICITY_COPY_TELLS,
@@ -16,13 +16,13 @@ import {
16
16
  } from "./chunk-XSKLVEI6.js";
17
17
  import {
18
18
  loadPlaywright
19
- } from "./chunk-ZV3FPQPM.js";
19
+ } from "./chunk-NK6AAY7O.js";
20
20
  import "./chunk-JYNXGCYZ.js";
21
21
  import {
22
22
  toJson
23
23
  } from "./chunk-QIEDBL4J.js";
24
- import "./chunk-GA3AJN67.js";
25
- import "./chunk-EHMEMSM2.js";
24
+ import "./chunk-A5M4AOMY.js";
25
+ import "./chunk-ZTHVOXZF.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.23.1",
3
+ "version": "0.23.2",
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.8",
2
+ "catalogueVersion": "2026.10.9",
3
3
  "lists": {
4
4
  "aiWords": [
5
5
  "tapestry",