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

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,233 @@
1
+ import AddToTeamButton from '../components/AddToTeamButton'
2
+ import Breadcrumb from '../components/Breadcrumb'
3
+ import TypeBadge from '../components/TypeBadge'
4
+ import { fmtHeight, fmtWeight } from '../lib/format'
5
+ import type { DetailData } from '../lib/types'
6
+
7
+ export default function DetailPage({
8
+ notFound,
9
+ id,
10
+ hasAbilities,
11
+ hasEvolution,
12
+ displayName,
13
+ num,
14
+ artwork,
15
+ genus,
16
+ flavorText,
17
+ height,
18
+ weight,
19
+ abilityCount,
20
+ heroBg,
21
+ typeNames,
22
+ stats,
23
+ statTotal,
24
+ abilities,
25
+ evolution,
26
+ }: DetailData) {
27
+ // Computed outside the notFound branch so the build job is unguarded: a guarded slot makes the job
28
+ // return `undefined` for the 404 payload and the server rejects it (ledger F67).
29
+ const heightLabel = fmtHeight(height)
30
+ const weightLabel = fmtWeight(weight)
31
+ return (
32
+ <section className="py-2">
33
+ <div className="mx-auto flex max-w-6xl items-center gap-1.5 px-4 pb-2 text-xs text-slate-400">
34
+ <a href="/" className="no-underline hover:text-slate-600 dark:hover:text-slate-200">
35
+ PokéDex
36
+ </a>
37
+ <Breadcrumb crumb={displayName} />
38
+ </div>
39
+ {notFound ? (
40
+ <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">
41
+ <div className="mt-3 text-6xl font-black tracking-tighter text-brand-500">404</div>
42
+ <h1 className="mt-4 text-2xl font-extrabold tracking-tight text-slate-900 dark:text-white">
43
+ No Pokémon named “{displayName}”
44
+ </h1>
45
+ <p className="mt-2 text-slate-500 dark:text-slate-400">
46
+ The Pokédex has no entry under that name. Check the spelling or browse the full list.
47
+ </p>
48
+ <a
49
+ href="/pokedex"
50
+ 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"
51
+ >
52
+ Back to Pokédex
53
+ </a>
54
+ </div>
55
+ ) : (
56
+ <div className="space-y-10">
57
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
58
+ <div
59
+ 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"
60
+ style={{ background: heroBg }}
61
+ >
62
+ <div className="self-start text-sm font-bold tabular-nums text-slate-400">{num}</div>
63
+ <img
64
+ src={artwork}
65
+ alt={displayName}
66
+ className="h-56 w-56 object-contain drop-shadow-xl"
67
+ />
68
+ <h1 className="mt-2 text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
69
+ {displayName}
70
+ </h1>
71
+ <div className="mt-1 text-sm font-medium text-slate-500 dark:text-slate-400">
72
+ {genus}
73
+ </div>
74
+ <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
75
+ {typeNames.map((t) => (
76
+ <TypeBadge key={t} type={t} />
77
+ ))}
78
+ </div>
79
+ <div className="mt-5 w-full">
80
+ <AddToTeamButton id={id} />
81
+ </div>
82
+ </div>
83
+
84
+ <div className="space-y-6 lg:col-span-3">
85
+ <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
86
+ <p className="text-slate-600 dark:text-slate-300">{flavorText}</p>
87
+ <div className="mt-5 grid grid-cols-3 gap-3">
88
+ <div className="rounded-2xl bg-slate-50 px-3 py-3 text-center dark:bg-slate-800/60">
89
+ <div className="text-lg font-extrabold text-slate-900 dark:text-white">
90
+ {heightLabel}
91
+ </div>
92
+ <div className="flex items-center justify-center gap-1 text-xs font-medium uppercase tracking-wide text-slate-400">
93
+ Height
94
+ </div>
95
+ </div>
96
+ <div className="rounded-2xl bg-slate-50 px-3 py-3 text-center dark:bg-slate-800/60">
97
+ <div className="text-lg font-extrabold text-slate-900 dark:text-white">
98
+ {weightLabel}
99
+ </div>
100
+ <div className="flex items-center justify-center gap-1 text-xs font-medium uppercase tracking-wide text-slate-400">
101
+ Weight
102
+ </div>
103
+ </div>
104
+ <div className="rounded-2xl bg-slate-50 px-3 py-3 text-center dark:bg-slate-800/60">
105
+ <div className="text-lg font-extrabold text-slate-900 dark:text-white">
106
+ {abilityCount}
107
+ </div>
108
+ <div className="text-xs font-medium uppercase tracking-wide text-slate-400">
109
+ Abilities
110
+ </div>
111
+ </div>
112
+ </div>
113
+ {hasAbilities && (
114
+ <div className="mt-4 flex flex-wrap gap-2">
115
+ {abilities.map((a) => (
116
+ <span
117
+ key={a.displayName}
118
+ 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"
119
+ >
120
+ <span
121
+ style={{ background: a.iconColor }}
122
+ className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold text-white"
123
+ >
124
+ {a.initial}
125
+ </span>
126
+ {a.displayName}
127
+ </span>
128
+ ))}
129
+ </div>
130
+ )}
131
+ </div>
132
+
133
+ <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
134
+ <div className="mb-4 flex items-center justify-between">
135
+ <h2 className="text-lg font-extrabold tracking-tight text-slate-900 dark:text-white">
136
+ Base stats
137
+ </h2>
138
+ <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">
139
+ {statTotal}
140
+ </span>
141
+ </div>
142
+ <div className="space-y-3">
143
+ {stats.map((st) => (
144
+ <div
145
+ key={st.label}
146
+ className="grid grid-cols-[3rem_2.5rem_1fr] items-center gap-3"
147
+ >
148
+ <span className="text-xs font-semibold uppercase tracking-wide text-slate-400">
149
+ {st.label}
150
+ </span>
151
+ <span className="text-sm font-bold tabular-nums text-slate-700 dark:text-slate-200">
152
+ {st.base}
153
+ </span>
154
+ <div className="h-2.5 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
155
+ <div
156
+ className="h-full rounded-full"
157
+ style={{ width: st.barWidth, background: st.barColor }}
158
+ />
159
+ </div>
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ {hasEvolution && (
168
+ <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
169
+ <h2 className="mb-5 text-lg font-extrabold tracking-tight text-slate-900 dark:text-white">
170
+ Evolution chain
171
+ </h2>
172
+ <div className="flex flex-wrap items-center justify-center gap-2">
173
+ {evolution.map((s) => (
174
+ <div key={s.id} className="flex items-center gap-2">
175
+ {s.isFirst ? (
176
+ <span />
177
+ ) : (
178
+ <div className="flex flex-col items-center px-1 text-slate-400">
179
+ <span className="text-xl leading-none">›</span>
180
+ {s.showLevel && (
181
+ <span className="text-[10px] font-semibold uppercase tracking-wide">
182
+ {s.levelLabel}
183
+ </span>
184
+ )}
185
+ </div>
186
+ )}
187
+ {s.isCurrent ? (
188
+ <a
189
+ href={s.detailHref}
190
+ 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"
191
+ >
192
+ <img
193
+ src={s.artwork}
194
+ alt={s.displayName}
195
+ loading="lazy"
196
+ className="h-20 w-20 object-contain"
197
+ />
198
+ <div className="text-[11px] font-semibold tabular-nums text-slate-400">
199
+ {s.num}
200
+ </div>
201
+ <div className="text-sm font-bold text-slate-900 dark:text-white">
202
+ {s.displayName}
203
+ </div>
204
+ </a>
205
+ ) : (
206
+ <a
207
+ href={s.detailHref}
208
+ 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"
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-semibold text-slate-800 dark:text-slate-100">
220
+ {s.displayName}
221
+ </div>
222
+ </a>
223
+ )}
224
+ </div>
225
+ ))}
226
+ </div>
227
+ </div>
228
+ )}
229
+ </div>
230
+ )}
231
+ </section>
232
+ )
233
+ }
@@ -0,0 +1,129 @@
1
+ import HeroSearch from '../components/HeroSearch'
2
+ import TypeBadge from '../components/TypeBadge'
3
+ import type { HomeData } from '../lib/types'
4
+
5
+ export default function HomePage({ featured, types }: HomeData) {
6
+ return (
7
+ <div className="space-y-16 py-4">
8
+ <section className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-brand-500 via-brand-600 to-indigo-700 px-6 py-16 text-center shadow-xl sm:px-12">
9
+ <span className="inline-flex items-center rounded-full bg-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white/90 ring-1 ring-white/20">
10
+ Built with brust
11
+ </span>
12
+ <h1 className="mx-auto mt-5 max-w-2xl text-4xl font-extrabold tracking-tight text-white sm:text-5xl">
13
+ Every Pokémon, one fast native-rendered Pokédex.
14
+ </h1>
15
+ <p className="mx-auto mt-4 max-w-xl text-base text-white/85 sm:text-lg">
16
+ Browse the National Dex, study the type chart, and build your dream team — server-rendered
17
+ in Rust, hydrated only where it counts.
18
+ </p>
19
+ <HeroSearch />
20
+ <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
21
+ <a
22
+ href="/pokedex"
23
+ className="inline-flex items-center gap-2 rounded-xl bg-white px-5 py-2.5 text-sm font-semibold text-brand-700 no-underline shadow-sm transition-transform hover:-translate-y-0.5"
24
+ >
25
+ Browse Pokédex
26
+ </a>
27
+ <a
28
+ href="/type-chart"
29
+ className="inline-flex items-center gap-2 rounded-xl bg-white/15 px-5 py-2.5 text-sm font-semibold text-white no-underline ring-1 ring-white/30 transition-colors hover:bg-white/25"
30
+ >
31
+ Type chart
32
+ </a>
33
+ </div>
34
+ </section>
35
+
36
+ <section>
37
+ <div className="mb-5 flex items-end justify-between">
38
+ <h2 className="text-2xl font-extrabold tracking-tight text-slate-900 dark:text-white">
39
+ Featured
40
+ </h2>
41
+ <a
42
+ href="/pokedex"
43
+ className="inline-flex items-center gap-1 text-sm font-semibold text-brand-600 no-underline hover:underline dark:text-brand-50"
44
+ >
45
+ View all
46
+ </a>
47
+ </div>
48
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
49
+ {featured.map((p) => (
50
+ <a
51
+ key={p.id}
52
+ href={p.detailHref}
53
+ className="group flex flex-col items-center rounded-2xl border border-slate-200 bg-white p-4 no-underline shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/50 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"
54
+ >
55
+ <span className="self-start text-[11px] font-semibold tabular-nums text-slate-400">
56
+ {p.num}
57
+ </span>
58
+ <img
59
+ src={p.artwork}
60
+ alt={p.displayName}
61
+ loading="lazy"
62
+ className="h-28 w-28 object-contain transition-transform group-hover:scale-110"
63
+ />
64
+ <div className="mt-1 text-sm font-semibold text-slate-800 dark:text-slate-100">
65
+ {p.displayName}
66
+ </div>
67
+ </a>
68
+ ))}
69
+ </div>
70
+ </section>
71
+
72
+ <section>
73
+ <h2 className="mb-5 text-2xl font-extrabold tracking-tight text-slate-900 dark:text-white">
74
+ Browse by type
75
+ </h2>
76
+ <div className="grid grid-cols-3 gap-2.5 sm:grid-cols-6">
77
+ {types.map((t) => (
78
+ <a key={t} href="/pokedex" className="flex items-center justify-center no-underline">
79
+ <TypeBadge type={t} />
80
+ </a>
81
+ ))}
82
+ </div>
83
+ </section>
84
+
85
+ <section className="overflow-hidden rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
86
+ <div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
87
+ <div>
88
+ <h2 className="text-2xl font-extrabold tracking-tight text-slate-900 dark:text-white">
89
+ Build your team
90
+ </h2>
91
+ <p className="mt-2 max-w-md text-slate-500 dark:text-slate-400">
92
+ Add up to six Pokémon from any detail page. Your team lives in the floating dock and
93
+ follows you across the whole site.
94
+ </p>
95
+ </div>
96
+ <a
97
+ href="/pokedex"
98
+ className="inline-flex shrink-0 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"
99
+ >
100
+ Start picking
101
+ </a>
102
+ </div>
103
+ </section>
104
+
105
+ <section>
106
+ <h2 className="mb-1 text-2xl font-extrabold tracking-tight text-slate-900 dark:text-white">
107
+ Built with brust
108
+ </h2>
109
+ <p className="mb-6 max-w-2xl text-slate-500 dark:text-slate-400">
110
+ This whole site is one brust app, served from an offline PokeAPI snapshot.
111
+ </p>
112
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
113
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
114
+ <div className="text-sm font-bold text-slate-900 dark:text-white">Native SSR routes</div>
115
+ <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">Pages compile to templates and render in Rust.</div>
116
+ </div>
117
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
118
+ <div className="text-sm font-bold text-slate-900 dark:text-white">Loaders + cache</div>
119
+ <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">Loaders run in Bun; responses are cached by tag.</div>
120
+ </div>
121
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
122
+ <div className="text-sm font-bold text-slate-900 dark:text-white">React islands</div>
123
+ <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">The team dock hydrates as a React child while the page stays static.</div>
124
+ </div>
125
+ </div>
126
+ </section>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,7 @@
1
+ export default function NotFoundPage() {
2
+ return (
3
+ <html lang="en" data-mode="dark"><head><meta charSet="utf-8" /><title>Not found · PokéDex</title><link rel="stylesheet" href="/public/app.css" /></head>
4
+ <body className="min-h-screen bg-slate-50 dark:bg-slate-950"><main className="mx-auto max-w-md py-16 text-center"><div className="text-6xl font-black text-brand-500">404</div><h1 className="mt-4 text-2xl font-extrabold">Nothing here</h1><a href="/" className="mt-6 inline-block rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-semibold text-white no-underline">Back home</a></main></body>
5
+ </html>
6
+ )
7
+ }
@@ -0,0 +1,72 @@
1
+ // Native leaf route "/type-chart" — the 18×18 type-effectiveness matrix.
2
+ // Renders into AppLayout's <Outlet/> slot (chrome lives in AppLayout). SINGLE
3
+ // return, NO local bindings. Pure read-only data → the ideal native page
4
+ // (compiled to jinja, rendered in Rust, zero React on the server).
5
+ //
6
+ // The 19×19 grid uses nested `.map()`: rows.map(r => r.cells.map(c => …)) into a
7
+ // CSS grid. Each row wrapper is `display:contents` (Tailwind `contents`) so
8
+ // every cell is a direct grid item — the row div leaves no box. Cell coloring
9
+ // is a static Tailwind utility class string the loader picks per effectiveness
10
+ // (literals in a .ts file, so `@source` scans them). The header row + row heads
11
+ // are `sticky` (also set in the loader class strings).
12
+ import type { TypeChartData } from '../lib/types'
13
+
14
+ export default function TypeChart({ rows }: TypeChartData) {
15
+ return (
16
+ <section className="py-2">
17
+ <div className="mb-6">
18
+ <h1 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
19
+ Type chart
20
+ </h1>
21
+ <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-500 dark:text-slate-400">
22
+ Damage relations — read a row (attacker) across to a column (defender). Rendered
23
+ server-side in Rust from a compiled template.
24
+ </p>
25
+ </div>
26
+
27
+ <div className="mb-5 flex flex-wrap items-center gap-2.5 text-sm">
28
+ <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
29
+ <span className="flex h-5 w-5 items-center justify-center rounded bg-emerald-500 text-xs font-bold text-white">
30
+ 2
31
+ </span>
32
+ super effective
33
+ </span>
34
+ <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
35
+ <span className="flex h-5 w-5 items-center justify-center rounded bg-rose-400 text-xs font-bold text-white dark:bg-rose-500">
36
+ ½
37
+ </span>
38
+ not very effective
39
+ </span>
40
+ <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
41
+ <span className="flex h-5 w-5 items-center justify-center rounded bg-slate-800 text-xs font-bold text-slate-100 dark:bg-slate-950 dark:text-slate-400">
42
+ 0
43
+ </span>
44
+ no effect
45
+ </span>
46
+ <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
47
+ <span className="h-5 w-5 rounded bg-slate-50 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700" />
48
+ normal (1×)
49
+ </span>
50
+ </div>
51
+
52
+ <div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-900">
53
+ <div className="grid w-full grid-cols-[2.75rem_repeat(18,minmax(1.75rem,1fr))] gap-px overflow-hidden rounded-xl bg-slate-200 dark:bg-slate-700">
54
+ {rows.map((r) => (
55
+ <div key={r.id} className="contents">
56
+ {r.cells.map((c) => (
57
+ <div
58
+ key={c.id}
59
+ className={c.className}
60
+ title={c.title}
61
+ style={{ background: c.bg }}
62
+ >
63
+ {c.content}
64
+ </div>
65
+ ))}
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </div>
70
+ </section>
71
+ )
72
+ }
@@ -0,0 +1,24 @@
1
+ // Route "/unified" — one store, three surfaces: a React island, a native counter and a native read-only node all
2
+ // read and write `unified`. First paint comes from the server snapshot (`?start=` seeds it in the loader).
3
+ import UnifiedIsland from '../components/UnifiedIsland'
4
+ import UnifiedNative from '../components/UnifiedNative'
5
+ import UnifiedStoreNode from '../components/UnifiedStoreNode'
6
+
7
+ export default function UnifiedPage() {
8
+ return (
9
+ <section className="py-2">
10
+ <div className="mb-8">
11
+ <h1 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">Unified store</h1>
12
+ <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-500 dark:text-slate-400">
13
+ One store, three surfaces. The server seeds it, the first paint already shows the value, and a click on either side
14
+ moves the island, the native counter and the centre node together, with no reload.
15
+ </p>
16
+ </div>
17
+ <div className="grid items-center gap-6 rounded-3xl border border-slate-200 bg-white p-8 shadow-sm md:grid-cols-3 dark:border-slate-800 dark:bg-slate-900">
18
+ <UnifiedIsland />
19
+ <UnifiedStoreNode />
20
+ <UnifiedNative />
21
+ </div>
22
+ </section>
23
+ )
24
+ }
@@ -0,0 +1,3 @@
1
+ /* generated: bunx @tailwindcss/cli@4 -i styles/app.css -o public/app.css --minify — do not edit */
2
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
3
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-emerald-500:oklch(69.6% .17 162.48);--color-indigo-700:oklch(45.7% .24 277.023);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--drop-shadow-xl:0 9px 7px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:oklch(97% .02 264);--color-brand-500:oklch(55% .2 264);--color-brand-600:oklch(49% .2 264);--color-brand-700:oklch(43% .19 264)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.left-0{left:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[200\]{z-index:200}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-56{height:calc(var(--spacing) * 56)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-36{min-width:calc(var(--spacing) * 36)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[2\.75rem_repeat\(18\,minmax\(1\.75rem\,1fr\)\)\]{grid-template-columns:2.75rem repeat(18,minmax(1.75rem,1fr))}.grid-cols-\[3rem_2\.5rem_1fr\]{grid-template-columns:3rem 2.5rem 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-px{gap:1px}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}.self-start{align-self:flex-start}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-l-xl{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-r-xl{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-brand-500{border-color:var(--color-brand-500)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-brand-50{background-color:var(--color-brand-50)}.bg-brand-50\/60{background-color:#f0f5ff99}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab, var(--color-brand-50) 60%, transparent)}}.bg-brand-500{background-color:var(--color-brand-500)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-rose-400{background-color:var(--color-rose-400)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-white\/25{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.bg-white\/25{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-500{--tw-gradient-from:var(--color-brand-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-brand-600{--tw-gradient-via:var(--color-brand-600);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-indigo-700{--tw-gradient-to:var(--color-indigo-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-7{padding-block:calc(var(--spacing) * 7)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pl-1{padding-left:var(--spacing)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-brand-500{color:var(--color-brand-500)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-slate-100{color:var(--color-slate-100)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-white{color:var(--color-white)}.text-white\/85{color:#ffffffd9}@supports (color:color-mix(in lab, red, red)){.text-white\/85{color:color-mix(in oklab, var(--color-white) 85%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.ring-white\/20{--tw-ring-color:#fff3}@supports (color:color-mix(in lab, red, red)){.ring-white\/20{--tw-ring-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.ring-white\/30{--tw-ring-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.ring-white\/30{--tw-ring-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.drop-shadow-xl{--tw-drop-shadow-size:drop-shadow(0 9px 7px var(--tw-drop-shadow-color,#0000001a));--tw-drop-shadow:drop-shadow(var(--drop-shadow-xl));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:relative:hover{position:relative}.hover\:z-10:hover{z-index:10}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-brand-500\/50:hover{border-color:#3266e480}@supports (color:color-mix(in lab, red, red)){.hover\:border-brand-500\/50:hover{border-color:color-mix(in oklab, var(--color-brand-500) 50%, transparent)}}.hover\:border-slate-500:hover{border-color:var(--color-slate-500)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-brand-500:hover{--tw-ring-color:var(--color-brand-500)}.hover\:ring-inset:hover{--tw-ring-inset:inset}}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-12{padding-inline:calc(var(--spacing) * 12)}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media (min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}.dark\:border-brand-500:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-brand-500)}.dark\:border-slate-600:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-slate-600)}.dark\:border-slate-700:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-slate-700)}.dark\:border-slate-800:where([data-mode=dark],[data-mode=dark] *){border-color:var(--color-slate-800)}.dark\:bg-brand-500\/10:where([data-mode=dark],[data-mode=dark] *){background-color:#3266e41a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand-500\/10:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-brand-500) 10%, transparent)}}.dark\:bg-brand-600\/20:where([data-mode=dark],[data-mode=dark] *){background-color:#2253cf33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand-600\/20:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-brand-600) 20%, transparent)}}.dark\:bg-rose-500:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-rose-500)}.dark\:bg-slate-700:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-700)}.dark\:bg-slate-800:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-800)}.dark\:bg-slate-800\/60:where([data-mode=dark],[data-mode=dark] *){background-color:#1d293d99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-800\/60:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-slate-800) 60%, transparent)}}.dark\:bg-slate-900:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-900)}.dark\:bg-slate-900\/90:where([data-mode=dark],[data-mode=dark] *){background-color:#0f172be6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-900\/90:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-slate-900) 90%, transparent)}}.dark\:bg-slate-950:where([data-mode=dark],[data-mode=dark] *){background-color:var(--color-slate-950)}.dark\:bg-slate-950\/80:where([data-mode=dark],[data-mode=dark] *){background-color:#020618cc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-950\/80:where([data-mode=dark],[data-mode=dark] *){background-color:color-mix(in oklab, var(--color-slate-950) 80%, transparent)}}.dark\:text-brand-50:where([data-mode=dark],[data-mode=dark] *){color:var(--color-brand-50)}.dark\:text-slate-100:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-100)}.dark\:text-slate-200:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-200)}.dark\:text-slate-300:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-300)}.dark\:text-slate-400:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-400)}.dark\:text-slate-600:where([data-mode=dark],[data-mode=dark] *){color:var(--color-slate-600)}.dark\:text-white:where([data-mode=dark],[data-mode=dark] *){color:var(--color-white)}.dark\:ring-slate-700:where([data-mode=dark],[data-mode=dark] *){--tw-ring-color:var(--color-slate-700)}@media (hover:hover){.dark\:hover\:bg-slate-800:where([data-mode=dark],[data-mode=dark] *):hover{background-color:var(--color-slate-800)}.dark\:hover\:text-slate-200:where([data-mode=dark],[data-mode=dark] *):hover{color:var(--color-slate-200)}}}button{cursor:pointer}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="#ef4444"/><rect x="1" y="14" width="30" height="4" fill="#111"/><circle cx="16" cy="16" r="5" fill="#fff" stroke="#111" stroke-width="2"/></svg>
@@ -0,0 +1,27 @@
1
+ // examples/pokedex/routes.tsx
2
+ import { defineRoutes } from '@brust/core/routes'
3
+ import AppLayout from './components/AppLayout'
4
+ import { browseLoader, detailLoader, homeLoader, layoutLoader, typeChartLoader, unifiedLoader } from './lib/loaders'
5
+ import BrowsePage from './pages/BrowsePage'
6
+ import DetailPage from './pages/DetailPage'
7
+ import HomePage from './pages/HomePage'
8
+ import NotFoundPage from './pages/NotFoundPage'
9
+ import TypeChart from './pages/TypeChart'
10
+ import UnifiedPage from './pages/UnifiedPage'
11
+
12
+ export const routes = defineRoutes([
13
+ {
14
+ Component: AppLayout,
15
+ loader: layoutLoader,
16
+ children: [
17
+ { path: '/', Component: HomePage, loader: homeLoader },
18
+ { path: '/pokedex', Component: BrowsePage, loader: browseLoader },
19
+ // L1 for 60 s by tag; `?nocache=1` bypasses L1 (bench probe B measures the miss path).
20
+ { path: '/pokemon/{name}', Component: DetailPage, loader: detailLoader, cache: { ttl_seconds: 60, tags: ['pokemon', 'team'], bypass: 'query(nocache)' } },
21
+ { path: '/type-chart', Component: TypeChart, loader: typeChartLoader, cache: { ttl_seconds: 3600, tags: ['types', 'team'] } },
22
+ { path: '/unified', Component: UnifiedPage, loader: unifiedLoader },
23
+ ],
24
+ },
25
+ // Outside the layout on purpose (D3): a static full document → 0 Bun calls, no scripts.
26
+ { path: '*', Component: NotFoundPage },
27
+ ])
@@ -0,0 +1,53 @@
1
+ // examples/pokedex/scripts/snapshot.ts — run ONCE with network: `bun run snapshot`. Writes data/pokedex.json.
2
+ import { writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const API = 'https://pokeapi.co/api/v2'
6
+ const ALL_TYPES = ['normal','fire','water','electric','grass','ice','fighting','poison','ground','flying','psychic','bug','rock','ghost','dragon','dark','steel','fairy']
7
+ const idFromUrl = (url: string) => Number(/\/(\d+)\/?$/.exec(url)![1])
8
+ const clean = (s: string) => s.replace(/[\n\f\r]+/g, ' ').trim()
9
+ async function get<T>(path: string): Promise<T> {
10
+ const r = await fetch(`${API}${path}`)
11
+ if (!r.ok) throw new Error(`${path}: ${r.status}`)
12
+ return (await r.json()) as T
13
+ }
14
+ // biome-ignore lint/suspicious/noExplicitAny: PokeAPI JSON
15
+ type J = any
16
+ async function chain(url: string): Promise<{ id: number; name: string; minLevel: number | null }[]> {
17
+ const data = await (await fetch(url)).json() as J
18
+ const out: { id: number; name: string; minLevel: number | null }[] = []
19
+ let node = data.chain
20
+ while (node) { // linear walk, first branch (0.1.x rule)
21
+ out.push({ id: idFromUrl(node.species.url), name: node.species.name, minLevel: node.evolution_details?.[0]?.min_level ?? null })
22
+ node = node.evolves_to?.[0]
23
+ }
24
+ return out
25
+ }
26
+ const pokemon = []
27
+ for (let id = 1; id <= 151; id++) {
28
+ const p = await get<J>(`/pokemon/${id}`)
29
+ const s = await get<J>(`/pokemon-species/${id}`)
30
+ pokemon.push({
31
+ id, name: p.name,
32
+ types: p.types.map((t: J) => t.type.name),
33
+ stats: p.stats.map((st: J) => ({ name: st.stat.name, base: st.base_stat })),
34
+ artwork: p.sprites?.other?.['official-artwork']?.front_default ?? `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`,
35
+ genus: s.genera.find((g: J) => g.language.name === 'en')?.genus ?? '',
36
+ flavorText: clean(s.flavor_text_entries.find((e: J) => e.language.name === 'en')?.flavor_text ?? ''),
37
+ height: p.height, weight: p.weight,
38
+ abilities: p.abilities.map((a: J) => a.ability.name),
39
+ evolution: await chain(s.evolution_chain.url),
40
+ })
41
+ process.stderr.write(`\r${id}/151`)
42
+ }
43
+ const types: Record<string, Record<string, number>> = {}
44
+ for (const t of ALL_TYPES) {
45
+ const d = await get<J>(`/type/${t}`)
46
+ const rel: Record<string, number> = {}
47
+ for (const x of d.damage_relations.double_damage_to) rel[x.name] = 2
48
+ for (const x of d.damage_relations.half_damage_to) rel[x.name] = 0.5
49
+ for (const x of d.damage_relations.no_damage_to) rel[x.name] = 0
50
+ types[t] = rel
51
+ }
52
+ writeFileSync(join(import.meta.dir, '../data/pokedex.json'), `${JSON.stringify({ generatedAt: new Date().toISOString().slice(0, 10), pokemon, types }, null, 1)}\n`)
53
+ console.log('\nwrote data/pokedex.json')
@@ -0,0 +1,6 @@
1
+ // examples/pokedex/stores/team.ts — dot-free name (store R3), its own module (compiler R4); NEVER imports lib/team-db.
2
+ import { defineStore, signal } from '@brust/core/store'
3
+ import { MAX_TEAM } from '../lib/team'
4
+ import type { TeamMember } from '../lib/types'
5
+
6
+ export const team = defineStore('team', () => ({ members: signal<TeamMember[]>([]), max: signal(MAX_TEAM) }))