@pavp/wavefront 1.1.1 → 1.2.0
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.
- package/README.md +66 -171
- package/agents/module-builder.md +3 -3
- package/agents/tester.md +1 -1
- package/install.sh +1 -1
- package/package.json +4 -2
- package/reference/README.md +14 -0
- package/reference/module/note-module.md +608 -0
- package/skills/testing-jest-rtl/SKILL.md +2 -2
- package/uninstall.sh +1 -1
package/README.md
CHANGED
|
@@ -3,178 +3,87 @@
|
|
|
3
3
|
Claude Code agent framework for apps generated by **scaffold-nextjs-app**
|
|
4
4
|
(Next.js 15 · React 19 · MUI 7 · React Query · Zustand · RHF + Zod · next-intl · Jest/RTL · Clean Architecture).
|
|
5
5
|
|
|
6
|
-
5 specialist agents + 20 self-contained skills + a 9-command orchestration loop
|
|
6
|
+
**5 specialist agents + 20 self-contained skills + a 9-command orchestration loop**, so you describe work items instead of writing boilerplate. Generates responsive, composed UI continuous with your app — from a Figma export, screenshot, description, or nothing — with a colocated test in every layer.
|
|
7
7
|
|
|
8
8
|
> Distilled from real scaffold code, pinned at commit `8edaa0b` (modules `todo`/`auth`, `about-view`, `test/`, `src/i18n`). Not from docs — from the source. E2E-validated against a generated app.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## Install
|
|
10
|
+
## Documentation
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Or install the command globally: `npm i -g @pavp/wavefront` then `wavefront install --local <app>`. The CLI wraps the same scripts below (needs `bash`; macOS/Linux or WSL).
|
|
12
|
+
| If you want to… | Read |
|
|
13
|
+
|---|---|
|
|
14
|
+
| **Get started** — install + first module + first feature, copy-paste | **[docs/USAGE.md](docs/USAGE.md)** |
|
|
15
|
+
| **Understand the model** — agents vs skills vs commands, the loop, why self-contained | **[docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md)** |
|
|
16
|
+
| **Fix a problem** — symptom → cause → fix (users + contributors) | **[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** |
|
|
17
|
+
| **Author the framework** — file-format spec for agents/skills/commands | [docs/conventions.md](docs/conventions.md) |
|
|
18
|
+
| **See the history** — phase plan + roadmap | [docs/plan.md](docs/plan.md) · [docs/roadmap.md](docs/roadmap.md) |
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
git clone https://github.com/pavp/wavefront.git && cd wavefront
|
|
26
|
-
```
|
|
20
|
+
New here? Start with **[USAGE](docs/USAGE.md)**. The rest of this page is a reference.
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
---
|
|
29
23
|
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
./install.sh --global /path/to/your-next-app
|
|
33
|
-
```
|
|
34
|
-
Installs the surface to `~/.wavefront/` and symlinks `agents/`, `skills/`, `commands/` into the app's `.claude/`. One source, central updates (re-pull this repo → all projects updated). Uninstall: `./uninstall.sh /path/to/your-next-app`.
|
|
24
|
+
## Install
|
|
35
25
|
|
|
36
|
-
### Copy project-local
|
|
37
26
|
```bash
|
|
38
|
-
|
|
27
|
+
# via npm (no clone)
|
|
28
|
+
npx @pavp/wavefront install --global /path/to/your-next-app --with-hooks # ~/.wavefront + symlink
|
|
29
|
+
npx @pavp/wavefront install --local /path/to/your-next-app # copy into the app
|
|
30
|
+
npx @pavp/wavefront uninstall /path/to/your-next-app
|
|
39
31
|
```
|
|
40
|
-
Copies the surface into the app's `.claude/` (self-contained, no global state, manual updates).
|
|
41
32
|
|
|
42
|
-
|
|
33
|
+
- **Global + symlink** (recommended for multiple apps): one source in `~/.wavefront/`, re-pull/upgrade once → all projects updated.
|
|
34
|
+
- **Local**: a self-contained copy in the app's `.claude/`, no global state.
|
|
35
|
+
- Needs `bash` (macOS/Linux or WSL). Claude Code auto-discovers `agents/`+`skills/`+`commands/` under `.claude/` — no build step.
|
|
36
|
+
- `--with-hooks` also installs quality-gate hooks (eslint/stylelint on edit, tsc at end of turn; warn-only). It writes `.claude/settings.wavefront.json` for you to **merge** into your `settings.json` (never clobbers it); needs `jq`. Details + walkthrough in [USAGE](docs/USAGE.md#1-install-into-your-app).
|
|
43
37
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
### Optional: quality-gate hooks
|
|
47
|
-
|
|
48
|
-
Add `--with-hooks` to either install mode to also install machine gates:
|
|
49
|
-
```bash
|
|
50
|
-
./install.sh --global /path/to/your-next-app --with-hooks
|
|
51
|
-
```
|
|
52
|
-
- **PostToolUse (Edit/Write `*.{ts,tsx,js,jsx,css,scss}`)** → `eslint`/`stylelint --fix` on the touched file. Fast, per-file, **warn-only** (never blocks).
|
|
53
|
-
- **Stop (end of turn)** → `tsc --noEmit` project-wide, **warn-only**. (tsc needs the `@/` path graph, so it runs once at the end, not per-edit.)
|
|
54
|
-
- Installer copies `hooks/` into `.claude/hooks/` and writes `.claude/settings.wavefront.json` — **merge its `hooks` block into your `.claude/settings.json`** (the installer never overwrites your settings). Requires `jq` on PATH.
|
|
55
|
-
- Hooks are the cheap machine gate; the `reviewer` agent stays the semantic PASS/FAIL gate.
|
|
38
|
+
You can also `git clone` this repo and run `./install.sh --global|--local <app>` directly — same surface.
|
|
56
39
|
|
|
57
40
|
---
|
|
58
41
|
|
|
59
42
|
## Agents
|
|
60
43
|
|
|
61
|
-
| Agent | Use it to |
|
|
62
|
-
|
|
63
|
-
| `module-builder` | Generate a
|
|
64
|
-
| `tester` | Write/fix colocated Jest + RTL tests
|
|
65
|
-
| `i18n-tokens` | De-hardcode UI: strings → next-intl
|
|
66
|
-
| `reviewer` |
|
|
67
|
-
|
|
68
|
-
Invoke a subagent by name in Claude Code (e.g. "use module-builder to add a `notifications` module").
|
|
69
|
-
|
|
70
|
-
### Recommended flow (build → test → localize → review)
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
module-builder → generate the module
|
|
74
|
-
tester → add colocated tests, run yarn test
|
|
75
|
-
i18n-tokens → replace literals with keys/tokens
|
|
76
|
-
reviewer → final PASS/FAIL gate (naming, layers, lint, tsc)
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
`reviewer` is read-only and safe to run anytime. `module-builder` ends by running `tsc`/`lint` and handing off to tester + reviewer.
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
## Orchestration loop (F2a — new feature)
|
|
44
|
+
| Agent | Use it to | Edits? |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `module-builder` | Generate/modify a feature module across the Clean Arch chain (types → api → gateways → repository → store → selectors → hooks → views/components → barrels) | yes |
|
|
47
|
+
| `tester` | Write/fix colocated Jest + RTL tests (`@test/utils` + faker factories) | yes |
|
|
48
|
+
| `i18n-tokens` | De-hardcode UI: strings → next-intl, values → tokens, raw MUI → `@/ui`, `next/link` → `@/i18n/routing` | yes |
|
|
49
|
+
| `reviewer` | PASS/FAIL gate (naming, layers, dependency inversion, hook split, lint/tsc) — or a skill-drift `sync-audit` | read-only |
|
|
50
|
+
| `mapper` | Map an existing module before a change (ADD/MODIFY/KEEP plan + regression surface) | read-only |
|
|
84
51
|
|
|
85
|
-
|
|
52
|
+
Invoke by name ("use `module-builder` to add a `notifications` module"), or let the loop drive them. Recommended by-hand order: **`module-builder` → `tester` → `i18n-tokens` → `reviewer`**. Full walkthrough: [USAGE §2](docs/USAGE.md#2-your-first-module--by-hand-the-agent-flow).
|
|
86
53
|
|
|
87
|
-
|
|
88
|
-
/wavefront-init once per project — scaffold .planning/ + describe the project
|
|
89
|
-
/wavefront-feature "<prompt|story>" interactive intake → REQUIREMENTS (asks + suggests gaps)
|
|
90
|
-
/wavefront-plan "<title>" REQUIREMENTS → executable PHASE_PLAN (agents × layers)
|
|
91
|
-
/wavefront-execute "<title>" builder → tester → i18n-tokens, sequential, STATE after each
|
|
92
|
-
/wavefront-verify "<title>" reviewer + tests + acceptance-criteria check → PASS/FAIL
|
|
93
|
-
/wavefront-ship "<title>" branch + commit + PR (confirms first; verified items only)
|
|
94
|
-
/wavefront-state where am I? (safe, read-only)
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
- **Intake is interactive + proactive:** accepts a prompt, a user story, or a spec; asks blocking questions and *suggests* gaps it spots (edge cases, error states, i18n/a11y, implicit criteria) before any planning.
|
|
98
|
-
- **Resumable:** every command updates `.claude/.planning/STATE.md`, so a run continues cleanly after an interruption.
|
|
99
|
-
- **Fresh context:** execute dispatches each agent with only its task slice, fighting context rot.
|
|
100
|
-
- Commands are manual-invoke (`disable-model-invocation`) since they have side effects. `/wavefront-state` is the exception.
|
|
101
|
-
|
|
102
|
-
### Parallel waves (F3 — multi-module items)
|
|
103
|
-
|
|
104
|
-
When a work item spans **independent** modules, `/wavefront-plan` groups them into waves and `/wavefront-execute` builds same-wave modules concurrently (multiple builders in one dispatch). Within a module, the layer chain stays sequential. Off by default (`parallelization` config) — single-module work is unchanged. Items touching the same shared file are never parallelized (avoids write races). Note: this is parallelism across modules, NOT a per-layer agent split (that was analyzed and rejected — the layers are a dependency chain).
|
|
105
|
-
|
|
106
|
-
### Change existing code (F2b)
|
|
107
|
-
|
|
108
|
-
```
|
|
109
|
-
/wavefront-change "<prompt|story>" intake → MAP (read-only) → plan → execute(modify) → verify(+regression) → ship
|
|
110
|
-
```
|
|
111
|
-
- **Map-first (mandatory):** the `mapper` agent reads the target module and produces a change-plan (ADD/MODIFY/KEEP, blast radius, regression surface) before any edit. Modifying working code is the highest-risk op — no edit without a map.
|
|
112
|
-
- `module-builder` runs in **modify mode**: edits only the planned files, preserves existing public API, doesn't regenerate.
|
|
113
|
-
- **Regression is part of the gate:** verify runs the reviewer in diff-aware mode + new tests + the module's existing tests; PASS requires no regression.
|
|
54
|
+
## Orchestration loop
|
|
114
55
|
|
|
115
|
-
|
|
56
|
+
Slash commands that drive the agents through a work item with resumable state in `.claude/.planning/`:
|
|
116
57
|
|
|
117
58
|
```
|
|
118
|
-
/wavefront-
|
|
59
|
+
/wavefront-init scaffold .planning/ (once per project)
|
|
60
|
+
/wavefront-feature intake a prompt/story → REQUIREMENTS
|
|
61
|
+
/wavefront-plan REQUIREMENTS → executable PHASE_PLAN
|
|
62
|
+
/wavefront-execute builder → tester → i18n, STATE after each (resumable)
|
|
63
|
+
/wavefront-verify reviewer + tests + acceptance criteria → PASS/FAIL
|
|
64
|
+
/wavefront-ship branch + commit + PR (verified only, confirms first)
|
|
65
|
+
/wavefront-change modify existing code (map-first, modify mode + regression)
|
|
66
|
+
/wavefront-fix bug fix (diagnose → RED→GREEN → verify)
|
|
67
|
+
/wavefront-state where am I? (read-only)
|
|
119
68
|
```
|
|
120
|
-
- **Diagnose-first:** `reviewer` in diagnose mode traces the data flow and reports root cause as `file:line + why + fix direction` (read-only).
|
|
121
|
-
- **Test before fix (RED→GREEN):** `tester` writes a regression test that FAILS against the broken code (RED) — proving it captures the bug — then module-builder patches (minimal change), and the test must PASS (GREEN) with existing tests still green.
|
|
122
|
-
- The regression test stays permanently, preventing recurrence.
|
|
123
69
|
|
|
124
|
-
|
|
70
|
+
Resumable, fresh-context per agent, parallel waves across independent modules (off by default). How it all fits together: [HOW-IT-WORKS](docs/HOW-IT-WORKS.md). Step-by-step: [USAGE §3–5](docs/USAGE.md#3-your-first-feature--the-loop).
|
|
125
71
|
|
|
126
|
-
## Design → UI
|
|
72
|
+
## Design → UI
|
|
127
73
|
|
|
128
|
-
Any design source becomes UI built from `@/ui` + `@/styles/tokens`, composed like the rest of the app:
|
|
74
|
+
Any design source becomes UI built from `@/ui` + `@/styles/tokens`, composed like the rest of the app: **live Figma MCP** (official Dev Mode or Framelink — exact frames + variables → tokens), Figma export/screenshot/image, pasted HTML, a reference screen, a text description, or nothing (continuity). Responsive is mandatory (mobile-first, theme breakpoints). Details: [USAGE §6](docs/USAGE.md#6-building-from-a-design).
|
|
129
75
|
|
|
130
|
-
|
|
131
|
-
|---|---|
|
|
132
|
-
| Figma via MCP (live) | pull exact frames + variables from a configured Figma MCP → map variables to `@/styles/tokens` |
|
|
133
|
-
| Figma export / screenshot / image | read multimodally → infer layout → map to `@/ui` (fallback when no Figma MCP) |
|
|
134
|
-
| Pasted HTML/CSS | translate markup → `@/ui` (not copied) |
|
|
135
|
-
| Reference screen ("like the todo page") | replicate that screen's pattern |
|
|
136
|
-
| Text description | infer from the design-system inventory |
|
|
137
|
-
| **Nothing** | continuity: mirror the closest existing screen |
|
|
138
|
-
|
|
139
|
-
`design-intake` produces a design-spec (layout, intent→`@/ui` map, tokens, the four states loading/empty/error/populated, **responsive behavior**, gaps); `design-system-inventory` keeps it continuous; module-builder implements it. **Responsive is mandatory** (mobile-first, theme breakpoints `minMobile/minTablet/minDesktop`) — when a design is one-viewport, intake asks how it adapts before building. Elements with no `@/ui` equivalent are flagged (approximate or add to `@/ui`, never hand-rolled). **Live Figma MCP** is supported when configured: intake detects the official Dev Mode MCP (`get_code`/`get_variable_defs`/`get_image`) or Framelink (`get_figma_data`/`download_figma_images`), pulls exact frames + variables, and maps Figma variables → `@/styles/tokens`; with no Figma MCP present it degrades to the image path. You configure your own server — wavefront ships only the detection + mapping rules.
|
|
140
|
-
|
|
141
|
-
---
|
|
76
|
+
## Skills
|
|
142
77
|
|
|
143
|
-
|
|
78
|
+
Knowledge the agents read before acting — one pattern each, distilled from real scaffold code. Every skill carries `source` + `source_version` (pinned `8edaa0b`) for drift tracking via `sync-audit`.
|
|
144
79
|
|
|
145
|
-
|
|
146
|
-
- `
|
|
147
|
-
- `
|
|
148
|
-
- `
|
|
80
|
+
- **Architecture:** `clean-architecture` · `module-structure` · `naming-conventions`
|
|
81
|
+
- **Patterns:** `data-fetching-react-query` · `gateway-pattern` · `repository-pattern` · `store-zustand` · `selector-pattern` · `hook-patterns` · `forms-rhf-zod` · `mui-design-tokens`
|
|
82
|
+
- **UI:** `component-composition` · `responsive-layouts` · `error-boundary` · `design-system-inventory` · `design-intake`
|
|
83
|
+
- **Cross-cutting:** `testing-jest-rtl` · `i18n-next-intl`
|
|
84
|
+
- **Meta:** `sync-audit` · `requirement-intake`
|
|
149
85
|
|
|
150
|
-
|
|
151
|
-
- `data-fetching-react-query` — api service + Zod + endpoints
|
|
152
|
-
- `gateway-pattern` — `createXGateway(dataSource)` + interfaces
|
|
153
|
-
- `repository-pattern` — key factory + query/mutation options + combined repo
|
|
154
|
-
- `store-zustand` — `createStoreWithMiddleware` + immer + persist
|
|
155
|
-
- `selector-pattern` — derived state via `useMemo`
|
|
156
|
-
- `hook-patterns` — business vs controller split (mandatory)
|
|
157
|
-
- `forms-rhf-zod` — `useForm` + `zodResolver` + `@/ui` inputs
|
|
158
|
-
- `mui-design-tokens` — `@/ui` + `@/styles/tokens`, never raw MUI
|
|
159
|
-
|
|
160
|
-
Cross-cutting:
|
|
161
|
-
- `testing-jest-rtl` — `renderWithProviders` + factories + colocated tests
|
|
162
|
-
- `i18n-next-intl` — `useTranslations` + `@/i18n/routing` + remote messages
|
|
163
|
-
|
|
164
|
-
Design:
|
|
165
|
-
- `design-system-inventory` — real `@/ui`/token catalog + how existing screens compose (continuity engine)
|
|
166
|
-
- `design-intake` — normalize any design source (Figma export/image/HTML/reference/description/none) → design-spec mapped to `@/ui`+tokens
|
|
167
|
-
- `responsive-layouts` — mandatory mobile-first responsive: theme breakpoints + responsive `sx` + `useResponsiveScreen`
|
|
168
|
-
- `component-composition` — decompose into subcomponents; composition over inheritance; compound components (Modal pattern)
|
|
169
|
-
- `error-boundary` — recoverable error+retry state every data-fetching view renders when its query fails
|
|
170
|
-
|
|
171
|
-
Meta:
|
|
172
|
-
- `sync-audit` — detect skill drift vs scaffold `source_version` (used by `reviewer` audit mode)
|
|
173
|
-
- `requirement-intake` — normalize prompt/story/spec → REQUIREMENTS + acceptance criteria (used by the loop)
|
|
174
|
-
|
|
175
|
-
Each skill carries `source` + `source_version` frontmatter (pinned `8edaa0b`) for drift tracking via `sync-audit`.
|
|
176
|
-
|
|
177
|
-
---
|
|
86
|
+
Plus a **bundled reference module** (`reference/module/note-module.md`) — a canonical end-to-end Clean-Arch example the agents read to copy idioms, so generation works even after a project deletes the scaffold's example modules. Self-contained: no dependency on `src/modules/todo` surviving in the target app.
|
|
178
87
|
|
|
179
88
|
## Conventions (hard rules the agents enforce)
|
|
180
89
|
|
|
@@ -182,46 +91,32 @@ Each skill carries `source` + `source_version` frontmatter (pinned `8edaa0b`) fo
|
|
|
182
91
|
- Layer direction: View → business hook → repository → gateway → api. Never skip.
|
|
183
92
|
- UI from `@/ui` (not `@mui/material`); spacing/color from `@/styles/tokens`.
|
|
184
93
|
- Strings via next-intl `t()`; locale links via `@/i18n/routing`.
|
|
185
|
-
- Tests colocated; import
|
|
186
|
-
- Zod validates at the api boundary; schemas
|
|
94
|
+
- Tests colocated; import helpers only from `@test/utils`.
|
|
95
|
+
- Zod validates at the api boundary; schemas in `*.types.ts`.
|
|
187
96
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## Smoke checklist (verify in a generated app)
|
|
193
|
-
|
|
194
|
-
After installing into an app with deps installed:
|
|
195
|
-
|
|
196
|
-
- [ ] `reviewer` on an existing module (`src/modules/todo`) → PASS.
|
|
197
|
-
- [ ] `module-builder` generates a new small module (one entity, http only).
|
|
198
|
-
- [ ] `tsc --noEmit` clean on the new module.
|
|
199
|
-
- [ ] `yarn lint` clean on the new module.
|
|
200
|
-
- [ ] `tester` adds colocated tests → `yarn test` green.
|
|
201
|
-
- [ ] `i18n-tokens` leaves no hardcoded strings/spacing; lint stays clean.
|
|
202
|
-
- [ ] `reviewer` on the new module → PASS.
|
|
203
|
-
|
|
204
|
-
> Status: E2E-validated (2026-05-23) — built a full `notification` module in a generated app to tsc-clean + lint-clean + test-green. See `docs/plan.md`.
|
|
97
|
+
Format spec: [docs/conventions.md](docs/conventions.md).
|
|
205
98
|
|
|
206
99
|
---
|
|
207
100
|
|
|
208
101
|
## Contributing
|
|
209
102
|
|
|
210
|
-
This repo follows **Conventional Commits**, enforced by commitlint (`commit-msg` hook) — versioning is automatic.
|
|
103
|
+
This repo follows **Conventional Commits**, enforced by commitlint (`commit-msg` hook) — versioning is automatic. (Full gotcha list: [TROUBLESHOOTING § Contributing](docs/TROUBLESHOOTING.md#contributing-to-this-repo).)
|
|
211
104
|
|
|
212
|
-
- **Commit format:** `type(scope): subject` — lowercase subject, ≤72 chars.
|
|
105
|
+
- **Commit format:** `type(scope): subject` — **lowercase** subject (rejects any uppercase, incl. filenames/acronyms), ≤72 chars.
|
|
213
106
|
- **types:** `feat` `fix` `docs` `chore` `perf` `refactor` `test` `hotfix`
|
|
214
|
-
- **scopes (optional):** `agents` `skills` `commands` `hooks` `planning` `cli` `bin` `install` `npm` `ci` `config` `husky` `docs` `deps`
|
|
107
|
+
- **scopes (optional):** `agents` `skills` `commands` `hooks` `reference` `planning` `cli` `bin` `install` `npm` `ci` `config` `husky` `docs` `deps`
|
|
215
108
|
- **Branch names:** `type/description` (lowercase + hyphens), e.g. `feat/figma-mcp-source`. Enforced on `pre-push` (skipped in CI / on `main`).
|
|
216
|
-
- **
|
|
109
|
+
- **No `Co-Authored-By: Claude`** or AI-attribution trailers in commits/PRs.
|
|
110
|
+
- **Releases are automatic:** push to `main` → [semantic-release](https://semantic-release.gitbook.io) bumps the version (`feat`→minor, `fix`→patch, breaking→major), updates `CHANGELOG.md`, tags + GitHub Release, then OIDC publish to npm. **Never** bump `version`/tag/CHANGELOG by hand. **After a release, `git pull --ff-only` before new work** (semantic-release commits the bump back to `main`).
|
|
217
111
|
- Setup after clone: `npm install` (wires husky hooks).
|
|
218
112
|
|
|
219
|
-
## Roadmap
|
|
113
|
+
## Roadmap
|
|
114
|
+
|
|
115
|
+
All phases resolved — see [docs/roadmap.md](docs/roadmap.md).
|
|
220
116
|
|
|
221
117
|
- ✅ **MVP** — 5 agents + 20 skills, E2E-validated.
|
|
222
|
-
- ✅ **
|
|
223
|
-
- ✅ **
|
|
224
|
-
- ✅ **
|
|
225
|
-
- ✅ **F2** — orchestration loop (`/wavefront-*`) + parallel waves + change/fix flows.
|
|
118
|
+
- ✅ **F1** — standalone repo + global/local install + **npm distribution**.
|
|
119
|
+
- ✅ **F2** — orchestration loop + parallel waves + change/fix flows.
|
|
120
|
+
- ✅ **F4/F5** — sync-audit drift detection · quality-gate hooks.
|
|
226
121
|
- ✅ **F6/F7** — design intake (incl. live Figma MCP) + component composition.
|
|
227
|
-
- ❌ **F3** — per-layer agent subdivision —
|
|
122
|
+
- ❌ **F3** — per-layer agent subdivision — won't ship (linear layer deps; F2 waves already parallelize across modules).
|
package/agents/module-builder.md
CHANGED
|
@@ -5,14 +5,14 @@ tools: Read, Write, Edit, Grep, Glob, Bash
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Role
|
|
8
|
-
The implementer that owns the full Clean Architecture chain for a feature module in scaffold-nextjs-app. The developer describes a feature; this agent generates or edits the layered files so they don't write boilerplate. It mirrors the
|
|
8
|
+
The implementer that owns the full Clean Architecture chain for a feature module in scaffold-nextjs-app. The developer describes a feature; this agent generates or edits the layered files so they don't write boilerplate. It mirrors the **bundled canonical reference** (`.claude/reference/module/note-module.md`) exactly rather than inventing structure — do NOT depend on the target app having `src/modules/todo`/`auth`, since real projects routinely delete the example modules.
|
|
9
9
|
|
|
10
10
|
## Two modes
|
|
11
11
|
- **Create (default):** new module from scratch — full chain, bottom-up (workflow below).
|
|
12
12
|
- **Modify / patch:** change an EXISTING module. **Requires a `mapper` change-plan first** (which files ADD vs MODIFY vs KEEP). Edit only the files in the plan; respect the surrounding code; don't regenerate working files. "Patch" posture (bug fixes) = the smallest change that fixes it. After editing, the touched layers must still honor layer direction + conventions, and **existing tests must still pass** (regression). Hand off to tester (extend tests + regression) and reviewer (diff-aware).
|
|
13
13
|
|
|
14
14
|
## Operating rules
|
|
15
|
-
- Always: read the knowledge skills before generating; mirror the
|
|
15
|
+
- Always: read the knowledge skills before generating; mirror the bundled reference (`.claude/reference/module/note-module.md`); use kebab-case + correct suffixes; respect layer direction (View → business hook → repository → gateway → api); type boundaries with interfaces; validate at the api boundary with Zod; build UI from `@/ui` + `@/styles/tokens`; create `index.ts` barrels; pass `dataSource` (default `'http'`) through repository/selector/hook signatures.
|
|
16
16
|
- Always (UI): before writing views/components, read `design-system-inventory` (catalog + continuity) and `responsive-layouts`; if a design-spec exists, implement it. With NO design, follow the continuity rule — mirror the closest existing screen's components/layout/spacing. Always implement the four states (loading/empty/error/populated). For the **error** state, every data-fetching view returns the error-boundary pattern (an error+retry component wired to the query's `refetch`) when its business hook reports `error` — never let a failed fetch render blank (see `error-boundary` skill). Never hand-roll raw `<div>`/`<input>` to match a visual — translate to `@/ui` + tokens.
|
|
17
17
|
- Always (responsive — mandatory): every view/component is mobile-first responsive using the theme's custom breakpoints (`minMobile`/`minTablet`/`minDesktop`/`largeScreen`, NOT xs/sm/md) via `sx` objects (`{ minMobile: ..., minTablet: ... }`) or `useResponsiveScreen` for branching. No fixed pixel widths that overflow mobile; the four states are responsive too.
|
|
18
18
|
- Always (composition — mandatory): decompose UI into focused subcomponents under `components/` (mirror todo: `*-form`, `*-item`, `*-list`, sections) — the view ORCHESTRATES + wires hooks, it does NOT hold all markup inline. Apply the `component-composition` "when to extract" decision rule: **anything rendered inside `.map()` is ALWAYS its own component** (a list extracts its item — never inline JSX in the map); also extract on distinct responsibility, reuse, own state/handlers, ~30–40+ line JSX blocks. Extraction is recursive — components have subcomponents too. Compose via children/props; for multi-part UI use the compound-component pattern. NO inheritance, NO boolean-prop explosion. Each component independently renderable + testable. Don't over-fragment trivial markup.
|
|
@@ -44,7 +44,7 @@ The implementer that owns the full Clean Architecture chain for a feature module
|
|
|
44
44
|
## Workflow
|
|
45
45
|
1. Read all knowledge skills.
|
|
46
46
|
2. Clarify inputs: module name (kebab), entity/entities, fields, which CRUD ops, data sources needed (default `http` only), whether a validated form is needed.
|
|
47
|
-
3. Inspect
|
|
47
|
+
3. Inspect the reference: read the matching layer of the bundled `.claude/reference/module/note-module.md` to copy current idioms exactly. (Optional cross-check: if the target app still has `src/modules/todo`, you may compare — but the bundled reference is the source of truth; never require the live module.)
|
|
48
48
|
4. Generate in dependency order (bottom-up):
|
|
49
49
|
a. `[entity].types.ts` — TS types + Zod schemas (request/response/filters).
|
|
50
50
|
b. `api/[entity]-api.ts` — `XApiContract` + `createXApiService()` singleton, `httpClient` + `endpoints` + Zod. (Add endpoints to `@/api/endpoints` only after Ask-first.)
|
package/agents/tester.md
CHANGED
|
@@ -16,7 +16,7 @@ When fixing a bug, the test comes FIRST:
|
|
|
16
16
|
The regression test stays in the suite permanently (prevents recurrence). If your "reproducing" test passes before the fix, it doesn't actually capture the bug — rewrite it until it's RED first.
|
|
17
17
|
|
|
18
18
|
## Coverage discipline (mandatory — per layer)
|
|
19
|
-
Mirror the
|
|
19
|
+
Mirror the bundled reference (`.claude/reference/module/note-module.md` shows a colocated test per layer; don't rely on the target app keeping `src/modules/todo`). For a module, write a colocated `*.test.ts(x)` for each piece that has logic:
|
|
20
20
|
- **api** (`*-api.test.ts`) — request/response shape, Zod validation, endpoints called.
|
|
21
21
|
- **gateway** (`http-gateway.test.ts`, + localStorage if present) — delegates correctly, source info.
|
|
22
22
|
- **repository** (`*.repository.queries.test.ts` + `*.repository.mutations.test.ts`) — hooks fetch/mutate, cache effects (invalidate/setQueryData).
|
package/install.sh
CHANGED
|
@@ -8,7 +8,7 @@ set -euo pipefail
|
|
|
8
8
|
|
|
9
9
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
10
|
HOME_DIR="${WAVEFRONT_HOME:-$HOME/.wavefront}"
|
|
11
|
-
SURFACE=(agents skills commands planning-templates)
|
|
11
|
+
SURFACE=(agents skills commands reference planning-templates)
|
|
12
12
|
|
|
13
13
|
die() { echo "error: $*" >&2; exit 1; }
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pavp/wavefront",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Claude Code frontend-engineering agent framework for scaffold-nextjs-app apps (Next.js/React/MUI/Clean Architecture). Installs agents, skills, and an orchestration loop into a project's .claude/.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"wavefront": "bin/wavefront.js"
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
"release": "semantic-release",
|
|
11
11
|
"release:dry": "semantic-release --dry-run",
|
|
12
12
|
"validate:commits": "commitlint --from origin/main --to HEAD --verbose",
|
|
13
|
-
"validate:branch": "node scripts/validate-branch-name.js"
|
|
13
|
+
"validate:branch": "node scripts/validate-branch-name.js",
|
|
14
|
+
"validate:links": "node scripts/validate-skill-links.js"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"bin/",
|
|
17
18
|
"agents/",
|
|
18
19
|
"skills/",
|
|
19
20
|
"commands/",
|
|
21
|
+
"reference/",
|
|
20
22
|
"planning-templates/",
|
|
21
23
|
"hooks/",
|
|
22
24
|
"install.sh",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# reference/ — the framework's own idiom anchor
|
|
2
|
+
|
|
3
|
+
`module/note-module.md` is a canonical, end-to-end Clean-Arch module that agents read to copy current scaffold idioms.
|
|
4
|
+
|
|
5
|
+
**Why it exists:** apps built from scaffold-nextjs-app routinely **delete the example modules** (`src/modules/todo`, `src/modules/auth`). If `module-builder` relied on reading those live, it would lose its idiom anchor the moment a real project removes them. So the framework ships its own reference instead — no dependency on the scaffold OR on the target app keeping example modules.
|
|
6
|
+
|
|
7
|
+
**What it is:** one minimal CRUD entity (`note`) showing every layer once, in dependency order, with the exact idioms (`createXGateway` factory, key factory, `queryOptions`/`mutationOptions`, `createStoreWithMiddleware`, business/controller hook split, `@/ui` + tokens, the four states incl. error-boundary, colocated tests). The per-pattern detail lives in `skills/*`; this shows how the layers fit together.
|
|
8
|
+
|
|
9
|
+
**Compile-inert:** stored as `.md` so a consuming app never type-checks/lints it (same trick as `skills/component-composition/examples/compound-component.md`).
|
|
10
|
+
|
|
11
|
+
**Provenance / drift:** distilled from the scaffold's real `todo` module at `source_version: 8edaa0b` — the same pin the skills carry. Drift is tracked the same way: `sync-audit` (via the `reviewer` audit mode) diffs this pin against the live scaffold. When the scaffold's idioms change, re-distill this file and bump the pin.
|
|
12
|
+
|
|
13
|
+
- `source:` scaffold-nextjs-app `src/modules/todo` (types, api, repository, gateways, stores, selectors, views/hooks, components)
|
|
14
|
+
- `source_version:` 8edaa0b
|
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# Reference module — `note` (canonical Clean-Arch chain)
|
|
2
|
+
|
|
3
|
+
> **This is the framework's own idiom anchor.** Agents read THIS file to copy current scaffold idioms — they do NOT depend on the target app keeping `src/modules/todo`/`auth` (apps routinely delete the example modules).
|
|
4
|
+
>
|
|
5
|
+
> It is a single, minimal CRUD module for one entity (`note`: `id`, `title`, `body`, `completed`, timestamps), distilled from the scaffold's real `todo` module — same idioms, none of `todo`'s app-specific noise (test-error harness, bulk ops, prefetch/cancel surface, 7 selectors). One example per layer, in dependency order. The detailed per-pattern rules live in the individual `skills/*` — this shows how the layers fit together end to end.
|
|
6
|
+
>
|
|
7
|
+
> File is `.md` (compile-inert) so it never gets type-checked/linted in a consuming app. Provenance: see `reference/README.md`.
|
|
8
|
+
|
|
9
|
+
Module tree this produces:
|
|
10
|
+
```
|
|
11
|
+
src/modules/note/
|
|
12
|
+
note.types.ts
|
|
13
|
+
api/note-api.ts
|
|
14
|
+
repositories/note/
|
|
15
|
+
gateways/
|
|
16
|
+
note.gateway.types.ts
|
|
17
|
+
http-gateway/http-gateway.ts
|
|
18
|
+
index.ts # createNoteGateway(source) factory
|
|
19
|
+
note.repository.keys.ts
|
|
20
|
+
note.query-options.ts
|
|
21
|
+
note.repository.queries.ts
|
|
22
|
+
note.repository.mutations.ts
|
|
23
|
+
note.repository.types.ts
|
|
24
|
+
index.ts # combined noteRepository object
|
|
25
|
+
stores/
|
|
26
|
+
note.store.ts
|
|
27
|
+
note.store.types.ts
|
|
28
|
+
note.store.actions.ts
|
|
29
|
+
selectors/
|
|
30
|
+
use-filtered-notes-selector/use-filtered-notes-selector.hook.ts
|
|
31
|
+
views/note-management/
|
|
32
|
+
note-management.view.tsx
|
|
33
|
+
hooks/
|
|
34
|
+
use-note-management-business/use-note-management-business.hook.ts
|
|
35
|
+
use-note-management-controller/use-note-management-controller.hook.ts
|
|
36
|
+
index.ts
|
|
37
|
+
components/
|
|
38
|
+
note-form/note-form.component.tsx
|
|
39
|
+
note-item/note-item.component.tsx
|
|
40
|
+
note-list/note-list.component.tsx
|
|
41
|
+
error-boundary/error-boundary.component.tsx
|
|
42
|
+
index.ts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 1. Types + Zod schemas (`note.types.ts`) — Zod is the source of truth
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { z } from 'zod';
|
|
51
|
+
|
|
52
|
+
export const NoteSchema = z.object({
|
|
53
|
+
id: z.number(),
|
|
54
|
+
title: z.string().min(1, 'common.validation.requiredField'), // i18n key, not a literal
|
|
55
|
+
body: z.string().optional(),
|
|
56
|
+
completed: z.boolean().default(false),
|
|
57
|
+
createdAt: z.string(),
|
|
58
|
+
updatedAt: z.string(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const CreateNoteSchema = NoteSchema.omit({ id: true, createdAt: true, updatedAt: true, completed: true }).extend({
|
|
62
|
+
completed: z.boolean().optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const UpdateNoteSchema = CreateNoteSchema.partial();
|
|
66
|
+
|
|
67
|
+
export const NoteFiltersSchema = z
|
|
68
|
+
.object({
|
|
69
|
+
completed: z.union([z.boolean(), z.string().transform((v) => v === 'true')]).optional(),
|
|
70
|
+
search: z.string().optional(),
|
|
71
|
+
page: z.union([z.number().int().positive(), z.string().transform((v) => parseInt(v, 10))]).optional(),
|
|
72
|
+
limit: z.union([z.number().int().positive(), z.string().transform((v) => parseInt(v, 10))]).optional(),
|
|
73
|
+
})
|
|
74
|
+
.loose();
|
|
75
|
+
|
|
76
|
+
export const NoteArraySchema = z.array(NoteSchema);
|
|
77
|
+
|
|
78
|
+
// Types are INFERRED from schemas — never hand-written interfaces for the entity.
|
|
79
|
+
export type Note = z.infer<typeof NoteSchema>;
|
|
80
|
+
export type CreateNoteRequest = z.infer<typeof CreateNoteSchema>;
|
|
81
|
+
export type UpdateNoteRequest = z.infer<typeof UpdateNoteSchema>;
|
|
82
|
+
export type NoteFilters = z.infer<typeof NoteFiltersSchema>;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 2. API service (`api/note-api.ts`) — contract + Zod-validated httpClient + singleton
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import type { ApiOptions } from '@/api/api.types';
|
|
89
|
+
import { endpoints } from '@/api/endpoints'; // add NOTE.* to @/api/endpoints (Ask-first — shared file)
|
|
90
|
+
import { httpClient } from '@/api/http-client';
|
|
91
|
+
import type { CreateNoteRequest, Note, NoteFilters, UpdateNoteRequest } from '@/modules/note/note.types';
|
|
92
|
+
import { CreateNoteSchema, NoteArraySchema, NoteFiltersSchema, NoteSchema, UpdateNoteSchema } from '@/modules/note/note.types';
|
|
93
|
+
|
|
94
|
+
export interface NoteApiContract {
|
|
95
|
+
getAll(filters?: NoteFilters, options?: ApiOptions): Promise<Note[]>;
|
|
96
|
+
getById(id: string | number, options?: ApiOptions): Promise<Note>;
|
|
97
|
+
create(note: CreateNoteRequest, options?: ApiOptions): Promise<Note>;
|
|
98
|
+
update(id: string | number, note: UpdateNoteRequest, options?: ApiOptions): Promise<Note>;
|
|
99
|
+
delete(id: string | number, options?: ApiOptions): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const createNoteApiService = (): NoteApiContract => ({
|
|
103
|
+
async getAll(filters = {}, options) {
|
|
104
|
+
const response = await httpClient.get<Note[]>(endpoints.NOTE.BASE, {
|
|
105
|
+
params: filters,
|
|
106
|
+
requestSchema: NoteFiltersSchema, // request validated
|
|
107
|
+
responseSchema: NoteArraySchema, // response validated
|
|
108
|
+
signal: options?.signal,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return response.data;
|
|
112
|
+
},
|
|
113
|
+
async getById(id, options) {
|
|
114
|
+
const response = await httpClient.get<Note>(endpoints.NOTE.BY_ID(id), {
|
|
115
|
+
responseSchema: NoteSchema,
|
|
116
|
+
signal: options?.signal,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return response.data;
|
|
120
|
+
},
|
|
121
|
+
async create(note, options) {
|
|
122
|
+
const response = await httpClient.post<Note>(endpoints.NOTE.BASE, note, {
|
|
123
|
+
requestSchema: CreateNoteSchema,
|
|
124
|
+
responseSchema: NoteSchema,
|
|
125
|
+
signal: options?.signal,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return response.data;
|
|
129
|
+
},
|
|
130
|
+
async update(id, note, options) {
|
|
131
|
+
const response = await httpClient.put<Note>(endpoints.NOTE.BY_ID(id), note, {
|
|
132
|
+
requestSchema: UpdateNoteSchema,
|
|
133
|
+
responseSchema: NoteSchema,
|
|
134
|
+
signal: options?.signal,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return response.data;
|
|
138
|
+
},
|
|
139
|
+
async delete(id, options) {
|
|
140
|
+
await httpClient.delete(endpoints.NOTE.BY_ID(id), { signal: options?.signal });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const noteApi = createNoteApiService(); // singleton for the whole app
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 3. Gateway (`repositories/note/gateways/`) — interface + http impl + factory
|
|
148
|
+
|
|
149
|
+
`note.gateway.types.ts`:
|
|
150
|
+
```tsx
|
|
151
|
+
import type { CreateNoteRequest, Note, NoteFilters, UpdateNoteRequest } from '@/modules/note/note.types';
|
|
152
|
+
import type { BaseGateway, GatewayOptions } from '@/types/gateway.types';
|
|
153
|
+
|
|
154
|
+
export interface NoteGateway extends BaseGateway {
|
|
155
|
+
findAll(filters?: NoteFilters, options?: GatewayOptions): Promise<Note[]>;
|
|
156
|
+
findById(id: string | number, options?: GatewayOptions): Promise<Note>;
|
|
157
|
+
create(note: CreateNoteRequest, options?: GatewayOptions): Promise<Note>;
|
|
158
|
+
update(id: string | number, note: UpdateNoteRequest, options?: GatewayOptions): Promise<Note>;
|
|
159
|
+
delete(id: string | number, options?: GatewayOptions): Promise<void>;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`http-gateway/http-gateway.ts`:
|
|
164
|
+
```tsx
|
|
165
|
+
import { noteApi } from '@/modules/note/api/note-api';
|
|
166
|
+
import type { NoteGateway } from '@/modules/note/repositories/note/gateways/note.gateway.types';
|
|
167
|
+
|
|
168
|
+
export const createHttpNoteGateway = (): NoteGateway => ({
|
|
169
|
+
async findAll(filters, options) {
|
|
170
|
+
return noteApi.getAll(filters, options);
|
|
171
|
+
},
|
|
172
|
+
async findById(id, options) {
|
|
173
|
+
return noteApi.getById(id, options);
|
|
174
|
+
},
|
|
175
|
+
async create(note, options) {
|
|
176
|
+
return noteApi.create(note, options);
|
|
177
|
+
},
|
|
178
|
+
async update(id, note, options) {
|
|
179
|
+
return noteApi.update(id, note, options);
|
|
180
|
+
},
|
|
181
|
+
async delete(id, options) {
|
|
182
|
+
await noteApi.delete(id, options);
|
|
183
|
+
},
|
|
184
|
+
getSourceInfo() {
|
|
185
|
+
return {
|
|
186
|
+
type: 'http',
|
|
187
|
+
name: 'HTTP API Gateway',
|
|
188
|
+
capabilities: { offline: false, realtime: true, persistence: true },
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`gateways/index.ts` — the factory (`DataSource` is `'http' | 'localStorage'` only, no `'mock'`):
|
|
195
|
+
```tsx
|
|
196
|
+
import { DataSource } from '@/types/gateway.types';
|
|
197
|
+
|
|
198
|
+
import { createHttpNoteGateway } from './http-gateway/http-gateway';
|
|
199
|
+
import type { NoteGateway } from './note.gateway.types';
|
|
200
|
+
|
|
201
|
+
export const createNoteGateway = (source: DataSource = 'http'): NoteGateway => {
|
|
202
|
+
switch (source) {
|
|
203
|
+
case 'http':
|
|
204
|
+
return createHttpNoteGateway();
|
|
205
|
+
// add createLocalStorageNoteGateway() only if a localStorage source is requested
|
|
206
|
+
default:
|
|
207
|
+
return createHttpNoteGateway();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export { createHttpNoteGateway } from './http-gateway/http-gateway';
|
|
212
|
+
export type { NoteGateway } from './note.gateway.types';
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## 4. Repository (`repositories/note/`) — keys → options → queries → mutations → types → combined
|
|
216
|
+
|
|
217
|
+
`note.repository.keys.ts` — query-key factory, namespaced by `dataSource`:
|
|
218
|
+
```tsx
|
|
219
|
+
import type { NoteFilters } from '@/modules/note/note.types';
|
|
220
|
+
import type { DataSource } from '@/types/gateway.types';
|
|
221
|
+
|
|
222
|
+
export const noteQueryKeys = {
|
|
223
|
+
all: ['notes'] as const,
|
|
224
|
+
lists: (dataSource: DataSource = 'http') => [...noteQueryKeys.all, 'list', dataSource] as const,
|
|
225
|
+
list: (filters: NoteFilters = {}, dataSource: DataSource = 'http') => [...noteQueryKeys.lists(dataSource), filters] as const,
|
|
226
|
+
details: (dataSource: DataSource = 'http') => [...noteQueryKeys.all, 'detail', dataSource] as const,
|
|
227
|
+
detail: (id: string | number, dataSource: DataSource = 'http') => [...noteQueryKeys.details(dataSource), id] as const,
|
|
228
|
+
} as const;
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`note.query-options.ts` — `queryOptions`/`mutationOptions` factories (reused by hooks + prefetch):
|
|
232
|
+
```tsx
|
|
233
|
+
import { mutationOptions, queryOptions } from '@tanstack/react-query';
|
|
234
|
+
|
|
235
|
+
import type { DataSource } from '@/types/gateway.types';
|
|
236
|
+
|
|
237
|
+
import { createNoteGateway } from './gateways';
|
|
238
|
+
import { noteQueryKeys } from './note.repository.keys';
|
|
239
|
+
|
|
240
|
+
const getNotesQueryOptions = (filters = {}, dataSource: DataSource = 'http') =>
|
|
241
|
+
queryOptions({
|
|
242
|
+
queryKey: noteQueryKeys.list(filters, dataSource),
|
|
243
|
+
queryFn: ({ signal }) => createNoteGateway(dataSource).findAll(filters, { signal }),
|
|
244
|
+
staleTime: 5 * 60 * 1000,
|
|
245
|
+
gcTime: 10 * 60 * 1000,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const getNoteQueryOptions = (id: string | number, dataSource: DataSource = 'http') =>
|
|
249
|
+
queryOptions({
|
|
250
|
+
queryKey: noteQueryKeys.detail(id, dataSource),
|
|
251
|
+
queryFn: ({ signal }) => createNoteGateway(dataSource).findById(id, { signal }),
|
|
252
|
+
staleTime: 5 * 60 * 1000,
|
|
253
|
+
gcTime: 10 * 60 * 1000,
|
|
254
|
+
enabled: !!id,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const getCreateNoteMutationOptions = (dataSource: DataSource = 'http') =>
|
|
258
|
+
mutationOptions({ mutationFn: (note: any) => createNoteGateway(dataSource).create(note), retry: 1 });
|
|
259
|
+
|
|
260
|
+
const getUpdateNoteMutationOptions = (dataSource: DataSource = 'http') =>
|
|
261
|
+
mutationOptions({
|
|
262
|
+
mutationFn: ({ id, data }: { id: string | number; data: any }) => createNoteGateway(dataSource).update(id, data),
|
|
263
|
+
retry: 1,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const getDeleteNoteMutationOptions = (dataSource: DataSource = 'http') =>
|
|
267
|
+
mutationOptions({ mutationFn: (id: string | number) => createNoteGateway(dataSource).delete(id), retry: 1 });
|
|
268
|
+
|
|
269
|
+
export const noteQueryOptions = { notes: getNotesQueryOptions, note: getNoteQueryOptions } as const;
|
|
270
|
+
export const noteMutationOptions = {
|
|
271
|
+
createNote: getCreateNoteMutationOptions,
|
|
272
|
+
updateNote: getUpdateNoteMutationOptions,
|
|
273
|
+
deleteNote: getDeleteNoteMutationOptions,
|
|
274
|
+
} as const;
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`note.repository.queries.ts` — query hooks compose the options:
|
|
278
|
+
```tsx
|
|
279
|
+
import { useQuery } from '@tanstack/react-query';
|
|
280
|
+
|
|
281
|
+
import { noteQueryOptions } from './note.query-options';
|
|
282
|
+
import type { NoteQueriesRepository } from './note.repository.types';
|
|
283
|
+
|
|
284
|
+
export const noteQueriesRepository: NoteQueriesRepository = {
|
|
285
|
+
useNotes: (filters = {}, dataSource = 'http', options) =>
|
|
286
|
+
useQuery({ ...noteQueryOptions.notes(filters, dataSource), ...options }),
|
|
287
|
+
useNote: (id, dataSource = 'http', options) =>
|
|
288
|
+
useQuery({ ...noteQueryOptions.note(id, dataSource), ...options }),
|
|
289
|
+
};
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`note.repository.mutations.ts` — mutation hooks own cache writes in `onSuccess`; always forward the user's `options?.onSuccess`:
|
|
293
|
+
```tsx
|
|
294
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
295
|
+
|
|
296
|
+
import type { Note } from '@/modules/note/note.types';
|
|
297
|
+
|
|
298
|
+
import { noteMutationOptions } from './note.query-options';
|
|
299
|
+
import { noteQueryKeys } from './note.repository.keys';
|
|
300
|
+
import type { NoteMutationsRepository } from './note.repository.types';
|
|
301
|
+
|
|
302
|
+
export const noteMutationsRepository: NoteMutationsRepository = {
|
|
303
|
+
useCreateNote: (dataSource = 'http', options) => {
|
|
304
|
+
const queryClient = useQueryClient();
|
|
305
|
+
const baseOptions = noteMutationOptions.createNote(dataSource);
|
|
306
|
+
|
|
307
|
+
return useMutation({
|
|
308
|
+
...baseOptions,
|
|
309
|
+
// eslint-disable-next-line max-params ← REQUIRED: onSuccess has 4 params, lint cap is 3
|
|
310
|
+
onSuccess: (newNote, vars, onMutateResult, ctx) => {
|
|
311
|
+
queryClient.invalidateQueries({ queryKey: noteQueryKeys.lists(dataSource) });
|
|
312
|
+
queryClient.setQueryData<Note[]>(noteQueryKeys.lists(dataSource), (old) => (old ? [...old, newNote] : [newNote]));
|
|
313
|
+
options?.onSuccess?.(newNote, vars, onMutateResult, ctx);
|
|
314
|
+
},
|
|
315
|
+
...options,
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
useUpdateNote: (dataSource = 'http', options) => {
|
|
319
|
+
const queryClient = useQueryClient();
|
|
320
|
+
|
|
321
|
+
return useMutation({
|
|
322
|
+
...noteMutationOptions.updateNote(dataSource),
|
|
323
|
+
// eslint-disable-next-line max-params
|
|
324
|
+
onSuccess: (updated: Note, vars, onMutateResult, ctx) => {
|
|
325
|
+
queryClient.setQueryData<Note[]>(noteQueryKeys.lists(), (old) => old?.map((n) => (n.id === updated.id ? updated : n)));
|
|
326
|
+
queryClient.invalidateQueries({ queryKey: noteQueryKeys.lists(dataSource) });
|
|
327
|
+
options?.onSuccess?.(updated, vars, onMutateResult, ctx);
|
|
328
|
+
},
|
|
329
|
+
...options,
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
useDeleteNote: (dataSource = 'http', options) => {
|
|
333
|
+
const queryClient = useQueryClient();
|
|
334
|
+
|
|
335
|
+
return useMutation({
|
|
336
|
+
...noteMutationOptions.deleteNote(dataSource),
|
|
337
|
+
// eslint-disable-next-line max-params
|
|
338
|
+
onSuccess: (result, deletedId, onMutateResult, ctx) => {
|
|
339
|
+
queryClient.setQueryData<Note[]>(noteQueryKeys.lists(), (old) => old?.filter((n) => n.id !== deletedId));
|
|
340
|
+
queryClient.invalidateQueries({ queryKey: noteQueryKeys.lists(dataSource) });
|
|
341
|
+
options?.onSuccess?.(result, deletedId, onMutateResult, ctx);
|
|
342
|
+
},
|
|
343
|
+
...options,
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`note.repository.types.ts` — interfaces (queries extend `BaseRepository`; options from `@/core/lib/react-query`):
|
|
350
|
+
```tsx
|
|
351
|
+
import { type UseMutationResult, type UseQueryResult } from '@tanstack/react-query';
|
|
352
|
+
|
|
353
|
+
import type { BaseRepository } from '@/core/lib/react-query';
|
|
354
|
+
import { MutationOptions, QueryOptions } from '@/core/lib/react-query';
|
|
355
|
+
import type { CreateNoteRequest, Note, NoteFilters, UpdateNoteRequest } from '@/modules/note/note.types';
|
|
356
|
+
import type { DataSource } from '@/types/gateway.types';
|
|
357
|
+
|
|
358
|
+
export interface NoteQueriesRepository extends BaseRepository {
|
|
359
|
+
useNotes: (filters?: NoteFilters, dataSource?: DataSource, options?: QueryOptions) => UseQueryResult<Note[], Error>;
|
|
360
|
+
useNote: (id: string | number, dataSource?: DataSource, options?: QueryOptions) => UseQueryResult<Note, Error>;
|
|
361
|
+
}
|
|
362
|
+
export interface NoteMutationsRepository {
|
|
363
|
+
useCreateNote: (dataSource?: DataSource, options?: MutationOptions) => UseMutationResult<Note, Error, CreateNoteRequest>;
|
|
364
|
+
useUpdateNote: (dataSource?: DataSource, options?: MutationOptions) => UseMutationResult<Note, Error, { id: string | number; data: UpdateNoteRequest }>;
|
|
365
|
+
useDeleteNote: (dataSource?: DataSource, options?: MutationOptions) => UseMutationResult<void, Error, string | number>;
|
|
366
|
+
}
|
|
367
|
+
export interface NoteRepository {
|
|
368
|
+
queries: NoteQueriesRepository;
|
|
369
|
+
mutations: NoteMutationsRepository;
|
|
370
|
+
queryKeys: typeof import('./note.repository.keys').noteQueryKeys;
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`repositories/note/index.ts` — the combined repository object the hooks import:
|
|
375
|
+
```tsx
|
|
376
|
+
import { noteMutationsRepository } from './note.repository.mutations';
|
|
377
|
+
import { noteQueriesRepository } from './note.repository.queries';
|
|
378
|
+
import { noteQueryKeys } from './note.repository.keys';
|
|
379
|
+
import type { NoteRepository } from './note.repository.types';
|
|
380
|
+
|
|
381
|
+
export const noteRepository: NoteRepository = {
|
|
382
|
+
queries: noteQueriesRepository,
|
|
383
|
+
mutations: noteMutationsRepository,
|
|
384
|
+
queryKeys: noteQueryKeys,
|
|
385
|
+
};
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## 5. Store (`stores/`) — Zustand via `createStoreWithMiddleware` (never raw `create`)
|
|
389
|
+
|
|
390
|
+
`note.store.types.ts`:
|
|
391
|
+
```tsx
|
|
392
|
+
import type { NoteFilters } from '@/modules/note/note.types';
|
|
393
|
+
|
|
394
|
+
export interface NoteState {
|
|
395
|
+
selectedNoteId: number | null;
|
|
396
|
+
filters: NoteFilters;
|
|
397
|
+
setSelectedNoteId: (id: number | null) => void;
|
|
398
|
+
setFilters: (filters: NoteFilters) => void;
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`note.store.ts`:
|
|
403
|
+
```tsx
|
|
404
|
+
import { createStoreWithMiddleware } from '@/core/lib/zustand';
|
|
405
|
+
|
|
406
|
+
import type { NoteState } from './note.store.types';
|
|
407
|
+
|
|
408
|
+
export const useNoteStore = createStoreWithMiddleware<NoteState>(
|
|
409
|
+
(set) => ({
|
|
410
|
+
selectedNoteId: null,
|
|
411
|
+
filters: {},
|
|
412
|
+
setSelectedNoteId: (id) => set((state) => { state.selectedNoteId = id; }), // immer middleware → mutate draft
|
|
413
|
+
setFilters: (filters) => set((state) => { state.filters = filters; }),
|
|
414
|
+
}),
|
|
415
|
+
{ name: 'note-store' },
|
|
416
|
+
);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`note.store.actions.ts` — selector hook for actions (stable references):
|
|
420
|
+
```tsx
|
|
421
|
+
import { useNoteStore } from './note.store';
|
|
422
|
+
|
|
423
|
+
export const useNoteActions = () =>
|
|
424
|
+
useNoteStore((s) => ({ setSelectedNoteId: s.setSelectedNoteId, setFilters: s.setFilters }));
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## 6. Selector (`selectors/`) — derived state via `useMemo` over a query
|
|
428
|
+
|
|
429
|
+
`use-filtered-notes-selector/use-filtered-notes-selector.hook.ts`:
|
|
430
|
+
```tsx
|
|
431
|
+
import { useMemo } from 'react';
|
|
432
|
+
|
|
433
|
+
import { noteRepository } from '@/modules/note/repositories/note';
|
|
434
|
+
import { useNoteStore } from '@/modules/note/stores/note.store';
|
|
435
|
+
import type { DataSource } from '@/types/gateway.types';
|
|
436
|
+
|
|
437
|
+
export const useFilteredNotesSelector = (dataSource: DataSource = 'http') => {
|
|
438
|
+
const filters = useNoteStore((s) => s.filters);
|
|
439
|
+
const notesQuery = noteRepository.queries.useNotes(filters, dataSource);
|
|
440
|
+
|
|
441
|
+
const data = useMemo(() => {
|
|
442
|
+
const notes = notesQuery.data ?? [];
|
|
443
|
+
if (filters.completed === undefined) return notes;
|
|
444
|
+
|
|
445
|
+
return notes.filter((n) => n.completed === filters.completed);
|
|
446
|
+
}, [notesQuery.data, filters.completed]);
|
|
447
|
+
|
|
448
|
+
return { ...notesQuery, data };
|
|
449
|
+
};
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## 7. Hooks (`views/note-management/hooks/`) — business vs controller split (mandatory)
|
|
453
|
+
|
|
454
|
+
**Business hook** = data + business rules + the four states. Exposes `error` + `refetch` for the error-boundary:
|
|
455
|
+
```tsx
|
|
456
|
+
'use client';
|
|
457
|
+
|
|
458
|
+
import { useCallback } from 'react';
|
|
459
|
+
|
|
460
|
+
import { noteRepository } from '@/modules/note/repositories/note';
|
|
461
|
+
import { useNoteActions } from '@/modules/note/stores/note.store.actions';
|
|
462
|
+
import type { CreateNoteRequest, UpdateNoteRequest } from '@/modules/note/note.types';
|
|
463
|
+
import type { DataSource } from '@/types/gateway.types';
|
|
464
|
+
|
|
465
|
+
export const useNoteManagementBusiness = (dataSource: DataSource = 'http') => {
|
|
466
|
+
const { setSelectedNoteId } = useNoteActions();
|
|
467
|
+
|
|
468
|
+
const notesQuery = noteRepository.queries.useNotes({}, dataSource);
|
|
469
|
+
const createMutation = noteRepository.mutations.useCreateNote(dataSource);
|
|
470
|
+
const deleteMutation = noteRepository.mutations.useDeleteNote(dataSource);
|
|
471
|
+
|
|
472
|
+
const createNote = useCallback(
|
|
473
|
+
(data: CreateNoteRequest) => {
|
|
474
|
+
if (!data.title?.trim()) throw new Error('common.validation.requiredField');
|
|
475
|
+
|
|
476
|
+
return createMutation.mutate({ ...data, title: data.title.trim() });
|
|
477
|
+
},
|
|
478
|
+
[createMutation],
|
|
479
|
+
);
|
|
480
|
+
const deleteNote = useCallback((id: number) => deleteMutation.mutate(id), [deleteMutation]);
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
notes: notesQuery.data ?? [],
|
|
484
|
+
isLoading: notesQuery.isLoading,
|
|
485
|
+
isEmpty: !notesQuery.isLoading && (notesQuery.data?.length ?? 0) === 0,
|
|
486
|
+
error: notesQuery.error ?? createMutation.error ?? deleteMutation.error, // four states: error
|
|
487
|
+
isCreating: createMutation.isPending,
|
|
488
|
+
isDeleting: deleteMutation.isPending,
|
|
489
|
+
createNote,
|
|
490
|
+
deleteNote,
|
|
491
|
+
setSelectedNoteId,
|
|
492
|
+
refetch: notesQuery.refetch, // wired to the error-boundary's onRetry
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Controller hook** = UI orchestration (event handlers that adapt UI events → business actions). No data fetching:
|
|
498
|
+
```tsx
|
|
499
|
+
'use client';
|
|
500
|
+
|
|
501
|
+
import { useCallback } from 'react';
|
|
502
|
+
|
|
503
|
+
import type { CreateNoteRequest } from '@/modules/note/note.types';
|
|
504
|
+
|
|
505
|
+
export const useNoteManagementController = () => {
|
|
506
|
+
const handleCreateSubmit = useCallback(
|
|
507
|
+
(createNote: (data: CreateNoteRequest) => void) => (data: CreateNoteRequest) => createNote(data),
|
|
508
|
+
[],
|
|
509
|
+
);
|
|
510
|
+
const handleDeleteClick = useCallback((deleteNote: (id: number) => void) => (id: number) => deleteNote(id), []);
|
|
511
|
+
|
|
512
|
+
return { handleCreateSubmit, handleDeleteClick };
|
|
513
|
+
};
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## 8. View (`views/note-management/note-management.view.tsx`) — orchestrates; returns the error state; no inline markup blocks
|
|
517
|
+
|
|
518
|
+
```tsx
|
|
519
|
+
'use client';
|
|
520
|
+
|
|
521
|
+
import { ErrorBoundary, NoteForm, NoteList } from '@/modules/note/components';
|
|
522
|
+
import tokens from '@/styles/tokens';
|
|
523
|
+
import { Box, Typography } from '@/ui';
|
|
524
|
+
|
|
525
|
+
import { useNoteManagementBusiness, useNoteManagementController } from './hooks';
|
|
526
|
+
|
|
527
|
+
export const NoteManagementView = () => {
|
|
528
|
+
const { notes, isLoading, isCreating, isDeleting, error, createNote, deleteNote, refetch } = useNoteManagementBusiness('http');
|
|
529
|
+
const { handleCreateSubmit, handleDeleteClick } = useNoteManagementController();
|
|
530
|
+
|
|
531
|
+
// four states: ERROR — recoverable error+retry, never a blank screen (see error-boundary skill)
|
|
532
|
+
if (error) return <ErrorBoundary error={error} onRetry={refetch} />;
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<Box sx={{ p: tokens.spacing.scale6 }}>
|
|
536
|
+
<Typography gutterBottom variant="h4">
|
|
537
|
+
Notes
|
|
538
|
+
</Typography>
|
|
539
|
+
<NoteForm isCreating={isCreating} onSubmit={handleCreateSubmit(createNote)} />
|
|
540
|
+
<NoteList isDeleting={isDeleting} isLoading={isLoading} notes={notes} onDelete={handleDeleteClick(deleteNote)} />
|
|
541
|
+
</Box>
|
|
542
|
+
);
|
|
543
|
+
};
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## 9. Components (`components/`) — composed, `@/ui` + tokens, mapped row is its own component
|
|
547
|
+
|
|
548
|
+
`note-list.component.tsx` — anything inside `.map()` is its own component (`NoteItem`), never inline JSX:
|
|
549
|
+
```tsx
|
|
550
|
+
import tokens from '@/styles/tokens';
|
|
551
|
+
import { Box, Typography } from '@/ui';
|
|
552
|
+
|
|
553
|
+
import { NoteItem } from '../note-item/note-item.component';
|
|
554
|
+
import type { Note } from '@/modules/note/note.types';
|
|
555
|
+
|
|
556
|
+
interface NoteListProps {
|
|
557
|
+
notes: Note[];
|
|
558
|
+
isLoading: boolean;
|
|
559
|
+
isDeleting: boolean;
|
|
560
|
+
onDelete: (id: number) => void;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export const NoteList = ({ notes, isLoading, isDeleting, onDelete }: NoteListProps) => {
|
|
564
|
+
if (isLoading) return <Typography>Loading…</Typography>; // four states: loading
|
|
565
|
+
if (notes.length === 0) return <Typography>No notes yet</Typography>; // four states: empty
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing.scale4 }}>
|
|
569
|
+
{notes.map((note) => (
|
|
570
|
+
<NoteItem key={note.id} note={note} isDeleting={isDeleting} onDelete={onDelete} />
|
|
571
|
+
))}
|
|
572
|
+
</Box>
|
|
573
|
+
);
|
|
574
|
+
};
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
`error-boundary/error-boundary.component.tsx` — recoverable error+retry (see the `error-boundary` skill for the full version + test). `NoteForm` uses RHF + `zodResolver(CreateNoteSchema)` (see `forms-rhf-zod`), `NoteItem` is `memo`'d with role-based interactions.
|
|
578
|
+
|
|
579
|
+
`components/index.ts` barrels every component; `views/index.ts` barrels the view; each layer dir has its `index.ts`.
|
|
580
|
+
|
|
581
|
+
## 10. Tests (colocated, one per layer) — `@test/utils` + faker factories
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
// note-item.component.test.tsx
|
|
585
|
+
import { fireEvent, renderWithProviders, screen } from '@test/utils';
|
|
586
|
+
import { createMockNote } from '@test/entities/note.mock'; // faker factory; create if missing
|
|
587
|
+
|
|
588
|
+
import { NoteItem } from './note-item.component';
|
|
589
|
+
|
|
590
|
+
it('calls onDelete when delete is clicked', () => {
|
|
591
|
+
const onDelete = jest.fn();
|
|
592
|
+
renderWithProviders(<NoteItem note={createMockNote()} isDeleting={false} onDelete={onDelete} />);
|
|
593
|
+
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
|
|
594
|
+
expect(onDelete).toHaveBeenCalled();
|
|
595
|
+
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Every logic layer gets a colocated `*.test.ts(x)` (api/gateway/repository queries+mutations/store/selector/business+controller hooks/each component/view). Import test helpers ONLY from `@test/utils`; query by role/label first.
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## Layer dependency direction (never violate)
|
|
603
|
+
|
|
604
|
+
```
|
|
605
|
+
View → controller hook + business hook → repository (queries/mutations) → gateway → api → httpClient
|
|
606
|
+
↘ store / selectors (local + derived state)
|
|
607
|
+
```
|
|
608
|
+
A View/component never calls the api or gateway directly — always through the repository. Types flow from `*.types.ts` (Zod-inferred). UI is `@/ui` + `@/styles/tokens` only. Strings are i18n keys, not literals.
|
|
@@ -9,8 +9,8 @@ source_version: 8edaa0b
|
|
|
9
9
|
|
|
10
10
|
Tests colocate next to source as `*.test.ts` / `*.test.tsx`. Everything is imported from `@test/utils` (lint forbids importing RTL / `react-dom` directly in feature tests).
|
|
11
11
|
|
|
12
|
-
## Coverage is per-layer
|
|
13
|
-
|
|
12
|
+
## Coverage is per-layer
|
|
13
|
+
Test EVERY layer — a colocated test for api, gateway, repository (queries + mutations), store, each selector, business + controller hooks, each helper, each component, and the view (the bundled `.claude/reference/module/note-module.md` shows this per-layer test layout; don't rely on the target app keeping `src/modules/todo`). Match that: one colocated test per piece with logic, not just the happy-path hook.
|
|
14
14
|
- **Unit:** pure pieces alone — helpers, store, a presentational component with mock props.
|
|
15
15
|
- **Integration:** pieces wired through providers — `renderHookWithProviders` for a business hook (hook→repository→gateway→api), a view with real hooks.
|
|
16
16
|
Cover both. A module with one test is undertested.
|