Skip to content

Latest commit

 

History

History
865 lines (665 loc) · 33.2 KB

File metadata and controls

865 lines (665 loc) · 33.2 KB

Setting up an app on swatchkit (hand-rolled)

A reference for setting up a project that uses swatchkit as a pattern library, without a build framework. You write a few small build scripts yourself, and npm run build chains them into a deployable static site.

The build uses esbuild for CSS and JS bundling, so you get real parsers, source maps, and minification without writing a custom bundler.

Use this when you want a static site, total control over output, and a small dep tree. If you want HMR, code splitting, or framework-style dev experience, use a framework-driven setup instead.


Fastest path: scaffold the whole thing

Everything this guide builds by hand can be scaffolded in one command:

mkdir my-app && cd my-app
npm init -y
npm pkg set type=module private=true   # the app starter is ESM — set this first
npm install -D swatchkit esbuild
npx swatchkit init --app --cssDir ./src/css
npm install
npm run dev

Set "type": "module" before init --app. The starter writes an ESM config (export default { … }); without "type": "module" Node loads it as CommonJS and the build fails with Error: Unexpected token 'export'. If you hit that, run npm pkg set type=module then re-run with --force.

swatchkit init --app creates the integrated config, the esbuild build scripts (scripts/clean.js, build-site.js, build-assets.js), shared renderers (src/components/button.js, card.js), a home page, two example swatches, and a watch-enabled package.json. npm run dev then builds, watches your source, and serves at http://localhost:8080/ (app) and /swatchkit/ (pattern library).

The rest of this guide explains each piece so you can customize it or build it yourself. If you used --app, treat the sections below as a reference for what you already have. Some examples here are intentionally a little richer than the minimal starter (e.g. a theme-toggle.js to show how shared browser-side JS fits in) — they illustrate the pattern, not the exact generated files.


What you get

  • A main app at / — your real product UI, rendered at build time.
  • A pattern library at /swatchkit/ — component documentation, generated by the swatchkit CLI from your shared renderers.
  • Shared renderers in src/components/, called by both the app and the swatchkit pages. One source of truth.
  • A dist/ directory with plain HTML, CSS, and JS — deployable to any static host (S3, Netlify, GitHub Pages, a USB stick).

Project layout

my-project/
├── package.json
├── swatchkit.config.js
├── scripts/                          ← your build scripts (Step 3)
│   ├── clean.js
│   ├── build-site.js
│   └── build-assets.js
├── src/
│   ├── components/                   ← your renderers
│   │   ├── button.js
│   │   └── card.js
│   ├── pages/
│   │   └── home.js                   ← exports home() → main app HTML
│   ├── css/
│   │   ├── main.css                  ← your entry CSS (@imports everything)
│   │   ├── theme.css                 ← your app's theme overrides
│   │   ├── swatches/
│   │   │   ├── index.css
│   │   │   ├── button.css
│   │   │   └── card.css
│   │   ├── global/                   ← from `swatchkit init`
│   │   ├── compositions/             ← from `swatchkit init`
│   │   └── utilities/                ← from `swatchkit init`
│   └── js/
│       ├── main.js                   ← entry script for the main app
│       └── theme-toggle.js           ← shared init (e.g. dark-mode toggle)
├── swatchkit/                        ← pattern library source
│   ├── _swatchkit.html               ← layout for the library index
│   ├── _preview.html                 ← layout for individual previews
│   ├── swatches/
│   │   ├── button/index.js           ← calls renderButton → swatch HTML
│   │   └── card/index.js             ← calls renderCard → swatch HTML
│   ├── compositions/…                ← from `swatchkit init`
│   ├── utilities/…                   ← from `swatchkit init`
│   └── tokens/…                      ← generated each build (token docs)

Everything in global/, compositions/, utilities/ (under both src/css/ and swatchkit/) comes from npx swatchkit init. The rest is yours.


Step 1: package.json

