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

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,37 @@
1
+ // components/AddToTeamButton.tsx — NATIVE (M3-F D7). Render reads the team store through useStore: the server paints
2
+ // __store.team.*, the chunk reads team.members() live. Writes go through lib/team-client (handlers only, compiler rule
3
+ // store-write-in-render).
4
+ import { useStore } from '@brust/core/store/react'
5
+ import { useState } from 'react'
6
+ import { toggleMember } from '../lib/team-client'
7
+ import { team } from '../stores/team'
8
+
9
+ export default function AddToTeamButton({ id }: { id: number }) {
10
+ const s = useStore(team)
11
+ const [busy, setBusy] = useState(false)
12
+ const inTeam = s.members.some((m) => m.id === id)
13
+ const full = !inTeam && s.members.length >= s.max
14
+ return (
15
+ <button
16
+ type="button"
17
+ data-testid="add-to-team"
18
+ data-in-team={inTeam ? 'true' : 'false'}
19
+ disabled={busy || full}
20
+ className={
21
+ inTeam
22
+ ? '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'
23
+ : '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'
24
+ }
25
+ onClick={async () => {
26
+ setBusy(true)
27
+ try {
28
+ await toggleMember(id, inTeam)
29
+ } finally {
30
+ setBusy(false)
31
+ }
32
+ }}
33
+ >
34
+ {inTeam ? 'In your team' : full ? 'Team Full' : 'Add to team'}
35
+ </button>
36
+ )
37
+ }
@@ -0,0 +1,32 @@
1
+ // components/AppLayout.tsx — the document (S9): plain <html>, <Outlet/> for the leaf, TeamBuilder as a react child.
2
+ import { Outlet } from '@brust/core/routes'
3
+ import type { NavItem } from '../lib/types'
4
+ import NavLink from './NavLink'
5
+ import TeamBuilder from './TeamBuilder'
6
+ import ThemeToggle from './ThemeToggle'
7
+
8
+ export default function AppLayout(props: { title: string; mode: 'dark' | 'light'; nav: NavItem[] }) {
9
+ return (
10
+ <html lang="en" data-mode={props.mode}>
11
+ <head>
12
+ <meta charSet="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14
+ <title>{props.title}</title>
15
+ <link rel="icon" href="/public/favicon.svg" />
16
+ <link rel="stylesheet" href="/public/app.css" />
17
+ </head>
18
+ <body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
19
+ <header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 dark:border-slate-800 dark:bg-slate-950/80">
20
+ <nav className="mx-auto flex h-16 max-w-6xl items-center gap-2 px-4">
21
+ <a href="/" className="mr-2 flex items-center gap-2 no-underline"><span className="grid h-8 w-8 place-items-center rounded-lg bg-brand-500 text-sm font-extrabold text-white">P</span><span className="text-base font-extrabold">PokéDex</span></a>
22
+ {props.nav.map((n) => <NavLink key={n.href} href={n.href} label={n.label} active={n.active} />)}
23
+ <div className="ml-auto"><ThemeToggle mode={props.mode} /></div>
24
+ </nav>
25
+ </header>
26
+ <main className="mx-auto max-w-6xl px-4 py-8"><Outlet /></main>
27
+ <footer className="border-t border-slate-200 py-6 text-center text-xs text-slate-400 dark:border-slate-800">Built with brust · data: PokeAPI snapshot</footer>
28
+ <TeamBuilder />
29
+ </body>
30
+ </html>
31
+ )
32
+ }
@@ -0,0 +1,4 @@
1
+ // components/Breadcrumb.tsx — static (no nav store in M2).
2
+ export default function Breadcrumb(props: { crumb: string }) {
3
+ return <b className="text-slate-600 dark:text-slate-300">{props.crumb}</b>
4
+ }
@@ -0,0 +1,11 @@
1
+ // components/DexCard.tsx — static per-row child (props only, no job: its list is state-derived, D4).
2
+ import type { DexCard as Card } from '../lib/types'
3
+ export default function DexCard(props: { card: Card }) {
4
+ return (
5
+ <a href={props.card.detailHref} data-dex={props.card.num} className="group flex flex-col items-center rounded-2xl border border-slate-200 bg-white p-3 no-underline shadow-sm dark:border-slate-800 dark:bg-slate-900">
6
+ <span className="self-start text-[11px] font-semibold tabular-nums text-slate-400">{props.card.num}</span>
7
+ <img src={props.card.artwork} alt={props.card.displayName} loading="lazy" className="h-24 w-24 object-contain" />
8
+ <div className="mt-1 text-sm font-semibold">{props.card.displayName}</div>
9
+ </a>
10
+ )
11
+ }
@@ -0,0 +1,26 @@
1
+ // components/DexFilter.tsx — useState filter/sort; `filterSort` is a module helper so the first paint is a
2
+ // precompute job seeded with the initial state and the client recomputes on change (battery e-precompute-state).
3
+ import { useState } from 'react'
4
+ import { filterSort } from '../lib/filter'
5
+ import type { DexCard as Card } from '../lib/types'
6
+ import DexCard from './DexCard'
7
+ export default function DexFilter(props: { items: Card[] }) {
8
+ const [q, setQ] = useState('')
9
+ const [az, setAz] = useState(false)
10
+ const shown = filterSort(props.items, q, az)
11
+ return (
12
+ <section>
13
+ <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
14
+ <input type="search" placeholder="Search Pokémon…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm sm:max-w-xs dark:border-slate-700 dark:bg-slate-900" />
15
+ <div className="flex items-center gap-3">
16
+ <button type="button" onClick={() => setAz(false)} className="rounded-l-xl border border-slate-200 px-3 py-1.5 text-sm dark:border-slate-700">Dex#</button>
17
+ <button type="button" onClick={() => setAz(true)} className="rounded-r-xl border border-slate-200 px-3 py-1.5 text-sm dark:border-slate-700">A–Z</button>
18
+ <span data-testid="count" className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold tabular-nums dark:bg-slate-800">{shown.length} / {props.items.length}</span>
19
+ </div>
20
+ </div>
21
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
22
+ {shown.map((c) => <DexCard key={c.id} card={c} />)}
23
+ </div>
24
+ </section>
25
+ )
26
+ }
@@ -0,0 +1,14 @@
1
+ // components/HeroSearch.tsx — controlled input + useId; a plain GET form to /pokedex?q= (no SPA navigate).
2
+ import { useId, useState } from 'react'
3
+ export default function HeroSearch() {
4
+ const id = useId()
5
+ const [q, setQ] = useState('')
6
+ return (
7
+ <form action="/pokedex" method="get" className="mx-auto mt-8 flex w-full max-w-md items-center gap-2 rounded-2xl bg-white/95 p-2 shadow-lg dark:bg-slate-900/90">
8
+ <label htmlFor={id} className="sr-only">Search the Pokédex</label>
9
+ <input id={id} name="q" type="search" placeholder="Search the Pokédex…" value={q} onChange={(e) => setQ(e.target.value)}
10
+ className="min-w-0 flex-1 rounded-xl bg-transparent px-3 py-2 text-sm text-slate-900 dark:text-white" />
11
+ <button type="submit" className="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white">Search</button>
12
+ </form>
13
+ )
14
+ }
@@ -0,0 +1,6 @@
1
+ // components/NavLink.tsx — static; `active` is computed in the loader from `path` (no client nav in M2).
2
+ const BASE = 'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
3
+ const ACTIVE = 'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-semibold text-brand-600 bg-brand-50 dark:text-brand-50 dark:bg-brand-600/20'
4
+ export default function NavLink(props: { href: string; label: string; active: boolean }) {
5
+ return <a href={props.href} data-active={props.active ? '1' : '0'} className={props.active ? ACTIVE : BASE}>{props.label}</a>
6
+ }
@@ -0,0 +1,32 @@
1
+ // components/TeamBuilder.tsx — react tier on purpose (useReducer for `open`): SSR + idle hydration (S12). The roster is the
2
+ // unified `team` store: the SSR scope is seeded from the snapshot, so the first client snapshot equals the server paint (D8).
3
+ import { useStore } from '@brust/core/store/react'
4
+ import { useReducer } from 'react'
5
+ import { removeMember } from '../lib/team-client'
6
+ import { team } from '../stores/team'
7
+
8
+ const flip = (open: boolean): boolean => !open
9
+
10
+ export default function TeamBuilder() {
11
+ const { members, max } = useStore(team)
12
+ const [open, toggle] = useReducer(flip, false)
13
+ return (
14
+ <div className="fixed bottom-5 right-5 z-[200]">
15
+ {open && (
16
+ <div data-testid="team-panel" className="mb-3 w-80 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
17
+ <div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 dark:border-slate-800"><span className="text-sm font-extrabold">My team</span><span className="ml-auto text-xs font-semibold">{members.length} / {max}</span></div>
18
+ {members.length === 0 ? <div className="px-5 py-7 text-center text-xs text-slate-400">No Pokémon on your team yet.</div> : members.map((m) => (
19
+ <div key={m.id} className="flex items-center gap-2.5 border-b border-slate-100 px-3.5 py-2.5 dark:border-slate-800">
20
+ <img src={m.artwork} alt={m.displayName} className="h-7 w-7 object-contain" />
21
+ <a href={`/pokemon/${m.name}`} className="min-w-0 flex-1 text-xs font-semibold no-underline">{m.displayName}</a>
22
+ <button type="button" aria-label="Remove" onClick={() => removeMember(m.id)} className="rounded p-1 text-slate-400">×</button>
23
+ </div>
24
+ ))}
25
+ </div>
26
+ )}
27
+ <button type="button" onClick={() => toggle()} className="inline-flex items-center gap-1.5 rounded-full bg-brand-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg">
28
+ My team <span data-testid="team-count" className="rounded-full bg-white/25 px-2 py-0.5 text-xs font-extrabold">{members.length}</span>
29
+ </button>
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,16 @@
1
+ // components/ThemeToggle.tsx — useState + useEffect (spec §9 example). No cookie write (actions are M3): the
2
+ // loader still reads `mode` from the cookie for the first paint; the toggle is per page view.
3
+ import { useEffect, useId, useState } from 'react'
4
+ export default function ThemeToggle(props: { mode: 'dark' | 'light' }) {
5
+ // `useId` makes this instance server-fed, which is what puts its client chunk in the manifest's
6
+ // `children[]` (ledger F66: a native child with no job and no useId never gets its chunk linked).
7
+ const id = useId()
8
+ const [mode, setMode] = useState(props.mode)
9
+ useEffect(() => { document.documentElement.dataset.mode = mode }, [mode])
10
+ return (
11
+ <button type="button" id={id} aria-label="Toggle theme" data-testid="theme-toggle" onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
12
+ className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium dark:border-slate-700">
13
+ {mode === 'dark' ? 'Light' : 'Dark'}
14
+ </button>
15
+ )
16
+ }
@@ -0,0 +1,5 @@
1
+ // components/TypeBadge.tsx — one helper-backed job; rendered per row on HomePage AND DetailPage (D4).
2
+ import { label, tint } from '../lib/format'
3
+ export default function TypeBadge(props: { type: string }) {
4
+ return <span data-type={props.type} style={{ background: tint(props.type) }} className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">{label(props.type)}</span>
5
+ }
@@ -0,0 +1,28 @@
1
+ // components/UnifiedIsland.tsx — React island (SSR + hydration). `useStore` alone compiles native, so the "clicks from
2
+ // React" tally (useReducer) is what puts this on the react tier (D10 asks for an island).
3
+ import { useStore } from '@brust/core/store/react'
4
+ import { useReducer } from 'react'
5
+ import { unified } from '../stores/unified'
6
+
7
+ const tick = (n: number): number => n + 1
8
+
9
+ export default function UnifiedIsland() {
10
+ const { count } = useStore(unified)
11
+ const [mine, tally] = useReducer(tick, 0)
12
+ return (
13
+ <div className="flex flex-col items-center gap-4">
14
+ <p data-testid="unified-island-count" className="text-4xl font-bold tabular-nums">{count}</p>
15
+ <button
16
+ type="button"
17
+ data-testid="unified-island-inc"
18
+ onClick={() => {
19
+ tally()
20
+ unified.count.set(count + 1)
21
+ }}
22
+ className="inline-flex h-10 items-center rounded-lg bg-brand-500 px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90"
23
+ >
24
+ Increment from React ({mine})
25
+ </button>
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,20 @@
1
+ // components/UnifiedNative.tsx — NATIVE (0 jobs): render reads the `unified` store, the handler writes it. The chunk ships zero React.
2
+ import { useStore } from '@brust/core/store/react'
3
+ import { unified } from '../stores/unified'
4
+
5
+ export default function UnifiedNative() {
6
+ const s = useStore(unified)
7
+ return (
8
+ <div className="flex flex-col items-center gap-4">
9
+ <p data-testid="unified-native-count" className="text-4xl font-bold tabular-nums">{s.count}</p>
10
+ <button
11
+ type="button"
12
+ data-testid="unified-native-inc"
13
+ onClick={() => unified.count.set(s.count + 1)}
14
+ className="inline-flex h-10 items-center rounded-lg border border-slate-300 px-4 text-sm font-semibold transition-colors hover:border-slate-500 dark:border-slate-600"
15
+ >
16
+ Increment — zero React
17
+ </button>
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,14 @@
1
+ // components/UnifiedStoreNode.tsx — the centre node: a read-only native view of the same store both panels write (0 jobs).
2
+ import { useStore } from '@brust/core/store/react'
3
+ import { unified } from '../stores/unified'
4
+
5
+ export default function UnifiedStoreNode() {
6
+ const { count } = useStore(unified)
7
+ return (
8
+ <div className="flex min-w-36 flex-col items-center gap-1 rounded-2xl border-2 border-brand-500 px-6 py-5 text-center">
9
+ <span className="text-sm font-semibold">one store</span>
10
+ <span data-testid="unified-node-count" className="text-3xl font-bold tabular-nums text-brand-500">{count}</span>
11
+ <code className="text-sm text-slate-500 dark:text-slate-400">unified</code>
12
+ </div>
13
+ )
14
+ }