@domandigital/craft 0.23.0 → 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,17 @@
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
+
9
+ ## 0.23.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 297ee1b: `trailing-contrast` no longer goes quiet on a sentence that follows one `not-just-but` blocks (DOM-1358, catalogue 2026.10.8). The check that keeps one sentence from being reported twice looked back a fixed 80 characters and could reach the sentence before; it now reads only the sentence the match sits in. Found by running the released `copy-check` over the 92EL lines: "Edge matters for game servers, not registration." went unreported when it came straight after the gerund pair.
14
+
3
15
  ## 0.23.0
4
16
 
5
17
  ### Minor 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.7`, 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.7`, 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-A7KUKNX6.js";
12
+ } from "./chunk-NK6AAY7O.js";
13
13
  import "./chunk-JYNXGCYZ.js";
14
- import "./chunk-3LCBLDAO.js";
15
- import "./chunk-KIBDFRHT.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,8 +358,33 @@ 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
- const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
385
+ const before = block.text.slice(0, index);
386
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
387
+ const sentence = block.text.slice(start, index + match.length);
363
388
  return NEGATION.some((pattern) => pattern.test(sentence));
364
389
  };
365
390
  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;
@@ -788,7 +813,9 @@ var COPY_TELLS = [
788
813
  f("content/report.md", "Edge matters for game servers, not registration."),
789
814
  f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
790
815
  f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
791
- f("content/home.md", "Real Square data, not a vanity metric.")
816
+ f("content/home.md", "Real Square data, not a vanity metric."),
817
+ // Reported even straight after a sentence the blocking rule reports.
818
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
792
819
  ],
793
820
  pass: [
794
821
  f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
@@ -892,6 +919,46 @@ var COPY_TELLS = [
892
919
  ]
893
920
  }
