Skip to content

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, phaze and 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

The DSL subpath exports a tiny vocabulary intended for terse, idiomatic Phaze code. Four exports:

NameAliasesPurpose
ssignalCreate a reactive signal
ccomputedCreate a derived value
watcheffectRun 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.

Phaze does NOT have onMount, onUnmount, onCleanup, init, dispose, or useEffect-style lifecycle hooks. The component model is positional, not named:

Where you write codeWhen it runs
Top of the component bodyOnce, 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.

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 tracking
const x = count() // 4. outside any thunk — same as current()
// because no active reactive scope exists

Bare 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.

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():

SourceUse
{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).


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 identifierCompiler behavior
sNo 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.

The phaze-compile mechanism is a small Babel plugin (~200 lines including everything). DSL recognition is roughly:

// Tracks DSL bindings per file
state.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.


Phaze recognizes five JSX namespace prefixes via phaze-compile:

NamespacePurposeCompiler behavior
phaze:attrReactive attribute expressionsRewrites to attr={() => expr}
on:eventNative DOM event listenersRewrites to onEvent={fn} (camelCase)
use:directiveDOM behavior attachmentsEmits post-creation directive(el, () => value) call
class:nameConditional class additionEmits effect(() => el.classList.toggle('name', !!expr)) post-creation
bind:value / bind:checkedTwo-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.

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).


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).

Phaze has two ways to extend element behavior. Each has a distinct job:

ComponentDirective
Signature(props) => Node(el, value: () => T) => void
ReturnsDOM treenothing or cleanup
Creates DOM at JSX positionYesNo
Used as<Component />use:directive={value}
JobStructure (what UI exists)Behavior (what existing UI does)
CompositionNesting (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.

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 let variable, 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.

  • use:directiveName in JSX — camelCase identifier
  • Implementation file: directives/directive-name.ts — kebab-case filename
  • Export: named export, lowercase camelCase
directives/click-outside.ts
export const clickOutside = (el: HTMLElement, value: () => () => void) => { ... }
// usage
import { clickOutside } from './directives/click-outside'
<div use:clickOutside={onClose} />

All four mechanisms (phaze:, phaze(), on:, use:) are compile-time transforms. The phaze-compile package itself ships zero bytes (build-time only).

SourceAfter compileBundle 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-creationslightly 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 phaze macro function’s runtime body — tree-shakes after rewrite
  • Namespace recognition logic — pure compile-time AST work

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.

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 (class x2, aria-label, data-visible, value, aria-pressed, aria-expanded)
  • on: namespace — 4 events (click x3, input)
  • use: directives — 5 distinct (autofocus, tooltip x2, longpress, clickOutside, intersect)
  • Bare signals ({greeting})
  • Auto-wrapped ternaries in JSX child position ({isFav() ? '★' : '☆'})
  • Auto-wrapped logical AND in JSX ({expanded() && <div>...})
  • cleanup() from main phaze
  • 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.

These are the directive bodies the Card depends on. Each is a small, focused implementation suitable for a build test.

import { effect } from '@madenowhere/phaze'
export const autofocus = (el: HTMLElement, value: () => boolean) => {
effect(() => {
if (value()) el.focus()
})
}
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)
}
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)
}
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
}
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())
}

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; }

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(...) and watch(...) arguments wrapped in () => ... arrows
  • Replace phaze(expr) calls with () => expr arrows
  • Convert phaze:attr={expr} to attr={() => expr}
  • Convert on:event={fn} to onEvent={fn} (camelCase)
  • Convert use:directive={value} to post-creation directive call
  • Drop the phaze import (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 phaze macro 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-visible attribute (use:intersect)
  • Console logs the watch output on every signal change

If all three checks pass, the DSL and directive system is working correctly.

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.


FeatureStatus
s, c, watch aliases in /dslProposed
phaze(expr) macro in /dslProposed (replaces earlier Q proposal)
phaze:attr JSX namespaceProposed
on:event JSX namespace (camelCase rewrite)Proposed
use:directive JSX namespaceProposed
Compiler conservative auto-wrap (children only)Shipped (phaze-compile 0.0.4)
Compiler aggressive auto-wrapRejected — 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.