Building apps on Civility

Guides for building a Civility app — an offline-first, installable PWA whose data lives on the user’s devices and, when they choose, syncs through a Civility server (yours, theirs, or a hosted one).

A Civility app is a static bundle — HTML, an ES-module entry point, a service worker, and CSS — that civ build emits from a www/ source tree. No server-side rendering, no backend to run. Sync, encryption, and AI are packages you opt into per app.

Protocol-level design lives in the specs under docs/spec/; per-symbol API docs are generated at https://jsr.io/@civility/<package>/doc.


Quickstart #

1. Prerequisites

# Deno 2.x
curl -fsSL https://deno.land/install.sh | sh

# The Civility CLI (installs the `civ` binary)
deno install jsr:@civility/cli -Afg --name=civ --unstable-kv

civ icons (optional, for generating PWA icon sizes) shells out to ImageMagick — install it if you want icon generation, otherwise skip that step.

2. Scaffold and run

civ init my-app                              # or: civ init my-app --url https://my-app.example
cd my-app
deno task init                               # civ icons — generate icon sizes (needs ImageMagick; optional)
deno task dev                                # civ start — dev server at http://localhost:8000, live rebuild

civ init copies the PWA template into my-app/ — a civility.json build config, a deno.json, and a www/ tree with a working router, service worker, store, and example routes. deno task dev serves it and rebuilds on every save (--port 3000 to change the port). That’s already an installable, offline-first PWA running on local state — no server involved.

3. Build and deploy

deno task build                              # civ build → static bundle in civility.json "outdir" (e.g. ./www/dist)

civ build produces a version-namespaced JS bundle, the service worker, the web manifest, and generated icons. There’s no server component, so deploying is a drag-and-drop:

  • Netlify Drop or Cloudflare Pages Direct Upload — drag the output folder in and you have a live HTTPS PWA.
  • Deno Deploy / GitHub Pages / any static host — point it at the output directory.
  • Your own box — serve the directory behind Caddy or nginx.

Two things to get right on any host but civ start: one response header on the service worker’s versioned route, and an additive deploy — publish each new build alongside the old version directories rather than replacing them, or installs pinned to an older version stop loading entirely. See Deployment for ready-to-use snippets and the reasoning.

Because state lives on the device and sync is user-configured, a plain static host still gives users a persistent, installable, offline-capable, multi-device app with nothing for you to operate. When they want sync, they point the app at a Civility server at runtime (see Sync) — the same bundle works against any server.


The shape of an app #

civ init scaffolds this tree — the same layout every Civility app uses, from a toy to the reference apps (climb, cook, fit):

