@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `@tristan2828/ui-foundation/testing` — Playwright helpers and suites for
|
|
2
|
+
// what every app built on the foundation must keep true. An app's spec file
|
|
3
|
+
// calls a suite with its own routes, instead of carrying a copy of the spec
|
|
4
|
+
// to edit (each of these was forked by the first real app for exactly that).
|
|
5
|
+
//
|
|
6
|
+
// Runs in Node, inside Playwright: @playwright/test and @axe-core/playwright
|
|
7
|
+
// are optional peer dependencies, needed only by apps that import this.
|
|
8
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
9
|
+
import { expect, test } from '@playwright/test';
|
|
10
|
+
/**
|
|
11
|
+
* Forces a response for the page's *first* load: set before navigation, so
|
|
12
|
+
* it wins the race against the app's own first fetch. For states forced
|
|
13
|
+
* after load, use `waitForMswReady` and then `window.__msw.worker.use(...)`.
|
|
14
|
+
*/
|
|
15
|
+
export async function forceMswOverride(page, override) {
|
|
16
|
+
await page.addInitScript((spec) => {
|
|
17
|
+
window.__E2E_MSW_OVERRIDE__ = spec;
|
|
18
|
+
}, override);
|
|
19
|
+
}
|
|
20
|
+
/** `window.__msw` is set only once the app's mocking has started — page.goto's load event doesn't wait for it. */
|
|
21
|
+
export async function waitForMswReady(page) {
|
|
22
|
+
await page.waitForFunction(() => window.__msw !== undefined);
|
|
23
|
+
}
|
|
24
|
+
/** Starts the page logged out: GET /auth/me answers 401 on first load. */
|
|
25
|
+
export function forceLoggedOut(page) {
|
|
26
|
+
return forceMswOverride(page, {
|
|
27
|
+
method: 'get',
|
|
28
|
+
path: '*/api/auth/me',
|
|
29
|
+
status: 401,
|
|
30
|
+
body: { detail: 'Not authenticated' },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function expectNoAxeViolations(page) {
|
|
34
|
+
const results = await new AxeBuilder({ page }).analyze();
|
|
35
|
+
expect(results.violations).toEqual([]);
|
|
36
|
+
}
|
|
37
|
+
// The app's own pages, read from the Primary nav. Below the sidebar's
|
|
38
|
+
// mobile breakpoint the nav renders into an off-canvas Sheet *portaled to
|
|
39
|
+
// document.body*, outside the <nav aria-label="Primary"> landmark, so the
|
|
40
|
+
// links are read from the sheet there. Keyed off the `isMobile` fixture,
|
|
41
|
+
// deliberately not a link count: count() doesn't retry, so on a slow first
|
|
42
|
+
// paint it read 0 on desktop too, clicked the trigger (which collapses the
|
|
43
|
+
// desktop sidebar) and then waited for a sheet that never came.
|
|
44
|
+
async function navRoutes(page, isMobile) {
|
|
45
|
+
await page.goto('/');
|
|
46
|
+
const nav = isMobile
|
|
47
|
+
? (await page.getByRole('button', { name: 'Toggle Sidebar' }).first().click(), page.getByRole('dialog'))
|
|
48
|
+
: page.getByRole('navigation', { name: 'Primary' });
|
|
49
|
+
await expect(nav.getByRole('link').first()).toBeVisible();
|
|
50
|
+
const hrefs = await nav
|
|
51
|
+
.getByRole('link')
|
|
52
|
+
.evaluateAll((links) => links.map((link) => link.href));
|
|
53
|
+
// Only this app's pages: a sidebarExtra group may link to other sites.
|
|
54
|
+
const origin = new URL(page.url()).origin;
|
|
55
|
+
return [...new Set(hrefs.filter((href) => new URL(href).origin === origin).map((href) => new URL(href).pathname))];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Accessibility of the real screens in light and dark mode: every page in
|
|
59
|
+
* the Primary nav (discovered, so a new entity's table is covered with no
|
|
60
|
+
* edit), the given form routes, and the logged-out screens. axe's
|
|
61
|
+
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
62
|
+
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
63
|
+
* emulateMedia switches it.
|
|
64
|
+
*/
|
|
65
|
+
export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
|
|
66
|
+
for (const colorScheme of ['light', 'dark']) {
|
|
67
|
+
test.describe(`accessibility (${colorScheme})`, () => {
|
|
68
|
+
test.beforeEach(async ({ page }) => {
|
|
69
|
+
await page.emulateMedia({ colorScheme });
|
|
70
|
+
});
|
|
71
|
+
test('every nav page has zero axe violations', async ({ page, isMobile }) => {
|
|
72
|
+
for (const route of await navRoutes(page, isMobile)) {
|
|
73
|
+
await page.goto(route);
|
|
74
|
+
await expect(page.locator('html')).toHaveClass(colorScheme);
|
|
75
|
+
// Wait for data, not the loading skeleton, so the real UI is checked.
|
|
76
|
+
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
77
|
+
await test.step(route, () => expectNoAxeViolations(page));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
for (const route of [...formRoutes, ...loggedOutRoutes]) {
|
|
81
|
+
test(`${route} has zero axe violations`, async ({ page }) => {
|
|
82
|
+
await page.goto(route);
|
|
83
|
+
await expect(page.locator('form')).toBeVisible();
|
|
84
|
+
await expectNoAxeViolations(page);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Mock mode is announced on every route, logged-out ones included — /login
|
|
92
|
+
* is where a mock session passing for a real one first cost someone time.
|
|
93
|
+
* The default Playwright run is MSW-backed, so the banner must be there.
|
|
94
|
+
*/
|
|
95
|
+
export function defineMockModeBannerSuite({ routes }) {
|
|
96
|
+
test('mock mode is announced on every route, including logged-out ones', async ({ page }) => {
|
|
97
|
+
const banner = page.getByRole('status').filter({ hasText: 'Mock data' });
|
|
98
|
+
for (const route of routes) {
|
|
99
|
+
await page.goto(route);
|
|
100
|
+
await test.step(route, () => expect(banner).toBeVisible());
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Linter } from 'eslint'
|
|
2
|
+
|
|
3
|
+
export declare const PACKAGE_NAME: string
|
|
4
|
+
export declare const PRIMITIVES: string[]
|
|
5
|
+
export declare const TOKEN_SYNTAX_RULES: { selector: string; message: string }[]
|
|
6
|
+
export declare const NO_BARE_FETCH: { selector: string; message: string }
|
|
7
|
+
export declare function baseConfig(): Linter.Config
|
|
8
|
+
export declare function kebabCaseFilenames(files: string[], ignores?: string[]): Linter.Config
|
|
9
|
+
|
|
10
|
+
/** The lint config for an app built on the foundation. */
|
|
11
|
+
export default function uiFoundation(options?: { ignores?: string[] }): Linter.Config[]
|
package/eslint/index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `@tristan2828/ui-foundation/eslint` — the lint half of the Hard Rules
|
|
2
|
+
// (AGENTS.md), shared so every app enforces the same ones and gets new ones
|
|
3
|
+
// by upgrading the package. An app's whole eslint.config.js:
|
|
4
|
+
//
|
|
5
|
+
// import uiFoundation from '@tristan2828/ui-foundation/eslint'
|
|
6
|
+
// export default uiFoundation()
|
|
7
|
+
//
|
|
8
|
+
// Plain JavaScript on purpose: ESLint loads this before any TypeScript
|
|
9
|
+
// tooling runs. The plugins are the app's own devDependencies (optional
|
|
10
|
+
// peer dependencies of the package).
|
|
11
|
+
import { readdirSync } from 'node:fs'
|
|
12
|
+
import js from '@eslint/js'
|
|
13
|
+
import tanstackQuery from '@tanstack/eslint-plugin-query'
|
|
14
|
+
import checkFile from 'eslint-plugin-check-file'
|
|
15
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
16
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
17
|
+
import globals from 'globals'
|
|
18
|
+
import tseslint from 'typescript-eslint'
|
|
19
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
20
|
+
|
|
21
|
+
export const PACKAGE_NAME = '@tristan2828/ui-foundation'
|
|
22
|
+
|
|
23
|
+
// The primitives the package ships, read from what was built (dist/) or,
|
|
24
|
+
// inside this repo before a build, from source. An app importing one of
|
|
25
|
+
// these from its own src/components/ui/ has a second, unpatched copy —
|
|
26
|
+
// usually because `shadcn add` pulled it in as another primitive's
|
|
27
|
+
// dependency.
|
|
28
|
+
function shippedPrimitives() {
|
|
29
|
+
for (const dir of ['../dist/components/ui', '../src/components/ui']) {
|
|
30
|
+
try {
|
|
31
|
+
return readdirSync(new URL(dir, import.meta.url))
|
|
32
|
+
.filter((file) => /^[a-z0-9-]+\.(js|tsx)$/.test(file) && !file.includes('.stories.'))
|
|
33
|
+
.map((file) => file.replace(/\.(js|tsx)$/, ''))
|
|
34
|
+
} catch {
|
|
35
|
+
// Not there — try the next location.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
export const PRIMITIVES = shippedPrimitives()
|
|
41
|
+
|
|
42
|
+
// Raw Tailwind palette classes bypass the semantic token layer — this is
|
|
43
|
+
// what makes "dark mode looks right" a lint error instead of a visual
|
|
44
|
+
// surprise.
|
|
45
|
+
const TAILWIND_PALETTE_CLASS =
|
|
46
|
+
'/\\b(bg|text|border|ring|fill|stroke)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d{2,3}\\b/'
|
|
47
|
+
const HEX_COLOR = '/#[0-9a-f]{3,8}/i'
|
|
48
|
+
|
|
49
|
+
export const TOKEN_SYNTAX_RULES = [
|
|
50
|
+
{
|
|
51
|
+
selector: `JSXAttribute[name.name='className'] Literal[value=${TAILWIND_PALETTE_CLASS}]`,
|
|
52
|
+
message:
|
|
53
|
+
'Use semantic tokens (bg-primary, text-muted-foreground, ...), not raw Tailwind palette classes. See the design-language doc.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
selector: `JSXAttribute[name.name='style'] Property Literal[value=${HEX_COLOR}]`,
|
|
57
|
+
message: 'No raw hex colors in style props. Use a semantic token.',
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
export const NO_BARE_FETCH = {
|
|
62
|
+
selector: "CallExpression[callee.name='fetch']",
|
|
63
|
+
message:
|
|
64
|
+
'Do not call fetch directly. All server state goes through TanStack Query, which calls src/api/gateway/, which builds on the foundation gateway entry point.',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The rules every TypeScript file gets, before any boundary rule.
|
|
68
|
+
export function baseConfig() {
|
|
69
|
+
return {
|
|
70
|
+
files: ['**/*.{ts,tsx}'],
|
|
71
|
+
extends: [
|
|
72
|
+
js.configs.recommended,
|
|
73
|
+
tseslint.configs.recommended,
|
|
74
|
+
reactHooks.configs.flat.recommended,
|
|
75
|
+
reactRefresh.configs.vite,
|
|
76
|
+
],
|
|
77
|
+
languageOptions: {
|
|
78
|
+
ecmaVersion: 2020,
|
|
79
|
+
globals: globals.browser,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function kebabCaseFilenames(files, ignores = []) {
|
|
85
|
+
return {
|
|
86
|
+
files,
|
|
87
|
+
ignores,
|
|
88
|
+
plugins: { 'check-file': checkFile },
|
|
89
|
+
rules: {
|
|
90
|
+
'check-file/filename-naming-convention': [
|
|
91
|
+
'error',
|
|
92
|
+
{ '**/*.{ts,tsx}': 'KEBAB_CASE' },
|
|
93
|
+
{ ignoreMiddleExtensions: true },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const GATEWAY_ONLY = {
|
|
100
|
+
group: [`${PACKAGE_NAME}/gateway`],
|
|
101
|
+
message:
|
|
102
|
+
'Only src/api/gateway/ may import the foundation gateway entry point. Components and hooks call a gateway module through TanStack Query.',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PACKAGE_INTERNALS = {
|
|
106
|
+
group: [`${PACKAGE_NAME}/dist/*`, `${PACKAGE_NAME}/src/*`],
|
|
107
|
+
message: `Import from ${PACKAGE_NAME}'s public entry points only. Need something it doesn't export? Raise it for the foundation instead of reaching in.`,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appImportPatterns({ allowGateway }) {
|
|
111
|
+
const patterns = [PACKAGE_INTERNALS]
|
|
112
|
+
if (!allowGateway) patterns.push(GATEWAY_ONLY)
|
|
113
|
+
if (PRIMITIVES.length > 0) {
|
|
114
|
+
patterns.push({
|
|
115
|
+
regex: `^@/components/ui/(${PRIMITIVES.join('|')})$`,
|
|
116
|
+
message: `The foundation ships this primitive: import it from ${PACKAGE_NAME}/ui/<name> and delete the app's copy (shadcn add re-creates one when another primitive depends on it).`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
return patterns
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The lint config for an app built on the foundation.
|
|
124
|
+
* @param {{ ignores?: string[] }} [options] extra paths to ignore, on top of build output and generated files
|
|
125
|
+
*/
|
|
126
|
+
export default function uiFoundation(options = {}) {
|
|
127
|
+
return defineConfig([
|
|
128
|
+
// .claude/worktrees: a session running in a git worktree nested under
|
|
129
|
+
// the project root is normal in an agent-built repo; without this, one
|
|
130
|
+
// session's in-progress files fail another session's verify.
|
|
131
|
+
globalIgnores([
|
|
132
|
+
'dist',
|
|
133
|
+
'storybook-static',
|
|
134
|
+
'test-results',
|
|
135
|
+
'playwright-report',
|
|
136
|
+
'public/mockServiceWorker.js',
|
|
137
|
+
'.claude/worktrees',
|
|
138
|
+
'backend',
|
|
139
|
+
...(options.ignores ?? []),
|
|
140
|
+
]),
|
|
141
|
+
{
|
|
142
|
+
...baseConfig(),
|
|
143
|
+
rules: {
|
|
144
|
+
'no-restricted-syntax': ['error', ...TOKEN_SYNTAX_RULES, NO_BARE_FETCH],
|
|
145
|
+
'no-restricted-imports': ['error', { patterns: appImportPatterns({ allowGateway: false }) }],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
// App-added shadcn primitives: CLI-installed, and shadcn's convention
|
|
150
|
+
// exports a component alongside its `*Variants` helper from one file
|
|
151
|
+
// — a false positive for fast-refresh.
|
|
152
|
+
files: ['src/components/ui/**/*.{ts,tsx}'],
|
|
153
|
+
rules: { 'react-refresh/only-export-components': 'off' },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
// The gateway is the one layer allowed onto the network.
|
|
157
|
+
files: ['src/api/gateway/**/*.{ts,tsx}'],
|
|
158
|
+
rules: {
|
|
159
|
+
'no-restricted-imports': ['error', { patterns: appImportPatterns({ allowGateway: true }) }],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
// Kebab-case for everything hand-authored. App.tsx and main.tsx are
|
|
163
|
+
// Vite's own scaffold names; src/components/ui/ is shadcn's.
|
|
164
|
+
kebabCaseFilenames(['src/**/*.{ts,tsx}', 'e2e/**/*.ts', 'tests/**/*.ts'], [
|
|
165
|
+
'src/App.tsx',
|
|
166
|
+
'src/main.tsx',
|
|
167
|
+
'src/components/ui/**',
|
|
168
|
+
]),
|
|
169
|
+
{
|
|
170
|
+
// Tests exercise the HTTP boundary directly (gateway tests stub
|
|
171
|
+
// fetch, conformance tests call it against MSW, e2e specs call it in
|
|
172
|
+
// the page) — the no-bare-fetch rule keeps app code on the gateway,
|
|
173
|
+
// not tests off the primitive they're testing.
|
|
174
|
+
files: ['tests/**/*.{ts,tsx}', 'e2e/**/*.{ts,tsx}'],
|
|
175
|
+
rules: { 'no-restricted-syntax': ['error', ...TOKEN_SYNTAX_RULES] },
|
|
176
|
+
},
|
|
177
|
+
...tanstackQuery.configs['flat/recommended'],
|
|
178
|
+
])
|
|
179
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
openapi: 3.1.0
|
|
2
|
+
info:
|
|
3
|
+
title: UI Foundation — the contract every app shares
|
|
4
|
+
version: "3.0.0"
|
|
5
|
+
description: >
|
|
6
|
+
The part of an app's API contract that the foundation package's own code
|
|
7
|
+
calls: session-cookie auth under /auth/*, and the two error envelopes the
|
|
8
|
+
gateway's toAppError() translates (FastAPI's default 422 and
|
|
9
|
+
HTTPException shapes). An app's openapi.yaml must contain every path and
|
|
10
|
+
component here, unchanged — `ui-foundation check-contract` fails verify
|
|
11
|
+
when it drifts. Descriptions, summaries and examples may differ; shapes
|
|
12
|
+
may not.
|
|
13
|
+
|
|
14
|
+
A path marked `x-optional: true` may be left out of an app's spec (an
|
|
15
|
+
app without self-service sign-up omits /auth/register), but if present
|
|
16
|
+
it must match.
|
|
17
|
+
|
|
18
|
+
servers:
|
|
19
|
+
- url: /api
|
|
20
|
+
|
|
21
|
+
paths:
|
|
22
|
+
/auth/login:
|
|
23
|
+
post:
|
|
24
|
+
operationId: login
|
|
25
|
+
summary: Log in with email and password, receiving a session cookie
|
|
26
|
+
tags: [auth]
|
|
27
|
+
requestBody:
|
|
28
|
+
required: true
|
|
29
|
+
content:
|
|
30
|
+
application/json:
|
|
31
|
+
schema: { $ref: "#/components/schemas/LoginRequest" }
|
|
32
|
+
responses:
|
|
33
|
+
"200":
|
|
34
|
+
description: Authenticated. Sets the session_id cookie.
|
|
35
|
+
content:
|
|
36
|
+
application/json:
|
|
37
|
+
schema: { $ref: "#/components/schemas/User" }
|
|
38
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
39
|
+
"422": { $ref: "#/components/responses/ValidationError" }
|
|
40
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
41
|
+
|
|
42
|
+
/auth/register:
|
|
43
|
+
x-optional: true
|
|
44
|
+
post:
|
|
45
|
+
operationId: register
|
|
46
|
+
summary: Create an account and immediately log in, receiving a session cookie
|
|
47
|
+
tags: [auth]
|
|
48
|
+
requestBody:
|
|
49
|
+
required: true
|
|
50
|
+
content:
|
|
51
|
+
application/json:
|
|
52
|
+
schema: { $ref: "#/components/schemas/RegisterRequest" }
|
|
53
|
+
responses:
|
|
54
|
+
"200":
|
|
55
|
+
description: Registered and authenticated. Sets the session_id cookie.
|
|
56
|
+
content:
|
|
57
|
+
application/json:
|
|
58
|
+
schema: { $ref: "#/components/schemas/User" }
|
|
59
|
+
"422": { $ref: "#/components/responses/ValidationError" }
|
|
60
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
61
|
+
|
|
62
|
+
/auth/logout:
|
|
63
|
+
post:
|
|
64
|
+
operationId: logout
|
|
65
|
+
summary: Clear the current session
|
|
66
|
+
tags: [auth]
|
|
67
|
+
responses:
|
|
68
|
+
"204":
|
|
69
|
+
description: Logged out (always, whether or not a session existed).
|
|
70
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
71
|
+
|
|
72
|
+
/auth/me:
|
|
73
|
+
get:
|
|
74
|
+
operationId: getCurrentUser
|
|
75
|
+
summary: The currently authenticated user
|
|
76
|
+
tags: [auth]
|
|
77
|
+
responses:
|
|
78
|
+
"200":
|
|
79
|
+
description: OK.
|
|
80
|
+
content:
|
|
81
|
+
application/json:
|
|
82
|
+
schema: { $ref: "#/components/schemas/User" }
|
|
83
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
84
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
85
|
+
|
|
86
|
+
components:
|
|
87
|
+
schemas:
|
|
88
|
+
User:
|
|
89
|
+
type: object
|
|
90
|
+
required: [id, email, name]
|
|
91
|
+
properties:
|
|
92
|
+
id: { type: integer, readOnly: true }
|
|
93
|
+
email: { type: string, format: email }
|
|
94
|
+
name: { type: string, minLength: 1, maxLength: 200 }
|
|
95
|
+
|
|
96
|
+
LoginRequest:
|
|
97
|
+
type: object
|
|
98
|
+
required: [email, password]
|
|
99
|
+
properties:
|
|
100
|
+
email: { type: string, format: email }
|
|
101
|
+
password: { type: string, minLength: 8 }
|
|
102
|
+
|
|
103
|
+
RegisterRequest:
|
|
104
|
+
type: object
|
|
105
|
+
required: [email, name, password]
|
|
106
|
+
properties:
|
|
107
|
+
email: { $ref: "#/components/schemas/User/properties/email" }
|
|
108
|
+
name: { $ref: "#/components/schemas/User/properties/name" }
|
|
109
|
+
password: { $ref: "#/components/schemas/LoginRequest/properties/password" }
|
|
110
|
+
|
|
111
|
+
ValidationErrorBody:
|
|
112
|
+
type: object
|
|
113
|
+
description: FastAPI's default 422 shape (RequestValidationError).
|
|
114
|
+
required: [detail]
|
|
115
|
+
properties:
|
|
116
|
+
detail:
|
|
117
|
+
type: array
|
|
118
|
+
items:
|
|
119
|
+
type: object
|
|
120
|
+
required: [loc, msg, type]
|
|
121
|
+
properties:
|
|
122
|
+
loc:
|
|
123
|
+
type: array
|
|
124
|
+
items:
|
|
125
|
+
type: ["string", "integer"]
|
|
126
|
+
msg: { type: string }
|
|
127
|
+
type: { type: string }
|
|
128
|
+
|
|
129
|
+
HTTPErrorBody:
|
|
130
|
+
type: object
|
|
131
|
+
description: FastAPI's default HTTPException shape.
|
|
132
|
+
required: [detail]
|
|
133
|
+
properties:
|
|
134
|
+
detail: { type: string }
|
|
135
|
+
|
|
136
|
+
responses:
|
|
137
|
+
ValidationError:
|
|
138
|
+
description: Validation failed on one or more fields.
|
|
139
|
+
content:
|
|
140
|
+
application/json:
|
|
141
|
+
schema: { $ref: "#/components/schemas/ValidationErrorBody" }
|
|
142
|
+
NotFoundError:
|
|
143
|
+
description: Resource not found.
|
|
144
|
+
content:
|
|
145
|
+
application/json:
|
|
146
|
+
schema: { $ref: "#/components/schemas/HTTPErrorBody" }
|
|
147
|
+
UnauthorizedError:
|
|
148
|
+
description: Missing or invalid credentials.
|
|
149
|
+
content:
|
|
150
|
+
application/json:
|
|
151
|
+
schema: { $ref: "#/components/schemas/HTTPErrorBody" }
|
|
152
|
+
ServerError:
|
|
153
|
+
description: Unhandled server error.
|
|
154
|
+
content:
|
|
155
|
+
application/json:
|
|
156
|
+
schema: { $ref: "#/components/schemas/HTTPErrorBody" }
|
package/package.json
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tristan2828/ui-foundation",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Tristan2828/ui-foundation.git",
|
|
9
|
+
"directory": "packages/ui-foundation"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": [
|
|
13
|
+
"**/*.css"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"styles",
|
|
18
|
+
"eslint",
|
|
19
|
+
"bin",
|
|
20
|
+
"conventions",
|
|
21
|
+
"openapi"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"ui-foundation": "bin/ui-foundation.mjs"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./ui/*": {
|
|
32
|
+
"types": "./dist/components/ui/*.d.ts",
|
|
33
|
+
"default": "./dist/components/ui/*.js"
|
|
34
|
+
},
|
|
35
|
+
"./gateway": {
|
|
36
|
+
"types": "./dist/gateway.d.ts",
|
|
37
|
+
"default": "./dist/gateway.js"
|
|
38
|
+
},
|
|
39
|
+
"./mocks": {
|
|
40
|
+
"types": "./dist/mocks/index.d.ts",
|
|
41
|
+
"default": "./dist/mocks/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./testing": {
|
|
44
|
+
"types": "./dist/testing/index.d.ts",
|
|
45
|
+
"default": "./dist/testing/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./eslint": {
|
|
48
|
+
"types": "./eslint/index.d.ts",
|
|
49
|
+
"default": "./eslint/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./styles.css": {
|
|
52
|
+
"style": "./styles/index.css",
|
|
53
|
+
"default": "./styles/index.css"
|
|
54
|
+
},
|
|
55
|
+
"./openapi.yaml": "./openapi/foundation.yaml",
|
|
56
|
+
"./package.json": "./package.json"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "node scripts/build.mjs",
|
|
60
|
+
"prepack": "npm run build",
|
|
61
|
+
"gen:api": "openapi-typescript ./openapi/foundation.yaml -o ./src/api/schema.ts",
|
|
62
|
+
"lint": "eslint .",
|
|
63
|
+
"storybook": "storybook dev -p 6006",
|
|
64
|
+
"build-storybook": "storybook build",
|
|
65
|
+
"preview-storybook": "vite preview --outDir storybook-static --port 6006 --strictPort",
|
|
66
|
+
"verify:fast": "npm run gen:api && git diff --exit-code -- src/api/schema.ts && tsc -b && eslint . --max-warnings 0 && node bin/ui-foundation.mjs check-deps && npm run build && vitest run",
|
|
67
|
+
"verify": "npm run verify:fast && playwright test -c playwright.storybook.config.ts"
|
|
68
|
+
},
|
|
69
|
+
"peerDependencies": {
|
|
70
|
+
"@axe-core/playwright": "^4.13.0",
|
|
71
|
+
"@base-ui/react": "^1.8.0",
|
|
72
|
+
"@eslint/js": "^10.0.1",
|
|
73
|
+
"@playwright/test": "^1.63.0",
|
|
74
|
+
"@tanstack/eslint-plugin-query": "^5.103.1",
|
|
75
|
+
"@tanstack/react-query": "^5.103.1",
|
|
76
|
+
"@tanstack/react-table": "^9.2.4",
|
|
77
|
+
"eslint": "^10.10.0",
|
|
78
|
+
"eslint-plugin-check-file": "^3.3.2",
|
|
79
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
80
|
+
"eslint-plugin-react-refresh": "^0.5.7",
|
|
81
|
+
"globals": "^17.12.0",
|
|
82
|
+
"msw": "^2.15.0",
|
|
83
|
+
"next-themes": "^0.4.6",
|
|
84
|
+
"react": "^19.3.0",
|
|
85
|
+
"react-dom": "^19.3.0",
|
|
86
|
+
"react-hook-form": "^7.88.0",
|
|
87
|
+
"react-router": "^7.18.4",
|
|
88
|
+
"sonner": "^2.0.8",
|
|
89
|
+
"tailwindcss": "^4.3.3",
|
|
90
|
+
"typescript-eslint": "^8.70.0",
|
|
91
|
+
"zod": "^4.6.5"
|
|
92
|
+
},
|
|
93
|
+
"peerDependenciesMeta": {
|
|
94
|
+
"@axe-core/playwright": { "optional": true },
|
|
95
|
+
"@eslint/js": { "optional": true },
|
|
96
|
+
"@playwright/test": { "optional": true },
|
|
97
|
+
"@tanstack/eslint-plugin-query": { "optional": true },
|
|
98
|
+
"eslint": { "optional": true },
|
|
99
|
+
"eslint-plugin-check-file": { "optional": true },
|
|
100
|
+
"eslint-plugin-react-hooks": { "optional": true },
|
|
101
|
+
"eslint-plugin-react-refresh": { "optional": true },
|
|
102
|
+
"globals": { "optional": true },
|
|
103
|
+
"msw": { "optional": true },
|
|
104
|
+
"typescript-eslint": { "optional": true }
|
|
105
|
+
},
|
|
106
|
+
"dependencies": {
|
|
107
|
+
"@fontsource-variable/geist": "^5.3.0",
|
|
108
|
+
"@hookform/resolvers": "^5.9.1",
|
|
109
|
+
"class-variance-authority": "^0.7.1",
|
|
110
|
+
"cn": "^0.3.0",
|
|
111
|
+
"date-fns": "^4.4.0",
|
|
112
|
+
"js-yaml": "^5.4.2",
|
|
113
|
+
"lucide-react": "^1.47.0",
|
|
114
|
+
"react-day-picker": "^10.0.1",
|
|
115
|
+
"shadcn": "^4.21.0",
|
|
116
|
+
"tw-animate-css": "^1.4.0"
|
|
117
|
+
},
|
|
118
|
+
"devDependencies": {
|
|
119
|
+
"@axe-core/playwright": "^4.13.0",
|
|
120
|
+
"@base-ui/react": "^1.8.0",
|
|
121
|
+
"@eslint/js": "^10.0.1",
|
|
122
|
+
"@playwright/test": "^1.63.0",
|
|
123
|
+
"@storybook/addon-docs": "^10.6.0",
|
|
124
|
+
"@storybook/react-vite": "^10.6.0",
|
|
125
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
126
|
+
"@tanstack/eslint-plugin-query": "^5.103.1",
|
|
127
|
+
"@tanstack/react-query": "^5.103.1",
|
|
128
|
+
"@tanstack/react-table": "^9.2.4",
|
|
129
|
+
"@types/node": "^24.13.5",
|
|
130
|
+
"@types/react": "^19.3.0",
|
|
131
|
+
"@types/react-dom": "^19.3.0",
|
|
132
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
133
|
+
"eslint": "^10.10.0",
|
|
134
|
+
"eslint-plugin-check-file": "^3.3.2",
|
|
135
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
136
|
+
"eslint-plugin-react-refresh": "^0.5.7",
|
|
137
|
+
"globals": "^17.12.0",
|
|
138
|
+
"msw": "^2.15.0",
|
|
139
|
+
"next-themes": "^0.4.6",
|
|
140
|
+
"openapi-typescript": "^7.13.0",
|
|
141
|
+
"react": "^19.3.0",
|
|
142
|
+
"react-dom": "^19.3.0",
|
|
143
|
+
"react-hook-form": "^7.88.0",
|
|
144
|
+
"react-router": "^7.18.4",
|
|
145
|
+
"sonner": "^2.0.8",
|
|
146
|
+
"storybook": "^10.6.0",
|
|
147
|
+
"tailwindcss": "^4.3.3",
|
|
148
|
+
"typescript": "~6.0.3",
|
|
149
|
+
"typescript-eslint": "^8.70.0",
|
|
150
|
+
"vite": "^8.3.0",
|
|
151
|
+
"vitest": "^5.0.1",
|
|
152
|
+
"zod": "^4.6.5"
|
|
153
|
+
},
|
|
154
|
+
"publishConfig": {
|
|
155
|
+
"access": "public",
|
|
156
|
+
"provenance": true
|
|
157
|
+
}
|
|
158
|
+
}
|