super-ux 0.40.0 → 0.41.3

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,121 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.41.3 — 2026-08-16
4
+
5
+ **`facts.md` is a document, not only a facts table.** `facts()` took any
6
+ six-column row anywhere in the file, and a project that keeps a product ledger
7
+ there has one whose columns mean something else entirely:
8
+ `Product | App Store name | id | Released | Sold | Publisher today`. Its **Sold**
9
+ year landed in `Review`, so three completed sales produced "was due for review
10
+ on 2022" warnings, and the ledger's own header row became a fact called
11
+ `Product`. Four phantom rows in a registry of 43.
12
+
13
+ Scoped by header now — a table qualifies when its first column says `Fact`,
14
+ which is the only thing in a markdown table that declares what its columns
15
+ mean. A `tables()` helper groups rows per table beside the existing
16
+ `table_rows()`, which flattens every table in a file and is right for a caller
17
+ that wants every row.
18
+
19
+ Found on `sshlg.me` and fixed there first, in that project's copy of the linter,
20
+ where it would have been overwritten by the next sync — a plugin-owned file
21
+ edited in a downstream project serves that project until the day it silently
22
+ does not. Ported up with its fixture, which was watched failing.
23
+
24
+ ## 0.41.2 — 2026-08-16
25
+
26
+ **A source file is not prose, and the prose rules were reading all of it.** On
27
+ `sshlg.me`, whose `Sources:` block points `marketing` at `src/data/*.ts`, that
28
+ produced 20 rhetorical-dash errors inside `//` comments and 7 keyword-stuffing
29
+ errors on `const`, `string`, `name` and `category`. Twenty-seven standing errors
30
+ that no edit to the copy could clear, sitting in a report meant to be read. The
31
+ failure is the same one 0.40.1 fixed from the other direction: a check nobody
32
+ can act on gets ignored, and then it is not a check.
33
+
34
+ For a file with a code suffix the body is now its **copy** — the string literals
35
+ `_looks_like_copy` already accepts, which is the definition `B022` sweeps with.
36
+ A comment is addressed to a maintainer and an identifier is not a word.
37
+
38
+ Three things this had to get right, and the first two were found by getting them
39
+ wrong:
40
+
41
+ - **Comments are stripped by a scanner, not a regex.** `"https://x"` contains
42
+ `//`. The first attempt used a pattern and immediately reported a rhetorical
43
+ dash inside a comment that quoted a phrase — in the very change meant to stop
44
+ reading comments.
45
+ - **`${...}` is substituted, not dropped.** `CODE_FRAGMENT_RE` rejects any
46
+ literal carrying an interpolation, so without this every interpolated string
47
+ would fail `_looks_like_copy`. On the site that would have silently dropped the
48
+ whole biography. It turns out interpolated copy was **not** covered before
49
+ either: the fixture for it fails against 0.40.1, so this widens coverage rather
50
+ than preserving it.
51
+ - **A literal with no space is skipped.** `"@/data/site"` counted as copy would
52
+ put `data` into the density figures.
53
+
54
+ Ten fixtures, five end-to-end and five on the scanner directly. Four were watched
55
+ failing before the fix went in; the fifth end-to-end case is the boundary that
56
+ must not move — a rhetorical dash in a rendered string is still an error.
57
+
58
+ **And the same sentence has a second half: a source file is not a *document*
59
+ either.** `B051` measures keyword density, which is a property of the page a
60
+ reader meets. A project that keeps its copy in `src/data/*.ts` splits one page
61
+ across seven files, so measuring each file separately measures the split. On
62
+ `sshlg.me` that produced six errors — `co-founder` at 2.0% of
63
+ `track-record.ts`, `account` at 1.2% of `site.ts` — while the rendered page
64
+ carried **nothing above 1%** and those two words sat at 0.07% and 0.04%.
65
+
66
+ Code files are pooled into one document for that check; markdown sources are
67
+ not, because there one file really is one page. A pooled finding names the set
68
+ rather than a file, since no single file is the defect. The fixture that proves
69
+ it has a twin that must keep firing: a word genuinely dense across the whole
70
+ pool is still an error.
71
+
72
+ Measured on `sshlg.me`: **32 errors to 5**, and the five that remain are one
73
+ class — `B021` on registry rows whose text carries an interpolated count or an
74
+ inline link, which needs a linter that reads `dist/` rather than `src/`. One
75
+ real finding surfaced on the way: a rhetorical dash in live copy that had been
76
+ buried under twenty false ones.
77
+
78
+ ## 0.41.1 — 2026-08-16
79
+
80
+ **`B024` fined the writer for meeting a threshold this pack sets.** The
81
+ sentence-case check reads any capitalised word inside a sentence as Title Case,
82
+ and a contraction of the first person survived every exemption it had: `I'm` is
83
+ not upper-case, is in no entity table, and starts with a capital. So it fired.
84
+
85
+ That is not a cosmetic false positive. `formats.md` asks for **4 to 8
86
+ contractions per 1000 words** on a published surface and names their absence as
87
+ "the single loudest reason our prose reads as assembled" — and the first-person
88
+ contractions are the loudest ones available. The check and the threshold were
89
+ pulling in opposite directions, and the only repair available to a writer was to
90
+ delete the contraction the pack had just asked for. Found on `sshlg.me`, where
91
+ three registry rows tripped it on 2026-08-15 and none of them was miscased.
92
+
93
+ `I'm`, `I'll`, `I've` and `I'd` are now exempt, with either apostrophe. Nothing
94
+ else is: `We're` and `They'd` inside a sentence really are miscased, and the
95
+ capital is grammar only for the first person. Both halves are fixtures, so the
96
+ exemption cannot widen without a test saying so.
97
+
98
+ ## 0.41.0 — 2026-08-16
99
+
100
+ ### Added
101
+
102
+ - **`ux-audit` checks its batches against each other before the report reads as one
103
+ answer.** The batches run independently — in a large scope, in parallel subagents that
104
+ never see one another — and the summary then turns them into a single verdict. That is a
105
+ convergence, and a convergence trusts its inputs because they arrived.
106
+
107
+ Four things to look for: one root cause wearing several finding ids, which splits its own
108
+ priority across three rows; two batches that contradict on one screen, PASS in one and
109
+ FAIL in the other; a batch that returned nothing where its scenarios touch a screen
110
+ another batch flagged, since an empty result and an unrun batch look identical in a
111
+ summary; and a verdict whose evidence is weaker than its neighbour's, presented at equal
112
+ weight.
113
+
114
+ `Cross-batch: clean` is the answer most audits write, and writing it is the point. The
115
+ scenario base already had the same mechanism one layer up — `ux-scenarios` step 4,
116
+ *scenarios that contradict each other* — and this is it applied to the audit's own
117
+ outputs rather than to its inputs.
118
+
3
119
  ## 0.40.0 — 2026-08-14