my-app/
├── civility.json          ← build config (name, type, root, outdir, static, icon)
├── deno.json              ← imports (@civility/*, lit) + tasks (dev/build/icons)
└── www/
    ├── index.html         ← <header>, <main>, optional <footer>/<ui-bottom-bar>
    ├── index.ts           ← entry: router + client.init()
    ├── worker.ts          ← service worker: init([...plugins])
    ├── store/app.ts       ← app state, exported as a singleton
    ├── routes/*.ts        ← one LitElement custom-element per page
    └── static/            ← civility.css, utilities.css, theme.css, icons

Three conventions hold every app together.

One app store, exported as a singleton

www/store/app.ts owns your state and fans changes out to the UI. Routes come and go; the store outlives them, so components import the singleton and subscribe to it.

// www/store/app.ts
import { Store } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'

const APP_ID = 'me.example.myapp'
const store = new Store(new IDBStorage({ dbName: APP_ID }), {
  documents: ['settings'],
  collections: ['items'],
  versions: [{
    version: globalThis.__CIVILITY__?.version ?? '0.0.0',
    schema: appSchema,
  }],
})

export class App {
  settings = store.document<Settings>('settings', { theme: 'auto' })
  items = store.collection<Item>('items')

  #subs = new Set<() => void>()
  constructor() {
    store.subscribe(() => this.#notify())
    this.#init()
  }
  async #init() {
    await this.settings.preload()
    await this.items.preload()
    this.#notify()
  }
  #notify() {
    for (const cb of this.#subs) cb()
  }
  addEventListener(fn: () => void) {
    this.#subs.add(fn)
  }
  removeEventListener(fn: () => void) {
    this.#subs.delete(fn)
  }
}

export default new App()

The store turns every change — local or pulled from sync — into one “something changed” signal. globalThis.__CIVILITY__.version is injected by civ build, so your schema version tracks releases automatically. See Store for Document / Collection / Property and schema migrations.

A route is a custom element

Routes are plain Lit elements rendered into the light DOM (createRenderRoot() { return this }) so they inherit the app’s global CSS instead of being isolated by shadow DOM. Each subscribes to the store and re-renders on change:

// www/routes/home.ts
import { html, LitElement } from 'lit'
import app from '../store/app.ts'

class HomePage extends LitElement {
  override createRenderRoot() {
    return this // light DOM: civility.css / theme.css apply
  }
  #onChange = () => this.requestUpdate()
  override connectedCallback() {
    super.connectedCallback()
    app.addEventListener(this.#onChange)
  }
  override disconnectedCallback() {
    app.removeEventListener(this.#onChange)
    super.disconnectedCallback()
  }
  override render() {
    return html`<ui-card>${app.items.value?.size ?? 0} items</ui-card>`
  }
}
customElements.define('home-page', HomePage)

Wire routes together with createLayoutRouter from @civility/ui, which swaps one element into a <main> landmark per route and manages headers and nav:

// www/index.ts
import { createLayoutRouter } from '@civility/ui'
import { client } from '@civility/workers'
import './routes/home.ts'
import './routes/settings.ts'

client.init()

const { ready } = createLayoutRouter<NavMeta>({
  router: { selectorAttrib: 'data-route', useHash: true },
  defaultRoute: '/',
  landmarks: { main: 'main', header: 'body > header' },
  routes: {
    '/': { main: () => ({ tag: 'home-page' }) },
    '/items/{id}': {
      main: (ctx) => ({
        tag: 'item-page',
        attrs: { 'item-id': ctx.params.id },
      }),
    },
    '/settings': { main: () => ({ tag: 'settings-page' }) },
  },
})
ready()

Hash routing (useHash: true) means deep links need no server rewrite rules — that’s what makes the drag-and-drop deploy work. The UI guide covers the router, the ui-* / civ-* components, and styling.

A service worker precaches the shell

www/worker.ts makes the PWA open with no network:

// www/worker.ts
import {
  init,
  versionedUrl,
  withCleanup,
  withFetchStrategy,
  withPrecache,
  withUpdatePolling,
} from '@civility/workers'

init([
  withPrecache([
    '/',
    '/index.html',
    versionedUrl('/dist/index.js'),
    '/manifest.json',
    '/static/theme.css',
  ]),
  withCleanup(),
  withUpdatePolling(),
  withFetchStrategy(),
])

civ build writes the bundle to a fresh version-namespaced directory every build; versionedUrl resolves /dist/index.js to that build’s actual path, which is what makes updates reliable no matter how a host caches static files.

Other worker plugins: withBlobProxy({ dbName, pathPrefix?, blobStore? }) serves blob bytes to <img> / fetch from the local store (see Blobs), withBackgroundPrecache(metaUrl?) warms the cache in the background, and withFetchStrategy(handler) takes a custom strategy — cacheFirst, networkFirst, or staleWhileRevalidate.

On the client side (index.ts), @civility/workers exposes client:

import { client } from '@civility/workers'

client.init() // register the service worker
client.startUpdatePolling() // prompt on a new version
client.configureBlobProxy({ serverUrl, token }) // pair with withBlobProxy
client.isInstalled() / client.isPWASupported() / client.applyUpdate()

For the update prompt, drop <civ-version> (@civility/ui) into the app shell — it checks for and applies updates with no further wiring. client.applyUpdate() is there if you build your own update UI.


Features #

Each capability is a package you opt into. Start with local state; add the rest as you need them.

| Guide | Package | What it gives you | | ––––––––––––––––––– | –––––––––– | ———————————————————————————— | | Store | @civility/store | Reactive local state — Document, Collection, Property, schema migrations. | | Blobs | @civility/blobs | Content-addressed binary storage (images, files) that dedupes automatically. | | Sync | @civility/cloud | Push/pull your stores to a Civility server across devices — plus E2E encryption. | | Services | @civility/services | Call AI and other credentialed services — bring-your-own-key or host bundle. | | UI | @civility/ui | The layout router, ui-* / civ-* web components, and the CSS / theming system. |

store and blobs work on their own for local-only apps; sync builds on one or both to add cross-device replication and encryption.

Shipping an app also involves:

  • Registration — app id, manifest, and origin verification for the consent popup.
  • Deployment — the one response header your static host must set.
  • Testing — in-memory model tests and integration tests against a real server.

Recipes #

Complete shapes for common apps, each built from the features above:

  • Todo list — a single Collection, the “hello world” of Civility apps.
  • Journal — dated entries plus a settings document.
  • Gallery — images with @civility/blobs, synced lazily.
  • AI-powered — structured generation with @civility/services.
  • Public share links — publish a document as a read-only link with @civility/cloud.

Standalone HTML demos of the UI components live in examples/.