@brust/create-app 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (35) hide show
  1. package/README.md +2 -1
  2. package/package.json +5 -1
  3. package/src/args.ts +25 -3
  4. package/src/cli.ts +24 -6
  5. package/src/dep.ts +2 -1
  6. package/src/shadcn.ts +41 -0
  7. package/src/tailwind.ts +10 -0
  8. package/templates/blank/README.md.tmpl +5 -4
  9. package/templates/blank/_gitignore +2 -0
  10. package/templates/blank/app.css +32 -0
  11. package/templates/blank/components/Counter.tsx +5 -1
  12. package/templates/blank/components/Layout.tsx +5 -4
  13. package/templates/blank/package.json.tmpl +9 -3
  14. package/templates/blank/pages/HomePage.tsx +29 -5
  15. package/templates/blank/public/favicon.svg +1 -1
  16. package/templates/blank/wrangler.jsonc.tmpl +9 -0
  17. package/templates/pokedex/README.md.tmpl +20 -7
  18. package/templates/pokedex/_gitignore +2 -0
  19. package/templates/pokedex/actions.ts +5 -5
  20. package/templates/pokedex/{styles/app.css → app.css} +0 -1
  21. package/templates/pokedex/brust.toml +21 -1
  22. package/templates/pokedex/components/AppLayout.tsx +1 -1
  23. package/templates/pokedex/components/TeamBuilder.tsx +2 -1
  24. package/templates/pokedex/components/UnifiedIsland.tsx +2 -1
  25. package/templates/pokedex/lib/cloudflare.d.ts +24 -0
  26. package/templates/pokedex/lib/loaders.ts +2 -2
  27. package/templates/pokedex/lib/team-db.d1.ts +47 -0
  28. package/templates/pokedex/lib/team-db.sqlite.ts +49 -0
  29. package/templates/pokedex/lib/team-db.ts +7 -46
  30. package/templates/pokedex/migrations/0001_team.sql +2 -0
  31. package/templates/pokedex/package.json.tmpl +10 -2
  32. package/templates/pokedex/pages/NotFoundPage.tsx +3 -1
  33. package/templates/pokedex/wrangler.jsonc +9 -0
  34. package/templates/blank/public/app.css +0 -14
  35. package/templates/pokedex/public/app.css +0 -3