894
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
+ },
895
962
  {
896
963
  id: "where-x-meets-y",
897
964
  name: "'Where X meets Y'",
@@ -3495,6 +3562,8 @@ var HOUSE = {
3495
3562
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3496
3563
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3497
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" },
3498
3567
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3499
3568
  "stock-phrase": { tier: "review", label: "stock phrase" },
3500
3569
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4263,7 +4332,7 @@ var IMAGERY_TELLS = [
4263
4332
  ];
4264
4333
 
4265
4334
  // src/character/check.ts
4266
- var CATALOGUE_VERSION = "2026.10.7";
4335
+ var CATALOGUE_VERSION = "2026.10.9";
4267
4336
  var CATALOGUE = [
4268
4337
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4269
4338
  ...SPECIFICITY_COPY_TELLS,
package/dist/audit.js CHANGED
@@ -323,8 +323,33 @@ 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
- const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
350
+ const before = block.text.slice(0, index);
351
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
352
+ const sentence = block.text.slice(start, index + match.length);
328
353
  return NEGATION.some((pattern) => pattern.test(sentence));
329
354
  };
330
355
  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;
@@ -753,7 +778,9 @@ var COPY_TELLS = [
753
778
  f("content/report.md", "Edge matters for game servers, not registration."),
754
779
  f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
755
780
  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.")
781
+ f("content/home.md", "Real Square data, not a vanity metric."),
782
+ // Reported even straight after a sentence the blocking rule reports.
783
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
757
784
  ],
758
785
  pass: [
759
786
  f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
@@ -857,6 +884,46 @@ var COPY_TELLS = [
857
884
  ]
858
885
  }
859
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
+ },
860
927
  {
861
928
  id: "where-x-meets-y",
862
929
  name: "'Where X meets Y'",
@@ -3460,6 +3527,8 @@ var HOUSE = {
3460
3527
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
3461
3528
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
3462
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" },
3463
3532
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3464
3533
  "stock-phrase": { tier: "review", label: "stock phrase" },
3465
3534
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -4228,7 +4297,7 @@ var IMAGERY_TELLS = [
4228
4297
  ];
4229
4298
 
4230
4299
  // src/character/check.ts
4231
- var CATALOGUE_VERSION = "2026.10.7";
4300
+ var CATALOGUE_VERSION = "2026.10.9";
4232
4301
  var CATALOGUE = [
4233
4302
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
4234
4303
  ...SPECIFICITY_COPY_TELLS,
@@ -9,7 +9,7 @@ import {
9
9
  parseFile,
10
10
  sentencesOf,
11
11
  specificity
12
- } from "./chunk-KIBDFRHT.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.7";
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-4NON4PVK.js";
7
+ } from "./chunk-QNX7CROK.js";
8
8
  import {
9
9
  loadNull
10
- } from "./chunk-3LHIKF5L.js";
10
+ } from "./chunk-E2ZYFBQX.js";
11
11
  import {
12
12
  parseFlags
13
- } from "./chunk-KGEJ2TGT.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-3LCBLDAO.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-CRTC7JSF.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-3LCBLDAO.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-KIBDFRHT.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-CRTC7JSF.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-HWGSNSDD.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-E2CFVIGZ.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-TPV2EIIN.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-YRRBBU27.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-X3RZE4GM.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-HWGSNSDD.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-ROVZDHVR.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-HWGSNSDD.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-RHU3MAZY.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-EAUVLHFJ.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-IYRJZWMN.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-KGEJ2TGT.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-3LCBLDAO.js";
25
+ } from "./chunk-A5M4AOMY.js";
26
26
  import {
27
27
  extractStrings
28
- } from "./chunk-KIBDFRHT.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-CRTC7JSF.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-CRTC7JSF.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-A7KUKNX6.js";
4
+ } from "./chunk-NK6AAY7O.js";
5
5
  import {
6
6
  describeTypicality,
7
7
  loadNull
8
- } from "./chunk-3LHIKF5L.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-KGEJ2TGT.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-3LCBLDAO.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-3LCBLDAO.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-3LCBLDAO.js";
13
+ } from "./chunk-A5M4AOMY.js";
14
14
  import {
15
15
  TRADE_NOUNS
16
- } from "./chunk-KIBDFRHT.js";
16
+ } from "./chunk-ZTHVOXZF.js";
17
17
 
18
18
  // src/direction/types.ts
19
19
  var DIRECTION_VERSION = 2;
@@ -746,8 +746,33 @@ 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
- const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
773
+ const before = block.text.slice(0, index);
774
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
775
+ const sentence = block.text.slice(start, index + match.length);
751
776
  return NEGATION.some((pattern) => pattern.test(sentence));
752
777
  };
753
778
  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;
@@ -1176,7 +1201,9 @@ var COPY_TELLS = [
1176
1201
  f("content/report.md", "Edge matters for game servers, not registration."),
1177
1202
  f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
1178
1203
  f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
1179
- f("content/home.md", "Real Square data, not a vanity metric.")
1204
+ f("content/home.md", "Real Square data, not a vanity metric."),
1205
+ // Reported even straight after a sentence the blocking rule reports.
1206
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
1180
1207
  ],
1181
1208
  pass: [
1182
1209
  f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
@@ -1280,6 +1307,46 @@ var COPY_TELLS = [
1280
1307
  ]
1281
1308
  }
1282
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
+ },
1283
1350
  {
1284
1351
  id: "where-x-meets-y",
1285
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-OTQQTXXK.js";
7
- import "./chunk-A7KUKNX6.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-Y2LGYHJ5.js";
13
+ } from "./chunk-A5OYA2BG.js";
14
14
  import {
15
15
  PAGE_TYPES,
16
16
  validateDirection
17
- } from "./chunk-4NON4PVK.js";
17
+ } from "./chunk-QNX7CROK.js";
18
18
  import {
19
19
  loadNull
20
- } from "./chunk-3LHIKF5L.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-KGEJ2TGT.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-3LCBLDAO.js";
46
- import "./chunk-KIBDFRHT.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-CRTC7JSF.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-Y2LGYHJ5.js";
8
- import "./chunk-4NON4PVK.js";
9
- import "./chunk-3LHIKF5L.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-KGEJ2TGT.js";
11
+ import "./chunk-CB4QDMH2.js";
12
12
  import "./chunk-QIEDBL4J.js";
