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.
- {prodc-0.7.2 → prodc-0.8.0}/CHANGELOG.md +29 -1
- {prodc-0.7.2 → prodc-0.8.0}/PKG-INFO +6 -1
- {prodc-0.7.2 → prodc-0.8.0}/README.md +3 -0
- {prodc-0.7.2 → prodc-0.8.0}/pyproject.toml +3 -1
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/__init__.py +1 -1
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/agent.md +7 -0
- prodc-0.8.0/src/prodc/catalog/__init__.py +44 -0
- prodc-0.8.0/src/prodc/catalog/model.py +45 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/catalog/ux.py +10 -39
- prodc-0.8.0/src/prodc/catalog/visual.py +329 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/app.py +11 -2
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/commands.py +23 -9
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/common.py +8 -1
- prodc-0.8.0/src/prodc/cli/mcp.py +132 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/config.py +13 -4
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/loader.py +5 -3
- prodc-0.8.0/src/prodc/mcp_config.py +147 -0
- prodc-0.8.0/src/prodc/mcp_server.py +267 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/writer.py +10 -0
- prodc-0.8.0/tests/test_catalog_registry.py +62 -0
- prodc-0.8.0/tests/test_edit_sha.py +46 -0
- prodc-0.8.0/tests/test_mcp_install.py +115 -0
- prodc-0.8.0/tests/test_mcp_server.py +117 -0
- prodc-0.8.0/tests/test_visual_catalog.py +144 -0
- prodc-0.7.2/src/prodc/catalog/__init__.py +0 -10
- {prodc-0.7.2 → prodc-0.8.0}/.gitignore +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/LICENSE +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/adapters.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/anchor.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/board.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/callbacks.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/__main__.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/render.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/dedup.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/glossary.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/guide.md +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/inbox.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/issues.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/model.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/py.typed +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/report.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/report_export.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/report_html.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/status.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/storybook.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/src/prodc/views.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/fixtures/storybook/index.json +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_adapters.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_anchor.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_answer.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_attest_policy.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_awaits.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_callbacks.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_capture_block_scalar.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_catalog_add.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_catalog_lint.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_citation.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_claim_gating.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_cli.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_command_hook.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_confidence.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_config_keys.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_contradicts.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_decisions.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_declared_only.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_dedup.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_dedup_candidates.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_description.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_drafts.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_evalgate.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_expects.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_external_adapter.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_files_key.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_flowsteps.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_gitlab_source.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_glossary.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_importance.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_inbox.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_inventory.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_issues.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_lint_decision.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_loader.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_models.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_needref.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_path.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_path_views.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_playwright.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_principle_withdrawal.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_principles.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_relink.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_report.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_report_export.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_report_html.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_results_reports.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_review.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_roles.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_source_role.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_sources.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_stage.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_staleness.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_status.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_storybook.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_suggest.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_suggest_eval.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_ux_catalog.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_variants.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_view_links.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_views.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_views_report.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_vitest.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_who_variants.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_withdrawn.py +0 -0
- {prodc-0.7.2 → prodc-0.8.0}/tests/test_writer.py +0 -0
- {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
|
|
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.
|
|
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
|
|
@@ -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
|
|
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=
|
|
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=
|
|
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>``."""
|