package/README.md CHANGED
@@ -4,6 +4,7 @@ Scaffold a [brust](https://github.com/AssetsArt/brust) v2 app.
4
4
 
5
5
  ```bash
6
6
  bun create @brust/app my-app # --template blank (default) | pokedex
7
+ bun create @brust/app my-app --shadcn=button,card,dialog # also installs and runs `brust shadcn init` (needs @brust/core 2.0.0-alpha.11+)
7
8
  cd my-app && bun install
8
9
  bun run dev # http://127.0.0.1:1337
9
10
  ```
@@ -17,4 +18,4 @@ bun run dev # http://127.0.0.1:1337
17
18
 
18
19
  `bun create` rejects `-t` and `-y`; use the long flags (the short ones work with `bunx @brust/create-app`).
19
20
 
20
- Full guide: [docs/create-app.md](https://github.com/AssetsArt/brust/blob/v2/docs/create-app.md).
21
+ Full guide: [docs/create-app.md](https://github.com/AssetsArt/brust/blob/main/docs/create-app.md).
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@brust/create-app",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0-alpha.21",
4
4
  "description": "Scaffold a brust v2 app — bun create @brust/app <name>",
5
5
  "license": "MIT",
6
+ "homepage": "https://brust.dev",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/AssetsArt/brust.git",
9
10
  "directory": "packages/create-app"
10
11
  },
12
+ "bugs": {
13
+ "url": "https://github.com/AssetsArt/brust/issues"
14
+ },
11
15
  "type": "module",
12
16
  "bin": {
13
17
  "create-app": "./bin/create-app.ts"
package/src/args.ts CHANGED
@@ -4,8 +4,11 @@ import { isAbsolute, resolve } from 'node:path'
4
4
 
5
5
  const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/
6
6
  const MAX_NAME_LEN = 50
7
+ // The same rule as `brust shadcn init` (R41.8): a component is a registry name or an https URL, checked before anything is written.
8
+ const COMPONENT_RE = /^[a-z0-9][a-z0-9-]*$/
9
+ const REGISTRY_URL_RE = /^https:\/\/[^\s,]+$/
7
10
 
8
- export const USAGE = 'usage: bun create @brust/app <name> [--template blank|pokedex] [--dir <path>] [--yes] [--brust-dep <spec>]'
11
+ export const USAGE = 'usage: bun create @brust/app <name> [--template blank|pokedex] [--tailwind] [--dir <path>] [--yes] [--brust-dep <spec>] [--shadcn[=button,card,...]] [--version]'
9
12
 
10
13
  export interface ParsedArgs {
11
14
  projectName: string
@@ -13,7 +16,13 @@ export interface ParsedArgs {
13
16
  template?: string
14
17
  yes: boolean
15
18
  brustDep?: string
19
+ /** `--tailwind` (L21): accepted and ignored since L28: the blank template uses Tailwind CSS v4 by default. */
20
+ tailwind: boolean
16
21
  help: boolean
22
+ /** `--shadcn[=a,b]` (F181): after scaffolding, install and run `brust shadcn init` with these components (`[]` = set up only). */
23
+ shadcn?: string[]
24
+ /** `--version` (F131): print the bare version and exit; no short form (see the note above). */
25
+ version: boolean
17
26
  }
18
27
 
19
28
  export function parseArgs(args: string[]): ParsedArgs {
@@ -22,7 +31,10 @@ export function parseArgs(args: string[]): ParsedArgs {
22
31
  let template: string | undefined
23
32
  let brustDep: string | undefined
24
33
  let yes = false
34
+ let tailwind = false
25
35
  let help = false
36
+ let version = false
37
+ let shadcn: string[] | undefined
26
38
  const need = (flag: string, v: string | undefined): string => {
27
39
  if (!v) throw new Error(`create-app: ${flag} requires a value`)
28
40
  return v
@@ -32,6 +44,7 @@ export function parseArgs(args: string[]): ParsedArgs {
32
44
  const a = args[i]!
33
45
  if (a === '--') continue
34
46
  if (a === '--help' || a === '-h') help = true
47
+ else if (a === '--version') version = true
35
48
  else if (a === '--dir') dir = need('--dir', args[++i])
36
49
  else if (a.startsWith('--dir=')) dir = need('--dir', a.slice('--dir='.length))
37
50
  else if (a === '--template' || a === '-t') template = need('--template', args[++i])
@@ -39,12 +52,21 @@ export function parseArgs(args: string[]): ParsedArgs {
39
52
  else if (a === '--brust-dep') brustDep = need('--brust-dep', args[++i])
40
53
  else if (a.startsWith('--brust-dep=')) brustDep = need('--brust-dep', a.slice('--brust-dep='.length))
41
54
  else if (a === '--yes' || a === '-y') yes = true
55
+ else if (a === '--tailwind') tailwind = true
56
+ else if (a === '--shadcn') shadcn = shadcn ?? []
57
+ else if (a.startsWith('--shadcn=')) {
58
+ const names = a.slice('--shadcn='.length).split(',').filter(Boolean)
59
+ for (const c of names) {
60
+ if (!COMPONENT_RE.test(c) && !REGISTRY_URL_RE.test(c)) throw new Error(`create-app: --shadcn component "${c}" must match ^[a-z0-9][a-z0-9-]*$ or be an https:// registry URL (without commas)`)
61
+ }
62
+ shadcn = [...(shadcn ?? []), ...names]
63
+ }
42
64
  else if (a.startsWith('-')) throw new Error(`create-app: unknown flag "${a}"`)
43
65
  else if (name === undefined) name = a
44
66
  else throw new Error(`create-app: unexpected positional argument "${a}"`)
45
67
  }
46
68
 
47
- if (help) return { projectName: name ?? '', targetDir: '', template, yes, brustDep, help }
69
+ if (help || version) return { projectName: name ?? '', targetDir: '', template, yes, brustDep, tailwind, help, version }
48
70
  if (!name) throw new Error(`create-app: missing project name. ${USAGE}`)
49
71
  if (name.length > MAX_NAME_LEN) throw new Error(`create-app: project name too long (max ${MAX_NAME_LEN} chars)`)
50
72
  if (!NAME_RE.test(name)) {
@@ -52,5 +74,5 @@ export function parseArgs(args: string[]): ParsedArgs {
52
74
  }
53
75
  const cwd = process.cwd()
54
76
  const targetDir = dir ? (isAbsolute(dir) ? dir : resolve(cwd, dir)) : resolve(cwd, name)
55
- return { projectName: name, targetDir, template, yes, brustDep, help }
77
+ return { projectName: name, targetDir, template, yes, brustDep, tailwind, help, version, ...(shadcn && { shadcn }) }
56
78
  }
package/src/cli.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  // `create-app` main — a port of 0.1.x runNew (runtime/cli/new.ts:221-294) with the v2 dependency resolution.
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { readdir, rm } from 'node:fs/promises'
4
4
  import { copyTemplate } from './copy.ts'
5
5
  import { parseArgs, USAGE } from './args.ts'
6
6
  import { applyOverrides, monorepoRoot, resolveBrustDep } from './dep.ts'
7
+ import { TAILWIND_DEFAULT_NOTE } from './tailwind.ts'
8
+ import { runShadcn, shq, spawnRunner } from './shadcn.ts'
7
9
  import { selectTemplate } from './templates.ts'
8
10
 
11
+ // F131: read at run time from the package.json that `bun publish` ships, so release-bump.ts stays the only place versions change.
12
+ const ownVersion = (): string => (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '0.0.0'
13
+
9
14
  const fail = (msg: string): never => {
10
15
  console.error(msg)
11
16
  process.exit(1)
@@ -19,6 +24,11 @@ export async function main(argv: string[]): Promise<void> {
19
24
  console.log(USAGE)
20
25
  return
21
26
  }
27
+ if (parsed.version) {
28
+ console.log(ownVersion())
29
+ return
30
+ }
31
+ console.log(`@brust/create-app ${ownVersion()}\n`)
22
32
  const { projectName, targetDir } = parsed
23
33
 
24
34
  const targetExisted = existsSync(targetDir)
@@ -26,6 +36,8 @@ export async function main(argv: string[]): Promise<void> {
26
36
 
27
37
  let tmpl: ReturnType<typeof selectTemplate>
28
38
  try { tmpl = selectTemplate({ explicit: parsed.template, yes: parsed.yes, isTTY: Boolean(process.stdin.isTTY) }) } catch (e) { return fail(msg(e)) }
39
+ // L28 R29.1: Tailwind is the blank template's default; the flag stays accepted so older instructions still work.
40
+ if (parsed.tailwind) console.log(TAILWIND_DEFAULT_NOTE)
29
41
  const dep = resolveBrustDep({ explicit: parsed.brustDep, targetDir })
30
42
 
31
43
  try {
@@ -44,15 +56,21 @@ export async function main(argv: string[]): Promise<void> {
44
56
  if (!targetExisted) await rm(targetDir, { recursive: true, force: true }).catch(() => {})
45
57
  return fail(`create-app: failed to scaffold (${msg(e)})`)
46
58
  }
47
- printNextSteps(projectName, targetDir, tmpl.name, dep.kind === 'workspace')
59
+ if (parsed.shadcn) {
60
+ const code = await runShadcn({ targetDir, components: parsed.shadcn, workspace: dep.kind === 'workspace', run: spawnRunner, out: (l) => console.log(l) })
61
+ if (code !== 0) process.exit(code)
62
+ printNextSteps(projectName, targetDir, tmpl.name, dep.spec, dep.kind === 'workspace', true)
63
+ return
64
+ }
65
+ printNextSteps(projectName, targetDir, tmpl.name, dep.spec, dep.kind === 'workspace')
48
66
  }
49
67
 
50
- function printNextSteps(name: string, targetDir: string, template: string, workspace: boolean): void {
68
+ function printNextSteps(name: string, targetDir: string, template: string, spec: string, workspace: boolean, installed = false): void {
51
69
  const cwd = process.cwd()
52
70
  const displayPath = targetDir.startsWith(`${cwd}/`) ? `./${targetDir.slice(cwd.length + 1)}` : targetDir
53
- console.log(`Created ${name} at ${targetDir} (template: ${template})\n`)
71
+ console.log(`Created ${name} at ${targetDir} (template: ${template}, @brust/core ${spec})\n`)
54
72
  console.log('Next:')
55
- console.log(` cd ${displayPath}`)
56
- console.log(workspace ? ' bun install # run it at the brust repo root: this app is a workspace member' : ' bun install')
73
+ console.log(` cd ${shq(displayPath)}`)
74
+ if (!installed) console.log(workspace ? ' bun install # run it at the brust repo root: this app is a workspace member' : ' bun install')
57
75
  console.log(' bun run dev')
58
76
  }
package/src/dep.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { dirname, join, relative, resolve } from 'node:path'
4
4
 
5
- /** = scripts/release-bump.ts PLATS. */
5
+ /** = scripts/release-files.ts PLATS. */
6
6
  export const PLATS = ['darwin-x64', 'darwin-arm64', 'linux-x64-gnu', 'linux-arm64-gnu', 'linux-x64-musl', 'linux-arm64-musl'] as const
7
7
 
8
8
  export interface BrustDep {
@@ -44,6 +44,7 @@ export function resolveBrustDep(o: { explicit?: string; targetDir: string; pkgRo
44
44
  if (inWorkspace(root, o.targetDir)) return { kind: 'workspace', spec: 'workspace:*' }
45
45
  // `file:` alone fails: core's own workspace:* deps do not resolve outside the workspace globs, so override them.
46
46
  const overrides: Record<string, string> = { '@brust/runtime-dom': `file:${root}/packages/runtime-dom` }
47
+ overrides['@brust/core-cf'] = `file:${root}/npm/core-cf`
47
48
  for (const p of PLATS) overrides[`@brust/native-${p}`] = `file:${root}/npm/${p}`
48
49
  return { kind: 'file', spec: `file:${root}/packages/brust`, overrides }
49
50
  }
package/src/shadcn.ts ADDED
@@ -0,0 +1,41 @@
1
+ // `--shadcn[=a,b]` (F181): after the scaffold, install the project and run its own `brust shadcn init`.
2
+ // Every child is an argv array (no shell). The project's own `node_modules/.bin/brust` is run by path, never `bunx brust`,
3
+ // which could fetch an unrelated package named `brust` from the registry when the local one is missing.
4
+ import { join } from 'node:path'
5
+
6
+ /** Runs `argv` in `cwd` with the terminal attached; resolves to the exit code. */
7
+ export type Runner = (argv: string[], cwd: string) => Promise<number>
8
+
9
+ export const spawnRunner: Runner = async (argv, cwd) => {
10
+ try {
11
+ const p = Bun.spawn(argv, { cwd, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
12
+ return await p.exited
13
+ } catch {
14
+ return 127
15
+ }
16
+ }
17
+
18
+ /** POSIX single-quoting: nothing inside expands when the printed command is pasted into a shell. */
19
+ export const shq = (s: string): string => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
20
+
21
+ /** Returns 0 when done, else the failing step's exit code (the project is left in place; `lines` says what to run). */
22
+ export async function runShadcn(o: { targetDir: string; components: string[]; workspace: boolean; run: Runner; out: (line: string) => void }): Promise<number> {
23
+ const { targetDir, components, out } = o
24
+ const by = `cd ${shq(targetDir)} && bun install && node_modules/.bin/brust shadcn init${components.map((c) => ` ${shq(c)}`).join('')}`
25
+ if (o.workspace) {
26
+ out('--shadcn: this app is a workspace member of a brust checkout; run `bun install` at the repo root, then in the app:')
27
+ out(` node_modules/.bin/brust shadcn init${components.map((c) => ` ${shq(c)}`).join('')}`)
28
+ return 0
29
+ }
30
+ out('\n--shadcn: installing, then running `brust shadcn init` (needs @brust/core 2.0.0-alpha.11 or later)')
31
+ const install = await o.run(['bun', 'install'], targetDir)
32
+ if (install !== 0) {
33
+ out(`create-app: bun install failed (exit ${install}); the project is created. Finish with: ${by}`)
34
+ return install
35
+ }
36
+ const init = await o.run([join(targetDir, 'node_modules/.bin/brust'), 'shadcn', 'init', ...components], targetDir)
37
+ if (init !== 0) {
38
+ out(`create-app: brust shadcn init failed (exit ${init}); the project is created. Run again: cd ${shq(targetDir)} && node_modules/.bin/brust shadcn init${components.map((c) => ` ${shq(c)}`).join('')}`)
39
+ }
40
+ return init
41
+ }
@@ -0,0 +1,10 @@
1
+ // Tailwind CSS v4 in the blank template (gaps round L21, made the default by L28 R29.1): the template ships `app.css`
2
+ // (`@import "tailwindcss"` + `@theme` tokens), the layout imports it (brust builds it and links it in <head>, L19), and
3
+ // the pinned dev dependencies below are in its package.json (R21.1: the app's own; L32 R33.2: the compiler, its Node API
4
+ // and the scanner, one exact version, so the build compiles with the tailwindcss the app pinned). `--tailwind` no longer
5
+ // changes anything; it prints TAILWIND_DEFAULT_NOTE.
6
+
7
+ /** The exact versions brust's own tests build with (the blank template's package.json pins the same). */
8
+ export const TAILWIND_DEV_DEPS = { '@tailwindcss/node': '4.3.3', '@tailwindcss/oxide': '4.3.3', tailwindcss: '4.3.3' }
9
+
10
+ export const TAILWIND_DEFAULT_NOTE = 'create-app: Tailwind CSS v4 is the default; the --tailwind flag does nothing'
@@ -6,10 +6,10 @@ A [brust](https://github.com/AssetsArt/brust) app: React components compiled ahe
6
6
 
7
7
  ```bash
8
8
  bun install
9
- bun run dev # brust build routes.tsx && brust start -> http://127.0.0.1:1337
9
+ bun run dev # brust dev routes.tsx (hot reload) -> http://127.0.0.1:1337
10
10
  ```
11
11
 
12
- Scripts: `dev` (build then start), `build`, `start`, `typecheck`.
12
+ Scripts: `dev` (`brust dev`, with hot reload), `build`, `start`, `typecheck`, `dev:cf` (build for Cloudflare Workers and run `wrangler dev`), `deploy:cf` (build and `wrangler deploy`).
13
13
 
14
14
  ## Layout
15
15
 
@@ -18,6 +18,7 @@ Scripts: `dev` (build then start), `build`, `start`, `typecheck`.
18
18
  - `components/Counter.tsx` — a `useState` counter; the compiler makes it a native component (no React in the browser).
19
19
  - `actions.ts` — an Elysia 2 app on `brust()`, served by Rust under `/_brust/action` (enabled by `[actions]` in `brust.toml`).
20
20
  - `public/` — static files, served at `/public/...`.
21
+ - `wrangler.jsonc` — your Cloudflare bindings (D1, KV, vars); brust owns `main`, `assets` and `compatibility_date`.
21
22
 
22
23
  ## The hello action
23
24
 
@@ -38,5 +39,5 @@ const { data } = await api.hello.get({ query: { name: 'ash' } })
38
39
 
39
40
  ## Docs
40
41
 
41
- - Actions: https://github.com/AssetsArt/brust/blob/v2/docs/actions.md
42
- - The unified store: https://github.com/AssetsArt/brust/blob/v2/docs/store.md
42
+ - Actions: https://github.com/AssetsArt/brust/blob/main/docs/actions.md
43
+ - The unified store: https://github.com/AssetsArt/brust/blob/main/docs/store.md
@@ -1,2 +1,4 @@
1
1
  node_modules/
2
2
  dist/
3
+ .brust/
4
+ .wrangler/
@@ -0,0 +1,32 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Tailwind CSS v4, CSS-first: no tailwind.config. The tokens below are the brust docs palette; each one becomes a
4
+ utility (bg-paper, text-ink, border-rule, ...). A *.module.css that uses @apply starts with
5
+ @reference "../app.css"; (see brust docs/css.md). */
6
+ @theme {
7
+ --color-paper: oklch(94% 0.035 78);
8
+ --color-sheet: oklch(97% 0.02 82);
9
+ --color-ink: oklch(18% 0.035 82);
10
+ --color-ink-2: oklch(36% 0.045 80);
11
+ --color-rule: oklch(76% 0.05 77);
12
+ --color-amber: oklch(68% 0.145 74);
13
+ --color-amber-ink: oklch(46% 0.12 62);
14
+ --font-sans: ui-rounded, "SF Pro Rounded", system-ui, sans-serif;
15
+ }
16
+
17
+ /* Dark mode follows the system: the same tokens, darker values, so every utility switches with them. */
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --color-paper: oklch(20% 0.028 185);
21
+ --color-sheet: oklch(24% 0.032 182);
22
+ --color-ink: oklch(91% 0.02 82);
23
+ --color-ink-2: oklch(77% 0.03 82);
24
+ --color-rule: oklch(36% 0.032 180);
25
+ --color-amber: oklch(74% 0.12 78);
26
+ --color-amber-ink: oklch(81% 0.11 80);
27
+ }
28
+ }
29
+
30
+ :root {
31
+ color-scheme: light dark;
32
+ }
@@ -4,7 +4,11 @@ import { useState } from 'react'
4
4
  export default function Counter() {
5
5
  const [count, setCount] = useState(0)
6
6
  return (
7
- <button type="button" onClick={() => setCount(count + 1)}>
7
+ <button
8
+ type="button"
9
+ className="cursor-pointer rounded-[0.6rem] border border-amber-ink bg-amber px-[1.1rem] py-2 font-semibold text-[oklch(18%_0.035_82)] hover:brightness-106 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-amber-ink"
10
+ onClick={() => setCount(count + 1)}
11
+ >
8
12
  count is {count}
9
13
  </button>
10
14
  )
@@ -1,5 +1,7 @@
1
- // The document root: a plain <html> with <Outlet/> for the page. Static files are served from /public.
1
+ // The document root: a plain <html> with <Outlet/> for the page. app.css (Tailwind CSS v4) is imported here; brust
2
+ // builds it into one hashed file and links it in <head>. Static files are served from /public.
2
3
  import { Outlet } from '@brust/core/routes'
4
+ import '../app.css'
3
5
 
4
6
  export default function Layout() {
5
7
  return (
@@ -9,10 +11,9 @@ export default function Layout() {
9
11
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10
12
  <title>brust app</title>
11
13
  <link rel="icon" href="/public/favicon.svg" />
12
- <link rel="stylesheet" href="/public/app.css" />
13
14
  </head>
14
- <body>
15
- <main>
15
+ <body className="bg-paper font-sans text-ink leading-[1.55]">
16
+ <main className="mx-auto box-border max-w-2xl px-4 py-16">
16
17
  <Outlet />
17
18
  </main>
18
19
  </body>
@@ -4,10 +4,12 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "brust build routes.tsx && brust start",
7
+ "dev": "brust dev routes.tsx",
8
8
  "build": "brust build routes.tsx",
9
9
  "start": "brust start",
10
- "typecheck": "bun check"
10
+ "typecheck": "bun check",
11
+ "dev:cf": "brust dev routes.tsx --target cloudflare",
12
+ "deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc"
11
13
  },
12
14
  "dependencies": {
13
15
  "@brust/core": __BRUST_DEP__,
@@ -17,8 +19,12 @@
17
19
  "react-dom": "^19.2.0"
18
20
  },
19
21
  "devDependencies": {
22
+ "@tailwindcss/node": "4.3.3",
23
+ "@tailwindcss/oxide": "4.3.3",
20
24
  "@types/bun": "^1.4.0",
21
25
  "@types/react": "^19.2.0",
22
- "typescript": "^7.0.2"
26
+ "tailwindcss": "4.3.3",
27
+ "typescript": "^7.0.2",
28
+ "wrangler": "^4.149.0"
23
29
  }
24
30
  }
@@ -1,11 +1,35 @@
1
1
  import Counter from '../components/Counter'
2
2
 
3
+ const link = 'font-semibold text-amber-ink underline underline-offset-[0.2em] hover:decoration-2 focus-visible:rounded focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-amber-ink'
4
+
3
5
  export default function HomePage() {
4
6
  return (
5
- <section>
6
- <h1>Hello from brust</h1>
7
- <p>Edit pages/HomePage.tsx, then run bun run dev again.</p>
8
- <Counter />
9
- </section>
7
+ <>
8
+ <header className="flex items-center gap-[0.9rem]">
9
+ <svg className="flex-none" viewBox="0 0 32 32" width="44" height="44" aria-hidden="true">
10
+ <rect width="32" height="32" rx="8" fill="#062323" />
11
+ <path d="M7 19a9 9 0 0 1 18 0z" fill="#f2a516" />
12
+ <rect x="5" y="20.5" width="22" height="2.2" rx="1.1" fill="#f2a516" />
13
+ <rect x="10" y="24.6" width="12" height="2" rx="1" fill="#f2a516" opacity=".55" />
14
+ </svg>
15
+ <h1 className="m-0 text-4xl leading-[1.1] font-bold tracking-[-0.01em]">brust app</h1>
16
+ </header>
17
+ <p className="mt-4 mb-8 text-lg text-ink-2">Edit pages/HomePage.tsx and save. The page reloads.</p>
18
+ <section className="rounded-2xl border border-rule bg-sheet p-6">
19
+ <Counter />
20
+ <p className="mt-4 mb-0 text-[0.9375rem] text-ink-2">useState only, so this button is native: a signal in the browser, no React shipped.</p>
21
+ </section>
22
+ <nav className="mt-8 flex flex-wrap gap-x-6 gap-y-2" aria-label="Learn more">
23
+ <a className={link} href="https://brust.dev">
24
+ Docs
25
+ </a>
26
+ <a className={link} href="https://brust.dev/docs/routing">
27
+ Routes
28
+ </a>
29
+ <a className={link} href="https://brust.dev/docs/actions">
30
+ Actions
31
+ </a>
32
+ </nav>
33
+ </>
10
34
  )
11
35
  }
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="#ef4444"/><rect x="1" y="14" width="30" height="4" fill="#111"/><circle cx="16" cy="16" r="5" fill="#fff" stroke="#111" stroke-width="2"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#062323"/><path d="M7 19a9 9 0 0 1 18 0z" fill="#f2a516"/><rect x="5" y="20.5" width="22" height="2.2" rx="1.1" fill="#f2a516"/><rect x="10" y="24.6" width="12" height="2" rx="1" fill="#f2a516" opacity=".55"/></svg>
@@ -0,0 +1,9 @@
1
+ // Your Cloudflare config for `bun run dev:cf` / `bun run deploy:cf`.
2
+ // `brust build --target cloudflare` merges this file into dist/cf/wrangler.jsonc and owns these keys:
3
+ // main, assets (the ASSETS binding), compatibility_date, no_bundle, build, rules, site.
4
+ {
5
+ "name": "__PROJECT_NAME__"
6
+ // "d1_databases": [{ "binding": "DB", "database_name": "__PROJECT_NAME__", "database_id": "<id>" }],
7
+ // "kv_namespaces": [{ "binding": "KV", "id": "<id>" }],
8
+ // "vars": { "GREETING": "hello" }
9
+ }
@@ -7,7 +7,7 @@ React islands, and a persistent team built with Elysia actions + SQLite and one
7
7
 
8
8
  ```bash
9
9
  bun install
10
- bun run dev # brust build routes.tsx && brust start -> http://127.0.0.1:1337
10
+ bun run dev # brust dev routes.tsx (hot reload) -> http://127.0.0.1:1337
11
11
  ```
12
12
 
13
13
  Scripts: `dev` (build then start), `build`, `start`, `typecheck`, `snapshot` (needs network; rewrites `data/pokedex.json`).
@@ -26,19 +26,32 @@ at most 6 members and a seventh add answers `409` (`TeamFull`). The team store (
26
26
 
27
27
  - `routes.tsx` — `/`, `/pokedex`, `/pokemon/{name}` (L1 60 s, `?nocache` bypass), `/type-chart` (L1 1 h), `/unified`, and a
28
28
  root-level `*` 404 outside the layout. Pages that show the team carry the cache tag `team`.
29
- - `lib/` — loaders over the snapshot, `team-db.ts` (sqlite, server-only), `team-client.ts` + `api.ts` (the typed actions client).
29
+ - `lib/` — loaders over the snapshot, `team-db.ts` (the team interface: sqlite natively, D1 on Cloudflare via `[cloudflare.alias]`; server-only), `team-client.ts` + `api.ts` (the typed actions client).
30
30
  - `actions.ts` — the Elysia 2 team app on `brust()`; `stores/` — `team` and `unified` (`defineStore`).
31
31
  - `components/`, `pages/` — plain React functions.
32
32
 
33
- ## Regenerating the stylesheet (never hand-edit `public/app.css`)
33
+ ## Cloudflare Workers (preview)
34
34
 
35
35
  ```bash
36
- bunx @tailwindcss/cli@4 -i styles/app.css -o public/app.css --minify
36
+ bun run cf:migrate:local # create the local D1 table (state in .wrangler/)
37
+ bun run dev:cf # build for Workers and run `wrangler dev` (rebuilds on change)
37
38
  ```
38
39
 
39
- (Tailwind must resolve from the input's directory: install `tailwindcss` in the project first.)
40
+ `brust build --target cloudflare` swaps `lib/team-db.ts` for the D1 version (`lib/team-db.d1.ts`) through `[cloudflare.alias]`
41
+ in `brust.toml`; your bindings live in `wrangler.jsonc`. To deploy: `wrangler d1 create __PROJECT_NAME__-team`, paste the
42
+ `database_id` into the **app-root** `wrangler.jsonc` (not `dist/cf/`, which is regenerated), `bun run cf:migrate:remote`,
43
+ then `bun run deploy:cf`.
44
+
45
+ Preview note: a team change refreshes cached team pages only in the isolate that served the action; other isolates catch up
46
+ within the route `ttl` (60 s for `/pokemon/*`, 1 h for `/type-chart`) until the cross-isolate invalidation lands.
47
+
48
+ ## Styling
49
+
50
+ `app.css` (Tailwind CSS v4, CSS-first: no `tailwind.config`) is imported by `components/AppLayout.tsx` and by the 404 page, which renders its
51
+ own `<html>`. Edit `app.css`; brust builds it into one minified, content-hashed file and links it in `<head>`. There is no prebuilt
52
+ stylesheet and no `<link>` to write. `brust dev` swaps the stylesheet in place, without a reload.
40
53
 
41
54
  ## Docs
42
55
 
43
- - Actions: https://github.com/AssetsArt/brust/blob/v2/docs/actions.md
44
- - The unified store: https://github.com/AssetsArt/brust/blob/v2/docs/store.md
56
+ - Actions: https://github.com/AssetsArt/brust/blob/main/docs/actions.md
57
+ - The unified store: https://github.com/AssetsArt/brust/blob/main/docs/store.md
@@ -1,3 +1,5 @@
1
1
  node_modules/
2
2
  dist/
3
3
  data/team.sqlite*
4
+ .wrangler/
5
+ .dev.vars*
@@ -13,21 +13,21 @@ export interface TeamDeps {
13
13
  }
14
14
 
15
15
  export function teamApp({ db, invalidate }: TeamDeps = { db: teamDb, invalidate: cache.invalidate }) {
16
- const reply = () => ({ team: db().list(), max: MAX_TEAM })
16
+ const reply = async () => ({ team: await db().list(), max: MAX_TEAM })
17
17
  // R5: cached pages that render the team carry the tag `team`; drop them after a real change.
18
18
  const changed = () => invalidate({ tags: ['team'] })
19
19
  return new Elysia({ adapter: brust() })
20
20
  .get('/team', () => reply())
21
- .post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ body }) => {
21
+ .post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, async ({ body }) => {
22
22
  const p = pokemonById(body.id)
23
23
  if (!p) return problem(404, { title: 'UnknownPokemon', detail: `no Pokémon #${body.id}` })
24
- const r = db().add(p)
24
+ const r = await db().add(p)
25
25
  if (r === 'full') return problem(409, { title: 'TeamFull', detail: `the team already has ${MAX_TEAM} members`, max: MAX_TEAM })
26
26
  if (r === 'added') changed()
27
27
  return reply()
28
28
  })
29
- .delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ params }) => {
30
- if (db().remove(params.id)) changed()
29
+ .delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, async ({ params }) => {
30
+ if (await db().remove(params.id)) changed()
31
31
  return reply()
32
32
  })
33
33
  }
@@ -1,5 +1,4 @@
1
1
  @import "tailwindcss";
2
- @source "../**/*.{tsx,ts}";
3
2
  @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));
4
3
 
5
4
  @theme {
@@ -3,6 +3,26 @@ address = "127.0.0.1"
3
3
  port = 1337
4
4
 
5
5
  [build]
6
- server_only = ["lib/team-db.ts"]
6
+ server_only = ["lib/team-db"]
7
7
 
8
8
  [actions]
9
+
10
+ # `brust build --target cloudflare` swaps the sqlite roster for the D1 one (the whole facade module).
11
+ [cloudflare.alias]
12
+ "lib/team-db.ts" = "lib/team-db.d1.ts"
13
+
14
+ # A shared second cache tier (needs `bun add @brust/cache-redis`; see the brust docs, "Redis / Dragonfly"):
15
+ # [cache]
16
+ # adapter = "redis" # or "dragonfly"
17
+ # [cache.redis]
18
+ # url = "env:REDIS_URL"
19
+
20
+ # Or, on Cloudflare Workers: a shared second cache tier (KV bodies + a Durable Object invalidation broadcast). Off by default.
21
+ # Needs: bun add @brust/cache-cloudflare, plus the KV and Durable Object bindings in wrangler.jsonc (see the comment there).
22
+ # [cache]
23
+ # adapter = "cloudflare-kv"
24
+ # broadcast = "cloudflare-do"
25
+ # epoch_interval_ms = 5000
26
+ #
27
+ # [cache.cloudflare]
28
+ # kv_min_ttl_s = 300
@@ -1,6 +1,7 @@
1
1
  // components/AppLayout.tsx — the document (S9): plain <html>, <Outlet/> for the leaf, TeamBuilder as a react child.
2
2
  import { Outlet } from '@brust/core/routes'
3
3
  import type { NavItem } from '../lib/types'
4
+ import '../app.css'
4
5
  import NavLink from './NavLink'
5
6
  import TeamBuilder from './TeamBuilder'
6
7
  import ThemeToggle from './ThemeToggle'
@@ -13,7 +14,6 @@ export default function AppLayout(props: { title: string; mode: 'dark' | 'light'
13
14
  <meta name="viewport" content="width=device-width, initial-scale=1" />
14
15
  <title>{props.title}</title>
15
16
  <link rel="icon" href="/public/favicon.svg" />
16
- <link rel="stylesheet" href="/public/app.css" />
17
17
  </head>
18
18
  <body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
19
19
  <header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 dark:border-slate-800 dark:bg-slate-950/80">
@@ -1,7 +1,7 @@
1
1
  // components/TeamBuilder.tsx — react tier on purpose (useReducer for `open`): SSR + idle hydration (S12). The roster is the
2
2
  // unified `team` store: the SSR scope is seeded from the snapshot, so the first client snapshot equals the server paint (D8).
3
3
  import { useStore } from '@brust/core/store/react'
4
- import { useReducer } from 'react'
4
+ import { useReducer, useTransition } from 'react'
5
5
  import { removeMember } from '../lib/team-client'
6
6
  import { team } from '../stores/team'
7
7
 
@@ -9,6 +9,7 @@ const flip = (open: boolean): boolean => !open
9
9
 
10
10
  export default function TeamBuilder() {
11
11
  const { members, max } = useStore(team)
12
+ useTransition()
12
13
  const [open, toggle] = useReducer(flip, false)
13
14
  return (
14
15
  <div className="fixed bottom-5 right-5 z-[200]">
@@ -1,13 +1,14 @@
1
1
  // components/UnifiedIsland.tsx — React island (SSR + hydration). `useStore` alone compiles native, so the "clicks from
2
2
  // React" tally (useReducer) is what puts this on the react tier (D10 asks for an island).
3
3
  import { useStore } from '@brust/core/store/react'
4
- import { useReducer } from 'react'
4
+ import { useReducer, useTransition } from 'react'
5
5
  import { unified } from '../stores/unified'
6
6
 
7
7
  const tick = (n: number): number => n + 1
8
8
 
9
9
  export default function UnifiedIsland() {
10
10
  const { count } = useStore(unified)
11
+ useTransition()
11
12
  const [mine, tally] = useReducer(tick, 0)
12
13
  return (
13
14
  <div className="flex flex-col items-center gap-4">
@@ -0,0 +1,24 @@
1
+ // Ambient types for the Cloudflare Workers surface this example uses (lib/team-db.d1.ts): just the subset of D1 that
2
+ // is called, so `bun check` needs no @cloudflare/workers-types (whose global Request/Response clash with lib DOM).
3
+ interface D1Meta {
4
+ changes: number
5
+ last_row_id: number
6
+ }
7
+ interface D1Result<T = unknown> {
8
+ success: boolean
9
+ results: T[]
10
+ meta: D1Meta
11
+ }
12
+ interface D1PreparedStatement {
13
+ bind(...values: unknown[]): D1PreparedStatement
14
+ all<T = Record<string, unknown>>(): Promise<D1Result<T>>
15
+ first<T = Record<string, unknown>>(): Promise<T | null>
16
+ run(): Promise<D1Result>
17
+ }
18
+ interface D1Database {
19
+ prepare(query: string): D1PreparedStatement
20
+ batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>
21
+ }
22
+ declare module 'cloudflare:workers' {
23
+ export const env: { TEAM_DB?: D1Database }
24
+ }
@@ -9,8 +9,8 @@ import type { BrowseData, DetailData, HomeData, TypeChartCellVM, TypeChartData,
9
9
 
10
10
  const FEATURED = [{ id: 1, name: 'bulbasaur' }, { id: 4, name: 'charmander' }, { id: 7, name: 'squirtle' }, { id: 25, name: 'pikachu' }, { id: 39, name: 'jigglypuff' }, { id: 94, name: 'gengar' }, { id: 143, name: 'snorlax' }, { id: 150, name: 'mewtwo' }]
11
11
  /** Layout loader: seeds the request's store scope, so every layout page paints the DB roster (D4/E7). */
12
- export function layoutLoader(): void {
13
- team.members.set(teamDb().list())
12
+ export async function layoutLoader(): Promise<void> {
13
+ team.members.set(await teamDb().list())
14
14
  team.max.set(MAX_TEAM)
15
15
  }
16
16
  const NAV = [{ href: '/', label: 'Home' }, { href: '/pokedex', label: 'Pokédex' }, { href: '/type-chart', label: 'Type chart' }, { href: '/unified', label: 'Unified' }]
@@ -0,0 +1,47 @@
1
+ // examples/pokedex/lib/team-db.d1.ts — the TeamDb on Cloudflare D1. `brust.toml` [cloudflare.alias] swaps lib/team-db.ts for
2
+ // this file in `brust build --target cloudflare`. The binding is read per call (never at import: the build smoke-imports the
3
+ // bundle with an empty env) and nothing is cached across requests.
4
+ import { env } from 'cloudflare:workers'
5
+ import { MAX_TEAM } from './team'
6
+ import type { AddResult, TeamDb } from './team-db'
7
+ import type { TeamMember } from './types'
8
+
9
+ export type { AddResult, TeamDb }
10
+
11
+ interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
12
+
13
+ const LIST = 'SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid'
14
+ // ONE statement decides the cap: the count check and the insert cannot be split across awaits on D1 (no transaction handle),
15
+ // so the insert is conditional. added_at is a clock that never repeats, as in the sqlite twin.
16
+ const INSERT =
17
+ 'INSERT INTO team (id, name, display_name, num, types, artwork, added_at) ' +
18
+ 'SELECT ?1, ?2, ?3, ?4, ?5, ?6, MAX(?7, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)) ' +
19
+ 'WHERE (SELECT COUNT(*) FROM team) < ?8 ON CONFLICT(id) DO NOTHING'
20
+ const HAS = 'SELECT 1 AS one FROM team WHERE id = ?1'
21
+
22
+ export function openTeamDb(d1: D1Database): TeamDb {
23
+ return {
24
+ async list() {
25
+ const { results } = await d1.prepare(LIST).all<Row>()
26
+ return results.map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num }))
27
+ },
28
+ async add(m: TeamMember): Promise<AddResult> {
29
+ // One batch = one transaction: added (1 row) | present (the id exists, even when full) | full.
30
+ const [ins, has] = await d1.batch([
31
+ d1.prepare(INSERT).bind(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now(), MAX_TEAM),
32
+ d1.prepare(HAS).bind(m.id),
33
+ ])
34
+ if (ins!.meta.changes === 1) return 'added'
35
+ return has!.results.length > 0 ? 'present' : 'full'
36
+ },
37
+ async remove(id: number) {
38
+ return (await d1.prepare('DELETE FROM team WHERE id = ?1').bind(id).run()).meta.changes > 0
39
+ },
40
+ }
41
+ }
42
+
43
+ export function teamDb(): TeamDb {
44
+ const d1 = env.TEAM_DB
45
+ if (!d1) throw new Error('TEAM_DB D1 binding missing: add it to wrangler.jsonc (see README)')
46
+ return openTeamDb(d1)
47
+ }
@@ -0,0 +1,49 @@
1
+ // examples/pokedex/lib/team-db.sqlite.ts — the native TeamDb (server-only via brust.toml [build] server_only = lib/team-db).
2
+ // The team is ONE shared roster, as in 0.1.x, but persisted: every worker thread opens the same file (WAL), so workers never
3
+ // diverge (M3-F E7). Methods return Promises so the interface matches D1 (lib/team-db.d1.ts); the work itself stays synchronous.
4
+ import { Database } from 'bun:sqlite'
5
+ import { mkdirSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { MAX_TEAM } from './team'
8
+ import type { AddResult, TeamDb } from './team-db'
9
+ import type { TeamMember } from './types'
10
+
11
+ export type SqliteTeamDb = TeamDb & { close(): void }
12
+
13
+ export const TEAM_DDL =
14
+ 'CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL)'
15
+
16
+ interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
17
+
18
+ export function openTeamDb(file: string): SqliteTeamDb {
19
+ if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true })
20
+ const db = new Database(file, { create: true })
21
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000')
22
+ db.exec(TEAM_DDL)
23
+ const listQ = db.query<Row, []>('SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid')
24
+ const hasQ = db.query<{ one: number }, [number]>('SELECT 1 AS one FROM team WHERE id = ?')
25
+ const countQ = db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM team')
26
+ const insertQ = db.query('INSERT INTO team (id, name, display_name, num, types, artwork, added_at) VALUES (?, ?, ?, ?, ?, ?, MAX(?, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)))')
27
+ const deleteQ = db.query('DELETE FROM team WHERE id = ?')
28
+ // added_at is a clock that never repeats (MAX(now, last + 1)): rowid IS the Pokémon id, so it cannot break same-ms ties.
29
+ // count check + insert under ONE write lock (.immediate): two workers adding at five members cannot both succeed.
30
+ const addTx = db.transaction((m: TeamMember): AddResult => {
31
+ if (hasQ.get(m.id)) return 'present'
32
+ if ((countQ.get()?.n ?? 0) >= MAX_TEAM) return 'full'
33
+ insertQ.run(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now())
34
+ return 'added'
35
+ })
36
+ return {
37
+ list: async () => listQ.all().map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num })),
38
+ add: async (m) => addTx.immediate(m),
39
+ remove: async (id) => deleteQ.run(id).changes > 0,
40
+ close: () => db.close(),
41
+ }
42
+ }
43
+
44
+ let shared: SqliteTeamDb | null = null
45
+ export const teamDbPath = (): string => process.env.POKEDEX_TEAM_DB || join(process.cwd(), 'data/team.sqlite')
46
+ /** Opened lazily on first use: `brust build` imports routes.tsx, and building must not create the file. */
47
+ export function teamDb(): SqliteTeamDb {
48
+ return (shared ??= openTeamDb(teamDbPath()))
49
+ }
@@ -1,52 +1,13 @@
1
- // examples/pokedex/lib/team-db.ts — server-only (brust.toml [build] server_only). The team is ONE shared roster, as in
2
- // 0.1.x, but persisted: every worker thread opens the same file (WAL), so workers never diverge (M3-F E7).
3
- import { Database } from 'bun:sqlite'
4
- import { mkdirSync } from 'node:fs'
5
- import { dirname, join } from 'node:path'
6
- import { MAX_TEAM } from './team'
1
+ // examples/pokedex/lib/team-db.ts — the TeamDb interface (async: D1 has no sync API) and the native default.
2
+ // `brust build --target cloudflare` swaps this whole module for lib/team-db.d1.ts via brust.toml [cloudflare.alias],
3
+ // so app imports (`./lib/team-db`) never change. Server-only (brust.toml [build] server_only = lib/team-db).
7
4
  import type { TeamMember } from './types'
8
5
 
9
6
  export type AddResult = 'added' | 'present' | 'full'
10
7
  export interface TeamDb {
11
- list(): TeamMember[]
12
- add(m: TeamMember): AddResult
13
- remove(id: number): boolean
14
- close(): void
8
+ list(): Promise<TeamMember[]>
9
+ add(m: TeamMember): Promise<AddResult>
10
+ remove(id: number): Promise<boolean>
15
11
  }
16
12
 
17
- interface Row { id: number; name: string; display_name: string; num: string; types: string; artwork: string }
18
-
19
- export function openTeamDb(file: string): TeamDb {
20
- if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true })
21
- const db = new Database(file, { create: true })
22
- db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000')
23
- db.exec(
24
- 'CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL)',
25
- )
26
- const listQ = db.query<Row, []>('SELECT id, name, display_name, num, types, artwork FROM team ORDER BY added_at, rowid')
27
- const hasQ = db.query<{ one: number }, [number]>('SELECT 1 AS one FROM team WHERE id = ?')
28
- const countQ = db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM team')
29
- const insertQ = db.query('INSERT INTO team (id, name, display_name, num, types, artwork, added_at) VALUES (?, ?, ?, ?, ?, ?, MAX(?, (SELECT COALESCE(MAX(added_at), 0) + 1 FROM team)))')
30
- const deleteQ = db.query('DELETE FROM team WHERE id = ?')
31
- // added_at is a clock that never repeats (MAX(now, last + 1)): rowid IS the Pokémon id, so it cannot break same-ms ties.
32
- // count check + insert under ONE write lock (.immediate): two workers adding at five members cannot both succeed.
33
- const addTx = db.transaction((m: TeamMember): AddResult => {
34
- if (hasQ.get(m.id)) return 'present'
35
- if ((countQ.get()?.n ?? 0) >= MAX_TEAM) return 'full'
36
- insertQ.run(m.id, m.name, m.displayName, m.num, JSON.stringify(m.types), m.artwork, Date.now())
37
- return 'added'
38
- })
39
- return {
40
- list: () => listQ.all().map((r) => ({ id: r.id, name: r.name, displayName: r.display_name, types: JSON.parse(r.types) as string[], artwork: r.artwork, num: r.num })),
41
- add: (m) => addTx.immediate(m),
42
- remove: (id) => deleteQ.run(id).changes > 0,
43
- close: () => db.close(),
44
- }
45
- }
46
-
47
- let shared: TeamDb | null = null
48
- export const teamDbPath = (): string => process.env.POKEDEX_TEAM_DB || join(process.cwd(), 'data/team.sqlite')
49
- /** Opened lazily on first use: `brust build` imports routes.tsx, and building must not create the file. */
50
- export function teamDb(): TeamDb {
51
- return (shared ??= openTeamDb(teamDbPath()))
52
- }
13
+ export { openTeamDb, type SqliteTeamDb, teamDb, teamDbPath } from './team-db.sqlite'
@@ -0,0 +1,2 @@
1
+ -- pokedex team roster (lib/team-db.sqlite.ts TEAM_DDL; the same table on D1)
2
+ CREATE TABLE IF NOT EXISTS team (id INTEGER PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, num TEXT NOT NULL, types TEXT NOT NULL, artwork TEXT NOT NULL, added_at INTEGER NOT NULL);
@@ -4,8 +4,12 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "brust build routes.tsx && brust start",
7
+ "dev": "brust dev routes.tsx",
8
8
  "build": "brust build routes.tsx",
9
+ "dev:cf": "wrangler d1 migrations apply TEAM_DB --local --persist-to .wrangler/state && brust dev routes.tsx --target cloudflare",
10
+ "deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc",
11
+ "cf:migrate:local": "wrangler d1 migrations apply TEAM_DB --local --persist-to .wrangler/state",
12
+ "cf:migrate:remote": "wrangler d1 migrations apply TEAM_DB --remote",
9
13
  "start": "brust start",
10
14
  "snapshot": "bun scripts/snapshot.ts",
11
15
  "typecheck": "bun check"
@@ -18,8 +22,12 @@
18
22
  "react-dom": "^19.2.0"
19
23
  },
20
24
  "devDependencies": {
25
+ "@tailwindcss/node": "4.3.3",
26
+ "@tailwindcss/oxide": "4.3.3",
21
27
  "@types/bun": "^1.4.0",
22
28
  "@types/react": "^19.2.0",
23
- "typescript": "^7.0.2"
29
+ "tailwindcss": "4.3.3",
30
+ "typescript": "^7.0.2",
31
+ "wrangler": "^4.149.0"
24
32
  }
25
33
  }
@@ -1,6 +1,8 @@
1
+ import '../app.css'
2
+
1
3
  export default function NotFoundPage() {
2
4
  return (
3
- <html lang="en" data-mode="dark"><head><meta charSet="utf-8" /><title>Not found · PokéDex</title><link rel="stylesheet" href="/public/app.css" /></head>
5
+ <html lang="en" data-mode="dark"><head><meta charSet="utf-8" /><title>Not found · PokéDex</title></head>
4
6
  <body className="min-h-screen bg-slate-50 dark:bg-slate-950"><main className="mx-auto max-w-md py-16 text-center"><div className="text-6xl font-black text-brand-500">404</div><h1 className="mt-4 text-2xl font-extrabold">Nothing here</h1><a href="/" className="mt-6 inline-block rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-semibold text-white no-underline">Back home</a></main></body>
5
7
  </html>
6
8
  )
@@ -0,0 +1,9 @@
1
+ // Bindings for `brust build --target cloudflare` (merged into dist/cf/wrangler.jsonc).
2
+ // brust owns main, assets and compatibility_date. Add the database_id from `wrangler d1 create pokedex-team` before deploy.
3
+ {
4
+ // To enable the shared cache tier (brust.toml [cache]), add these beside d1_databases:
5
+ // "kv_namespaces": [{ "binding": "BRUST_CACHE", "id": "<wrangler kv namespace create brust-cache>" }],
6
+ // "durable_objects": { "bindings": [{ "name": "BRUST_CACHE_DO", "class_name": "BrustCacheDO" }] },
7
+ // "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }],
8
+ "d1_databases": [{ "binding": "TEAM_DB", "database_name": "pokedex-team", "migrations_dir": "migrations" }]
9
+ }
@@ -1,14 +0,0 @@
1
- :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
2
- body { margin: 0; }
3
- main { max-width: 40rem; margin: 4rem auto; padding: 0 1.5rem; line-height: 1.5; }
4
- h1 { font-size: 2rem; margin: 0 0 0.5rem; }
5
- button {
6
- cursor: pointer;
7
- font: inherit;
8
- padding: 0.5rem 1rem;
9
- border: 1px solid currentColor;
10
- border-radius: 0.5rem;
11
- background: transparent;
12
- color: inherit;
13
- }
14
- button:hover { opacity: 0.8; }
@@ -1,3 +0,0 @@
1
- /* generated: bunx @tailwindcss/cli@4 -i styles/app.css -o public/app.css --minify — do not edit */
2
- /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
3
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-emerald-500:oklch(69.6% .17 162.48);--color-indigo-700:oklch(45.7% .24 277.023);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--drop-shadow-xl:0 9px 7px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:oklch(97% .02 264);--color-brand-500:oklch(55% .2 264);--color-brand-600:oklch(49% .2 264);--color-brand-700:oklch(43% .19 264)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.left-0{left:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[200\]{z-index:200}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-56{height:calc(var(--spacing) * 56)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-36{min-width:calc(var(--spacing) * 36)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[2\.75rem_repeat\(18\,minmax\(1\.75rem\,1fr\)\)\]{grid-template-columns:2.75rem repeat(18,minmax(1.75rem,1fr))}.grid-cols-\[3rem_2\.5rem_1fr\]{grid-template-columns:3rem 2.5rem 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-px{gap:1px}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}.self-start{align-self:flex-start}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-l-xl{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-r-xl{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-brand-500{border-color:var(--color-brand-500)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-brand-50{background-color:var(--color-brand-50)}.bg-brand-50\/60{background-color:#f0f5ff99}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab, var(--color-brand-50) 60%, transparent)}}.bg-brand-500{background-color:var(--color-brand-500)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-rose-400{background-color:var(--color-rose-400)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-white\/25{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.bg-white\/25{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-500{--tw-gradient-from:var(--color-brand-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-brand-600{--tw-gradient-via:var(--color-brand-600);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-indigo-700{--tw-gradient-to:var(--color-indigo-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-7{padding-block:calc(var(--spacing) * 7)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pl-1{padding-left:var(--spacing)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-brand-500{color:var(--color-brand-500)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-slate-100{color:var(--color-slate-100)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-white{color:var(--color-white)}.text-white\/85{color:#ffffffd9}@supports (color:color-mix(in lab, red, red)){.text-white\/85{color:color-mix(in oklab, var(--color-white) 85%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.ring-white\/20{--tw-ring-color:#fff3}@supports (color:color-mix(in lab, red, red)){.ring-white\/20{--tw-ring-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.ring-white\/30{--tw-ring-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.ring-white\/30{--tw-ring-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.drop-shadow-xl{--tw-drop-shadow-size:drop-shadow(0 9px 7px var(--tw-drop-shadow-color,#0000001a));--tw-drop-shadow:drop-shadow(var(--drop-shadow-xl));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:relative:hover{position:relative}.hover\:z-10:hover{z-index:10}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-brand-500\/50:hover{border-color:#3266e480}@supports (color:color-mix(in lab, red, red)){.hover\:border-brand-500\/50:hover{border-color:color-mix(in oklab, var(--color-brand-500) 50%, transparent)}}.hover\:border-slate-500:hover{border-color:var(--color-slate-500)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-brand-500:hover{--tw-ring-color:var(--color-brand-500)}.hover\:ring-inset:hover{--tw-ring-inset:inset}}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-12{padding-inline:calc(var(--spacing) * 12)}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media (min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}.dark\:border-brand-500:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-brand-500)}.dark\:border-slate-600:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-slate-600)}.dark\:border-slate-700:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-slate-700)}.dark\:border-slate-800:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-slate-800)}.dark\:bg-brand-500\/10:where([data-mode=dark],[data-mode=dark] *){background-color:#3266e41a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand-500\/10:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-brand-500) 10%, transparent)}}.dark\:bg-brand-600\/20:where([data-mode=dark],[data-mode=dark] *){background-color:#2253cf33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand-600\/20:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-brand-600) 20%, transparent)}}.dark\:bg-rose-500:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-rose-500)}.dark\:bg-slate-700:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-700)}.dark\:bg-slate-800:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-800)}.dark\:bg-slate-800\/60:where([data-mode=dark],[data-mode=dark] *){background-color:#1d293d99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-800\/60:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-slate-800) 60%, transparent)}}.dark\:bg-slate-900:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-900)}.dark\:bg-slate-900\/90:where([data-mode=dark],[data-mode=dark] *){background-color:#0f172be6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-900\/90:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-slate-900) 90%, transparent)}}.dark\:bg-slate-950:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-950)}.dark\:bg-slate-950\/80:where([data-mode=dark],[data-mode=dark] *){background-color:#020618cc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-950\/80:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-slate-950) 80%, transparent)}}.dark\:text-brand-50:where([data-mode=dark],[data-mode=dark] *){color:var(--color-brand-50)}.dark\:text-slate-100:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-100)}.dark\:text-slate-200:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-200)}.dark\:text-slate-300:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-300)}.dark\:text-slate-400:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-400)}.dark\:text-slate-600:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-600)}.dark\:text-white:where([data-mode=dark],[data-mode=dark] *){color:var(--color-white)}.dark\:ring-slate-700:where([data-mode=dark],[data-mode=dark] *){--tw-ring-color:var(--color-slate-700)}@media (hover:hover){.dark\:hover\:bg-slate-800:where([data-mode=dark],[data-mode=dark] *):hover{background-color:var(--color-slate-800)}.dark\:hover\:text-slate-200:where([data-mode=dark],[data-mode=dark] *):hover{color:var(--color-slate-200)}}}button{cursor:pointer}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}