@brust/create-app 0.0.0-stage → 2.0.0-alpha.1

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 (62) hide show
  1. package/README.md +19 -2
  2. package/bin/create-app.ts +4 -0
  3. package/package.json +27 -4
  4. package/src/args.ts +56 -0
  5. package/src/cli.ts +58 -0
  6. package/src/copy.ts +44 -0
  7. package/src/dep.ts +60 -0
  8. package/src/templates.ts +64 -0
  9. package/templates/blank/README.md.tmpl +42 -0
  10. package/templates/blank/_gitignore +2 -0
  11. package/templates/blank/actions.ts +13 -0
  12. package/templates/blank/brust.toml +6 -0
  13. package/templates/blank/components/Counter.tsx +11 -0
  14. package/templates/blank/components/Layout.tsx +21 -0
  15. package/templates/blank/package.json.tmpl +24 -0
  16. package/templates/blank/pages/HomePage.tsx +11 -0
  17. package/templates/blank/public/app.css +14 -0
  18. package/templates/blank/public/favicon.svg +1 -0
  19. package/templates/blank/routes.tsx +5 -0
  20. package/templates/blank/tsconfig.json +37 -0
  21. package/templates/pokedex/README.md.tmpl +44 -0
  22. package/templates/pokedex/_gitignore +3 -0
  23. package/templates/pokedex/actions.ts +37 -0
  24. package/templates/pokedex/brust.toml +8 -0
  25. package/templates/pokedex/components/AddToTeamButton.tsx +37 -0
  26. package/templates/pokedex/components/AppLayout.tsx +32 -0
  27. package/templates/pokedex/components/Breadcrumb.tsx +4 -0
  28. package/templates/pokedex/components/DexCard.tsx +11 -0
  29. package/templates/pokedex/components/DexFilter.tsx +26 -0
  30. package/templates/pokedex/components/HeroSearch.tsx +14 -0
  31. package/templates/pokedex/components/NavLink.tsx +6 -0
  32. package/templates/pokedex/components/TeamBuilder.tsx +32 -0
  33. package/templates/pokedex/components/ThemeToggle.tsx +16 -0
  34. package/templates/pokedex/components/TypeBadge.tsx +5 -0
  35. package/templates/pokedex/components/UnifiedIsland.tsx +28 -0
  36. package/templates/pokedex/components/UnifiedNative.tsx +20 -0
  37. package/templates/pokedex/components/UnifiedStoreNode.tsx +14 -0
  38. package/templates/pokedex/data/pokedex.json +8767 -0
  39. package/templates/pokedex/lib/api.ts +5 -0
  40. package/templates/pokedex/lib/filter.ts +6 -0
  41. package/templates/pokedex/lib/format.ts +4 -0
  42. package/templates/pokedex/lib/loaders.ts +192 -0
  43. package/templates/pokedex/lib/pokeapi.ts +43 -0
  44. package/templates/pokedex/lib/team-client.ts +26 -0
  45. package/templates/pokedex/lib/team-db.ts +52 -0
  46. package/templates/pokedex/lib/team.ts +2 -0
  47. package/templates/pokedex/lib/types.ts +126 -0
  48. package/templates/pokedex/package.json.tmpl +25 -0
  49. package/templates/pokedex/pages/BrowsePage.tsx +15 -0
  50. package/templates/pokedex/pages/DetailPage.tsx +233 -0
  51. package/templates/pokedex/pages/HomePage.tsx +129 -0
  52. package/templates/pokedex/pages/NotFoundPage.tsx +7 -0
  53. package/templates/pokedex/pages/TypeChart.tsx +72 -0
  54. package/templates/pokedex/pages/UnifiedPage.tsx +24 -0
  55. package/templates/pokedex/public/app.css +3 -0
  56. package/templates/pokedex/public/favicon.svg +1 -0
  57. package/templates/pokedex/routes.tsx +27 -0
  58. package/templates/pokedex/scripts/snapshot.ts +53 -0
  59. package/templates/pokedex/stores/team.ts +6 -0
  60. package/templates/pokedex/stores/unified.ts +4 -0
  61. package/templates/pokedex/styles/app.css +14 -0
  62. package/templates/pokedex/tsconfig.json +37 -0
