Design notes: DSL & directives
Status: Living design document. Captures decisions about the @madenowhere/phaze/dsl subpath, the JSX namespace system, and the directive contract. Read this before writing Phaze components, modifying phaze-compile, or designing ecosystem packages.
Scope:
- The DSL vocabulary —
s,c,watch,phazeand what each does - The JSX namespace system —
phaze:,on:,use: - The directive contract — what makes a directive, when to use one
- The compiler’s role — what phaze-compile transforms
Not in scope (separate docs):
- Rejected patterns (refs, aggressive auto-wrap, lifecycle hooks, etc.)
- The phaze-in-view → phaze-directives/in-view migration (case study for hook → directive)
- Gluon library architecture
Part 1: The DSL (@madenowhere/phaze/dsl)
Section titled “Part 1: The DSL (@madenowhere/phaze/dsl)”Vocabulary
Section titled “Vocabulary”The DSL subpath exports a tiny vocabulary intended for terse, idiomatic Phaze code. Four exports:
| Name | Aliases | Purpose |
|---|---|---|
s | signal | Create a reactive signal |
c | computed | Create a derived value |
watch | effect | Run a side-effecting reactive computation |
phaze | (compiler macro) | Wrap an expression in a thunk for reactive use |
Everything else stays in main @madenowhere/phaze under its canonical name. Specifically cleanup, untrack, and batch are NOT aliased — they’re imported as-is when needed:
import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'import { cleanup } from '@madenowhere/phaze'The principle: only what’s different lives in /dsl. cleanup reads fine as-is and doesn’t fit a “rename for terseness” pattern.
Lifecycle Stance: No Named Hooks
Section titled “Lifecycle Stance: No Named Hooks”Phaze does NOT have onMount, onUnmount, onCleanup, init, dispose, or useEffect-style lifecycle hooks. The component model is positional, not named:
| Where you write code | When it runs |
|---|---|
| Top of the component body | Once, on mount |
Inside watch(...) | On mount + every dep change |
Inside c(...) | Lazily, when read; memoized |
Passed to cleanup(fn) | On disposal |
Top-level statements ARE the mount logic — they run when the component function executes, which happens once. There’s no need for an explicit onMount primitive because the function body itself is the mount.
const card = () => { const count = s(0)
console.log('mounted') // mount-time setup watch(console.log(`count: ${count()}`)) // reactive logging cleanup(() => console.log('unmounted')) // teardown hook
return <button onClick={() => count.update(n => n + 1)}>{count}</button>}This positional model is the deliberate alternative to React’s named-lifecycle ceremony. Don’t reintroduce onMount/init/dispose aliases — they’d recreate the React mental model the framework rejects.
Reading Patterns: Four Positions
Section titled “Reading Patterns: Four Positions”A signal can be read in four ways, each with different reactive semantics:
const count = s(0)
<span>{count}</span> // 1. bare — reactive, fast path<span>{phaze(count() * 2)}</span> // 2. phaze() wrapper — reactive expression<span>{count.current()}</span> // 3. current() — explicit snapshot, no trackingconst x = count() // 4. outside any thunk — same as current() // because no active reactive scope existsBare signal ({count}): runtime detects the callable, wires it directly to a text/attribute binding. No thunk allocation. Cheapest reactive path.
phaze() wrapper: compiler rewrites phaze(expr) to () => expr. The runtime sets up a reactive binding around the thunk; the thunk auto-tracks all signals it reads.
current(): reads the value without tracking. Used for one-time snapshots — “what was the value at mount” — or to read a signal in a callback without subscribing.
Top-level call: count() outside any reactive context (e.g., during component construction) doesn’t track because there’s no active computation to subscribe.
When to Reach for phaze()
Section titled “When to Reach for phaze()”Bare signals ({count}) are the fast path when you’re rendering a single signal value directly. The moment you need to do something to it — arithmetic, formatting, comparison, combining with another signal — wrap in phaze():
| Source | Use |
|---|---|
{count} | Bare. Fast path. |
{count()} | ❌ Just a number, evaluated once at mount |
{count() + 1} | {phaze(count() + 1)} — reactive expression |
{Total: ${total()}} | {phaze(Total: ${total()})} — template literal |
{count() > 5 ? 'high' : 'low'} | Auto-wrapped by compiler in child position; no phaze() needed |
class="static" | No wrapper — static string |
class={isActive() ? 'on' : 'off'} | phaze:class={isActive() ? 'on' : 'off'} (preferred) or class={phaze(...)} |
Test when writing JSX: am I rendering a single signal, or am I rendering something built from signals? Single signal = bare. Anything else = phaze() (or phaze: for attributes — covered in Part 2).
Part 2: Compiler Behavior
Section titled “Part 2: Compiler Behavior”Subpath Opt-In
Section titled “Subpath Opt-In”The DSL transforms are opt-in via the @madenowhere/phaze/dsl import path. phaze-compile recognizes this specific import and applies transformations only to identifiers bound from it.
| DSL identifier | Compiler behavior |
|---|---|
s | No transform — just an alias |
c(expr) | Wraps argument: c(() => expr) |
watch(expr) | Wraps argument: watch(() => expr) |
phaze(expr) | Rewrites to () => expr; phaze import drops, body tree-shakes |
Files that don’t import from /dsl get no DSL transformation, even if they happen to define a function named s or phaze. The transformations are scoped to bindings traced from the /dsl import:
// In a file with this import:import { c, watch } from '@madenowhere/phaze/dsl'
// These get auto-thunked:const doubled = c(count() * 2)watch(console.log(`count: ${count()}`))
// In a file with this import (no /dsl):import { computed, effect } from '@madenowhere/phaze'
// These do NOT auto-thunk — caller passes the arrow explicitly:const doubled = computed(() => count() * 2)effect(() => console.log(`count: ${count()}`))Important gotcha: import { signal as s } from '@madenowhere/phaze' is NOT the same as import { s } from '@madenowhere/phaze/dsl'. The first is a plain rename. The second activates compiler magic. The subpath path matters, not just the local name.
Visitor Sketch
Section titled “Visitor Sketch”The phaze-compile mechanism is a small Babel plugin (~200 lines including everything). DSL recognition is roughly:
// Tracks DSL bindings per filestate.dslLocals = new Map<string, string>() // local name → original DSL name
ImportDeclaration(path) { if (path.node.source.value !== '@madenowhere/phaze/dsl') return
for (const spec of path.node.specifiers) { if (spec.type !== 'ImportSpecifier') continue state.dslLocals.set(spec.local.name, spec.imported.name) }}
CallExpression(path) { if (path.node.callee.type !== 'Identifier') return const dslName = state.dslLocals.get(path.node.callee.name) if (!dslName) return
// Auto-thunk the argument if (dslName === 'c' || dslName === 'watch' || dslName === 'phaze') { const arg = path.node.arguments[0] if (arg && arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') { path.node.arguments[0] = types.arrowFunctionExpression([], arg) } }
// For phaze specifically, additionally remove the wrapper itself if (dslName === 'phaze') { path.replaceWith(path.node.arguments[0]) }}Roughly 30 lines of visitor logic for the DSL transforms.
Part 3: JSX Namespaces
Section titled “Part 3: JSX Namespaces”Phaze recognizes five JSX namespace prefixes via phaze-compile:
| Namespace | Purpose | Compiler behavior |
|---|---|---|
phaze:attr | Reactive attribute expressions | Rewrites to attr={() => expr} |
on:event | Native DOM event listeners | Rewrites to onEvent={fn} (camelCase) |
use:directive | DOM behavior attachments | Emits post-creation directive(el, () => value) call |
class:name | Conditional class addition | Emits effect(() => el.classList.toggle('name', !!expr)) post-creation |
bind:value / bind:checked | Two-way input binding (minimal scope: text-like inputs, textarea, checkbox only) | Emits signal pass-through + listen() post-creation |
Each namespace owns one job. Don’t overload — on: is for native events only; synthesized interactions (longpress, swipe, etc.) belong in use:. The two namespaces added after the initial three (class:, bind:) are documented in detail in class_bind_implementation.md.
phaze:attr — Reactive Attribute Expressions
Section titled “phaze:attr — Reactive Attribute Expressions”Wraps the attribute value in a thunk so the runtime re-evaluates when its tracked signals change.
<article phaze:class={isFav() ? 'card fav' : 'card'} phaze:aria-label={`Card for ${name()}`} phaze:disabled={qty() <= 0}>Compiler output:
<article class={() => isFav() ? 'card fav' : 'card'} aria-label={() => `Card for ${name()}`} disabled={() => qty() <= 0}>The runtime detects the function value and sets up a reactive binding. When tracked signals change, the binding re-runs the thunk and writes the result.
Symmetric function form for child expressions: since JSX namespaces only work on attributes, child expressions use phaze(...):
<p>{phaze(`Total: $${total()}`)}</p>Same transformation, different position. The two grammatical positions cover all reactive-expression cases.
on:event — Event Listeners
Section titled “on:event — Event Listeners”Rewrites kebab-case event names to camelCase props that Phaze’s JSX runtime already handles.
<button on:click={fn}>Click</button>Compiler output:
<button onClick={fn}>Click</button>Limitation: standard events only. This simple rewrite path can’t preserve event names with hyphens, dots, or colons. For custom events like sl-change (Shoelace), astro:before-load, or htmx:afterRequest, the camelCase mapping is lossy — on:sl-change would compile to onSlChange which doesn’t match the actual sl-change event name.
For custom events, wrap them in a directive. The directive’s body uses listen(el, 'sl-change', ...) directly — plain JavaScript, no JSX involved, kebab-case event name preserved verbatim.
// Don't:<sl-input on:sl-change={fn} /> // would compile to onSlChange — broken
// Do:<sl-input use:slChange={(e) => name.set(e.target.value)} />// ↑ directive listens for 'sl-change' internally via listen(el, 'sl-change', ...)The directive just attaches an event listener for the real (kebab-case) event name. No two-way binding — that’s a separate concern that’s been deliberately deferred. If you find yourself wanting “wire this signal both ways through this input,” that’s the moment to revisit the two-way binding decision rather than smuggle it in as a directive.
use:directive — DOM Behavior Attachments
Section titled “use:directive — DOM Behavior Attachments”Emits a post-element-creation function call:
<input use:autofocus={shouldFocus()} />Compiler output (approximate):
((el) => { autofocus(el, () => shouldFocus()) return el})(jsx('input', {}))The directive function is captured from the surrounding scope. The value is wrapped in a thunk so directives can read it reactively or pass it through.
Multiple directives stack on one element:
<button use:autofocus={true} use:tooltip={"Click to send"} use:longpress={() => showOptions()} on:click={send}> Send</button>Each directive becomes one post-creation call. They don’t know about each other; they just stack on the same element. Order doesn’t matter unless directives have explicit ordering dependencies (rare).
Part 4: The Directive Contract
Section titled “Part 4: The Directive Contract”A directive is a function with this shape:
type Directive<T> = (el: HTMLElement, value: () => T) => void | (() => void)- First arg: the DOM element the directive is attached to
- Second arg: a thunk returning the directive’s value (always a function, even if the caller passed a static value — the compiler wraps everything)
- Return value: void, or an optional cleanup function
Cleanup can also be registered via cleanup() from main @madenowhere/phaze, which is the more idiomatic Phaze pattern (composes with the parent computation tree).
Component vs Directive
Section titled “Component vs Directive”Phaze has two ways to extend element behavior. Each has a distinct job:
| Component | Directive | |
|---|---|---|
| Signature | (props) => Node | (el, value: () => T) => void |
| Returns | DOM tree | nothing or cleanup |
| Creates DOM at JSX position | Yes | No |
| Used as | <Component /> | use:directive={value} |
| Job | Structure (what UI exists) | Behavior (what existing UI does) |
| Composition | Nesting (each adds a wrapper) | Stacking (multiple on one element) |
The rule:
Components contribute DOM to the JSX tree where they’re written. Directives attach behavior to an existing element — and that behavior may include creating, moving, or destroying DOM elsewhere, just not at the call site.
A tooltip is a directive even though it creates a <div> and appends to document.body — because the DOM it creates is out-of-band from the JSX position. The directive is attached to the trigger button; the tooltip element lives elsewhere in the DOM. Same pattern applies to use:portal, use:dialog, use:contextMenu, use:notify, etc.
Imperative vs Reactive Inside a Directive
Section titled “Imperative vs Reactive Inside a Directive”Directives can use Phaze’s reactive primitives (signal, effect, cleanup), but should only when reactivity genuinely earns its place. For straightforward DOM work, imperative code is clearer and shorter.
Imperative is right when:
- The directive sets up listeners and runs them as events fire
- Internal state is a simple
letvariable, not multi-source derived state - DOM manipulation is direct (
el.style.foo = bar,el.classList.add(...), etc.) - There’s no fan-out — one signal driving many bindings, or many sources combining
Reactive is right when:
- The directive’s behavior depends on multiple inputs that combine into derived state
- Multiple bindings inside the directive should react to shared state
- The directive exposes its internal state back to the caller via a passed signal
- Animation states cascade (idle → enter → visible → exit)
Example: tooltip directive — imperative wins:
import { listen, cleanup } from '@madenowhere/phaze'
export const tooltip = (el: HTMLElement, value: () => string) => { let tip: HTMLDivElement | null = null
const show = () => { tip = document.createElement('div') tip.className = 'phaze-tooltip' tip.textContent = value() document.body.appendChild(tip)
const rect = el.getBoundingClientRect() tip.style.position = 'fixed' tip.style.top = `${rect.bottom + 8}px` tip.style.left = `${rect.left}px` }
const hide = () => { tip?.remove(); tip = null }
listen(el, 'mouseenter', show) listen(el, 'mouseleave', hide) listen(el, 'focus', show) listen(el, 'blur', hide)
cleanup(hide)}~25 lines, no signals, no JSX, plain DOM API plus listen() (which auto-cleans via the active computation’s AbortController) and cleanup() for explicit teardown. The “phaze-shaped” version with internal signals and phaze:style bindings would be ~35 lines of less-direct code with the same end behavior. Choose what fits.
Anti-pattern to avoid: reaching for signals inside a directive because the framework provides them, when imperative code does the job more clearly. The directive’s job is to do work to an element. If that work is genuinely multi-state reactive, signals earn their place. If it’s “set up listeners, run handlers,” imperative is the right tool.
Directive Naming Conventions
Section titled “Directive Naming Conventions”use:directiveNamein JSX — camelCase identifier- Implementation file:
directives/directive-name.ts— kebab-case filename - Export: named export, lowercase camelCase
export const clickOutside = (el: HTMLElement, value: () => () => void) => { ... }
// usageimport { clickOutside } from './directives/click-outside'<div use:clickOutside={onClose} />Part 5: Bundle Impact
Section titled “Part 5: Bundle Impact”All four mechanisms (phaze:, phaze(), on:, use:) are compile-time transforms. The phaze-compile package itself ships zero bytes (build-time only).
| Source | After compile | Bundle delta vs hand-written |
|---|---|---|
phaze:class={expr} | class={() => expr} | 0 |
class={phaze(expr)} | class={() => expr} | 0 |
{phaze(expr)} | {() => expr} | 0 |
on:click={fn} | onClick={fn} | 0 |
use:dir={value} | dir(el, () => value) post-creation | slightly less than equivalent ref callback |
The compiler doesn’t add bytes to your bundle relative to writing equivalent code by hand. It just lets you write the cleaner source.
What ships in the bundle (regardless of feature use):
- Phaze runtime — paid once, doesn’t grow per usage
- Component code — same bytes whether you used DSL sugar or explicit thunks
- Directive function bodies — only ship if you use them, tree-shake otherwise
What never ships:
- The phaze-compile package — build-time only
- The
phazemacro function’s runtime body — tree-shakes after rewrite - Namespace recognition logic — pure compile-time AST work
Build Test Reference
Section titled “Build Test Reference”A complete reference Card component using every DSL feature, every JSX namespace, and a representative set of directives. Use this as a build test for phaze-compile changes — if it compiles cleanly and runs correctly, the DSL/directive transforms are working end-to-end.
Card Component (card.tsx)
Section titled “Card Component (card.tsx)”import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'import { cleanup } from '@madenowhere/phaze'import { autofocus } from './directives/autofocus'import { tooltip } from './directives/tooltip'import { longpress } from './directives/longpress'import { clickOutside } from './directives/click-outside'import { intersect } from './directives/intersect'
export const card = () => { // ── Signals ───────────────────────────────────────────────────── const name = s('') const isFav = s(false) const expanded = s(false) const isVisible = s(false) // populated by use:intersect
// ── Computeds ─────────────────────────────────────────────────── const greeting = c(name() ? `Hello, ${name()}!` : 'Type your name') const charCount = c(name().length)
// ── Watchers ──────────────────────────────────────────────────── watch(console.log(`Card: name="${name()}", fav=${isFav()}, visible=${isVisible()}`))
// ── Cleanup ───────────────────────────────────────────────────── cleanup(() => console.log('Card unmounted'))
// ── Actions ───────────────────────────────────────────────────── const reset = () => { name.set('') isFav.set(false) expanded.set(false) }
return ( <article phaze:class={`card${isFav() ? ' fav' : ''}${expanded() ? ' expanded' : ''}`} phaze:aria-label={`Greeting card for ${name() || 'anonymous'}`} phaze:data-visible={isVisible()} use:intersect={isVisible} use:clickOutside={() => () => expanded.set(false)} > <header> <h3>Greeting Card</h3> <button phaze:class={isFav() ? 'fav-button active' : 'fav-button'} phaze:aria-pressed={isFav()} use:tooltip={isFav() ? 'Remove from favorites' : 'Add to favorites'} on:click={() => isFav.update(f => !f)} > {isFav() ? '★' : '☆'} </button> </header>
<input type="text" placeholder="Your name..." use:autofocus={true} phaze:value={name()} on:input={(e) => name.set(e.currentTarget.value)} />
<p>{greeting}</p> <p>{phaze(`(${charCount()} characters typed)`)}</p>
{expanded() && ( <div class="details"> <p>{phaze(`Visibility: ${isVisible() ? 'in view' : 'out of view'}`)}</p> <p>{phaze(`Total chars: ${charCount()}`)}</p> </div> )}
<footer> <button phaze:aria-expanded={expanded()} on:click={() => expanded.update(e => !e)} > {expanded() ? 'Hide details' : 'Show details'} </button>
<button use:tooltip={"Long-press to reset"} use:longpress={() => reset} on:click={() => name.set(name() + '!')} > Add ! </button> </footer> </article> )}Coverage check — features exercised by this component:
-
s— 4 signals (name,isFav,expanded,isVisible) -
c— 2 computeds (greeting,charCount), auto-thunked by compiler -
watch— 1 effect (logging), auto-thunked by compiler -
phaze()— 3 reactive child expressions (char count, visibility text, char total) -
phaze:namespace — 7 reactive attributes (classx2,aria-label,data-visible,value,aria-pressed,aria-expanded) -
on:namespace — 4 events (clickx3,input) -
use:directives — 5 distinct (autofocus,tooltipx2,longpress,clickOutside,intersect) - Bare signals (
{greeting}) - Auto-wrapped ternaries in JSX child position (
{isFav() ? '★' : '☆'}) - Auto-wrapped logical AND in JSX (
{expanded() && <div>...}) -
cleanup()from mainphaze - No refs, no
onMount/init/dispose, no React-shaped patterns
If phaze-compile correctly compiles this file and the runtime correctly runs it (input updates greeting reactively, clicking favorite toggles class and tooltip, intersect fires when scrolling, longpress resets state, click outside collapses expansion), the DSL/directive system is working end-to-end.
Directive Implementations
Section titled “Directive Implementations”These are the directive bodies the Card depends on. Each is a small, focused implementation suitable for a build test.
directives/autofocus.ts
Section titled “directives/autofocus.ts”import { effect } from '@madenowhere/phaze'
export const autofocus = (el: HTMLElement, value: () => boolean) => { effect(() => { if (value()) el.focus() })}directives/tooltip.ts
Section titled “directives/tooltip.ts”import { listen, cleanup } from '@madenowhere/phaze'
export const tooltip = (el: HTMLElement, value: () => string) => { let tip: HTMLDivElement | null = null
const show = () => { tip = document.createElement('div') tip.className = 'phaze-tooltip' tip.textContent = value() document.body.appendChild(tip)
const rect = el.getBoundingClientRect() tip.style.position = 'fixed' tip.style.top = `${rect.bottom + 8}px` tip.style.left = `${rect.left}px` }
const hide = () => { tip?.remove(); tip = null }
listen(el, 'mouseenter', show) listen(el, 'mouseleave', hide) listen(el, 'focus', show) listen(el, 'blur', hide)
cleanup(hide)}directives/longpress.ts
Section titled “directives/longpress.ts”import { listen, cleanup } from '@madenowhere/phaze'
export const longpress = (el: HTMLElement, value: () => () => void) => { let timer: number | null = null
const start = () => { timer = window.setTimeout(() => { value()() timer = null }, 500) }
const cancel = () => { if (timer !== null) { clearTimeout(timer) timer = null } }
listen(el, 'mousedown', start) listen(el, 'touchstart', start) listen(el, 'mouseup', cancel) listen(el, 'mouseleave', cancel) listen(el, 'touchend', cancel) listen(el, 'touchcancel', cancel)
cleanup(cancel)}directives/click-outside.ts
Section titled “directives/click-outside.ts”import { listen } from '@madenowhere/phaze'
export const clickOutside = (el: HTMLElement, value: () => () => void) => { listen(document, 'click', (e) => { if (!el.contains(e.target as Node)) value()() }) // listen() registers via active computation's AbortController — auto-cleans}directives/intersect.ts
Section titled “directives/intersect.ts”import { cleanup, type Signal } from '@madenowhere/phaze'
export const intersect = (el: HTMLElement, value: () => Signal<boolean>) => { const sig = value()
if (typeof IntersectionObserver === 'undefined') return
const obs = new IntersectionObserver(([entry]) => { sig.set(entry.isIntersecting) })
obs.observe(el) cleanup(() => obs.disconnect())}Optional Paired CSS
Section titled “Optional Paired CSS”Not required for the build test, but useful for visual confirmation:
.card { border: 1px solid #ccc; padding: 16px; border-radius: 8px; max-width: 400px; font-family: system-ui, sans-serif; margin: 8px 0;}.card.fav { border-color: gold; box-shadow: 0 0 8px rgba(255, 215, 0, 0.4); }.card.expanded { border-color: #4a90e2; }.card[data-visible="true"] { opacity: 1; }.card[data-visible="false"] { opacity: 0.5; }
.fav-button { background: none; border: none; font-size: 24px; cursor: pointer;}.fav-button.active { color: gold; }
.phaze-tooltip { background: #222; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; z-index: 9999; pointer-events: none;}
.details { margin-top: 12px; padding: 8px; background: #f5f5f5; border-radius: 4px; }footer { display: flex; gap: 8px; margin-top: 12px; }Build Test Procedure
Section titled “Build Test Procedure”To validate the DSL and directive system end-to-end:
1. Compile-time check. Run phaze-compile on card.tsx. The output should:
- Have
c(...)andwatch(...)arguments wrapped in() => ...arrows - Replace
phaze(expr)calls with() => exprarrows - Convert
phaze:attr={expr}toattr={() => expr} - Convert
on:event={fn}toonEvent={fn}(camelCase) - Convert
use:directive={value}to post-creation directive call - Drop the
phazeimport (no longer referenced after rewrite)
2. Bundle check. Bundle the compiled output with esbuild. The bundle should:
- Include only the directive functions actually used
- NOT include the
phazemacro body (tree-shaken) - NOT include phaze-compile (build-time only)
- Be roughly the same size as a hand-written equivalent (no compiler overhead)
3. Runtime check. Mount the card to a DOM element and verify:
- Input field auto-focuses on mount (
use:autofocus) - Typing in input updates the greeting and char count (signals + computeds)
- Clicking favorite toggles class and aria-pressed state (
phaze:class,phaze:aria-pressed) - Hovering favorite shows tooltip with reactive text (
use:tooltip) - Long-pressing “Add !” button (≥500ms) resets state (
use:longpress) - Clicking outside the card collapses expansion (
use:clickOutside) - Scrolling card in/out of viewport toggles
data-visibleattribute (use:intersect) - Console logs the watch output on every signal change
If all three checks pass, the DSL and directive system is working correctly.
Minimal Smoke Test
Section titled “Minimal Smoke Test”If the full Card is too much for a quick verification, this minimal version exercises just the core DSL transforms — useful for fast iteration on the compiler:
import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'import { autofocus } from './directives/autofocus'
export const minimal = () => { const count = s(0) const doubled = c(count() * 2)
watch(console.log(`count: ${count()}`))
return ( <div phaze:class={count() > 5 ? 'high' : 'low'}> <input use:autofocus={true} type="number" on:input={(e) => count.set(+e.currentTarget.value)} /> <p>{count} × 2 = {doubled}</p> <p>{phaze(`Status: ${count() > 5 ? 'high' : 'low'}`)}</p> </div> )}Covers s, c, watch, phaze(), phaze:, on:, use:, bare signals, and auto-wrapped child ternaries — every transform type in ~15 lines. If this compiles and runs, the compiler is wired correctly.
Status Summary
Section titled “Status Summary”| Feature | Status |
|---|---|
s, c, watch aliases in /dsl | Proposed |
phaze(expr) macro in /dsl | Proposed (replaces earlier Q proposal) |
phaze:attr JSX namespace | Proposed |
on:event JSX namespace (camelCase rewrite) | Proposed |
use:directive JSX namespace | Proposed |
| Compiler conservative auto-wrap (children only) | Shipped (phaze-compile 0.0.4) |
| Compiler aggressive auto-wrap | Rejected — preserves “no magic” identity |
bind: JSX namespace (two-way binding) | Rejected for now — adds complexity for marginal gain |
When implementing: ship phaze: and phaze() together as one transform pass; on: is a small standalone rewrite; use: requires both the namespace recognition and the post-creation call emission. None require runtime changes.