create-simone-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/bin/create-simone-app.ts +6 -0
- package/package.json +61 -0
- package/src/cli.ts +97 -0
- package/src/matrix.ts +39 -0
- package/src/options.ts +137 -0
- package/src/prompts.ts +108 -0
- package/src/scaffold.ts +304 -0
- package/src/templates.ts +77 -0
- package/templates/api/base/.agents/skills/ai-rules/SKILL.md +12 -0
- package/templates/api/base/.agents/skills/plan-feature/SKILL.md +13 -0
- package/templates/api/base/.agents/skills/review-diff/SKILL.md +12 -0
- package/templates/api/base/.agents/skills/tdd-step/SKILL.md +14 -0
- package/templates/api/base/.claude/agents/reviewer.md +20 -0
- package/templates/api/base/.claude/hooks/format.sh +22 -0
- package/templates/api/base/.claude/settings.json +38 -0
- package/templates/api/base/.codex/agents/reviewer.toml +7 -0
- package/templates/api/base/.codex/config.toml +10 -0
- package/templates/api/base/.codex/hooks.json +12 -0
- package/templates/api/base/.env.example +2 -0
- package/templates/api/base/.github/workflows/ci.yml +18 -0
- package/templates/api/base/.prettierignore +10 -0
- package/templates/api/base/.prettierrc +20 -0
- package/templates/api/base/CLAUDE.md +1 -0
- package/templates/api/base/README.md +43 -0
- package/templates/api/base/TASK.md.template +30 -0
- package/templates/api/base/_gitignore +8 -0
- package/templates/api/base/docs/agents/base.md +54 -0
- package/templates/api/base/eslint.config.js +21 -0
- package/templates/api/base/openapi.yaml +233 -0
- package/templates/api/base/package.json +41 -0
- package/templates/api/base/scripts/agents-build.ts +26 -0
- package/templates/api/base/scripts/rules-check.ts +48 -0
- package/templates/api/base/scripts/rules-sync.ts +35 -0
- package/templates/api/base/scripts/rules.config.ts +28 -0
- package/templates/api/base/scripts/seed-regen.ts +64 -0
- package/templates/api/base/src/data/orders.json +187 -0
- package/templates/api/base/src/data/products.json +142 -0
- package/templates/api/base/src/data/repository.ts +55 -0
- package/templates/api/base/src/data/schemas.ts +44 -0
- package/templates/api/base/src/data/users.json +352 -0
- package/templates/api/base/src/lib/appName.ts +2 -0
- package/templates/api/base/src/lib/http-error.ts +14 -0
- package/templates/api/base/src/lib/pagination.ts +55 -0
- package/templates/api/base/src/modules/orders/orders.repository.ts +26 -0
- package/templates/api/base/src/modules/orders/orders.schema.ts +11 -0
- package/templates/api/base/src/modules/products/products.repository.ts +17 -0
- package/templates/api/base/src/modules/products/products.schema.ts +21 -0
- package/templates/api/base/src/modules/stats/stats.repository.ts +37 -0
- package/templates/api/base/src/modules/users/users.repository.ts +26 -0
- package/templates/api/base/src/modules/users/users.schema.ts +15 -0
- package/templates/api/base/test/docs.test.ts +23 -0
- package/templates/api/base/test/health.test.ts +19 -0
- package/templates/api/base/test/orders.test.ts +82 -0
- package/templates/api/base/test/products.test.ts +54 -0
- package/templates/api/base/test/repository.test.ts +45 -0
- package/templates/api/base/test/smoke.test.ts +7 -0
- package/templates/api/base/test/stats.test.ts +40 -0
- package/templates/api/base/test/users.test.ts +84 -0
- package/templates/api/base/tsconfig.json +22 -0
- package/templates/api/base/vitest.config.ts +16 -0
- package/templates/api/variants/features/no-github-ci/variant.json +1 -0
- package/templates/api/variants/framework/express/docs/agents/framework-express.md +9 -0
- package/templates/api/variants/framework/express/src/app.ts +37 -0
- package/templates/api/variants/framework/express/src/middleware/errorHandler.ts +13 -0
- package/templates/api/variants/framework/express/src/middleware/validate.ts +59 -0
- package/templates/api/variants/framework/express/src/modules/docs/docs.router.ts +15 -0
- package/templates/api/variants/framework/express/src/modules/health/health.router.ts +7 -0
- package/templates/api/variants/framework/express/src/modules/orders/orders.router.ts +14 -0
- package/templates/api/variants/framework/express/src/modules/products/products.router.ts +24 -0
- package/templates/api/variants/framework/express/src/modules/stats/stats.router.ts +9 -0
- package/templates/api/variants/framework/express/src/modules/users/users.router.ts +24 -0
- package/templates/api/variants/framework/express/src/server.ts +8 -0
- package/templates/api/variants/framework/express/test/errorHandler.test.ts +89 -0
- package/templates/api/variants/framework/express/variant.json +15 -0
- package/templates/fe/base/.agents/skills/ai-rules/SKILL.md +12 -0
- package/templates/fe/base/.agents/skills/plan-feature/SKILL.md +13 -0
- package/templates/fe/base/.agents/skills/review-diff/SKILL.md +12 -0
- package/templates/fe/base/.agents/skills/tdd-step/SKILL.md +14 -0
- package/templates/fe/base/.claude/agents/reviewer.md +21 -0
- package/templates/fe/base/.claude/hooks/format.sh +22 -0
- package/templates/fe/base/.claude/settings.json +41 -0
- package/templates/fe/base/.codex/agents/reviewer.toml +7 -0
- package/templates/fe/base/.codex/config.toml +10 -0
- package/templates/fe/base/.codex/hooks.json +12 -0
- package/templates/fe/base/.env.example +2 -0
- package/templates/fe/base/.github/workflows/ci.yml +37 -0
- package/templates/fe/base/.prettierignore +14 -0
- package/templates/fe/base/.prettierrc +20 -0
- package/templates/fe/base/CLAUDE.md +1 -0
- package/templates/fe/base/README.md +37 -0
- package/templates/fe/base/TASK.md.template +31 -0
- package/templates/fe/base/_gitignore +11 -0
- package/templates/fe/base/docs/agents/base.md +69 -0
- package/templates/fe/base/e2e/users.spec.ts +82 -0
- package/templates/fe/base/eslint.config.js +169 -0
- package/templates/fe/base/index.html +13 -0
- package/templates/fe/base/package.json +63 -0
- package/templates/fe/base/playwright.config.ts +17 -0
- package/templates/fe/base/public/favicon.svg +1 -0
- package/templates/fe/base/public/icons.svg +24 -0
- package/templates/fe/base/scripts/agents-build.ts +26 -0
- package/templates/fe/base/scripts/rules-check.ts +48 -0
- package/templates/fe/base/scripts/rules-sync.ts +35 -0
- package/templates/fe/base/scripts/rules.config.ts +35 -0
- package/templates/fe/base/src/components/shared/EmptyState.tsx +23 -0
- package/templates/fe/base/src/components/shared/ErrorState.tsx +37 -0
- package/templates/fe/base/src/components/shared/PageHeader.tsx +21 -0
- package/templates/fe/base/src/features/users/UserSearch.tsx +48 -0
- package/templates/fe/base/src/features/users/UsersPage.tsx +58 -0
- package/templates/fe/base/src/features/users/UsersTable.tsx +245 -0
- package/templates/fe/base/src/features/users/UsersTableSkeleton.tsx +16 -0
- package/templates/fe/base/src/features/users/__tests__/UserSearch.test.tsx +30 -0
- package/templates/fe/base/src/features/users/__tests__/UsersPage.test.tsx +69 -0
- package/templates/fe/base/src/features/users/__tests__/UsersTable.test.tsx +120 -0
- package/templates/fe/base/src/features/users/__tests__/useUsers.test.tsx +62 -0
- package/templates/fe/base/src/features/users/useUsers.ts +22 -0
- package/templates/fe/base/src/features/users/users.api.ts +22 -0
- package/templates/fe/base/src/features/users/users.schema.ts +43 -0
- package/templates/fe/base/src/lib/apiClient.test.ts +56 -0
- package/templates/fe/base/src/lib/apiClient.ts +42 -0
- package/templates/fe/base/src/lib/appName.ts +2 -0
- package/templates/fe/base/src/lib/env.test.ts +21 -0
- package/templates/fe/base/src/lib/env.ts +26 -0
- package/templates/fe/base/src/lib/queryClient.ts +15 -0
- package/templates/fe/base/src/lib/router.ts +11 -0
- package/templates/fe/base/src/main.tsx +21 -0
- package/templates/fe/base/src/routeTree.gen.ts +77 -0
- package/templates/fe/base/src/routes/__root.tsx +83 -0
- package/templates/fe/base/src/routes/index.tsx +26 -0
- package/templates/fe/base/src/routes/users/index.tsx +24 -0
- package/templates/fe/base/src/stores/seam.test.ts +21 -0
- package/templates/fe/base/src/test/api-mock.ts +22 -0
- package/templates/fe/base/src/test/fixtures/users.ts +39 -0
- package/templates/fe/base/src/test/msw/handlers.ts +9 -0
- package/templates/fe/base/src/test/msw/server.ts +5 -0
- package/templates/fe/base/src/test/render.tsx +45 -0
- package/templates/fe/base/src/test/setup.ts +15 -0
- package/templates/fe/base/tsconfig.json +25 -0
- package/templates/fe/base/vite.config.ts +22 -0
- package/templates/fe/base/vitest.config.ts +21 -0
- package/templates/fe/variants/features/no-github-ci/variant.json +1 -0
- package/templates/fe/variants/features/no-msw/src/test/api-mock.ts +52 -0
- package/templates/fe/variants/features/no-msw/src/test/setup.ts +13 -0
- package/templates/fe/variants/features/no-msw/variant.json +4 -0
- package/templates/fe/variants/features/no-playwright/.github/workflows/ci.yml +19 -0
- package/templates/fe/variants/features/no-playwright/variant.json +5 -0
- package/templates/fe/variants/state/jotai/docs/agents/state-jotai.md +5 -0
- package/templates/fe/variants/state/jotai/src/stores/index.ts +15 -0
- package/templates/fe/variants/state/jotai/src/stores/ui.atoms.test.tsx +29 -0
- package/templates/fe/variants/state/jotai/src/stores/ui.atoms.ts +39 -0
- package/templates/fe/variants/state/jotai/variant.json +12 -0
- package/templates/fe/variants/state/zustand/docs/agents/state-zustand.md +5 -0
- package/templates/fe/variants/state/zustand/src/stores/index.ts +12 -0
- package/templates/fe/variants/state/zustand/src/stores/ui.store.test.ts +34 -0
- package/templates/fe/variants/state/zustand/src/stores/ui.store.ts +62 -0
- package/templates/fe/variants/state/zustand/variant.json +5 -0
- package/templates/fe/variants/ui/base-ui/components.json +25 -0
- package/templates/fe/variants/ui/base-ui/docs/agents/ui-base-ui.md +7 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/alert.tsx +75 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/button.tsx +57 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/input.tsx +19 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/skeleton.tsx +13 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/table.tsx +113 -0
- package/templates/fe/variants/ui/base-ui/src/index.css +130 -0
- package/templates/fe/variants/ui/base-ui/src/lib/utils.test.ts +10 -0
- package/templates/fe/variants/ui/base-ui/src/lib/utils.ts +1 -0
- package/templates/fe/variants/ui/base-ui/variant.json +23 -0
- package/templates/fe/variants/ui/radix/components.json +25 -0
- package/templates/fe/variants/ui/radix/docs/agents/ui-radix.md +7 -0
- package/templates/fe/variants/ui/radix/src/components/ui/alert.tsx +75 -0
- package/templates/fe/variants/ui/radix/src/components/ui/button.tsx +66 -0
- package/templates/fe/variants/ui/radix/src/components/ui/input.tsx +18 -0
- package/templates/fe/variants/ui/radix/src/components/ui/skeleton.tsx +13 -0
- package/templates/fe/variants/ui/radix/src/components/ui/table.tsx +113 -0
- package/templates/fe/variants/ui/radix/src/index.css +130 -0
- package/templates/fe/variants/ui/radix/src/lib/utils.test.ts +10 -0
- package/templates/fe/variants/ui/radix/src/lib/utils.ts +1 -0
- package/templates/fe/variants/ui/radix/variant.json +23 -0
- package/templates/fullstack/.github/workflows/ci.yml +18 -0
- package/templates/fullstack/AGENTS.md +7 -0
- package/templates/fullstack/CLAUDE.md +1 -0
- package/templates/fullstack/README.md +16 -0
- package/templates/fullstack/_gitignore +11 -0
- package/templates/fullstack/package.json +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig, mergeConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
import viteConfig from './vite.config'
|
|
4
|
+
|
|
5
|
+
export default mergeConfig(
|
|
6
|
+
viteConfig,
|
|
7
|
+
defineConfig({
|
|
8
|
+
test: {
|
|
9
|
+
environment: 'jsdom',
|
|
10
|
+
globals: false,
|
|
11
|
+
setupFiles: ['src/test/setup.ts'],
|
|
12
|
+
include: ['src/**/*.test.{ts,tsx}'],
|
|
13
|
+
coverage: {
|
|
14
|
+
provider: 'v8',
|
|
15
|
+
include: ['src/features/**'],
|
|
16
|
+
exclude: ['src/**/*.test.{ts,tsx}'],
|
|
17
|
+
thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "remove": [".github"] }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { apiClient } from '@/lib/apiClient'
|
|
2
|
+
|
|
3
|
+
import { listUsers } from './fixtures/users'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Test seam over the HTTP mock without MSW: an axios adapter serves the same fixture.
|
|
7
|
+
* Feature tests only call `apiMock.*`, identical to the MSW variant.
|
|
8
|
+
*/
|
|
9
|
+
interface Once {
|
|
10
|
+
status: number
|
|
11
|
+
body: unknown
|
|
12
|
+
}
|
|
13
|
+
let once: Once | undefined
|
|
14
|
+
|
|
15
|
+
const axiosLikeError = (config: unknown, status: number, body: unknown) =>
|
|
16
|
+
Object.assign(new Error(`Request failed with status code ${status}`), {
|
|
17
|
+
isAxiosError: true,
|
|
18
|
+
config,
|
|
19
|
+
response: { data: body, status, statusText: '', headers: {}, config },
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const installApiMock = () => {
|
|
23
|
+
apiClient.defaults.adapter = async (config) => {
|
|
24
|
+
const ok = (data: unknown) => ({
|
|
25
|
+
data,
|
|
26
|
+
status: 200,
|
|
27
|
+
statusText: 'OK',
|
|
28
|
+
headers: {},
|
|
29
|
+
config,
|
|
30
|
+
})
|
|
31
|
+
if (once) {
|
|
32
|
+
const { status, body } = once
|
|
33
|
+
once = undefined
|
|
34
|
+
if (status >= 400) throw axiosLikeError(config, status, body)
|
|
35
|
+
return ok(body)
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(apiClient.getUri(config), 'http://localhost:3000')
|
|
38
|
+
if (url.pathname.endsWith('/users')) return ok(listUsers(url))
|
|
39
|
+
throw axiosLikeError(config, 404, { error: `Not mocked: ${url.pathname}` })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const apiMock = {
|
|
44
|
+
/** Next request answers `status` with `body`. */
|
|
45
|
+
failOnce: (status: number, body: unknown) => {
|
|
46
|
+
once = { status, body }
|
|
47
|
+
},
|
|
48
|
+
/** Next request answers 200 with `body`. */
|
|
49
|
+
respondOnce: (body: unknown) => {
|
|
50
|
+
once = { status: 200, body }
|
|
51
|
+
},
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { cleanup } from '@testing-library/react'
|
|
3
|
+
import { afterEach, beforeAll } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { resetStores } from '@/stores'
|
|
6
|
+
|
|
7
|
+
import { installApiMock } from './api-mock'
|
|
8
|
+
|
|
9
|
+
beforeAll(() => installApiMock())
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
resetStores()
|
|
12
|
+
cleanup()
|
|
13
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
verify:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: oven-sh/setup-bun@v2
|
|
14
|
+
with:
|
|
15
|
+
bun-version: latest
|
|
16
|
+
- run: bun install --frozen-lockfile
|
|
17
|
+
- run: bun run rules:check
|
|
18
|
+
- run: bun run verify
|
|
19
|
+
- run: bun run build
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
## State: Jotai (client state) — deviation core-rules §7.1
|
|
2
|
+
|
|
3
|
+
- `src/stores/ui.atoms.ts` → `themeAtom` (persisted), `sidebarOpenAtom`, `tableDensityAtom` + derived `tableCellClassAtom`. Provider-less in the app; `StoreProvider` = Jotai `Provider` for a fresh store per test.
|
|
4
|
+
- The app never imports `jotai`: it uses the seam hooks from `@/stores` — `useTheme()`, `useTableDensity()`, `StoreProvider`, `resetStores()` (ESLint `no-restricted-imports` + `seam.test.ts`).
|
|
5
|
+
- Atoms hold **client** state only; server data lives in TanStack Query. Cross-route state goes in search params.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Barrel: the only import path for state outside src/stores (ESLint no-restricted-imports).
|
|
2
|
+
// Consumers see a stable hook API; variants swap the implementation file.
|
|
3
|
+
export {
|
|
4
|
+
StoreProvider,
|
|
5
|
+
TABLE_DENSITIES,
|
|
6
|
+
resetStores,
|
|
7
|
+
sidebarOpenAtom,
|
|
8
|
+
tableCellClassAtom,
|
|
9
|
+
tableDensityAtom,
|
|
10
|
+
themeAtom,
|
|
11
|
+
useTableDensity,
|
|
12
|
+
useTheme,
|
|
13
|
+
type TableDensity,
|
|
14
|
+
type Theme,
|
|
15
|
+
} from './ui.atoms'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { StoreProvider, useTableDensity, useTheme } from '@/stores'
|
|
5
|
+
|
|
6
|
+
describe('ui atoms (jotai)', () => {
|
|
7
|
+
it('derives cell classes and toggles density', () => {
|
|
8
|
+
const { result } = renderHook(() => useTableDensity(), {
|
|
9
|
+
wrapper: StoreProvider,
|
|
10
|
+
})
|
|
11
|
+
expect(result.current).toMatchObject({
|
|
12
|
+
density: 'comfortable',
|
|
13
|
+
cellClass: 'px-4 py-3',
|
|
14
|
+
})
|
|
15
|
+
act(() => result.current.toggle())
|
|
16
|
+
expect(result.current).toMatchObject({
|
|
17
|
+
density: 'compact',
|
|
18
|
+
cellClass: 'px-2 py-1 text-sm',
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('exposes theme through the seam hook and persists it', () => {
|
|
23
|
+
const { result } = renderHook(() => useTheme(), { wrapper: StoreProvider })
|
|
24
|
+
expect(result.current.theme).toBe('light')
|
|
25
|
+
act(() => result.current.setTheme('dark'))
|
|
26
|
+
expect(result.current.theme).toBe('dark')
|
|
27
|
+
expect(localStorage.getItem('ui-theme')).toBe('"dark"')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Provider, atom, useAtom, useAtomValue } from 'jotai'
|
|
2
|
+
import { atomWithStorage } from 'jotai/utils'
|
|
3
|
+
|
|
4
|
+
export type Theme = 'light' | 'dark'
|
|
5
|
+
export const TABLE_DENSITIES = ['comfortable', 'compact'] as const
|
|
6
|
+
export type TableDensity = (typeof TABLE_DENSITIES)[number]
|
|
7
|
+
|
|
8
|
+
// Deviation core-rules §7.1: Jotai for client state; still never server data.
|
|
9
|
+
export const themeAtom = atomWithStorage<Theme>('ui-theme', 'light')
|
|
10
|
+
export const sidebarOpenAtom = atom(true)
|
|
11
|
+
export const tableDensityAtom = atom<TableDensity>('comfortable')
|
|
12
|
+
|
|
13
|
+
/** Derived: Tailwind classes for table cells at the current density. */
|
|
14
|
+
export const tableCellClassAtom = atom((get) =>
|
|
15
|
+
get(tableDensityAtom) === 'compact' ? 'px-2 py-1 text-sm' : 'px-4 py-3',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// ---- Seam API consumed by the app (same shape in every state variant) ----
|
|
19
|
+
|
|
20
|
+
export const useTheme = () => {
|
|
21
|
+
const [theme, setTheme] = useAtom(themeAtom)
|
|
22
|
+
return { theme, setTheme }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useTableDensity = () => {
|
|
26
|
+
const [density, setDensity] = useAtom(tableDensityAtom)
|
|
27
|
+
const cellClass = useAtomValue(tableCellClassAtom)
|
|
28
|
+
return {
|
|
29
|
+
density,
|
|
30
|
+
cellClass,
|
|
31
|
+
toggle: () => setDensity(density === 'compact' ? 'comfortable' : 'compact'),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fresh Jotai store per subtree: tests wrap with it, the app can stay provider-less. */
|
|
36
|
+
export const StoreProvider = Provider
|
|
37
|
+
|
|
38
|
+
/** Isolation comes from StoreProvider; nothing global to reset. */
|
|
39
|
+
export const resetStores = () => undefined
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": { "jotai": "^3.0.0" },
|
|
3
|
+
"agents": ["state-jotai"],
|
|
4
|
+
"eslint": { "stateLibs": ["jotai"] },
|
|
5
|
+
"deviations": [
|
|
6
|
+
{
|
|
7
|
+
"rule": "core-rules §7.1",
|
|
8
|
+
"what": "Jotai instead of Zustand for client state",
|
|
9
|
+
"why": "chosen at scaffold time; atoms stay behind the `@/stores` seam so the rest of the code is identical to the Zustand variant"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
## State: Zustand (global client state)
|
|
2
|
+
|
|
3
|
+
- `src/stores/ui.store.ts` → `useUiStore` (sidebar, theme, table density) with `persist`. Pattern `use<Feature>Store`, typed interface, selectors in components.
|
|
4
|
+
- The app never imports `zustand`: it uses the seam hooks from `@/stores` — `useTheme()`, `useTableDensity()`, `StoreProvider`, `resetStores()` (ESLint `no-restricted-imports` + `seam.test.ts`).
|
|
5
|
+
- Zustand holds **client** state only; server data lives in TanStack Query (core-rules §7). Cross-route state goes in search params.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Barrel: the only import path for state outside src/stores (ESLint no-restricted-imports).
|
|
2
|
+
// Consumers see a stable hook API; variants swap the implementation file.
|
|
3
|
+
export {
|
|
4
|
+
StoreProvider,
|
|
5
|
+
TABLE_DENSITIES,
|
|
6
|
+
resetStores,
|
|
7
|
+
useTableDensity,
|
|
8
|
+
useTheme,
|
|
9
|
+
useUiStore,
|
|
10
|
+
type TableDensity,
|
|
11
|
+
type Theme,
|
|
12
|
+
} from './ui.store'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { resetStores, useTableDensity, useTheme, useUiStore } from '@/stores'
|
|
5
|
+
|
|
6
|
+
describe('ui store (zustand)', () => {
|
|
7
|
+
beforeEach(() => resetStores())
|
|
8
|
+
|
|
9
|
+
it('toggles the sidebar', () => {
|
|
10
|
+
useUiStore.getState().toggleSidebar()
|
|
11
|
+
expect(useUiStore.getState().sidebarOpen).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('exposes theme through the seam hook and persists it', () => {
|
|
15
|
+
const { result } = renderHook(() => useTheme())
|
|
16
|
+
expect(result.current.theme).toBe('light')
|
|
17
|
+
act(() => result.current.setTheme('dark'))
|
|
18
|
+
expect(result.current.theme).toBe('dark')
|
|
19
|
+
expect(localStorage.getItem('ui-storage')).toContain('"theme":"dark"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('derives table cell classes from the density', () => {
|
|
23
|
+
const { result } = renderHook(() => useTableDensity())
|
|
24
|
+
expect(result.current).toMatchObject({
|
|
25
|
+
density: 'comfortable',
|
|
26
|
+
cellClass: 'px-4 py-3',
|
|
27
|
+
})
|
|
28
|
+
act(() => result.current.toggle())
|
|
29
|
+
expect(result.current).toMatchObject({
|
|
30
|
+
density: 'compact',
|
|
31
|
+
cellClass: 'px-2 py-1 text-sm',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand'
|
|
4
|
+
import { persist } from 'zustand/middleware'
|
|
5
|
+
|
|
6
|
+
export type Theme = 'light' | 'dark'
|
|
7
|
+
export const TABLE_DENSITIES = ['comfortable', 'compact'] as const
|
|
8
|
+
export type TableDensity = (typeof TABLE_DENSITIES)[number]
|
|
9
|
+
|
|
10
|
+
// core-rules §7: Zustand = global client state only (never server data).
|
|
11
|
+
interface UiState {
|
|
12
|
+
sidebarOpen: boolean
|
|
13
|
+
theme: Theme
|
|
14
|
+
tableDensity: TableDensity
|
|
15
|
+
toggleSidebar: () => void
|
|
16
|
+
setTheme: (theme: Theme) => void
|
|
17
|
+
toggleTableDensity: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const initialState = {
|
|
21
|
+
sidebarOpen: true,
|
|
22
|
+
theme: 'light' as Theme,
|
|
23
|
+
tableDensity: 'comfortable' as TableDensity,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const useUiStore = create<UiState>()(
|
|
27
|
+
persist(
|
|
28
|
+
(set) => ({
|
|
29
|
+
...initialState,
|
|
30
|
+
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
|
31
|
+
setTheme: (theme) => set({ theme }),
|
|
32
|
+
toggleTableDensity: () =>
|
|
33
|
+
set((s) => ({
|
|
34
|
+
tableDensity:
|
|
35
|
+
s.tableDensity === 'compact' ? 'comfortable' : 'compact',
|
|
36
|
+
})),
|
|
37
|
+
}),
|
|
38
|
+
{ name: 'ui-storage' },
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// ---- Seam API consumed by the app (same shape in every state variant) ----
|
|
43
|
+
|
|
44
|
+
export const useTheme = () => ({
|
|
45
|
+
theme: useUiStore((s) => s.theme),
|
|
46
|
+
setTheme: useUiStore((s) => s.setTheme),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const useTableDensity = () => {
|
|
50
|
+
const density = useUiStore((s) => s.tableDensity)
|
|
51
|
+
const toggle = useUiStore((s) => s.toggleTableDensity)
|
|
52
|
+
return {
|
|
53
|
+
density,
|
|
54
|
+
toggle,
|
|
55
|
+
cellClass: density === 'compact' ? 'px-2 py-1 text-sm' : 'px-4 py-3',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Zustand stores are global: nothing to provide. Tests isolate through `resetStores()`. */
|
|
60
|
+
export const StoreProvider = ({ children }: { children: ReactNode }) => children
|
|
61
|
+
|
|
62
|
+
export const resetStores = () => useUiStore.setState(initialState)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "base-nova",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/index.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "lucide",
|
|
14
|
+
"rtl": false,
|
|
15
|
+
"aliases": {
|
|
16
|
+
"components": "@/components",
|
|
17
|
+
"utils": "@/lib/utils",
|
|
18
|
+
"ui": "@/components/ui",
|
|
19
|
+
"lib": "@/lib",
|
|
20
|
+
"hooks": "@/hooks"
|
|
21
|
+
},
|
|
22
|
+
"menuColor": "default",
|
|
23
|
+
"menuAccent": "subtle",
|
|
24
|
+
"registries": {}
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## UI: shadcn on Base UI
|
|
2
|
+
|
|
3
|
+
- `components.json`: style `base-nova` (Base UI preset of the shadcn v4 CLI), baseColor `neutral`, iconLibrary `lucide`, css `src/index.css`.
|
|
4
|
+
- Components only via `bunx shadcn@latest add <name>`; never edit `src/components/ui/*` by hand (ESLint/Prettier ignore it).
|
|
5
|
+
- UI seam: the UI library touches only `components.json`, `src/components/ui/*`, `src/index.css`. `@base-ui/*` is imported only inside `src/components/ui/` (enforced by `no-restricted-imports`); everything else composes the wrappers (`Button`, `Input`, `Table`, …).
|
|
6
|
+
- `cn()` comes from `@/lib/utils` (shadcn's `cn` package, drop-in for clsx + tailwind-merge).
|
|
7
|
+
- Single CSS file `src/index.css`: Tailwind, tw-animate-css, shadcn/tailwind.css, theme variables. No other `.css`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "cn"
|
|
4
|
+
|
|
5
|
+
const alertVariants = cva(
|
|
6
|
+
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-card text-card-foreground",
|
|
11
|
+
destructive:
|
|
12
|
+
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
variant: "default",
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function Alert({
|
|
22
|
+
className,
|
|
23
|
+
variant,
|
|
24
|
+
...props
|
|
25
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
data-slot="alert"
|
|
29
|
+
role="alert"
|
|
30
|
+
className={cn(alertVariants({ variant }), className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="alert-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function AlertDescription({
|
|
50
|
+
className,
|
|
51
|
+
...props
|
|
52
|
+
}: React.ComponentProps<"div">) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="alert-description"
|
|
56
|
+
className={cn(
|
|
57
|
+
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
data-slot="alert-action"
|
|
69
|
+
className={cn("absolute top-2 right-2", className)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "cn"
|
|
4
|
+
|
|
5
|
+
const buttonVariants = cva(
|
|
6
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
|
11
|
+
outline:
|
|
12
|
+
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
13
|
+
secondary:
|
|
14
|
+
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
15
|
+
ghost:
|
|
16
|
+
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
17
|
+
destructive:
|
|
18
|
+
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
19
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
default:
|
|
23
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
24
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
25
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
26
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
27
|
+
icon: "size-8",
|
|
28
|
+
"icon-xs":
|
|
29
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
30
|
+
"icon-sm":
|
|
31
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
32
|
+
"icon-lg": "size-9",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: {
|
|
36
|
+
variant: "default",
|
|
37
|
+
size: "default",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
function Button({
|
|
43
|
+
className,
|
|
44
|
+
variant = "default",
|
|
45
|
+
size = "default",
|
|
46
|
+
...props
|
|
47
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
48
|
+
return (
|
|
49
|
+
<ButtonPrimitive
|
|
50
|
+
data-slot="button"
|
|
51
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Input as InputPrimitive } from "@base-ui/react/input"
|
|
3
|
+
import { cn } from "cn"
|
|
4
|
+
|
|
5
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
6
|
+
return (
|
|
7
|
+
<InputPrimitive
|
|
8
|
+
type={type}
|
|
9
|
+
data-slot="input"
|
|
10
|
+
className={cn(
|
|
11
|
+
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Input }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { cn } from "cn"
|
|
2
|
+
|
|
3
|
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
4
|
+
return (
|
|
5
|
+
<div
|
|
6
|
+
data-slot="skeleton"
|
|
7
|
+
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
8
|
+
{...props}
|
|
9
|
+
/>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { Skeleton }
|