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,29 +1,34 @@
1
1
  // Domain + view-model types for the PokéDex example.
2
2
  //
3
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.
4
+ // minijinja. Loaders precompute formatted strings, classNames, hrefs, inline
5
+ // style values, and x-props JSON; the templates only interpolate member-paths,
6
+ // run `.map()`, and branch on inline conditionals.
7
+
8
+ /** In-process / island team store member. */
9
+ export interface TeamMember {
10
+ id: number
11
+ name: string
12
+ displayName: string
13
+ types: string[]
14
+ artwork: string
15
+ num: string
16
+ addedAt: number
17
+ }
9
18
 
10
19
  /** Chrome view-model the router-level AppLayout reads from the MERGED loader
11
- * context (Approach a native <Outlet/> nesting). AppLayout takes NO props at
12
- * its call site (`<AppLayout native>`), so each leaf loader returns these
13
- * fields and the chain-loader merge folds them into the one flat jinja context
14
- * AppLayout's template reads ({{ title }}, active === 'list', teamProps). The
15
- * key names are chosen NOT to collide with any page-data field. */
20
+ * context. Each leaf loader returns these fields; the chain-loader merge folds
21
+ * them into the one flat jinja context AppLayout's template reads. */
16
22
  export interface ChromeData {
17
23
  title: string // dynamic <title> + nav header, via AppLayout's <BrustPage title={title}>
18
- active: 'list' | 'typechart' // which sidebar nav item gets is-active (S11 conditional)
19
24
  crumb: string // topbar breadcrumb leaf label
20
- teamProps: { teamInitial: TeamMember[] } // floating team-dock island initial state
21
25
  mode: 'dark' | 'light' // theme, read from the `mode` cookie → <html data-mode={mode}>
26
+ teamProps: { teamInitial: TeamMember[] } // floating team-dock island initial state
22
27
  }
23
28
 
