prodc 0.7.2__tar.gz → 0.8.0__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 (119) hide show
  1. {prodc-0.7.2 → prodc-0.8.0}/CHANGELOG.md +29 -1
  2. {prodc-0.7.2 → prodc-0.8.0}/PKG-INFO +6 -1
  3. {prodc-0.7.2 → prodc-0.8.0}/README.md +3 -0
  4. {prodc-0.7.2 → prodc-0.8.0}/pyproject.toml +3 -1
  5. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/__init__.py +1 -1
  6. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/agent.md +7 -0
  7. prodc-0.8.0/src/prodc/catalog/__init__.py +44 -0
  8. prodc-0.8.0/src/prodc/catalog/model.py +45 -0
  9. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/catalog/ux.py +10 -39
  10. prodc-0.8.0/src/prodc/catalog/visual.py +329 -0
  11. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/app.py +11 -2
  12. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/commands.py +23 -9
  13. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/common.py +8 -1
  14. prodc-0.8.0/src/prodc/cli/mcp.py +132 -0
  15. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/config.py +13 -4
  16. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/loader.py +5 -3
  17. prodc-0.8.0/src/prodc/mcp_config.py +147 -0
  18. prodc-0.8.0/src/prodc/mcp_server.py +267 -0
  19. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/writer.py +10 -0
  20. prodc-0.8.0/tests/test_catalog_registry.py +62 -0
  21. prodc-0.8.0/tests/test_edit_sha.py +46 -0
  22. prodc-0.8.0/tests/test_mcp_install.py +115 -0
  23. prodc-0.8.0/tests/test_mcp_server.py +117 -0
  24. prodc-0.8.0/tests/test_visual_catalog.py +144 -0
  25. prodc-0.7.2/src/prodc/catalog/__init__.py +0 -10
  26. {prodc-0.7.2 → prodc-0.8.0}/.gitignore +0 -0
  27. {prodc-0.7.2 → prodc-0.8.0}/LICENSE +0 -0
  28. {prodc-0.7.2 → prodc-0.8.0}/examples/quickstart/features/orders.feature +0 -0
  29. {prodc-0.7.2 → prodc-0.8.0}/examples/quickstart/needs/jordan.yaml +0 -0
  30. {prodc-0.7.2 → prodc-0.8.0}/examples/quickstart/prodc.toml +0 -0
  31. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/adapters.py +0 -0
  32. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/anchor.py +0 -0
  33. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/board.py +0 -0
  34. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/callbacks.py +0 -0
  35. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/__init__.py +0 -0
  36. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/__main__.py +0 -0
  37. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/render.py +0 -0
  38. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/serialize.py +0 -0
  39. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/dedup.py +0 -0
  40. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/glossary.py +0 -0
  41. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/guide.md +0 -0
  42. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/inbox.py +0 -0
  43. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/issues.py +0 -0
  44. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/model.py +0 -0
  45. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/py.typed +0 -0
  46. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/report.py +0 -0
  47. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/report_export.py +0 -0
  48. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/report_html.py +0 -0
  49. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/status.py +0 -0
  50. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/storybook.py +0 -0
  51. {prodc-0.7.2 → prodc-0.8.0}/src/prodc/views.py +0 -0
  52. {prodc-0.7.2 → prodc-0.8.0}/tests/fixtures/storybook/index.json +0 -0
  53. {prodc-0.7.2 → prodc-0.8.0}/tests/test_adapters.py +0 -0
  54. {prodc-0.7.2 → prodc-0.8.0}/tests/test_anchor.py +0 -0
  55. {prodc-0.7.2 → prodc-0.8.0}/tests/test_answer.py +0 -0
  56. {prodc-0.7.2 → prodc-0.8.0}/tests/test_attest_policy.py +0 -0
  57. {prodc-0.7.2 → prodc-0.8.0}/tests/test_awaits.py +0 -0
  58. {prodc-0.7.2 → prodc-0.8.0}/tests/test_callbacks.py +0 -0
  59. {prodc-0.7.2 → prodc-0.8.0}/tests/test_capture_block_scalar.py +0 -0
  60. {prodc-0.7.2 → prodc-0.8.0}/tests/test_catalog_add.py +0 -0
  61. {prodc-0.7.2 → prodc-0.8.0}/tests/test_catalog_lint.py +0 -0
  62. {prodc-0.7.2 → prodc-0.8.0}/tests/test_citation.py +0 -0
  63. {prodc-0.7.2 → prodc-0.8.0}/tests/test_claim_gating.py +0 -0
  64. {prodc-0.7.2 → prodc-0.8.0}/tests/test_cli.py +0 -0
  65. {prodc-0.7.2 → prodc-0.8.0}/tests/test_command_hook.py +0 -0
  66. {prodc-0.7.2 → prodc-0.8.0}/tests/test_confidence.py +0 -0
  67. {prodc-0.7.2 → prodc-0.8.0}/tests/test_config_keys.py +0 -0
  68. {prodc-0.7.2 → prodc-0.8.0}/tests/test_contradicts.py +0 -0
  69. {prodc-0.7.2 → prodc-0.8.0}/tests/test_decisions.py +0 -0
  70. {prodc-0.7.2 → prodc-0.8.0}/tests/test_declared_only.py +0 -0
  71. {prodc-0.7.2 → prodc-0.8.0}/tests/test_dedup.py +0 -0
  72. {prodc-0.7.2 → prodc-0.8.0}/tests/test_dedup_candidates.py +0 -0
  73. {prodc-0.7.2 → prodc-0.8.0}/tests/test_description.py +0 -0
  74. {prodc-0.7.2 → prodc-0.8.0}/tests/test_drafts.py +0 -0
  75. {prodc-0.7.2 → prodc-0.8.0}/tests/test_evalgate.py +0 -0
  76. {prodc-0.7.2 → prodc-0.8.0}/tests/test_expects.py +0 -0
  77. {prodc-0.7.2 → prodc-0.8.0}/tests/test_external_adapter.py +0 -0
  78. {prodc-0.7.2 → prodc-0.8.0}/tests/test_files_key.py +0 -0
  79. {prodc-0.7.2 → prodc-0.8.0}/tests/test_flowsteps.py +0 -0
  80. {prodc-0.7.2 → prodc-0.8.0}/tests/test_gitlab_source.py +0 -0
  81. {prodc-0.7.2 → prodc-0.8.0}/tests/test_glossary.py +0 -0
  82. {prodc-0.7.2 → prodc-0.8.0}/tests/test_importance.py +0 -0
  83. {prodc-0.7.2 → prodc-0.8.0}/tests/test_inbox.py +0 -0
  84. {prodc-0.7.2 → prodc-0.8.0}/tests/test_inventory.py +0 -0
  85. {prodc-0.7.2 → prodc-0.8.0}/tests/test_issues.py +0 -0
  86. {prodc-0.7.2 → prodc-0.8.0}/tests/test_lint_decision.py +0 -0
  87. {prodc-0.7.2 → prodc-0.8.0}/tests/test_loader.py +0 -0
  88. {prodc-0.7.2 → prodc-0.8.0}/tests/test_models.py +0 -0
  89. {prodc-0.7.2 → prodc-0.8.0}/tests/test_needref.py +0 -0
  90. {prodc-0.7.2 → prodc-0.8.0}/tests/test_path.py +0 -0
  91. {prodc-0.7.2 → prodc-0.8.0}/tests/test_path_views.py +0 -0
  92. {prodc-0.7.2 → prodc-0.8.0}/tests/test_playwright.py +0 -0
  93. {prodc-0.7.2 → prodc-0.8.0}/tests/test_principle_withdrawal.py +0 -0
  94. {prodc-0.7.2 → prodc-0.8.0}/tests/test_principles.py +0 -0
  95. {prodc-0.7.2 → prodc-0.8.0}/tests/test_relink.py +0 -0
  96. {prodc-0.7.2 → prodc-0.8.0}/tests/test_report.py +0 -0
  97. {prodc-0.7.2 → prodc-0.8.0}/tests/test_report_export.py +0 -0
  98. {prodc-0.7.2 → prodc-0.8.0}/tests/test_report_html.py +0 -0
  99. {prodc-0.7.2 → prodc-0.8.0}/tests/test_results_reports.py +0 -0
  100. {prodc-0.7.2 → prodc-0.8.0}/tests/test_review.py +0 -0
  101. {prodc-0.7.2 → prodc-0.8.0}/tests/test_roles.py +0 -0
  102. {prodc-0.7.2 → prodc-0.8.0}/tests/test_source_role.py +0 -0
  103. {prodc-0.7.2 → prodc-0.8.0}/tests/test_sources.py +0 -0
  104. {prodc-0.7.2 → prodc-0.8.0}/tests/test_stage.py +0 -0
  105. {prodc-0.7.2 → prodc-0.8.0}/tests/test_staleness.py +0 -0
  106. {prodc-0.7.2 → prodc-0.8.0}/tests/test_status.py +0 -0
  107. {prodc-0.7.2 → prodc-0.8.0}/tests/test_storybook.py +0 -0
  108. {prodc-0.7.2 → prodc-0.8.0}/tests/test_suggest.py +0 -0
  109. {prodc-0.7.2 → prodc-0.8.0}/tests/test_suggest_eval.py +0 -0
  110. {prodc-0.7.2 → prodc-0.8.0}/tests/test_ux_catalog.py +0 -0
  111. {prodc-0.7.2 → prodc-0.8.0}/tests/test_variants.py +0 -0
  112. {prodc-0.7.2 → prodc-0.8.0}/tests/test_view_links.py +0 -0
  113. {prodc-0.7.2 → prodc-0.8.0}/tests/test_views.py +0 -0
  114. {prodc-0.7.2 → prodc-0.8.0}/tests/test_views_report.py +0 -0
  115. {prodc-0.7.2 → prodc-0.8.0}/tests/test_vitest.py +0 -0
  116. {prodc-0.7.2 → prodc-0.8.0}/tests/test_who_variants.py +0 -0
  117. {prodc-0.7.2 → prodc-0.8.0}/tests/test_withdrawn.py +0 -0
  118. {prodc-0.7.2 → prodc-0.8.0}/tests/test_writer.py +0 -0
  119. {prodc-0.7.2 → prodc-0.8.0}/tests/test_writer_comments.py +0 -0