13
13
  import "./chunk-WUACK2HX.js";
14
- import "./chunk-3LCBLDAO.js";
15
- import "./chunk-KIBDFRHT.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-3LHIKF5L.js";
7
+ } from "./chunk-E2ZYFBQX.js";
8
8
  import "./chunk-OQXTQQAU.js";
9
9
  import {
10
10
  parseFlags
11
- } from "./chunk-KGEJ2TGT.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-3LCBLDAO.js";
25
- import "./chunk-KIBDFRHT.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-CRTC7JSF.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-KIBDFRHT.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-3LHIKF5L.js";
12
+ } from "./chunk-E2ZYFBQX.js";
13
13
  import "./chunk-OQXTQQAU.js";
14
- import "./chunk-KGEJ2TGT.js";
14
+ import "./chunk-CB4QDMH2.js";
15
15
  import "./chunk-QIEDBL4J.js";
16
16
  import "./chunk-WUACK2HX.js";
17
- import "./chunk-3LCBLDAO.js";
18
- import "./chunk-KIBDFRHT.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-OTQQTXXK.js";
8
- import "./chunk-A7KUKNX6.js";
7
+ } from "./chunk-FBGXUVZU.js";
8
+ import "./chunk-NK6AAY7O.js";
9
9
  import "./chunk-JYNXGCYZ.js";
10
- import "./chunk-3LHIKF5L.js";
10
+ import "./chunk-E2ZYFBQX.js";
11
11
  import "./chunk-OQXTQQAU.js";
12
- import "./chunk-KGEJ2TGT.js";
12
+ import "./chunk-CB4QDMH2.js";
13
13
  import "./chunk-QIEDBL4J.js";
14
14
  import "./chunk-WUACK2HX.js";
15
- import "./chunk-3LCBLDAO.js";
16
- import "./chunk-KIBDFRHT.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-KGEJ2TGT.js";
19
+ } from "./chunk-CB4QDMH2.js";
20
20
  import {
21
21
  toJson
22
22
  } from "./chunk-QIEDBL4J.js";
23
- import "./chunk-3LCBLDAO.js";
24
- import "./chunk-KIBDFRHT.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-4NON4PVK.js";
22
+ } from "./chunk-QNX7CROK.js";
23
23
  import {
24
24
  parseFlags
25
- } from "./chunk-KGEJ2TGT.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-3LCBLDAO.js";
42
- import "./chunk-KIBDFRHT.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-CRTC7JSF.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-KGEJ2TGT.js";
4
+ } from "./chunk-CB4QDMH2.js";
5
5
  import "./chunk-QIEDBL4J.js";
6
- import "./chunk-3LCBLDAO.js";
7
- import "./chunk-KIBDFRHT.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,8 +2160,33 @@ 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
- const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
2187
+ const before = block.text.slice(0, index);
2188
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
2189
+ const sentence = block.text.slice(start, index + match.length);
2165
2190
  return NEGATION.some((pattern2) => pattern2.test(sentence));
2166
2191
  };
2167
2192
  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;
@@ -2590,7 +2615,9 @@ var COPY_TELLS = [
2590
2615
  f("content/report.md", "Edge matters for game servers, not registration."),
2591
2616
  f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
2592
2617
  f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
2593
- f("content/home.md", "Real Square data, not a vanity metric.")
2618
+ f("content/home.md", "Real Square data, not a vanity metric."),
2619
+ // Reported even straight after a sentence the blocking rule reports.
2620
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
2594
2621
  ],
