.phaze: Structure & state placement
Layouts and slots — the three layers
Section titled “Layouts and slots — the three layers”.phaze exposes phaze-cloudflare’s existing three-layer chrome composition without inventing new machinery:
| Layer | Where | Persists across | Slots |
|---|---|---|---|
| App shell | src/app.phaze (no ---page, just an arrow) | Every navigation | children |
| Section layout | layout: X label in ---page | Same-section navs | children |
| Per-page layout | <Layout> wrapped in the trailing region’s JSX | Nothing — just the page’s own composition | children + <Fragment slot="X"> named slots |
The three nest:
App shell ← persistent across all nav └─ Section layout (from `layout:`) ← persistent within same-section nav └─ Per-page <Layout> wrap ← per-page composition with named slots ├─ <Fragment slot="header"> ├─ <Fragment slot="sidebar"> └─ default content.phaze doesn’t need to invent slot composition — phaze-compile’s existing extractSlots pass handles <Fragment slot="X"> lifting at the inline <Layout>…</Layout> call site in the trailing region.
State placement — the decision tree
Section titled “State placement — the decision tree”Every signal has exactly one of two lifetimes:
- Local — one copy per component mount; disposed on unmount.
- Global (module) — one copy per page load; shared across every mount of this component (and every other importer if exported).
The decision flow:
- Does each component instance need its own copy?
- Yes → Local. Declare in
---statewithout a decorator. Compiles toconst X = …inside the component body. - No → continue.
- Yes → Local. Declare in
- Is THIS component the only consumer (it owns the signal, even if shared across its own mounts)?
- Yes → Global (module), inline. Declare in
---statewith@global. Compiles toconst X = …at module scope, above the component. - No → continue.
- Yes → Global (module), inline. Declare in
- Do multiple unrelated components read or write this signal?
- Yes → Global (module), shared file. Declare in
src/store.ts(or another shared module) andimportit into each consumer.
- Yes → Global (module), shared file. Declare in
Where each kind of declaration lives:
| Declaration | Region | Lifetime | Marker |
|---|---|---|---|
| Imports | Top of file | Module load | — |
| Inline subcomponents, lookup tables, helpers | Top of file (above ---page) | Module load | — |
state: s('s1') (per-mount state machine) | ---state | Local | none |
CARD_ANIM: { … } (per-mount constant alongside state) | ---state | Local | none |
if (sig.current() === 'X') sig.set(…) (mount-time setup) | ---state | Local | none — passthrough |
@global selectedPost: signal('landing') (this component’s shared selection) | ---state | Global (module) | @global |
drafts: s<Item[]>([]) (typed Local decl) | ---state | Local | none — typed via factory <T> |
const products = s.async(actions.X()) (module-scope decl with TS-side type) | Above ---page | Global (module) | — — uses regular const + factory <T> |
| Cross-component shared signals | src/store.ts | Global (module), project-wide | — — explicit imports per consumer |
Effects, refs, use:-directives bound to local state | Trailing region | Local (live alongside the JSX they touch) | — |
Anti-patterns
Section titled “Anti-patterns”Each entry below names the anti-pattern, why it’s wrong, and the canonical replacement.
Function with PageContext in ---page
Section titled “Function with PageContext in ---page”// ❌ Wrong — head queries the DB; duplicates the loader's workhead: ({ params, env }) => env.DB.get(params.id).then(row => ({ title: row.name }))---page is for static config only. Anything that needs PageContext belongs in ---data. For dynamic titles, write <title> in the component body — the compiler lifts it to a synthesized head that derives from data.
Hand-written thunks in DSL macros
Section titled “Hand-written thunks in DSL macros”// ❌ Wrong — the `() =>` is the compiled outputconst data = signal.async(() => fetcher())const doubled = c(() => count() * 2)watch(() => log(count()))// ✅ Right — the compiler adds the arrowconst data = s.async(fetcher())const doubled = c(count() * 2)watch(log(count()))s.async / c / watch / phaze from /dsl auto-thunk their argument. Writing () => defeats the entire subpath.
Arrow + return in a page trailing region
Section titled “Arrow + return in a page trailing region”// ❌ Wrong — re-introduces the boilerplate the format drops({ data }) => { const state = s('s1') return <>…</>}// ✅ Right — page trailing region IS the function bodyconst state = s('s1')
<>…</>The page’s trailing region is statements + a JSX expression. The compiler wraps it.
Method-form predicates that collide with s.async
Section titled “Method-form predicates that collide with s.async”// ❌ Wrong — `s` from `/match` has `.is`/`.not` but blocks `s.async` auto-thunkingimport { s } from '@madenowhere/phaze/match'state.is('X')const data = s.async(fetcher()) // NOT auto-thunked; needs explicit () =>// ✅ Right — single `s` source, free-function predicatesimport { s } from '@madenowhere/phaze/dsl'import { is, not } from '@madenowhere/phaze/match'is(state, 'X')const data = s.async(fetcher()) // auto-thunkedCARD_ANIM lifted to @global or above ---page
Section titled “CARD_ANIM lifted to @global or above ---page”The canonical phaze idiom keeps lookup tables Local — colocated with the state signal they drive. Lifting CARD_ANIM to @global (or above ---page) makes the reader scroll across boundaries to find the state→pose mapping. Put it right under the state signal in ---state, both Local:
---statestate: s<'s1' | 's2' | 's3'>('s1')CARD_ANIM: { s1: { y: 0 }, s2: { y: -8 }, s3: { y: 0 } }---That’s one Local block, two consecutive lines, lifetime obvious.
import type in this project
Section titled “import type in this project”This project uses verbatimModuleSyntax: false in tsconfig.json — type-only names elide from plain import automatically. Writing import type { X } from '…' is redundant and conflicts with the project’s commit 43125e8 convention.
Dependent field-style ---data
Section titled “Dependent field-style ---data”Field-style fields can’t reference each other — they’re strictly parallel:
// ❌ Wrong — `related` can't reference `products`---dataproducts: env.DB.list()related: env.DB.list({ category: products[0]?.category }) // products is undefined here---// ✅ Right — switch to function-body form---dataconst products = await env.DB.list()return { products, related: await env.DB.list({ category: products[0]?.category }) }---