brustjs 0.1.16-alpha → 0.1.18-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.
@@ -0,0 +1,290 @@
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.
7
+
8
+ import { z } from 'zod'
9
+ import { type BrustRequest, type NativeVerdict, notFound } from 'brustjs/routes'
10
+ import {
11
+ ALL_TYPES,
12
+ artwork,
13
+ cap,
14
+ fetchEvolution,
15
+ fetchList,
16
+ fetchPokemon,
17
+ fetchSpecies,
18
+ fetchTypeRelations,
19
+ pad,
20
+ STAT_LABEL,
21
+ statBucket,
22
+ TYPE_COLOR,
23
+ } from './pokeapi'
24
+ import { teamStore } from './team-store'
25
+ import type { DetailData, ListData, TypeBadgeVM, TypeChartData } from './types'
26
+
27
+ /** Loader context shape — `loader: ({ params, path, req }) => data`. */
28
+ interface LoaderCtx {
29
+ params: Record<string, string>
30
+ path: string
31
+ req: BrustRequest
32
+ }
33
+
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),
42
+ })
43
+
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)
53
+
54
+ const items = results.map((r) => ({
55
+ id: r.id,
56
+ name: r.name,
57
+ displayName: cap(r.name),
58
+ num: pad(r.id),
59
+ artwork: artwork(r.id),
60
+ detailHref: `/pokemon/${r.name}`,
61
+ }))
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
+ 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
+ teamProps: { teamInitial: teamStore.list() },
85
+ }
86
+ }
87
+
88
+ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData | NativeVerdict> {
89
+ const name = params?.name ?? ''
90
+ const empty = emptyDetail(name)
91
+
92
+ const p = await fetchPokemon(name)
93
+ // GAP S9 (FIXED): native loaders can now `return notFound(data)` to render the
94
+ // route's OWN template with HTTP 404. The `notFound: true` flag on `empty`
95
+ // still drives the template's 404-block branch (S11); the sentinel sets the
96
+ // HTTP STATUS (404 instead of 200). They are complementary. See GAPS S9.
97
+ if (!p) return notFound(empty)
98
+
99
+ const species = await fetchSpecies(p.id)
100
+ // GAP (native↔streaming): a native route renders in Rust with NO React tree,
101
+ // so <Suspense> streaming is impossible. The evolution chain — the slow fetch
102
+ // the design wanted to stream — is therefore loaded BLOCKING here in the
103
+ // loader. See GAPS S3.
104
+ const rawEvo = await fetchEvolution(species.evolutionUrl)
105
+
106
+ const primary = p.types[0] ?? 'normal'
107
+ const tint = TYPE_COLOR[primary] ?? 'var(--primary-500)'
108
+
109
+ const stats = p.stats.map((s) => {
110
+ const pct = Math.min(100, Math.round((s.base / 200) * 100))
111
+ return {
112
+ label: STAT_LABEL[s.name] ?? s.name,
113
+ base: s.base,
114
+ // Bare percent — the template builds the declaration via the S1 style
115
+ // object: `style={{ width: st.barWidth }}` → `width:62%`.
116
+ barWidth: `${pct}%`,
117
+ barClassName: `dex-statbar__fill dex-statbar__fill--${statBucket(s.base)}`,
118
+ }
119
+ })
120
+
121
+ const abilities = p.abilities.map((a) => ({
122
+ displayName: cap(a),
123
+ initial: a.charAt(0).toUpperCase(),
124
+ // Bare color value — template does `style={{ background: a.iconColor }}`.
125
+ iconColor: tint,
126
+ }))
127
+
128
+ const evolution = rawEvo.map((s, i) => ({
129
+ id: s.id,
130
+ name: s.name,
131
+ displayName: cap(s.name),
132
+ num: pad(s.id),
133
+ artwork: artwork(s.id),
134
+ detailHref: `/pokemon/${s.name}`,
135
+ levelLabel: s.minLevel != null ? `Lv ${s.minLevel}` : '',
136
+ // Real per-item conditionals now work in native routes (S11): the template
137
+ // tests these booleans instead of toggling a precomputed `dex-hide` class.
138
+ isFirst: i === 0,
139
+ showLevel: i > 0 && s.minLevel != null,
140
+ cardClassName: s.id === p.id ? 'dex-evo__card dex-evo__card--current' : 'dex-evo__card',
141
+ }))
142
+
143
+ const hasEvolution = evolution.length > 1
144
+
145
+ return {
146
+ notFound: false,
147
+ pageTitle: `${cap(p.name)} · PokéDex`,
148
+ name: p.name,
149
+ id: p.id,
150
+ displayName: cap(p.name),
151
+ num: pad(p.id),
152
+ artwork: p.artwork,
153
+ genus: species.genus,
154
+ flavorText: species.flavorText,
155
+ heightLabel: `${(p.height / 10).toFixed(1)} m`,
156
+ weightLabel: `${(p.weight / 10).toFixed(1)} kg`,
157
+ abilityCount: p.abilities.length,
158
+ heroBg: `linear-gradient(160deg, color-mix(in srgb, ${tint} 22%, var(--surface-raised)), var(--surface-raised) 70%)`,
159
+ types: p.types.map(typeBadge),
160
+ stats,
161
+ statTotal: p.stats.reduce((a, s) => a + s.base, 0),
162
+ abilities,
163
+ hasAbilities: abilities.length > 0,
164
+ evolution,
165
+ hasEvolution,
166
+ addProps: {
167
+ id: p.id,
168
+ name: p.name,
169
+ displayName: cap(p.name),
170
+ num: pad(p.id),
171
+ types: p.types,
172
+ artwork: p.artwork,
173
+ },
174
+ teamProps: { teamInitial: teamStore.list() },
175
+ }
176
+ }
177
+
178
+ function emptyDetail(name: string): DetailData {
179
+ return {
180
+ notFound: true,
181
+ pageTitle: `${cap(name)} · PokéDex`,
182
+ name,
183
+ id: 0,
184
+ displayName: cap(name),
185
+ num: '',
186
+ artwork: '',
187
+ genus: '',
188
+ flavorText: '',
189
+ heightLabel: '',
190
+ weightLabel: '',
191
+ abilityCount: 0,
192
+ heroBg: '',
193
+ types: [],
194
+ stats: [],
195
+ statTotal: 0,
196
+ abilities: [],
197
+ hasAbilities: false,
198
+ evolution: [],
199
+ hasEvolution: false,
200
+ addProps: { id: 0, name, displayName: cap(name), num: '', types: [], artwork: '' },
201
+ teamProps: { teamInitial: teamStore.list() },
202
+ }
203
+ }
204
+
205
+ const SHORT: Record<string, string> = {
206
+ normal: 'NOR',
207
+ fire: 'FIR',
208
+ water: 'WAT',
209
+ electric: 'ELE',
210
+ grass: 'GRA',
211
+ ice: 'ICE',
212
+ fighting: 'FIG',
213
+ poison: 'POI',
214
+ ground: 'GRO',
215
+ flying: 'FLY',
216
+ psychic: 'PSY',
217
+ bug: 'BUG',
218
+ rock: 'ROC',
219
+ ghost: 'GHO',
220
+ dragon: 'DRA',
221
+ dark: 'DAR',
222
+ steel: 'STE',
223
+ fairy: 'FAI',
224
+ }
225
+
226
+ export async function typeChartLoader(): Promise<TypeChartData> {
227
+ // GAP S2: no loader-level batch/parallel helper or request-scoped cache — we
228
+ // fan out 18 fetches by hand with Promise.all (and there is no dedupe).
229
+ const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
230
+
231
+ // Flatten the 19×19 grid (1 header row/col + 18×18 matrix) into a single
232
+ // row-major array so the native template renders it with ONE `.map()`.
233
+ const cells: Array<Omit<TypeChartData['cells'][number], 'id'>> = []
234
+
235
+ // Header row: corner + 18 defending-type column heads.
236
+ cells.push({
237
+ className: 'dex-tc__corner',
238
+ content: 'ATK \ DEF',
239
+ title: 'Attacking \ Defending',
240
+ })
241
+ for (const def of ALL_TYPES) {
242
+ cells.push({
243
+ className: `dex-tc__colhead dex-tc__colhead--${def}`,
244
+ content: SHORT[def] ?? def.slice(0, 3).toUpperCase(),
245
+ title: cap(def),
246
+ })
247
+ }
248
+
249
+ // One row per attacking type: row head + 18 effectiveness cells.
250
+ ALL_TYPES.forEach((atk, i) => {
251
+ const rel = relations[i]!
252
+ cells.push({
253
+ className: `dex-tc__rowhead dex-tc__rowhead--${atk}`,
254
+ content: SHORT[atk] ?? atk.slice(0, 3).toUpperCase(),
255
+ title: cap(atk),
256
+ })
257
+ for (const def of ALL_TYPES) {
258
+ const mult = rel[def]
259
+ if (mult === 2)
260
+ cells.push({
261
+ className: 'dex-tc__cell dex-tc__cell--super',
262
+ content: '2',
263
+ title: `${cap(atk)} → ${cap(def)}: 2× (super effective)`,
264
+ })
265
+ else if (mult === 0.5)
266
+ cells.push({
267
+ className: 'dex-tc__cell dex-tc__cell--weak',
268
+ content: '½',
269
+ title: `${cap(atk)} → ${cap(def)}: ½× (not very effective)`,
270
+ })
271
+ else if (mult === 0)
272
+ cells.push({
273
+ className: 'dex-tc__cell dex-tc__cell--none',
274
+ content: '0',
275
+ title: `${cap(atk)} → ${cap(def)}: 0× (no effect)`,
276
+ })
277
+ else
278
+ cells.push({
279
+ className: 'dex-tc__cell',
280
+ content: '',
281
+ title: `${cap(atk)} → ${cap(def)}: 1×`,
282
+ })
283
+ }
284
+ })
285
+
286
+ return {
287
+ cells: cells.map((c, i) => ({ id: String(i), ...c })),
288
+ teamProps: { teamInitial: teamStore.list() },
289
+ }
290
+ }
@@ -0,0 +1,174 @@
1
+ // PokeAPI fetch wrappers + formatting helpers.
2
+ //
3
+ // These run inside route LOADERS (plain Bun/JS — full language, no native
4
+ // constraint). Their job is to fetch from PokeAPI and return fully render-ready
5
+ // view-models so the native (jinja) page templates only interpolate fields.
6
+
7
+ export const API = 'https://pokeapi.co/api/v2'
8
+
9
+ export const idFromUrl = (url: string): number => Number((url.match(/\/pokemon\/(\d+)\//) || [])[1])
10
+
11
+ /** Official-artwork PNG from the PokeAPI sprites CDN — derived from id so the
12
+ * list page needs ONE PokeAPI call per page (no per-Pokémon detail fetch). */
13
+ export const artwork = (id: number): string =>
14
+ `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`
15
+
16
+ export const cap = (s: string): string =>
17
+ s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' ') : s
18
+
19
+ export const pad = (n: number): string => `#${String(n).padStart(4, '0')}`
20
+
21
+ /** Pokémon type → AssetsArt design-token CSS variable (stays inside brand —
22
+ * no canonical Pokémon colours). Mirrored by `.dex-type--<name>` rules in
23
+ * app.css. */
24
+ export const TYPE_COLOR: Record<string, string> = {
25
+ normal: 'var(--ink-400)',
26
+ fire: 'var(--magenta-500)',
27
+ water: 'var(--blue-500)',
28
+ grass: 'var(--success-500)',
29
+ electric: 'var(--warning-500)',
30
+ ice: 'var(--viz-4)',
31
+ fighting: 'var(--viz-3)',
32
+ poison: 'var(--purple-600)',
33
+ ground: 'var(--viz-7)',
34
+ flying: 'var(--viz-5)',
35
+ psychic: 'var(--purple-500)',
36
+ bug: 'var(--viz-8)',
37
+ rock: 'var(--ink-500)',
38
+ ghost: 'var(--purple-700)',
39
+ dragon: 'var(--viz-2)',
40
+ dark: 'var(--ink-800)',
41
+ steel: 'var(--ink-400)',
42
+ fairy: 'var(--magenta-300)',
43
+ }
44
+
45
+ export const STAT_LABEL: Record<string, string> = {
46
+ hp: 'HP',
47
+ attack: 'Atk',
48
+ defense: 'Def',
49
+ 'special-attack': 'Sp.Atk',
50
+ 'special-defense': 'Sp.Def',
51
+ speed: 'Spd',
52
+ }
53
+
54
+ /** Bucket a base stat into one of three fill modifier classes. */
55
+ export const statBucket = (base: number): string =>
56
+ base >= 100 ? 'hi' : base >= 60 ? 'mid' : base >= 35 ? 'low' : 'min'
57
+
58
+ export interface RawPokemon {
59
+ id: number
60
+ name: string
61
+ types: string[]
62
+ stats: { name: string; base: number }[]
63
+ height: number
64
+ weight: number
65
+ abilities: string[]
66
+ artwork: string
67
+ }
68
+
69
+ export interface RawSpecies {
70
+ flavorText: string
71
+ genus: string
72
+ evolutionUrl: string
73
+ }
74
+
75
+ export interface RawEvolutionStage {
76
+ id: number
77
+ name: string
78
+ minLevel: number | null
79
+ }
80
+
81
+ export async function fetchList(offset: number, limit: number) {
82
+ const res = await fetch(`${API}/pokemon?limit=${limit}&offset=${offset}`)
83
+ if (!res.ok) throw new Error(`PokeAPI list ${res.status}`)
84
+ const page = (await res.json()) as {
85
+ count: number
86
+ results: { name: string; url: string }[]
87
+ }
88
+ return {
89
+ results: page.results.map((r) => ({ id: idFromUrl(r.url), name: r.name })),
90
+ total: page.count,
91
+ }
92
+ }
93
+
94
+ export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
95
+ const res = await fetch(`${API}/pokemon/${name}`)
96
+ if (!res.ok) return null
97
+ const p = (await res.json()) as any
98
+ return {
99
+ id: p.id,
100
+ name: p.name,
101
+ types: p.types.map((t: any) => t.type.name),
102
+ stats: p.stats.map((s: any) => ({ name: s.stat.name, base: s.base_stat })),
103
+ height: p.height,
104
+ weight: p.weight,
105
+ abilities: p.abilities.map((a: any) => a.ability.name),
106
+ artwork: p.sprites?.other?.['official-artwork']?.front_default || artwork(p.id),
107
+ }
108
+ }
109
+
110
+ export async function fetchSpecies(id: number): Promise<RawSpecies> {
111
+ const res = await fetch(`${API}/pokemon-species/${id}`)
112
+ const s = (await res.json()) as any
113
+ const flavor = s.flavor_text_entries?.find((e: any) => e.language.name === 'en')?.flavor_text as
114
+ | string
115
+ | undefined
116
+ return {
117
+ flavorText: (flavor || '').replace(/[\n\f\r]+/g, ' ').trim(),
118
+ genus: s.genera?.find((g: any) => g.language.name === 'en')?.genus || '',
119
+ evolutionUrl: s.evolution_chain?.url || '',
120
+ }
121
+ }
122
+
123
+ /** Walk the (linear) evolution chain. Branching chains (e.g. Eevee) are
124
+ * flattened to the first branch — noted as an open question in the design. */
125
+ export async function fetchEvolution(url: string): Promise<RawEvolutionStage[]> {
126
+ if (!url) return []
127
+ const res = await fetch(url)
128
+ if (!res.ok) return []
129
+ const data = (await res.json()) as any
130
+ const stages: RawEvolutionStage[] = []
131
+ let node = data.chain
132
+ while (node) {
133
+ const id = idFromUrl(node.species.url.replace('-species', ''))
134
+ const det = node.evolution_details?.[0] || {}
135
+ stages.push({ id, name: node.species.name, minLevel: det.min_level || null })
136
+ node = node.evolves_to?.[0]
137
+ }
138
+ return stages
139
+ }
140
+
141
+ /** The 18 canonical type names, in chart order. */
142
+ export const ALL_TYPES = [
143
+ 'normal',
144
+ 'fire',
145
+ 'water',
146
+ 'electric',
147
+ 'grass',
148
+ 'ice',
149
+ 'fighting',
150
+ 'poison',
151
+ 'ground',
152
+ 'flying',
153
+ 'psychic',
154
+ 'bug',
155
+ 'rock',
156
+ 'ghost',
157
+ 'dragon',
158
+ 'dark',
159
+ 'steel',
160
+ 'fairy',
161
+ ]
162
+
163
+ /** Fetch one type's damage relations → a map of defendingType → multiplier. */
164
+ export async function fetchTypeRelations(type: string): Promise<Record<string, number>> {
165
+ const res = await fetch(`${API}/type/${type}`)
166
+ if (!res.ok) return {}
167
+ const d = (await res.json()) as any
168
+ const rel = d.damage_relations
169
+ const out: Record<string, number> = {}
170
+ for (const t of rel.double_damage_to || []) out[t.name] = 2
171
+ for (const t of rel.half_damage_to || []) out[t.name] = 0.5
172
+ for (const t of rel.no_damage_to || []) out[t.name] = 0
173
+ return out
174
+ }
@@ -0,0 +1,28 @@
1
+ // In-process team store — a module-scope Map that lives for the whole server
2
+ // process. It is GLOBAL across every request and every visitor: brust ships no
3
+ // per-session / request-context primitive, so for this dogfood the team is a
4
+ // single shared roster. See ../FRAMEWORK-GAPS.md S6.
5
+
6
+ import type { TeamMember } from './types'
7
+
8
+ const team = new Map<number, TeamMember>()
9
+ export const MAX_TEAM = 6
10
+
11
+ export const teamStore = {
12
+ list: (): TeamMember[] => [...team.values()].sort((a, b) => a.addedAt - b.addedAt),
13
+
14
+ /** Add a member. Returns `false` when the team is full (and the member is
15
+ * new); idempotent for a member already on the team. */
16
+ add: (m: Omit<TeamMember, 'addedAt'>): boolean => {
17
+ if (team.has(m.id)) return true
18
+ if (team.size >= MAX_TEAM) return false
19
+ team.set(m.id, { ...m, addedAt: Date.now() })
20
+ return true
21
+ },
22
+
23
+ remove: (id: number): void => {
24
+ team.delete(id)
25
+ },
26
+
27
+ isFull: (): boolean => team.size >= MAX_TEAM,
28
+ }
@@ -0,0 +1,137 @@
1
+ // Domain + view-model types for the PokéDex example.
2
+ //
3
+ // NATIVE NOTE: the page components are `native: true` routes compiled to
4
+ // minijinja. They now support conditionals (S11), `style={{…}}` object attrs
5
+ // (S1), and dynamic `<BrustPage>` head props (S8) — all ✅ FIXED. Still
6
+ // precomputed in the loader: formatted strings, helper-derived values, and
7
+ // multi-property style strings (templates have no template-literals / arithmetic
8
+ // / helper calls). See ../FRAMEWORK-GAPS.md.
9
+
10
+ /** A single list cell — derived from the list endpoint alone (no detail fetch,
11
+ * see FRAMEWORK-GAPS.md S2 / N+1 avoidance). */
12
+ export interface CardVM {
13
+ id: number
14
+ name: string
15
+ displayName: string // "Bulbasaur"
16
+ num: string // "#0001"
17
+ artwork: string // CDN URL derived from id
18
+ detailHref: string // "/pokemon/bulbasaur"
19
+ }
20
+
21
+ export interface ListData {
22
+ items: CardVM[]
23
+ total: number
24
+ totalLabel: string // "1,302"
25
+ offset: number
26
+ showingLabel: string // "1–20 of 1,302"
27
+ pageLabel: string // "1 / 66"
28
+ // Native routes now support conditionals (GAPS S11 closed): the template
29
+ // branches with `{flags.hasPrev ? <a/> : <span/>}` on these booleans.
30
+ hasPrev: boolean
31
+ hasNext: boolean
32
+ prevHref: string
33
+ nextHref: string
34
+ offsetLabel: string // raw offset for the loader-echo line
35
+ teamProps: { teamInitial: TeamMember[] }
36
+ }
37
+
38
+ export interface TypeBadgeVM {
39
+ label: string // "Grass"
40
+ className: string // "dex-type dex-type--grass"
41
+ }
42
+
43
+ export interface StatVM {
44
+ label: string // "HP" / "Atk" / …
45
+ base: number
46
+ barWidth: string // "62%" — fed into `style={{ width: barWidth }}` (S1)
47
+ barClassName: string // "dex-statbar__fill dex-statbar__fill--mid"
48
+ }
49
+
50
+ export interface AbilityVM {
51
+ displayName: string // "Overgrow"
52
+ initial: string // "O"
53
+ iconColor: string // type tint — fed into `style={{ background: iconColor }}` (S1)
54
+ }
55
+
56
+ export interface EvolutionStageVM {
57
+ id: number
58
+ name: string
59
+ displayName: string
60
+ num: string
61
+ artwork: string
62
+ detailHref: string
63
+ levelLabel: string // "Lv 16"
64
+ // Native routes now support per-item conditionals (GAPS S11 closed): the
65
+ // template tests these booleans (`{!s.isFirst && <Arrow/>}`) instead of
66
+ // toggling a precomputed `dex-hide` class.
67
+ isFirst: boolean // true on the first stage (no leading arrow)
68
+ showLevel: boolean // true when this stage has a min level to show
69
+ cardClassName: string // adds the "current" highlight when this is the open Pokémon
70
+ }
71
+
72
+ /** The full view-model handed to DetailPage. Every field is render-ready. */
73
+ export interface DetailData {
74
+ notFound: boolean
75
+ // Native routes now branch with `{notFound ? <NotFound/> : <Content/>}` (S11),
76
+ // so the content and 404 block are mutually exclusive at render time rather
77
+ // than both emitted with one hidden via a precomputed class.
78
+ pageTitle: string // dynamic <title> via `<BrustPage title={d.pageTitle}>` (S8)
79
+ name: string
80
+ // present only when notFound === false:
81
+ id: number
82
+ displayName: string
83
+ num: string
84
+ artwork: string
85
+ genus: string
86
+ flavorText: string
87
+ heightLabel: string // "0.7 m"
88
+ weightLabel: string // "6.9 kg"
89
+ abilityCount: number
90
+ heroBg: string // gradient value for `style={{ background: heroBg }}` — type-tinted
91
+ types: TypeBadgeVM[]
92
+ stats: StatVM[]
93
+ statTotal: number
94
+ abilities: AbilityVM[]
95
+ hasAbilities: boolean
96
+ evolution: EvolutionStageVM[]
97
+ hasEvolution: boolean
98
+ // island props (a single path each — native island props can't be object literals):
99
+ addProps: AddToTeamProps
100
+ teamProps: { teamInitial: TeamMember[] }
101
+ }
102
+
103
+ /** Props for the AddToTeamButton island (raw types kept for the action body). */
104
+ export interface AddToTeamProps {
105
+ id: number
106
+ name: string
107
+ displayName: string
108
+ num: string
109
+ types: string[]
110
+ artwork: string
111
+ }
112
+
113
+ /** One cell of the type chart, FLATTENED into a single row-major array so the
114
+ * native template renders it with ONE `.map()` into a CSS grid — nested maps
115
+ * aren't proven on the native path, so we avoid them. See FRAMEWORK-GAPS.md S10. */
116
+ export interface TypeChartCellVM {
117
+ id: string // stable key (row/col coordinate)
118
+ className: string // "dex-tc__cell dex-tc__cell--super"
119
+ content: string // "2", "½", "0", a type short-code, or ""
120
+ title: string // tooltip
121
+ }
122
+
123
+ export interface TypeChartData {
124
+ cells: TypeChartCellVM[] // (18+1) × (18+1) row-major, including headers
125
+ teamProps: { teamInitial: TeamMember[] }
126
+ }
127
+
128
+ /** In-process team store member. */
129
+ export interface TeamMember {
130
+ id: number
131
+ name: string
132
+ displayName: string
133
+ types: string[]
134
+ artwork: string
135
+ num: string
136
+ addedAt: number
137
+ }