4
120
 
5
121
  A 42-page practitioner guide on building web2app funnels was read against the
@@ -236,7 +352,6 @@ developer landing page (`zernio.com`, 2026-08-12) rather than from a survey.
236
352
  - `test/floors.json`: `validate.py` 3160 → 3236 — four entries, their field and
237
353
  tag checks, and three routing rows.
238
354
 
239
-
240
355
  ## 0.37.0 — 2026-08-12
241
356
 
242
357
  The last five findings from the R-14 run — the ones that needed a **decision**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.40.0",
3
+ "version": "0.41.3",
4
4
  "description": "Scenario-driven UI development for AI agents (Claude Code, Cursor, 70+ agents): a versioned design chain in docs/ux/, a scenario-first hard rule, a deterministic drift linter, and evidence-backed UX audits. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "super-ux": "bin/super-ux.js"
@@ -351,6 +351,11 @@ def check_terminology(brand_dir: Path) -> list[Finding]:
351
351
  return findings
352
352
 
353
353
 
354
+ # `I'm`, `I'll`, `I've`, `I'd`, with either apostrophe. Nothing else: `We're`
355
+ # and `They'd` mid-sentence really are miscased, and only the first person has a
356
+ # capital that is grammar rather than a choice.
357
+ CONTRACTED_I = re.compile(r"^I['\u2019](m|ll|ve|d)$")
358
+
354
359
  WEAK_LABELS = {
355
360
  "ok", "yes", "no", "submit", "done", "go", "click here",
356
361
  "learn more", "get started", "continue",
@@ -373,6 +378,71 @@ CODE_FRAGMENT_RE = re.compile(
373
378
  )
374
379
 
375
380
 
381
+ # Where a build puts the page a reader actually gets. Checked in this order and
382
+ # the first one that exists wins; a project with none of them is checked against
383
+ # its source, exactly as before.
384
+ RENDER_DIRS = ("dist", "build", "out", "_site")
385
+
386
+ _ENTITIES = {"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"',
387
+ "&#39;": "'", "&apos;": "'", "&nbsp;": " ", "&#8217;": "\u2019",
388
+ "&#8216;": "\u2018", "&#8212;": "\u2014", "&#8211;": "\u2013"}
389
+
390
+ _rendered_cache: dict = {}
391
+
392
+
393
+ def normalise(text: str) -> str:
394
+ """Collapse whitespace so an 80-column wrap and a rendered line agree.
395
+
396
+ Punctuation spacing is normalised too, and that is not cosmetic tidying.
397
+ Stripping `<strong>people</strong>,` leaves `people ,` once the tag becomes
398
+ a space, so an inline span inside a sentence would fail the comparison over
399
+ a space no reader can see. Applied to both sides, so the check still
400
+ compares wording and no longer compares markup.
401
+ """
402
+ text = re.sub(r"\s+", " ", text).strip()
403
+ text = re.sub(r"\s+([,.;:!?%)\]}\u00bb\u201d\u2019])", r"\1", text)
404
+ return re.sub(r"([(\[{\u00ab\u201c])\s+", r"\1", text)
405
+
406
+
407
+ def rendered_text(root: Path) -> str | None:
408
+ """All built HTML as one normalised string, or None if nothing is built.
409
+
410
+ B021 asks whether the registry's string is what a reader sees, and used to
411
+ look for it in the component source. Two things make that unanswerable
412
+ there: an interpolated value never appears literally -- `{years} years,
413
+ installable.` cannot contain `13 years, installable.` -- and an inline
414
+ `<strong>` or `<a>` splits a sentence the registry stores whole. On one site
415
+ that was five errors nobody could fix, and both honest repairs were bad:
416
+ hardcode the number and lose the guarantee that it is derived, or delete the
417
+ rows and lose the check.
418
+
419
+ The text a reader gets exists, just not in `src/`. Tags come out, entities
420
+ come back, whitespace collapses, and the same comparison then answers the
421
+ question it was always asking.
422
+ """
423
+ if root in _rendered_cache:
424
+ return _rendered_cache[root]
425
+ out = None
426
+ for name in RENDER_DIRS:
427
+ built = root / name
428
+ if not built.is_dir():
429
+ continue
430
+ chunks = []
431
+ for page in sorted(built.rglob("*.html")):
432
+ html = read(page) or ""
433
+ html = re.sub(r"<(script|style)\b.*?</\1>", " ", html, flags=re.S | re.I)
434
+ html = re.sub(r"<[^>]+>", " ", html)
435
+ for ent, ch in _ENTITIES.items():
436
+ html = html.replace(ent, ch)
437
+ html = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), html)
438
+ chunks.append(html)
439
+ if chunks:
440
+ out = normalise(" ".join(chunks))
441
+ break
442
+ _rendered_cache[root] = out
443
+ return out
444
+
445
+
376
446
  def _looks_like_copy(literal: str) -> bool:
