brustjs 0.1.24-alpha → 0.1.26-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.
- package/example/pokedex/actions.ts +10 -2
- package/example/pokedex/app.css +56 -30
- package/example/pokedex/components/AddToTeamButton.tsx +1 -2
- package/example/pokedex/components/AppLayout.tsx +15 -27
- package/example/pokedex/components/NavLink.tsx +50 -0
- package/example/pokedex/components/NavPreloader.tsx +21 -0
- package/example/pokedex/components/ThemeToggle.tsx +51 -0
- package/example/pokedex/lib/loaders.ts +14 -6
- package/example/pokedex/lib/pokeapi.ts +7 -5
- package/example/pokedex/lib/types.ts +1 -0
- package/package.json +11 -9
- package/runtime/client/index.ts +4 -0
- package/runtime/cookies.ts +61 -0
- package/runtime/define-actions.ts +34 -7
- package/runtime/index.js +52 -52
- package/runtime/index.ts +9 -0
- package/runtime/islands/bootstrap.ts +10 -1
- package/runtime/islands/island.tsx +8 -4
- package/runtime/islands/native-render.ts +3 -1
- package/runtime/loader-cache.ts +42 -0
- package/runtime/navigation/active-nav.ts +75 -0
- package/runtime/navigation/index.ts +13 -0
- package/runtime/navigation/react.ts +24 -0
- package/runtime/navigation/store.ts +208 -0
- package/runtime/request-context.ts +35 -0
- package/runtime/routes.ts +96 -25
- package/runtime/treaty.ts +47 -10
- package/runtime/treaty.type-test.ts +69 -0
- package/runtime/tsconfig.typecheck.json +23 -0
|
@@ -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, ActionError } 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({
|
|
@@ -28,11 +28,19 @@ export const actions = defineActions()
|
|
|
28
28
|
}
|
|
29
29
|
return { team: teamStore.list(), max: MAX_TEAM }
|
|
30
30
|
},
|
|
31
|
-
{ body: TeamMemberInput },
|
|
31
|
+
{ body: TeamMemberInput, errors: { TEAM_FULL: z.object({ max: z.number() }) } },
|
|
32
32
|
)
|
|
33
33
|
.delete('/team/{id}', ({ params }) => {
|
|
34
34
|
teamStore.remove(Number(params.id))
|
|
35
35
|
return { team: teamStore.list(), max: MAX_TEAM }
|
|
36
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
|
+
)
|
|
37
45
|
|
|
38
46
|
export type Actions = typeof actions
|
package/example/pokedex/app.css
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
907
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1660
|
-
|
|
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; }
|
|
@@ -1685,3 +1685,29 @@ code, pre { font-family: var(--font-mono); }
|
|
|
1685
1685
|
.dex-type--fairy, .dex-tc__colhead--fairy, .dex-tc__rowhead--fairy { background: var(--magenta-300); }
|
|
1686
1686
|
|
|
1687
1687
|
.dex-hide { display: none !important; }
|
|
1688
|
+
|
|
1689
|
+
/* ----------------------------------------------------------------------------
|
|
1690
|
+
* SPA navigation preloader — a top progress bar rendered by the NavPreloader
|
|
1691
|
+
* React ISLAND (components/NavPreloader.tsx) while brustjs/navigation reports
|
|
1692
|
+
* phase === 'loading'. The island reads the nav store via useNav() and mounts
|
|
1693
|
+
* this bar only during a navigation; the bar is fixed so it escapes the .aa-app
|
|
1694
|
+
* grid and sits above the sticky topbar (z-index:10).
|
|
1695
|
+
* ------------------------------------------------------------------------- */
|
|
1696
|
+
.nav-preloader {
|
|
1697
|
+
position: fixed;
|
|
1698
|
+
inset: 0 0 auto 0;
|
|
1699
|
+
height: 3px;
|
|
1700
|
+
z-index: 9999;
|
|
1701
|
+
overflow: hidden;
|
|
1702
|
+
pointer-events: none;
|
|
1703
|
+
}
|
|
1704
|
+
.nav-preloader__bar {
|
|
1705
|
+
height: 100%;
|
|
1706
|
+
width: 40%;
|
|
1707
|
+
background: var(--aa-gradient, #1B75BB);
|
|
1708
|
+
animation: nav-preloader-slide 0.9s ease-in-out infinite;
|
|
1709
|
+
}
|
|
1710
|
+
@keyframes nav-preloader-slide {
|
|
1711
|
+
from { transform: translateX(-110%); }
|
|
1712
|
+
to { transform: translateX(360%); }
|
|
1713
|
+
}
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
// The behavior is react-free: `signal`/`computed` from brustjs/store (the window
|
|
9
9
|
// singleton on the client), `client` from brustjs/client (the treaty action
|
|
10
10
|
// client — also react-free), and the shared teamStore. NO react imports.
|
|
11
|
-
import type { ActionErrorBody } from 'brustjs'
|
|
12
11
|
import { client } from 'brustjs/client'
|
|
13
12
|
import { computed, signal } from 'brustjs/store'
|
|
14
13
|
import type { Actions } from '../actions'
|
|
@@ -57,7 +56,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
57
56
|
if (data) {
|
|
58
57
|
full.set(false)
|
|
59
58
|
teamStore.members.set(data.team)
|
|
60
|
-
} else if (
|
|
59
|
+
} else if (error?.value.code === 'TEAM_FULL') {
|
|
61
60
|
full.set(true) // server rejected — team full; surface instead of silent no-op
|
|
62
61
|
}
|
|
63
62
|
}
|
|
@@ -10,29 +10,33 @@
|
|
|
10
10
|
//
|
|
11
11
|
// MUST stay single-return with no local bindings above it — a local `const`
|
|
12
12
|
// would make the compiler soft-fall-back to an SSR component (no <html> shell).
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// Active-nav is now CLIENT-driven: each <NavLink> is a native directive component
|
|
14
|
+
// whose behavior watches brustjs/navigation's `nav.path` and sets is-active itself
|
|
15
|
+
// (no SSR `active` conditional, no data-brust-active-nav magic). AppLayout owns the
|
|
16
|
+
// single <main> (leaves are fragments in the <Outlet/> slot) — a leaf adding its
|
|
17
|
+
// own <main> breaks SPA-nav extraction. See ../FRAMEWORK-GAPS.md.
|
|
17
18
|
import { BrustPage, Island, Outlet } from 'brustjs'
|
|
18
19
|
import type { TeamMember } from '../lib/types'
|
|
20
|
+
import NavLink from './NavLink'
|
|
21
|
+
import NavPreloader from './NavPreloader'
|
|
19
22
|
import TeamBuilder from './TeamBuilder'
|
|
23
|
+
import ThemeToggle from './ThemeToggle'
|
|
20
24
|
|
|
21
25
|
export default function AppLayout({
|
|
22
26
|
title,
|
|
23
|
-
active,
|
|
24
27
|
crumb,
|
|
25
28
|
teamProps,
|
|
29
|
+
mode,
|
|
26
30
|
}: {
|
|
27
31
|
title: string
|
|
28
|
-
active: 'list' | 'typechart'
|
|
29
32
|
crumb: string
|
|
30
33
|
teamProps: { teamInitial: TeamMember[] }
|
|
34
|
+
mode: 'dark' | 'light'
|
|
31
35
|
}) {
|
|
32
36
|
return (
|
|
33
37
|
<BrustPage
|
|
34
38
|
lang="en"
|
|
35
|
-
|
|
39
|
+
data-mode={mode}
|
|
36
40
|
title={title}
|
|
37
41
|
head={[{ tag: 'link', rel: 'icon', href: '/favicon.svg' }]}
|
|
38
42
|
>
|
|
@@ -48,26 +52,8 @@ export default function AppLayout({
|
|
|
48
52
|
</div>
|
|
49
53
|
<nav className="aa-sidebar__nav">
|
|
50
54
|
<div className="aa-sidebar__group-title">Pokédex</div>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<span>All Pokémon</span>
|
|
54
|
-
</a>
|
|
55
|
-
) : (
|
|
56
|
-
<a className="aa-nav-item" href="/">
|
|
57
|
-
<span>All Pokémon</span>
|
|
58
|
-
</a>
|
|
59
|
-
)}
|
|
60
|
-
{active === 'typechart' ? (
|
|
61
|
-
<a className="aa-nav-item is-active" href="/type-chart">
|
|
62
|
-
<span>Type chart</span>
|
|
63
|
-
<span className="aa-nav-item__count">native</span>
|
|
64
|
-
</a>
|
|
65
|
-
) : (
|
|
66
|
-
<a className="aa-nav-item" href="/type-chart">
|
|
67
|
-
<span>Type chart</span>
|
|
68
|
-
<span className="aa-nav-item__count">native</span>
|
|
69
|
-
</a>
|
|
70
|
-
)}
|
|
55
|
+
<NavLink native href="/" label="All Pokémon" />
|
|
56
|
+
<NavLink native href="/type-chart" label="Type chart" count="native" />
|
|
71
57
|
</nav>
|
|
72
58
|
<div className="aa-sidebar__user">
|
|
73
59
|
<span className="aa-avatar aa-avatar--sm dex-brand-avatar">B</span>
|
|
@@ -87,6 +73,7 @@ export default function AppLayout({
|
|
|
87
73
|
<span className="dex-crumb__sep">›</span>
|
|
88
74
|
<b>{crumb}</b>
|
|
89
75
|
</div>
|
|
76
|
+
<ThemeToggle native />
|
|
90
77
|
</header>
|
|
91
78
|
|
|
92
79
|
<div className="aa-content">
|
|
@@ -95,6 +82,7 @@ export default function AppLayout({
|
|
|
95
82
|
</main>
|
|
96
83
|
|
|
97
84
|
<Island component={TeamBuilder} props={teamProps} ssr hydrate="load" />
|
|
85
|
+
<Island component={NavPreloader} hydrate="load" />
|
|
98
86
|
</div>
|
|
99
87
|
</BrustPage>
|
|
100
88
|
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// NATIVE INTERACTIVE COMPONENT — a sidebar nav link whose active state is driven
|
|
2
|
+
// by the navigation store, WATCHED in the behavior and applied by the author (no
|
|
3
|
+
// data-brust-active-nav magic attribute). This is the "consume the nav store
|
|
4
|
+
// yourself" pattern: `nav` from brustjs/navigation is a plain reactive source
|
|
5
|
+
// (signals shared cross-chunk via brustjs/store's Symbol.for tracker), so a
|
|
6
|
+
// `computed` over `nav.path()` re-runs on every SPA navigation — the same store
|
|
7
|
+
// the React island reads via useNav(). The bootstrap-owned navigator commits
|
|
8
|
+
// nav.path; this behavior reacts.
|
|
9
|
+
//
|
|
10
|
+
// Single-file component: `export const behavior` → _directives.js (react-free);
|
|
11
|
+
// the JSX default → the native template the compiler lowers to minijinja.
|
|
12
|
+
import { nav } from 'brustjs/navigation'
|
|
13
|
+
import { computed } from 'brustjs/store'
|
|
14
|
+
|
|
15
|
+
// behavior → registered as "navLink". Reads the link's own href off the element
|
|
16
|
+
// (no x-props needed), watches nav.path, and returns the active className +
|
|
17
|
+
// aria-current. x-bind-class sets the full className; x-bind-aria-current with a
|
|
18
|
+
// null value removes the attribute (see runtime setBound).
|
|
19
|
+
export const behavior = ({ el }: { el: HTMLElement }) => {
|
|
20
|
+
const linkPath = new URL((el as HTMLAnchorElement).href, location.href).pathname
|
|
21
|
+
const cls = computed(() => (nav.path() === linkPath ? 'aa-nav-item is-active' : 'aa-nav-item'))
|
|
22
|
+
const current = computed(() => (nav.path() === linkPath ? 'page' : null))
|
|
23
|
+
return { cls, current }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// default → jinja. The SSR className is the inactive base; the behavior sets
|
|
27
|
+
// is-active on bind and on every SPA nav. `count` is an optional native inline
|
|
28
|
+
// conditional (the "native" chip on the type-chart link).
|
|
29
|
+
export default function NavLink({
|
|
30
|
+
href,
|
|
31
|
+
label,
|
|
32
|
+
count,
|
|
33
|
+
}: {
|
|
34
|
+
href: string
|
|
35
|
+
label: string
|
|
36
|
+
count?: string
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<a
|
|
40
|
+
x-data="navLink"
|
|
41
|
+
x-bind-class="cls"
|
|
42
|
+
x-bind-aria-current="current"
|
|
43
|
+
className="aa-nav-item"
|
|
44
|
+
href={href}
|
|
45
|
+
>
|
|
46
|
+
<span>{label}</span>
|
|
47
|
+
{count ? <span className="aa-nav-item__count">{count}</span> : null}
|
|
48
|
+
</a>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// REACT ISLAND demonstrating useNav() — a top "preloader" progress bar shown
|
|
2
|
+
// while an SPA navigation is in flight. This is the React-island counterpart to
|
|
3
|
+
// NavLink's native-behavior consumption of the SAME navigation store: the native
|
|
4
|
+
// behavior sets a class via x-bind, this island re-renders via React. Both read
|
|
5
|
+
// `brustjs/navigation` through brustjs/store's cross-chunk signal tracker.
|
|
6
|
+
//
|
|
7
|
+
// Placed OUTSIDE <main> in AppLayout so the bootstrap navigator (which only
|
|
8
|
+
// unmounts/swaps islands inside <main>) never tears it down during the very
|
|
9
|
+
// navigation it is indicating. It renders nothing when idle, and an indeterminate
|
|
10
|
+
// bar while `phase === 'loading'`.
|
|
11
|
+
import { useNav } from 'brustjs/client'
|
|
12
|
+
|
|
13
|
+
export default function NavPreloader() {
|
|
14
|
+
const { phase } = useNav()
|
|
15
|
+
if (phase !== 'loading') return null
|
|
16
|
+
return (
|
|
17
|
+
<div className="nav-preloader" role="progressbar" aria-label="กำลังโหลดหน้า">
|
|
18
|
+
<div className="nav-preloader__bar" />
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -87,12 +87,17 @@ export async function listLoader({ req }: LoaderCtx): Promise<ListData> {
|
|
|
87
87
|
active: 'list',
|
|
88
88
|
crumb: 'All Pokémon',
|
|
89
89
|
teamProps: { teamInitial: teamStore.list() },
|
|
90
|
+
mode: req.cookies.mode === 'light' ? 'light' : 'dark',
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export async function detailLoader({
|
|
94
|
+
export async function detailLoader({
|
|
95
|
+
params,
|
|
96
|
+
req,
|
|
97
|
+
}: LoaderCtx): Promise<DetailData | NativeVerdict> {
|
|
94
98
|
const name = params?.name ?? ''
|
|
95
|
-
const
|
|
99
|
+
const mode = req.cookies.mode === 'light' ? 'light' : 'dark'
|
|
100
|
+
const empty = emptyDetail(name, mode)
|
|
96
101
|
|
|
97
102
|
const p = await fetchPokemon(name)
|
|
98
103
|
// GAP S9 (FIXED): native loaders can now `return notFound(data)` to render the
|
|
@@ -153,6 +158,7 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
|
|
|
153
158
|
title: `${cap(p.name)} · PokéDex`,
|
|
154
159
|
active: 'list',
|
|
155
160
|
crumb: cap(p.name),
|
|
161
|
+
mode,
|
|
156
162
|
name: p.name,
|
|
157
163
|
id: p.id,
|
|
158
164
|
displayName: cap(p.name),
|
|
@@ -186,13 +192,14 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
|
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
function emptyDetail(name: string): DetailData {
|
|
195
|
+
function emptyDetail(name: string, mode: 'dark' | 'light'): DetailData {
|
|
190
196
|
return {
|
|
191
197
|
notFound: true,
|
|
192
198
|
// Chrome fields (ChromeData) read by AppLayout from the merged context.
|
|
193
199
|
title: `${cap(name)} · PokéDex`,
|
|
194
200
|
active: 'list',
|
|
195
201
|
crumb: cap(name),
|
|
202
|
+
mode,
|
|
196
203
|
name,
|
|
197
204
|
id: 0,
|
|
198
205
|
displayName: cap(name),
|
|
@@ -244,9 +251,9 @@ const SHORT: Record<string, string> = {
|
|
|
244
251
|
fairy: 'FAI',
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
export async function typeChartLoader(): Promise<TypeChartData> {
|
|
248
|
-
//
|
|
249
|
-
//
|
|
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.
|
|
250
257
|
const relations = await Promise.all(ALL_TYPES.map((t) => fetchTypeRelations(t)))
|
|
251
258
|
|
|
252
259
|
// Build the 19×19 grid as nested rows (header row + one row per attacking
|
|
@@ -327,5 +334,6 @@ export async function typeChartLoader(): Promise<TypeChartData> {
|
|
|
327
334
|
active: 'typechart',
|
|
328
335
|
crumb: 'Type chart',
|
|
329
336
|
teamProps: { teamInitial: teamStore.list() },
|
|
337
|
+
mode: req.cookies.mode === 'light' ? 'light' : 'dark',
|
|
330
338
|
}
|
|
331
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -18,6 +18,7 @@ export interface ChromeData {
|
|
|
18
18
|
active: 'list' | 'typechart' // which sidebar nav item gets is-active (S11 conditional)
|
|
19
19
|
crumb: string // topbar breadcrumb leaf label
|
|
20
20
|
teamProps: { teamInitial: TeamMember[] } // floating team-dock island initial state
|
|
21
|
+
mode: 'dark' | 'light' // theme, read from the `mode` cookie → <html data-mode={mode}>
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/** A single list cell — derived from the list endpoint alone (no detail fetch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26-alpha",
|
|
4
4
|
"description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"typescript": "^6.0.3"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
|
-
"brustjs-darwin-x64": "0.1.
|
|
44
|
-
"brustjs-darwin-arm64": "0.1.
|
|
45
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
46
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
48
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
43
|
+
"brustjs-darwin-x64": "0.1.26-alpha",
|
|
44
|
+
"brustjs-darwin-arm64": "0.1.26-alpha",
|
|
45
|
+
"brustjs-linux-x64-gnu": "0.1.26-alpha",
|
|
46
|
+
"brustjs-linux-arm64-gnu": "0.1.26-alpha",
|
|
47
|
+
"brustjs-linux-x64-musl": "0.1.26-alpha",
|
|
48
|
+
"brustjs-linux-arm64-musl": "0.1.26-alpha"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^19.2.6",
|
|
@@ -69,7 +69,8 @@
|
|
|
69
69
|
"./client": "./runtime/client/index.ts",
|
|
70
70
|
"./create": "./runtime/create.ts",
|
|
71
71
|
"./store": "./runtime/store/index.ts",
|
|
72
|
-
"./native": "./runtime/native/index.ts"
|
|
72
|
+
"./native": "./runtime/native/index.ts",
|
|
73
|
+
"./navigation": "./runtime/navigation/index.ts"
|
|
73
74
|
},
|
|
74
75
|
"files": [
|
|
75
76
|
"runtime",
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
"lint": "biome lint .",
|
|
99
100
|
"check": "biome check .",
|
|
100
101
|
"check:fix": "biome check --write .",
|
|
101
|
-
"ci": "biome ci ."
|
|
102
|
+
"ci": "biome ci .",
|
|
103
|
+
"typecheck:treaty": "cd runtime && bunx tsc -p tsconfig.typecheck.json --noEmit"
|
|
102
104
|
}
|
|
103
105
|
}
|
package/runtime/client/index.ts
CHANGED
|
@@ -26,3 +26,7 @@ export type { TreatyResponse, ClientOptions } from '../treaty.ts'
|
|
|
26
26
|
// surface and cannot be bundled for the browser. react.ts → define-store.ts is
|
|
27
27
|
// browser-safe (no node:async_hooks; the server resolver is injected separately).
|
|
28
28
|
export { useStore } from '../store/react.ts'
|
|
29
|
+
|
|
30
|
+
// Navigation-state view adapter (same rationale as useStore — browser/island
|
|
31
|
+
// entry only). brustjs/navigation itself stays React-free; the hook lives here.
|
|
32
|
+
export { useNav } from '../navigation/react.ts'
|