Design notes: class: and bind: spec
Status: ✅ Shipped. Both namespaces are implemented in phaze-compile — see Binding & transitions for the user-facing reference. This document is the original implementation spec, kept as the historical design record (the scoping decisions and rejected alternatives below explain why the shipped surface is minimal).
Originally an implementation proposal: both namespaces were reconsidered after initial rejection (see
rejected_patterns.mdhistory).
Audience: Contributors and design reviewers. The implementation matches this spec; deviations were recorded in PR descriptions.
Prerequisites:
- Familiarity with
dsl_directive.md(the existingphaze:,on:,use:namespace system) - Read access to phaze-compile’s existing visitor structure (Babel-based, ~200 LOC)
- Understanding of Phaze’s runtime binding helpers (
setText,setAttribute,setClass,listen)
Why these were reconsidered
Section titled “Why these were reconsidered”Both were rejected initially. The reopening criteria from rejected_patterns.md:
- A genuine use case emerges the alternative doesn’t cover
- The framework has matured to where the rejection’s reasoning no longer applies
- The ecosystem has shifted in ways that change the cost calculus
For class:, the trigger is criterion 1 — when you write components with three or four conditional classes, the phaze:class={...} template-string-with-ternaries form is genuinely worse than the additive class:foo={cond} form. The “without” version in evaluation testing was:
phaze:class={`card${isFav() ? ' fav' : ''}${expanded() ? ' expanded' : ''}${lowStock() ? ' warn' : ''}${isLoading() ? ' loading' : ''}`}That’s a real maintenance issue. Each space, each ternary, each missing-space bug is a place teams accumulate technical debt. class: solves it for ~15–20 lines of compiler code.
For bind:, the trigger is the minimal-scope insight: shipping only the trivial cases (bind:value on text inputs/textareas, bind:checked on checkboxes) covers the bulk of real-world usage without the implementation surface of the full Svelte-compatible variant. The original rejection assumed all-or-nothing; the minimal scope is a third option that earns its place.
Part 1: The class: namespace
Section titled “Part 1: The class: namespace”Syntax
Section titled “Syntax”<button class="btn" // static base class (unchanged behavior) class:active={isActive()} // conditionally adds 'active' when truthy class:loading={isLoading()} // conditionally adds 'loading' when truthy class:disabled={!canSubmit()} // conditionally adds 'disabled' when truthy> Submit</button>Each class:foo={expr} toggles the presence of class foo on the element based on the truthiness of expr. The expression auto-tracks signals it reads; when those change, the class is added or removed.
Semantics
Section titled “Semantics”- The class name is the part after
class:.class:activemeans the literal class nameactive. The compiler reads this from the JSXNamespacedName’s local name property. - The value is a thunk-wrapped expression. Coerced to boolean: truthy = class present, falsy = class absent.
- Composes additively with
class="...". The static class attribute sets the initial className; eachclass:reactively toggles its specific class on top. No interference. - Multiple
class:attributes are independent. Each gets its own effect, each toggles its own class. Order doesn’t matter. - Kebab-case class names work.
class:is-loading={...}toggles the classis-loading. JSX namespace local names can contain hyphens.
Compiler transformation
Section titled “Compiler transformation”The compiler sees:
<button class="btn" class:active={isActive()}>Emits (approximate):
const _el = jsx('button', { class: 'btn' })effect(() => _el.classList.toggle('active', !!isActive()))Multiple class: attributes become multiple effects:
<button class="btn" class:active={isActive()} class:loading={isLoading()}>Becomes:
const _el = jsx('button', { class: 'btn' })effect(() => _el.classList.toggle('active', !!isActive()))effect(() => _el.classList.toggle('loading', !!isLoading()))The pattern is consistent: one effect per class: directive, each calling classList.toggle with the class name as a string literal and the boolean value.
Visitor sketch
Section titled “Visitor sketch”JSXAttribute(path) { if (path.node.name.type !== 'JSXNamespacedName') return if (path.node.name.namespace.name !== 'class') return
const className = path.node.name.name.name // e.g., 'active', 'is-loading' const value = path.node.value // JSXExpressionContainer or string literal
let valueExpr if (!value) { valueExpr = types.booleanLiteral(true) // class:active with no value → always on } else if (value.type === 'StringLiteral') { valueExpr = types.booleanLiteral(true) // class:active="anything" treated as always on } else if (value.type === 'JSXExpressionContainer') { valueExpr = value.expression }
// Collect this class: directive for post-element-creation emission // (mark on the JSXElement to be processed when emitting jsx() call) markForPostCreation(path.parent, { type: 'class', className, valueExpr, })
path.remove() // strip the class: attribute itself}Then when emitting the JSXElement:
// Wrap jsx() call in IIFE that runs post-creation effects:((el) => { effect(() => el.classList.toggle('active', !!isActive())) effect(() => el.classList.toggle('loading', !!isLoading())) return el})(jsx('button', { class: 'btn' }))Same IIFE pattern used by use: directives.
Edge cases
Section titled “Edge cases”Combining with phaze:class:
<button phaze:class="btn primary" class:active={isActive()}>This is supported but discouraged. phaze:class would write the entire className reactively, potentially overwriting class:active’s toggle. The fix is to use static class="btn primary" instead of phaze:class when combining with class: directives. Document this in the user-facing docs; don’t error on it.
Empty / no-value form:
<button class:active>Submit</button>JSX allows attribute with no value (booleans). Treat as class:active={true} — always on. Useful only in rare cases but should not crash.
Kebab-case class names:
<div class:is-loading={isLoading()}>The local name is-loading is parsed by Babel as a valid JSXIdentifier in namespace position. The string 'is-loading' is passed to classList.toggle verbatim.
Reserved JS keywords as class names:
<div class:new={isNew()}> // 'new' is a JS keyword<div class:default={isDefault()}> // 'default' is a JS keywordThese are valid because they’re JSX identifier parts, not JS identifiers. The compiler should pass them through as string literals to classList.toggle. Verify Babel’s parser handles this correctly (it should).
Test cases
Section titled “Test cases”Add these to phaze-compile’s test suite:
// Test 1: Single class: directive<button class="btn" class:active={cond}>x</button>// Expected output includes:// effect(() => el.classList.toggle('active', !!cond))
// Test 2: Multiple class: directives<div class="card" class:fav={a} class:expanded={b} class:warn={c}>x</div>// Expected output includes three separate effect() calls
// Test 3: Static class + class: combine<button class="btn primary" class:active={cond}>x</button>// Static class 'btn primary' preserved on initial render
// Test 4: Kebab-case class name<div class:is-loading={cond}>x</div>// Expected output: classList.toggle('is-loading', !!cond)
// Test 5: Boolean attribute form<button class:active>x</button>// Expected output: classList.toggle('active', true)
// Test 6: Signal directly as value<button class:active={isActive}>x</button> // isActive is a signal// Expected: effect(() => el.classList.toggle('active', !!isActive()))// (signal call inside effect for tracking)Bundle size impact
Section titled “Bundle size impact”Each class: directive adds:
- ~15 bytes per use site for the inline
effect(() => el.classList.toggle('name', !!expr))call - Zero runtime additions (uses existing
effectand nativeclassList.toggle) - Zero compile-time additions to the bundle (phaze-compile ships zero bytes)
Same per-use cost as use: directives. Negligible across a real app.
Part 2: The bind: namespace (minimal scope)
Section titled “Part 2: The bind: namespace (minimal scope)”Scope (what’s supported and what’s not)
Section titled “Scope (what’s supported and what’s not)”Supported:
| Pattern | Element type | Event |
|---|---|---|
bind:value={signal} | <input type="text"> and other text-like inputs (email, password, search, url, tel) | input |
bind:value={signal} | <textarea> | input |
bind:checked={signal} | <input type="checkbox"> | change |
NOT supported (use the manual form):
| Pattern | Why not supported | Manual alternative |
|---|---|---|
bind:value on <input type="number"> | Needs valueAsNumber coercion | value={n} on:input={(e) => n.set(+e.currentTarget.value)} |
bind:value on <input type="date"> | Needs Date parsing/formatting | value={d} on:input={(e) => d.set(new Date(e.currentTarget.value))} |
bind:value on <select> | Needs property access, <select multiple> is array | value={s} on:change={(e) => s.set(e.currentTarget.value)} |
bind:value on <input type="radio"> | Group semantics; one value across multiple inputs | Group of <input on:change={...} /> reading from same signal |
bind:group={signal} | Radio button group binding | Manual radio pattern |
bind:files, bind:open, bind:clientWidth, etc. | Specialized bindings with separate semantics | Manual property reads |
The minimal scope handles the two most common cases (text input, checkbox) which cover ~80% of real-world two-way binding use. Anything else uses the explicit manual form, which never silently breaks.
Syntax
Section titled “Syntax”<input type="text" bind:value={name} /><textarea bind:value={bio} /><input type="checkbox" bind:checked={remember} />The signal flows both ways: signal changes update the input; user input updates the signal.
Semantics
Section titled “Semantics”bind:valueon text inputs: treatssignal()as the current value (pass-through), listens forinputevent, callssignal.set(e.currentTarget.value)to write back.bind:checkedon checkboxes: treatssignal()as the current checked state, listens forchangeevent, callssignal.set(e.currentTarget.checked)to write back.- The runtime guards against re-entrancy. When the signal is updated from the user input event, the binding’s read side won’t trigger a redundant write back to the DOM because the value is already correct (and the runtime’s
setAttributedoes an equality check). - The value is always a
Signal<string>orSignal<boolean>. No automatic coercion. If you have aSignal<number>, use the manual form with explicit+coercion.
Compiler transformation
Section titled “Compiler transformation”For bind:value:
<input type="text" bind:value={name} />Emits (approximate):
const _el = jsx('input', { type: 'text', value: name }) // signal pass-through for readlisten(_el, 'input', (e) => name.set(e.currentTarget.value)) // write on input eventFor bind:checked:
<input type="checkbox" bind:checked={remember} />Emits:
const _el = jsx('input', { type: 'checkbox', checked: remember })listen(_el, 'change', (e) => remember.set(e.currentTarget.checked))Visitor sketch
Section titled “Visitor sketch”JSXAttribute(path) { if (path.node.name.type !== 'JSXNamespacedName') return if (path.node.name.namespace.name !== 'bind') return
const property = path.node.name.name.name // 'value' or 'checked' const value = path.node.value
if (property !== 'value' && property !== 'checked') { throw path.buildCodeFrameError( `bind:${property} is not supported. Phaze's bind: namespace handles ` + `bind:value (text inputs, textarea) and bind:checked (checkboxes) only. ` + `Use the manual form for other cases: value={signal} on:input={...} or similar.` ) }
if (!value || value.type !== 'JSXExpressionContainer') { throw path.buildCodeFrameError(`bind:${property} requires a signal as its value`) }
const signalExpr = value.expression const event = property === 'checked' ? 'change' : 'input' const dotProp = property === 'checked' ? 'checked' : 'value'
// Replace bind: with regular attribute (signal pass-through): const replacement = types.jsxAttribute( types.jsxIdentifier(property), // value= or checked= types.jsxExpressionContainer(signalExpr) ) path.replaceWith(replacement)
// Mark for post-creation listen() call: markForPostCreation(path.parent, { type: 'bind', event, dotProp, signalExpr, })}Then when emitting the JSXElement:
((el) => { listen(el, 'input', (e) => name.set(e.currentTarget.value)) return el})(jsx('input', { type: 'text', value: name }))Error handling
Section titled “Error handling”The compiler should produce clear compile-time errors for unsupported bind:* variants. Don’t silently emit broken code. Examples:
<input type="number" bind:value={age} />// Error: bind:value on <input type="number"> is not supported.// Use the manual form with explicit coercion:// <input type="number" value={age} on:input={(e) => age.set(+e.currentTarget.value)} />Note: detecting the type attribute requires the compiler to look at sibling attributes on the JSXOpeningElement. This works only for static type="..." literals — if the type is dynamic (type={someSignal}), the compiler can’t validate. In the dynamic case, emit the basic bind:value transform (string-typed) and trust the user knows what they’re doing.
<select bind:value={role} />// Error: bind:value on <select> is not supported. Use the manual form:// <select value={role} on:change={(e) => role.set(e.currentTarget.value)}><input bind:group={favorite} value="apple" />// Error: bind:group is not supported. Use a radio button group with manual on:change handlers.The error messages should be specific, point at the manual alternative, and not just say “unsupported.” Save the user the trip to docs.
Test cases
Section titled “Test cases”// Test 1: bind:value on text input<input type="text" bind:value={name} />// Expected output:// const el = jsx('input', { type: 'text', value: name })// listen(el, 'input', (e) => name.set(e.currentTarget.value))
// Test 2: bind:value on textarea<textarea bind:value={bio} />// Expected: same shape as text input
// Test 3: bind:checked on checkbox<input type="checkbox" bind:checked={remember} />// Expected output:// const el = jsx('input', { type: 'checkbox', checked: remember })// listen(el, 'change', (e) => remember.set(e.currentTarget.checked))
// Test 4: bind:value on number input — should error<input type="number" bind:value={age} />// Expected: compile error with message pointing at manual form
// Test 5: bind:value on select — should error<select bind:value={role}><option>a</option></select>// Expected: compile error
// Test 6: bind:group — should error<input type="radio" bind:group={favorite} />// Expected: compile error
// Test 7: dynamic type (warning, not error)<input type={inputType} bind:value={text} />// Expected: basic transform emitted (string handling), no error// Possibly emit a console.warn at runtime via the compiler? Or just trust the user.
// Test 8: Combining bind:value with on:input — caller wants explicit access too<input bind:value={text} on:input={(e) => console.log(e.currentTarget.value)} />// Expected: both wire up; both listeners fire on input.// Order: framework listener (from bind:) fires, then user's on:input.Bundle size impact
Section titled “Bundle size impact”Each bind: directive adds:
- ~25 bytes per use site for the
listen()call (slightly larger thanclass:because it includes the event name string and the handler closure) - Zero new runtime functions (uses existing
listenfrom main phaze) - Zero compile-time bundle additions
Same order of magnitude as class:. Negligible.
Part 3: Integration with existing namespaces
Section titled “Part 3: Integration with existing namespaces”The four-namespace system after this lands:
| Namespace | Job | Status |
|---|---|---|
phaze:attr | Reactive attribute expressions | Proposed (in dsl_directive.md) |
on:event | Native DOM event listeners | Proposed (in dsl_directive.md) |
use:directive | DOM behavior attachments | Proposed (in dsl_directive.md) |
class:name | Conditional class addition | New (this doc) |
bind:value, bind:checked | Two-way input binding (minimal scope) | New (this doc) |
All five share the same architectural model:
- Compile-time recognition (Babel plugin scoped to specific namespace prefixes)
- Per-namespace visitor logic
- Zero runtime additions
- Zero bundle delta vs hand-written equivalents
The compiler implementation should reuse the same markForPostCreation mechanism (or equivalent) across use:, class:, and bind: — all three emit code that runs after element creation.
Order of post-creation calls
Section titled “Order of post-creation calls”When an element has multiple post-creation directives (use:, class:, bind:), they should fire in source order:
<input type="text" class="input" use:autofocus={true} class:invalid={!valid()} bind:value={text}/>Should emit:
((el) => { autofocus(el, () => true) effect(() => el.classList.toggle('invalid', !valid())) listen(el, 'input', (e) => text.set(e.currentTarget.value)) return el})(jsx('input', { type: 'text', class: 'input', value: text }))Source order. Don’t get clever about reordering.
Part 4: Updates to other docs
Section titled “Part 4: Updates to other docs”dsl_directive.md
Section titled “dsl_directive.md”Add to the JSX Namespaces table in Part 3:
| Namespace | Purpose | Compiler behavior |
|---|---|---|
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 inputs, textarea, checkbox only) | Emits signal pass-through + listen() post-creation |
Add a brief section after use: documenting both, with examples.
rejected_patterns.md
Section titled “rejected_patterns.md”Move section 5 (bind: JSX namespace) and remove the implicit-rejection language for class: (which wasn’t explicitly in the doc but the spirit was that template-string conditionals were the answer).
Update section 5 to read something like:
Originally rejected. The full Svelte-compatible
bind:would require covering many input types (number, date, select, radio, file, etc.) each with their own coercion and event semantics. The original rejection assumed all-or-nothing.Reconsidered with minimal scope. Shipping only
bind:value(text inputs, textarea) andbind:checked(checkboxes) covers the bulk of real usage. Type-fiddly variants stay manual. Seeclass_bind_implementation.mdfor the spec.Still rejected: the full Svelte-compatible variant,
bind:group,bind:files,bind:clientWidth, and any “implicit” two-way binding outside the two supported cases above. Use manual form for those.
Add a similar entry noting class: was rejected by implication (the template-string-conditional pattern was treated as sufficient) but is now planned.
Part 5: Implementation checklist
Section titled “Part 5: Implementation checklist”For the implementer (Claude Code or human):
Compiler changes (phaze-compile)
Section titled “Compiler changes (phaze-compile)”- Add
JSXAttributevisitor branch forclass:namespace - Add
JSXAttributevisitor branch forbind:namespace - Implement post-creation IIFE wrapping (or reuse from
use:if shipped) - Add compile-time validation for unsupported
bind:variants with helpful error messages - Add unit tests covering all test cases above
Runtime changes (@madenowhere/phaze)
Section titled “Runtime changes (@madenowhere/phaze)”- None. Both namespaces use existing
effect,listen,classList.toggle. Verify no runtime additions slip in.
Documentation updates
Section titled “Documentation updates”- Update
dsl_directive.mdPart 3 with new namespaces - Update
rejected_patterns.mdsection 5 with reconsideration note - Add user-facing docs section (probably in the main docs site) showing the new patterns
- Update the Card build test in
dsl_directive.mdto useclass:for at least one example (showing real-world usage)
Verification
Section titled “Verification”- Compile the Card build test from
dsl_directive.mdwith the changes - Verify bundle size hasn’t grown beyond expected (~15 bytes per
class:, ~25 bytes perbind:use site) - Verify runtime behavior end-to-end: typing in input updates signal, signal updates propagate to input, conditional classes toggle correctly
- Verify compile errors fire for
bind:valueon<input type="number">,<select>, etc.
Out of scope for this PR
Section titled “Out of scope for this PR”- Full Svelte-compatible
bind:value(number, date, file, etc.) bind:groupfor radiosbind:clientWidth,bind:innerHTML, and other dimensional/property bindings- A configurable option for “enable full bind: at user’s risk” — keep the minimal scope strict to avoid feature creep
Quick comparison reference
Section titled “Quick comparison reference”For docs and PR description, here’s the before/after comparison that justifies the work:
Multiple conditional classes — without class::
<article phaze:class={ `card${isFav() ? ' fav' : ''}${expanded() ? ' expanded' : ''}${lowStock() ? ' warn' : ''}${isLoading() ? ' loading' : ''}` }>With class::
<article class="card" class:fav={isFav()} class:expanded={expanded()} class:warn={lowStock()} class:loading={isLoading()}>Two-way input binding — without bind::
<input type="text" value={name} on:input={(e) => name.set(e.currentTarget.value)}/>
<input type="checkbox" checked={remember} on:change={(e) => remember.set(e.currentTarget.checked)}/>With bind: (minimal scope):
<input type="text" bind:value={name} /><input type="checkbox" bind:checked={remember} />Same end behavior in both cases. The class: form removes template-string fragility; the bind: form removes 1 line per input.
Final note
Section titled “Final note”These additions don’t replace the existing namespaces. phaze:class={...} remains the way to set an entirely conditional class string (e.g., phaze:class={theme()}). value={signal} on:input={...} remains the way to handle non-text-input two-way binding (number, date, select). The new namespaces are additions for specific common cases, not replacements for the more general forms.
Total compiler additions: ~40 lines for both namespaces combined. Zero runtime additions. Zero bundle delta for non-users. The work is small; the ergonomic win is concentrated in exactly the patterns where Phaze code currently feels clunkiest.