prodc 0.7.1__tar.gz → 0.7.4__tar.gz

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.
Files changed (113) hide show
  1. {prodc-0.7.1 → prodc-0.7.4}/CHANGELOG.md +24 -0
  2. {prodc-0.7.1 → prodc-0.7.4}/PKG-INFO +1 -1
  3. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/__init__.py +1 -1
  4. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/adapters.py +20 -1
  5. prodc-0.7.4/src/prodc/catalog/__init__.py +44 -0
  6. prodc-0.7.4/src/prodc/catalog/model.py +45 -0
  7. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/catalog/ux.py +10 -39
  8. prodc-0.7.4/src/prodc/catalog/visual.py +329 -0
  9. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/commands.py +23 -9
  10. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/config.py +13 -4
  11. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/loader.py +5 -3
  12. prodc-0.7.4/tests/test_catalog_registry.py +62 -0
  13. prodc-0.7.4/tests/test_visual_catalog.py +144 -0
  14. {prodc-0.7.1 → prodc-0.7.4}/tests/test_vitest.py +22 -0
  15. prodc-0.7.1/src/prodc/catalog/__init__.py +0 -10
  16. {prodc-0.7.1 → prodc-0.7.4}/.gitignore +0 -0
  17. {prodc-0.7.1 → prodc-0.7.4}/LICENSE +0 -0
  18. {prodc-0.7.1 → prodc-0.7.4}/README.md +0 -0
  19. {prodc-0.7.1 → prodc-0.7.4}/examples/quickstart/features/orders.feature +0 -0
  20. {prodc-0.7.1 → prodc-0.7.4}/examples/quickstart/needs/jordan.yaml +0 -0
  21. {prodc-0.7.1 → prodc-0.7.4}/examples/quickstart/prodc.toml +0 -0
  22. {prodc-0.7.1 → prodc-0.7.4}/pyproject.toml +0 -0
  23. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/agent.md +0 -0
  24. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/anchor.py +0 -0
  25. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/board.py +0 -0
  26. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/callbacks.py +0 -0
  27. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/__init__.py +0 -0
  28. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/__main__.py +0 -0
  29. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/app.py +0 -0
  30. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/common.py +0 -0
  31. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/render.py +0 -0
  32. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/cli/serialize.py +0 -0
  33. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/dedup.py +0 -0
  34. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/glossary.py +0 -0
  35. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/guide.md +0 -0
  36. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/inbox.py +0 -0
  37. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/issues.py +0 -0
  38. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/model.py +0 -0
  39. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/py.typed +0 -0
  40. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/report.py +0 -0
  41. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/report_export.py +0 -0
  42. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/report_html.py +0 -0
  43. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/status.py +0 -0
  44. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/storybook.py +0 -0
  45. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/views.py +0 -0
  46. {prodc-0.7.1 → prodc-0.7.4}/src/prodc/writer.py +0 -0
  47. {prodc-0.7.1 → prodc-0.7.4}/tests/fixtures/storybook/index.json +0 -0
  48. {prodc-0.7.1 → prodc-0.7.4}/tests/test_adapters.py +0 -0
  49. {prodc-0.7.1 → prodc-0.7.4}/tests/test_anchor.py +0 -0
  50. {prodc-0.7.1 → prodc-0.7.4}/tests/test_answer.py +0 -0
  51. {prodc-0.7.1 → prodc-0.7.4}/tests/test_attest_policy.py +0 -0
  52. {prodc-0.7.1 → prodc-0.7.4}/tests/test_awaits.py +0 -0
  53. {prodc-0.7.1 → prodc-0.7.4}/tests/test_callbacks.py +0 -0
  54. {prodc-0.7.1 → prodc-0.7.4}/tests/test_capture_block_scalar.py +0 -0
  55. {prodc-0.7.1 → prodc-0.7.4}/tests/test_catalog_add.py +0 -0
  56. {prodc-0.7.1 → prodc-0.7.4}/tests/test_catalog_lint.py +0 -0
  57. {prodc-0.7.1 → prodc-0.7.4}/tests/test_citation.py +0 -0
  58. {prodc-0.7.1 → prodc-0.7.4}/tests/test_claim_gating.py +0 -0
  59. {prodc-0.7.1 → prodc-0.7.4}/tests/test_cli.py +0 -0
  60. {prodc-0.7.1 → prodc-0.7.4}/tests/test_command_hook.py +0 -0
  61. {prodc-0.7.1 → prodc-0.7.4}/tests/test_confidence.py +0 -0
  62. {prodc-0.7.1 → prodc-0.7.4}/tests/test_config_keys.py +0 -0
  63. {prodc-0.7.1 → prodc-0.7.4}/tests/test_contradicts.py +0 -0
  64. {prodc-0.7.1 → prodc-0.7.4}/tests/test_decisions.py +0 -0
  65. {prodc-0.7.1 → prodc-0.7.4}/tests/test_declared_only.py +0 -0
  66. {prodc-0.7.1 → prodc-0.7.4}/tests/test_dedup.py +0 -0
  67. {prodc-0.7.1 → prodc-0.7.4}/tests/test_dedup_candidates.py +0 -0
  68. {prodc-0.7.1 → prodc-0.7.4}/tests/test_description.py +0 -0
  69. {prodc-0.7.1 → prodc-0.7.4}/tests/test_drafts.py +0 -0
  70. {prodc-0.7.1 → prodc-0.7.4}/tests/test_evalgate.py +0 -0
  71. {prodc-0.7.1 → prodc-0.7.4}/tests/test_expects.py +0 -0
  72. {prodc-0.7.1 → prodc-0.7.4}/tests/test_external_adapter.py +0 -0
  73. {prodc-0.7.1 → prodc-0.7.4}/tests/test_files_key.py +0 -0
  74. {prodc-0.7.1 → prodc-0.7.4}/tests/test_flowsteps.py +0 -0
  75. {prodc-0.7.1 → prodc-0.7.4}/tests/test_gitlab_source.py +0 -0
  76. {prodc-0.7.1 → prodc-0.7.4}/tests/test_glossary.py +0 -0
  77. {prodc-0.7.1 → prodc-0.7.4}/tests/test_importance.py +0 -0
  78. {prodc-0.7.1 → prodc-0.7.4}/tests/test_inbox.py +0 -0
  79. {prodc-0.7.1 → prodc-0.7.4}/tests/test_inventory.py +0 -0
  80. {prodc-0.7.1 → prodc-0.7.4}/tests/test_issues.py +0 -0
  81. {prodc-0.7.1 → prodc-0.7.4}/tests/test_lint_decision.py +0 -0
  82. {prodc-0.7.1 → prodc-0.7.4}/tests/test_loader.py +0 -0
  83. {prodc-0.7.1 → prodc-0.7.4}/tests/test_models.py +0 -0
  84. {prodc-0.7.1 → prodc-0.7.4}/tests/test_needref.py +0 -0
  85. {prodc-0.7.1 → prodc-0.7.4}/tests/test_path.py +0 -0
  86. {prodc-0.7.1 → prodc-0.7.4}/tests/test_path_views.py +0 -0
  87. {prodc-0.7.1 → prodc-0.7.4}/tests/test_playwright.py +0 -0
  88. {prodc-0.7.1 → prodc-0.7.4}/tests/test_principle_withdrawal.py +0 -0
  89. {prodc-0.7.1 → prodc-0.7.4}/tests/test_principles.py +0 -0
  90. {prodc-0.7.1 → prodc-0.7.4}/tests/test_relink.py +0 -0
  91. {prodc-0.7.1 → prodc-0.7.4}/tests/test_report.py +0 -0
  92. {prodc-0.7.1 → prodc-0.7.4}/tests/test_report_export.py +0 -0
  93. {prodc-0.7.1 → prodc-0.7.4}/tests/test_report_html.py +0 -0
  94. {prodc-0.7.1 → prodc-0.7.4}/tests/test_results_reports.py +0 -0
  95. {prodc-0.7.1 → prodc-0.7.4}/tests/test_review.py +0 -0
  96. {prodc-0.7.1 → prodc-0.7.4}/tests/test_roles.py +0 -0
  97. {prodc-0.7.1 → prodc-0.7.4}/tests/test_source_role.py +0 -0
  98. {prodc-0.7.1 → prodc-0.7.4}/tests/test_sources.py +0 -0
  99. {prodc-0.7.1 → prodc-0.7.4}/tests/test_stage.py +0 -0
  100. {prodc-0.7.1 → prodc-0.7.4}/tests/test_staleness.py +0 -0
  101. {prodc-0.7.1 → prodc-0.7.4}/tests/test_status.py +0 -0
  102. {prodc-0.7.1 → prodc-0.7.4}/tests/test_storybook.py +0 -0
  103. {prodc-0.7.1 → prodc-0.7.4}/tests/test_suggest.py +0 -0
  104. {prodc-0.7.1 → prodc-0.7.4}/tests/test_suggest_eval.py +0 -0
  105. {prodc-0.7.1 → prodc-0.7.4}/tests/test_ux_catalog.py +0 -0
  106. {prodc-0.7.1 → prodc-0.7.4}/tests/test_variants.py +0 -0
  107. {prodc-0.7.1 → prodc-0.7.4}/tests/test_view_links.py +0 -0
  108. {prodc-0.7.1 → prodc-0.7.4}/tests/test_views.py +0 -0
  109. {prodc-0.7.1 → prodc-0.7.4}/tests/test_views_report.py +0 -0
  110. {prodc-0.7.1 → prodc-0.7.4}/tests/test_who_variants.py +0 -0
  111. {prodc-0.7.1 → prodc-0.7.4}/tests/test_withdrawn.py +0 -0
  112. {prodc-0.7.1 → prodc-0.7.4}/tests/test_writer.py +0 -0
  113. {prodc-0.7.1 → prodc-0.7.4}/tests/test_writer_comments.py +0 -0
