DSL & directives
DSL stands for Domain Specific Language — the DX layer of Phaze, enabled in God Mode.
In Phaze, directives can be thought of as a “headless component” that adds reactive behavior to a DOM element. The core directives phaze:, on:, class:, bind: don’t add any additional size — use: directives can be thought of as “add-ons”. Directives stack and keep things much cleaner. Phaze has been built with performance from the ground up — directives are tree-shaken per-import, so you pay only for the use: directives you attach. For caching granularity in production, directives can also be split into their own chunk via chunkDirectives: true, bundled as phaze-directives.js.
The DSL subpath — @madenowhere/phaze/dsl
Section titled “The DSL subpath — @madenowhere/phaze/dsl”Four exports, all compile-time-aware:
| Name | Aliases | What the phaze-compiler does |
|---|---|---|
s | signal | Plain alias — no transform. Carries s.await (compile-only) and s.async / s.bind from the signal factory |
c | computed | Auto-thunks: c(expr) → c(() => expr) |
watch | effect | Auto-thunks: watch(expr) → watch(() => expr) |
phaze | (macro) | Rewrites phaze(expr) → () => expr, drops the import |
import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'import { cleanup } from '@madenowhere/phaze'
export function Counter() { const count = s(0) const doubled = c(count() * 2) // auto-thunked watch(console.log(`count: ${count()}`)) // auto-thunked cleanup(() => console.log('unmounted')) // imported normally
return ( <button on:click={() => count.update(n => n + 1)}> {count} × 2 = {doubled} </button> )}Equivalent without the DSL:
import { signal, computed, effect, cleanup } from '@madenowhere/phaze'
const count = signal(0)const doubled = computed(() => count() * 2)effect(() => console.log(`count: ${count()}`))The two are transformed by the phaze-compiler to the same code. The DSL is purely an authoring convention — pick the import style your project prefers.
The phaze() macro — reactive child expressions
Section titled “The phaze() macro — reactive child expressions”Bare signal reads in JSX ({count}) are the fast path: the runtime detects the callable and wires it to a text/attribute binding directly. The moment you need to do something to the value — arithmetic, formatting, comparison — wrap in phaze():
<p>{count}</p> {/* bare — fast path */}<p>{phaze(`Total: $${total()}`)}</p> {/* template literal */}<p>{phaze(count() > 5 ? 'high' : 'low')}</p> {/* expression */}phaze(expr) rewrites to () => expr; the import drops out and the macro body tree-shakes. Same bytes as writing the arrow yourself.
The phaze-compiler also auto-wraps ternaries and && expressions in JSX child position, so {cond() ? <A/> : <B/>} is reactive without phaze(). The wrapper is for cases the auto-wrap doesn’t cover — most commonly template literals.
s.await — the triggered flow
Section titled “s.await — the triggered flow”s.await({ steps }) is a triggered, sequential pipeline: named steps run in order, each step’s result threading into the next, with one shared failure state. Idle until .run(payload).
const register = s.await({ registerStart: actions.Passkey.registerStart, createPasskey: createPasskey, registerFinish: actions.Passkey.registerFinish, done: [({ createPasskey }) => auth.credId = createPasskey.id, auth.save()]})
<button on:click={register.run()}>Register passkey</button><p>{register.match({ pending: 'waiting for passkey…', error: (e) => e.message })}</p>Why it’s a signal, not a helper
Section titled “Why it’s a signal, not a helper”Sequencing is the cheap half — async/await already does that. The point is what the sequence is:
s.awaitis sequencing whose intermediate states are reactive values, scoped to the component that owns them.
Phaze drives the DOM through fine-grained bindings: a component runs once, and each reactive read wires straight to the node it feeds, so an update touches exactly those nodes — no VDOM, no diffing, no re-render pass. Every value reaches the screen the same way, as a signal, so a process’s states are signals too.
Building on signal.async inherits that machinery: real signals behind match, effect() scoping so disposal cancels in-flight work, abortSignal() inside steps. The trigger is a signal as well — .run() is a write, which is why it composes inside watch(…), why retry is just another run, and why idle exists at all (trigger() ? runSteps() : null).
Step arms. A callable arm (a bare reference or an arrow) is called with the threaded value — registerStart above receives the payload, createPasskey receives what it returned. Any other expression — including a call — evaluates at its turn, so no () => is needed to defer it:
approve: actions.QR.approve({ code: data.code }) // evaluates when its turn comesWhat the compiler writes around every step, which is most of what the DSL buys:
| Step resolves to | Flow does |
|---|---|
{ data, error } (an action envelope) | unwraps — error becomes the flow’s error, data threads on |
an Error | that error state, no throw needed — check: (r) => r.ok ? r : new Error('rejected') |
null / undefined | cancels back to idle — a dismissed ceremony is not a failure |
a rejection named NotAllowedError | idle, not error (the WebAuthn dismissal) |
The done arm runs last and never threads. It takes one effect, or a list of them, awaited in source order under the same callable-vs-expression rule — so only the element that needs a step result pays for an arrow. A spread or an empty list is a compile error: the effects must be statically ordered.
Reader surface: .pending() (fired and loading — false at birth), .error(), .value(), .run(payload?), .reset() (back to true idle), and .match({ pending, error, ready }).
Bundle cost: 0. There is no runtime await member — the built dsl.js is export const s = signal. The whole machine emits inline from primitives that already ship (s, s.async), so an app that uses ten flows adds no library. Unlike signal.async, there is no hand-written equivalent form: without phaze-compile, s.await is not a function.
Subpath gotcha
Section titled “Subpath 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 phaze-compile‘s auto-thunking. The transform is keyed on the import path, not the local name.
JSX namespaces
Section titled “JSX namespaces”Six prefixes, each owning one job:
| Namespace | Purpose | Transformed via phaze-compiler |
|---|---|---|
phaze:attr={expr} | Reactive attribute | attr={() => expr} |
on:event={fn} | Native DOM event listener | onEvent={fn} (camelCase); CallExpression values auto-thunk to () => … |
use:name={value} | Behavior directive | name(el, () => value) post-creation |
class:name={cond} | Conditional class toggle | effect(() => el.classList.toggle('name', !!cond)) post-creation |
bind:value={signal} / bind:checked={signal} | Two-way input binding (minimal scope) | Signal pass-through + listen() post-creation |
nav:scroll / nav:transition | Per-navigation link options | data-nav-* attribute — static stays literal, reactive takes the phaze: thunk |
Combining everything
Section titled “Combining everything”All five namespaces compose on a single element. Post-creation operations (use:, class:, bind:) run in source order inside one IIFE; phaze: and on: rewrite in place to standard attributes:
<input type="text" class="input" use:autofocus={true} class:invalid={!valid()} bind:value={text} on:focus={track}/>Transformed via phaze-compiler (approximate)
((__el) => ( autofocus(__el, () => true), effect(() => __el.classList.toggle('invalid', !valid())), listen(__el, 'input', (e) => text.set(e.currentTarget.value)), __el))(<input type="text" class="input" value={text} onFocus={track} />)The phaze:/on:/use:/class:/bind: namespaces and the DSL auto-thunking require phaze-compile in your build pipeline.
- Astro:
@madenowhere/phaze-astrowires the phaze-compiler automatically. No extra config. - Vite (no Astro): add the
@madenowhere/vite-plugin-phazeplugin, which includes the phaze-compiler. - Other bundlers: import the Babel plugin from
@madenowhere/phaze-compiledirectly.
If you only want the runtime — no DSL, no namespaces — @madenowhere/phaze alone works with any tooling that supports the standard JSX automatic transform. See Setup.
In this section
Section titled “In this section”- DSL: Attributes & events —
phaze:attrreactive attributes andon:eventnative DOM events - DSL: Directives —
use:directives,use:spring,use:warp, directive anatomy, and the core directives package - DSL: Binding & transitions —
class:,bind:,defer:, andtransition: - DSL: Diagnostics — compile-time errors, runtime warnings, and bundle impact