npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@magic-spells/puzzle

v0.9.0

Published

SPA-first JavaScript framework — single-file .pzl components, reactive data() components, Go + esbuild compiler

Readme

Puzzle

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)
  • Small apps, honestly measured — a minimal app is 23.0 KB gzip with the router, store, and validation included, not just a view layer; the complete todos example ships at 27.0 KB gzip. Regenerated every release by npm run measure:size
  • Zero JavaScript toolchain — the CLI is one prebuilt Go binary; no Babel, no bundler config, no postinstall scripts

Quick start

Install the puzzle CLI once (a prebuilt Go binary — no toolchain needed):

npm install -g @magic-spells/puzzle

Then 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 production

Or add Puzzle to an existing project — one dev dependency gives you both the client runtime and the CLI:

npm install -D @magic-spells/puzzle

Status: 0.9.0 — the current 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.

Puzzle is pre-1.0, so 0.x minor bumps may carry breaking changes — caret ranges do not cross them. See CHANGELOG.md before upgrading.

constellation/doc/DOC-SPEC.md is the canonical, frozen v1 contract — it indexes the numbered sections (§1–§67) across six domain cards, and they are the source of truth for exactly what ships; if anything here conflicts with it, the spec wins.

Features

  • Single-file components (.pzl) with template + scripts + styles — optional TypeScript (<script lang="ts">), scoped styles (<style scoped>), skeletons, comments, slots, and refs
  • Snippets — <Snippet fits="row" user>…</Snippet> hands a component a template it can stamp repeatedly with its own data; parameters are declared as bare attributes, and a snippet forwards through a wrapper's <Children/>
  • Component families — related components import as one unit and invoke with dot notation (<Frame.Wrapper>), scaffolded by puzzle generate component Frame --family Wrapper,Content
  • Reactive data with automatic view updates
  • Two-way form binding with no directive — value={ draft } and checked={ todo.completed } read and write; the compiler synthesizes the handler, so there is no bind: prefix and no mirror handler to maintain
  • Model/store architecture with adapters, relationships, schema validation, persistence, and read/write server sync — opt-in via the @magic-spells/puzzle/adapter subpath (local-only apps ship none of it)
  • Server data with no loading code — findOne/findMany inside a view's data() fetch whatever the store is missing and settle before the view commits, so a committed null always means "does not exist", never "still loading"
  • Template expressions that are JavaScript — { truncate(title.toLowerCase(), 40) }, {#for t in todos.filter(t => !t.done)}: one closed grammar and method table, parsed once and shared with Magic Spells Sites, with display functions called like any function
  • Translations — { t('cart.title') } with one app/locales/<tag>.json per language: nested files, CLDR plurals through Intl.PluralRules, missing keys filled from the default at build time, and only the active locale's hashed file downloaded; this.ctx.i18n.setLocale('es') switches in place. Apps without i18n configured ship none of it
  • Multilingual static sites — i18n.routing: 'prefix' gives every non-default language its own URLs (/es/about) and its own prerendered pages, with hreflang alternates, a sitemap.xml once site is set, a first-visit language redirect, <html dir> for right-to-left locales and translated page titles
  • Raw template blocks — {#raw}…{/raw} turns off template-expression parsing so JSON, JavaScript, CSS, and syntax examples with literal braces compile as-is (HTML inside still renders normally)
  • Nested routing with view slots — path routing by default, hash/memory via hashRouter()/memoryRouter() from @magic-spells/puzzle/router-modes; scroll restoration; base paths; anchors; mode-agnostic path-shaped hrefs via the built-in link function
  • Prerendered :param routes — staticPaths lists a route's pages at build time (/blog/:slug becomes one page per post, per language), as an array, a function, or a model name
  • Runtime component selection — <Component is={ cards[embed.type] }> picks one of the constructors the file imports; no registry, no runtime module lookup
  • Hooks for tests and tools — router.push() resolves a NavigationResult instead of rejecting, router.views lists every page (with an optional agent description per route), store.observe() watches records from outside a view, and await app.idle() waits for the app to go quiet
  • On-demand route views — view: lazy(() => import('./views/Admin.pzl')) in the route table downloads a view or layout the first time a navigation needs it, guards first
  • Virtual DOM with efficient diffing, pk-aware list keying, and {#for} rows and static markup cached between renders
  • 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)
  • App-level error handling — one compiled errorView replaces a failed view in place with { error, info, retry }; the onError hook funnels every framework-contained error
  • Go-based compiler for fast builds and state-preserving live reload (store and JSON-safe local view state survive edits)
  • Type-checked templates — puzzle check runs the app's own TypeScript over every .pzl, so a typo in { user.nmae } is a type error reported at its real line and column
  • SPA-first output with two optional prerender modes — output: 'hybrid' (prerendered pages the SPA takes over) and output: 'static' (true static pages, no router or app.js); no request-time SSR server or hydration layer
  • Puzzle Pieces component library — ready-made .pzl components installed with puzzle add piece <name> (browse the catalog)

Experimental: 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.

Installation

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/puzzle

The 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), Linux (x64, arm64), and Windows (x64); npm downloads only the one matching your machine. Windows-on-ARM runs the x64 binary under emulation. Once installed, puzzle is on your PATH for npm scripts:

// package.json
{
  "scripts": {
    "dev": "puzzle dev",
    "build": "puzzle build"
  }
}

Scaffold a new project

With the CLI installed globally (npm install -g @magic-spells/puzzle):

puzzle init my-app
cd my-app
npm install
npm run dev

The generated app depends on @magic-spells/puzzle locally, so collaborators who clone it only need npm install — no global CLI required.

For a TypeScript app, answer yes to the TypeScript prompt or pass the flag:

puzzle init my-app --typescript
cd my-app
npm install
npm run check   # type-check with puzzle check
npm run dev

Every component is <script lang="ts"> with typed data(), props, events and lifecycle hooks, the modules are .ts — the build entry app/app.ts, routes.ts, the todos template's models — and package.json adds typescript plus a check script. The build starts from app/app.ts when it exists, otherwise app/app.js, and refuses an app that has both. It strips types without checking them; npm run check is the type check, under the strict tsconfig.json the scaffold writes. In an app with that tsconfig.json, puzzle generate writes TypeScript too: <script lang="ts"> stubs, app/models/<name>.ts, and an index.ts family barrel.

Other platforms, or building from source

The prebuilt binaries cover macOS, Linux, and Windows. On any other platform — or if you prefer to build the CLI yourself — build it from a checkout with Go, then put the puzzle binary on your PATH:

git clone https://github.com/magic-spells/puzzle
cd puzzle/packages/puzzle/compiler && go build -o puzzle ./cmd/puzzle

On Windows use -o puzzle.exe.

Project Structure

my-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

Template Syntax

Basic Interpolation

<p>{ user.name }</p>
<h1>{ capitalize(title) }</h1>
<p>{ items.length } items, { currency(price * quantity) }</p>
<p>{ nickname ?? name } · { tags.join(', ') }</p>
<ul>{#for t in todos.filter(t => !t.done)}<li>{ t.title }</li>{/for}</ul>

A template expression is JavaScript from a closed table, so it means the same thing when PuzzleKit compiles it to JavaScript and when Magic Spells Sites evaluates it in Go. The grammar: literals, template literals, arrays and objects; a.b, a?.b, a[i]; the operators with JavaScript precedence (+ - * / %, comparisons, == != === !==, && || !, ??, ?:); arrow functions as call arguments (items.map(i => i.name)); and three kinds of call — a display function by name (currency(price)), a method from the table on a string, array or number (name.trim(), items.filter(…), n.toFixed(2)), and the globals Math.*, Number, String, Boolean, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, encodeURI, decodeURI, Array.isArray and Object.keys/values/entries. The count is .length. No method mutates (toSorted and toReversed, not sort and reverse), and anything outside the table — new Date(), Date.now(), JSON.stringify(x), **, bitwise operators, spread, regex literals, and window, document or globalThis read as a value — is a compile error at its own line and column that names the replacement. Member reads are guarded: { user.address.city } prints nothing when address is missing.

A template never reaches the view instance: this is a compile error in every template expression, @event handler arguments included. Every value a template shows comes through data(), and a handler reaches the view through its own name (@click={ save(items.length - 1) } calls the view's save). An @event value is always such a call (or the handler's bare name); inside its arguments event is the DOM event and a chain rooted at it is unrestricted — @input={ rename(event.target.value) }. Work on the event itself, such as event.preventDefault(), goes inside the handler method or through the :prevent modifier.

A literal brace is escaped with a backslash — \{ and \} — anywhere an expression could appear, attribute values included:

<input pattern="[0-9]\{5\}" />
<p>Use \{ name \} to interpolate.</p>

For a whole block of literal braces (JSON, CSS, a syntax example) use {#raw}…{/raw} instead — but {#raw} is not allowed inside an attribute value, so the escape is the only way out there.

Comments

{## 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>

Control Flow

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

Events & Binding

<!-- Event handlers -->
<button @click={ handleClick }>Click me</button>
<form @submit={ handleForm(event) }>
  <!-- Two-way: reads searchQuery AND writes the user's edit back. No handler. -->
  <input value={ searchQuery } />
  <select value={ selectedOption }></select>
</form>

<!-- A one-member path writes the record itself, through validated update() -->
<input type="checkbox" checked={ todo.completed } />

<!-- Event modifiers: prevent / stop / once / passive / capture + key filters, and they stack -->
<input @keydown:enter={ handleSubmit } @keydown:escape:prevent={ cancelEdit } />
<button @click:once={ claimReward }>Claim</button>
<div @wheel:passive={ onScroll }>…</div>

Two-way binding (D147): value= and checked= on a plain <input>, <textarea>, or <select> bind in both directions when the expression is a bare identifier or a one-member path — the compiler synthesizes the write-back handler. A bare identifier writes local state; a path writes the record through validated update(). Opt out with your own @input/@change, a non-path expression (value={ x ?? '' }), or a static readonly. Handlers on other events (@blur, @keydown:enter) coexist with the bind.

Event modifiers (prevent, stop, once, and key filters like :enter/:escape) stack; the canonical order is key-gate → once-spend → preventDefault → stopPropagation → handler. passive and capture set the native listener options (D187); passive with prevent is a compile error.

Dynamic URL attributes (href={ url }, src="{ path }", …) never carry javascript: or vbscript:, and data: only in image positions: a blocked value leaves the attribute off, in the browser and in prerendered HTML alike, with a warning in development (D186). Static attributes you write yourself are left as written. See constellation/doc/DOC-SPEC.md §5.

Slots & Nested Routing

<!-- 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>

Display Functions

A display function transforms a value for display without modifying the underlying data. It is called like any function, and calls nest:

{ truncate(capitalize(post.body.trim()), 140) }
<!-- trim, capitalize, then cut to 140 characters -->
{ date(post.publishedAt, 'long') }
<!-- September 24, 2026 -->

What a JavaScript method or Math global already says is not a function: use .toUpperCase(), .toLowerCase(), .trim(), .replaceAll(a, b), .join(', '), Math.floor(), Math.ceil() and Math.abs(). Lists are shaped with array methods (items.filter(…), .toSorted(…), .at(-1)) or in data(); counting, arithmetic and fallbacks are .length, the operators and ??.

The library is the standard set — the same names, arguments and meaning in PuzzleKit and in Sites — plus the browser-only link and timeago. An app registers its own through the formatters config map and calls them the same way ({ specialFormat(product.title) }); one that reuses a standard name wins, with a development warning. An unregistered name never crashes a render — the value passes through unchanged and a single console.error names the offender (and, for a name JavaScript covers, the method to write instead).

Text

{ capitalize(name) }
<!-- First character uppercase, the rest untouched: iPhone → IPhone -->
{ truncate(content, 100) }
<!-- At most 100 characters, the … included -->
{ pluralize(count, 'comment') }
<!-- 1 comment / 3 comments; irregular: pluralize(n, 'person', 'people') -->
{ strip_html(post.excerpt) }
<!-- Tags removed -->

Numbers

{ currency(price, '$', 2) }
<!-- $1,219.99 -->
{ percentage(progress) }
<!-- 75.4 → 75% (the number as written) -->
{ number_with_delimiter(count) }
<!-- 1,234,567 in the viewer's locale; number_with_delimiter(n, ',') forces one -->
{ compact_number(followers) }
<!-- 1.2K, 45K, 3.4M -->
{ round(rating, 1) }
<!-- 4.3 — half away from zero, a number -->

Values

{ json(obj) }
<!-- JSON with sorted keys -->

Markup Functions

Every interpolation is text unless its outermost call is one of these two, and both are safe by construction:

{ raw(post.bodyHtml) }
<!-- Real HTML, always through an allowlist sanitizer -->
{ newline_to_br(comment.text) }
<!-- Escaped text with a real <br> per line break -->

raw keeps document markup, links and images, class and id (DOMPurify's defaults; no id on <img> and none starting with __), and target="_blank" on links (always with rel="noopener noreferrer"; any other target is dropped). It removes <script> (with its contents), <style>, <iframe>, <object>, <embed>, <svg>, forms, every on* handler, style, name, and any URL that is not relative or http(s) (links also keep mailto: and tel:). Because class and id survive, sanitized content can use your app's CSS and name its elements — for untrusted user HTML that is a UI-overlay and naming risk (a fixed inset-0 block over your page, an id that shadows an undefined global), not code execution. A markup function must be the outermost call of a text interpolation — inside another call, in an attribute or a prop it is a compile error — so an app function can never inject markup. Apps that never use either function ship none of this code.

Dates

date, time and datetime take the presets short, medium, long and iso, in the viewer's locale and time zone. With no preset, date is medium, time is short, and datetime is the medium date with the short time. A literal preset the standard functions do not know (time(at, 'shrot')) is a compile-time warning (an app may register its own time), and at run time an unknown preset renders the default.

{ date(createdAt) }
<!-- Sep 24, 2026 -->
{ date(createdAt, 'long') }
<!-- September 24, 2026 -->
{ time(createdAt) }
<!-- 3:04 PM -->
{ datetime(updatedAt) }
<!-- Sep 24, 2026, 3:04 PM -->
{ datetime(updatedAt, 'iso') }
<!-- 2026-09-24T15:04:05-04:00 -->
{ timeago(publishedAt) }
<!-- 2 hours ago -->

Translations

With i18n: { locales: ['en', 'es'], defaultLocale: 'en' } in puzzle.config.js and one app/locales/<tag>.json per locale:

{
  "cart": {
    "title": "Your cart",
    "items": { "one": "{count} item", "other": "{count} items" }
  },
  "greeting": "Hello, {name}!"
}
{ t('cart.title') }
<!-- Your cart -->
{ t('greeting', { name: user.name }) }
<!-- Hello, Ada! — fills {name} -->
{ t('cart.items', { count: cart.count }) }
<!-- 3 items — a numeric `count` picks the plural form -->

A missing key prints the key itself. Dates and numbers (date, number_with_delimiter, compact_number, …) follow the active locale. Switch languages with this.ctx.i18n.setLocale('es'): the new file loads first, then the page rebuilds in place and the choice is remembered. See examples/i18n.

Add routing: 'prefix' to the i18n block and every non-default locale gets its own URLs under its tag (/es/about; the default stays unprefixed), with one prerendered page per locale in static and hybrid output. link() adds the active prefix for you, and this.ctx.i18n.locales is a list of { locale, label, href, active } entries a language switcher can render as is (the language-switcher and language-menu pieces do).

Single-File Components

<!-- 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>

Waiting for a render

await this.renderTick() resolves once every pending store flush and scheduled render has landed, including renders those schedule (D188):

this.setData({ editing: true });
await this.renderTick();
this.refs.editor.focus();

It forces nothing and does not wait for async data(), requests or navigation; app.idle() waits for the whole app to go quiet.

Imports

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

Component families

Related components can import as one unit and invoke with dot notation. Group them in a directory with a plain JS barrel — .pzl stays one class per file:

app/components/Frame/
  Frame.pzl  Wrapper.pzl  Content.pzl  index.js
// index.js
import Frame from './Frame.pzl';
import Wrapper from './Wrapper.pzl';
import Content from './Content.pzl';

export { Frame, Wrapper, Content };
export default Object.assign(Frame, { Wrapper, Content });
<puzzle-view>
  <Frame>
    <Frame.Wrapper>
      <Frame.Content>…</Frame.Content>
    </Frame.Wrapper>
  </Frame>
</puzzle-view>

<script>
  import { PuzzleView } from '@magic-spells/puzzle';
  import Frame from '@/components/Frame';

  export default class Page extends PuzzleView {}
</script>

A dotted tag is an ordinary member expression resolved against module scope — no registry, no compiler magic. puzzle generate component Frame --family Wrapper,Content scaffolds the whole directory, barrel included.

Monorepo

The framework lives at packages/puzzle in the magic-spells/puzzle repository; the repo root is a private shell whose scripts delegate here. packages/ holds everything that releases in lockstep with it:

Editor grammars live in their own repos — see Syntax Highlighting below.

Puzzle Pieces

Puzzle Pieces is the official component library for Puzzle — ready-made .pzl components (and their styles) you can drop into any app. It lives in this monorepo and releases in lockstep with the framework:

puzzle add piece <name>

Pieces are fetched from the @magic-spells/puzzle-pieces npm package, version-matched to your CLI — puzzle 0.8.x pulls the newest pieces 0.8.x — so a piece is always authored for the compiler installing it. --pieces-version pins an exact release; --registry accepts npm:pkg[@version], a local directory, or an http(s) URL.

A piece that is a component family installs path-preserving: its members land in their own directory under the manifest's target (app/components/ui/NavigationMenu/Item.pzl), barrel included, and pieces.lock records each member by its full app-relative path.

Preview every piece in the live catalog at magicspells.io/puzzle-pieces.

Syntax Highlighting

Editor extensions provide full .pzl highlighting — native HTML, JavaScript/TypeScript, and CSS per section, plus Puzzle's template expressions, directives, and event bindings:

Install instructions are in each repository's README.

Documentation

AI project memory

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.

CLI Commands

# 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

# Type-check .pzl scripts and template expressions with the app's own TypeScript
puzzle check

# Serve an existing build the way a production host will
puzzle preview
puzzle preview ./my-app --port 4000

# Per-phase timing tables for builds and dev rebuilds
puzzle build --profile-build
puzzle dev --profile-build

# 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 skills

Every command above is 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 stylesheet.

By default a build emits one dist/app.js, and a dynamic import() is inlined into it. Set build: { splitting: true } in puzzle.config.js and each dynamic import() becomes a lazy chunk under dist/chunks/ that the browser fetches only when that code path runs — the way to keep a heavy on-demand dependency (a chart library, a diagram renderer, an editor) off the first page load. Production split builds name the entry app-<hash>.js, the stylesheet styles-<hash>.css, and any CSS imported by the entry app-<hash>.css. The build rewrites the shell's references and adds modulepreload links for the entry's transitive static-import chunks; lazy chunks stay on demand. esbuild's ESM splitting has no chunk-loader runtime, so it adds no runtime bytes. The build's size banner also names your largest dependencies and, in production builds, flags any single one over 200 KB (the threshold assumes minified bytes, so dev builds never warn).

build.hashNames defaults to build.splitting. Set build: { splitting: true, hashNames: false } to keep app.js and styles.css while retaining preloads, or build: { hashNames: true } to hash without splitting. Development builds always keep fixed names and add no preloads. Files copied from public/ are never renamed. A hashed SPA or hybrid build requires the shell to reference app.js as /app.js, ./app.js or app.js; use hashNames: false when another page needs the fixed URL.

output: 'hybrid' uses the same entry and stylesheet names as the SPA, with prerendered HTML under dist/<path>/index.html. output: 'static' ships no app entry: each page loads dist/_puzzle/<slug>.js, or dist/_puzzle/<slug>-<hash>.js when hashing is on, and preloads its shared dist/_puzzle/chunks/. A static build with no splitting or hashNames key keeps fixed page-module names and styles.css, with preloads in production.

Splitting adds one deployment rule: chunk filenames are content-hashed, so a new deploy writes new ones, and a tab still holding the previous entry will ask for the chunk names that entry graph was built with. Keep the previous dist/chunks/ files available for a support window after a deploy, or deploy immutably (each build to its own directory, with the old one still served). If a lazy chunk does 404, the failure is contained the way any other pre-commit route failure is: nothing commits, the page the user is on stays put, and the loader rejection reports through onError as the navigation phase. A rejected import is never cached, so the next attempt loads again — and once the client reloads, it asks for the chunk names the current build wrote.

On an interactive terminal, build and dev also mention newer Puzzle releases. The notice is printed from a cached answer and never waits on the network — no build pays registry latency, ever. When that answer is more than an hour old the command starts a detached background process to refresh it and exits without waiting, so a release published since your last check is mentioned on the run after the refresh lands rather than on the run that fetched it. A failed refresh is not retried for fifteen minutes, so an unreachable registry costs one short-lived process every quarter hour rather than one per command. Set PUZZLE_NO_UPDATE_CHECK=1 to disable it; the check is skipped automatically when CI is set or output is not a terminal.

puzzle preview serves a build you already produced, with no watcher, no live reload, and no dev.proxy — the artifact is checked exactly as it sits on disk. Its optional argument is the project directory (its dist/ is found for you), not the output directory. It serves per the resolved output mode: an SPA gets history-API fallback, hybrid serves the prerendered page first and the shell otherwise, and static gets clean URLs and a real 404.html rather than the shell — a folder's own 404.html first, so a miss under /es/ gets dist/es/404.html. It defaults to port 4000 so it runs alongside puzzle dev.

puzzle upgrade updates the CLI you are running, resolved from the executable's own install context rather than the current directory — bumping a project's @magic-spells/puzzle dependency is npm's job, not the CLI's. puzzle upgrade --check only reports the current and latest versions.

Agent skill

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.

puzzle check type-checks every .pzl file with the TypeScript the app itself has installed — Puzzle never installs one for you. A lang="ts" script is checked as written; every template expression is re-emitted as typed statements, so a typo in { user.nmae } is a type error reported at its real .pzl line and column. A plain-JavaScript component gets its template expressions checked and its script body left alone.

The full CLI surface (see constellation/doc/DOC-SPEC.md §13): init, generate, add, check, 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
puzzle init my-app --template todos --typescript

# Generate a stub (component, view, layout, or model) — TypeScript stubs in an
# app with a tsconfig.json
puzzle generate component UserCard --path components/ui/

# Wire up Tailwind, install a piece or a palette (see Puzzle Pieces above), or run diagnostics
puzzle add tailwind
puzzle add piece <name>
puzzle add theme <name>
puzzle add skills
puzzle doctor

License

Puzzle is released under the MIT License.