@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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/bin/ui-foundation.mjs +249 -0
  4. package/conventions/AGENTS.md +149 -0
  5. package/conventions/agents/deny-impl-read.mjs +36 -0
  6. package/conventions/agents/skills/new-entity/SKILL.md +22 -0
  7. package/conventions/agents/spec-tester.md +35 -0
  8. package/conventions/docs/add-an-entity.md +204 -0
  9. package/conventions/docs/cell-patterns.md +204 -0
  10. package/conventions/docs/column-options.md +122 -0
  11. package/conventions/docs/design-language.md +116 -0
  12. package/conventions/docs/entity-plan-template.md +67 -0
  13. package/dist/api/contracts.d.ts +20 -0
  14. package/dist/api/contracts.js +4 -0
  15. package/dist/api/gateway/auth.d.ts +9 -0
  16. package/dist/api/gateway/auth.js +37 -0
  17. package/dist/api/gateway/errors.d.ts +5 -0
  18. package/dist/api/gateway/errors.js +36 -0
  19. package/dist/api/query-client.d.ts +2 -0
  20. package/dist/api/query-client.js +19 -0
  21. package/dist/api/schema.d.ts +246 -0
  22. package/dist/api/schema.js +5 -0
  23. package/dist/api/transport/index.d.ts +5 -0
  24. package/dist/api/transport/index.js +14 -0
  25. package/dist/auth/auth-context.d.ts +19 -0
  26. package/dist/auth/auth-context.js +12 -0
  27. package/dist/auth/auth-provider.d.ts +4 -0
  28. package/dist/auth/auth-provider.js +101 -0
  29. package/dist/auth/use-auth.d.ts +2 -0
  30. package/dist/auth/use-auth.js +9 -0
  31. package/dist/components/app/app-shell.d.ts +28 -0
  32. package/dist/components/app/app-shell.js +64 -0
  33. package/dist/components/app/data-table.d.ts +56 -0
  34. package/dist/components/app/data-table.js +167 -0
  35. package/dist/components/app/entity-form.d.ts +14 -0
  36. package/dist/components/app/entity-form.js +8 -0
  37. package/dist/components/app/error-state.d.ts +6 -0
  38. package/dist/components/app/error-state.js +20 -0
  39. package/dist/components/app/foundation-providers.d.ts +9 -0
  40. package/dist/components/app/foundation-providers.js +15 -0
  41. package/dist/components/app/mock-mode-banner.d.ts +3 -0
  42. package/dist/components/app/mock-mode-banner.js +16 -0
  43. package/dist/components/app/multi-choice.d.ts +14 -0
  44. package/dist/components/app/multi-choice.js +13 -0
  45. package/dist/components/app/password-input.d.ts +3 -0
  46. package/dist/components/app/password-input.js +30 -0
  47. package/dist/components/app/route-error-boundary.d.ts +1 -0
  48. package/dist/components/app/route-error-boundary.js +13 -0
  49. package/dist/components/theme-provider.d.ts +2 -0
  50. package/dist/components/theme-provider.js +8 -0
  51. package/dist/components/ui/badge.d.ts +7 -0
  52. package/dist/components/ui/badge.js +50 -0
  53. package/dist/components/ui/button.d.ts +8 -0
  54. package/dist/components/ui/button.js +34 -0
  55. package/dist/components/ui/calendar.d.ts +10 -0
  56. package/dist/components/ui/calendar.js +76 -0
  57. package/dist/components/ui/card.d.ts +11 -0
  58. package/dist/components/ui/card.js +25 -0
  59. package/dist/components/ui/combobox.d.ts +24 -0
  60. package/dist/components/ui/combobox.js +62 -0
  61. package/dist/components/ui/dialog.d.ts +17 -0
  62. package/dist/components/ui/dialog.js +37 -0
  63. package/dist/components/ui/empty.d.ts +11 -0
  64. package/dist/components/ui/empty.js +33 -0
  65. package/dist/components/ui/field.d.ts +24 -0
  66. package/dist/components/ui/field.js +67 -0
  67. package/dist/components/ui/input-group.d.ts +18 -0
  68. package/dist/components/ui/input-group.js +57 -0
  69. package/dist/components/ui/input.d.ts +3 -0
  70. package/dist/components/ui/input.js +8 -0
  71. package/dist/components/ui/label.d.ts +3 -0
  72. package/dist/components/ui/label.js +7 -0
  73. package/dist/components/ui/popover.d.ts +9 -0
  74. package/dist/components/ui/popover.js +23 -0
  75. package/dist/components/ui/select.d.ts +15 -0
  76. package/dist/components/ui/select.js +35 -0
  77. package/dist/components/ui/separator.d.ts +3 -0
  78. package/dist/components/ui/separator.js +8 -0
  79. package/dist/components/ui/sheet.d.ts +14 -0
  80. package/dist/components/ui/sheet.js +38 -0
  81. package/dist/components/ui/sidebar.d.ts +63 -0
  82. package/dist/components/ui/sidebar.js +255 -0
  83. package/dist/components/ui/skeleton.d.ts +2 -0
  84. package/dist/components/ui/skeleton.js +6 -0
  85. package/dist/components/ui/sonner.d.ts +3 -0
  86. package/dist/components/ui/sonner.js +24 -0
  87. package/dist/components/ui/spinner.d.ts +2 -0
  88. package/dist/components/ui/spinner.js +7 -0
  89. package/dist/components/ui/table.d.ts +13 -0
  90. package/dist/components/ui/table.js +45 -0
  91. package/dist/components/ui/textarea.d.ts +3 -0
  92. package/dist/components/ui/textarea.js +7 -0
  93. package/dist/components/ui/tooltip.d.ts +6 -0
  94. package/dist/components/ui/tooltip.js +16 -0
  95. package/dist/gateway.d.ts +2 -0
  96. package/dist/gateway.js +5 -0
  97. package/dist/hooks/table-url-changes.d.ts +14 -0
  98. package/dist/hooks/table-url-changes.js +32 -0
  99. package/dist/hooks/use-debounced-value.d.ts +1 -0
  100. package/dist/hooks/use-debounced-value.js +12 -0
  101. package/dist/hooks/use-mobile.d.ts +1 -0
  102. package/dist/hooks/use-mobile.js +14 -0
  103. package/dist/hooks/use-table-url-state.d.ts +14 -0
  104. package/dist/hooks/use-table-url-state.js +43 -0
  105. package/dist/index.d.ts +22 -0
  106. package/dist/index.js +28 -0
  107. package/dist/lib/utils.d.ts +1 -0
  108. package/dist/lib/utils.js +1 -0
  109. package/dist/mocks/auth.d.ts +9 -0
  110. package/dist/mocks/auth.js +79 -0
  111. package/dist/mocks/e2e-hooks.d.ts +3 -0
  112. package/dist/mocks/e2e-hooks.js +28 -0
  113. package/dist/mocks/index.d.ts +3 -0
  114. package/dist/mocks/index.js +5 -0
  115. package/dist/mocks/override.d.ts +23 -0
  116. package/dist/mocks/override.js +1 -0
  117. package/dist/routes/login-schema.d.ts +7 -0
  118. package/dist/routes/login-schema.js +13 -0
  119. package/dist/routes/login.d.ts +8 -0
  120. package/dist/routes/login.js +58 -0
  121. package/dist/routes/register-schema.d.ts +9 -0
  122. package/dist/routes/register-schema.js +23 -0
  123. package/dist/routes/register.d.ts +1 -0
  124. package/dist/routes/register.js +58 -0
  125. package/dist/routes/return-path.d.ts +1 -0
  126. package/dist/routes/return-path.js +9 -0
  127. package/dist/testing/index.d.ts +40 -0
  128. package/dist/testing/index.js +103 -0
  129. package/eslint/index.d.ts +11 -0
  130. package/eslint/index.js +179 -0
  131. package/openapi/foundation.yaml +156 -0
  132. package/package.json +158 -0
  133. package/styles/index.css +88 -0
  134. 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.