{
  "name": "my-app",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "clean": "node scripts/clean.js",
    "build:site": "node scripts/build-site.js",
    "build:swatchkit": "swatchkit",
    "build:assets": "node scripts/build-assets.js",
    "build": "npm run clean && npm run build:site && npm run build:swatchkit && npm run build:assets",
    "build:prod": "npm run clean && npm run build:site && npm run build:swatchkit && node scripts/build-assets.js --prod",
    "watch:site": "onchange 'src/pages/**/*' 'src/components/**/*' -- npm run build:site",
    "watch:assets": "onchange 'src/css/**/*' 'src/js/**/*' 'src/components/**/*' -e 'src/css/utilities/utilities.css' -- npm run build:assets",
    "swatchkit:watch": "swatchkit --watch",
    "serve": "http-server dist -c-1 -p 8080 -o",
    "dev": "npm run build && npm-run-all --parallel watch:site watch:assets swatchkit:watch serve"
  },
  "devDependencies": {
    "esbuild": "^0.28.0",
    "http-server": "^14.1.1",
    "npm-run-all": "^4.1.5",
    "onchange": "^7.1.0",
    "swatchkit": "^X.Y.Z"
  }
}

The swatchkit version in devDependencies is whatever release of swatchkit you ran init against; bump it manually when you upgrade swatchkit.


The build chain runs in this order, and the order is load-bearing:

1. **`clean`** — wipe `dist/` (cross-platform, works on Windows too).
2. **`build:site`** — render `src/pages/home.js` → `dist/index.html` (the main app).
3. **`build:swatchkit`** — run the `swatchkit` CLI. This parses your `@swatchkit` token blocks (in `src/css/global/tokens.css` and any other `tokenSources`), regenerates `src/css/utilities/utilities.css` from them, and writes the pattern library to `dist/swatchkit/`. Your token CSS itself is never modified.
4. **`build:assets`** — esbuild bundles `src/css/main.css` → `dist/css/main.css` and `src/js/main.js` → `dist/js/main.js`. Also copies `swatchkit-ui.css` and `swatchkit-preview.css` to `dist/css/`.

`build:swatchkit` *must* run before `build:assets` — the freshly regenerated `utilities.css` needs to exist before esbuild reads `main.css` (which `@import`s it). The full build chain handles this; if you run the steps individually, keep the order.