377
447
  """A quoted literal that could plausibly be user-visible text."""
378
448
  if not literal or literal[0].islower() and " " not in literal:
@@ -463,11 +533,23 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
463
533
  lit for _q, lit in LITERAL_RE.findall(body) if _looks_like_copy(lit)
464
534
  ]
465
535
  if literals:
466
- if row["text"] not in body and row["text"].strip() not in body:
536
+ # The registry records what a reader sees, so the built page is the
537
+ # authority whenever there is one. Source is the fallback and stays
538
+ # byte-exact for projects that do not build.
539
+ built = rendered_text(root)
540
+ if built is not None:
541
+ if normalise(row["text"]) not in built:
542
+ findings.append(Finding(
543
+ "B021", SEVERITY_ERROR, location, 0,
544
+ f"`{row['key']}` is \"{row['text']}\" in the registry, "
545
+ f"but that text is not on the rendered page",
546
+ ))
547
+ elif row["text"] not in body and row["text"].strip() not in body:
467
548
  findings.append(Finding(
468
549
  "B021", SEVERITY_ERROR, location, 0,
469
550
  f"`{row['key']}` is \"{row['text']}\" in the registry, "
470
- f"but that text is not in {file_part}",
551
+ f"but that text is not in {file_part} (no build found; "
552
+ f"checked the source)",
471
553
  ))
