brustjs 0.1.28-alpha → 0.1.30-alpha

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.
@@ -1,11 +1,9 @@
1
- // Route loaders. Each runs in a Bun worker (full JS), fetches from PokeAPI, and
2
- // returns a fully render-ready view-model. Templates now do conditionals (S11),
3
- // `style={{…}}` objects (S1), and dynamic head props (S8); the loader still
4
- // precomputes formatted strings, booleans for conditionals, and multi-property
5
- // style strings (no template-literals / arithmetic / helper calls in templates).
6
- // See ../FRAMEWORK-GAPS.md.
1
+ // Route loaders. Each runs in a Bun worker (full JS), and returns a fully
2
+ // render-ready view-model. SLICE 1: these are STUBS — they return the chrome
3
+ // fields every page needs (title / crumb / mode / teamProps) plus the minimal
4
+ // data each stub page interpolates. Real PokeAPI-backed data lands in later
5
+ // slices.
7
6
 
8
- import { z } from 'zod'
9
7
  import { type BrustRequest, type NativeVerdict, notFound } from 'brustjs/routes'
10
8
  import {
11
9
  ALL_TYPES,
@@ -22,35 +20,67 @@ import {
22
20
  TYPE_COLOR,
23
21
  } from './pokeapi'
24
22
  import { teamStore } from './team-store'
25
- import type { DetailData, ListData, TypeBadgeVM, TypeChartCellVM, TypeChartData } from './types'
23
+ import type {
24
+ BrowseData,
25
+ DetailData,
26
+ HomeData,
27
+ TypeBadgeVM,
28
+ TypeChartCellVM,
29
+ TypeChartData,
30
+ TypeChartRowVM,
31
+ } from './types'
32
+
33
+ /** A curated set of iconic Pokémon for the home featured strip. Hardcoded
34
+ * {id,name} so the home page needs ZERO PokeAPI calls — artwork is derived from
35
+ * id, names supply the display label and detail href. */
36
+ const FEATURED: { id: number; name: string }[] = [
37
+ { id: 1, name: 'bulbasaur' },
38
+ { id: 4, name: 'charmander' },
39
+ { id: 7, name: 'squirtle' },
40
+ { id: 25, name: 'pikachu' },
41
+ { id: 39, name: 'jigglypuff' },
42
+ { id: 94, name: 'gengar' },
43
+ { id: 143, name: 'snorlax' },
44
+ { id: 150, name: 'mewtwo' },
45
+ ]
26
46
 
27
- /** Loader context shape — `loader: ({ params, path, req }) => data`. */
28
47
  interface LoaderCtx {
29
48
  params: Record<string, string>
30
49
  path: string
31
50
  req: BrustRequest
32
51
  }
33
52
 
34
- const PAGE = 20
35
- const NATIONAL_MAX = 1302
36
-
37
- // GAP S1: query validation is not symmetric with actions. Actions bind a schema
38
- // in the descriptor and hand the handler a typed+validated `query`; loaders get
39
- // `req.search` as a raw Record<string,string> and must validate by hand here.
40
- const ListQuery = z.object({
41
- offset: z.coerce.number().int().min(0).max(NATIONAL_MAX).catch(0),
53
+ const chrome = (req: BrustRequest, title: string, crumb: string) => ({
54
+ title,
55
+ crumb,
56
+ mode: (req.cookies.mode === 'light' ? 'light' : 'dark') as 'light' | 'dark',
57
+ teamProps: { teamInitial: teamStore.list() },
42
58
  })
43
59
 
44
- const fmt = (n: number) => n.toLocaleString('en-US')
45
- const typeBadge = (t: string): TypeBadgeVM => ({
46
- label: cap(t),
47
- className: `dex-type dex-type--${t}`,
48
- })
49
-
50
- export async function listLoader({ req }: LoaderCtx): Promise<ListData> {
51
- const offset = ListQuery.parse(req?.search ?? {}).offset
52
- const { results, total } = await fetchList(offset, PAGE)
60
+ export async function homeLoader({ req }: LoaderCtx): Promise<HomeData> {
61
+ const featured = FEATURED.map((p) => ({
62
+ id: p.id,
63
+ name: p.name,
64
+ displayName: cap(p.name),
65
+ num: pad(p.id),
66
+ artwork: artwork(p.id),
67
+ detailHref: `/pokemon/${p.name}`,
68
+ }))
69
+ const typeTiles = ALL_TYPES.map((t) => ({
70
+ name: t,
71
+ label: cap(t),
72
+ color: TYPE_COLOR[t] ?? '#888888',
73
+ href: '/pokedex',
74
+ }))
75
+ return {
76
+ ...chrome(req, 'PokéDex · built with brust', 'Home'),
77
+ featured,
78
+ typeTiles,
79
+ }
80
+ }
53
81
 
82
+ export async function browseLoader({ req }: LoaderCtx): Promise<BrowseData> {
83
+ const { results } = await fetchList(0, 151)
54
84
  const items = results.map((r) => ({
55
85
  id: r.id,
56
86
  name: r.name,
@@ -59,106 +89,79 @@ export async function listLoader({ req }: LoaderCtx): Promise<ListData> {
59
89
  artwork: artwork(r.id),
60
90
  detailHref: `/pokemon/${r.name}`,
61
91
  }))
62
-
63
- const lastPage = Math.ceil(total / PAGE)
64
- const pageNo = Math.floor(offset / PAGE) + 1
65
- const hasPrev = offset > 0
66
- const hasNext = offset + PAGE < total
67
- const prevOffset = Math.max(0, offset - PAGE)
68
-
69
92
  return {
93
+ ...chrome(req, 'Pokédex · Browse', 'Pokédex'),
70
94
  items,
71
- total,
72
- totalLabel: fmt(total),
73
- offset,
74
- offsetLabel: String(offset),
75
- showingLabel: `${fmt(offset + 1)}–${fmt(Math.min(offset + PAGE, total))} of ${fmt(total)}`,
76
- pageLabel: `${pageNo} / ${lastPage}`,
77
- // Real conditionals now exist in native routes (GAPS S11 closed): the
78
- // template branches on these booleans with `{flags.hasPrev ? <a/> : <span/>}`
79
- // instead of always-rendering a loader-computed hide-class.
80
- hasPrev,
81
- hasNext,
82
- prevHref: hasPrev ? (prevOffset > 0 ? `/?offset=${prevOffset}` : '/') : '#',
83
- nextHref: hasNext ? `/?offset=${offset + PAGE}` : '#',
84
- // Chrome fields (ChromeData) — merged into the flat jinja context and read by
85
- // the router-level AppLayout (Approach a: native <Outlet/> nesting).
86
- title: 'PokéDex · brust example',
87
- active: 'list',
88
- crumb: 'All Pokémon',
89
- teamProps: { teamInitial: teamStore.list() },
90
- mode: req.cookies.mode === 'light' ? 'light' : 'dark',
95
+ dexProps: JSON.stringify({ items }),
91
96
  }
92
97
  }
93
98
 
99
+ /** Base-stat bucket → bar fill hex. Bucket order: hi / mid / low / min. */
100
+ const BAR_COLOR: Record<string, string> = {
101
+ hi: '#16a34a', // green-600
102
+ mid: '#0ea5e9', // sky-500
103
+ low: '#f59e0b', // amber-500
104
+ min: '#ef4444', // red-500
105
+ }
106
+
107
+ const typeBadge = (t: string): TypeBadgeVM => ({
108
+ label: cap(t),
109
+ color: TYPE_COLOR[t] ?? '#888888',
110
+ })
111
+
94
112
  export async function detailLoader({
95
113
  params,
96
114
  req,
97
115
  }: LoaderCtx): Promise<DetailData | NativeVerdict> {
98
116
  const name = params?.name ?? ''
99
- const mode = req.cookies.mode === 'light' ? 'light' : 'dark'
100
- const empty = emptyDetail(name, mode)
117
+ const empty = emptyDetail(req, name)
101
118
 
102
119
  const p = await fetchPokemon(name)
103
- // GAP S9 (FIXED): native loaders can now `return notFound(data)` to render the
104
- // route's OWN template with HTTP 404. The `notFound: true` flag on `empty`
105
- // still drives the template's 404-block branch (S11); the sentinel sets the
106
- // HTTP STATUS (404 instead of 200). They are complementary. See GAPS S9.
120
+ // Native loaders can `return notFound(data)` to render THIS route's own
121
+ // template with HTTP 404. The `notFound: true` flag on `empty` drives the
122
+ // template's 404-block branch; the sentinel sets the HTTP STATUS.
107
123
  if (!p) return notFound(empty)
108
124
 
109
125
  const species = await fetchSpecies(p.id)
110
- // GAP (native↔streaming): a native route renders in Rust with NO React tree,
111
- // so <Suspense> streaming is impossible. The evolution chain the slow fetch
112
- // the design wanted to stream — is therefore loaded BLOCKING here in the
113
- // loader. See GAPS S3.
126
+ // A native route renders in Rust with no React tree, so <Suspense> streaming
127
+ // is impossible the evolution chain (the slow fetch) is loaded BLOCKING here.
114
128
  const rawEvo = await fetchEvolution(species.evolutionUrl)
115
129
 
116
130
  const primary = p.types[0] ?? 'normal'
117
- const tint = TYPE_COLOR[primary] ?? 'var(--primary-500)'
131
+ const tint = TYPE_COLOR[primary] ?? '#888888'
118
132
 
119
133
  const stats = p.stats.map((s) => {
120
134
  const pct = Math.min(100, Math.round((s.base / 200) * 100))
135
+ const bucket = statBucket(s.base)
121
136
  return {
122
137
  label: STAT_LABEL[s.name] ?? s.name,
123
138
  base: s.base,
124
- // Bare percent — the template builds the declaration via the S1 style
125
- // object: `style={{ width: st.barWidth }}` → `width:62%`.
126
139
  barWidth: `${pct}%`,
127
- barClassName: `dex-statbar__fill dex-statbar__fill--${statBucket(s.base)}`,
140
+ barColor: BAR_COLOR[bucket] ?? '#0ea5e9',
128
141
  }
129
142
  })
130
143
 
131
144
  const abilities = p.abilities.map((a) => ({
132
145
  displayName: cap(a),
133
146
  initial: a.charAt(0).toUpperCase(),
134
- // Bare color value — template does `style={{ background: a.iconColor }}`.
135
147
  iconColor: tint,
136
148
  }))
137
149
 
138
150
  const evolution = rawEvo.map((s, i) => ({
139
151
  id: s.id,
140
- name: s.name,
141
152
  displayName: cap(s.name),
142
153
  num: pad(s.id),
143
154
  artwork: artwork(s.id),
144
155
  detailHref: `/pokemon/${s.name}`,
145
156
  levelLabel: s.minLevel != null ? `Lv ${s.minLevel}` : '',
146
- // Real per-item conditionals now work in native routes (S11): the template
147
- // tests these booleans instead of toggling a precomputed `dex-hide` class.
148
157
  isFirst: i === 0,
149
158
  showLevel: i > 0 && s.minLevel != null,
150
- cardClassName: s.id === p.id ? 'dex-evo__card dex-evo__card--current' : 'dex-evo__card',
159
+ isCurrent: s.id === p.id,
151
160
  }))
152
161
 
153
- const hasEvolution = evolution.length > 1
154
-
155
162
  return {
163
+ ...chrome(req, `${cap(p.name)} · PokéDex`, cap(p.name)),
156
164
  notFound: false,
157
- // Chrome fields (ChromeData) read by AppLayout from the merged context.
158
- title: `${cap(p.name)} · PokéDex`,
159
- active: 'list',
160
- crumb: cap(p.name),
161
- mode,
162
165
  name: p.name,
163
166
  id: p.id,
164
167
  displayName: cap(p.name),
@@ -169,17 +172,18 @@ export async function detailLoader({
169
172
  heightLabel: `${(p.height / 10).toFixed(1)} m`,
170
173
  weightLabel: `${(p.weight / 10).toFixed(1)} kg`,
171
174
  abilityCount: p.abilities.length,
172
- heroBg: `linear-gradient(160deg, color-mix(in srgb, ${tint} 22%, var(--surface-raised)), var(--surface-raised) 70%)`,
175
+ // Loaders are full JS template literals are fine HERE (the constraint is
176
+ // on the native template body only). Brand-tinted hero gradient.
177
+ heroBg: `linear-gradient(160deg, ${tint}33, transparent 70%)`,
173
178
  types: p.types.map(typeBadge),
174
179
  stats,
175
180
  statTotal: p.stats.reduce((a, s) => a + s.base, 0),
176
181
  abilities,
177
182
  hasAbilities: abilities.length > 0,
178
183
  evolution,
179
- hasEvolution,
184
+ hasEvolution: evolution.length > 1,
180
185
  // Native templates can't call JSON.stringify, so precompute the x-props JSON
181
- // here. The compiler emits it as x-props="{{ (addProps) | e }}" (XSS-safe);
182
- // the directive runtime JSON.parses it back into the behavior's `props`.
186
+ // here. AddToTeamButton's prop contract is unchanged.
183
187
  addProps: JSON.stringify({
184
188
  id: p.id,
185
189
  name: p.name,
@@ -188,18 +192,13 @@ export async function detailLoader({
188
192
  types: p.types,
189
193
  artwork: p.artwork,
190
194
  }),
191
- teamProps: { teamInitial: teamStore.list() },
192
195
  }
193
196
  }
194
197
 
195
- function emptyDetail(name: string, mode: 'dark' | 'light'): DetailData {
198
+ function emptyDetail(req: BrustRequest, name: string): DetailData {
196
199
  return {
200
+ ...chrome(req, `${cap(name)} · PokéDex`, cap(name)),
197
201
  notFound: true,
198
- // Chrome fields (ChromeData) read by AppLayout from the merged context.
199
- title: `${cap(name)} · PokéDex`,
200
- active: 'list',
201
- crumb: cap(name),
202
- mode,
203
202
  name,
204
203
  id: 0,
205
204
  displayName: cap(name),
@@ -226,7 +225,6 @@ function emptyDetail(name: string, mode: 'dark' | 'light'): DetailData {
226
225
  types: [],
227
226
  artwork: '',
228
227
  }),
229
- teamProps: { teamInitial: teamStore.list() },
230
228
  }
231
229
  }
232
230
 
@@ -251,45 +249,58 @@ const SHORT: Record<string, string> = {
251
249
  fairy: 'FAI',
252
250
  }
253
251
 
252
+ // Effectiveness → static Tailwind utility class string. These are literals in a
253
+ // .ts file scanned by `@source`, so the scanner sees every class. The header /
254
+ // row-head / corner / data cells share a base sizing class.
255
+ const CELL_BASE =
256
+ 'flex items-center justify-center text-xs font-semibold tabular-nums aspect-square'
257
+ const HEAD_BASE =
258
+ 'flex items-center justify-center text-[10px] font-bold uppercase tracking-tight text-white aspect-square'
259
+ const CELL_CLASS: Record<string, string> = {
260
+ super: `${CELL_BASE} bg-green-500/25 text-green-700 dark:text-green-300`,
261
+ weak: `${CELL_BASE} bg-red-500/20 text-red-700 dark:text-red-300`,
262
+ none: `${CELL_BASE} bg-slate-800/80 text-slate-200`,
263
+ normal: `${CELL_BASE} text-slate-300 dark:text-slate-600`,
264
+ }
265
+
254
266
  export async function typeChartLoader({ req }: LoaderCtx): Promise<TypeChartData> {
255
- // Fan out 18 distinct type fetches with Promise.all; each goes through
256
- // cachedFetch (S2), so duplicate in-flight GETs within the request dedupe.
267
+ // Fan out 18 type fetches; each goes through cachedFetch so duplicate in-flight
268
+ // GETs within the request dedupe.
257
269
  const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
258
270
 
259
271
  // Build the 19×19 grid as nested rows (header row + one row per attacking
260
- // type). The native template renders it with nested `.map()` — rows.map(r =>
261
- // r.cells.map(c => …)) — into the CSS grid (`.dex-tc__row{display:contents}`
262
- // keeps every cell a direct grid item, so the layout is unchanged).
263
- const rows: TypeChartData['rows'] = []
272
+ // type). The native template renders it with nested `.map()`.
273
+ const rows: TypeChartRowVM[] = []
264
274
 
265
- // Header row: corner + 18 defending-type column heads.
266
275
  const headerCells: TypeChartCellVM[] = [
267
276
  {
268
277
  id: '0-0',
269
- className: 'dex-tc__corner',
270
- content: 'ATKDEF',
271
- title: 'Attacking Defending',
278
+ className: `${HEAD_BASE} sticky left-0 top-0 z-20 text-[9px]`,
279
+ content: 'ATKDEF',
280
+ title: 'Attacking Defending',
281
+ bg: '#334155', // slate-700
272
282
  },
273
283
  ]
274
284
  ALL_TYPES.forEach((def, j) => {
275
285
  headerCells.push({
276
286
  id: `0-${j + 1}`,
277
- className: `dex-tc__colhead dex-tc__colhead--${def}`,
287
+ className: `${HEAD_BASE} sticky top-0 z-10`,
278
288
  content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
279
289
  title: cap(def),
290
+ bg: TYPE_COLOR[def] ?? '#888888',
280
291
  })
281
292
  })
282
293
  rows.push({ id: '0', cells: headerCells })
283
294
 
284
- // One row per attacking type: row head + 18 effectiveness cells.
285
295
  ALL_TYPES.forEach((atk, i) => {
286
- const rel = relations[i]!
296
+ const rel = relations[i] ?? {}
287
297
  const rowCells: TypeChartCellVM[] = [
288
298
  {
289
299
  id: `${i + 1}-0`,
290
- className: `dex-tc__rowhead dex-tc__rowhead--${atk}`,
300
+ className: `${HEAD_BASE} sticky left-0 z-10`,
291
301
  content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
292
302
  title: cap(atk),
303
+ bg: TYPE_COLOR[atk] ?? '#888888',
293
304
  },
294
305
  ]
295
306
  ALL_TYPES.forEach((def, j) => {
@@ -298,42 +309,41 @@ export async function typeChartLoader({ req }: LoaderCtx): Promise<TypeChartData
298
309
  if (mult === 2)
299
310
  rowCells.push({
300
311
  id,
301
- className: 'dex-tc__cell dex-tc__cell--super',
312
+ className: CELL_CLASS.super!,
302
313
  content: '2',
303
314
  title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
315
+ bg: '',
304
316
  })
305
317
  else if (mult === 0.5)
306
318
  rowCells.push({
307
319
  id,
308
- className: 'dex-tc__cell dex-tc__cell--weak',
320
+ className: CELL_CLASS.weak!,
309
321
  content: '½',
310
322
  title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
323
+ bg: '',
311
324
  })
312
325
  else if (mult === 0)
313
326
  rowCells.push({
314
327
  id,
315
- className: 'dex-tc__cell dex-tc__cell--none',
328
+ className: CELL_CLASS.none!,
316
329
  content: '0',
317
330
  title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
331
+ bg: '',
318
332
  })
319
333
  else
320
334
  rowCells.push({
321
335
  id,
322
- className: 'dex-tc__cell',
336
+ className: CELL_CLASS.normal!,
323
337
  content: '',
324
338
  title: `${cap(atk)} → ${cap(def)}: 1×`,
339
+ bg: '',
325
340
  })
326
341
  })
327
342
  rows.push({ id: String(i + 1), cells: rowCells })
328
343
  })
329
344
 
330
345
  return {
346
+ ...chrome(req, 'PokéDex · type chart', 'Type chart'),
331
347
  rows,
332
- // Chrome fields (ChromeData) read by AppLayout from the merged context.
333
- title: 'PokéDex · type chart',
334
- active: 'typechart',
335
- crumb: 'Type chart',
336
- teamProps: { teamInitial: teamStore.list() },
337
- mode: req.cookies.mode === 'light' ? 'light' : 'dark',
338
348
  }
339
349
  }
@@ -20,28 +20,28 @@ export const cap = (s: string): string =>
20
20
 
21
21
  export const pad = (n: number): string => `#${String(n).padStart(4, '0')}`
22
22
 
23
- /** Pokémon type → AssetsArt design-token CSS variable (stays inside brand
24
- * no canonical Pokémon colours). Mirrored by `.dex-type--<name>` rules in
25
- * app.css. */
23
+ /** Pokémon type → real hex color. Tailwind's scanner can't see runtime-built
24
+ * class strings, so type tints are applied as inline `style` values from the
25
+ * loader using these hexes. */
26
26
  export const TYPE_COLOR: Record<string, string> = {
27
- normal: 'var(--ink-400)',
28
- fire: 'var(--magenta-500)',
29
- water: 'var(--blue-500)',
30
- grass: 'var(--success-500)',
31
- electric: 'var(--warning-500)',
32
- ice: 'var(--viz-4)',
33
- fighting: 'var(--viz-3)',
34
- poison: 'var(--purple-600)',
35
- ground: 'var(--viz-7)',
36
- flying: 'var(--viz-5)',
37
- psychic: 'var(--purple-500)',
38
- bug: 'var(--viz-8)',
39
- rock: 'var(--ink-500)',
40
- ghost: 'var(--purple-700)',
41
- dragon: 'var(--viz-2)',
42
- dark: 'var(--ink-800)',
43
- steel: 'var(--ink-400)',
44
- fairy: 'var(--magenta-300)',
27
+ normal: '#9099a1',
28
+ fire: '#ef7444',
29
+ water: '#4d90d5',
30
+ grass: '#63bb5b',
31
+ electric: '#f5c84b',
32
+ ice: '#74cec0',
33
+ fighting: '#ce4069',
34
+ poison: '#ab6ac8',
35
+ ground: '#d97746',
36
+ flying: '#8fa8dd',
37
+ psychic: '#f06fa0',
38
+ bug: '#90c12c',
39
+ rock: '#c7b78b',
40
+ ghost: '#5269ac',
41
+ dragon: '#0a6dc4',
42
+ dark: '#5a5366',
43
+ steel: '#5a8ea1',
44
+ fairy: '#ec8fe6',
45
45
  }
46
46
 
47
47
  export const STAT_LABEL: Record<string, string> = {
@@ -1,7 +1,7 @@
1
1
  // In-process team store — a module-scope Map that lives for the whole server
2
2
  // process. It is GLOBAL across every request and every visitor: brust ships no
3
3
  // per-session / request-context primitive, so for this dogfood the team is a
4
- // single shared roster. See ../FRAMEWORK-GAPS.md S6.
4
+ // single shared roster.
5
5
 
6
6
  import type { TeamMember } from './types'
7
7