24
- /** A single list cell — derived from the list endpoint alone (no detail fetch,
25
- * see FRAMEWORK-GAPS.md S2 / N+1 avoidance). */
26
- export interface CardVM {
29
+ /** A single dex grid cell — derived from the list endpoint alone (no detail
30
+ * fetch). */
31
+ export interface DexCard {
27
32
  id: number
28
33
  name: string
29
34
  displayName: string // "Bulbasaur"
@@ -32,127 +37,101 @@ export interface CardVM {
32
37
  detailHref: string // "/pokemon/bulbasaur"
33
38
  }
34
39
 
35
- export interface ListData extends ChromeData {
36
- items: CardVM[]
37
- total: number
38
- totalLabel: string // "1,302"
39
- offset: number
40
- showingLabel: string // "1–20 of 1,302"
41
- pageLabel: string // "1 / 66"
42
- // Native routes now support conditionals (GAPS S11 closed): the template
43
- // branches with `{flags.hasPrev ? <a/> : <span/>}` on these booleans.
44
- hasPrev: boolean
45
- hasNext: boolean
46
- prevHref: string
47
- nextHref: string
48
- offsetLabel: string // raw offset for the loader-echo line
49
- }
50
-
51
40
  export interface TypeBadgeVM {
52
41
  label: string // "Grass"
53
- className: string // "dex-type dex-type--grass"
42
+ color: string // hex tint — fed into an inline style value
54
43
  }
55
44
 
56
- export interface StatVM {
57
- label: string // "HP" / "Atk" / …
58
- base: number
59
- barWidth: string // "62%" fed into `style={{ width: barWidth }}` (S1)
60
- barClassName: string // "dex-statbar__fill dex-statbar__fill--mid"
45
+ /** One base-stat bar row on the detail page. All formatting precomputed. */
46
+ export interface StatBarVM {
47
+ label: string // "HP" / "Atk" …
48
+ base: number // raw base value
49
+ barWidth: string // "62%" — fed into style={{ width }}
50
+ barColor: string // hex per bucket — fed into style={{ background }}
61
51
  }
62
52
 
53
+ /** One ability chip on the detail page. */
63
54
  export interface AbilityVM {
64
55
  displayName: string // "Overgrow"
65
56
  initial: string // "O"
66
- iconColor: string // type tint — fed into `style={{ background: iconColor }}` (S1)
57
+ iconColor: string // hex tint — style={{ background }}
67
58
  }
68
59
 
69
- export interface EvolutionStageVM {
60
+ /** One stage of the evolution chain. */
61
+ export interface EvoStageVM {
70
62
  id: number
71
- name: string
72
63
  displayName: string
73
- num: string
64
+ num: string // "#0001"
74
65
  artwork: string
75
- detailHref: string
76
- levelLabel: string // "Lv 16"
77
- // Native routes now support per-item conditionals (GAPS S11 closed): the
78
- // template tests these booleans (`{!s.isFirst && <Arrow/>}`) instead of
79
- // toggling a precomputed `dex-hide` class.
80
- isFirst: boolean // true on the first stage (no leading arrow)
81
- showLevel: boolean // true when this stage has a min level to show
82
- cardClassName: string // adds the "current" highlight when this is the open Pokémon
66
+ detailHref: string // "/pokemon/ivysaur"
67
+ levelLabel: string // "Lv 16" or ""
68
+ isFirst: boolean
69
+ showLevel: boolean
70
+ isCurrent: boolean
71
+ }
72
+
73
+ /** One "browse by type" tile on the home page. */
74
+ export interface TypeTileVM {
75
+ name: string // raw type key, used as the .map() key
76
+ label: string // "Grass"
77
+ color: string // hex tint — fed into an inline style value
78
+ href: string // "/pokedex"
79
+ }
80
+
81
+ /** Home landing page data — curated featured strip + type tiles + chrome. */
82
+ export interface HomeData extends ChromeData {
83
+ featured: DexCard[]
84
+ typeTiles: TypeTileVM[]
83
85
  }
84
86
 
85
- /** The full view-model handed to DetailPage. Every field is render-ready. */
87
+ /** Browse (dex grid) page data. `dexProps` is the loader-precomputed JSON
88
+ * string handed to the DexFilter native directive (gen-1 grid, keyed x-for). */
89
+ export interface BrowseData extends ChromeData {
90
+ items: DexCard[] // the SSR {% for %} source (member-path array)
91
+ dexProps: string // the client x-props JSON (unchanged)
92
+ }
93
+
94
+ /** Detail page data. Every formatted string / className / inline-style value /
95
+ * x-props JSON is precomputed here so the native template only interpolates. */
86
96
  export interface DetailData extends ChromeData {
87
97
  notFound: boolean
88
- // Native routes now branch with `{notFound ? <NotFound/> : <Content/>}` (S11),
89
- // so the content and 404 block are mutually exclusive at render time rather
90
- // than both emitted with one hidden via a precomputed class.
91
- // The dynamic <title> is `title` (ChromeData), read by AppLayout's
92
- // <BrustPage title={title}> — no separate pageTitle field.
93
98
  name: string
94
- // present only when notFound === false:
95
99
  id: number
96
100
  displayName: string
97
- num: string
101
+ num: string // "#0001"
98
102
  artwork: string
99
- genus: string
103
+ genus: string // "Seed Pokémon"
100
104
  flavorText: string
101
105
  heightLabel: string // "0.7 m"
102
106
  weightLabel: string // "6.9 kg"
103
107
  abilityCount: number
104
- heroBg: string // gradient value for `style={{ background: heroBg }}` type-tinted
108
+ heroBg: string // CSS gradient string built in the loader from the type tint
105
109
  types: TypeBadgeVM[]
106
- stats: StatVM[]
110
+ stats: StatBarVM[]
107
111
  statTotal: number
108
112
  abilities: AbilityVM[]
109
113
  hasAbilities: boolean
110
- evolution: EvolutionStageVM[]
114
+ evolution: EvoStageVM[]
111
115
  hasEvolution: boolean
112
- // native interactive props: a single string path each (native props can't be
113
- // object literals). addProps is the loader-precomputed JSON string handed to
114
- // <AddToTeamButton data={addProps} /> → x-props (Spec B native directives).
115
- addProps: string
116
- }
117
-
118
- /** Shape of the AddToTeamButton native behavior's `props` (JSON-parsed from
119
- * x-props). Matches the action body fields so toggle() can post it directly. */
120
- export interface AddToTeamProps {
121
- id: number
122
- name: string
123
- displayName: string
124
- num: string
125
- types: string[]
126
- artwork: string
116
+ addProps: string // loader-precomputed x-props JSON for AddToTeamButton
127
117
  }
128
118
 
129
119
  /** One cell of the type chart. */
130
120
  export interface TypeChartCellVM {
131
121
  id: string // stable key "row-col"
132
- className: string // "dex-tc__cell dex-tc__cell--super"
122
+ className: string // static Tailwind utility string the loader picks per effectiveness
133
123
  content: string // "2", "½", "0", a type short-code, or ""
134
124
  title: string // tooltip
125
+ bg: string // inline background — type hex for header/row-head cells, '' for data cells
135
126
  }
136
127
 
137
- /** One row of the type chart (header row + one row per attacking type). The
138
- * native template renders rows.map(r => r.cells.map(c => …)) — nested `.map()`
139
- * is supported on the native path. */
128
+ /** One row (header or attack) of the type chart nested cells. */
140
129
  export interface TypeChartRowVM {
141
- id: string // row index as string
142
- cells: TypeChartCellVM[] // 19 cells (1 head + 18)
130
+ id: string
131
+ cells: TypeChartCellVM[]
143
132
  }
144
133
 
134
+ /** Type chart page data — nested rows[].cells[] grid, rendered with nested .map(). */
145
135
  export interface TypeChartData extends ChromeData {
146
- rows: TypeChartRowVM[] // 19 rows (1 header + 18), each 19 cells
147
- }
148
-
149
- /** In-process team store member. */
150
- export interface TeamMember {
151
- id: number
152
- name: string
153
- displayName: string
154
- types: string[]
155
- artwork: string
156
- num: string
157
- addedAt: number
136
+ rows: TypeChartRowVM[]
158
137
  }
@@ -0,0 +1,31 @@
1
+ // Native leaf route "/pokedex" — the dex grid. Renders into AppLayout's
2
+ // <Outlet/> slot (chrome lives in AppLayout). SINGLE return, NO local bindings.
3
+ //
4
+ // The grid itself is the DexFilter native directive. The loader precomputes the
5
+ // full gen-1 item list two ways: `items` (the array → the SSR `{% for c in items %}`
6
+ // seed the compiler emits, so the 151 cards paint server-side) and `dexProps` (the
7
+ // same list as the client x-props JSON). DexFilter seeds a signal from props.items
8
+ // and owns search/sort + the keyed x-for reconcile that adopts the SSR'd nodes.
9
+ import DexFilter from '../components/DexFilter'
10
+ import type { BrowseData } from '../lib/types'
11
+
12
+ export default function BrowsePage({ items, dexProps }: BrowseData) {
13
+ return (
14
+ <section className="py-2">
15
+ <div className="mb-6">
16
+ <h1 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
17
+ Pokédex
18
+ </h1>
19
+ <p className="mt-2 max-w-2xl text-slate-500 dark:text-slate-400">
20
+ The original 151. Search by name and toggle sort order — the grid reconciles live with a
21
+ keyed{' '}
22
+ <code className="rounded bg-slate-100 px-1.5 py-0.5 text-sm dark:bg-slate-800">
23
+ x-for
24
+ </code>
25
+ , no full re-render.
26
+ </p>
27
+ </div>
28
+ <DexFilter native items={items} data={dexProps} />
29
+ </section>
30
+ )
31
+ }
@@ -1,18 +1,17 @@
1
- // Route "/pokemon/{name}" — NATIVE detail leaf route, rendered into AppLayout's
2
- // <Outlet/> slot (chrome lives in AppLayout). Returns JUST its inner aa-content
3
- // fragmentno <BrustPage>, no <main>. Per the decision to push native as far
4
- // as possible, the evolution chain is loaded BLOCKING in the loader (a
5
- // native/jinja route has no React tree, so <Suspense> streaming is impossible —
6
- // see ../FRAMEWORK-GAPS.md S3).
1
+ // Native leaf route "/pokemon/{name}" — the detail page. Renders into
2
+ // AppLayout's <Outlet/> slot (chrome lives in AppLayout). SINGLE return, NO
3
+ // local bindings every formatted string / className / inline-style value /
4
+ // x-props JSON is precomputed in detailLoader.
7
5
  //
8
- // Native routes now support conditionals (S11): the page uses a real
9
- // `{notFound ? <NotFound/> : <Content/>}` branch, `{hasAbilities && …}` /
10
- // `{hasEvolution && …}` sections, and per-item `{!s.isFirst && <Arrow/>}` /
11
- // `{s.showLevel && <Level/>}` separators no more loader-computed hide-classes.
12
- // The <title> is dynamic via AppLayout's `<BrustPage title={title}>` (S8, the
13
- // loader merges `title` into the shared context) and inline styles use
14
- // `style={{…}}` objects (S1).
6
+ // Native constraints: member-paths, `.map()`, inline conditionals
7
+ // (`{c ? a : b}` / `{c && x}`), and `style={{…}}` with member-path/literal
8
+ // values only. Type-tint colors and the hero gradient come from the loader as
9
+ // inline-style values (Tailwind can't scan runtime-built color classes). The
10
+ // evolution chain is loaded BLOCKING in the loader (native routes have no React
11
+ // tree, so <Suspense> streaming is impossible).
12
+ import { ArrowLeft, ChevronRight, Ruler, SearchX, Sigma, Weight } from 'lucide-react'
15
13
  import AddToTeamButton from '../components/AddToTeamButton'
14
+ import Breadcrumb from '../components/Breadcrumb'
16
15
  import type { DetailData } from '../lib/types'
17
16
 
18
17
  export default function DetailPage({
@@ -36,65 +35,109 @@ export default function DetailPage({
36
35
  addProps,
37
36
  }: DetailData) {
38
37
  return (
39
- <>
40
- <a className="aa-btn aa-btn--ghost aa-btn--sm dex-back" href="/">
41
- Pokédex
42
- </a>
43
-
38
+ <section className="py-2">
39
+ <div className="mx-auto flex max-w-6xl items-center gap-1.5 px-4 pb-2 text-xs text-slate-400">
40
+ <a href="/" className="no-underline hover:text-slate-600 dark:hover:text-slate-200">
41
+ PokéDex
42
+ </a>
43
+ <ChevronRight size={14} isr={{ key: 'LcIconChevronRight14' }} />
44
+ <Breadcrumb native crumb={displayName} />
45
+ </div>
44
46
  {notFound ? (
45
- <div className="dex-notfound">
46
- <div className="dex-notfound__code">404</div>
47
- <h2 className="aa-h3">No Pokémon named “{displayName}”</h2>
48
- <p className="dex-notfound__desc">
49
- loader ได้ 404 จาก PokeAPI. brust ยังไม่มี notFound() sentinel — หน้านี้จึงตอบ HTTP 200 พร้อม
50
- body นี้ (ดู FRAMEWORK-GAPS.md S9).
47
+ <div className="mx-auto max-w-md rounded-3xl border border-slate-200 bg-white px-6 py-16 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
48
+ <SearchX size={48} className="mx-auto text-brand-500" isr={{ key: 'LcIconSearchX48' }} />
49
+ <div className="mt-3 text-6xl font-black tracking-tighter text-brand-500">404</div>
50
+ <h1 className="mt-4 text-2xl font-extrabold tracking-tight text-slate-900 dark:text-white">
51
+ No Pokémon named “{displayName}”
52
+ </h1>
53
+ <p className="mt-2 text-slate-500 dark:text-slate-400">
54
+ The Pokédex has no entry under that name. Check the spelling or browse the full list.
51
55
  </p>
52
- <a className="aa-btn" href="/">
53
- ‹ Back to Pokédex
56
+ <a
57
+ href="/pokedex"
58
+ className="mt-6 inline-flex items-center gap-2 rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-semibold text-white no-underline shadow-sm transition-colors hover:bg-brand-600"
59
+ >
60
+ <ArrowLeft size={16} isr={{ key: 'LcIconArrowLeft' }} />
61
+ Back to Pokédex
54
62
  </a>
55
63
  </div>
56
64
  ) : (
57
- <>
58
- <div className="dex-detail-grid">
59
- <div className="aa-card dex-hero" style={{ background: heroBg }}>
60
- <div className="dex-hero__num">{num}</div>
61
- <h1 className="dex-hero__name">{displayName}</h1>
62
- <div className="dex-hero__genus">{genus}</div>
63
- <div className="dex-hero__art">
64
- <img src={artwork} alt={displayName} className="dex-hero__img" />
65
+ <div className="space-y-10">
66
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
67
+ <div
68
+ className="relative flex flex-col items-center overflow-hidden rounded-3xl border border-slate-200 bg-white p-6 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:col-span-2"
69
+ style={{ background: heroBg }}
70
+ >
71
+ <div className="self-start text-sm font-bold tabular-nums text-slate-400">{num}</div>
72
+ <img
73
+ src={artwork}
74
+ alt={displayName}
75
+ className="h-56 w-56 object-contain drop-shadow-xl"
76
+ />
77
+ <h1 className="mt-2 text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
78
+ {displayName}
79
+ </h1>
80
+ <div className="mt-1 text-sm font-medium text-slate-500 dark:text-slate-400">
81
+ {genus}
65
82
  </div>
66
- <div className="dex-hero__types">
83
+ <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
67
84
  {types.map((t) => (
68
- <span key={t.label} className={t.className}>
85
+ <span
86
+ key={t.label}
87
+ style={{ background: t.color }}
88
+ className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white shadow-sm"
89
+ >
69
90
  {t.label}
70
91
  </span>
71
92
  ))}
72
93
  </div>
73
- <AddToTeamButton native data={addProps} />
94
+ <div className="mt-6 w-full max-w-xs">
95
+ <AddToTeamButton native data={addProps} />
96
+ </div>
74
97
  </div>
75
98
 
76
- <div className="dex-detail-right">
77
- <div className="aa-card aa-card--padded">
78
- <p className="dex-flavor">{flavorText}</p>
79
- <div className="dex-measures">
80
- <div className="dex-measure">
81
- <div className="dex-measure__v">{heightLabel}</div>
82
- <div className="dex-measure__k">Height</div>
99
+ <div className="space-y-6 lg:col-span-3">
100
+ <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
101
+ <p className="text-slate-600 dark:text-slate-300">{flavorText}</p>
102
+ <div className="mt-5 grid grid-cols-3 gap-3">
103
+ <div className="rounded-2xl bg-slate-50 px-3 py-3 text-center dark:bg-slate-800/60">
104
+ <div className="text-lg font-extrabold text-slate-900 dark:text-white">
105
+ {heightLabel}
106
+ </div>
107
+ <div className="flex items-center justify-center gap-1 text-xs font-medium uppercase tracking-wide text-slate-400">
108
+ <Ruler size={12} isr={{ key: 'LcIconRuler12' }} />
109
+ Height
110
+ </div>
83
111
  </div>
84
- <div className="dex-measure">
85
- <div className="dex-measure__v">{weightLabel}</div>
86
- <div className="dex-measure__k">Weight</div>
112
+ <div className="rounded-2xl bg-slate-50 px-3 py-3 text-center dark:bg-slate-800/60">
113
+ <div className="text-lg font-extrabold text-slate-900 dark:text-white">
114
+ {weightLabel}
115
+ </div>
116
+ <div className="flex items-center justify-center gap-1 text-xs font-medium uppercase tracking-wide text-slate-400">
117
+ <Weight size={12} isr={{ key: 'LcIconWeight12' }} />
118
+ Weight
119
+ </div>
87
120
  </div>
88
- <div className="dex-measure">
89
- <div className="dex-measure__v">{abilityCount}</div>
90
- <div className="dex-measure__k">Abilities</div>
121
+ <div className="rounded-2xl bg-slate-50 px-3 py-3 text-center dark:bg-slate-800/60">
122
+ <div className="text-lg font-extrabold text-slate-900 dark:text-white">
123
+ {abilityCount}
124
+ </div>
125
+ <div className="text-xs font-medium uppercase tracking-wide text-slate-400">
126
+ Abilities
127
+ </div>
91
128
  </div>
92
129
  </div>
93
130
  {hasAbilities && (
94
- <div className="dex-abilities">
131
+ <div className="mt-4 flex flex-wrap gap-2">
95
132
  {abilities.map((a) => (
96
- <span key={a.displayName} className="aa-chip">
97
- <span className="aa-chip__icon" style={{ background: a.iconColor }}>
133
+ <span
134
+ key={a.displayName}
135
+ className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 py-1 pl-1 pr-3 text-sm font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-200"
136
+ >
137
+ <span
138
+ style={{ background: a.iconColor }}
139
+ className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold text-white"
140
+ >
98
141
  {a.initial}
99
142
  </span>
100
143
  {a.displayName}
@@ -104,18 +147,33 @@ export default function DetailPage({
104
147
  )}
105
148
  </div>
106
149
 
107
- <div className="aa-section dex-stats">
108
- <div className="aa-section__head">
109
- <h2 className="aa-section__title">Base stats</h2>
110
- <span className="dex-stats__total">Σ {statTotal}</span>
150
+ <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
151
+ <div className="mb-4 flex items-center justify-between">
152
+ <h2 className="text-lg font-extrabold tracking-tight text-slate-900 dark:text-white">
153
+ Base stats
154
+ </h2>
155
+ <span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-xs font-bold tabular-nums text-slate-500 dark:bg-slate-800 dark:text-slate-300">
156
+ <Sigma size={12} isr={{ key: 'LcIconSigma12' }} />
157
+ {statTotal}
158
+ </span>
111
159
  </div>
112
- <div className="aa-section__body dex-stats__body">
160
+ <div className="space-y-3">
113
161
  {stats.map((st) => (
114
- <div key={st.label} className="dex-statbar">
115
- <span className="dex-statbar__label">{st.label}</span>
116
- <span className="dex-statbar__val">{st.base}</span>
117
- <div className="dex-statbar__track">
118
- <div className={st.barClassName} style={{ width: st.barWidth }} />
162
+ <div
163
+ key={st.label}
164
+ className="grid grid-cols-[3rem_2.5rem_1fr] items-center gap-3"
165
+ >
166
+ <span className="text-xs font-semibold uppercase tracking-wide text-slate-400">
167
+ {st.label}
168
+ </span>
169
+ <span className="text-sm font-bold tabular-nums text-slate-700 dark:text-slate-200">
170
+ {st.base}
171
+ </span>
172
+ <div className="h-2.5 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
173
+ <div
174
+ className="h-full rounded-full"
175
+ style={{ width: st.barWidth, background: st.barColor }}
176
+ />
119
177
  </div>
120
178
  </div>
121
179
  ))}
@@ -125,42 +183,69 @@ export default function DetailPage({
125
183
  </div>
126
184
 
127
185
  {hasEvolution && (
128
- <div className="aa-section dex-evo">
129
- <div className="aa-section__head">
130
- <div>
131
- <h2 className="aa-section__title">Evolution chain</h2>
132
- <div className="aa-section__desc">
133
- โหลดใน loader (native route stream{' '}
134
- <code className="dex-code">&lt;Suspense&gt;</code> ไม่ได้ — ดู GAPS S3)
135
- </div>
136
- </div>
137
- </div>
138
- <div className="aa-section__body dex-evo__body">
186
+ <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
187
+ <h2 className="mb-5 text-lg font-extrabold tracking-tight text-slate-900 dark:text-white">
188
+ Evolution chain
189
+ </h2>
190
+ <div className="flex flex-wrap items-center justify-center gap-2">
139
191
  {evolution.map((s) => (
140
- <div key={s.id} className="dex-evo__stage">
141
- {!s.isFirst && (
142
- <div className="dex-evo__sep">
143
- <span className="dex-evo__arrow">›</span>
144
- {s.showLevel && <span className="dex-evo__lv">{s.levelLabel}</span>}
192
+ <div key={s.id} className="flex items-center gap-2">
193
+ {s.isFirst ? (
194
+ <span />
195
+ ) : (
196
+ <div className="flex flex-col items-center px-1 text-slate-400">
197
+ <span className="text-xl leading-none">›</span>
198
+ {s.showLevel && (
199
+ <span className="text-[10px] font-semibold uppercase tracking-wide">
200
+ {s.levelLabel}
201
+ </span>
202
+ )}
145
203
  </div>
146
204
  )}
147
- <a className={s.cardClassName} href={s.detailHref}>
148
- <img
149
- className="dex-evo__img"
150
- src={s.artwork}
151
- alt={s.displayName}
152
- loading="lazy"
153
- />
154
- <div className="dex-evo__num">{s.num}</div>
155
- <div className="dex-evo__name">{s.displayName}</div>
156
- </a>
205
+ {s.isCurrent ? (
206
+ <a
207
+ href={s.detailHref}
208
+ className="flex w-28 flex-col items-center rounded-2xl border-2 border-brand-500 bg-brand-50/60 p-3 no-underline shadow-sm dark:border-brand-500 dark:bg-brand-500/10"
209
+ >
210
+ <img
211
+ src={s.artwork}
212
+ alt={s.displayName}
213
+ loading="lazy"
214
+ className="h-20 w-20 object-contain"
215
+ />
216
+ <div className="text-[11px] font-semibold tabular-nums text-slate-400">
217
+ {s.num}
218
+ </div>
219
+ <div className="text-sm font-bold text-slate-900 dark:text-white">
220
+ {s.displayName}
221
+ </div>
222
+ </a>
223
+ ) : (
224
+ <a
225
+ href={s.detailHref}
226
+ className="flex w-28 flex-col items-center rounded-2xl border border-slate-200 bg-white p-3 no-underline shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/50 dark:border-slate-800 dark:bg-slate-900"
227
+ >
228
+ <img
229
+ src={s.artwork}
230
+ alt={s.displayName}
231
+ loading="lazy"
232
+ className="h-20 w-20 object-contain"
233
+ />
234
+ <div className="text-[11px] font-semibold tabular-nums text-slate-400">
235
+ {s.num}
236
+ </div>
237
+ <div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
238
+ {s.displayName}
239
+ </div>
240
+ </a>
241
+ )}
157
242
  </div>
158
243
  ))}
159
244
  </div>
160
245
  </div>
161
246
  )}
162
- </>
247
+ </div>
163
248
  )}
164
- </>
249
+ </section>
165
250
  )
166
251
  }