@@ -4,10 +4,38 @@ All notable changes are documented here. Versioning is [SemVer](https://semver.o
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - `prodc mcp serve`: prodc as an MCP server, with the optional extra `pip install 'prodc[mcp]'`.
12
+ 10 read tools and 5 write tools. `capture` and `add_need` refuse `primary`. `accept`,
13
+ `outcome`, `attest`, `withdraw` and `sync` are not exposed. See [docs/mcp.md](docs/mcp.md).
14
+ - `prodc mcp install` writes the server into `.mcp.json` (or `--client cursor|vscode`). It changes
15
+ only the `prodc` entry and refuses a file it cannot parse. `prodc mcp uninstall` removes it.
16
+ - A write's `--json` output has `sha_before` and `sha_after`, to pass as `--expect-sha`.
17
+
18
+ ## [0.7.4] - 2026-11-11
19
+
20
+ ### Added
21
+
22
+ - A `visual` catalog pack: `[principles].packs = ["visual"]`. Four entries ship a vitest guard
23
+ (text contrast, UI contrast, status not shown by colour alone, no colour literal outside the
24
+ token files). `visual-palette-chosen` is signed off by a person with an `accept:` proof. See
25
+ [docs/visual.md](docs/visual.md).
26
+ - `prodc lint` reports an unknown name in `[principles].packs` (lint exits 1).
27
+
28
+ ### Changed
29
+
30
+ - `prodc add principle --from-catalog` stops with a clear message when the entry's scope is not in
31
+ `[principles].scopes`.
32
+
33
+ ## [0.7.2] - 2026-11-09
34
+
7
35
  ### Fixed
8
36
 
9
37
  - 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.
38
+ `at most 50 rows` matches ``it(`at most ${LIMIT} rows`)``. Before, the proof was BROKEN.
11
39
 
12
40
  ## [0.7.1] - 2026-11-09
13
41
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.7.2
3
+ Version: 0.8.0
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
@@ -24,6 +24,8 @@ Requires-Dist: pyyaml>=6
24
24
  Provides-Extra: docs
25
25
  Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
26
26
  Requires-Dist: mkdocs>=1.6; extra == 'docs'
27
+ Provides-Extra: mcp
28
+ Requires-Dist: mcp<3,>=2; extra == 'mcp'
27
29
  Description-Content-Type: text/markdown
28
30
 
29
31
  # prodc
@@ -196,6 +198,9 @@ The Python modules are not a public API. Use the CLI, `--json` and the YAML file
196
198
  - `prodc agent >> AGENTS.md` adds instructions for a coding agent to act as the project's product
197
199
  manager. An agent may record `assumption` or `secondary` sources; `primary` is only for a real
198
200
  user's own words. `prodc guide` is a short guide for people.
201
+ - `prodc mcp install` registers prodc as an MCP server, so an agent in Claude Code, Cursor, or VS
202
+ Code uses it through tools. Install with `pip install 'prodc[mcp]'`. See
203
+ [MCP server](https://cardona-oss.gitlab.io/prodc/mcp/).
199
204
 
200
205
  prodc also has goals, releases, roles, principles, and decisions
201
206
  ([model docs](https://cardona-oss.gitlab.io/prodc/model/)), and the commands `dupes`,
@@ -168,6 +168,9 @@ The Python modules are not a public API. Use the CLI, `--json` and the YAML file
168
168
  - `prodc agent >> AGENTS.md` adds instructions for a coding agent to act as the project's product
169
169
  manager. An agent may record `assumption` or `secondary` sources; `primary` is only for a real
170
170
  user's own words. `prodc guide` is a short guide for people.
171
+ - `prodc mcp install` registers prodc as an MCP server, so an agent in Claude Code, Cursor, or VS
172
+ Code uses it through tools. Install with `pip install 'prodc[mcp]'`. See
173
+ [MCP server](https://cardona-oss.gitlab.io/prodc/mcp/).
171
174
 
172
175
  prodc also has goals, releases, roles, principles, and decisions
173
176
  ([model docs](https://cardona-oss.gitlab.io/prodc/model/)), and the commands `dupes`,
@@ -31,6 +31,8 @@ dependencies = ["pyyaml>=6"]
31
31
  prodc = "prodc.cli:main"
32
32
 
33
33
  [project.optional-dependencies]
34
+ # mcp: the optional MCP server (`prodc mcp serve`). v2 moved the server import, so pin the major.
35
+ mcp = ["mcp>=2,<3"]
34
36
  # docs are an *extra* (not a uv dependency-group): CI's `pages` job and moon's `docs` task
35
37
  # install them with `--extra docs` to build the GitLab Pages site
36
38
  docs = [
@@ -68,7 +70,7 @@ include = ["src", "tests", "examples", "README.md", "CHANGELOG.md", "LICENSE"]
68
70
 
69
71
  [dependency-groups]
70
72
  # musil is a dev-only model checker (tests/test_models.py); prodc's runtime stays pyyaml-only.
71
- dev = ["ruff>=0.6", "pyright[nodejs]>=1.1.390", "pytest>=8", "types-pyyaml>=6", "musil>=0.35"]
73
+ dev = ["ruff>=0.6", "pyright[nodejs]>=1.1.390", "pytest>=8", "types-pyyaml>=6", "musil>=0.35", "mcp>=2,<3"]
72
74
 
73
75
  [tool.ruff]
74
76
  line-length = 100
@@ -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.8.0" # the single source of truth; pyproject reads it via [tool.hatch.version]
@@ -132,6 +132,13 @@ boards under), and `prodc diff <snapshot.json>` (what changed since a
132
132
  saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
133
133
  `--wide`, `--include-drafts`, and `--stale <days>`.
134
134
 
135
+ ## As MCP tools
136
+
137
+ If your client has the prodc MCP server (`prodc mcp install`), the same commands are tools:
138
+ `status`, `show`, `plan`, `lint`, `capture`, `set`, `add_need`, and others. The rules above
139
+ still hold. `capture` and `add_need` accept only `secondary` and `assumption`. Chain writes by
140
+ passing the last `sha_after` as `expect_sha`.
141
+
135
142
  ## Running non-interactively (CI, or no human in the loop)
136
143
 
137
144
  - **Exit codes are the contract, not the stdout.** `0` = clean; `1` = a gate failed (a BROKEN
@@ -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>``."""