`build:prod` is the deployable artifact: same chain, but `build-assets.js` runs with `--prod`, which turns on minification and turns off source maps. See [Source maps](#source-maps) below.

See [The development workflow](#the-development-workflow) below for what `dev`, the `watch:*` scripts, and `serve` do.

## Step 2: `swatchkit.config.js`

```js
export default {
  cssDir: "./src/css",
  cssCopy: false,
  cssPath: "../css/",
};
  • cssDir — where your source CSS lives. The CLI reads this to locate your @swatchkit token blocks and to write the generated utilities/utilities.css.
  • cssCopy: false — don't copy CSS into dist/swatchkit/css/. The pattern library's HTML will reference ../css/main.css instead, so the CSS lives in one place (dist/css/) and both the app and the library point at it.
  • cssPath — the path from the swatchkit HTML files to the shared CSS. The default (when omitted) is ../<basename of cssDir>/, so this is usually just what you'd get by default. Set it explicitly only if your build puts CSS somewhere unusual.

Step 3: The build scripts

Three small Node scripts: clean.js, build-site.js, and build-assets.js (which is just one esbuild call). Drop them in scripts/ and chain them from package.json (already done in Step 1).

scripts/clean.js

Cross-platform replacement for rm -rf dist. Works on Windows, macOS, and Linux without depending on a Unix shell being available.

import fs from "node:fs";

fs.rmSync("dist", { recursive: true, force: true });

console.log("[clean] Removed dist/");

scripts/build-site.js

Renders src/pages/home.js (which exports an HTML string) to dist/index.html. Pure rendering — no template engine, no framework. The renderer is called once at build time.

import fs from "node:fs/promises";
import path from "node:path";
import { home } from "../src/pages/home.js";

const distDir = path.resolve("dist");

await fs.mkdir(distDir, { recursive: true });
await fs.writeFile(path.join(distDir, "index.html"), home(), "utf8");

console.log("[site] Built dist/index.html");

The script imports home() from your src/pages/home.js and writes its return value to dist/index.html. The whole app HTML is generated at build time — the browser never executes the renderers.

The output references the stable output names:

<link rel="stylesheet" href="./css/main.css" />
<script type="module" src="./js/main.js"></script>

scripts/build-assets.js

Replaces a hand-rolled CSS bundler and a hand-rolled JS bundler with a single esbuild call. esbuild is a real parser, so @import (with media queries, @layer, etc.), normal import/export, and third-party modules all just work. Minification and source maps are one option each.

import fs from "node:fs";
import path from "node:path";
import * as esbuild from "esbuild";

const isProduction =
  process.argv.includes("--prod") || process.env.NODE_ENV === "production";

// Standalone CSS files referenced by name in the SwatchKit layouts.
// They are NOT imported by main.css, so esbuild never sees them — copy them.
const STANDALONE_CSS = ["swatchkit-ui.css", "swatchkit-preview.css"];

await esbuild.build({
  entryPoints: ["src/css/main.css", "src/js/main.js"],
  bundle: true,
  outdir: "dist",
  outbase: "src",
  minify: isProduction,
  sourcemap: !isProduction, // dev-only maps; omit from production output
  format: "esm",
  // Emit referenced assets (fonts/images via url()) next to the output
  // instead of failing, in case a dependency ships them.
  loader: {
    ".woff": "file",
    ".woff2": "file",
    ".ttf": "file",
    ".eot": "file",
    ".svg": "file",
    ".png": "file",
    ".jpg": "file",
    ".gif": "file",
  },
  logLevel: "info",
});

fs.mkdirSync(path.join("dist", "css"), { recursive: true });
for (const file of STANDALONE_CSS) {
  const src = path.join("src", "css", file);
  const dest = path.join("dist", "css", file);
  if (fs.existsSync(src)) {
    fs.copyFileSync(src, dest);
    console.log(`[assets] Copied dist/css/${file}`);
  }
}

console.log("[assets] Built CSS and JS with esbuild");

With outbase: "src", esbuild preserves the source folder structure in the output:

dist/
├── css/
│   ├── main.css
│   ├── main.css.map        (dev only)
│   ├── swatchkit-ui.css      (copied)
│   └── swatchkit-preview.css (copied)
└── js/
    ├── main.js
    └── main.js.map         (dev only)

Both build and build:prod use this same script — the only difference is the --prod flag (or NODE_ENV=production), which flips minify to true and sourcemap to false.

The loader config tells esbuild to copy font and image files (referenced via url() in CSS) into the output instead of failing. If your CSS doesn't reference external assets, this is a no-op.

Source maps

A source map (main.js.map) is a separate JSON file linked from the bottom of the bundle by a comment:

//# sourceMappingURL=main.js.map

Key facts:

  • The map is only fetched when DevTools is open. Normal visitors never download it. It is a DevTools feature, not a runtime API — the JS engine ignores the comment during execution.
  • Shipping maps therefore has zero runtime cost and does not undermine minification: the executed file is still fully minified. The map just lets a developer's DevTools show original source + names.
  • The only real tradeoff is source exposure: the .map inlines the original source (sourcesContent), so anyone who fetches it can read your unminified code.

esbuild sourcemap modes:

Mode Comment in file .map emitted Use
true yes yes full live-site debugging
"external" no yes maps uploaded to an error tracker (Sentry, etc.), not the server
"inline" data URI no (embedded) bloats the shipped file — avoid for prod
false no no no maps

This script's default: sourcemap: !isProduction — full maps in dev, none in the production bundle. This keeps prod output clean and avoids source exposure, while keeping dev fully debuggable. If you use Sentry-style error tracking, switch the production side to "external" and upload the .map files separately.


Step 4: The source directories

src/components/

Where your renderers live. A renderer is a pure function that takes a props object and returns an HTML string. The contract is formalized in The renderer contract in the main README — short version: pure, deterministic, no side effects, no build-tool coupling.

A renderer:

// src/components/button.js

/**
 * @param {object} props
 * @param {string} props.label  - Visible button text
 * @param {string} [props.href] - If set, renders an <a> instead of a <button>
 * @param {"primary"|"outline"|"danger"} [props.variant] - Visual style (default: primary)
 * @param {"small"|"large"} [props.size] - Optional size modifier
 */
export function renderButton({ label, href, variant = "primary", size }) {
  const classes = ["button"];

  if (variant === "outline") classes.push("outline");
  else if (variant === "danger") classes.push("danger");

  if (size === "small") classes.push("small");
  else if (size === "large") classes.push("large");

  const classStr = classes.join(" ");

  if (href) {
    return `<a class="${classStr}" href="${href}">${label}</a>`;
  }
  return `<button class="${classStr}">${label}</button>`;
}

This is renderButton — the same function the app uses, the same function the swatchkit page uses. One source of truth.

src/pages/home.js

Exports a home() function that returns the main app's HTML as a string. Called at build time by scripts/build-site.js.

// src/pages/home.js

import { renderButton } from "../components/button.js";
import { renderCard } from "../components/card.js";

const html = String.raw;

export function home() {
  return html`<!doctype html>
<html lang="en" data-theme="light">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
    <link rel="stylesheet" href="./css/main.css" />
  </head>
  <body class="wrapper region flow">
    <header>
      <div>
        <h1>My App</h1>
        <p>
          A real project using the same component render functions as the
          SwatchKit pattern library. See
          <a href="./swatchkit/">the pattern library</a>.
        </p>
      </div>
      <button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
        <svg class="moon visible" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
        </svg>
        <svg class="sun hidden" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="4" />
          <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
        </svg>
      </button>
    </header>

    <section>
      <h2>Buttons</h2>
      <p>
        These buttons use the same <code>renderButton</code> function as
        the button swatch in the pattern library.
      </p>

      <h3>Primary Actions</h3>
      <div class="cluster gap">
        ${renderButton({ label: "Save changes" })}
        ${renderButton({ label: "Continue", href: "/next" })}
      </div>

      <h3>Destructive</h3>
      ${renderButton({ label: "Delete project", variant: "danger" })}

      <h3>Sizes</h3>
      <div class="cluster gap">
        ${renderButton({ label: "Small", size: "small" })}
        ${renderButton({ label: "Default" })}
        ${renderButton({ label: "Large", size: "large" })}
      </div>

      <h3>Outline</h3>
      <div class="cluster gap">
        ${renderButton({ label: "Cancel", variant: "outline", href: "/" })}
        ${renderButton({ label: "Confirm", variant: "outline", href: "/confirm" })}
      </div>
    </section>

    <section>
      <h2>Card Example</h2>
      ${renderCard({
        title: "Project Alpha",
        body: "A soft editorial brand for Aurora.",
        ctaLabel: "View brand",
        ctaHref: "/brands/aurora/",
      })}
    </section>

    <script type="module" src="./js/main.js"></script>
  </body>
</html>`;
}

This is a complete example — the whole app, with header, two sections (buttons and a card), and the theme toggle wired up. The pattern is: import the renderers at the top, call them inline as template substitutions. The browser sees the resulting HTML, not the source.

For a starting project, you can trim this down to just a header and one component. The full version is here so you can see how everything fits together — copy the parts you need.

src/css/main.css

The entry stylesheet. It declares the cascade-layer order, then imports each group of styles into its layer:

@layer reset, tokens, elements, compositions, swatches, app, utilities;

@import "global/reset.css" layer(reset);
@import "global/tokens.css" layer(tokens);
@import "global/variables.css" layer(tokens);
@import "global/elements.css" layer(elements);

@import "compositions/index.css" layer(compositions);
@import "swatches/index.css" layer(swatches);
@import "utilities/index.css" layer(utilities);

/* === Your App Styles === */
@layer app {
  /* @import "theme.css" layer(app);  — or write rules directly here */
}

global/, compositions/, and utilities/ come from swatchkit init. swatches/ and your app CSS are yours — see Step 6 for how to extend them.

Cascade layers make the cascade predictable: a later layer always wins over an earlier one, regardless of selector specificity. utilities is declared last, so a utility class in your markup wins over component and app styles without !important. Plain unlayered CSS still beats every layer — your escape hatch.

Two rules to keep in mind:

  • @import statements must come before any regular style rules. Keep the @layer declaration and all @import lines at the top; put app CSS in the @layer app { } block.
  • The single @layer reset, …, utilities; line fixes the order globally. It doesn't matter that the @layer app { } block physically appears after the utilities import — utilities still wins because it's later in that declaration.

esbuild reads this file as a real CSS entry: it follows the @imports (including layer(...) assignments), inlines them into one file, and supports media queries, @layer, and quoted/unquoted url() in the process.

src/js/main.js

The entry script for the main app. Imports any shared initialization and runs it:

import { initThemeToggle } from "./theme-toggle.js";

initThemeToggle();

esbuild bundles this entry plus all the modules it transitively imports into a single dist/js/main.js. The browser loads it as <script type="module" src="./js/main.js"> from your home() template.

swatchkit/swatches/<name>/index.js

A swatch is a JS file that imports the same renderer your app uses, then exports the HTML string for that swatch's preview:

// swatchkit/swatches/button/index.js
import { renderButton } from "../../../src/components/button.js";

const html = String.raw;

export default html`
  <h2>Button</h2>
  <p>
    This swatch uses the same <code>renderButton</code> function as
    the main app at <code>src/pages/home.js</code>. Edit one place,
    see the change everywhere.
  </p>

  <h3>Primary Actions</h3>
  <div class="cluster gap">
    ${renderButton({ label: "Save changes" })}
    ${renderButton({ label: "Continue", href: "/next" })}
  </div>

  <h3>Outline</h3>
  <div class="cluster gap">
    ${renderButton({ label: "Cancel", variant: "outline", href: "/" })}
    ${renderButton({ label: "Confirm", variant: "outline", href: "/confirm" })}
  </div>

  <h3>Destructive</h3>
  ${renderButton({ label: "Delete project", variant: "danger" })}

  <h3>Sizes</h3>
  <div class="cluster gap">
    ${renderButton({ label: "Small", size: "small" })}
    ${renderButton({ label: "Default" })}
    ${renderButton({ label: "Large", size: "large" })}
  </div>
`;

The relative import path is ../../../src/components/button.js — three levels up from swatchkit/swatches/button/index.js to the project root, then down into src/components/. The swatchkit CLI executes this file at build time, takes its default export, and bakes the resulting HTML into the swatch's preview page.


Step 5: The build chain

Once everything is in place:

npm run build

Output (in order):

[clean] Removed dist/
[site] Built dist/index.html
[SwatchKit] Starting build…
  Source:   …/swatchkit
  Output:   …/dist/swatchkit
Parsing token blocks (src/css/global/tokens.css, …)…
Skipping CSS copy (cssCopy: false). CSS referenced at: ../css/
Scanning HTML patterns (swatchkit/**/*.html)…
Generated 20 preview pages in …/dist/swatchkit/preview
Using custom layout: …/swatchkit/_swatchkit.html
Build complete! Generated …/dist/swatchkit/index.html
[assets] Built CSS and JS with esbuild
  dist/css/main.css     29.0 kB
  dist/js/main.js       0.8 kB
[assets] Copied dist/css/swatchkit-ui.css
[assets] Copied dist/css/swatchkit-preview.css

For a deployable artifact:

npm run build:prod

Same chain, but build-assets.js is invoked with --prod: output is minified, no source maps.


The development workflow

npm run build is a one-shot build. For day-to-day work, npm run dev builds once and then watches your source and rebuilds the affected part on save, while serving dist/ at http://localhost:8080/.

npm run dev

It runs four things in parallel (via npm-run-all):

Script Watches Rebuilds
watch:site src/pages/**, src/components/** dist/index.html (re-runs build:site)
watch:assets src/css/**, src/js/**, src/components/** dist/css/main.css + dist/js/main.js (re-runs esbuild)
swatchkit:watch swatchkit/**, tokens.css dist/swatchkit/ + regenerates token utilities
serve static server with caching off (-c-1)

A few things worth knowing:

  • Editing a shared renderer (e.g. src/components/button.js) triggers both watch:site (the app HTML re-renders) and watch:assets (the JS bundle rebuilds) — so the app and the bundle stay in sync.
  • Editing tokens.css triggers swatchkit:watch (regenerates utilities.css and the token docs) and watch:assets (re-bundles the CSS so the new token values land in dist/css/main.css). That double-trigger is intentional.
  • watch:assets deliberately ignores src/css/utilities/utilities.css — that file is generated by swatchkit:watch, and watching it would cause a rebuild loop.
  • This is not hot-reload. The watchers rebuild dist/, but the browser won't refresh on its own — reload the page to see changes. The -c-1 flag ensures the reload fetches fresh main.css / main.js rather than a cached copy. (If you want true auto-refresh, swap http-server for browser-sync or move to a framework-driven dev server.)
  • It's a single parallel process group: Ctrl+C stops all of it. If a run fails with "port 8080 in use," a previous dev left a server running — pkill -f http-server clears it.

Step 6: Adding a new component

End-to-end. Let's say you're adding a "badge" component.

1. Write the renderer in src/components/.

// src/components/badge.js
export function renderBadge({ label, tone = "neutral" }) {
  return `<span class="badge ${tone}">${label}</span>`;
}

2. Add the component's CSS.

/* src/css/swatches/badge.css */

.badge {
  display: inline-block;
  padding: 0.25em 0.5em;
  font-size: 0.75rem;
  font-weight: 600;
  border-radius: 4px;
  background: var(--color-mid);
  color: var(--color-light);
}

.badge.success { background: #2d8a4e; }
.badge.danger  { background: #c82333; }

Register it in src/css/swatches/index.css:

@import "button.css";
@import "card.css";
@import "badge.css";   /* ← new */

3. Create the swatch.

// swatchkit/swatches/badge/index.js
import { renderBadge } from "../../../src/components/badge.js";

const html = String.raw;

export default html`
  <h2>Badge</h2>
  <p>
    Inline status indicator. Same <code>renderBadge</code> function
    as the main app.
  </p>
  <div class="cluster gap">
    ${renderBadge({ label: "Neutral" })}
    ${renderBadge({ label: "Success", tone: "success" })}
    ${renderBadge({ label: "Danger", tone: "danger" })}
  </div>
`;

4. Use it in your app's home().

import { renderBadge } from "../components/badge.js";
// …
${renderBadge({ label: "New" })}

5. Build.

npm run build

The renderer is now used in both the main app (via home()) and the swatchkit pattern library (via swatchkit/swatches/badge/index.js). Edit src/components/badge.js and both update on the next build.


What the renderer contract is

The contract is formalized in The renderer contract in the main README. Short version:

  • Signature: function renderX(props) → string
  • Pure: no side effects, no I/O, no global state
  • Deterministic: same input → same output, every time
  • Self-contained output: the returned string is a complete HTML fragment

If you follow this, the same function works in your app, in the swatchkit pages, and in any future test you might write.


What goes in dist/ after a build

dist/
├── index.html                  ← main app (from src/pages/home.js)
├── css/
│   ├── main.css                ← bundled by esbuild from src/css/main.css
│   ├── main.css.map            (dev only)
│   ├── swatchkit-ui.css        ← copied from src/css/
│   └── swatchkit-preview.css   ← copied from src/css/
├── js/
│   ├── main.js                 ← bundled by esbuild from src/js/main.js
│   └── main.js.map             (dev only)
└── swatchkit/
    ├── index.html              ← pattern library index
    └── preview/
        ├── compositions/…      ← one page per composition
        ├── swatches/…          ← one page per swatch
        ├── tokens/…            ← token reference pages
        └── utilities/…         ← one page per utility

Both the main app and the swatchkit pages reference dist/css/main.css (via the relative path resolved from each page's location). The CSS file is generated once, used by both. With npm run build:prod, the .map files are omitted and the CSS/JS are minified.


Common tweaks

  • Cache-busting (opt-in). The default stable filenames (main.css, main.js) match the references in home() and the SwatchKit templates. For long-lived cache headers, switch to hashed names:
    entryNames: "[dir]/[name]-[hash]",
    metafile: true,
    But hashed names require extra plumbing: build-site.js must read esbuild's metafile to learn the hashed names before writing dist/index.html, and the SwatchKit HTML expects main.css. Recommend keeping stable filenames unless your deploy target clearly benefits.
  • Source maps in production. Switch the production side of sourcemap to "external" and upload the .map files separately to your error tracker (Sentry, etc.). The shipped bundle won't have a sourceMappingURL comment, but the tracker will use the maps to show real source in stack traces.
  • Different CSS/JS outputs. Add more entryPoints to build-assets.js — esbuild handles multiple entries with one call. Each entry gets its own output under dist/ based on its path under src/.
  • A real dev server. npm run dev (the http-server line in package.json) is a static file server with caching disabled. For something with HMR, switch to a framework-driven setup.
  • More swatchkit sections. Anything you add under swatchkit/<section>/<item>/index.{js,html} becomes a section in the sidebar automatically. See the main README's "The Magic Folder" section.

Appendix: Zero-dependency alternative

For projects that refuse any dependency (not even esbuild), here's an explicit-concat CSS approach. Instead of @import, maintain an ordered list and concatenate — no regex, no @import parsing, no parser.

// scripts/build-assets.js (zero-dep variant, CSS only)
import fs from "node:fs";
import path from "node:path";

const order = [
  "global/reset.css",
  "global/tokens.css",
  "global/variables.css",
  "global/elements.css",
  "compositions/index.css",
  "utilities/index.css",
  "swatches/index.css",
  "theme.css",
];

const out = order
  .map((rel) => fs.readFileSync(path.join("src/css", rel), "utf8"))
  .join("\n");

fs.mkdirSync("dist/css", { recursive: true });
fs.writeFileSync("dist/css/main.css", out);

For JS, a zero-dep setup typically copies the entry verbatim to dist/js/main.js — the script can't recursively resolve import statements without a real bundler. So your src/js/main.js must not import from other local files; all initialization code lives in the entry itself.

The tradeoff: you maintain the order array instead of @import lines, and you get no minification, no source maps, no third-party resolution. Acceptable for a purist zero-dep setup; esbuild is better for anything production-ish.


From-scratch quickstart

Scaffolded (recommended)

mkdir my-app && cd my-app
npm init -y
npm pkg set type=module private=true   # the app starter is ESM — set this first
npm install -D swatchkit esbuild
npx swatchkit init --app --cssDir ./src/css
npm install
npm run dev

init --app writes the integrated config, the build scripts, shared renderers (button, card), a home page, two example swatches, and a watch-enabled package.json. You're running with a working app + pattern library in under a minute. Everything it generates is described in the steps above — edit away.

The app starter is ESM, so set "type": "module" before running init --app (the npm pkg set type=module line above). Otherwise Node loads the generated export default config as CommonJS and the build fails with Error: Unexpected token 'export'.

Manual (build it yourself)

If you'd rather assemble it by hand (or understand each piece):

# 1. New project
mkdir my-app && cd my-app
npm init -y
npm pkg set type=module

# 2. Tooling
npm install -D swatchkit esbuild http-server npm-run-all onchange

# 3. SwatchKit config + scaffold (one step)
npx swatchkit init --cssDir ./src/css

Then set the integrated config:

// swatchkit.config.js
export default {
  cssDir: "./src/css",
  cssCopy: false,
  cssPath: "../css/",
};

Create the three scripts from the "Step 3" section above (scripts/clean.js, scripts/build-site.js, scripts/build-assets.js) and the package.json scripts block from "Step 1".

Create the minimum app surface:

src/
├── pages/home.js          # exports home() → app HTML string
├── components/            # shared renderX(props) → HTML string
├── css/main.css           # @imports global/compositions/utilities/swatches
└── js/main.js             # imports + inits browser modules
swatchkit/
├── _swatchkit.html        # from scaffold
├── _preview.html          # from scaffold
└── swatches/<name>/index.js  # imports the same renderer as the app

Build and preview:

npm run build        # one-shot dev build (with source maps)
npm run dev          # build + watch + serve at :8080
# or for a deployable artifact:
npm run build:prod   # minified, no source maps

Result:

dist/
├── index.html          # main app
├── css/main.css        # bundled, both app + library link it
├── js/main.js          # bundled
└── swatchkit/          # pattern library, links ../css/main.css

When this setup starts to hurt

This setup is great for static sites with a small to moderate number of components. It starts to feel limiting when:

  • You want HMR while editing components.
  • You want TypeScript, JSX, or a real framework.
  • You want code splitting, content-hashed asset names, or service-worker-driven caching out of the box.
  • You start accepting user input at runtime and the renderers need to handle escapes, validation, or async data.

For those cases, a framework-driven setup (Vite, Astro, etc.) is the next step. The shared-renderer pattern still works — the only thing that changes is what bundles the CSS and JS.