brustjs 0.1.23-alpha → 0.1.25-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.
@@ -6,7 +6,7 @@
6
6
  // api.team({ id }).delete() → DELETE /_brust/action/team/{id}
7
7
 
8
8
  import { z } from 'zod'
9
- import { defineActions } from 'brustjs'
9
+ import { cookies, defineActions, ActionError } from 'brustjs'
10
10
  import { MAX_TEAM, teamStore } from './lib/team-store'
11
11
 
12
12
  const TeamMemberInput = z.object({
@@ -23,18 +23,24 @@ export const actions = defineActions()
23
23
  .post(
24
24
  '/team',
25
25
  ({ body }) => {
26
- const ok = teamStore.add(body)
27
- // GAP S7: a domain error (team full) cannot throw a typed non-2xx across
28
- // the treaty boundary, so it rides back inside the success payload as a
29
- // `full` flag. The client therefore checks two places (transport `error`
30
- // AND `data.full`). See ./FRAMEWORK-GAPS.md S7.
31
- return { team: teamStore.list(), max: MAX_TEAM, full: !ok }
26
+ if (!teamStore.add(body)) {
27
+ throw new ActionError(409, 'TEAM_FULL', { data: { max: MAX_TEAM } })
28
+ }
29
+ return { team: teamStore.list(), max: MAX_TEAM }
32
30
  },
33
- { body: TeamMemberInput },
31
+ { body: TeamMemberInput, errors: { TEAM_FULL: z.object({ max: z.number() }) } },
34
32
  )
35
33
  .delete('/team/{id}', ({ params }) => {
36
34
  teamStore.remove(Number(params.id))
37
35
  return { team: teamStore.list(), max: MAX_TEAM }
38
36
  })
37
+ .post(
38
+ '/theme',
39
+ ({ body }) => {
40
+ cookies.set('mode', body.mode, { path: '/', maxAge: 31536000, sameSite: 'Lax' })
41
+ return { mode: body.mode }
42
+ },
43
+ { body: z.object({ mode: z.enum(['dark', 'light']) }) },
44
+ )
39
45
 
40
46
  export type Actions = typeof actions
@@ -2,9 +2,9 @@
2
2
  PokéDex app.css — AssetsArt Design System (tokens + components) +
3
3
  a PokéDex (.dex-*) layer that replaces the prototype's inline styles.
4
4
  ---------------------------------------------------------------------
5
- NOTE: dark-mode only. The design's [data-mode="dark"] selectors are
6
- rewritten to .dark, set via <BrustPage className="dark"> (BrustPage
7
- only sets <html class>, not arbitrary data-* attrs see GAPS S8).
5
+ NOTE: light + dark. :root holds the LIGHT defaults; [data-mode="dark"]
6
+ remaps them. The mode is set on <html data-mode={mode}> via BrustPage's
7
+ data-* support, driven by the `mode` cookie + the native ThemeToggle.
8
8
  Remote @import of Google Fonts is dropped (the css pipeline runs through
9
9
  @tailwindcss/node); we rely on the system-font fallbacks in the stacks.
10
10
  ===================================================================== */
@@ -328,7 +328,7 @@
328
328
  The product (Cloud Server Portal) defaults to DARK; marketing + first-run
329
329
  default to LIGHT.
330
330
  ===================================================================== */
331
- .dark {
331
+ [data-mode="dark"] {
332
332
  --ink-0: #14171F;
333
333
  --ink-25: #181B25;
334
334
  --ink-50: #1C2030; /* card surface */
@@ -445,7 +445,7 @@ a:hover { text-decoration: underline; }
445
445
  code, pre { font-family: var(--font-mono); }
446
446
 
447
447
  ::selection { background: var(--primary-200); color: var(--primary-900); }
448
- .dark ::selection { background: rgba(127,63,151,0.45); color: #fff; }
448
+ [data-mode="dark"] ::selection { background: rgba(127,63,151,0.45); color: #fff; }
449
449
 
450
450
  /* =====================================================================
451
451
  TYPE UTILITIES — opt-in semantic typography classes
@@ -642,7 +642,7 @@ code, pre { font-family: var(--font-mono); }
642
642
  --_fg: var(--primary-500);
643
643
  --_border: var(--primary-500);
644
644
  }
645
- .dark .aa-btn--outline { --_fg: var(--purple-300); --_border: var(--purple-400); }
645
+ [data-mode="dark"] .aa-btn--outline { --_fg: var(--purple-300); --_border: var(--purple-400); }
646
646
  .aa-btn--danger {
647
647
  --_bg: var(--danger-600);
648
648
  --_bg-hover: var(--danger-700);
@@ -815,11 +815,11 @@ code, pre { font-family: var(--font-mono); }
815
815
  }
816
816
 
817
817
  /* dark-mode pill foreground gets lifted */
818
- .dark .aa-badge--success { color: #6BD9A4; }
819
- .dark .aa-badge--warning { color: #F0C76A; }
820
- .dark .aa-badge--danger { color: #F195B2; }
821
- .dark .aa-badge--info { color: #7BB6E2; }
822
- .dark .aa-badge--soft { color: #C9A4D8; }
818
+ [data-mode="dark"] .aa-badge--success { color: #6BD9A4; }
819
+ [data-mode="dark"] .aa-badge--warning { color: #F0C76A; }
820
+ [data-mode="dark"] .aa-badge--danger { color: #F195B2; }
821
+ [data-mode="dark"] .aa-badge--info { color: #7BB6E2; }
822
+ [data-mode="dark"] .aa-badge--soft { color: #C9A4D8; }
823
823
 
824
824
  /* Status dot pill (used in tables) */
825
825
  .aa-pill {
@@ -843,10 +843,10 @@ code, pre { font-family: var(--font-mono); }
843
843
  .aa-pill--muted { background: var(--ink-100); color: var(--text-tertiary); }
844
844
  .aa-pill--no-dot::before { display: none; }
845
845
 
846
- .dark .aa-pill--ok { color: #6BD9A4; }
847
- .dark .aa-pill--warn { color: #F0C76A; }
848
- .dark .aa-pill--err { color: #F195B2; }
849
- .dark .aa-pill--info { color: #7BB6E2; }
846
+ [data-mode="dark"] .aa-pill--ok { color: #6BD9A4; }
847
+ [data-mode="dark"] .aa-pill--warn { color: #F0C76A; }
848
+ [data-mode="dark"] .aa-pill--err { color: #F195B2; }
849
+ [data-mode="dark"] .aa-pill--info { color: #7BB6E2; }
850
850
 
851
851
  /* =====================================================================
852
852
  TABLE
@@ -903,8 +903,8 @@ code, pre { font-family: var(--font-mono); }
903
903
  .aa-tab[aria-selected="true"], .aa-tab.is-active {
904
904
  color: var(--primary-600); border-color: var(--primary-600); font-weight: var(--fw-semibold);
905
905
  }
906
- .dark .aa-tab[aria-selected="true"],
907
- .dark .aa-tab.is-active { color: var(--purple-300); border-color: var(--purple-400); }
906
+ [data-mode="dark"] .aa-tab[aria-selected="true"],
907
+ [data-mode="dark"] .aa-tab.is-active { color: var(--purple-300); border-color: var(--purple-400); }
908
908
 
909
909
  .aa-segmented {
910
910
  display: inline-flex; padding: 3px;
@@ -947,10 +947,10 @@ code, pre { font-family: var(--font-mono); }
947
947
  .aa-alert--danger .aa-alert__icon { color: var(--danger-600); }
948
948
 
949
949
  /* dark-mode alert text */
950
- .dark .aa-alert--info { color: #9DC4E6; }
951
- .dark .aa-alert--success { color: #93D6B0; }
952
- .dark .aa-alert--warning { color: #EFCC74; }
953
- .dark .aa-alert--danger { color: #F195B2; }
950
+ [data-mode="dark"] .aa-alert--info { color: #9DC4E6; }
951
+ [data-mode="dark"] .aa-alert--success { color: #93D6B0; }
952
+ [data-mode="dark"] .aa-alert--warning { color: #EFCC74; }
953
+ [data-mode="dark"] .aa-alert--danger { color: #F195B2; }
954
954
 
955
955
  /* =====================================================================
956
956
  AVATAR
@@ -972,7 +972,7 @@ code, pre { font-family: var(--font-mono); }
972
972
  .aa-avatar--sm { width: 28px; height: 28px; font-size: var(--text-3xs); }
973
973
  .aa-avatar--lg { width: 44px; height: 44px; font-size: var(--text-md); }
974
974
  .aa-avatar--xl { width: 64px; height: 64px; font-size: var(--text-lg); }
975
- .dark .aa-avatar { color: var(--purple-200); }
975
+ [data-mode="dark"] .aa-avatar { color: var(--purple-200); }
976
976
 
977
977
  /* =====================================================================
978
978
  PROGRESS
@@ -1027,8 +1027,8 @@ code, pre { font-family: var(--font-mono); }
1027
1027
  .aa-kpi__delta--up { color: var(--success-600); }
1028
1028
  .aa-kpi__delta--down { color: var(--danger-600); }
1029
1029
  .aa-kpi__delta--neutral { color: var(--text-tertiary); }
1030
- .dark .aa-kpi__delta--up { color: #6BD9A4; }
1031
- .dark .aa-kpi__delta--down { color: #F195B2; }
1030
+ [data-mode="dark"] .aa-kpi__delta--up { color: #6BD9A4; }
1031
+ [data-mode="dark"] .aa-kpi__delta--down { color: #F195B2; }
1032
1032
  .aa-kpi__meta {
1033
1033
  display: inline-flex; align-items: center; gap: 6px;
1034
1034
  font-size: var(--text-xs);
@@ -1092,7 +1092,7 @@ code, pre { font-family: var(--font-mono); }
1092
1092
  letter-spacing: 0.08em;
1093
1093
  margin-left: auto;
1094
1094
  }
1095
- .dark .aa-sidebar__env { color: #93D6B0; }
1095
+ [data-mode="dark"] .aa-sidebar__env { color: #93D6B0; }
1096
1096
 
1097
1097
  .aa-sidebar__nav {
1098
1098
  flex: 1;
@@ -1131,7 +1131,7 @@ code, pre { font-family: var(--font-mono); }
1131
1131
  color: var(--primary-700);
1132
1132
  font-weight: var(--fw-semibold);
1133
1133
  }
1134
- .dark .aa-nav-item.is-active { color: #C9A4D8; }
1134
+ [data-mode="dark"] .aa-nav-item.is-active { color: #C9A4D8; }
1135
1135
  .aa-nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.75; }
1136
1136
  .aa-nav-item__count {
1137
1137
  margin-left: auto;
@@ -1146,7 +1146,7 @@ code, pre { font-family: var(--font-mono); }
1146
1146
  }
1147
1147
  .aa-nav-item.is-active .aa-nav-item__count { background: var(--primary-100); color: var(--primary-700); }
1148
1148
  .aa-nav-item__count--alert { background: var(--danger-50); color: var(--danger-700); }
1149
- .dark .aa-nav-item__count--alert { color: #F195B2; }
1149
+ [data-mode="dark"] .aa-nav-item__count--alert { color: #F195B2; }
1150
1150
 
1151
1151
  .aa-sidebar__user {
1152
1152
  padding: 12px;
@@ -1302,7 +1302,7 @@ code, pre { font-family: var(--font-mono); }
1302
1302
  }
1303
1303
  .aa-toolbar__filter:hover { border-color: var(--ink-300); }
1304
1304
  .aa-toolbar__filter.is-active { border-color: var(--primary-500); background: var(--primary-50); color: var(--primary-700); }
1305
- .dark .aa-toolbar__filter.is-active { color: #C9A4D8; }
1305
+ [data-mode="dark"] .aa-toolbar__filter.is-active { color: #C9A4D8; }
1306
1306
  .aa-toolbar__filter svg { width: 12px; height: 12px; stroke: currentColor; fill: none; stroke-width: 2; }
1307
1307
 
1308
1308
  /* Service / category chip */
@@ -1656,8 +1656,8 @@ code, pre { font-family: var(--font-mono); }
1656
1656
  .dex-tc__cell--weak { background: var(--danger-50); color: var(--danger-700); }
1657
1657
  .dex-tc__cell--none { background: var(--ink-100); color: var(--text-muted); }
1658
1658
  .dex-tc__swatch { min-height: auto; width: 22px; height: 22px; border-radius: 4px; border: none; }
1659
- .dark .dex-tc__cell--super { color: #6BD9A4; }
1660
- .dark .dex-tc__cell--weak { color: #F195B2; }
1659
+ [data-mode="dark"] .dex-tc__cell--super { color: #6BD9A4; }
1660
+ [data-mode="dark"] .dex-tc__cell--weak { color: #F195B2; }
1661
1661
 
1662
1662
  /* not found */
1663
1663
  .dex-notfound { display: grid; place-items: center; padding: 80px 20px; text-align: center; }
@@ -24,9 +24,10 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
24
24
  // TeamBuilder island — they resolve the same window singleton. A native
25
25
  // x-on-click mutation is therefore seen reactively by a React island.
26
26
  const busy = signal(false)
27
+ const full = signal(false)
27
28
  const inTeam = computed(() => (teamStore.members() ?? []).some((m) => m.id === props.id))
28
29
  const label = computed(() =>
29
- inTeam() ? '✓ In your team' : disabled() ? 'Team Full' : '+ Add to team',
30
+ inTeam() ? '✓ In your team' : disabled() || full() ? 'Team Full' : '+ Add to team',
30
31
  )
31
32
  const btnClass = computed(() => `aa-btn aa-btn--full${inTeam() ? ' aa-btn--secondary' : ''}`)
32
33
  const disabled = computed(() => busy() || ((teamStore.members()?.length ?? 0) >= 6 && !inTeam()))
@@ -44,7 +45,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
44
45
  const { data } = await api.team({ id: props.id }).delete()
45
46
  if (data) teamStore.members.set(data.team)
46
47
  } else {
47
- const { data } = await api.team.post({
48
+ const { data, error } = await api.team.post({
48
49
  id: props.id,
49
50
  name: props.name,
50
51
  displayName: props.displayName,
@@ -52,8 +53,11 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
52
53
  types: props.types,
53
54
  artwork: props.artwork,
54
55
  })
55
- if (data && !data?.full) {
56
+ if (data) {
57
+ full.set(false)
56
58
  teamStore.members.set(data.team)
59
+ } else if (error?.value.code === 'TEAM_FULL') {
60
+ full.set(true) // server rejected — team full; surface instead of silent no-op
57
61
  }
58
62
  }
59
63
  } finally {
@@ -61,7 +65,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
61
65
  }
62
66
  }
63
67
 
64
- return { init, label, btnClass, toggle, disabled }
68
+ return { init, label, btnClass, toggle, disabled, full }
65
69
  }
66
70
 
67
71
  // default → jinja (server). The x-* directives are static string attributes the
@@ -1,34 +1,41 @@
1
- // Shared native layout shell. INLINED into each route at build time (T6): the
2
- // route's root is <PageLayout native …>, whose expansion roots in <BrustPage>,
3
- // which the compiler now promotes to the document shell. Pages supply only
4
- // title/active/crumb + their aa-content inner.
1
+ // Router-level native layout (Approach a: nested routes + <Outlet/>). Written
2
+ // ONCE and nested as the parent of every page route in routes.tsx. The synth
3
+ // wrapper the compiler builds is `<AppLayout native><Leaf native/></AppLayout>`
4
+ // AppLayout receives NO props at the call site, so the chrome data it renders
5
+ // (title / active / crumb / teamProps) comes from the MERGED LOADER CONTEXT
6
+ // instead (F5 chrome-prop migration). Each leaf loader returns those fields and
7
+ // the chain-loader merge folds them into one flat jinja context; the names
8
+ // below are destructured so the native compiler resolves them as member-paths
9
+ // ({{ title }}, active === 'list', <TeamBuilder props={teamProps}/>).
5
10
  //
6
11
  // MUST stay single-return with no local bindings above it — a local `const`
7
12
  // would make the compiler soft-fall-back to an SSR component (no <html> shell).
8
13
  // active-nav uses conditional ELEMENTS (S11), not a className ternary
9
- // (unsupported). See ../FRAMEWORK-GAPS.md.
10
- import { BrustPage, Island } from 'brustjs'
11
- import type { ReactNode } from 'react'
14
+ // (unsupported). AppLayout owns the single <main> (leaves are fragments in the
15
+ // <Outlet/> slot) a leaf adding its own <main> breaks SPA-nav extraction.
16
+ // See ../FRAMEWORK-GAPS.md.
17
+ import { BrustPage, Island, Outlet } from 'brustjs'
12
18
  import type { TeamMember } from '../lib/types'
13
19
  import TeamBuilder from './TeamBuilder'
20
+ import ThemeToggle from './ThemeToggle'
14
21
 
15
- export default function PageLayout({
22
+ export default function AppLayout({
16
23
  title,
17
24
  active,
18
25
  crumb,
19
26
  teamProps,
20
- children,
27
+ mode,
21
28
  }: {
22
29
  title: string
23
30
  active: 'list' | 'typechart'
24
31
  crumb: string
25
32
  teamProps: { teamInitial: TeamMember[] }
26
- children: ReactNode
33
+ mode: 'dark' | 'light'
27
34
  }) {
28
35
  return (
29
36
  <BrustPage
30
37
  lang="en"
31
- className="dark"
38
+ data-mode={mode}
32
39
  title={title}
33
40
  head={[{ tag: 'link', rel: 'icon', href: '/favicon.svg' }]}
34
41
  >
@@ -83,9 +90,12 @@ export default function PageLayout({
83
90
  <span className="dex-crumb__sep">›</span>
84
91
  <b>{crumb}</b>
85
92
  </div>
93
+ <ThemeToggle native />
86
94
  </header>
87
95
 
88
- <div className="aa-content">{children}</div>
96
+ <div className="aa-content">
97
+ <Outlet />
98
+ </div>
89
99
  </main>
90
100
 
91
101
  <Island component={TeamBuilder} props={teamProps} ssr hydrate="load" />
@@ -0,0 +1,51 @@
1
+ // NATIVE INTERACTIVE COMPONENT (B4 dogfood) — the dark/light theme toggle in
2
+ // the topbar. Single-file native directive component: a co-located
3
+ // `export const behavior` (client logic, react-free) + a JSX `default` export
4
+ // (the native template the compiler lowers to minijinja). The build bundles
5
+ // ONLY `behavior` into _directives.js, registered as "themeToggle" (camelCase
6
+ // filename); the JSX default is tree-shaken out so react never leaks client-side.
7
+ //
8
+ // react-free: `signal`/`computed` from brustjs/store (the window singleton on
9
+ // the client), `client` from brustjs/client (the treaty action client). The
10
+ // toggle flips <html data-mode> immediately (no reload) AND persists via the
11
+ // /theme action which sets the `mode` cookie — so SSR matches on the next load.
12
+ import { client } from 'brustjs/client'
13
+ import { computed, signal } from 'brustjs/store'
14
+ import type { Actions } from '../actions'
15
+
16
+ const api = client<Actions>()
17
+
18
+ // behavior → client bundle, registered as "themeToggle". Reads the initial mode
19
+ // straight off <html data-mode> (server already set it from the cookie).
20
+ export const behavior = () => {
21
+ const mode = signal(
22
+ typeof document !== 'undefined' ? (document.documentElement.dataset.mode ?? 'dark') : 'dark',
23
+ )
24
+ const label = computed(() => (mode() === 'dark' ? '☀ Light' : '🌙 Dark'))
25
+
26
+ async function toggle() {
27
+ const next = mode() === 'dark' ? 'light' : 'dark'
28
+ document.documentElement.dataset.mode = next // flip the theme immediately
29
+ mode.set(next)
30
+ await api.theme.post({ mode: next }) // persist via cookie for the next SSR
31
+ }
32
+
33
+ return { toggle, label }
34
+ }
35
+
36
+ // default → jinja (server). The x-* directives are static string attributes the
37
+ // native compiler passes straight through; the directive runtime binds them to
38
+ // the behavior instance on the client.
39
+ export default function ThemeToggle() {
40
+ return (
41
+ <button
42
+ type="button"
43
+ x-data="themeToggle"
44
+ x-text="label"
45
+ x-on-click="toggle"
46
+ className="aa-btn aa-btn--outline aa-btn--sm"
47
+ >
48
+ 🌙 Dark
49
+ </button>
50
+ )
51
+ }
@@ -81,13 +81,23 @@ export async function listLoader({ req }: LoaderCtx): Promise<ListData> {
81
81
  hasNext,
82
82
  prevHref: hasPrev ? (prevOffset > 0 ? `/?offset=${prevOffset}` : '/') : '#',
83
83
  nextHref: hasNext ? `/?offset=${offset + PAGE}` : '#',
84
+ // Chrome fields (ChromeData) — merged into the flat jinja context and read by
85
+ // the router-level AppLayout (Approach a: native <Outlet/> nesting).
86
+ title: 'PokéDex · brust example',
87
+ active: 'list',
88
+ crumb: 'All Pokémon',
84
89
  teamProps: { teamInitial: teamStore.list() },
90
+ mode: req.cookies.mode === 'light' ? 'light' : 'dark',
85
91
  }
86
92
  }
87
93
 
88
- export async function detailLoader({ params }: LoaderCtx): Promise<DetailData | NativeVerdict> {
94
+ export async function detailLoader({
95
+ params,
96
+ req,
97
+ }: LoaderCtx): Promise<DetailData | NativeVerdict> {
89
98
  const name = params?.name ?? ''
90
- const empty = emptyDetail(name)
99
+ const mode = req.cookies.mode === 'light' ? 'light' : 'dark'
100
+ const empty = emptyDetail(name, mode)
91
101
 
92
102
  const p = await fetchPokemon(name)
93
103
  // GAP S9 (FIXED): native loaders can now `return notFound(data)` to render the
@@ -144,7 +154,11 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
144
154
 
145
155
  return {
146
156
  notFound: false,
147
- pageTitle: `${cap(p.name)} · PokéDex`,
157
+ // Chrome fields (ChromeData) read by AppLayout from the merged context.
158
+ title: `${cap(p.name)} · PokéDex`,
159
+ active: 'list',
160
+ crumb: cap(p.name),
161
+ mode,
148
162
  name: p.name,
149
163
  id: p.id,
150
164
  displayName: cap(p.name),
@@ -178,10 +192,14 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
178
192
  }
179
193
  }
180
194
 
181
- function emptyDetail(name: string): DetailData {
195
+ function emptyDetail(name: string, mode: 'dark' | 'light'): DetailData {
182
196
  return {
183
197
  notFound: true,
184
- pageTitle: `${cap(name)} · PokéDex`,
198
+ // Chrome fields (ChromeData) read by AppLayout from the merged context.
199
+ title: `${cap(name)} · PokéDex`,
200
+ active: 'list',
201
+ crumb: cap(name),
202
+ mode,
185
203
  name,
186
204
  id: 0,
187
205
  displayName: cap(name),
@@ -233,9 +251,9 @@ const SHORT: Record<string, string> = {
233
251
  fairy: 'FAI',
234
252
  }
235
253
 
236
- export async function typeChartLoader(): Promise<TypeChartData> {
237
- // GAP S2: no loader-level batch/parallel helper or request-scoped cache we
238
- // fan out 18 fetches by hand with Promise.all (and there is no dedupe).
254
+ export async function typeChartLoader({ req }: LoaderCtx): Promise<TypeChartData> {
255
+ // Fan out 18 distinct type fetches with Promise.all; each goes through
256
+ // cachedFetch (S2), so duplicate in-flight GETs within the request dedupe.
239
257
  const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
240
258
 
241
259
  // Build the 19×19 grid as nested rows (header row + one row per attacking
@@ -311,6 +329,11 @@ export async function typeChartLoader(): Promise<TypeChartData> {
311
329
 
312
330
  return {
313
331
  rows,
332
+ // Chrome fields (ChromeData) read by AppLayout from the merged context.
333
+ title: 'PokéDex · type chart',
334
+ active: 'typechart',
335
+ crumb: 'Type chart',
314
336
  teamProps: { teamInitial: teamStore.list() },
337
+ mode: req.cookies.mode === 'light' ? 'light' : 'dark',
315
338
  }
316
339
  }
@@ -4,6 +4,8 @@
4
4
  // constraint). Their job is to fetch from PokeAPI and return fully render-ready
5
5
  // view-models so the native (jinja) page templates only interpolate fields.
6
6
 
7
+ import { cachedFetch } from 'brustjs'
8
+
7
9
  export const API = 'https://pokeapi.co/api/v2'
8
10
 
9
11
  export const idFromUrl = (url: string): number => Number((url.match(/\/pokemon\/(\d+)\//) || [])[1])
@@ -79,7 +81,7 @@ export interface RawEvolutionStage {
79
81
  }
80
82
 
81
83
  export async function fetchList(offset: number, limit: number) {
82
- const res = await fetch(`${API}/pokemon?limit=${limit}&offset=${offset}`)
84
+ const res = await cachedFetch(`${API}/pokemon?limit=${limit}&offset=${offset}`)
83
85
  if (!res.ok) throw new Error(`PokeAPI list ${res.status}`)
84
86
  const page = (await res.json()) as {
85
87
  count: number
@@ -92,7 +94,7 @@ export async function fetchList(offset: number, limit: number) {
92
94
  }
93
95
 
94
96
  export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
95
- const res = await fetch(`${API}/pokemon/${name}`)
97
+ const res = await cachedFetch(`${API}/pokemon/${name}`)
96
98
  if (!res.ok) return null
97
99
  const p = (await res.json()) as any
98
100
  return {
@@ -108,7 +110,7 @@ export async function fetchPokemon(name: string): Promise<RawPokemon | null> {
108
110
  }
109
111
 
110
112
  export async function fetchSpecies(id: number): Promise<RawSpecies> {
111
- const res = await fetch(`${API}/pokemon-species/${id}`)
113
+ const res = await cachedFetch(`${API}/pokemon-species/${id}`)
112
114
  const s = (await res.json()) as any
113
115
  const flavor = s.flavor_text_entries?.find((e: any) => e.language.name === 'en')?.flavor_text as
114
116
  | string
@@ -124,7 +126,7 @@ export async function fetchSpecies(id: number): Promise<RawSpecies> {
124
126
  * flattened to the first branch — noted as an open question in the design. */
125
127
  export async function fetchEvolution(url: string): Promise<RawEvolutionStage[]> {
126
128
  if (!url) return []
127
- const res = await fetch(url)
129
+ const res = await cachedFetch(url)
128
130
  if (!res.ok) return []
129
131
  const data = (await res.json()) as any
130
132
  const stages: RawEvolutionStage[] = []
@@ -162,7 +164,7 @@ export const ALL_TYPES = [
162
164
 
163
165
  /** Fetch one type's damage relations → a map of defendingType → multiplier. */
164
166
  export async function fetchTypeRelations(type: string): Promise<Record<string, number>> {
165
- const res = await fetch(`${API}/type/${type}`)
167
+ const res = await cachedFetch(`${API}/type/${type}`)
166
168
  if (!res.ok) return {}
167
169
  const d = (await res.json()) as any
168
170
  const rel = d.damage_relations
@@ -7,6 +7,20 @@
7
7
  // multi-property style strings (templates have no template-literals / arithmetic
8
8
  // / helper calls). See ../FRAMEWORK-GAPS.md.
9
9
 
10
+ /** 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. */
16
+ export interface ChromeData {
17
+ 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
+ crumb: string // topbar breadcrumb leaf label
20
+ teamProps: { teamInitial: TeamMember[] } // floating team-dock island initial state
21
+ mode: 'dark' | 'light' // theme, read from the `mode` cookie → <html data-mode={mode}>
22
+ }
23
+
10
24
  /** A single list cell — derived from the list endpoint alone (no detail fetch,
11
25
  * see FRAMEWORK-GAPS.md S2 / N+1 avoidance). */
12
26
  export interface CardVM {
@@ -18,7 +32,7 @@ export interface CardVM {
18
32
  detailHref: string // "/pokemon/bulbasaur"
19
33
  }
20
34
 
21
- export interface ListData {
35
+ export interface ListData extends ChromeData {
22
36
  items: CardVM[]
23
37
  total: number
24
38
  totalLabel: string // "1,302"
@@ -32,7 +46,6 @@ export interface ListData {
32
46
  prevHref: string
33
47
  nextHref: string
34
48
  offsetLabel: string // raw offset for the loader-echo line
35
- teamProps: { teamInitial: TeamMember[] }
36
49
  }
37
50
 
38
51
  export interface TypeBadgeVM {
@@ -70,12 +83,13 @@ export interface EvolutionStageVM {
70
83
  }
71
84
 
72
85
  /** The full view-model handed to DetailPage. Every field is render-ready. */
73
- export interface DetailData {
86
+ export interface DetailData extends ChromeData {
74
87
  notFound: boolean
75
88
  // Native routes now branch with `{notFound ? <NotFound/> : <Content/>}` (S11),
76
89
  // so the content and 404 block are mutually exclusive at render time rather
77
90
  // than both emitted with one hidden via a precomputed class.
78
- pageTitle: string // dynamic <title> via `<BrustPage title={d.pageTitle}>` (S8)
91
+ // The dynamic <title> is `title` (ChromeData), read by AppLayout's
92
+ // <BrustPage title={title}> — no separate pageTitle field.
79
93
  name: string
80
94
  // present only when notFound === false:
81
95
  id: number
@@ -99,7 +113,6 @@ export interface DetailData {
99
113
  // object literals). addProps is the loader-precomputed JSON string handed to
100
114
  // <AddToTeamButton data={addProps} /> → x-props (Spec B native directives).
101
115
  addProps: string
102
- teamProps: { teamInitial: TeamMember[] }
103
116
  }
104
117
 
105
118
  /** Shape of the AddToTeamButton native behavior's `props` (JSON-parsed from
@@ -129,9 +142,8 @@ export interface TypeChartRowVM {
129
142
  cells: TypeChartCellVM[] // 19 cells (1 head + 18)
130
143
  }
131
144
 
132
- export interface TypeChartData {
145
+ export interface TypeChartData extends ChromeData {
133
146
  rows: TypeChartRowVM[] // 19 rows (1 header + 18), each 19 cells
134
- teamProps: { teamInitial: TeamMember[] }
135
147
  }
136
148
 
137
149
  /** In-process team store member. */
@@ -1,5 +1,7 @@
1
- // Route "/pokemon/{name}" — NATIVE detail page. Per the decision to push native
2
- // as far as possible, the evolution chain is loaded BLOCKING in the loader (a
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
+ // fragment — no <BrustPage>, no <main>. Per the decision to push native as far
4
+ // as possible, the evolution chain is loaded BLOCKING in the loader (a
3
5
  // native/jinja route has no React tree, so <Suspense> streaming is impossible —
4
6
  // see ../FRAMEWORK-GAPS.md S3).
5
7
  //
@@ -7,15 +9,14 @@
7
9
  // `{notFound ? <NotFound/> : <Content/>}` branch, `{hasAbilities && …}` /
8
10
  // `{hasEvolution && …}` sections, and per-item `{!s.isFirst && <Arrow/>}` /
9
11
  // `{s.showLevel && <Level/>}` separators — no more loader-computed hide-classes.
10
- // The <title> is dynamic via `<BrustPage title={pageTitle}>` (S8) and inline
11
- // styles use `style={{…}}` objects (S1).
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).
12
15
  import AddToTeamButton from '../components/AddToTeamButton'
13
- import PageLayout from '../components/PageLayout'
14
16
  import type { DetailData } from '../lib/types'
15
17
 
16
18
  export default function DetailPage({
17
19
  notFound,
18
- pageTitle,
19
20
  hasAbilities,
20
21
  hasEvolution,
21
22
  displayName,
@@ -33,10 +34,9 @@ export default function DetailPage({
33
34
  abilities,
34
35
  evolution,
35
36
  addProps,
36
- teamProps,
37
37
  }: DetailData) {
38
38
  return (
39
- <PageLayout native title={pageTitle} active="list" crumb={displayName} teamProps={teamProps}>
39
+ <>
40
40
  <a className="aa-btn aa-btn--ghost aa-btn--sm dex-back" href="/">
41
41
  ‹ Pokédex
42
42
  </a>
@@ -161,6 +161,6 @@ export default function DetailPage({
161
161
  )}
162
162
  </>
163
163
  )}
164
- </PageLayout>
164
+ </>
165
165
  )
166
166
  }