brustjs 0.1.28-alpha → 0.1.29-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,50 @@
1
1
  // NATIVE INTERACTIVE COMPONENT (Spec B dogfood) — the "Add to team / In your
2
- // team" toggle on the detail page. Formerly a React island; now a single-file
3
- // native directive component: a co-located `export const behavior` (client
4
- // logic, react-free) + a JSX `default` export (the native template the compiler
5
- // lowers to minijinja). The build bundles ONLY `behavior` into _directives.js;
6
- // the JSX default is tree-shaken out so react never leaks into the client bundle.
2
+ // team" toggle on the detail page. A single-file native directive component: a
3
+ // co-located `export const behavior` (client logic, react-free) + a JSX
4
+ // `default` export (the native template the compiler lowers to minijinja). The
5
+ // build bundles ONLY `behavior` into _directives.js; the JSX default is
6
+ // tree-shaken out so react never leaks into the client bundle.
7
7
  //
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 { Plus } from 'lucide-react'
11
12
  import { client } from 'brustjs/client'
12
13
  import { computed, signal } from 'brustjs/store'
13
14
  import type { Actions } from '../actions'
14
- import type { AddToTeamProps } from '../lib/types'
15
+ import type { TeamMember } from '../lib/types'
15
16
  import { teamStore } from '../stores/team'
16
17
 
17
18
  const api = client<Actions>()
18
19
 
20
+ interface AddToTeamProps {
21
+ id: number
22
+ name: string
23
+ displayName: string
24
+ num: string
25
+ types: string[]
26
+ artwork: string
27
+ }
28
+
29
+ const BASE =
30
+ 'inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-600 disabled:opacity-50'
31
+ const IN_TEAM =
32
+ 'inline-flex w-full items-center justify-center rounded-lg bg-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors disabled:opacity-50 dark:bg-slate-700 dark:text-slate-100'
33
+
19
34
  // behavior → client bundle, registered as "addToTeamButton" (camelCase filename).
20
35
  // `props` is the JSON parsed out of the element's x-props attribute (precomputed
21
36
  // by the loader as a JSON string — native templates can't call JSON.stringify).
