prodc 0.6.3__tar.gz → 0.7.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 (108) hide show
  1. {prodc-0.6.3 → prodc-0.7.0}/CHANGELOG.md +19 -0
  2. {prodc-0.6.3 → prodc-0.7.0}/PKG-INFO +1 -1
  3. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/__init__.py +1 -1
  4. prodc-0.7.0/src/prodc/catalog/__init__.py +10 -0
  5. prodc-0.7.0/src/prodc/catalog/ux.py +437 -0
  6. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/app.py +25 -3
  7. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/commands.py +124 -1
  8. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/config.py +17 -1
  9. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/loader.py +8 -0
  10. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/model.py +1 -0
  11. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/writer.py +56 -0
  12. prodc-0.7.0/tests/test_catalog_add.py +180 -0
  13. prodc-0.7.0/tests/test_catalog_lint.py +210 -0
  14. prodc-0.7.0/tests/test_principle_withdrawal.py +169 -0
  15. prodc-0.7.0/tests/test_ux_catalog.py +44 -0
  16. {prodc-0.6.3 → prodc-0.7.0}/.gitignore +0 -0
  17. {prodc-0.6.3 → prodc-0.7.0}/LICENSE +0 -0
  18. {prodc-0.6.3 → prodc-0.7.0}/README.md +0 -0
  19. {prodc-0.6.3 → prodc-0.7.0}/examples/quickstart/features/orders.feature +0 -0
  20. {prodc-0.6.3 → prodc-0.7.0}/examples/quickstart/needs/jordan.yaml +0 -0
  21. {prodc-0.6.3 → prodc-0.7.0}/examples/quickstart/prodc.toml +0 -0
  22. {prodc-0.6.3 → prodc-0.7.0}/pyproject.toml +0 -0
  23. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/adapters.py +0 -0
  24. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/agent.md +0 -0
  25. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/anchor.py +0 -0
  26. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/board.py +0 -0
  27. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/callbacks.py +0 -0
  28. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/__init__.py +0 -0
  29. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/__main__.py +0 -0
  30. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/common.py +0 -0
  31. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/render.py +0 -0
  32. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/cli/serialize.py +0 -0
  33. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/dedup.py +0 -0
  34. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/glossary.py +0 -0
  35. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/guide.md +0 -0
  36. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/inbox.py +0 -0
  37. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/issues.py +0 -0
  38. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/py.typed +0 -0
  39. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/report.py +0 -0
  40. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/report_export.py +0 -0
  41. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/report_html.py +0 -0
  42. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/status.py +0 -0
  43. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/storybook.py +0 -0
  44. {prodc-0.6.3 → prodc-0.7.0}/src/prodc/views.py +0 -0
  45. {prodc-0.6.3 → prodc-0.7.0}/tests/fixtures/storybook/index.json +0 -0
  46. {prodc-0.6.3 → prodc-0.7.0}/tests/test_adapters.py +0 -0
  47. {prodc-0.6.3 → prodc-0.7.0}/tests/test_anchor.py +0 -0
  48. {prodc-0.6.3 → prodc-0.7.0}/tests/test_answer.py +0 -0
  49. {prodc-0.6.3 → prodc-0.7.0}/tests/test_attest_policy.py +0 -0
  50. {prodc-0.6.3 → prodc-0.7.0}/tests/test_awaits.py +0 -0
  51. {prodc-0.6.3 → prodc-0.7.0}/tests/test_callbacks.py +0 -0
  52. {prodc-0.6.3 → prodc-0.7.0}/tests/test_capture_block_scalar.py +0 -0
  53. {prodc-0.6.3 → prodc-0.7.0}/tests/test_citation.py +0 -0
  54. {prodc-0.6.3 → prodc-0.7.0}/tests/test_claim_gating.py +0 -0
  55. {prodc-0.6.3 → prodc-0.7.0}/tests/test_cli.py +0 -0
  56. {prodc-0.6.3 → prodc-0.7.0}/tests/test_command_hook.py +0 -0
  57. {prodc-0.6.3 → prodc-0.7.0}/tests/test_confidence.py +0 -0
  58. {prodc-0.6.3 → prodc-0.7.0}/tests/test_config_keys.py +0 -0
  59. {prodc-0.6.3 → prodc-0.7.0}/tests/test_contradicts.py +0 -0
  60. {prodc-0.6.3 → prodc-0.7.0}/tests/test_decisions.py +0 -0
  61. {prodc-0.6.3 → prodc-0.7.0}/tests/test_declared_only.py +0 -0
  62. {prodc-0.6.3 → prodc-0.7.0}/tests/test_dedup.py +0 -0
  63. {prodc-0.6.3 → prodc-0.7.0}/tests/test_dedup_candidates.py +0 -0
  64. {prodc-0.6.3 → prodc-0.7.0}/tests/test_description.py +0 -0
  65. {prodc-0.6.3 → prodc-0.7.0}/tests/test_drafts.py +0 -0
  66. {prodc-0.6.3 → prodc-0.7.0}/tests/test_evalgate.py +0 -0
  67. {prodc-0.6.3 → prodc-0.7.0}/tests/test_expects.py +0 -0
  68. {prodc-0.6.3 → prodc-0.7.0}/tests/test_external_adapter.py +0 -0
  69. {prodc-0.6.3 → prodc-0.7.0}/tests/test_files_key.py +0 -0
  70. {prodc-0.6.3 → prodc-0.7.0}/tests/test_flowsteps.py +0 -0
  71. {prodc-0.6.3 → prodc-0.7.0}/tests/test_gitlab_source.py +0 -0
  72. {prodc-0.6.3 → prodc-0.7.0}/tests/test_glossary.py +0 -0
  73. {prodc-0.6.3 → prodc-0.7.0}/tests/test_importance.py +0 -0
  74. {prodc-0.6.3 → prodc-0.7.0}/tests/test_inbox.py +0 -0
  75. {prodc-0.6.3 → prodc-0.7.0}/tests/test_inventory.py +0 -0
  76. {prodc-0.6.3 → prodc-0.7.0}/tests/test_issues.py +0 -0
  77. {prodc-0.6.3 → prodc-0.7.0}/tests/test_lint_decision.py +0 -0
  78. {prodc-0.6.3 → prodc-0.7.0}/tests/test_loader.py +0 -0
  79. {prodc-0.6.3 → prodc-0.7.0}/tests/test_models.py +0 -0
  80. {prodc-0.6.3 → prodc-0.7.0}/tests/test_needref.py +0 -0
  81. {prodc-0.6.3 → prodc-0.7.0}/tests/test_path.py +0 -0
  82. {prodc-0.6.3 → prodc-0.7.0}/tests/test_path_views.py +0 -0
  83. {prodc-0.6.3 → prodc-0.7.0}/tests/test_playwright.py +0 -0
  84. {prodc-0.6.3 → prodc-0.7.0}/tests/test_principles.py +0 -0
  85. {prodc-0.6.3 → prodc-0.7.0}/tests/test_relink.py +0 -0
  86. {prodc-0.6.3 → prodc-0.7.0}/tests/test_report.py +0 -0
  87. {prodc-0.6.3 → prodc-0.7.0}/tests/test_report_export.py +0 -0
  88. {prodc-0.6.3 → prodc-0.7.0}/tests/test_report_html.py +0 -0
  89. {prodc-0.6.3 → prodc-0.7.0}/tests/test_results_reports.py +0 -0
  90. {prodc-0.6.3 → prodc-0.7.0}/tests/test_review.py +0 -0
  91. {prodc-0.6.3 → prodc-0.7.0}/tests/test_roles.py +0 -0
  92. {prodc-0.6.3 → prodc-0.7.0}/tests/test_source_role.py +0 -0
  93. {prodc-0.6.3 → prodc-0.7.0}/tests/test_sources.py +0 -0
  94. {prodc-0.6.3 → prodc-0.7.0}/tests/test_stage.py +0 -0
  95. {prodc-0.6.3 → prodc-0.7.0}/tests/test_staleness.py +0 -0
  96. {prodc-0.6.3 → prodc-0.7.0}/tests/test_status.py +0 -0
  97. {prodc-0.6.3 → prodc-0.7.0}/tests/test_storybook.py +0 -0
  98. {prodc-0.6.3 → prodc-0.7.0}/tests/test_suggest.py +0 -0
  99. {prodc-0.6.3 → prodc-0.7.0}/tests/test_suggest_eval.py +0 -0
  100. {prodc-0.6.3 → prodc-0.7.0}/tests/test_variants.py +0 -0
  101. {prodc-0.6.3 → prodc-0.7.0}/tests/test_view_links.py +0 -0
  102. {prodc-0.6.3 → prodc-0.7.0}/tests/test_views.py +0 -0
  103. {prodc-0.6.3 → prodc-0.7.0}/tests/test_views_report.py +0 -0
  104. {prodc-0.6.3 → prodc-0.7.0}/tests/test_vitest.py +0 -0
  105. {prodc-0.6.3 → prodc-0.7.0}/tests/test_who_variants.py +0 -0
  106. {prodc-0.6.3 → prodc-0.7.0}/tests/test_withdrawn.py +0 -0
  107. {prodc-0.6.3 → prodc-0.7.0}/tests/test_writer.py +0 -0
  108. {prodc-0.6.3 → prodc-0.7.0}/tests/test_writer_comments.py +0 -0