472
554
  if file_part not in swept:
473
555
  swept.add(file_part)
@@ -507,6 +589,14 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
507
589
  continue
508
590
  if bare.isupper() and len(bare) <= 4:
509
591
  continue
592
+ # A contraction of "I" is not Title Case, and flagging it fought a
593
+ # threshold this pack sets on purpose: formats.md asks for 4-8
594
+ # contractions per 1000 words and the first-person ones are the
595
+ # loudest available. Three registry rows on sshlg.me tripped this on
596
+ # 2026-08-15 and none of them was miscased; the writer's only repair
597
+ # was to delete the contraction the pack had asked for.
598
+ if CONTRACTED_I.match(bare):
599
+ continue
510
600
  if bare[0].isupper():
511
601
  findings.append(Finding(
512
602
  "B024", SEVERITY_ERROR, row["location"], 0,
@@ -563,6 +653,103 @@ def _front_matter(text: str) -> tuple[dict, str]:
563
653
  return fields, text[match.end():]
564
654
 
565
655
 
656
+ # A source file is not prose, and the prose rules were reading all of it. On
657
+ # sshlg.me that produced 20 rhetorical-dash errors inside `//` comments and 7
658
+ # keyword-stuffing errors on `const`, `string` and `name` -- 27 standing errors
659
+ # that no edit to the copy could clear, in a report meant to be read. A report
660
+ # nobody can act on is a report nobody reads, which is the same failure as a
661
+ # rule that contradicts a threshold.
662
+ #
663
+ # For a code file the body is its copy: the string literals `_looks_like_copy`
664
+ # accepts, which is the definition `B022` already sweeps with. A comment is
665
+ # addressed to a maintainer and an identifier is not a word; a brand pack has no
666
+ # opinion about either.
667
+ CODE_SUFFIXES = {
668
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
669
+ ".py", ".go", ".rb", ".rs", ".java", ".kt", ".swift", ".php",
670
+ }
671
+
672
+ # `${...}` in a template literal is a value, not a word.
673
+ #
674
+ # Replaced by a space rather than dropped, so `founder of ${n} products` does not
675
+ # collapse into one word. Substituted rather than left in place, because
676
+ # `CODE_FRAGMENT_RE` rejects any literal carrying an interpolation -- so without
677
+ # this line every interpolated string would fail `_looks_like_copy` and
678
+ # interpolated copy would lose its coverage entirely. That would be a worse bug
679
+ # than the one this fixes: on sshlg.me the whole biography is interpolated.
680
+ INTERPOLATION_RE = re.compile(r"\$\{[^{}]*\}")
681
+
682
+
683
+ # Languages whose line comment is `#` rather than `//`.
684
+ HASH_COMMENT_SUFFIXES = {".py", ".rb"}
685
+
686
+
687
+ def _strip_comments(text: str, suffix: str) -> str:
688
+ """Remove comments, leaving string literals untouched.
689
+
690
+ A regex cannot do this and the first version of this fix tried: `"https://x"`
691
+ contains `//`, and a comment can quote a phrase, which is how
692
+ `// "AI integrations" named nothing checkable -- and "AI" is the medium` came
693
+ back as a rhetorical-dash error in copy on the first run of the very change
694
+ that was supposed to stop reading comments.
695
+
696
+ The scanner is small because it only has to know one thing at a time:
697
+ whether it is currently inside a quote.
698
+ """
699
+ hashed = suffix in HASH_COMMENT_SUFFIXES
700
+ out: list[str] = []
701
+ quote = None
702
+ i, n = 0, len(text)
703
+ while i < n:
704
+ ch = text[i]
705
+ if quote:
706
+ out.append(ch)
707
+ if ch == "\\" and i + 1 < n: # an escaped quote is not the end
708
+ out.append(text[i + 1])
709
+ i += 2
710
+ continue
711
+ if ch == quote:
712
+ quote = None
713
+ i += 1
714
+ continue
715
+ if ch in "\"'`":
716
+ quote = ch
717
+ out.append(ch)
718
+ i += 1
719
+ continue
720
+ if hashed and ch == "#":
721
+ while i < n and text[i] != "\n":
722
+ i += 1
723
+ continue
724
+ if not hashed and text.startswith("//", i):
725
+ while i < n and text[i] != "\n":
726
+ i += 1
727
+ continue
728
+ if not hashed and text.startswith("/*", i):
729
+ end = text.find("*/", i + 2)
730
+ i = n if end == -1 else end + 2
731
+ continue
732
+ out.append(ch)
733
+ i += 1
734
+ return "".join(out)
735
+
736
+
737
+ def _copy_in_code(text: str, suffix: str = ".ts") -> str:
738
+ """The user-visible strings in a source file, joined as prose."""
739
+ out = []
740
+ for _quote, literal in LITERAL_RE.findall(_strip_comments(text, suffix)):
741
+ candidate = re.sub(r"\s{2,}", " ", INTERPOLATION_RE.sub(" ", literal)).strip()
742
+ # A literal with no space is an import path, an identifier or a one-word
743
+ # label. None of them can carry a rhetorical dash or stuff a keyword, and
744
+ # `"@/data/site"` counted as copy would put `data` into the density
745
+ # figures. B022 still sees them; these checks do not need to.
746
+ if " " not in candidate:
747
+ continue
748
+ if _looks_like_copy(candidate):
749
+ out.append(candidate)
750
+ return "\n\n".join(out)
751
+
752
+
566
753
  def documents(brand_dir: Path, sources: dict, key: str) -> list[tuple]:
567
754
  """(relative path, front matter, body) for one declared source key."""
568
755
  root = brand_dir.parent.parent
@@ -572,6 +759,8 @@ def documents(brand_dir: Path, sources: dict, key: str) -> list[tuple]:
572
759
  if not path.is_file():
573
760
  continue
574
761
  fields, body = _front_matter(read(path) or "")
762
+ if path.suffix in CODE_SUFFIXES:
763
+ body = _copy_in_code(body, path.suffix)
575
764
  out.append((path.relative_to(root).as_posix(), fields, body))
576
765
  return out
577
766
 
@@ -590,17 +779,61 @@ def surfaces(brand_dir: Path) -> dict[str, dict]:
590
779
  return out
591
780
 
592
781
 
782
+ def tables(text: str) -> list[tuple[list[str], list[list[str]]]]:
783
+ """The same rows, but grouped per table as (header, data rows).
784
+
785
+ `table_rows` flattens every table in a file into one list, which is right
786
+ for a caller that wants every row and wrong for one that wants the rows of
787
+ a particular table. `facts()` was the second kind and used the first: it
788
+ took any six-column row anywhere in facts.md, so the App Store table --
789
+ `Product | App Store name | id | Released | Sold | Publisher today` -- was
790
+ read as six fact fields. Its **Sold** year landed in `Review`, which
791
+ produced three "was due for review on 2022" warnings about sales that
792
+ completed on schedule, and its own header row became a fact called
793
+ `Product`. Four phantom rows in a registry of 43.
794
+
795
+ What it did **not** do, checked before this comment was written: the App
796
+ Store ids were already registered properly, by the `Sold-app store ids` row,
797
+ so the B030 corpus was never widened by the bug. Only the product names
798
+ entered it, and B030 only reads numbers.
799
+
800
+ A table is identified by its header, because that is the only thing in a
801
+ markdown table that says what its columns mean.
802
+ """
803
+ blocks, current = [], []
804
+ for line in text.splitlines():
805
+ stripped = line.strip()
806
+ if stripped.startswith("|") and stripped.endswith("|"):
807
+ current.append(line)
808
+ elif current:
809
+ rows = table_rows("\n".join(current))
810
+ if rows:
811
+ blocks.append((rows[0], rows[1:]))
812
+ current = []
813
+ if current:
814
+ rows = table_rows("\n".join(current))
815
+ if rows:
816
+ blocks.append((rows[0], rows[1:]))
817
+ return blocks
818
+
819
+
593
820
  def facts(brand_dir: Path) -> list[dict]:
594
821
  rows = []
595
- for cells in table_rows(read(brand_dir / "facts.md") or ""):
596
- if len(cells) < 6 or cells[0].strip().lower() == "fact":
822
+ for header, body in tables(read(brand_dir / "facts.md") or ""):
823
+ # Scoped by header rather than by column count -- see tables(). Any
824
+ # six-column table in this file used to qualify, and one of them is a
825
+ # product ledger whose columns mean something else entirely.
826
+ if len(header) < 6 or header[0].strip().lower() != "fact":
597
827
  continue
598
- if unfilled(cells[0]) or unfilled(cells[1]):
599
- continue
600
- rows.append({
601
- "fact": cells[0], "value": cells[1], "source": cells[2],
602
- "checked": cells[3], "review": cells[4], "public": cells[5],
603
- })
828
+ for cells in body:
829
+ if len(cells) < 6:
830
+ continue
831
+ if unfilled(cells[0]) or unfilled(cells[1]):
832
+ continue
833
+ rows.append({
834
+ "fact": cells[0], "value": cells[1], "source": cells[2],
835
+ "checked": cells[3], "review": cells[4], "public": cells[5],
836
+ })
604
837
  return rows
605
838
 
606
839
 
@@ -962,7 +1195,26 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
962
1195
  ))