@@ -4,6 +4,30 @@ All notable changes are documented here. Versioning is [SemVer](https://semver.o
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.4] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - A `visual` catalog pack: `[principles].packs = ["visual"]`. Four entries ship a vitest guard
12
+ (text contrast, UI contrast, status not shown by colour alone, no colour literal outside the
13
+ token files). `visual-palette-chosen` is signed off by a person with an `accept:` proof. See
14
+ [docs/visual.md](docs/visual.md).
15
+ - `prodc lint` reports an unknown name in `[principles].packs` (lint exits 1).
16
+
17
+ ### Changed
18
+
19
+ - `prodc add principle --from-catalog` stops with a clear message when the entry's scope is not in
20
+ `[principles].scopes`.
21
+
22
+ ## [0.7.2] - 2026-11-09
23
+
24
+ ### Fixed
25
+
26
+ - A vitest or Playwright proof now finds a test whose title is a template literal: the locator
27
+ `at most 50 rows` matches ``it(`at most ${LIMIT} rows`)``. Before, the proof was BROKEN.
28
+
29
+ ## [0.7.1] - 2026-11-09
30
+
7
31
  ### Changed
8
32
 
9
33
  - `ux-miller` recipe: replaced placeholder with a source-scanning vitest guard that tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.7.1
3
+ Version: 0.7.4
4
4
  Summary: Product management as code: roles, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/cardona-oss/prodc
6
6
  Project-URL: Repository, https://gitlab.com/cardona-oss/prodc
@@ -1,3 +1,3 @@
1
1
  """prodc: product management as code."""
2
2
 
3
- __version__ = "0.7.1" # the single source of truth; pyproject reads it via [tool.hatch.version]
3
+ __version__ = "0.7.4" # the single source of truth; pyproject reads it via [tool.hatch.version]
@@ -580,18 +580,37 @@ class VitestAdapter:
580
580
  def _titles_missing(text: str, title: str, sep: str) -> str | None:
581
581
  """The first segment of ``title`` that does not appear in a test file's source, else None.
582
582
 
583
- Each title segment must appear in the source. A quote escaped in the literal is tolerated.
583
+ Each title segment must appear in the source. A quote escaped in the literal is tolerated. A
584
+ segment also matches a template-literal title when it is that title with each ``${...}``
585
+ filled in, as vitest and Playwright render it (``at most ${LIMIT} rows`` matches
586
+ ``at most 50 rows``).
584
587
  """
588
+ templates = _template_titles(text)
585
589
  for seg in (s.strip() for s in title.split(sep)):
586
590
  if (
587
591
  seg not in text
588
592
  and seg.replace("'", "\\'") not in text
589
593
  and seg.replace('"', '\\"') not in text
594
+ and not any(t.fullmatch(seg) for t in templates)
590
595
  ):
591
596
  return seg
592
597
  return None
593
598
 
594
599
 
600
+ #: A backtick string holding at least one ``${...}`` substitution.
601
+ _TEMPLATE_RE = re.compile(r"`(?P<body>(?:\\.|[^`\\])*\$\{(?:\\.|[^`\\])*)`")
602
+ _SUBST_RE = re.compile(r"\$\{[^}]*\}")
603
+
604
+
605
+ def _template_titles(text: str) -> list[re.Pattern[str]]:
606
+ """A regex per template literal in ``text``: literal parts escaped, each ``${...}`` as ``.+``."""
607
+ out: list[re.Pattern[str]] = []
608
+ for m in _TEMPLATE_RE.finditer(text):
609
+ parts = _SUBST_RE.split(m["body"])
610
+ out.append(re.compile(".+".join(re.escape(part) for part in parts), re.DOTALL))
611
+ return out
612
+
613
+
595
614
  def _js_inventory(root: Path, patterns: Sequence[str], test_re: re.Pattern[str]) -> list[str]:
596
615
  """Every ``file#title`` that ``test_re`` finds; a title with ``${`` is computed, so skipped."""
597
616
  out: set[str] = set()
@@ -0,0 +1,44 @@
1
+ """Catalog of reusable principle definitions grouped by pack.
2
+
3
+ A pack is a named collection of :class:`CatalogEntry` items. Each entry carries a
4
+ :class:`Recipe` when the rule can be verified by a test. :data:`CATALOG` holds every entry of
5
+ every pack; :func:`find` looks one up by id. An entry id starts with its pack name and a dash
6
+ (``ux-fitts``, ``visual-contrast-text``), so ``ux-`` and ``visual-`` are reserved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .model import CatalogEntry, Recipe
12
+ from .ux import UX_CATALOG
13
+ from .visual import VISUAL_CATALOG
14
+
15
+ CATALOG: tuple[CatalogEntry, ...] = UX_CATALOG + VISUAL_CATALOG
16
+ """Every catalog entry, all packs."""
17
+
18
+ PACKS: frozenset[str] = frozenset(e.pack for e in CATALOG)
19
+ """The pack names ``[principles].packs`` accepts."""
20
+
21
+ _BY_ID: dict[str, CatalogEntry] = {e.id: e for e in CATALOG}
22
+
23
+
24
+ def find(entry_id: str) -> CatalogEntry | None:
25
+ """Return the catalog entry with this id, or None."""
26
+ return _BY_ID.get(entry_id)
27
+
28
+
29
+ def reserved_pack(principle_id: str) -> str | None:
30
+ """Return the pack whose prefix ``principle_id`` uses, or None when it uses no pack prefix."""
31
+ head = principle_id.split("-", 1)[0]
32
+ return head if head in PACKS and principle_id.startswith(f"{head}-") else None
33
+
34
+
35
+ __all__ = [
36
+ "CATALOG",
37
+ "PACKS",
38
+ "UX_CATALOG",
39
+ "VISUAL_CATALOG",
40
+ "CatalogEntry",
41
+ "Recipe",
42
+ "find",
43
+ "reserved_pack",
44
+ ]
@@ -0,0 +1,45 @@
1
+ """The catalog records: a principle template and the test recipe that guards it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Recipe:
11
+ """A test recipe for a checkable catalog principle.
12
+
13
+ ``kind`` names the test framework. ``snippet`` is a real guard taken from a consumer repo,
14
+ to adapt to the adopting repo's paths. ``proof_locator_hint`` suggests
15
+ the locator prefix and path to put under ``proofs:`` in the principle YAML.
16
+ """
17
+
18
+ kind: Literal["vitest", "playwright", "pytest"]
19
+ summary: str
20
+ snippet: str
21
+ proof_locator_hint: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CatalogEntry:
26
+ """One principle template from a named pack.
27
+
28
+ ``id`` is globally unique across all packs, starts with ``<pack>-``, and is the id written
29
+ to the YAML when the team adopts the entry. ``scope`` is the principle scope the adopted
30
+ principle gets; it must be listed in ``[principles].scopes``. ``text_template`` uses
31
+ ``{slot}`` placeholders for values the team fills in (e.g. ``{N}`` for a numeric threshold).
32
+ ``checkable`` is True when the rule can be verified by a test; a checkable entry must carry
33
+ a ``recipe``. ``sign_off`` is True when no test can check the rule and a person signs it off
34
+ with an ``accept:`` proof instead; lint asks for it like a checkable entry.
35
+ """
36
+
37
+ id: str
38
+ pack: str
39
+ title: str
40
+ url: str
41
+ text_template: str
42
+ checkable: bool
43
+ recipe: Recipe | None
44
+ scope: str = "design"
45
+ sign_off: bool = False
@@ -11,49 +11,14 @@ rendered text, a secondary source pointing at the law page, and prints the recip
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from dataclasses import dataclass
15
- from typing import Literal
16
-
17
-
18
- @dataclass(frozen=True)
19
- class Recipe:
20
- """A placeholder test recipe for a checkable UX-law principle.
21
-
22
- ``kind`` names the test framework. ``snippet`` is a placeholder to replace with a real
23
- guard from the consumer repo (e.g. from crm or tyko). ``proof_locator_hint`` suggests
24
- the locator prefix and path to put under ``proofs:`` in the principle YAML.
25
- """
26
-
27
- kind: Literal["vitest", "playwright", "pytest"]
28
- summary: str
29
- snippet: str
30
- proof_locator_hint: str
31
-
32
-
33
- @dataclass(frozen=True)
34
- class CatalogEntry:
35
- """One principle template from a named pack.
36
-
37
- ``id`` is globally unique across all packs and is the id written to the YAML when the
38
- team adopts the entry. ``text_template`` uses ``{slot}`` placeholders for values the team
39
- fills in (e.g. ``{N}`` for a numeric threshold). ``checkable`` is True when the rule can
40
- be verified by a test; a checkable entry must carry a ``recipe``.
41
- """
42
-
43
- id: str
44
- pack: str
45
- title: str
46
- url: str
47
- text_template: str
48
- checkable: bool
49
- recipe: Recipe | None
50
-
14
+ from .model import CatalogEntry, Recipe
51
15
 
52
16
  # -- checkable entries -------------------------------------------------------
53
17
 
54
18
  _FITTS = CatalogEntry(
55
19
  id="ux-fitts",
56
20
  pack="ux",
21
+ scope="design",
57
22
  title="Fitts's Law",
58
23
  url="https://lawsofux.com/fittss-law/",
59
24
  text_template=(
@@ -105,6 +70,7 @@ describe("interactive targets meet the 24x24px minimum (WCAG 2.5.8)", () => {
105
70
  _HICK = CatalogEntry(
106
71
  id="ux-hick",
107
72
  pack="ux",
73
+ scope="design",
108
74
  title="Hick's Law",
109
75
  url="https://lawsofux.com/hicks-law/",
110
76
  text_template="No top-level navigation menu shows more than {N} items at once.",
@@ -148,6 +114,7 @@ describe("the main nav has at most {N} top-level sections per role", () => {
148
114
  _VON_RESTORFF = CatalogEntry(
149
115
  id="ux-von-restorff",
150
116
  pack="ux",
117
+ scope="design",
151
118
  title="Von Restorff Effect",
152
119
  url="https://lawsofux.com/von-restorff-effect/",
153
120
  text_template=(
@@ -157,7 +124,7 @@ _VON_RESTORFF = CatalogEntry(
157
124
  checkable=True,
158
125
  recipe=Recipe(
159
126
  kind="vitest",
160
- summary="Grep form/dialog source files for variant=\"primary\"; each file must have at most one.",
127
+ summary='Grep form/dialog source files for variant="primary"; each file must have at most one.',
161
128
  # Real guard from crm: apps/website/src/design/vonRestorff.guard.test.ts
162
129
  # jsdom has no layout; scan source files statically for variant="primary" in JSX.
163
130
  snippet="""\
@@ -196,13 +163,14 @@ describe("at most one primary button per form or dialog", () => {
196
163
  });
197
164
  });
198
165
  """,
199
- proof_locator_hint="vitest:src/design/vonRestorff.guard.test.ts::at most one primary button per form or dialog > no form or dialog file contains more than one variant=\"primary\" button",
166
+ proof_locator_hint='vitest:src/design/vonRestorff.guard.test.ts::at most one primary button per form or dialog > no form or dialog file contains more than one variant="primary" button',
200
167
  ),
201
168
  )
202
169
 
203
170
  _MILLER = CatalogEntry(
204
171
  id="ux-miller",
205
172
  pack="ux",
173
+ scope="design",
206
174
  title="Miller's Law",
207
175
  url="https://lawsofux.com/millers-law/",
208
176
  text_template=(
@@ -247,6 +215,7 @@ describe("Miller's Law: list page cap stays within agreed bounds", () => {
247
215
  _JAKOB = CatalogEntry(
248
216
  id="ux-jakob",
249
217
  pack="ux",
218
+ scope="design",
250
219
  title="Jakob's Law",
251
220
  url="https://lawsofux.com/jakobs-law/",
252
221
  text_template=(
@@ -320,11 +289,13 @@ describe("Jakob's Law: app conventions match user expectations", () => {
320
289
 
321
290
  # -- non-checkable entries ---------------------------------------------------
322
291
 
292
+
323
293
  def _nc(id: str, title: str, url: str, text_template: str) -> CatalogEntry:
324
294
  """Build a non-checkable catalog entry (no recipe)."""
325
295
  return CatalogEntry(
326
296
  id=id,
327
297
  pack="ux",
298
+ scope="design",
328
299
  title=title,
329
300
  url=url,
330
301
  text_template=text_template,
@@ -0,0 +1,329 @@
1
+ """Visual-design catalog entries: the visual pack.
2
+
3
+ Each :class:`CatalogEntry` maps a rule from a published source (WCAG 2.2, Material 3) to a
4
+ principle template. Four entries have a test that checks them and carry a :class:`Recipe`.
5
+ ``visual-palette-chosen`` has no test: a person picks the palette and signs it off with an
6
+ ``accept:`` proof (``sign_off=True``).
7
+
8
+ The recipes are real guards run against crm's ``src/design/tokens.css``. Adapt the paths, the
9
+ pair table, and the status type to the adopting repo. A guard that fails on first run is
10
+ reporting real problems, not a broken recipe.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .model import CatalogEntry, Recipe
16
+
17
+ _CONTRAST_SNIPPET = r"""// src/design/contrast.ts: WCAG 2.2 contrast ratio for hex and rgb()/rgba() colours.
18
+ // https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
19
+
20
+ export type Rgba = { r: number; g: number; b: number; a: number };
21
+
22
+ /** Parse #rgb, #rrggbb, rgb(r, g, b) or rgba(r, g, b, a). Throws on anything else. */
23
+ export function parseColor(value: string): Rgba {
24
+ const v = value.trim().toLowerCase();
25
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(v);
26
+ if (hex) {
27
+ const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
28
+ return {
29
+ r: parseInt(h.slice(0, 2), 16),
30
+ g: parseInt(h.slice(2, 4), 16),
31
+ b: parseInt(h.slice(4, 6), 16),
32
+ a: 1,
33
+ };
34
+ }
35
+ const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(v);
36
+ if (fn) {
37
+ return { r: +fn[1], g: +fn[2], b: +fn[3], a: fn[4] === undefined ? 1 : +fn[4] };
38
+ }
39
+ throw new Error(`cannot parse colour ${JSON.stringify(value)}`);
40
+ }
41
+
42
+ /** Paint a translucent colour over an opaque background. */
43
+ export function composite(fg: Rgba, bg: Rgba): Rgba {
44
+ const mix = (f: number, b: number) => f * fg.a + b * (1 - fg.a);
45
+ return { r: mix(fg.r, bg.r), g: mix(fg.g, bg.g), b: mix(fg.b, bg.b), a: 1 };
46
+ }
47
+
48
+ function channel(c8: number): number {
49
+ const c = c8 / 255;
50
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
51
+ }
52
+
53
+ export function luminance(c: Rgba): number {
54
+ return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
55
+ }
56
+
57
+ /** Contrast ratio of fg on bg, unrounded. A translucent fg is composited over bg first. */
58
+ export function contrast(fg: string, bg: string): number {
59
+ const back = parseColor(bg);
60
+ if (back.a !== 1) throw new Error(`background ${bg} must be opaque`);
61
+ const front = composite(parseColor(fg), back);
62
+ const [hi, lo] = [luminance(front), luminance(back)].sort((x, y) => y - x);
63
+ return (hi + 0.05) / (lo + 0.05);
64
+ }
65
+
66
+ /** Read `--name: value;` declarations from one CSS block and resolve var() references. */
67
+ export function readTokens(css: string, selector: string): Map<string, string> {
68
+ const clean = css.replace(/\/\*[\s\S]*?\*\//g, "");
69
+ const start = clean.indexOf(`${selector} {`);
70
+ if (start < 0) throw new Error(`no ${selector} block`);
71
+ const body = clean.slice(start, clean.indexOf("}", start));
72
+ const raw = new Map<string, string>();
73
+ for (const m of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) raw.set(m[1], m[2].trim());
74
+ const resolve = (v: string, depth = 0): string => {
75
+ const ref = /^var\((--[\w-]+)\)$/.exec(v);
76
+ if (!ref) return v;
77
+ const next = raw.get(ref[1]);
78
+ if (next === undefined || depth > 10) throw new Error(`unresolved ${v}`);
79
+ return resolve(next, depth + 1);
80
+ };
81
+ return new Map([...raw].map(([k, v]) => [k, resolve(v)]));
82
+ }
83
+
84
+ // src/design/contrast.guard.test.ts: text >= 4.5:1, large text and UI >= 3:1 (WCAG 2.2 SC 1.4.3, 1.4.11).
85
+ import { readFileSync } from "node:fs";
86
+ import { join } from "node:path";
87
+ import { describe, expect, it } from "vitest";
88
+ import { contrast, readTokens } from "./contrast";
89
+
90
+ type Kind = "text" | "large" | "ui";
91
+ const MIN: Record<Kind, number> = { text: 4.5, large: 3, ui: 3 };
92
+
93
+ // Which token sits on which. Token files do not say it, so the pairs are listed by hand.
94
+ // Disabled controls are exempt (SC 1.4.3, 1.4.11). Placeholder text is not.
95
+ const PAIRS: Array<{ fg: string; bg: string; kind: Kind }> = [
96
+ { fg: "--ink", bg: "--surface", kind: "text" },
97
+ { fg: "--ink-muted", bg: "--surface", kind: "text" },
98
+ { fg: "--ink-soft", bg: "--surface", kind: "text" },
99
+ { fg: "--ink-faint", bg: "--surface", kind: "text" }, // placeholder text
100
+ { fg: "--ink", bg: "--cream", kind: "text" },
101
+ { fg: "--accent-fg", bg: "--accent", kind: "text" },
102
+ { fg: "--on-inverse", bg: "--surface-inverse", kind: "text" },
103
+ { fg: "--on-inverse-dim", bg: "--surface-inverse", kind: "text" },
104
+ { fg: "--fg-danger", bg: "--bg-danger-soft", kind: "text" },
105
+ { fg: "--fg-warn", bg: "--bg-warn-soft", kind: "text" },
106
+ { fg: "--fg-success", bg: "--bg-success-soft", kind: "text" },
107
+ { fg: "--fg-info", bg: "--bg-info-soft", kind: "text" },
108
+ { fg: "--accent", bg: "--surface", kind: "ui" }, // focus ring
109
+ { fg: "--border-strong", bg: "--surface", kind: "ui" }, // selected input border
110
+ ];
111
+
112
+ const CSS = readFileSync(join(import.meta.dirname, "tokens.css"), "utf8");
113
+ const THEMES = [":root", '[data-theme="dark"]'];
114
+
115
+ describe("colour contrast meets WCAG 2.2 AA", () => {
116
+ it("computes the published reference ratios", () => {
117
+ expect(contrast("#000", "#fff")).toBeCloseTo(21, 5);
118
+ expect(contrast("#767676", "#fff")).toBeGreaterThanOrEqual(4.5); // 4.54
119
+ expect(contrast("#777777", "#fff")).toBeLessThan(4.5); // 4.48, never rounded up
120
+ expect(() => contrast("oklch(0.5 0.1 200)", "#fff")).toThrow(); // unparseable fails, not skipped
121
+ });
122
+
123
+ const tokensFor = (theme: string) => {
124
+ const light = readTokens(CSS, ":root");
125
+ return theme === ":root" ? light : new Map([...light, ...readTokens(CSS, theme)]);
126
+ };
127
+ const failures = (theme: string, kinds: Kind[]) => {
128
+ const tokens = tokensFor(theme);
129
+ return PAIRS.filter((p) => kinds.includes(p.kind)).flatMap(({ fg, bg, kind }) => {
130
+ const ratio = contrast(tokens.get(fg) ?? fg, tokens.get(bg) ?? bg);
131
+ return ratio < MIN[kind] ? [`${theme} ${fg} on ${bg}: ${ratio.toFixed(2)} < ${MIN[kind]}`] : [];
132
+ });
133
+ };
134
+
135
+ it("text pairs meet 4.5:1, large text 3:1, in every theme", () => {
136
+ expect(THEMES.flatMap((t) => failures(t, ["text", "large"]))).toEqual([]);
137
+ });
138
+
139
+ it("UI component and state pairs meet 3:1, in every theme", () => {
140
+ expect(THEMES.flatMap((t) => failures(t, ["ui"]))).toEqual([]);
141
+ });
142
+
143
+ it("checks real pairs, not an empty table", () => {
144
+ expect(PAIRS.length).toBeGreaterThanOrEqual(8);
145
+ });
146
+ });
147
+ """
148
+
149
+ _CONTRAST_TEXT = CatalogEntry(
150
+ id="visual-contrast-text",
151
+ pack="visual",
152
+ scope="visual",
153
+ title="Contrast (Minimum), WCAG 2.2 SC 1.4.3",
154
+ url="https://www.w3.org/TR/WCAG22/#contrast-minimum",
155
+ text_template=(
156
+ "Text has a contrast ratio of at least 4.5:1 against its background, and large text "
157
+ "(18pt, or 14pt bold) at least 3:1, in every theme. Placeholder text counts. Text on a "
158
+ "disabled control and logotypes are exempt."
159
+ ),
160
+ checkable=True,
161
+ recipe=Recipe(
162
+ kind="vitest",
163
+ summary=(
164
+ "Compute the WCAG ratio for each listed text/background token pair in every theme. "
165
+ "The pair table is by hand: token files do not say which colour sits on which."
166
+ ),
167
+ snippet=_CONTRAST_SNIPPET,
168
+ proof_locator_hint=(
169
+ "vitest:src/design/contrast.guard.test.ts::colour contrast meets WCAG 2.2 AA > "
170
+ "text pairs meet 4.5:1, large text 3:1, in every theme"
171
+ ),
172
+ ),
173
+ )
174
+
175
+ _CONTRAST_UI = CatalogEntry(
176
+ id="visual-contrast-ui",
177
+ pack="visual",
178
+ scope="visual",
179
+ title="Non-text Contrast, WCAG 2.2 SC 1.4.11",
180
+ url="https://www.w3.org/TR/WCAG22/#non-text-contrast",
181
+ text_template=(
182
+ "The visual information needed to identify a control and its state (input border, "
183
+ "focus ring, selected state) and the parts of a graphic needed to understand it have a "
184
+ "contrast ratio of at least 3:1 against adjacent colours. Disabled controls and "
185
+ "decorative borders are exempt."
186
+ ),
187
+ checkable=True,
188
+ recipe=Recipe(
189
+ kind="vitest",
190
+ summary="The same contrast guard as visual-contrast-text; list the pairs with kind 'ui'.",
191
+ snippet=_CONTRAST_SNIPPET,
192
+ proof_locator_hint=(
193
+ "vitest:src/design/contrast.guard.test.ts::colour contrast meets WCAG 2.2 AA > "
194
+ "UI component and state pairs meet 3:1, in every theme"
195
+ ),
196
+ ),
197
+ )
198
+
199
+ _NOT_COLOR_ALONE = CatalogEntry(
200
+ id="visual-not-color-alone",
201
+ pack="visual",
202
+ scope="visual",
203
+ title="Use of Color, WCAG 2.2 SC 1.4.1",
204
+ url="https://www.w3.org/TR/WCAG22/#use-of-color",
205
+ text_template=(
206
+ "No status, state, or action is shown by colour alone: every status variant also has its "
207
+ "own text label or icon."
208
+ ),
209
+ checkable=True,
210
+ recipe=Recipe(
211
+ kind="vitest",
212
+ summary=(
213
+ "Walk every value of the status type (kept exhaustive with `satisfies`) and check "
214
+ "each has a non-empty label and no two share one."
215
+ ),
216
+ snippet=r"""// src/design/notColorAlone.guard.test.ts: every status has its own text label (WCAG 2.2 SC 1.4.1).
217
+ import { describe, expect, it } from "vitest";
218
+ import { type OrderLifecycle, orderStatusLabel } from "./primitives/OrderStatusPill";
219
+
220
+ // Every value of the status type. `satisfies` makes TypeScript fail when a status is added
221
+ // to the type and not here, so the guard stays exhaustive.
222
+ const ALL = {
223
+ open: true,
224
+ packed: true,
225
+ out_for_delivery: true,
226
+ delivered: true,
227
+ cancelled: true,
228
+ } satisfies Record<OrderLifecycle, true>;
229
+
230
+ describe("status is never shown by colour alone", () => {
231
+ it("every status renders a non-empty text label", () => {
232
+ for (const status of Object.keys(ALL)) {
233
+ expect(orderStatusLabel(status).trim(), `${status} has no label`).not.toBe("");
234
+ }
235
+ });
236
+
237
+ it("no two statuses share a label, so the text tells them apart without colour", () => {
238
+ const labels = Object.keys(ALL).map((s) => orderStatusLabel(s));
239
+ expect(new Set(labels).size).toBe(labels.length);
240
+ });
241
+ });
242
+ """,
243
+ proof_locator_hint=(
244
+ "vitest:src/design/notColorAlone.guard.test.ts::status is never shown by colour "
245
+ "alone > no two statuses share a label, so the text tells them apart without colour"
246
+ ),
247
+ ),
248
+ )
249
+
250
+ _COLOR_ROLES = CatalogEntry(
251
+ id="visual-color-roles",
252
+ pack="visual",
253
+ scope="visual",
254
+ title="Color roles (Material 3)",
255
+ url="https://developer.android.com/design/ui/mobile/guides/styles/color",
256
+ text_template=(
257
+ "Every colour in the UI comes from a role token ({token_file}). No hex, rgb(), hsl() or "
258
+ "oklch() literal appears outside the token files."
259
+ ),
260
+ checkable=True,
261
+ recipe=Recipe(
262
+ kind="vitest",
263
+ summary="Scan every .css/.ts/.tsx source outside the token files for a colour literal.",
264
+ snippet=r"""// src/design/colorRoles.guard.test.ts: colours come from role tokens, never raw literals.
265
+ // Material 3: "use tokens instead of hard-coded color values".
266
+ import { readdirSync, readFileSync, statSync } from "node:fs";
267
+ import { join, relative } from "node:path";
268
+ import { describe, expect, it } from "vitest";
269
+
270
+ const SRC = join(import.meta.dirname, "..");
271
+ const TOKEN_FILES = new Set(["design/tokens.css"]); // the only files allowed to hold literals
272
+ const LITERAL = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/;
273
+
274
+ function files(dir: string): string[] {
275
+ return readdirSync(dir).flatMap((name) => {
276
+ const path = join(dir, name);
277
+ if (statSync(path).isDirectory()) return name === "node_modules" ? [] : files(path);
278
+ return /\.(css|tsx?)$/.test(name) && !/\.(test|stories)\.tsx?$/.test(name) ? [path] : [];
279
+ });
280
+ }
281
+
282
+ describe("colours come from role tokens", () => {
283
+ it("no colour literal outside the token files", () => {
284
+ const hits = files(SRC)
285
+ .filter((f) => !TOKEN_FILES.has(relative(SRC, f)))
286
+ .flatMap((f) =>
287
+ readFileSync(f, "utf8")
288
+ .replace(/\/\*[\s\S]*?\*\//g, "")
289
+ .split("\n")
290
+ .flatMap((line, i) => (LITERAL.test(line) ? [`${relative(SRC, f)}:${i + 1}`] : [])),
291
+ );
292
+ expect(hits).toEqual([]);
293
+ });
294
+
295
+ it("watches a real tree", () => {
296
+ expect(files(SRC).length).toBeGreaterThan(10);
297
+ });
298
+ });
299
+ """,
300
+ proof_locator_hint=(
301
+ "vitest:src/design/colorRoles.guard.test.ts::colours come from role tokens > "
302
+ "no colour literal outside the token files"
303
+ ),
304
+ ),
305
+ )
306
+
307
+ _PALETTE_CHOSEN = CatalogEntry(
308
+ id="visual-palette-chosen",
309
+ pack="visual",
310
+ scope="visual",
311
+ title="A person picks the palette",
312
+ url="https://platform.claude.com/cookbook/coding-prompting-for-frontend-aesthetics",
313
+ text_template=(
314
+ "The palette is {palette}, picked by {who} from {N} candidates with different hue "
315
+ "anchors shown side by side. A model does not pick the palette alone."
316
+ ),
317
+ checkable=False,
318
+ recipe=None,
319
+ sign_off=True,
320
+ )
321
+
322
+ VISUAL_CATALOG: tuple[CatalogEntry, ...] = (
323
+ _CONTRAST_TEXT,
324
+ _CONTRAST_UI,
325
+ _NOT_COLOR_ALONE,
326
+ _COLOR_ROLES,
327
+ _PALETTE_CHOSEN,
328
+ )
329
+ """The visual pack. Adopt with ``prodc add principle --from-catalog <id>``."""