@@ -4,6 +4,8 @@ All notable changes are documented here. Versioning is [SemVer](https://semver.o
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.0] - 2026-11-09
8
+
7
9
  ### Added
8
10
 
9
11
  - A `storybook` proof kind: a need can point at the Storybook stories that show it. prodc
@@ -14,6 +16,23 @@ All notable changes are documented here. Versioning is [SemVer](https://semver.o
14
16
  answer it: must-have opinions and warrant reviews with concerns. It never writes (#113).
15
17
  - `[prodc].name` sets the board name that `status`, `portfolio` and `report` show. The default is
16
18
  still the directory name (#125).
19
+ - `docs/principles.md` documents how to adopt a UX law as a design principle: concrete rule as
20
+ `text`, the law page as a `secondary` source, and a guard test as the proof.
21
+ - `prodc.catalog.ux` ships 30 UX laws from lawsofux.com as catalog entries (`UX_CATALOG`).
22
+ Five are checkable (ux-fitts, ux-hick, ux-von-restorff, ux-miller, ux-jakob) and carry a
23
+ `Recipe` with a snippet for the guard test. `docs/ux-laws.md` lists all 30.
24
+ The ux-fitts, ux-hick, and ux-von-restorff recipes now carry real guard code extracted from
25
+ crm (source-file scanning approach); ux-miller and ux-jakob keep placeholder snippets.
26
+ - `Principle` now supports `withdrawn: {reason, date[, kind]}`, reusing the existing `Withdrawal`
27
+ type. A withdrawn principle is not evaluated by lint. `prodc withdraw principle <id>` and
28
+ `prodc reinstate principle <id>` write the field.
29
+ - `prodc add principle --from-catalog <id>` seeds a principle from the UX catalog: sets the id,
30
+ scope=design, text from the entry's template, and a secondary source; prints the recipe snippet.
31
+ `--decline <reason>` additionally marks it withdrawn out_of_scope (a deliberate skip, on record).
32
+ - `[principles].packs = ["ux"]` lists catalog packs the board opts into.
33
+ - `[lint].missing_catalog_principles = "warn"|"error"|"off"` (default `warn`) reports checkable
34
+ entries in the declared packs that have not been added or declined.
35
+ - `lint` warns when a principle's text still contains a `{slot}` from the template.
17
36
 
18
37
  ### Fixed
19
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.6.3
3
+ Version: 0.7.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
@@ -1,3 +1,3 @@
1
1
  """prodc: product management as code."""
2
2
 
3
- __version__ = "0.6.3" # the single source of truth; pyproject reads it via [tool.hatch.version]
3
+ __version__ = "0.7.0" # the single source of truth; pyproject reads it via [tool.hatch.version]
@@ -0,0 +1,10 @@
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. Import the ux pack with
5
+ :data:`prodc.catalog.ux.UX_CATALOG`.
6
+ """
7
+
8
+ from .ux import UX_CATALOG, CatalogEntry, Recipe
9
+
10
+ __all__ = ["UX_CATALOG", "CatalogEntry", "Recipe"]
@@ -0,0 +1,437 @@
1
+ """UX-law catalog entries: the ux pack.
2
+
3
+ Each :class:`CatalogEntry` maps a law from https://lawsofux.com/ to a principle template
4
+ the team can adopt. Five laws have concrete, testable thresholds and carry a :class:`Recipe`
5
+ (ux-fitts, ux-hick, ux-von-restorff, ux-miller, ux-jakob). The rest are ``checkable=False``
6
+ with no recipe: they require judgment, not a test.
7
+
8
+ Adopt an entry with ``prodc add principle --from-catalog <id>``. The command writes the
9
+ rendered text, a secondary source pointing at the law page, and prints the recipe snippet.
10
+ """
11
+
12
+ from __future__ import annotations
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
+
51
+
52
+ # -- checkable entries -------------------------------------------------------
53
+
54
+ _FITTS = CatalogEntry(
55
+ id="ux-fitts",
56
+ pack="ux",
57
+ title="Fitts's Law",
58
+ url="https://lawsofux.com/fittss-law/",
59
+ text_template=(
60
+ "Every interactive element has a touch/click target of at least 24 x 24 CSS px "
61
+ "(WCAG 2.5.8 desktop) and 44 x 44 CSS px on mobile."
62
+ ),
63
+ checkable=True,
64
+ recipe=Recipe(
65
+ kind="vitest",
66
+ summary="Test the pure button-style function: every size variant sets minHeight >= 24px.",
67
+ # Real guard from crm: apps/website/src/design/fittssLaw.guard.test.ts
68
+ # jsdom has no layout engine; test the pure style function instead of a rendered bounding box.
69
+ snippet="""\
70
+ // src/design/fittssLaw.guard.test.ts
71
+ import { describe, expect, it } from "vitest";
72
+ import { buttonVisualStyle, type ButtonSize } from "./primitives/Button";
73
+
74
+ const SIZES: ButtonSize[] = ["sm", "md", "lg"];
75
+
76
+ describe("interactive targets meet the 24x24px minimum (WCAG 2.5.8)", () => {
77
+ it("every size variant sets minHeight >= 24px", () => {
78
+ for (const size of SIZES) {
79
+ const style = buttonVisualStyle({ size });
80
+ expect(
81
+ style.minHeight,
82
+ `size "${size}" minHeight ${style.minHeight} is below the 24px WCAG minimum`,
83
+ ).toBeGreaterThanOrEqual(24);
84
+ }
85
+ });
86
+
87
+ it("icon-only buttons set minWidth >= 24px (they form a square target)", () => {
88
+ for (const size of SIZES) {
89
+ const style = buttonVisualStyle({ size, iconOnly: true });
90
+ expect(style.minWidth).toBeGreaterThanOrEqual(24);
91
+ }
92
+ });
93
+
94
+ it("checks a real primitive, not an empty fixture", () => {
95
+ const style = buttonVisualStyle({ variant: "primary", size: "md" });
96
+ expect(style.minHeight).toBeDefined();
97
+ expect(SIZES.length).toBeGreaterThan(0);
98
+ });
99
+ });
100
+ """,
101
+ proof_locator_hint="vitest:src/design/fittssLaw.guard.test.ts::interactive targets meet the 24x24px minimum (WCAG 2.5.8) > every size variant sets minHeight >= 24px",
102
+ ),
103
+ )
104
+
105
+ _HICK = CatalogEntry(
106
+ id="ux-hick",
107
+ pack="ux",
108
+ title="Hick's Law",
109
+ url="https://lawsofux.com/hicks-law/",
110
+ text_template="No top-level navigation menu shows more than {N} items at once.",
111
+ checkable=True,
112
+ recipe=Recipe(
113
+ kind="vitest",
114
+ summary="Count top-level nav items per role; each must stay at or below the threshold set when the principle was written.",
115
+ # Real guard from crm: apps/website/src/design/hicksLaw.guard.test.ts
116
+ # Thresholds are the counts at adoption time; the guard prevents growth beyond them.
117
+ snippet="""\
118
+ // src/design/hicksLaw.guard.test.ts
119
+ import { describe, expect, it } from "vitest";
120
+ import { sectionsForRole } from "../lib/navLens";
121
+
122
+ // Replace MAX_NAV_ITEMS with the current count per role in your app.
123
+ const MAX_NAV_ITEMS = {N};
124
+
125
+ describe("the main nav has at most {N} top-level sections per role", () => {
126
+ it("primary role sees at most {N} sections", () => {
127
+ const sections = sectionsForRole("primary");
128
+ expect(
129
+ sections.length,
130
+ `primary role now has ${sections.length} nav sections (threshold {N}) — ` +
131
+ "update this guard and the principle YAML if the addition is intentional",
132
+ ).toBeLessThanOrEqual(MAX_NAV_ITEMS);
133
+ });
134
+
135
+ it("watches a real nav lens: sections are non-empty and named", () => {
136
+ const sections = sectionsForRole("primary");
137
+ expect(sections.length).toBeGreaterThan(0);
138
+ for (const s of sections) {
139
+ expect(s).toMatch(/^\\//);
140
+ }
141
+ });
142
+ });
143
+ """,
144
+ proof_locator_hint="vitest:src/design/hicksLaw.guard.test.ts::the main nav has at most {N} top-level sections per role > primary role sees at most {N} sections",
145
+ ),
146
+ )
147
+
148
+ _VON_RESTORFF = CatalogEntry(
149
+ id="ux-von-restorff",
150
+ pack="ux",
151
+ title="Von Restorff Effect",
152
+ url="https://lawsofux.com/von-restorff-effect/",
153
+ text_template=(
154
+ "Each form or dialog has at most one primary-action button. "
155
+ "Secondary actions use a lower-emphasis style."
156
+ ),
157
+ checkable=True,
158
+ recipe=Recipe(
159
+ kind="vitest",
160
+ summary="Grep form/dialog source files for variant=\"primary\"; each file must have at most one.",
161
+ # Real guard from crm: apps/website/src/design/vonRestorff.guard.test.ts
162
+ # jsdom has no layout; scan source files statically for variant="primary" in JSX.
163
+ snippet="""\
164
+ // src/design/vonRestorff.guard.test.ts
165
+ import { readdirSync, readFileSync, statSync } from "node:fs";
166
+ import { join, relative } from "node:path";
167
+ import { describe, expect, it } from "vitest";
168
+
169
+ const SRC = join(import.meta.dirname, "..");
170
+ const FORM_PATTERN = /(?:Form|Dialog|Modal)\\.tsx$/;
171
+ const PRIMARY_BUTTON = /variant="primary"/g;
172
+
173
+ function formFiles(dir: string): string[] {
174
+ const out: string[] = [];
175
+ for (const entry of readdirSync(dir)) {
176
+ const full = join(dir, entry);
177
+ if (statSync(full).isDirectory()) out.push(...formFiles(full));
178
+ else if (FORM_PATTERN.test(entry)) out.push(full);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ describe("at most one primary button per form or dialog", () => {
184
+ it("no form or dialog file contains more than one variant=\\"primary\\" button", () => {
185
+ const offenders: string[] = [];
186
+ for (const file of formFiles(SRC)) {
187
+ const src = readFileSync(file, "utf8");
188
+ const count = (src.match(PRIMARY_BUTTON) ?? []).length;
189
+ if (count > 1) offenders.push(`${relative(SRC, file)} (${count} primary buttons)`);
190
+ }
191
+ expect(offenders).toEqual([]);
192
+ });
193
+
194
+ it("watches a real tree: at least 10 form/dialog files must exist", () => {
195
+ expect(formFiles(SRC).length).toBeGreaterThanOrEqual(10);
196
+ });
197
+ });
198
+ """,
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",
200
+ ),
201
+ )
202
+
203
+ _MILLER = CatalogEntry(
204
+ id="ux-miller",
205
+ pack="ux",
206
+ title="Miller's Law",
207
+ url="https://lawsofux.com/millers-law/",
208
+ text_template=(
209
+ "Lists that can exceed {N} items are paginated or grouped into sections. "
210
+ "No flat list renders more than {N} rows without a pager or section heading."
211
+ ),
212
+ checkable=True,
213
+ recipe=Recipe(
214
+ kind="vitest",
215
+ summary="Assert that any list beyond the threshold has pagination or section headers.",
216
+ # TODO: replace with the guard from crm/tyko once it is extracted.
217
+ snippet="""\
218
+ // tests/design/list-paging.test.ts
219
+ import { render } from '@testing-library/react'
220
+ import { ItemList } from '../src/ItemList'
221
+
222
+ const THRESHOLD = {N}
223
+ const items = Array.from({ length: THRESHOLD + 1 }, (_, i) => ({ id: i, label: `Item ${i}` }))
224
+
225
+ test('long list shows a pager or section headers', () => {
226
+ const { container } = render(<ItemList items={items} />)
227
+ const hasPager = container.querySelector('[aria-label="pagination"]') !== null
228
+ const hasGroups = container.querySelectorAll('[role="group"]').length > 1
229
+ expect(hasPager || hasGroups).toBe(true)
230
+ })
231
+ """,
232
+ proof_locator_hint="vitest:tests/design/list-paging.test.ts::long list shows a pager or section headers",
233
+ ),
234
+ )
235
+
236
+ _JAKOB = CatalogEntry(
237
+ id="ux-jakob",
238
+ pack="ux",
239
+ title="Jakob's Law",
240
+ url="https://lawsofux.com/jakobs-law/",
241
+ text_template=(
242
+ "Dialogs close on Escape. The Enter key submits the focused form. "
243
+ "The site logo links to the home page."
244
+ ),
245
+ checkable=True,
246
+ recipe=Recipe(
247
+ kind="playwright",
248
+ summary="Verify Escape closes dialogs, Enter submits forms, and the logo links home.",
249
+ # TODO: replace with the guard from crm/tyko once it is extracted.
250
+ snippet="""\
251
+ // tests/design/conventions.spec.ts
252
+ import { test, expect } from '@playwright/test'
253
+
254
+ test('Escape closes the dialog', async ({ page }) => {
255
+ await page.goto('/')
256
+ await page.click('[data-testid="open-dialog"]')
257
+ await expect(page.locator('[role="dialog"]')).toBeVisible()
258
+ await page.keyboard.press('Escape')
259
+ await expect(page.locator('[role="dialog"]')).not.toBeVisible()
260
+ })
261
+
262
+ test('logo links to home', async ({ page }) => {
263
+ await page.goto('/about')
264
+ await page.click('[data-testid="site-logo"]')
265
+ await expect(page).toHaveURL('/')
266
+ })
267
+ """,
268
+ proof_locator_hint="playwright:tests/design/conventions.spec.ts::Escape closes the dialog",
269
+ ),
270
+ )
271
+
272
+ # -- non-checkable entries ---------------------------------------------------
273
+
274
+ def _nc(id: str, title: str, url: str, text_template: str) -> CatalogEntry:
275
+ """Build a non-checkable catalog entry (no recipe)."""
276
+ return CatalogEntry(
277
+ id=id,
278
+ pack="ux",
279
+ title=title,
280
+ url=url,
281
+ text_template=text_template,
282
+ checkable=False,
283
+ recipe=None,
284
+ )
285
+
286
+
287
+ UX_CATALOG: tuple[CatalogEntry, ...] = (
288
+ _FITTS,
289
+ _HICK,
290
+ _VON_RESTORFF,
291
+ _MILLER,
292
+ _JAKOB,
293
+ _nc(
294
+ "ux-aesthetic-usability",
295
+ "Aesthetic-Usability Effect",
296
+ "https://lawsofux.com/aesthetic-usability-effect/",
297
+ "Visual polish is applied before usability research, not after.",
298
+ ),
299
+ _nc(
300
+ "ux-choice-overload",
301
+ "Choice Overload",
302
+ "https://lawsofux.com/choice-overload/",
303
+ "No decision screen offers more than {N} options without a guided recommendation.",
304
+ ),
305
+ _nc(
306
+ "ux-chunking",
307
+ "Chunking",
308
+ "https://lawsofux.com/chunking/",
309
+ "Related items are grouped visually so users can process them as single units.",
310
+ ),
311
+ _nc(
312
+ "ux-cognitive-load",
313
+ "Cognitive Load",
314
+ "https://lawsofux.com/cognitive-load/",
315
+ "Each screen has one primary goal. Supporting information is secondary or hidden until needed.",
316
+ ),
317
+ _nc(
318
+ "ux-doherty",
319
+ "Doherty Threshold",
320
+ "https://lawsofux.com/doherty-threshold/",
321
+ "Every interaction responds or shows a loading indicator within 400 ms.",
322
+ ),
323
+ _nc(
324
+ "ux-flow",
325
+ "Flow",
326
+ "https://lawsofux.com/flow/",
327
+ "Task difficulty is matched to user skill: not so easy it is boring, not so hard it blocks.",
328
+ ),
329
+ _nc(
330
+ "ux-goal-gradient",
331
+ "Goal-Gradient Effect",
332
+ "https://lawsofux.com/goal-gradient-effect/",
333
+ "Progress indicators show how much of the task is complete, accelerating effort near the end.",
334
+ ),
335
+ _nc(
336
+ "ux-law-of-common-region",
337
+ "Law of Common Region",
338
+ "https://lawsofux.com/law-of-common-region/",
339
+ "Elements inside a visible boundary are perceived as a group.",
340
+ ),
341
+ _nc(
342
+ "ux-law-of-pragnanz",
343
+ "Law of Pragnanz",
344
+ "https://lawsofux.com/law-of-pr%C3%A4gnanz/",
345
+ "Visual elements are as simple as their meaning allows.",
346
+ ),
347
+ _nc(
348
+ "ux-law-of-proximity",
349
+ "Law of Proximity",
350
+ "https://lawsofux.com/law-of-proximity/",
351
+ "Related controls are placed close together; unrelated controls are separated by space.",
352
+ ),
353
+ _nc(
354
+ "ux-law-of-similarity",
355
+ "Law of Similarity",
356
+ "https://lawsofux.com/law-of-similarity/",
357
+ "Controls with the same visual treatment perform the same kind of action.",
358
+ ),
359
+ _nc(
360
+ "ux-law-of-uniform-connectedness",
361
+ "Law of Uniform Connectedness",
362
+ "https://lawsofux.com/law-of-uniform-connectedness/",
363
+ "Items that are visually connected (by a line, enclosure, or shared background) are related.",
364
+ ),
365
+ _nc(
366
+ "ux-mental-model",
367
+ "Mental Model",
368
+ "https://lawsofux.com/mental-model/",
369
+ "The interface matches users' prior expectations from similar products.",
370
+ ),
371
+ _nc(
372
+ "ux-occams-razor",
373
+ "Occam's Razor",
374
+ "https://lawsofux.com/occams-razor/",
375
+ "When two designs solve the problem equally well, the simpler one ships.",
376
+ ),
377
+ _nc(
378
+ "ux-paradox-of-active-user",
379
+ "Paradox of the Active User",
380
+ "https://lawsofux.com/paradox-of-the-active-user/",
381
+ "Onboarding is short and skippable; core tasks work without reading documentation.",
382
+ ),
383
+ _nc(
384
+ "ux-pareto",
385
+ "Pareto Principle",
386
+ "https://lawsofux.com/pareto-principle/",
387
+ "The {N} most common tasks are optimised first; the remaining tasks are reachable but not featured.",
388
+ ),
389
+ _nc(
390
+ "ux-parkinsons",
391
+ "Parkinson's Law",
392
+ "https://lawsofux.com/parkinsons-law/",
393
+ "Forms have no optional fields unless a user explicitly requests them.",
394
+ ),
395
+ _nc(
396
+ "ux-peak-end",
397
+ "Peak-End Rule",
398
+ "https://lawsofux.com/peak-end-rule/",
399
+ "The most important moment and the last moment of a task are designed deliberately.",
400
+ ),
401
+ _nc(
402
+ "ux-postels",
403
+ "Postel's Law",
404
+ "https://lawsofux.com/postels-law/",
405
+ "Input fields accept leading/trailing whitespace and common alternate formats.",
406
+ ),
407
+ _nc(
408
+ "ux-selective-attention",
409
+ "Selective Attention",
410
+ "https://lawsofux.com/selective-attention/",
411
+ "Critical status changes use motion or color contrast, not only text.",
412
+ ),
413
+ _nc(
414
+ "ux-serial-position",
415
+ "Serial Position Effect",
416
+ "https://lawsofux.com/serial-position-effect/",
417
+ "The most important actions are placed at the start or end of a list or toolbar.",
418
+ ),
419
+ _nc(
420
+ "ux-teslers",
421
+ "Tesler's Law",
422
+ "https://lawsofux.com/teslers-law/",
423
+ "Complexity that cannot be removed from the domain is absorbed by the system, not the user.",
424
+ ),
425
+ _nc(
426
+ "ux-working-memory",
427
+ "Working Memory",
428
+ "https://lawsofux.com/working-memory/",
429
+ "Users are not required to remember information from one screen to use it on another.",
430
+ ),
431
+ _nc(
432
+ "ux-zeigarnik",
433
+ "Zeigarnik Effect",
434
+ "https://lawsofux.com/zeigarnik-effect/",
435
+ "Incomplete tasks are surfaced on return so users can resume where they left off.",
436
+ ),
437
+ )
@@ -203,7 +203,10 @@ def main(argv: list[str] | None = None) -> int:
203
203
  ad.add_argument("noun", choices=["need", "role", "principle", "decision"], help="what to add")
204
204
  ad.add_argument(
205
205
  "need_id",
206
- help="the new item's id: need (N-9), role, principle (P-1), or decision (D-1)",
206
+ nargs="?",
207
+ default=None,
208
+ help="the new item's id: need (N-9), role, principle (P-1), or decision (D-1); "
209
+ "omit only with --from-catalog (the catalog id is used)",
207
210
  )
208
211
  ad.add_argument(
209
212
  "--text",
@@ -218,6 +221,21 @@ def main(argv: list[str] | None = None) -> int:
218
221
  help="principle only: promote a need into a principle: copy its text (unless --text "
219
222
  "overrides) and its sources as the provenance that surfaced it; the need stays on the board",
220
223
  )
224
+ ad.add_argument(
225
+ "--from-catalog",
226
+ default=None,
227
+ metavar="CATALOG_ID",
228
+ dest="from_catalog",
229
+ help="principle only: seed from a catalog entry (e.g. ux-fitts): writes id=catalog-id, "
230
+ "scope=design, text from the entry's template, and a secondary source; prints the recipe",
231
+ )
232
+ ad.add_argument(
233
+ "--decline",
234
+ default=None,
235
+ metavar="REASON",
236
+ help="principle only (with --from-catalog): write the principle then immediately mark it "
237
+ "withdrawn out_of_scope with REASON (records a deliberate decision to skip this law)",
238
+ )
221
239
  ad.add_argument(
222
240
  "--scope",
223
241
  default="product",
@@ -436,7 +454,10 @@ def main(argv: list[str] | None = None) -> int:
436
454
  parents=[config, json_out],
437
455
  help="record a decision against a need: withdrawn / won't-do (#74)",
438
456
  )
439
- wd.add_argument("target", help="the need id to withdraw, or 'decision' to withdraw a decision")
457
+ wd.add_argument(
458
+ "target",
459
+ help="the need id to withdraw, 'decision' to withdraw a decision, or 'principle' to withdraw a principle",
460
+ )
440
461
  wd.add_argument(
441
462
  "decision_id", nargs="?", default=None, help="the decision id (when target is 'decision')"
442
463
  )
@@ -481,7 +502,8 @@ def main(argv: list[str] | None = None) -> int:
481
502
  help="remove a need's withdrawal, putting it back on the board (#74)",
482
503
  )
483
504
  ri.add_argument(
484
- "target", help="the need id to reinstate, or 'decision' to reinstate a decision"
505
+ "target",
506
+ help="the need id to reinstate, 'decision' to reinstate a decision, or 'principle' to reinstate a principle",
485
507
  )
486
508
  ri.add_argument(
487
509
  "decision_id", nargs="?", default=None, help="the decision id (when target is 'decision')"