@@ -0,0 +1,5 @@
1
+ // examples/pokedex/lib/api.ts — browser-safe; the App import is type-only (the elysia lane's browser guard refuses a value import).
2
+ import { client } from '@brust/core/client'
3
+ import type { App } from '../actions'
4
+
5
+ export const api = client<App>()
@@ -0,0 +1,6 @@
1
+ import type { DexCard } from './types'
2
+ export function filterSort(items: DexCard[], q: string, az: boolean): DexCard[] {
3
+ const needle = q.trim().toLowerCase()
4
+ const out = needle ? items.filter((c) => c.name.includes(needle)) : items.slice()
5
+ return az ? out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) : out
6
+ }
@@ -0,0 +1,4 @@
1
+ export const fmtHeight = (dm: number) => `${(dm / 10).toFixed(1)} m`
2
+ export const fmtWeight = (hg: number) => `${(hg / 10).toFixed(1)} kg`
3
+ export const tint = (type: string) => ({ normal: '#9099a1', fire: '#ef7444', water: '#4d90d5', grass: '#63bb5b', electric: '#f5c84b', ice: '#74cec0', fighting: '#ce4069', poison: '#ab6ac8', ground: '#d97746', flying: '#8fa8dd', psychic: '#f06fa0', bug: '#90c12c', rock: '#c7b78b', ghost: '#5269ac', dragon: '#0a6dc4', dark: '#5a5366', steel: '#5a8ea1', fairy: '#ec8fe6' } as Record<string, string>)[type] ?? '#888888'
4
+ export const label = (type: string) => type.charAt(0).toUpperCase() + type.slice(1)
@@ -0,0 +1,192 @@
1
+ // examples/pokedex/lib/loaders.ts
2
+ import { type LoaderCtx, type LoaderReq, notFound, type Verdict } from '@brust/core/routes'
3
+ import { team } from '../stores/team'
4
+ import { unified } from '../stores/unified'
5
+ import { ALL_TYPES, artwork, cap, fetchEvolution, fetchList, fetchPokemon, fetchSpecies, fetchTypeRelations, pad, STAT_LABEL, statBucket, TYPE_COLOR } from './pokeapi'
6
+ import { MAX_TEAM } from './team'
7
+ import { teamDb } from './team-db'
8
+ import type { BrowseData, DetailData, HomeData, TypeChartCellVM, TypeChartData, TypeChartRowVM } from './types'
9
+
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
+ /** 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())
14
+ team.max.set(MAX_TEAM)
15
+ }
16
+ const NAV = [{ href: '/', label: 'Home' }, { href: '/pokedex', label: 'Pokédex' }, { href: '/type-chart', label: 'Type chart' }, { href: '/unified', label: 'Unified' }]
17
+ const card = (p: { id: number; name: string }) => ({ id: p.id, name: p.name, displayName: cap(p.name), num: pad(p.id), artwork: artwork(p.id), detailHref: `/pokemon/${p.name}` })
18
+
19
+ /** Chrome every leaf returns: AppLayout reads it from the merged loader context (child keys win). */
20
+ const chrome = (req: LoaderReq, path: string, title: string, crumb: string) => ({
21
+ title, crumb,
22
+ mode: (req.cookies.mode === 'light' ? 'light' : 'dark') as 'light' | 'dark',
23
+ nav: NAV.map((n) => ({ ...n, active: n.href === path })),
24
+ })
25
+
26
+ export async function homeLoader({ req, path }: LoaderCtx): Promise<HomeData> {
27
+ return { ...chrome(req, path, 'PokéDex · built with brust', 'Home'), featured: FEATURED.map(card), types: ALL_TYPES }
28
+ }
29
+ export async function browseLoader({ req, path }: LoaderCtx): Promise<BrowseData> {
30
+ const q = (req.search.q ?? '').trim().toLowerCase()
31
+ const { results } = await fetchList(0, 151)
32
+ return { ...chrome(req, path, 'Pokédex · Browse', 'Pokédex'), q, items: results.filter((r) => !q || r.name.includes(q)).map(card) }
33
+ }
34
+ const BAR_COLOR: Record<string, string> = { hi: '#16a34a', mid: '#0ea5e9', low: '#f59e0b', min: '#ef4444' }
35
+ export async function detailLoader({ params, req, path }: LoaderCtx<{ name: string }>): Promise<DetailData | Verdict> {
36
+ const name = params.name ?? ''
37
+ const p = await fetchPokemon(name)
38
+ if (!p) return notFound(emptyDetail(req, path, name))
39
+ const species = await fetchSpecies(p.id)
40
+ const evo = await fetchEvolution(species.evolutionUrl)
41
+ const tint = TYPE_COLOR[p.types[0] ?? 'normal'] ?? '#888888'
42
+ return {
43
+ ...chrome(req, path, `${cap(p.name)} · PokéDex`, cap(p.name)),
44
+ notFound: false, name: p.name, id: p.id, displayName: cap(p.name), num: pad(p.id), artwork: p.artwork,
45
+ genus: species.genus, flavorText: species.flavorText,
46
+ height: p.height, weight: p.weight, // raw: DetailPage's job formats them (D4)
47
+ typeNames: p.types, // TypeBadge per row (D4)
48
+ heroBg: `linear-gradient(160deg, ${tint}33, transparent 70%)`,
49
+ stats: p.stats.map((s) => ({ label: STAT_LABEL[s.name] ?? s.name, base: s.base, barWidth: `${Math.min(100, Math.round((s.base / 200) * 100))}%`, barColor: BAR_COLOR[statBucket(s.base)] ?? '#0ea5e9' })),
50
+ statTotal: p.stats.reduce((a, s) => a + s.base, 0),
51
+ abilities: p.abilities.map((a) => ({ displayName: cap(a), initial: a.charAt(0).toUpperCase(), iconColor: tint })),
52
+ hasAbilities: p.abilities.length > 0,
53
+ evolution: evo.map((s, i) => ({ id: s.id, displayName: cap(s.name), num: pad(s.id), artwork: artwork(s.id), detailHref: `/pokemon/${s.name}`, levelLabel: s.minLevel != null ? `Lv ${s.minLevel}` : '', isFirst: i === 0, showLevel: i > 0 && s.minLevel != null, isCurrent: s.id === p.id })),
54
+ hasEvolution: evo.length > 1,
55
+ abilityCount: p.abilities.length,
56
+ }
57
+ }
58
+ function emptyDetail(req: LoaderReq, path: string, name: string): DetailData {
59
+ return { ...chrome(req, path, `${cap(name)} · PokéDex`, cap(name)), notFound: true, name, id: 0, displayName: cap(name), num: '', artwork: '', genus: '', flavorText: '', height: 0, weight: 0, typeNames: [], heroBg: '', stats: [], statTotal: 0, abilities: [], hasAbilities: false, evolution: [], hasEvolution: false, abilityCount: 0 }
60
+ }
61
+ const SHORT: Record<string, string> = {
62
+ normal: 'NOR',
63
+ fire: 'FIR',
64
+ water: 'WAT',
65
+ electric: 'ELE',
66
+ grass: 'GRA',
67
+ ice: 'ICE',
68
+ fighting: 'FIG',
69
+ poison: 'POI',
70
+ ground: 'GRO',
71
+ flying: 'FLY',
72
+ psychic: 'PSY',
73
+ bug: 'BUG',
74
+ rock: 'ROC',
75
+ ghost: 'GHO',
76
+ dragon: 'DRA',
77
+ dark: 'DAR',
78
+ steel: 'STE',
79
+ fairy: 'FAI',
80
+ }
81
+
82
+ // Effectiveness → static Tailwind utility class string. These are literals in a
83
+ // .ts file scanned by `@source`, so the scanner sees every class. The header /
84
+ // row-head / corner / data cells share a base sizing class.
85
+ // Effectiveness → static Tailwind utility class string. Every cell gets a solid
86
+ // fill so the `gap-px` over a slate gridline background reads as a continuous
87
+ // grid (the old design left 1× cells transparent → black voids you couldn't
88
+ // trace). `hover:` lifts a cell with an inset brand ring to pinpoint a matchup.
89
+ const CELL_BASE =
90
+ 'flex items-center justify-center text-xs font-bold tabular-nums aspect-square transition-colors hover:relative hover:z-10 hover:ring-2 hover:ring-inset hover:ring-brand-500'
91
+ const HEAD_BASE =
92
+ 'flex items-center justify-center text-[10px] font-bold uppercase tracking-tight text-white aspect-square'
93
+ // Fill hierarchy so the grid reads as FULL (every cell visibly distinct from
94
+ // the slate gridline showing through `gap-px`):
95
+ // light: card white < gridline slate-200, normal slate-50, 0 slate-800
96
+ // dark: card slate-900 < gridline slate-700, normal slate-800, 0 slate-950
97
+ // (normal must NOT equal the card colour or cells vanish; 0 must differ from
98
+ // normal or "no effect" looks like a blank.)
99
+ const CELL_CLASS: Record<string, string> = {
100
+ super: `${CELL_BASE} bg-emerald-500 text-white`,
101
+ weak: `${CELL_BASE} bg-rose-400 text-white dark:bg-rose-500`,
102
+ none: `${CELL_BASE} bg-slate-800 text-slate-100 dark:bg-slate-950 dark:text-slate-400`,
103
+ normal: `${CELL_BASE} bg-slate-50 text-slate-300 dark:bg-slate-800 dark:text-slate-600`,
104
+ }
105
+
106
+ function buildRows(relations: Record<string, number>[]): TypeChartRowVM[] {
107
+ const rows: TypeChartRowVM[] = []
108
+
109
+ const headerCells: TypeChartCellVM[] = [
110
+ {
111
+ id: '0-0',
112
+ className: `${HEAD_BASE} sticky left-0 top-0 z-20 text-[9px]`,
113
+ content: 'ATK/DEF',
114
+ title: 'Attacking / Defending',
115
+ bg: '#334155', // slate-700
116
+ },
117
+ ]
118
+ ALL_TYPES.forEach((def, j) => {
119
+ headerCells.push({
120
+ id: `0-${j + 1}`,
121
+ className: `${HEAD_BASE} sticky top-0 z-10`,
122
+ content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
123
+ title: cap(def),
124
+ bg: TYPE_COLOR[def] ?? '#888888',
125
+ })
126
+ })
127
+ rows.push({ id: '0', cells: headerCells })
128
+
129
+ ALL_TYPES.forEach((atk, i) => {
130
+ const rel = relations[i] ?? {}
131
+ const rowCells: TypeChartCellVM[] = [
132
+ {
133
+ id: `${i + 1}-0`,
134
+ className: `${HEAD_BASE} sticky left-0 z-10`,
135
+ content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
136
+ title: cap(atk),
137
+ bg: TYPE_COLOR[atk] ?? '#888888',
138
+ },
139
+ ]
140
+ ALL_TYPES.forEach((def, j) => {
141
+ const mult = rel[def]
142
+ const id = `${i + 1}-${j + 1}`
143
+ if (mult === 2)
144
+ rowCells.push({
145
+ id,
146
+ className: CELL_CLASS.super!,
147
+ content: '2',
148
+ title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
149
+ bg: '',
150
+ })
151
+ else if (mult === 0.5)
152
+ rowCells.push({
153
+ id,
154
+ className: CELL_CLASS.weak!,
155
+ content: '½',
156
+ title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
157
+ bg: '',
158
+ })
159
+ else if (mult === 0)
160
+ rowCells.push({
161
+ id,
162
+ className: CELL_CLASS.none!,
163
+ content: '0',
164
+ title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
165
+ bg: '',
166
+ })
167
+ else
168
+ rowCells.push({
169
+ id,
170
+ className: CELL_CLASS.normal!,
171
+ content: '',
172
+ title: `${cap(atk)} → ${cap(def)}: 1×`,
173
+ bg: '',
174
+ })
175
+ })
176
+ rows.push({ id: String(i + 1), cells: rowCells })
177
+ })
178
+
179
+ return rows
180
+ }
181
+
182
+ export async function typeChartLoader({ req, path }: LoaderCtx): Promise<TypeChartData> {
183
+ const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
184
+ const rows: TypeChartRowVM[] = buildRows(relations)
185
+ return { ...chrome(req, path, 'PokéDex · type chart', 'Type chart'), rows }
186
+ }
187
+
188
+ export async function unifiedLoader({ req, path }: LoaderCtx) {
189
+ const start = Math.max(0, Math.min(1_000_000, Math.trunc(Number(req.search.start) || 0)))
190
+ unified.count.set(start)
191
+ return chrome(req, path, 'Unified store · PokéDex', 'Unified')
192
+ }
@@ -0,0 +1,43 @@
1
+ // examples/pokedex/lib/pokeapi.ts — the 0.1.x helper surface, served from data/pokedex.json (no network).
2
+ import snap from '../data/pokedex.json'
3
+ import type { TeamMember } from './types'
4
+
5
+ export interface RawPokemon { id: number; name: string; types: string[]; stats: { name: string; base: number }[]; height: number; weight: number; abilities: string[]; artwork: string }
6
+ export interface RawSpecies { flavorText: string; genus: string; evolutionUrl: string }
7
+ export interface RawEvolutionStage { id: number; name: string; minLevel: number | null }
8
+
9
+ const byName = new Map(snap.pokemon.map((p) => [p.name, p]))
10
+ const byId = new Map(snap.pokemon.map((p) => [p.id, p]))
11
+
12
+ export const artwork = (id: number): string => byId.get(id)?.artwork ?? `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`
13
+ export const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' ') : s)
14
+ export const pad = (n: number): string => `#${String(n).padStart(4, '0')}`
15
+ export const TYPE_COLOR: Record<string, string> = { normal: '#9099a1', fire: '#ef7444', water: '#4d90d5', grass: '#63bb5b', electric: '#f5c84b', ice: '#74cec0', fighting: '#ce4069', poison: '#ab6ac8', ground: '#d97746', flying: '#8fa8dd', psychic: '#f06fa0', bug: '#90c12c', rock: '#c7b78b', ghost: '#5269ac', dragon: '#0a6dc4', dark: '#5a5366', steel: '#5a8ea1', fairy: '#ec8fe6' }
16
+ export const STAT_LABEL: Record<string, string> = { hp: 'HP', attack: 'Atk', defense: 'Def', 'special-attack': 'Sp.Atk', 'special-defense': 'Sp.Def', speed: 'Spd' }
17
+ export const statBucket = (base: number): string => (base >= 100 ? 'hi' : base >= 60 ? 'mid' : base >= 35 ? 'low' : 'min')
18
+ export const ALL_TYPES = ['normal','fire','water','electric','grass','ice','fighting','poison','ground','flying','psychic','bug','rock','ghost','dragon','dark','steel','fairy']
19
+
20
+ export async function fetchList(offset: number, limit: number) {
21
+ return { results: snap.pokemon.slice(offset, offset + limit).map((p) => ({ id: p.id, name: p.name })), total: snap.pokemon.length }
22
+ }
23
+ export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
24
+ const p = byName.get(name)
25
+ return p ? { id: p.id, name: p.name, types: p.types, stats: p.stats, height: p.height, weight: p.weight, abilities: p.abilities, artwork: p.artwork } : null
26
+ }
27
+ /** `evolutionUrl` is the Pokémon id as a string: the key `fetchEvolution` reads (0.1.x carried a URL). */
28
+ export async function fetchSpecies(id: number): Promise<RawSpecies> {
29
+ const p = byId.get(id)
30
+ return p ? { flavorText: p.flavorText, genus: p.genus, evolutionUrl: String(id) } : { flavorText: '', genus: '', evolutionUrl: '' }
31
+ }
32
+ export async function fetchEvolution(key: string): Promise<RawEvolutionStage[]> {
33
+ return key ? (byId.get(Number(key))?.evolution ?? []) : []
34
+ }
35
+ export async function fetchTypeRelations(type: string): Promise<Record<string, number>> {
36
+ return (snap.types as Record<string, Record<string, number>>)[type] ?? {}
37
+ }
38
+
39
+ /** The roster member for a Pokémon id, built from the offline snapshot (so a client cannot forge display data). */
40
+ export function pokemonById(id: number): TeamMember | null {
41
+ const p = byId.get(id)
42
+ return p ? { id: p.id, name: p.name, displayName: cap(p.name), num: pad(p.id), types: p.types, artwork: p.artwork } : null
43
+ }
@@ -0,0 +1,26 @@
1
+ // examples/pokedex/lib/team-client.ts — the one place UI code mutates the team; every reply is written to the store.
2
+ import { team } from '../stores/team'
3
+ import { api } from './api'
4
+ import type { TeamMember } from './types'
5
+
6
+ type Reply = { data: { team: TeamMember[]; max: number } | null; error: { status: unknown } | null }
7
+ const apply = (r: Reply): void => {
8
+ if (r.data) {
9
+ team.members.set(r.data.team)
10
+ team.max.set(r.data.max)
11
+ }
12
+ }
13
+ export async function refreshTeam(): Promise<void> {
14
+ apply(await api.team.get())
15
+ }
16
+ export async function removeMember(id: number): Promise<void> {
17
+ apply(await api.team({ id }).delete())
18
+ }
19
+ export async function addMember(id: number): Promise<void> {
20
+ const r = await api.team.post({ id })
21
+ if (r.error?.status === 409) return refreshTeam() // someone else filled the team: show the truth
22
+ apply(r)
23
+ }
24
+ export async function toggleMember(id: number, inTeam: boolean): Promise<void> {
25
+ return inTeam ? removeMember(id) : addMember(id)
26
+ }
@@ -0,0 +1,52 @@
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'
7
+ import type { TeamMember } from './types'
8
+
9
+ export type AddResult = 'added' | 'present' | 'full'
10
+ export interface TeamDb {
11
+ list(): TeamMember[]
12
+ add(m: TeamMember): AddResult
13
+ remove(id: number): boolean
14
+ close(): void
15
+ }
16
+
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
+ }
@@ -0,0 +1,2 @@
1
+ // examples/pokedex/lib/team.ts — isomorphic: stores, the client and the server all import it.
2
+ export const MAX_TEAM = 6
@@ -0,0 +1,126 @@
1
+ // Domain + view-model types for the PokéDex example.
2
+ //
3
+ // Loaders return plain data; components are ordinary React functions.
4
+
5
+ /** Team roster member. */
6
+ export interface TeamMember {
7
+ id: number
8
+ name: string
9
+ displayName: string
10
+ types: string[]
11
+ artwork: string
12
+ num: string
13
+ }
14
+
15
+ /** Chrome view-model the router-level AppLayout reads from the MERGED loader
16
+ * context. Each leaf loader returns these fields; the chain-loader merge folds
17
+ * them into the one flat jinja context AppLayout's template reads. */
18
+ export interface NavItem {
19
+ href: string
20
+ label: string
21
+ active: boolean
22
+ }
23
+
24
+ export interface ChromeData {
25
+ title: string
26
+ crumb: string
27
+ mode: 'dark' | 'light'
28
+ nav: NavItem[]
29
+ }
30
+
31
+ /** A single dex grid cell — derived from the list endpoint alone (no detail
32
+ * fetch). */
33
+ export interface DexCard {
34
+ id: number
35
+ name: string
36
+ displayName: string // "Bulbasaur"
37
+ num: string // "#0001"
38
+ artwork: string // CDN URL derived from id
39
+ detailHref: string // "/pokemon/bulbasaur"
40
+ }
41
+
42
+ /** One base-stat bar row on the detail page. All formatting precomputed. */
43
+ export interface StatBarVM {
44
+ label: string // "HP" / "Atk" …
45
+ base: number // raw base value
46
+ barWidth: string // "62%" — fed into style={{ width }}
47
+ barColor: string // hex per bucket — fed into style={{ background }}
48
+ }
49
+
50
+ /** One ability chip on the detail page. */
51
+ export interface AbilityVM {
52
+ displayName: string // "Overgrow"
53
+ initial: string // "O"
54
+ iconColor: string // hex tint — style={{ background }}
55
+ }
56
+
57
+ /** One stage of the evolution chain. */
58
+ export interface EvoStageVM {
59
+ id: number
60
+ displayName: string
61
+ num: string // "#0001"
62
+ artwork: string
63
+ detailHref: string // "/pokemon/ivysaur"
64
+ levelLabel: string // "Lv 16" or ""
65
+ isFirst: boolean
66
+ showLevel: boolean
67
+ isCurrent: boolean
68
+ }
69
+
70
+ /** Home landing page data — curated featured strip + type names + chrome. */
71
+ export interface HomeData extends ChromeData {
72
+ featured: DexCard[]
73
+ types: string[]
74
+ }
75
+
76
+ /** Browse (dex grid) page data. `items` feeds BOTH the SSR `{% for %}` and the
77
+ * DexFilter directive's `x-props` (json_attr-serialized) — one array, no separate
78
+ * pre-stringified JSON. */
79
+ export interface BrowseData extends ChromeData {
80
+ items: DexCard[]
81
+ q: string
82
+ }
83
+
84
+ /** Detail page data. Every formatted string / className / inline-style value /
85
+ * x-props JSON is precomputed here so the native template only interpolates. */
86
+ export interface DetailData extends ChromeData {
87
+ notFound: boolean
88
+ name: string
89
+ id: number
90
+ displayName: string
91
+ num: string // "#0001"
92
+ artwork: string
93
+ genus: string // "Seed Pokémon"
94
+ flavorText: string
95
+ abilityCount: number
96
+ heroBg: string // CSS gradient string built in the loader from the type tint
97
+ stats: StatBarVM[]
98
+ statTotal: number
99
+ abilities: AbilityVM[]
100
+ hasAbilities: boolean
101
+ evolution: EvoStageVM[]
102
+ hasEvolution: boolean
103
+ height: number
104
+ weight: number
105
+ typeNames: string[]
106
+ }
107
+
108
+ /** One cell of the type chart. */
109
+ export interface TypeChartCellVM {
110
+ id: string // stable key "row-col"
111
+ className: string // static Tailwind utility string the loader picks per effectiveness
112
+ content: string // "2", "½", "0", a type short-code, or ""
113
+ title: string // tooltip
114
+ bg: string // inline background — type hex for header/row-head cells, '' for data cells
115
+ }
116
+
117
+ /** One row (header or attack) of the type chart — nested cells. */
118
+ export interface TypeChartRowVM {
119
+ id: string
120
+ cells: TypeChartCellVM[]
121
+ }
122
+
123
+ /** Type chart page data — nested rows[].cells[] grid, rendered with nested .map(). */
124
+ export interface TypeChartData extends ChromeData {
125
+ rows: TypeChartRowVM[]
126
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "brust build routes.tsx && brust start",
8
+ "build": "brust build routes.tsx",
9
+ "start": "brust start",
10
+ "snapshot": "bun scripts/snapshot.ts",
11
+ "typecheck": "bun check"
12
+ },
13
+ "dependencies": {
14
+ "@brust/core": __BRUST_DEP__,
15
+ "@elysia/eden": "2.0.0-beta.6",
16
+ "elysia": "2.0.0-beta.30",
17
+ "react": "^19.2.0",
18
+ "react-dom": "^19.2.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "^1.4.0",
22
+ "@types/react": "^19.2.0",
23
+ "typescript": "^7.0.2"
24
+ }
25
+ }
@@ -0,0 +1,15 @@
1
+ import DexFilter from '../components/DexFilter'
2
+ import type { BrowseData } from '../lib/types'
3
+
4
+ export default function BrowsePage({ items, q }: BrowseData) {
5
+ return (
6
+ <section className="py-2">
7
+ <div className="mb-6">
8
+ <h1 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">Pokédex</h1>
9
+ <p className="mt-2 max-w-2xl text-slate-500 dark:text-slate-400">The original 151. Search by name and toggle the sort order.</p>
10
+ {q && <p className="mt-2 text-sm text-slate-500">Results for “{q}”</p>}
11
+ </div>
12
+ <DexFilter items={items} />
13
+ </section>
14
+ )
15
+ }