2595
2622
  pass: [
2596
2623
  f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
@@ -2694,6 +2721,46 @@ var COPY_TELLS = [
2694
2721
  ]
2695
2722
  }
2696
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
+ },
2697
2764
  {
2698
2765
  id: "where-x-meets-y",
2699
2766
  name: "'Where X meets Y'",
@@ -5634,6 +5701,8 @@ var HOUSE = {
5634
5701
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
5635
5702
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
5636
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" },
5637
5706
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5638
5707
  "stock-phrase": { tier: "review", label: "stock phrase" },
5639
5708
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6866,7 +6935,7 @@ var IMAGERY_TELLS = [
6866
6935
  ];
6867
6936
 
6868
6937
  // src/character/check.ts
6869
- var CATALOGUE_VERSION = "2026.10.7";
6938
+ var CATALOGUE_VERSION = "2026.10.9";
6870
6939
  var CATALOGUE = [
6871
6940
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6872
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.7";
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.7";
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,8 +1841,33 @@ 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
- const sentence = block.text.slice(Math.max(0, index - 80), index + match.length);
1868
+ const before = block.text.slice(0, index);
1869
+ const start = Math.max(before.search(/[^.!?\n]*$/), 0);
1870
+ const sentence = block.text.slice(start, index + match.length);
1846
1871
  return NEGATION.some((pattern2) => pattern2.test(sentence));
1847
1872
  };
1848
1873
  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;
@@ -2271,7 +2296,9 @@ var COPY_TELLS = [
2271
2296
  f("content/report.md", "Edge matters for game servers, not registration."),
2272
2297
  f("content/report.md", "Age 18 is the league's own threshold, not a legal one."),
2273
2298
  f("content/report.md", "Cloud computing means renting computing over the internet instead of owning it."),
2274
- f("content/home.md", "Real Square data, not a vanity metric.")
2299
+ f("content/home.md", "Real Square data, not a vanity metric."),
2300
+ // Reported even straight after a sentence the blocking rule reports.
2301
+ f("content/report.md", "That means renting services, not running servers.\n\nEdge matters for game servers, not registration.")
2275
2302
  ],
2276
2303
  pass: [
2277
2304
  f("content/home.md", "Rewire, retile and replumb a bathroom in five days."),
@@ -2375,6 +2402,46 @@ var COPY_TELLS = [
2375
2402
  ]
2376
2403
  }
2377
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
+ },
2378
2445
  {
2379
2446
  id: "where-x-meets-y",
2380
2447
  name: "'Where X meets Y'",
@@ -5315,6 +5382,8 @@ var HOUSE = {
5315
5382
  // New 2026-10-23 (founder, from 92EL deliverables): review, since a short slide label is often fine.
5316
5383
  "parallel-pair": { tier: "review", label: "verbless parallel pair, write the sentence it stands for" },
5317
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" },
5318
5387
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5319
5388
  "stock-phrase": { tier: "review", label: "stock phrase" },
5320
5389
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6547,7 +6616,7 @@ var IMAGERY_TELLS = [
6547
6616
  ];
6548
6617
 
6549
6618
  // src/character/check.ts
6550
- var CATALOGUE_VERSION = "2026.10.7";
6619
+ var CATALOGUE_VERSION = "2026.10.9";
6551
6620
  var CATALOGUE = [
6552
6621
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6553
6622
  ...SPECIFICITY_COPY_TELLS,
@@ -16,13 +16,13 @@ import {
16
16
  } from "./chunk-XSKLVEI6.js";
17
17
  import {
18
18
  loadPlaywright
19
- } from "./chunk-A7KUKNX6.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-3LCBLDAO.js";
25
- import "./chunk-KIBDFRHT.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.0",
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.7",
2
+ "catalogueVersion": "2026.10.9",
3
3
  "lists": {
4
4
  "aiWords": [
5
5
  "tapestry",