22
37
  export const behavior = ({ props }: { props: AddToTeamProps }) => {
23
- // Shared store (GAP S4): writing teamStore.members here is observed by the
24
- // TeamBuilder island — they resolve the same window singleton. A native
25
- // x-on-click mutation is therefore seen reactively by a React island.
26
38
  const busy = signal(false)
27
39
  const full = signal(false)
28
- const inTeam = computed(() => (teamStore.members() ?? []).some((m) => m.id === props.id))
29
- const label = computed(() =>
30
- inTeam() ? '✓ In your team' : disabled() || full() ? 'Team Full' : '+ Add to team',
40
+ const inTeam = computed(() =>
41
+ ((teamStore.members() ?? []) as TeamMember[]).some((m) => m.id === props.id),
31
42
  )
32
- const btnClass = computed(() => `aa-btn aa-btn--full${inTeam() ? ' aa-btn--secondary' : ''}`)
33
43
  const disabled = computed(() => busy() || ((teamStore.members()?.length ?? 0) >= 6 && !inTeam()))
44
+ const label = computed(() =>
45
+ inTeam() ? 'In your team' : disabled() || full() ? 'Team Full' : 'Add to team',
46
+ )
47
+ const btnClass = computed(() => (inTeam() ? IN_TEAM : BASE))
34
48
 
35
49
  async function init() {
36
50
  const r = await api.team.get()
@@ -41,7 +55,6 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
41
55
  busy.set(true)
42
56
  try {
43
57
  if (inTeam()) {
44
- // Bodyless DELETE is OK now (GAPS S12 fixed) — no more `.delete({})`.
45
58
  const { data } = await api.team({ id: props.id }).delete()
46
59
  if (data) teamStore.members.set(data.team)
47
60
  } else {
@@ -57,7 +70,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
57
70
  full.set(false)
58
71
  teamStore.members.set(data.team)
59
72
  } else if (error?.value.code === 'TEAM_FULL') {
60
- full.set(true) // server rejected — team full; surface instead of silent no-op
73
+ full.set(true)
61
74
  }
62
75
  }
63
76
  } finally {
@@ -74,17 +87,21 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
74
87
  // string, emitted by the compiler as x-props="{{ (data) | e }}" (XSS-safe).
75
88
  export default function AddToTeamButton({ data }: { data: string }) {
76
89
  return (
77
- <div x-data="addToTeamButton" x-props={data} style={{ position: 'relative' }}>
90
+ <div x-data="addToTeamButton" x-props={data} className="relative">
91
+ <Plus
92
+ size={16}
93
+ className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white"
94
+ isr={{ key: 'LcIconPlus' }}
95
+ />
78
96
  <button
79
97
  type="button"
80
98
  x-text="label"
81
99
  x-bind-class="btnClass"
82
100
  x-bind-disabled="disabled"
83
101
  x-on-click="toggle"
84
- className="aa-btn aa-btn--full"
85
- style={{ width: '100%' }}
102
+ className="inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-600 disabled:opacity-50"
86
103
  >
87
- Add to team
104
+ Add to team
88
105
  </button>
89
106
  </div>
90
107
  )
@@ -1,20 +1,15 @@
1
1
  // Router-level native layout (Approach a: nested routes + <Outlet/>). Written
2
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}/>).
3
+ // wrapper the compiler builds is `<AppLayout native><Leaf native/></AppLayout>`.
4
+ // The chrome data it renders (title / crumb / teamProps / mode) comes from the
5
+ // MERGED LOADER CONTEXT: each leaf loader returns those fields and the
6
+ // chain-loader merge folds them into one flat jinja context; the names below are
7
+ // destructured so the native compiler resolves them as member-paths.
10
8
  //
11
- // MUST stay single-return with no local bindings above it — a local `const`
9
+ // MUST stay single-return with NO local bindings above it — a local `const`
12
10
  // would make the compiler soft-fall-back to an SSR component (no <html> shell).
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.
11
+ // AppLayout owns the single <main> (leaves are fragments in the <Outlet/> slot).
12
+ import { GitFork } from 'lucide-react'
18
13
  import { BrustPage, Island, Outlet } from 'brustjs'
19
14
  import type { TeamMember } from '../lib/types'
20
15
  import NavLink from './NavLink'
@@ -24,12 +19,10 @@ import ThemeToggle from './ThemeToggle'
24
19
 
25
20
  export default function AppLayout({
26
21
  title,
27
- crumb,
28
22
  teamProps,
29
23
  mode,
30
24
  }: {
31
25
  title: string
32
- crumb: string
33
26
  teamProps: { teamInitial: TeamMember[] }
34
27
  mode: 'dark' | 'light'
35
28
  }) {
@@ -40,48 +33,53 @@ export default function AppLayout({
40
33
  title={title}
41
34
  head={[{ tag: 'link', rel: 'icon', href: '/favicon.svg' }]}
42
35
  >
43
- <div className="aa-app">
44
- <aside className="aa-sidebar">
45
- <div className="aa-sidebar__brand">
46
- <div className="aa-sidebar__brand-mark">P</div>
47
- <div className="grow truncate">
48
- <div className="aa-sidebar__brand-name">PokéDex</div>
49
- <div className="aa-sidebar__brand-sub">brust example app</div>
50
- </div>
51
- <span className="aa-sidebar__env">native</span>
52
- </div>
53
- <nav className="aa-sidebar__nav">
54
- <div className="aa-sidebar__group-title">Pokédex</div>
55
- <NavLink native href="/" label="All Pokémon" />
56
- <NavLink native href="/type-chart" label="Type chart" count="native" />
57
- </nav>
58
- <div className="aa-sidebar__user">
59
- <span className="aa-avatar aa-avatar--sm dex-brand-avatar">B</span>
60
- <div className="grow truncate dex-user">
61
- <div className="dex-user__name">brust dev</div>
62
- <div className="dex-user__host">localhost</div>
63
- </div>
64
- </div>
65
- </aside>
66
-
67
- <main className="aa-main">
68
- <header className="aa-topbar">
69
- <div className="aa-topbar__crumb">
70
- <a href="/" className="dex-crumb__root">
36
+ <div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
37
+ <header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 backdrop-blur-md dark:border-slate-800 dark:bg-slate-950/80">
38
+ <nav className="mx-auto flex h-16 max-w-6xl items-center gap-2 px-4">
39
+ <a href="/" className="mr-2 flex items-center gap-2 no-underline">
40
+ <span className="grid h-8 w-8 place-items-center rounded-lg bg-brand-500 text-sm font-extrabold text-white">
41
+ P
42
+ </span>
43
+ <span className="text-base font-extrabold tracking-tight text-slate-900 dark:text-white">
71
44
  PokéDex
72
- </a>
73
- <span className="dex-crumb__sep">›</span>
74
- <b>{crumb}</b>
45
+ </span>
46
+ </a>
47
+ <NavLink native href="/" label="Home" />
48
+ <NavLink native href="/pokedex" label="Pokédex" />
49
+ <NavLink native href="/type-chart" label="Type chart" />
50
+ <div className="ml-auto flex items-center gap-2">
51
+ <ThemeToggle native />
52
+ {/* Team dock opens via the TeamBuilder island's own floating trigger
53
+ (the island owns its open state). A navbar button here would be a
54
+ dead affordance — cross-chunk toggle wiring is out of scope. */}
75
55
  </div>
76
- <ThemeToggle native />
77
- </header>
56
+ </nav>
57
+ </header>
78
58
 
79
- <div className="aa-content">
59
+ <main className="mx-auto max-w-6xl px-4 py-8">
60
+ <div className="container">
80
61
  <Outlet />
81
62
  </div>
82
63
  </main>
83
64
 
84
- <Island component={TeamBuilder} props={teamProps} ssr hydrate="load" />
65
+ <footer className="flex items-center justify-center gap-3 border-t border-slate-200 py-6 text-center text-xs text-slate-400 dark:border-slate-800">
66
+ <span>Built with brust · data from PokeAPI</span>
67
+ <a
68
+ href="https://github.com/AssetsArt/brust"
69
+ className="inline-flex items-center text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200"
70
+ aria-label="brust on GitHub"
71
+ >
72
+ <GitFork size={16} isr={{ key: 'LcIconGitFork' }} />
73
+ </a>
74
+ </footer>
75
+
76
+ <Island
77
+ component={TeamBuilder}
78
+ props={teamProps}
79
+ ssr
80
+ hydrate="load"
81
+ isr={{ key: 'TeamBuilderDock', tags: ['team'] }}
82
+ />
85
83
  <Island component={NavPreloader} hydrate="load" />
86
84
  </div>
87
85
  </BrustPage>
@@ -0,0 +1,49 @@
1
+ // NATIVE INTERACTIVE COMPONENT — the breadcrumb leaf. It lives in AppLayout's
2
+ // persistent shell (OUTSIDE <main>), so the SSR-baked `crumb` would go stale on
3
+ // SPA navigation (only <main>/<Outlet> swaps; the shell does not re-render). Like
4
+ // NavLink, the fix is to WATCH the navigation store: a `computed` over `nav.path()`
5
+ // re-derives the label on every SPA nav, so the breadcrumb updates without a reload.
6
+ //
7
+ // Single-file component: `export const behavior` → _directives.js (react-free);
8
+ // the JSX default → the native template the compiler lowers to minijinja. The SSR
9
+ // text is the loader's `crumb` (correct for the first paint); the behavior takes
10
+ // over on hydration and on each navigation.
11
+ import { nav } from 'brustjs/navigation'
12
+ import { computed } from 'brustjs/store'
13
+
14
+ const STATIC_LABELS: Record<string, string> = {
15
+ '/': 'Home',
16
+ '/pokedex': 'Pokédex',
17
+ '/type-chart': 'Type chart',
18
+ }
19
+
20
+ // Capitalize + de-dash a path segment (mirrors pokeapi `cap`, inlined to keep the
21
+ // directive chunk free of the loader/fetch module).
22
+ const cap = (s: string): string =>
23
+ s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' ') : s
24
+
25
+ /** Derive the crumb label from the current path — the detail route carries the
26
+ * Pokémon name in the path itself (`/pokemon/ivysaur` → "Ivysaur"), so no loader
27
+ * data is needed client-side. */
28
+ function deriveCrumb(path: string): string {
29
+ if (STATIC_LABELS[path]) return STATIC_LABELS[path] as string
30
+ const seg = path.split('/').filter(Boolean)
31
+ if (seg[0] === 'pokemon' && seg[1]) return cap(seg[1])
32
+ return cap(seg[seg.length - 1] ?? '') || 'Home'
33
+ }
34
+
35
+ // behavior → registered as "breadcrumb". Watches nav.path; x-text binds `label`.
36
+ export const behavior = () => {
37
+ const label = computed(() => deriveCrumb(nav.path()))
38
+ return { label }
39
+ }
40
+
41
+ // default → jinja. SSR renders the loader `crumb`; `x-text="label"` overrides it on
42
+ // bind and on every SPA navigation.
43
+ export default function Breadcrumb({ crumb }: { crumb: string }) {
44
+ return (
45
+ <b x-data="breadcrumb" x-text="label" className="text-slate-600 dark:text-slate-300">
46
+ {crumb}
47
+ </b>
48
+ )
49
+ }
@@ -0,0 +1,108 @@
1
+ // NATIVE INTERACTIVE COMPONENT — the browse-page dex grid with live search +
2
+ // sort. The LOAD-BEARING dogfood of keyed `x-for` (0.1.28-alpha): the grid
3
+ // reconciles by `c.id` as the filtered/sorted list changes, no full re-render.
4
+ //
5
+ // Single-file native directive: `export const behavior` (react-free client
6
+ // logic) bundled into _directives.js as "dexFilter"; the JSX default lowered to
7
+ // minijinja. The full item list arrives as the loader-precomputed `x-props`
8
+ // JSON string (native templates can't call JSON.stringify), parsed into the
9
+ // behavior `props`. NO react imports — `signal`/`computed` from brustjs/store.
10
+ import { ArrowDownAZ, Hash, Search } from 'lucide-react'
11
+ import { computed, signal } from 'brustjs/store'
12
+
13
+ interface Card {
14
+ id: number
15
+ name: string
16
+ displayName: string
17
+ num: string
18
+ artwork: string
19
+ detailHref: string
20
+ }
21
+
22
+ export const behavior = ({ props }: { el: HTMLElement; props: unknown }) => {
23
+ const all = ((props as { items?: Card[] })?.items ?? []) as Card[]
24
+ const q = signal('')
25
+ const sortAz = signal(false)
26
+ const filtered = computed(() => {
27
+ const needle = q().trim().toLowerCase()
28
+ let out = needle ? all.filter((c) => c.name.includes(needle)) : all.slice()
29
+ if (sortAz()) out = out.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
30
+ return out
31
+ })
32
+ const onInput = (e: Event) => q.set((e.target as HTMLInputElement).value)
33
+ const setDex = () => sortAz.set(false)
34
+ const setAz = () => sortAz.set(true)
35
+ const countLabel = computed(() => `${filtered().length} / ${all.length}`)
36
+ return { q, sortAz, filtered, onInput, setDex, setAz, countLabel }
37
+ }
38
+
39
+ export default function DexFilter({ data }: { data?: string }) {
40
+ return (
41
+ <section x-data="dexFilter" x-props={data}>
42
+ <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
43
+ <div className="relative w-full sm:max-w-xs">
44
+ <Search
45
+ size={16}
46
+ className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
47
+ isr={{ key: 'LcIconSearchField' }}
48
+ />
49
+ <input
50
+ type="search"
51
+ x-on-input="onInput"
52
+ placeholder="Search Pokémon…"
53
+ className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-9 pr-4 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/30 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
54
+ />
55
+ </div>
56
+ <div className="flex items-center gap-3">
57
+ <div className="inline-flex overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700">
58
+ <button
59
+ type="button"
60
+ x-on-click="setDex"
61
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
62
+ >
63
+ <Hash size={14} isr={{ key: 'LcIconHash14' }} />
64
+ Dex#
65
+ </button>
66
+ <button
67
+ type="button"
68
+ x-on-click="setAz"
69
+ className="inline-flex items-center gap-1.5 border-l border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
70
+ >
71
+ <ArrowDownAZ size={14} isr={{ key: 'LcIconArrowDownAZ14' }} />
72
+ A–Z
73
+ </button>
74
+ </div>
75
+ <span
76
+ x-text="countLabel"
77
+ className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold tabular-nums text-slate-500 dark:bg-slate-800 dark:text-slate-400"
78
+ />
79
+ </div>
80
+ </div>
81
+
82
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
83
+ {/* biome-ignore lint/a11y/useValidAnchor: href is bound at hydration via x-bind-href (the template clone gets c.detailHref) */}
84
+ <a
85
+ x-for="c in filtered by c.id"
86
+ x-bind-href="c.detailHref"
87
+ className="group flex 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 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"
88
+ >
89
+ <span
90
+ x-text="c.num"
91
+ className="self-start text-[11px] font-semibold tabular-nums text-slate-400"
92
+ />
93
+ {/* biome-ignore lint/a11y/useAltText: alt is bound at hydration via x-bind-alt (c.displayName) */}
94
+ <img
95
+ x-bind-src="c.artwork"
96
+ x-bind-alt="c.displayName"
97
+ loading="lazy"
98
+ className="h-24 w-24 object-contain transition-transform group-hover:scale-110"
99
+ />
100
+ <div
101
+ x-text="c.displayName"
102
+ className="mt-1 text-sm font-semibold text-slate-800 dark:text-slate-100"
103
+ />
104
+ </a>
105
+ </div>
106
+ </section>
107
+ )
108
+ }
@@ -0,0 +1,51 @@
1
+ // NATIVE INTERACTIVE COMPONENT — the hero search box. Single-file native
2
+ // directive: a co-located `export const behavior` (client logic, react-free) +
3
+ // a JSX `default` export (the native template the compiler lowers to
4
+ // minijinja). The build bundles ONLY `behavior` into _directives.js, registered
5
+ // as "heroSearch" (camelCase filename); the JSX default is tree-shaken out.
6
+ //
7
+ // react-free: `signal` from brustjs/store (the window singleton on the client),
8
+ // `navigate` from brustjs/navigation (imperative SPA navigation). On submit the
9
+ // behavior pushes /pokedex with the typed query — dogfooding imperative
10
+ // navigate() + query-object serialization.
11
+ import { Search } from 'lucide-react'
12
+ import { navigate } from 'brustjs/navigation'
13
+ import { signal } from 'brustjs/store'
14
+
15
+ // behavior → registered as "heroSearch". Tracks the input value via x-on-input
16
+ // and navigates to the browse page with `?q=` on submit/click.
17
+ export const behavior = () => {
18
+ const value = signal('')
19
+ const onInput = (e: Event) => value.set((e.target as HTMLInputElement).value)
20
+ const go = (e?: Event) => {
21
+ e?.preventDefault()
22
+ navigate('/pokedex', { query: { q: value() } })
23
+ }
24
+ return { value, onInput, go }
25
+ }
26
+
27
+ // default → jinja. The <form> submit and the button click both call `go`; the
28
+ // input feeds the `value` signal through `onInput`.
29
+ export default function HeroSearch() {
30
+ return (
31
+ <form
32
+ x-data="heroSearch"
33
+ x-on-submit="go"
34
+ className="mx-auto mt-8 flex w-full max-w-md items-center gap-2 rounded-2xl bg-white/95 p-2 shadow-lg ring-1 ring-black/5 dark:bg-slate-900/90 dark:ring-white/10"
35
+ >
36
+ <input
37
+ type="search"
38
+ placeholder="Search the Pokédex…"
39
+ x-on-input="onInput"
40
+ className="min-w-0 flex-1 rounded-xl bg-transparent px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-white"
41
+ />
42
+ <button
43
+ type="submit"
44
+ className="inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-brand-600"
45
+ >
46
+ <Search size={16} isr={{ key: 'LcIconSearch' }} />
47
+ Search
48
+ </button>
49
+ </form>
50
+ )
51
+ }
@@ -1,50 +1,43 @@
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.
1
+ // NATIVE INTERACTIVE COMPONENT — a navbar link whose active state is driven by
2
+ // the navigation store, WATCHED in the behavior and applied by the author. `nav`
3
+ // from brustjs/navigation is a plain reactive source (signals shared cross-chunk
4
+ // via brustjs/store's Symbol.for tracker), so a `computed` over `nav.path()`
5
+ // re-runs on every SPA navigation — the same store the React island reads via
6
+ // useNav(). The bootstrap-owned navigator commits nav.path; this behavior reacts.
9
7
  //
10
8
  // Single-file component: `export const behavior` → _directives.js (react-free);
11
9
  // the JSX default → the native template the compiler lowers to minijinja.
12
10
  import { nav } from 'brustjs/navigation'
13
11
  import { computed } from 'brustjs/store'
14
12
 
13
+ const BASE =
14
+ 'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
15
+ const ACTIVE =
16
+ 'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-semibold text-brand-600 bg-brand-50 transition-colors dark:text-brand-50 dark:bg-brand-600/20'
17
+
15
18
  // behavior → registered as "navLink". Reads the link's own href off the element
16
19
  // (no x-props needed), watches nav.path, and returns the active className +
17
20
  // aria-current. x-bind-class sets the full className; x-bind-aria-current with a
18
21
  // null value removes the attribute (see runtime setBound).
19
22
  export const behavior = ({ el }: { el: HTMLElement }) => {
20
23
  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'))
24
+ const cls = computed(() => (nav.path() === linkPath ? ACTIVE : BASE))
22
25
  const current = computed(() => (nav.path() === linkPath ? 'page' : null))
23
26
  return { cls, current }
24
27
  }
25
28
 
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
- }) {
29
+ // default → jinja. The SSR className is the inactive base; the behavior sets the
30
+ // active class on bind and on every SPA nav.
31
+ export default function NavLink({ href, label }: { href: string; label: string }) {
38
32
  return (
39
33
  <a
40
34
  x-data="navLink"
41
35
  x-bind-class="cls"
42
36
  x-bind-aria-current="current"
43
- className="aa-nav-item"
37
+ className="inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
44
38
  href={href}
45
39
  >
46
40
  <span>{label}</span>
47
- {count ? <span className="aa-nav-item__count">{count}</span> : null}
48
41
  </a>
49
42
  )
50
43
  }
@@ -5,7 +5,7 @@
5
5
  // `brustjs/navigation` through brustjs/store's cross-chunk signal tracker.
6
6
  //
7
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
8
+ // unmounts/swaps islands inside <main>) never tears it down during the very
9
9
  // navigation it is indicating. It renders nothing when idle, and an indeterminate
10
10
  // bar while `phase === 'loading'`.
11
11
  import { useNav } from 'brustjs/client'
@@ -14,8 +14,12 @@ export default function NavPreloader() {
14
14
  const { phase } = useNav()
15
15
  if (phase !== 'loading') return null
16
16
  return (
17
- <div className="nav-preloader" role="progressbar" aria-label="กำลังโหลดหน้า">
18
- <div className="nav-preloader__bar" />
17
+ <div
18
+ className="fixed inset-x-0 top-0 z-[300] h-0.5 overflow-hidden bg-brand-500/20"
19
+ role="progressbar"
20
+ aria-label="Loading page"
21
+ >
22
+ <div className="h-full w-1/3 animate-pulse bg-brand-500" />
19
23
  </div>
20
24
  )
21
25
  }