brustjs 0.1.27-alpha → 0.1.29-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,78 @@ 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 {
70
- 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',
93
+ ...chrome(req, 'Pokédex · Browse', 'Pokédex'),
94
+ dexProps: JSON.stringify({ items }),
91
95
  }
92
96
  }
93
97
 
98
+ /** Base-stat bucket → bar fill hex. Bucket order: hi / mid / low / min. */
99
+ const BAR_COLOR: Record<string, string> = {
100
+ hi: '#16a34a', // green-600
101
+ mid: '#0ea5e9', // sky-500
102
+ low: '#f59e0b', // amber-500
103
+ min: '#ef4444', // red-500
104
+ }
105
+
106
+ const typeBadge = (t: string): TypeBadgeVM => ({
107
+ label: cap(t),
108
+ color: TYPE_COLOR[t] ?? '#888888',
109
+ })
110
+
94
111
  export async function detailLoader({
95
112
  params,
96
113
  req,
97
114
  }: LoaderCtx): Promise<DetailData | NativeVerdict> {
98
115
  const name = params?.name ?? ''
99
- const mode = req.cookies.mode === 'light' ? 'light' : 'dark'
100
- const empty = emptyDetail(name, mode)
116
+ const empty = emptyDetail(req, name)
101
117
 
102
118
  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.
119
+ // Native loaders can `return notFound(data)` to render THIS route's own
120
+ // template with HTTP 404. The `notFound: true` flag on `empty` drives the
121
+ // template's 404-block branch; the sentinel sets the HTTP STATUS.
107
122
  if (!p) return notFound(empty)
108
123
 
109
124
  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.
125
+ // A native route renders in Rust with no React tree, so <Suspense> streaming
126
+ // is impossible the evolution chain (the slow fetch) is loaded BLOCKING here.
114
127
  const rawEvo = await fetchEvolution(species.evolutionUrl)
115
128
 
116
129
  const primary = p.types[0] ?? 'normal'
117
- const tint = TYPE_COLOR[primary] ?? 'var(--primary-500)'
130
+ const tint = TYPE_COLOR[primary] ?? '#888888'
118
131
 
119
132
  const stats = p.stats.map((s) => {
120
133
  const pct = Math.min(100, Math.round((s.base / 200) * 100))
134
+ const bucket = statBucket(s.base)
121
135
  return {
122
136
  label: STAT_LABEL[s.name] ?? s.name,
123
137
  base: s.base,
124
- // Bare percent — the template builds the declaration via the S1 style
125
- // object: `style={{ width: st.barWidth }}` → `width:62%`.
126
138
  barWidth: `${pct}%`,
127
- barClassName: `dex-statbar__fill dex-statbar__fill--${statBucket(s.base)}`,
139
+ barColor: BAR_COLOR[bucket] ?? '#0ea5e9',
128
140
  }
129
141
  })
130
142
 
131
143
  const abilities = p.abilities.map((a) => ({
132
144
  displayName: cap(a),
133
145
  initial: a.charAt(0).toUpperCase(),
134
- // Bare color value — template does `style={{ background: a.iconColor }}`.
135
146
  iconColor: tint,
136
147
  }))
137
148
 
138
149
  const evolution = rawEvo.map((s, i) => ({
139
150
  id: s.id,
140
- name: s.name,
141
151
  displayName: cap(s.name),
142
152
  num: pad(s.id),
143
153
  artwork: artwork(s.id),
144
154
  detailHref: `/pokemon/${s.name}`,
145
155
  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
156
  isFirst: i === 0,
149
157
  showLevel: i > 0 && s.minLevel != null,
150
- cardClassName: s.id === p.id ? 'dex-evo__card dex-evo__card--current' : 'dex-evo__card',
158
+ isCurrent: s.id === p.id,
151
159
  }))
152
160
 
153
- const hasEvolution = evolution.length > 1
154
-
155
161
  return {
162
+ ...chrome(req, `${cap(p.name)} · PokéDex`, cap(p.name)),
156
163
  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
164
  name: p.name,
163
165
  id: p.id,
164
166
  displayName: cap(p.name),
@@ -169,17 +171,18 @@ export async function detailLoader({
169
171
  heightLabel: `${(p.height / 10).toFixed(1)} m`,
170
172
  weightLabel: `${(p.weight / 10).toFixed(1)} kg`,
171
173
  abilityCount: p.abilities.length,
172
- heroBg: `linear-gradient(160deg, color-mix(in srgb, ${tint} 22%, var(--surface-raised)), var(--surface-raised) 70%)`,
174
+ // Loaders are full JS template literals are fine HERE (the constraint is
175
+ // on the native template body only). Brand-tinted hero gradient.
176
+ heroBg: `linear-gradient(160deg, ${tint}33, transparent 70%)`,
173
177
  types: p.types.map(typeBadge),
174
178
  stats,
175
179
  statTotal: p.stats.reduce((a, s) => a + s.base, 0),
176
180
  abilities,
177
181
  hasAbilities: abilities.length > 0,
178
182
  evolution,
179
- hasEvolution,
183
+ hasEvolution: evolution.length > 1,
180
184
  // 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`.
185
+ // here. AddToTeamButton's prop contract is unchanged.
183
186
  addProps: JSON.stringify({
184
187
  id: p.id,
185
188
  name: p.name,
@@ -188,18 +191,13 @@ export async function detailLoader({
188
191
  types: p.types,
189
192
  artwork: p.artwork,
190
193
  }),
191
- teamProps: { teamInitial: teamStore.list() },
192
194
  }
193
195
  }
194
196
 
195
- function emptyDetail(name: string, mode: 'dark' | 'light'): DetailData {
197
+ function emptyDetail(req: BrustRequest, name: string): DetailData {
196
198
  return {
199
+ ...chrome(req, `${cap(name)} · PokéDex`, cap(name)),
197
200
  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
201
  name,
204
202
  id: 0,
205
203
  displayName: cap(name),
@@ -226,7 +224,6 @@ function emptyDetail(name: string, mode: 'dark' | 'light'): DetailData {
226
224
  types: [],
227
225
  artwork: '',
228
226
  }),
229
- teamProps: { teamInitial: teamStore.list() },
230
227
  }
231
228
  }
232
229
 
@@ -251,45 +248,58 @@ const SHORT: Record<string, string> = {
251
248
  fairy: 'FAI',
252
249
  }
253
250
 
251
+ // Effectiveness → static Tailwind utility class string. These are literals in a
252
+ // .ts file scanned by `@source`, so the scanner sees every class. The header /
253
+ // row-head / corner / data cells share a base sizing class.
254
+ const CELL_BASE =
255
+ 'flex items-center justify-center text-xs font-semibold tabular-nums aspect-square'
256
+ const HEAD_BASE =
257
+ 'flex items-center justify-center text-[10px] font-bold uppercase tracking-tight text-white aspect-square'
258
+ const CELL_CLASS: Record<string, string> = {
259
+ super: `${CELL_BASE} bg-green-500/25 text-green-700 dark:text-green-300`,
260
+ weak: `${CELL_BASE} bg-red-500/20 text-red-700 dark:text-red-300`,
261
+ none: `${CELL_BASE} bg-slate-800/80 text-slate-200`,
262
+ normal: `${CELL_BASE} text-slate-300 dark:text-slate-600`,
263
+ }
264
+
254
265
  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.
266
+ // Fan out 18 type fetches; each goes through cachedFetch so duplicate in-flight
267
+ // GETs within the request dedupe.
257
268
  const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
258
269
 
259
270
  // 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'] = []
271
+ // type). The native template renders it with nested `.map()`.
272
+ const rows: TypeChartRowVM[] = []
264
273
 
265
- // Header row: corner + 18 defending-type column heads.
266
274
  const headerCells: TypeChartCellVM[] = [
267
275
  {
268
276
  id: '0-0',
269
- className: 'dex-tc__corner',
270
- content: 'ATKDEF',
271
- title: 'Attacking Defending',
277
+ className: `${HEAD_BASE} sticky left-0 top-0 z-20 text-[9px]`,
278
+ content: 'ATKDEF',
279
+ title: 'Attacking Defending',
280
+ bg: '#334155', // slate-700
272
281
  },
273
282
  ]
274
283
  ALL_TYPES.forEach((def, j) => {
275
284
  headerCells.push({
276
285
  id: `0-${j + 1}`,
277
- className: `dex-tc__colhead dex-tc__colhead--${def}`,
286
+ className: `${HEAD_BASE} sticky top-0 z-10`,
278
287
  content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
279
288
  title: cap(def),
289
+ bg: TYPE_COLOR[def] ?? '#888888',
280
290
  })
281
291
  })
282
292
  rows.push({ id: '0', cells: headerCells })
283
293
 
284
- // One row per attacking type: row head + 18 effectiveness cells.
285
294
  ALL_TYPES.forEach((atk, i) => {
286
- const rel = relations[i]!
295
+ const rel = relations[i] ?? {}
287
296
  const rowCells: TypeChartCellVM[] = [
288
297
  {
289
298
  id: `${i + 1}-0`,
290
- className: `dex-tc__rowhead dex-tc__rowhead--${atk}`,
299
+ className: `${HEAD_BASE} sticky left-0 z-10`,
291
300
  content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
292
301
  title: cap(atk),
302
+ bg: TYPE_COLOR[atk] ?? '#888888',
293
303
  },
294
304
  ]
295
305
  ALL_TYPES.forEach((def, j) => {
@@ -298,42 +308,41 @@ export async function typeChartLoader({ req }: LoaderCtx): Promise<TypeChartData
298
308
  if (mult === 2)
299
309
  rowCells.push({
300
310
  id,
301
- className: 'dex-tc__cell dex-tc__cell--super',
311
+ className: CELL_CLASS.super!,
302
312
  content: '2',
303
313
  title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
314
+ bg: '',
304
315
  })
305
316
  else if (mult === 0.5)
306
317
  rowCells.push({
307
318
  id,
308
- className: 'dex-tc__cell dex-tc__cell--weak',
319
+ className: CELL_CLASS.weak!,
309
320
  content: '½',
310
321
  title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
322
+ bg: '',
311
323
  })
312
324
  else if (mult === 0)
313
325
  rowCells.push({
314
326
  id,
315
- className: 'dex-tc__cell dex-tc__cell--none',
327
+ className: CELL_CLASS.none!,
316
328
  content: '0',
317
329
  title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
330
+ bg: '',
318
331
  })
319
332
  else
320
333
  rowCells.push({
321
334
  id,
322
- className: 'dex-tc__cell',
335
+ className: CELL_CLASS.normal!,
323
336
  content: '',
324
337
  title: `${cap(atk)} → ${cap(def)}: 1×`,
338
+ bg: '',
325
339
  })
326
340
  })
327
341
  rows.push({ id: String(i + 1), cells: rowCells })
328
342
  })
329
343
 
330
344
  return {
345
+ ...chrome(req, 'PokéDex · type chart', 'Type chart'),
331
346
  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
347
  }
339
348
  }
@@ -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