963
1196
 
964
1197
  records = surfaces(brand_dir)
965
- for path, fields, body in documents(brand_dir, sources, "marketing"):
1198
+ marketing = documents(brand_dir, sources, "marketing")
1199
+
1200
+ # Density is a property of the DOCUMENT a reader meets, and a code file is
1201
+ # not one. A project that keeps its copy in `src/data/*.ts` splits one page
1202
+ # across seven files, and measuring each file separately measures the split:
1203
+ # on sshlg.me that produced six errors -- `co-founder` at 2.0% of
1204
+ # `track-record.ts`, `account` at 1.2% of `site.ts` -- while the rendered
1205
+ # page carried nothing above 1% and those two words sat at 0.07% and 0.04%.
1206
+ #
1207
+ # So the code files are pooled into one document and the markdown ones are
1208
+ # not, because there one file really is one page. The pooled finding names
1209
+ # the set rather than a file, since no single file is the defect.
1210
+ pooled = [d for d in marketing if Path(d[0]).suffix in CODE_SUFFIXES]
1211
+ per_file = [d for d in marketing if Path(d[0]).suffix not in CODE_SUFFIXES]
1212
+ if pooled:
1213
+ label = (f"{len(pooled)} source file(s) pooled"
1214
+ if len(pooled) > 1 else pooled[0][0])
1215
+ per_file.append((label, {}, "\n\n".join(d[2] for d in pooled)))
1216
+
1217
+ for path, fields, body in per_file:
966
1218
  words = [w.lower().strip(".,:;!?()\"'") for w in body.split()]
967
1219
  real = [w for w in words if len(w) > 3 and w not in STOPWORDS]
968
1220
  if len(real) >= 40:
@@ -979,6 +1231,8 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
979
1231
  ))
980
1232
  break
981
1233
 
1234
+ for path, fields, body in marketing:
1235
+
982
1236
  opening = body.strip().lower()[:120]
983
1237
  for filler in FILLER_OPENERS:
984
1238
  if opening.startswith(filler) or f"\n{filler}" in opening: