prodc 0.7.2__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.2 → prodc-0.7.4}/CHANGELOG.md +18 -1
  2. {prodc-0.7.2 → prodc-0.7.4}/PKG-INFO +1 -1
  3. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/__init__.py +1 -1
  4. prodc-0.7.4/src/prodc/catalog/__init__.py +44 -0
  5. prodc-0.7.4/src/prodc/catalog/model.py +45 -0
  6. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/catalog/ux.py +10 -39
  7. prodc-0.7.4/src/prodc/catalog/visual.py +329 -0
  8. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/commands.py +23 -9
  9. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/config.py +13 -4
  10. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/loader.py +5 -3
  11. prodc-0.7.4/tests/test_catalog_registry.py +62 -0
  12. prodc-0.7.4/tests/test_visual_catalog.py +144 -0
  13. prodc-0.7.2/src/prodc/catalog/__init__.py +0 -10
  14. {prodc-0.7.2 → prodc-0.7.4}/.gitignore +0 -0
  15. {prodc-0.7.2 → prodc-0.7.4}/LICENSE +0 -0
  16. {prodc-0.7.2 → prodc-0.7.4}/README.md +0 -0
  17. {prodc-0.7.2 → prodc-0.7.4}/examples/quickstart/features/orders.feature +0 -0
  18. {prodc-0.7.2 → prodc-0.7.4}/examples/quickstart/needs/jordan.yaml +0 -0
  19. {prodc-0.7.2 → prodc-0.7.4}/examples/quickstart/prodc.toml +0 -0
  20. {prodc-0.7.2 → prodc-0.7.4}/pyproject.toml +0 -0
  21. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/adapters.py +0 -0
  22. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/agent.md +0 -0
  23. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/anchor.py +0 -0
  24. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/board.py +0 -0
  25. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/callbacks.py +0 -0
  26. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/__init__.py +0 -0
  27. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/__main__.py +0 -0
  28. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/app.py +0 -0
  29. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/common.py +0 -0
  30. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/render.py +0 -0
  31. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/cli/serialize.py +0 -0
  32. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/dedup.py +0 -0
  33. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/glossary.py +0 -0
  34. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/guide.md +0 -0
  35. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/inbox.py +0 -0
  36. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/issues.py +0 -0
  37. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/model.py +0 -0
  38. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/py.typed +0 -0
  39. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/report.py +0 -0
  40. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/report_export.py +0 -0
  41. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/report_html.py +0 -0
  42. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/status.py +0 -0
  43. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/storybook.py +0 -0
  44. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/views.py +0 -0
  45. {prodc-0.7.2 → prodc-0.7.4}/src/prodc/writer.py +0 -0
  46. {prodc-0.7.2 → prodc-0.7.4}/tests/fixtures/storybook/index.json +0 -0
  47. {prodc-0.7.2 → prodc-0.7.4}/tests/test_adapters.py +0 -0
  48. {prodc-0.7.2 → prodc-0.7.4}/tests/test_anchor.py +0 -0
  49. {prodc-0.7.2 → prodc-0.7.4}/tests/test_answer.py +0 -0
  50. {prodc-0.7.2 → prodc-0.7.4}/tests/test_attest_policy.py +0 -0
  51. {prodc-0.7.2 → prodc-0.7.4}/tests/test_awaits.py +0 -0
  52. {prodc-0.7.2 → prodc-0.7.4}/tests/test_callbacks.py +0 -0
  53. {prodc-0.7.2 → prodc-0.7.4}/tests/test_capture_block_scalar.py +0 -0
  54. {prodc-0.7.2 → prodc-0.7.4}/tests/test_catalog_add.py +0 -0
  55. {prodc-0.7.2 → prodc-0.7.4}/tests/test_catalog_lint.py +0 -0
  56. {prodc-0.7.2 → prodc-0.7.4}/tests/test_citation.py +0 -0
  57. {prodc-0.7.2 → prodc-0.7.4}/tests/test_claim_gating.py +0 -0
  58. {prodc-0.7.2 → prodc-0.7.4}/tests/test_cli.py +0 -0
  59. {prodc-0.7.2 → prodc-0.7.4}/tests/test_command_hook.py +0 -0
  60. {prodc-0.7.2 → prodc-0.7.4}/tests/test_confidence.py +0 -0
  61. {prodc-0.7.2 → prodc-0.7.4}/tests/test_config_keys.py +0 -0
  62. {prodc-0.7.2 → prodc-0.7.4}/tests/test_contradicts.py +0 -0
  63. {prodc-0.7.2 → prodc-0.7.4}/tests/test_decisions.py +0 -0
  64. {prodc-0.7.2 → prodc-0.7.4}/tests/test_declared_only.py +0 -0
  65. {prodc-0.7.2 → prodc-0.7.4}/tests/test_dedup.py +0 -0
  66. {prodc-0.7.2 → prodc-0.7.4}/tests/test_dedup_candidates.py +0 -0
  67. {prodc-0.7.2 → prodc-0.7.4}/tests/test_description.py +0 -0
  68. {prodc-0.7.2 → prodc-0.7.4}/tests/test_drafts.py +0 -0
  69. {prodc-0.7.2 → prodc-0.7.4}/tests/test_evalgate.py +0 -0
  70. {prodc-0.7.2 → prodc-0.7.4}/tests/test_expects.py +0 -0
  71. {prodc-0.7.2 → prodc-0.7.4}/tests/test_external_adapter.py +0 -0
  72. {prodc-0.7.2 → prodc-0.7.4}/tests/test_files_key.py +0 -0
  73. {prodc-0.7.2 → prodc-0.7.4}/tests/test_flowsteps.py +0 -0
  74. {prodc-0.7.2 → prodc-0.7.4}/tests/test_gitlab_source.py +0 -0
  75. {prodc-0.7.2 → prodc-0.7.4}/tests/test_glossary.py +0 -0
  76. {prodc-0.7.2 → prodc-0.7.4}/tests/test_importance.py +0 -0
  77. {prodc-0.7.2 → prodc-0.7.4}/tests/test_inbox.py +0 -0
  78. {prodc-0.7.2 → prodc-0.7.4}/tests/test_inventory.py +0 -0
  79. {prodc-0.7.2 → prodc-0.7.4}/tests/test_issues.py +0 -0
  80. {prodc-0.7.2 → prodc-0.7.4}/tests/test_lint_decision.py +0 -0
  81. {prodc-0.7.2 → prodc-0.7.4}/tests/test_loader.py +0 -0
  82. {prodc-0.7.2 → prodc-0.7.4}/tests/test_models.py +0 -0
  83. {prodc-0.7.2 → prodc-0.7.4}/tests/test_needref.py +0 -0
  84. {prodc-0.7.2 → prodc-0.7.4}/tests/test_path.py +0 -0
  85. {prodc-0.7.2 → prodc-0.7.4}/tests/test_path_views.py +0 -0
  86. {prodc-0.7.2 → prodc-0.7.4}/tests/test_playwright.py +0 -0
  87. {prodc-0.7.2 → prodc-0.7.4}/tests/test_principle_withdrawal.py +0 -0
  88. {prodc-0.7.2 → prodc-0.7.4}/tests/test_principles.py +0 -0
  89. {prodc-0.7.2 → prodc-0.7.4}/tests/test_relink.py +0 -0
  90. {prodc-0.7.2 → prodc-0.7.4}/tests/test_report.py +0 -0
  91. {prodc-0.7.2 → prodc-0.7.4}/tests/test_report_export.py +0 -0
  92. {prodc-0.7.2 → prodc-0.7.4}/tests/test_report_html.py +0 -0
  93. {prodc-0.7.2 → prodc-0.7.4}/tests/test_results_reports.py +0 -0
  94. {prodc-0.7.2 → prodc-0.7.4}/tests/test_review.py +0 -0
  95. {prodc-0.7.2 → prodc-0.7.4}/tests/test_roles.py +0 -0
  96. {prodc-0.7.2 → prodc-0.7.4}/tests/test_source_role.py +0 -0
  97. {prodc-0.7.2 → prodc-0.7.4}/tests/test_sources.py +0 -0
  98. {prodc-0.7.2 → prodc-0.7.4}/tests/test_stage.py +0 -0
  99. {prodc-0.7.2 → prodc-0.7.4}/tests/test_staleness.py +0 -0
  100. {prodc-0.7.2 → prodc-0.7.4}/tests/test_status.py +0 -0
  101. {prodc-0.7.2 → prodc-0.7.4}/tests/test_storybook.py +0 -0
  102. {prodc-0.7.2 → prodc-0.7.4}/tests/test_suggest.py +0 -0
  103. {prodc-0.7.2 → prodc-0.7.4}/tests/test_suggest_eval.py +0 -0
  104. {prodc-0.7.2 → prodc-0.7.4}/tests/test_ux_catalog.py +0 -0
  105. {prodc-0.7.2 → prodc-0.7.4}/tests/test_variants.py +0 -0
  106. {prodc-0.7.2 → prodc-0.7.4}/tests/test_view_links.py +0 -0
  107. {prodc-0.7.2 → prodc-0.7.4}/tests/test_views.py +0 -0
  108. {prodc-0.7.2 → prodc-0.7.4}/tests/test_views_report.py +0 -0
  109. {prodc-0.7.2 → prodc-0.7.4}/tests/test_vitest.py +0 -0
  110. {prodc-0.7.2 → prodc-0.7.4}/tests/test_who_variants.py +0 -0
  111. {prodc-0.7.2 → prodc-0.7.4}/tests/test_withdrawn.py +0 -0
  112. {prodc-0.7.2 → prodc-0.7.4}/tests/test_writer.py +0 -0
  113. {prodc-0.7.2 → prodc-0.7.4}/tests/test_writer_comments.py +0 -0
@@ -4,10 +4,27 @@ 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
+
7
24
  ### Fixed
8
25
 
9
26
  - A vitest or Playwright proof now finds a test whose title is a template literal: the locator
10
- `at most 50 rows` matches `it(`at most ${LIMIT} rows`)`. Before, the proof was BROKEN.
27
+ `at most 50 rows` matches ``it(`at most ${LIMIT} rows`)``. Before, the proof was BROKEN.
11
28
 
12
29
  ## [0.7.1] - 2026-11-09
13
30
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.7.2
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.2" # 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]
@@ -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>``."""
@@ -19,7 +19,8 @@ from typing import cast
19
19
 
20
20
  from ..board import annotate_need_warnings, gather
21
21
  from ..callbacks import advance_ledger, fire, read_ledger, transitions, write_ledger
22
- from ..catalog import UX_CATALOG
22
+ from ..catalog import CATALOG
23
+ from ..catalog import find as find_catalog_entry
23
24
  from ..config import Config, ConfigError
24
25
  from ..dedup import (
25
26
  VariantGroup,
@@ -352,10 +353,10 @@ def cmd_lint(args: argparse.Namespace) -> int:
352
353
  # as warn by default; set [lint].missing_catalog_principles = "error" for a CI gate.
353
354
  if cfg.principle_packs and cfg.missing_catalog_principles != "off":
354
355
  present_ids = {p.id for p in model.principles}
355
- for entry in UX_CATALOG:
356
+ for entry in CATALOG:
356
357
  if entry.pack not in cfg.principle_packs:
357
358
  continue
358
- if not entry.checkable:
359
+ if not (entry.checkable or entry.sign_off):
359
360
  continue
360
361
  if entry.id not in present_ids:
361
362
  msg = (
@@ -375,9 +376,7 @@ def cmd_lint(args: argparse.Namespace) -> int:
375
376
  continue
376
377
  slots = _SLOT_RE.findall(p.text)
377
378
  if slots:
378
- warnings.append(
379
- (p.id, f"principle text has unfilled slot(s): {', '.join(slots)}")
380
- )
379
+ warnings.append((p.id, f"principle text has unfilled slot(s): {', '.join(slots)}"))
381
380
  if args.json:
382
381
  print(
383
382
  json.dumps(
@@ -673,7 +672,9 @@ def cmd_add(args: argparse.Namespace) -> int:
673
672
  print(f"prodc: no config at {cfg_path}", file=sys.stderr)
674
673
  return 2
675
674
  if args.need_id is None and getattr(args, "from_catalog", None) is None:
676
- print("prodc: add requires an id (or --from-catalog for a catalog principle)", file=sys.stderr)
675
+ print(
676
+ "prodc: add requires an id (or --from-catalog for a catalog principle)", file=sys.stderr
677
+ )
677
678
  return 2
678
679
  if args.noun == "role":
679
680
  if args.text is None:
@@ -700,12 +701,19 @@ def cmd_add(args: argparse.Namespace) -> int:
700
701
  from_catalog = getattr(args, "from_catalog", None)
701
702
  decline = getattr(args, "decline", None)
702
703
  if from_catalog is not None:
703
- entry = next((e for e in UX_CATALOG if e.id == from_catalog), None)
704
+ entry = find_catalog_entry(from_catalog)
704
705
  if entry is None:
705
706
  print(f"prodc: catalog entry {from_catalog!r} not found", file=sys.stderr)
706
707
  return 2
707
708
  try:
708
709
  cfg = Config.load(cfg_path)
710
+ if entry.scope not in cfg.principle_scopes:
711
+ print(
712
+ f"prodc: {entry.id} has scope {entry.scope!r}; add it to "
713
+ f"[principles].scopes in prodc.toml first",
714
+ file=sys.stderr,
715
+ )
716
+ return 2
709
717
  catalog_source = Source(kind=SourceKind.SECONDARY, locator=entry.url)
710
718
  edit = add_principle(
711
719
  cfg,
@@ -713,7 +721,7 @@ def cmd_add(args: argparse.Namespace) -> int:
713
721
  entry.text_template,
714
722
  file=args.file,
715
723
  revised_at=date.today().isoformat(),
716
- scope="design",
724
+ scope=entry.scope,
717
725
  sources=(catalog_source,),
718
726
  expect_sha=args.expect_sha,
719
727
  dry_run=args.dry_run,
@@ -722,6 +730,12 @@ def cmd_add(args: argparse.Namespace) -> int:
722
730
  if entry.recipe is not None and not args.dry_run:
723
731
  print(f"\nRecipe ({entry.recipe.kind}): {entry.recipe.summary}")
724
732
  print(entry.recipe.snippet)
733
+ if entry.sign_off and decline is None and not args.dry_run:
734
+ print(
735
+ f"\nSign-off: no test can check {entry.id}. Fill the slots, then add "
736
+ f"the proof 'accept:<who>@<date>' when the person signs it off. Add their "
737
+ f"own words as a primary source by editing the YAML; never via prodc add."
738
+ )
725
739
  if decline is not None and not args.dry_run:
726
740
  withdraw_principle(
727
741
  cfg,