A SPA-first JavaScript framework with single-file components, reactive data binding, and Go-based compilation.
▶ Live demo — the music example app built with Puzzle.
- ~200 ms production builds — compile, bundle, Tailwind, and minify, end to end (todos example, Apple Silicon)
- 18 KB gzipped apps — the complete todos example (runtime, router, store, and views) ships at 18.3 KB gzip / 55.9 KB raw
- Zero JavaScript toolchain — the CLI is one prebuilt Go binary; no Babel, no bundler config, no postinstall scripts
Install the puzzle CLI once (a prebuilt Go binary — no toolchain needed):
npm install -g @magic-spells/puzzleThen scaffold and run a new app:
puzzle init my-app
cd my-app
npm install
puzzle dev # develop with live reload
puzzle build # build for productionOr add Puzzle to an existing project — one dev dependency gives you both the client runtime and the CLI:
npm install -D @magic-spells/puzzleStatus: 0.1.0 — the first public release. The browser runtime, Go compiler, static generator, and CLI are implemented and covered by Go, Vitest/jsdom, type, package, example, and browser-focused checks.
constellation/doc/DOC-SPEC.md is the canonical, frozen v1 contract — its per-amendment sections (§12–§41) are the source of truth for exactly what shipped when; if anything here conflicts with it, the spec wins.
- Single-file components (
.pzl) with template + scripts + styles — optional TypeScript (<script lang="ts">), scoped styles (<style scoped>), skeletons, comments, slots, and refs - Reactive data with automatic view updates
- Model/store architecture with adapters, relationships, schema validation, persistence, and write sync
- Chainable display formatters —
{ title | downcase | truncate(40) } - Nested routing with view slots — history, hash, and memory modes; scroll restoration; base paths; anchors; mode-agnostic path-shaped hrefs via the built-in
linkformatter - Virtual DOM with efficient diffing and pk-aware list keying
- Built-in view & component animations (Web Animations API), including visibility-triggered enters and app lifecycle hooks
- Route transitions: sequential by default; overlapping cross-fades and shared-element morphs (experimental — see below)
- Go-based compiler for fast builds and state-preserving live reload (store and JSON-safe local view state survive edits)
- SPA-first output with two optional prerender modes —
output: 'hybrid'(prerendered pages the SPA takes over) andoutput: 'static'(true static pages, no router orapp.js); no request-time SSR server or hydration layer - Puzzle Pieces component library — ready-made
.pzlcomponents installed withpuzzle add piece <name>(browse the catalog)
Experimental in 0.1.0: overlapping route transitions (
transitionMode: 'overlap') and shared-element morph transitions (@magic-spells/puzzle/morph) work and are tested individually, but their interaction matrix with other opt-in features (nested reused layouts, hash/base-path routing, anchors) has had less real-world mileage than the core. They're safe to try — just expect rougher edges there, and prefer the default sequential transitions where stability matters most.
Install Puzzle as a dev dependency. This one package gives you both the client
runtime (import { PuzzleView } from '@magic-spells/puzzle') and the puzzle CLI:
npm install -D @magic-spells/puzzleThe CLI is a prebuilt Go binary delivered through per-platform optional
dependencies (no compiler toolchain, no postinstall step). Prebuilt binaries ship
for macOS (arm64, x64) and Linux (x64, arm64); npm downloads only the one
matching your machine. Once installed, puzzle is on your PATH for npm scripts:
With the CLI installed globally (npm install -g @magic-spells/puzzle):
puzzle init my-app
cd my-app
npm install
npm run devThe generated app depends on @magic-spells/puzzle locally, so collaborators
who clone it only need npm install — no global CLI required.
The prebuilt binary covers macOS and Linux. On any other platform — or if you prefer to build the CLI yourself — install it from source with Go:
go install github.com/magic-spells/puzzle/compiler/cmd/puzzle@latestmy-puzzle-app/
├── app/
│ ├── app.js # App initialization
│ ├── routes.js # Route definitions
│ ├── models/ # Optional models and adapters
│ ├── views/ # Routed .pzl views
│ ├── components/ # Reusable .pzl components
│ ├── layouts/ # Route layouts with <Slot/>
│ ├── assets/ # Source assets, including {#svg} files
│ ├── styles/ # Tailwind entry + global CSS
│ └── public/ # Static assets + index.html
├── puzzle.config.js # Compiler, styles, and output config
└── package.json
<p>{ user.name }</p>
<h1>{ title | capitalize }</h1>{## Single-line hash comments ##} {## Multi-line hash comments work too These
are useful for documentation ##}
<div class="user-card">
{## TODO: Add user avatar ##}
<h2>{ user.name }</h2>
{#comment}
<div class="temporarily-disabled">
<p>{ user.bio }</p>
</div>
{/comment}
</div><!-- Conditionals with {:else if} chaining -->
{#if loggedIn}
<p>Welcome back!</p>
{:else if loading}
<p>Loading...</p>
{:else}
<p>Please log in</p>
{/if}
<!-- Loops -->
{#for item in items}
<li>{ item.name }</li>
{/for}
<!-- Multi-branch; a {:when} takes comma-separated values, matching any of them -->
{#case status}
{:when 'loading', 'paused'}
<LoadingSpinner />
{:when 'error', 'no-connection'}
<ErrorMessage />
{:else}
<SuccessContent />
{/case}
<!-- Inverted conditional -->
{#unless user.isAdmin}
<p>Access denied</p>
{/unless}See constellation/doc/DOC-TEMPLATE-SYNTAX.md for the full grammar.
<!-- Event handlers -->
<button @click={ handleClick }>Click me</button>
<form @submit={ handleForm(event) }>
<!-- Controlled form property; the handler updates component/store state -->
<input value={ searchQuery } @input={ updateSearch(event) } />
<select value={ selectedOption }></select>
</form>
<!-- Event modifiers: prevent / stop / once + key filters, and they stack -->
<input @keydown:enter={ handleSubmit } @keydown:escape:prevent={ cancelEdit } />
<button @click:once={ claimReward }>Claim</button>Event modifiers (prevent, stop, once, and key filters like :enter/:escape) stack; the canonical order is key-gate → once-spend → preventDefault → stopPropagation → handler. See constellation/doc/DOC-SPEC.md §5.
<!-- Layout slot: the routed view renders at <Slot/> -->
<div class="user-layout">
<nav><!-- user navigation --></nav>
<Slot />
<!-- routed view renders here -->
</div>
<!-- Named component slots: static slot="name" on a direct child -->
<Card>
<h2 slot="header">Card Title</h2>
<p>Card content</p>
<!-- no slot attr → default slot -->
</Card>Named slots. The child declares regions with <Slot name="header"/>, and the call site routes a direct child into one with a static slot="header" attribute (stripped from the rendered output). An unfilled marker renders its fallback body — the content between paired marker tags — or nothing when self-closing; routed views fill the default marker only. See constellation/doc/DOC-SPEC.md §24.
Reusable components declare default child content with <Children/>, and a paired marker's body is the fallback shown when the caller supplies nothing:
<article class="card">
<Children/>
<footer><Slot name="footer"><button>OK</button></Slot></footer>
</article>Formatters transform data for display without modifying the underlying values.
They chain left to right with |, so each one receives the previous result:
{ title | downcase | replace('-', ' ') }
<!-- "My-Blog-Post" → "my blog post" -->
{ post.body | trim | truncate(140) | capitalize }
<!-- Chains can be any length; arguments go in parentheses -->An unregistered formatter name never crashes a render — the value passes
through that step unchanged and a single console.error names the offender.
{ text | trim }
<!-- Remove whitespace -->
{ name | capitalize }
<!-- First letter uppercase -->
{ title | upcase }
<!-- ALL UPPERCASE -->
{ title | downcase }
<!-- all lowercase -->
{ content | truncate(100) }
<!-- Limit to 100 chars -->
{ slug | replace('-', ' ') }
<!-- Replace characters -->{ price | currency('$', 2) }
<!-- $19.99 -->
{ progress | percentage }
<!-- 75% -->
{ count | number_with_delimiter }
<!-- 1,234,567 -->
{ rating | round(1) }
<!-- 4.3 -->{ names | join(', ') }
<!-- Join with commas -->{ createdAt | date('long') }
<!-- January 15, 2024 -->
{ updatedAt | date('short') }
<!-- 1/15/24 -->
{ publishedAt | timeago }
<!-- 2 hours ago -->{ html | raw }
<!-- Skips entity escaping; still renders as text, not injected HTML -->
{ obj | json }
<!-- JSON stringify --><!-- Button.pzl -->
<puzzle-view>
<button
class="btn { variantClass } { sizeClass }"
@click={ handleClick }
disabled={ disabled }>
{#if icon}
<Icon name={ icon } />
{/if}
<Children />
</button>
</puzzle-view>
<script>
import { PuzzleView } from '@magic-spells/puzzle';
import Icon from './Icon.pzl';
export default class Button extends PuzzleView {
data(params, props) {
const variant = props.variant || 'default'
const size = props.size || 'medium'
const icon = props.icon || null
const disabled = !!props.disabled
return {
variant,
size,
icon,
disabled,
variantClass: `btn--${variant}`,
sizeClass: `btn--${size}`
}
}
// `click` is a callback prop: a parent writes <Button @click={ handler }>
// and the compiler hands the child a function on this.props.click. There is
// no this.$emit — the child gates the event, the parent's function does the work.
events = {
handleClick: (event) => {
const { disabled } = this.getData()
const { click } = this.props
if (!disabled && typeof click === 'function') {
click(event)
}
}
}
}
</script>
<style>
.btn { padding: 0.75rem 1.5rem; border: 1px solid transparent; border-radius:
0.5rem; cursor: pointer; transition: all 0.2s; } .btn--primary { background:
var(--primary-color); color: white; } .btn--medium { font-size: 1rem; }
</style>Component imports live in <script> and can be relative or use @, the
built-in alias for your app/ directory — no configuration, works from any
depth:
import Icon from './Icon.pzl'; // relative
import Icon from '@/components/Icon.pzl'; // app/components/Icon.pzlPuzzle Pieces is the official
component library for Puzzle — ready-made .pzl components (and their styles)
you can drop into any app:
puzzle add piece <name>Preview every piece in the live catalog at magic-spells.github.io/puzzle-pieces.
Editor extensions provide full .pzl highlighting — native HTML, JavaScript/TypeScript, and CSS per section, plus Puzzle's template expressions, directives, event bindings, and formatter chains:
- puzzle-vscode - Visual Studio Code extension with snippets and completions
- puzzle-sublime - Sublime Text 4 syntax package
Install instructions are in each repository's README.
- User Guide - Complete guide to building Puzzle applications
- Component Reference - Complete .pzl component documentation
- Data Layer - Models, adapters, and store management
- Build Process - Compiler and build system details
This repository uses Constellation MCP
as long-term project memory for AI-assisted development. The cards in
constellation/ preserve decisions, features, data structures, component and
file relationships, flows, and plans so future AI conversations can recover the
full project context and build better plans without re-deriving earlier work.
# Development server with live reload
puzzle dev --port 3000
# Production build (default)
puzzle build
# True static pages (no router, no app.js; per-page mount module)
puzzle build --static
# Prerendered pages plus the SPA bundle the router takes over
puzzle build --hybrid
# Upgrade the installed CLI, or only check what is available
puzzle upgrade
puzzle upgrade --check
# Install the Puzzle agent skill for your coding tools, or refresh it later
puzzle add skills
puzzle upgrade skillsBoth commands are built and verified today. puzzle build compiles .pzl files and produces a working bundle; puzzle dev watches app/, rebuilds on change, and delivers full-page live reload over SSE (the reload client is injected into index.html at serve time). Both run the declared style pipeline automatically — styles: { use: ['tailwindcss'] } in puzzle.config.js (tailwindcss-only in v1) — so Tailwind output is included in the served/built styles.css.
On an interactive terminal, build and dev also use a cached, non-blocking
daily check to mention newer Puzzle releases. Set PUZZLE_NO_UPDATE_CHECK=1 to
disable it; the check is skipped automatically when CI is set.
puzzle upgrade updates a project or global package-manager install;
puzzle upgrade --check only reports the current and latest versions.
puzzle add skills installs the Puzzle agent skill — how to write .pzl files,
routing, the data layer, static output — into every Claude Code, Codex, and
Cursor config directory it finds (~/.claude, ~/.codex, ~/.cursor). On an
interactive terminal you pick the targets from a checklist; scripts install to
all of them.
The skill is compiled into the CLI binary, so it always matches the version that wrote it. Each install records which version that was, so re-running the command is how you refresh it:
- An install matching your current CLI is skipped as up to date.
- An older one asks before it is replaced. Declining leaves it alone but still installs anywhere that has no skill yet.
- On a non-interactive terminal an older install is refused rather than asked
about, and needs
--overwrite. - A symlinked install (a dev checkout linked into your config dir) is reported
and left alone unless you pass
--overwrite.
puzzle upgrade skills refreshes only the installs you already have, and
puzzle upgrade offers the same refresh automatically after it installs a new
version.
The full CLI surface (see constellation/doc/DOC-SPEC.md §13): init, generate, add, doctor, and info join dev and build.
# Scaffold a project; omitting the name prompts only in an interactive terminal
puzzle init my-app --template todos
# Generate a stub (component, view, layout, or model)
puzzle generate component UserCard --path components/ui/
# Wire up Tailwind, install a piece (see Puzzle Pieces above), or run diagnostics
puzzle add tailwind
puzzle add piece <name>
puzzle add skills
puzzle doctorPuzzle is released under the MIT License.
Made by Cory Schulz