@tristan2828/ui-foundation 3.0.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/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/ui-foundation.mjs +249 -0
- package/conventions/AGENTS.md +149 -0
- package/conventions/agents/deny-impl-read.mjs +36 -0
- package/conventions/agents/skills/new-entity/SKILL.md +22 -0
- package/conventions/agents/spec-tester.md +35 -0
- package/conventions/docs/add-an-entity.md +204 -0
- package/conventions/docs/cell-patterns.md +204 -0
- package/conventions/docs/column-options.md +122 -0
- package/conventions/docs/design-language.md +116 -0
- package/conventions/docs/entity-plan-template.md +67 -0
- package/dist/api/contracts.d.ts +20 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/gateway/auth.d.ts +9 -0
- package/dist/api/gateway/auth.js +37 -0
- package/dist/api/gateway/errors.d.ts +5 -0
- package/dist/api/gateway/errors.js +36 -0
- package/dist/api/query-client.d.ts +2 -0
- package/dist/api/query-client.js +19 -0
- package/dist/api/schema.d.ts +246 -0
- package/dist/api/schema.js +5 -0
- package/dist/api/transport/index.d.ts +5 -0
- package/dist/api/transport/index.js +14 -0
- package/dist/auth/auth-context.d.ts +19 -0
- package/dist/auth/auth-context.js +12 -0
- package/dist/auth/auth-provider.d.ts +4 -0
- package/dist/auth/auth-provider.js +101 -0
- package/dist/auth/use-auth.d.ts +2 -0
- package/dist/auth/use-auth.js +9 -0
- package/dist/components/app/app-shell.d.ts +28 -0
- package/dist/components/app/app-shell.js +64 -0
- package/dist/components/app/data-table.d.ts +56 -0
- package/dist/components/app/data-table.js +167 -0
- package/dist/components/app/entity-form.d.ts +14 -0
- package/dist/components/app/entity-form.js +8 -0
- package/dist/components/app/error-state.d.ts +6 -0
- package/dist/components/app/error-state.js +20 -0
- package/dist/components/app/foundation-providers.d.ts +9 -0
- package/dist/components/app/foundation-providers.js +15 -0
- package/dist/components/app/mock-mode-banner.d.ts +3 -0
- package/dist/components/app/mock-mode-banner.js +16 -0
- package/dist/components/app/multi-choice.d.ts +14 -0
- package/dist/components/app/multi-choice.js +13 -0
- package/dist/components/app/password-input.d.ts +3 -0
- package/dist/components/app/password-input.js +30 -0
- package/dist/components/app/route-error-boundary.d.ts +1 -0
- package/dist/components/app/route-error-boundary.js +13 -0
- package/dist/components/theme-provider.d.ts +2 -0
- package/dist/components/theme-provider.js +8 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +50 -0
- package/dist/components/ui/button.d.ts +8 -0
- package/dist/components/ui/button.js +34 -0
- package/dist/components/ui/calendar.d.ts +10 -0
- package/dist/components/ui/calendar.js +76 -0
- package/dist/components/ui/card.d.ts +11 -0
- package/dist/components/ui/card.js +25 -0
- package/dist/components/ui/combobox.d.ts +24 -0
- package/dist/components/ui/combobox.js +62 -0
- package/dist/components/ui/dialog.d.ts +17 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/empty.d.ts +11 -0
- package/dist/components/ui/empty.js +33 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/input-group.d.ts +18 -0
- package/dist/components/ui/input-group.js +57 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +8 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +23 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +35 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +8 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +38 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +255 -0
- package/dist/components/ui/skeleton.d.ts +2 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/sonner.d.ts +3 -0
- package/dist/components/ui/sonner.js +24 -0
- package/dist/components/ui/spinner.d.ts +2 -0
- package/dist/components/ui/spinner.js +7 -0
- package/dist/components/ui/table.d.ts +13 -0
- package/dist/components/ui/table.js +45 -0
- package/dist/components/ui/textarea.d.ts +3 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +5 -0
- package/dist/hooks/table-url-changes.d.ts +14 -0
- package/dist/hooks/table-url-changes.js +32 -0
- package/dist/hooks/use-debounced-value.d.ts +1 -0
- package/dist/hooks/use-debounced-value.js +12 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +14 -0
- package/dist/hooks/use-table-url-state.d.ts +14 -0
- package/dist/hooks/use-table-url-state.js +43 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +28 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +1 -0
- package/dist/mocks/auth.d.ts +9 -0
- package/dist/mocks/auth.js +79 -0
- package/dist/mocks/e2e-hooks.d.ts +3 -0
- package/dist/mocks/e2e-hooks.js +28 -0
- package/dist/mocks/index.d.ts +3 -0
- package/dist/mocks/index.js +5 -0
- package/dist/mocks/override.d.ts +23 -0
- package/dist/mocks/override.js +1 -0
- package/dist/routes/login-schema.d.ts +7 -0
- package/dist/routes/login-schema.js +13 -0
- package/dist/routes/login.d.ts +8 -0
- package/dist/routes/login.js +58 -0
- package/dist/routes/register-schema.d.ts +9 -0
- package/dist/routes/register-schema.js +23 -0
- package/dist/routes/register.d.ts +1 -0
- package/dist/routes/register.js +58 -0
- package/dist/routes/return-path.d.ts +1 -0
- package/dist/routes/return-path.js +9 -0
- package/dist/testing/index.d.ts +40 -0
- package/dist/testing/index.js +103 -0
- package/eslint/index.d.ts +11 -0
- package/eslint/index.js +179 -0
- package/openapi/foundation.yaml +156 -0
- package/package.json +158 -0
- package/styles/index.css +88 -0
- package/styles/theme.css +209 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tristan2828
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @tristan2828/ui-foundation
|
|
2
|
+
|
|
3
|
+
The shared layer of a family of small, contract-first, database-backed React
|
|
4
|
+
apps: shadcn primitives, the `DataTable`/`EntityForm` composites, an app
|
|
5
|
+
shell with session-cookie auth, the gateway's error seam, design tokens,
|
|
6
|
+
lint rules, Playwright suites, and the conventions an AI agent follows to
|
|
7
|
+
build on it. Apps upgrade by bumping the version.
|
|
8
|
+
|
|
9
|
+
Built for Vite + React 19 + TypeScript, Tailwind v4, shadcn/ui on Base UI,
|
|
10
|
+
React Router 7 and TanStack Query. Start a new app from the template rather
|
|
11
|
+
than installing this by hand:
|
|
12
|
+
[docs/create-an-app.md](https://github.com/Tristan2828/ui-foundation/blob/main/docs/create-an-app.md).
|
|
13
|
+
|
|
14
|
+
## Entry points
|
|
15
|
+
|
|
16
|
+
| Import | What |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
19
|
+
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, table, textarea, tooltip |
|
|
20
|
+
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
21
|
+
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `exposeMswForE2E` |
|
|
22
|
+
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
|
|
23
|
+
| `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
|
|
24
|
+
| `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
|
|
25
|
+
| `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*` and the error bodies |
|
|
26
|
+
|
|
27
|
+
## The `ui-foundation` command
|
|
28
|
+
|
|
29
|
+
Run from an app's root:
|
|
30
|
+
|
|
31
|
+
- `ui-foundation sync` writes the conventions into the app: the rules block
|
|
32
|
+
in `AGENTS.md`, `docs/foundation/`, and the agent files in `.claude/` and
|
|
33
|
+
`.codex/`. Run it after every upgrade and commit what it writes.
|
|
34
|
+
`sync --check` (in `verify`) fails when they differ from the installed
|
|
35
|
+
version.
|
|
36
|
+
- `ui-foundation check-contract [openapi.yaml]` fails unless the app's spec
|
|
37
|
+
contains the foundation's part unchanged.
|
|
38
|
+
- `ui-foundation check-deps` fails if `package.json` names a dependency
|
|
39
|
+
missing from `deps-allowlist.json`.
|
|
40
|
+
|
|
41
|
+
## Upgrading
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @tristan2828/ui-foundation@<version>
|
|
45
|
+
npx ui-foundation sync
|
|
46
|
+
npm run verify
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Release notes: https://github.com/Tristan2828/ui-foundation/releases.
|
|
50
|
+
|
|
51
|
+
MIT licensed.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `ui-foundation` command, run from an app's root:
|
|
3
|
+
//
|
|
4
|
+
// ui-foundation sync [--check]
|
|
5
|
+
// Writes the foundation's conventions into the app: the rules block in
|
|
6
|
+
// AGENTS.md, docs/foundation/ (the entity playbook and design docs), and
|
|
7
|
+
// the agent files (.claude/, .codex/). --check writes nothing and fails
|
|
8
|
+
// if any of them differ from this installed version, which is how
|
|
9
|
+
// `verify` catches a package upgrade that skipped `sync`, or a synced
|
|
10
|
+
// file edited in the app.
|
|
11
|
+
//
|
|
12
|
+
// ui-foundation check-contract [openapi.yaml]
|
|
13
|
+
// Fails unless the app's spec contains the foundation's part of the
|
|
14
|
+
// contract (auth paths, error envelopes) unchanged — the part the
|
|
15
|
+
// package's own code calls.
|
|
16
|
+
//
|
|
17
|
+
// ui-foundation check-deps [--allowlist deps-allowlist.json]
|
|
18
|
+
// Fails if package.json names a dependency the allowlist doesn't.
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { load as loadYaml } from 'js-yaml'
|
|
23
|
+
|
|
24
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
25
|
+
const PACKAGE = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'))
|
|
26
|
+
const APP_ROOT = process.cwd()
|
|
27
|
+
|
|
28
|
+
const read = (file) => readFileSync(file, 'utf8').replace(/\r\n/g, '\n')
|
|
29
|
+
const fail = (message) => {
|
|
30
|
+
console.error(message)
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------- sync
|
|
35
|
+
|
|
36
|
+
const BLOCK_START = '<!-- ui-foundation:start'
|
|
37
|
+
const BLOCK_END = '<!-- ui-foundation:end -->'
|
|
38
|
+
const BLOCK_HEADER = `${BLOCK_START} — synced from ${PACKAGE.name} by \`npx ui-foundation sync\`. Don't edit inside this block: your app's own notes go below it, and a change to the rules belongs in the foundation. -->`
|
|
39
|
+
|
|
40
|
+
// docs/foundation/ belongs to sync entirely: files the package no longer
|
|
41
|
+
// ships are removed from it. Everything else is written file by file.
|
|
42
|
+
const DOCS_DIR = 'docs/foundation'
|
|
43
|
+
|
|
44
|
+
function syncedFiles() {
|
|
45
|
+
const conventions = path.join(PACKAGE_ROOT, 'conventions')
|
|
46
|
+
const files = new Map()
|
|
47
|
+
for (const doc of readdirSync(path.join(conventions, 'docs'))) {
|
|
48
|
+
files.set(`${DOCS_DIR}/${doc}`, read(path.join(conventions, 'docs', doc)))
|
|
49
|
+
}
|
|
50
|
+
const skill = read(path.join(conventions, 'agents/skills/new-entity/SKILL.md'))
|
|
51
|
+
files.set('.claude/skills/new-entity/SKILL.md', skill)
|
|
52
|
+
files.set('.codex/skills/new-entity/SKILL.md', skill)
|
|
53
|
+
files.set('.claude/agents/spec-tester.md', read(path.join(conventions, 'agents/spec-tester.md')))
|
|
54
|
+
files.set('.claude/hooks/deny-impl-read.mjs', read(path.join(conventions, 'agents/deny-impl-read.mjs')))
|
|
55
|
+
return files
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function agentsBlock() {
|
|
59
|
+
const rules = read(path.join(PACKAGE_ROOT, 'conventions/AGENTS.md')).trimEnd()
|
|
60
|
+
return `${BLOCK_HEADER}\n\n${rules}\n\n${BLOCK_END}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The app's AGENTS.md with the block replaced (or added, above the app's
|
|
64
|
+
// own content, when an older app has none yet).
|
|
65
|
+
function agentsMdWithBlock(current) {
|
|
66
|
+
const block = agentsBlock()
|
|
67
|
+
if (current === null) {
|
|
68
|
+
return `${block}\n\n## This app\n\nWhat this app is, and anything an agent needs that the rules above don't cover.\n`
|
|
69
|
+
}
|
|
70
|
+
const start = current.indexOf(BLOCK_START)
|
|
71
|
+
const end = current.indexOf(BLOCK_END)
|
|
72
|
+
if (start === -1 || end === -1) return `${block}\n\n${current.trimStart()}`
|
|
73
|
+
return current.slice(0, start) + block + current.slice(end + BLOCK_END.length)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sync({ check }) {
|
|
77
|
+
const problems = []
|
|
78
|
+
const write = (relative, content) => {
|
|
79
|
+
const target = path.join(APP_ROOT, relative)
|
|
80
|
+
const current = existsSync(target) ? read(target) : null
|
|
81
|
+
if (current === content) return
|
|
82
|
+
if (check) {
|
|
83
|
+
problems.push(`${current === null ? 'missing' : 'differs'}: ${relative}`)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
87
|
+
writeFileSync(target, content)
|
|
88
|
+
console.log(`sync: wrote ${relative}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const files = syncedFiles()
|
|
92
|
+
for (const [relative, content] of files) write(relative, content)
|
|
93
|
+
|
|
94
|
+
const docsDir = path.join(APP_ROOT, DOCS_DIR)
|
|
95
|
+
if (existsSync(docsDir)) {
|
|
96
|
+
for (const file of readdirSync(docsDir)) {
|
|
97
|
+
const relative = `${DOCS_DIR}/${file}`
|
|
98
|
+
if (files.has(relative)) continue
|
|
99
|
+
if (check) problems.push(`no longer shipped: ${relative}`)
|
|
100
|
+
else {
|
|
101
|
+
rmSync(path.join(docsDir, file), { recursive: true })
|
|
102
|
+
console.log(`sync: removed ${relative}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const agentsPath = path.join(APP_ROOT, 'AGENTS.md')
|
|
108
|
+
const agents = existsSync(agentsPath) ? read(agentsPath) : null
|
|
109
|
+
if (check && agents !== null && !agents.includes(BLOCK_START)) {
|
|
110
|
+
problems.push('AGENTS.md has no ui-foundation block')
|
|
111
|
+
} else {
|
|
112
|
+
write('AGENTS.md', agentsMdWithBlock(agents))
|
|
113
|
+
}
|
|
114
|
+
if (agents !== null && !agents.includes(BLOCK_START) && !check) {
|
|
115
|
+
console.log(
|
|
116
|
+
'sync: AGENTS.md had no foundation block; added one at the top. Delete any older copy of the foundation rules below it.',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Claude Code reads CLAUDE.md, which imports AGENTS.md. Created once;
|
|
121
|
+
// the app may add to it.
|
|
122
|
+
if (!existsSync(path.join(APP_ROOT, 'CLAUDE.md'))) {
|
|
123
|
+
if (check) problems.push('missing: CLAUDE.md')
|
|
124
|
+
else write('CLAUDE.md', '@AGENTS.md\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (check) {
|
|
128
|
+
if (problems.length > 0) {
|
|
129
|
+
fail(
|
|
130
|
+
[
|
|
131
|
+
`sync --check: the app's conventions don't match ${PACKAGE.name}@${PACKAGE.version}:`,
|
|
132
|
+
...problems.map((problem) => ` ${problem}`),
|
|
133
|
+
'Run `npx ui-foundation sync`. If a synced file was edited on purpose, that change belongs in the foundation — raise it there; the next sync overwrites it here.',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
console.log(`sync --check: conventions match ${PACKAGE.name}@${PACKAGE.version}`)
|
|
138
|
+
} else {
|
|
139
|
+
console.log(`sync: conventions match ${PACKAGE.name}@${PACKAGE.version}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------- check-contract
|
|
144
|
+
|
|
145
|
+
// Wording may differ between the app's spec and the foundation's; shapes may not.
|
|
146
|
+
const IGNORED_KEYS = new Set(['description', 'summary', 'example', 'examples', 'tags', 'x-optional'])
|
|
147
|
+
|
|
148
|
+
function normalise(value) {
|
|
149
|
+
if (Array.isArray(value)) return value.map(normalise)
|
|
150
|
+
if (value && typeof value === 'object') {
|
|
151
|
+
return Object.fromEntries(
|
|
152
|
+
Object.keys(value)
|
|
153
|
+
.filter((key) => !IGNORED_KEYS.has(key))
|
|
154
|
+
.sort()
|
|
155
|
+
.map((key) => [key, normalise(value[key])]),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return value
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const same = (a, b) => JSON.stringify(normalise(a)) === JSON.stringify(normalise(b))
|
|
162
|
+
|
|
163
|
+
function checkContract(specPath) {
|
|
164
|
+
const target = path.resolve(APP_ROOT, specPath)
|
|
165
|
+
if (!existsSync(target)) fail(`check-contract: no ${specPath} here`)
|
|
166
|
+
const app = loadYaml(read(target))
|
|
167
|
+
const foundation = loadYaml(read(path.join(PACKAGE_ROOT, 'openapi/foundation.yaml')))
|
|
168
|
+
const problems = []
|
|
169
|
+
|
|
170
|
+
for (const [route, item] of Object.entries(foundation.paths)) {
|
|
171
|
+
const appItem = app.paths?.[route]
|
|
172
|
+
if (appItem === undefined) {
|
|
173
|
+
if (!item['x-optional']) problems.push(`missing path ${route}`)
|
|
174
|
+
} else if (!same(appItem, item)) {
|
|
175
|
+
problems.push(`path ${route} differs`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const [section, entries] of Object.entries(foundation.components)) {
|
|
179
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
180
|
+
const appEntry = app.components?.[section]?.[name]
|
|
181
|
+
if (appEntry === undefined) problems.push(`missing components.${section}.${name}`)
|
|
182
|
+
else if (!same(appEntry, entry)) problems.push(`components.${section}.${name} differs`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (problems.length > 0) {
|
|
187
|
+
fail(
|
|
188
|
+
[
|
|
189
|
+
`check-contract: ${specPath} doesn't contain ${PACKAGE.name}'s part of the contract unchanged:`,
|
|
190
|
+
...problems.map((problem) => ` ${problem}`),
|
|
191
|
+
`The package's auth and error handling call exactly these shapes. Copy them from node_modules/${PACKAGE.name}/openapi/foundation.yaml (wording may differ, shapes may not).`,
|
|
192
|
+
].join('\n'),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
console.log(`check-contract: ${specPath} contains the foundation contract`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// -------------------------------------------------------------- check-deps
|
|
199
|
+
|
|
200
|
+
function checkDeps(allowlistPath) {
|
|
201
|
+
const allowlistFile = path.resolve(APP_ROOT, allowlistPath)
|
|
202
|
+
const packageFile = path.join(APP_ROOT, 'package.json')
|
|
203
|
+
if (!existsSync(allowlistFile)) fail(`check-deps: no ${allowlistPath} here`)
|
|
204
|
+
const allowlist = JSON.parse(read(allowlistFile))
|
|
205
|
+
const allowed = new Set([...allowlist.dependencies, ...allowlist.devDependencies])
|
|
206
|
+
const pkg = JSON.parse(read(packageFile))
|
|
207
|
+
const declared = new Set(
|
|
208
|
+
['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].flatMap((field) =>
|
|
209
|
+
Object.keys(pkg[field] ?? {}),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
const violations = [...declared].filter((name) => !allowed.has(name))
|
|
213
|
+
if (violations.length > 0) {
|
|
214
|
+
fail(
|
|
215
|
+
violations
|
|
216
|
+
.map(
|
|
217
|
+
(name) =>
|
|
218
|
+
`check-deps: "${name}" is not in ${allowlistPath}. If it is needed, write the case in docs/BLOCKERS.md and stop.`,
|
|
219
|
+
)
|
|
220
|
+
.join('\n'),
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
console.log(`check-deps: all ${declared.size} declared dependencies are allowlisted`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------- main
|
|
227
|
+
|
|
228
|
+
const [command, ...args] = process.argv.slice(2)
|
|
229
|
+
const option = (name, fallback) => {
|
|
230
|
+
const index = args.indexOf(name)
|
|
231
|
+
return index === -1 ? fallback : args[index + 1]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
switch (command) {
|
|
235
|
+
case 'sync':
|
|
236
|
+
sync({ check: args.includes('--check') })
|
|
237
|
+
break
|
|
238
|
+
case 'check-contract':
|
|
239
|
+
checkContract(args.find((arg) => !arg.startsWith('--')) ?? 'openapi.yaml')
|
|
240
|
+
break
|
|
241
|
+
case 'check-deps':
|
|
242
|
+
checkDeps(option('--allowlist', 'deps-allowlist.json'))
|
|
243
|
+
break
|
|
244
|
+
case '--version':
|
|
245
|
+
console.log(PACKAGE.version)
|
|
246
|
+
break
|
|
247
|
+
default:
|
|
248
|
+
fail('usage: ui-foundation <sync [--check] | check-contract [openapi.yaml] | check-deps [--allowlist file] | --version>')
|
|
249
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Built on @tristan2828/ui-foundation
|
|
2
|
+
|
|
3
|
+
## Stack
|
|
4
|
+
Vite + React 19 + TypeScript, Tailwind v4, shadcn/ui (Base UI primitives),
|
|
5
|
+
React Router v7, TanStack Query. The shared layer — primitives, the
|
|
6
|
+
`DataTable`/`EntityForm` composites, the app shell, auth, the gateway error
|
|
7
|
+
seam, design tokens, lint rules — is the npm package
|
|
8
|
+
`@tristan2828/ui-foundation`, updated by bumping its version. Everything in
|
|
9
|
+
`src/` is this app's own.
|
|
10
|
+
|
|
11
|
+
Contract-first: `openapi.yaml` is the source of truth and is owned by this
|
|
12
|
+
app. In development MSW serves the contract, so there may be no backend at
|
|
13
|
+
all.
|
|
14
|
+
|
|
15
|
+
Pinned tool versions live in `deps-allowlist.json` (`tools`). Never use
|
|
16
|
+
`@latest` for a tool command.
|
|
17
|
+
|
|
18
|
+
## Hard Rules
|
|
19
|
+
Each rule names the check that enforces it. If you hit the check, the check
|
|
20
|
+
is right. Do not disable, skip, or work around it.
|
|
21
|
+
|
|
22
|
+
- NEVER copy a file out of `node_modules/@tristan2828/ui-foundation` to
|
|
23
|
+
change it, and never patch it in place. Configure it through its props, or
|
|
24
|
+
raise the change for the foundation (see "Changing the Foundation"). A
|
|
25
|
+
copied composite is a file maintained twice with nothing saying so.
|
|
26
|
+
(Enforced: lint forbids importing the package's internals, and a shipped
|
|
27
|
+
primitive from anywhere but the package.)
|
|
28
|
+
- NEVER hand-roll a component that exists in shadcn. Import it from
|
|
29
|
+
`@tristan2828/ui-foundation/ui/<name>` if the package ships it; otherwise
|
|
30
|
+
run `npx shadcn@<tools.shadcn> add <name>` into `src/components/ui/`. If
|
|
31
|
+
that also writes a primitive the package ships (a dependency of the one
|
|
32
|
+
you asked for), delete the copy and import the package's. (Enforced: lint
|
|
33
|
+
rejects `@/components/ui/<name>` for any primitive the package ships.)
|
|
34
|
+
- NEVER add a dependency that is not in `deps-allowlist.json`. If you
|
|
35
|
+
believe one is needed, write the case in `docs/BLOCKERS.md` and stop.
|
|
36
|
+
(Enforced: `ui-foundation check-deps` in verify.)
|
|
37
|
+
- NEVER hand-write an API type. All types come from `src/api/schema.d.ts`,
|
|
38
|
+
generated from `openapi.yaml`. If a type is missing, run `npm run gen:api`.
|
|
39
|
+
(Enforced: codegen diff in verify.)
|
|
40
|
+
- NEVER change the foundation's part of `openapi.yaml` — the `/auth/*` paths
|
|
41
|
+
and the error envelopes. The package's code calls exactly those shapes.
|
|
42
|
+
(Enforced: `ui-foundation check-contract` in verify.)
|
|
43
|
+
- NEVER use a raw hex value or a Tailwind palette color (`bg-blue-500`).
|
|
44
|
+
Semantic tokens only: `bg-primary`, `text-muted-foreground`. (Enforced:
|
|
45
|
+
the foundation's ESLint token rule; axe contrast in light and dark mode,
|
|
46
|
+
`e2e/a11y.spec.ts`.)
|
|
47
|
+
- NEVER fetch in `useEffect`. All server state goes through TanStack Query.
|
|
48
|
+
(Enforced: eslint-plugin-query + the no-bare-fetch rule.)
|
|
49
|
+
- NEVER read auth state outside `useAuth()`. The package exports nothing
|
|
50
|
+
else that knows how auth works. (Enforced: the package's exports.)
|
|
51
|
+
- NEVER import `@tristan2828/ui-foundation/gateway` outside
|
|
52
|
+
`src/api/gateway/`. Components and hooks call a gateway module through
|
|
53
|
+
TanStack Query. (Enforced: no-restricted-imports.)
|
|
54
|
+
- NEVER let a backend-shaped response reach a component. Paginated data is
|
|
55
|
+
`Page<T>`. Failures are `AppError`. Queries are `QuerySpec`. The gateway
|
|
56
|
+
translates; nothing above it knows the wire format. (Enforced: gateway
|
|
57
|
+
return types are the contracts; tsc.)
|
|
58
|
+
- NEVER write a gateway test by reading the gateway. Tests come from
|
|
59
|
+
`openapi.yaml`. (Enforced in Claude Code: the spec-tester subagent cannot
|
|
60
|
+
read `src/api/gateway/`. In other tools: write them before the gateway
|
|
61
|
+
exists — `docs/foundation/add-an-entity.md` step 3.)
|
|
62
|
+
- NEVER name a file in PascalCase or snake_case. Kebab-case everywhere —
|
|
63
|
+
routes, components, hooks, tests (`widget-form.tsx`, not
|
|
64
|
+
`WidgetForm.tsx`). (Enforced: eslint-plugin-check-file.)
|
|
65
|
+
- NEVER edit a synced file: this block, `docs/foundation/`, and the agent
|
|
66
|
+
files under `.claude/` and `.codex/` that `ui-foundation sync` writes.
|
|
67
|
+
(Enforced: `ui-foundation sync --check` in verify.)
|
|
68
|
+
|
|
69
|
+
## Required States
|
|
70
|
+
Every data view handles: loading, empty, error, and success. Use
|
|
71
|
+
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does.
|
|
72
|
+
Every screen has one Playwright test per state, forced via MSW overrides
|
|
73
|
+
(`@tristan2828/ui-foundation/testing`).
|
|
74
|
+
|
|
75
|
+
## Design Language
|
|
76
|
+
Colour, tone, badges, cell patterns and density follow
|
|
77
|
+
`docs/foundation/design-language.md` and `docs/foundation/cell-patterns.md`.
|
|
78
|
+
A pattern the foundation lacks is worth raising there: the design language
|
|
79
|
+
grows in the package so every app gets it, with axe contrast coverage in
|
|
80
|
+
both themes.
|
|
81
|
+
|
|
82
|
+
## Scope and Stopping
|
|
83
|
+
- Do the task you were given and nothing beyond it. A task is done when
|
|
84
|
+
`npm run verify` passes — not when it feels complete.
|
|
85
|
+
- If verify cannot pass without breaking a Hard Rule, an instruction
|
|
86
|
+
conflicts with current library docs, or the change would exceed the
|
|
87
|
+
task's scope: write `docs/BLOCKERS.md` (what, why, what you tried) and
|
|
88
|
+
stop. Do not guess, do not widen scope.
|
|
89
|
+
- Ideas that are out of scope go in the app's backlog, not in code.
|
|
90
|
+
|
|
91
|
+
## Correct Patterns
|
|
92
|
+
```tsx
|
|
93
|
+
// Error handling — AppError, never a raw response
|
|
94
|
+
const { data, error } = useWidgetsQuery(query)
|
|
95
|
+
if (error) return <ErrorState error={error} /> // error is AppError
|
|
96
|
+
|
|
97
|
+
// Validation — server field errors bind straight to the form
|
|
98
|
+
form.setError(field, { message: err.fieldErrors[field][0] })
|
|
99
|
+
|
|
100
|
+
// Color — semantic tokens only
|
|
101
|
+
<div className="bg-card text-card-foreground border-border" />
|
|
102
|
+
|
|
103
|
+
// Forms — FieldGroup wraps every field, never a bare <label>+<input> stack
|
|
104
|
+
<FieldGroup>
|
|
105
|
+
<Field>
|
|
106
|
+
<FieldLabel htmlFor="name">Name</FieldLabel>
|
|
107
|
+
<Input id="name" {...register('name')} />
|
|
108
|
+
</Field>
|
|
109
|
+
</FieldGroup>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Tooling
|
|
113
|
+
Look up shadcn component APIs with `npx shadcn@<tools.shadcn> view <name>`
|
|
114
|
+
before guessing props — component APIs move between releases and training
|
|
115
|
+
data lags them. The package's own props are in its type declarations
|
|
116
|
+
(`node_modules/@tristan2828/ui-foundation/dist/**/*.d.ts`).
|
|
117
|
+
|
|
118
|
+
## Before You Finish
|
|
119
|
+
Run `npm run verify`. It must pass. Do not report a task complete on a
|
|
120
|
+
failing gate. The developer reviews results — the running app, a
|
|
121
|
+
screenshot, an options page — not every line, so the gates are the safety
|
|
122
|
+
net.
|
|
123
|
+
|
|
124
|
+
## Adding an Entity
|
|
125
|
+
Follow `docs/foundation/add-an-entity.md` (in Claude Code, `/new-entity
|
|
126
|
+
<Name>` is a shortcut to it). It builds from the entity's plan,
|
|
127
|
+
`docs/entities/<entity>.md`; if there's no plan, work one out with the
|
|
128
|
+
developer first. Never guess the fields.
|
|
129
|
+
|
|
130
|
+
## Upgrading the Foundation
|
|
131
|
+
```bash
|
|
132
|
+
npm install @tristan2828/ui-foundation@<version>
|
|
133
|
+
npx ui-foundation sync
|
|
134
|
+
npm run verify
|
|
135
|
+
```
|
|
136
|
+
Read the release notes between your version and the new one first
|
|
137
|
+
(https://github.com/Tristan2828/ui-foundation/releases). `sync` rewrites
|
|
138
|
+
this block, `docs/foundation/` and the agent files; commit what it writes.
|
|
139
|
+
|
|
140
|
+
## Changing the Foundation
|
|
141
|
+
The package is shared by every app. When this app needs something it
|
|
142
|
+
doesn't do:
|
|
143
|
+
1. Prefer a prop or slot the package already has.
|
|
144
|
+
2. If the change is generic — another app would want it — raise it in the
|
|
145
|
+
foundation repo (an issue, or a PR against `packages/ui-foundation`), and
|
|
146
|
+
build this app's screen once the release ships. That's the path a fix
|
|
147
|
+
takes to reach every app.
|
|
148
|
+
3. If it is truly this app's alone, build it in `src/` on top of the
|
|
149
|
+
package's exports — a new component, not a modified copy of one.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook for the spec-tester subagent: refuses any Read of the
|
|
3
|
+
// app's gateway implementation, or the package's. This is what makes "tests written
|
|
4
|
+
// from openapi.yaml, not from the implementation" an enforced guarantee
|
|
5
|
+
// instead of a prompt instruction. Must be declared in spec-tester.md's own
|
|
6
|
+
// frontmatter — subagents do not inherit hooks from .claude/settings.json.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const DENIED_PATTERNS = [
|
|
10
|
+
/src[\\/]api[\\/]gateway[\\/]/,
|
|
11
|
+
/ui-foundation[\\/]dist[\\/](api[\\/](gateway|transport)|gateway\.js)/,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
let input = '';
|
|
15
|
+
try {
|
|
16
|
+
input = readFileSync(0, 'utf8');
|
|
17
|
+
} catch {
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let payload;
|
|
22
|
+
try {
|
|
23
|
+
payload = JSON.parse(input);
|
|
24
|
+
} catch {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const filePath = payload?.tool_input?.file_path ?? '';
|
|
29
|
+
if (DENIED_PATTERNS.some((re) => re.test(filePath))) {
|
|
30
|
+
console.error(
|
|
31
|
+
`spec-tester may not read implementation files. Denied: ${filePath}. Write tests from openapi.yaml and the UI contracts only.`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.exit(0);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: new-entity
|
|
3
|
+
description: This skill should be used when the user asks to "add an entity", "add a new entity", "scaffold a CRUD screen", "add a resource screen", or names a new domain object (e.g. "add Invoice") that needs a spec, gateway, mocks, table, and form built following this repo's widgets reference pattern.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Add a full CRUD entity end to end. The entity name is this skill's
|
|
8
|
+
argument — if none was given, ask for one before proceeding.
|
|
9
|
+
|
|
10
|
+
Read `docs/foundation/add-an-entity.md` in full, then follow it exactly, in order —
|
|
11
|
+
starting with its "Before anything: the entity plan" section. Never guess
|
|
12
|
+
what the entity is: every field comes from `docs/entities/<entity>.md`,
|
|
13
|
+
and if that file doesn't exist you plan it with the developer and wait for
|
|
14
|
+
their go-ahead before building anything. That file is the only copy of the
|
|
15
|
+
playbook; this skill deliberately does not repeat it, so the two can't
|
|
16
|
+
drift apart. Do not add anything beyond what it lists — note extra ideas
|
|
17
|
+
in the app's backlog.
|
|
18
|
+
|
|
19
|
+
This skill is a shortcut, not a gate: a developer asking in plain words
|
|
20
|
+
("add a Game entity following docs/foundation/add-an-entity.md") gets exactly the
|
|
21
|
+
same workflow, in Claude Code or any other tool. The plan requirement is
|
|
22
|
+
what keeps an agent from building on its own initiative.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec-tester
|
|
3
|
+
description: Writes gateway translation tests from openapi.yaml and contracts.ts only. Used in Phase 2 and by the new-entity playbook — never invoked to test code it can read.
|
|
4
|
+
tools: Read, Write, Bash(vitest *)
|
|
5
|
+
hooks:
|
|
6
|
+
PreToolUse:
|
|
7
|
+
- matcher: "Read"
|
|
8
|
+
hooks:
|
|
9
|
+
- type: command
|
|
10
|
+
command: "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/deny-impl-read.mjs"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Write gateway tests from `openapi.yaml` and the UI contracts `Page<T>`,
|
|
14
|
+
`AppError` and `QuerySpec` (their type declarations are in
|
|
15
|
+
`node_modules/@tristan2828/ui-foundation/dist/api/contracts.d.ts`). You have
|
|
16
|
+
not seen the implementation in `src/api/gateway/` and must not read it — a
|
|
17
|
+
`PreToolUse` hook will refuse those reads.
|
|
18
|
+
|
|
19
|
+
Assert, from the spec alone:
|
|
20
|
+
|
|
21
|
+
- A 422 response becomes `AppError` with `kind: 'validation'` and populated
|
|
22
|
+
`fieldErrors`.
|
|
23
|
+
- Paginated list responses normalize to `Page<T>` (`items`, `total`,
|
|
24
|
+
`page`, `pageSize`).
|
|
25
|
+
- A network failure (no response reached) becomes `kind: 'network'`, never
|
|
26
|
+
a false success.
|
|
27
|
+
- Other error shapes (404, 401, 500) map to `notfound`, `auth`, `server`
|
|
28
|
+
respectively.
|
|
29
|
+
|
|
30
|
+
Write the tests to fail first — the gateway that makes them pass does not
|
|
31
|
+
exist yet the first time you run in Phase 2. That is correct.
|
|
32
|
+
|
|
33
|
+
Do not attempt to work around a denied read. If you are refused a file you
|
|
34
|
+
believe you need, stop and report which file and why in your final
|
|
35
|
+
response — do not guess at the gateway's behavior instead.
|