* feat: automated demo deploy to demo.lofi.host on each release On every successful publish, a workflow generates the starter app from the exact published JSR version, applies the checked-in demo overlay, builds, and deploys to Cloudflare Pages. The overlay restyles the starter into its volcanic opposite and showcases the release surface: s.privateTable encryption at rest, the strict CSP promoted from _headers.example to a real response header, and BYOS ticket enrollment on a compiled local-only build. A guard test pins overlay paths to STARTER_FILES so template renames fail at PR time. Demo links land in the README, docs, homepage, and site nav. * feat(demo): restructure the overlay into an incident board The demo now replaces the starter's task example the documented way rather than reskinning it: a new schema (incidents with sealed severity and status enums over s.privateTable), matching permissions, a three-state board island with per-row sync fates, and a top-bar strip that measures storage, network, and unsynced writes live. Account and device panels sit behind disclosures, provenance moves to the footer, and the page carries no eyebrow or pill elements. Write confirmations name the store, which is accurate in both local-only and enrolled-sync modes. * docs(site): bring both homepages current with the shipped surface The lofi landing gains three feature cells (server-blind fields, verbs with effects, the self-defending shell), sink-aware restore in the lifecycle band, a BYOS cross-link to /node, and an encryption caveat rewritten from 'the server reads unencrypted columns' to the sealing limits that actually remain. The engine-defect bullet drops its hardcoded count in favor of the mechanism. The node landing reframes tickets from 'bearer credentials' to the two-stage posture with device binding, and adds write verdicts and connection observability to the reference shelf. * docs(site): align the homepage demo line with the reworked build
- commit
- 6c73e5513cfc7283f51e5e1cae5f3945b5ba3aef
- parent
- 6cdf1ab412769dd8d5d299730317f7d9053767fd
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -0,0 +1,125 @@
1+name: Demo deploy2+3+# Deploys demo.lofi.host: the starter app generated by the real `create`4+# command from the exact published JSR version, with the checked-in demo5+# overlay (apps/demo/overlay) applied before the build. Runs after each6+# successful publish; the dispatch input redeploys any published version7+# without cutting a release.8+9+on:10+ workflow_run:11+ workflows: ["Publish to JSR"]12+ types: [completed]13+ workflow_dispatch:14+ inputs:15+ version:16+ description: "Published @nzip/lofi version (e.g. 0.8.0)"17+ required: true18+19+permissions:20+ contents: read21+22+concurrency:23+ group: demo-deploy24+ cancel-in-progress: false25+26+jobs:27+ deploy:28+ if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'29+ runs-on: ubuntu-latest30+ # the Cloudflare credentials live as secrets on the `dev` environment; a job31+ # must declare that environment for them to resolve32+ environment:33+ name: dev34+ url: https://demo.lofi.host35+ steps:36+ - uses: actions/checkout@v437+ - uses: denoland/setup-deno@v238+ with:39+ deno-version: v2.x40+41+ # A tag-triggered publish run reports its tag as head_branch.42+ - name: Resolve released version43+ id: version44+ env:45+ DISPATCH_VERSION: ${{ inputs.version }}46+ RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}47+ run: |48+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then49+ version="$DISPATCH_VERSION"50+ else51+ version="${RUN_HEAD_BRANCH#v}"52+ fi53+ if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then54+ echo "::error::cannot resolve a release version from '$version'"55+ exit 156+ fi57+ echo "version=$version" >> "$GITHUB_OUTPUT"58+59+ - name: Wait for JSR availability60+ run: |61+ v="${{ steps.version.outputs.version }}"62+ for attempt in $(seq 1 30); do63+ if curl -fsS --max-time 10 https://jsr.io/@nzip/lofi/meta.json \64+ | jq -e --arg v "$v" '.versions[$v]' > /dev/null; then65+ exit 066+ fi67+ sleep 1068+ done69+ echo "::error::@nzip/lofi@$v is not visible on JSR"70+ exit 171+72+ # Generated outside the checkout so the repository import map cannot73+ # shadow the published package.74+ - name: Generate the demo app from the published package75+ working-directory: ${{ runner.temp }}76+ run: |77+ deno run -A --minimum-dependency-age=0 \78+ "jsr:@nzip/lofi@${{ steps.version.outputs.version }}/create" demo-app79+80+ - name: Apply the demo overlay81+ run: |82+ deno run -A tools/demo_overlay.ts \83+ --app "${{ runner.temp }}/demo-app" \84+ --version "${{ steps.version.outputs.version }}"85+86+ # The publish quarantine also applies to the generated project's own87+ # dependency resolution; prime the cache the way tools/golden.ts does.88+ - name: Prime the registry cache89+ working-directory: ${{ runner.temp }}/demo-app90+ run: |91+ jq -r '.imports | to_entries[]92+ | select(.key == "@nzip/lofi" or (.key | startswith("@nzip/lofi/")))93+ | select(.key | endswith("/") | not) | .value' deno.json \94+ | sort -u | xargs deno cache --minimum-dependency-age=095+96+ - name: Doctor97+ working-directory: ${{ runner.temp }}/demo-app98+ run: deno task doctor99+100+ - name: Build101+ working-directory: ${{ runner.temp }}/demo-app102+ run: deno task build103+104+ # Pages consumes _headers, so the CSP the build documents in105+ # _headers.example ships as a real response header on the demo.106+ - name: Promote the CSP header snippet107+ working-directory: ${{ runner.temp }}/demo-app108+ run: |109+ if [ -f dist/_headers.example ]; then110+ cp dist/_headers.example dist/_headers111+ fi112+113+ # wrangler runs via npx (npm cache) rather than wrangler-action: the action114+ # installs wrangler with `npm i` into the checkout, and npm cannot graft115+ # onto the Deno-managed node_modules layout at the repo root.116+ - name: Deploy to Cloudflare Pages117+ env:118+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}119+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}120+ run: |121+ if ! npx --yes wrangler@4 pages project list | grep -q "demo-lofi-host"; then122+ npx --yes wrangler@4 pages project create demo-lofi-host --production-branch=main123+ fi124+ npx --yes wrangler@4 pages deploy "${{ runner.temp }}/demo-app/dist" \125+ --project-name=demo-lofi-host --branch=main@@ -11,7 +11,8 @@ can add [Jazz](https://jazz.tools)-powered sync and recovery later without repla
1111 or rewriting product code.1212 1313 [Documentation](https://lofi.host/docs) · [API reference](https://lofi.host/api) ·14-[JSR package](https://jsr.io/@nzip/lofi) · [MIT License](LICENSE)14+[Live demo](https://demo.lofi.host) · [JSR package](https://jsr.io/@nzip/lofi) ·15+[MIT License](LICENSE)1516 1617 Building with an AI agent? Feed it [llms.txt](https://lofi.host/llms.txt) for an index of the1718 documentation, or [llms-full.txt](https://lofi.host/llms-full.txt) for the complete docs and API@@ -29,7 +30,9 @@ deno task dev
2930 3031 Open the URL printed by the development server. The starter is a small task app: add a task and3132 reload the page to see that it remains in durable local storage. Once the page is open, disconnect32-the network and you can keep reading and writing data locally.33+the network and you can keep reading and writing data locally. Prefer to look first? A reworked34+build of the starter runs at [demo.lofi.host](https://demo.lofi.host), deployed by the release35+workflow from the published package.3336 3437 The generated app starts in local-only mode. You do not need an account, a backend, or an `.env`3538 file to begin building. To provision managed Jazz sync, scaffold with `--sync` or runBinary file.
@@ -0,0 +1,19 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">2+ <title id="title">lofi demo mark</title>3+ <defs>4+ <linearGradient id="heat" x1="0" y1="1" x2="1" y2="0">5+ <stop offset="0" stop-color="#b3122e" />6+ <stop offset="0.55" stop-color="#ff3d00" />7+ <stop offset="1" stop-color="#ff9d45" />8+ </linearGradient>9+ </defs>10+ <rect width="512" height="512" rx="112" fill="#0c0908" />11+ <path12+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"13+ fill="url(#heat)"14+ />15+ <path16+ d="M164 184h38v127h105v38H164ZM168 319l30 22-92 125-30-22Z"17+ fill="#0c0908"18+ />19+</svg>@@ -0,0 +1,93 @@
1+Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.Binary file.
Binary file.
Binary file.
Binary file.
@@ -0,0 +1,55 @@
1+{2+ "name": "lofi demo",3+ "short_name": "lofi demo",4+ "description": "The lofi starter app, restyled and deployed from each release",5+ "id": "./lofi-demo",6+ "lang": "en",7+ "dir": "ltr",8+ "start_url": "./",9+ "scope": "./",10+ "display": "standalone",11+ "orientation": "any",12+ "background_color": "#0c0908",13+ "theme_color": "#0c0908",14+ "icons": [15+ {16+ "src": "./icon-192.png",17+ "sizes": "192x192",18+ "type": "image/png",19+ "purpose": "any"20+ },21+ {22+ "src": "./icon-512.png",23+ "sizes": "512x512",24+ "type": "image/png",25+ "purpose": "any"26+ },27+ {28+ "src": "./icon-maskable-512.png",29+ "sizes": "512x512",30+ "type": "image/png",31+ "purpose": "maskable"32+ },33+ {34+ "src": "./icon-monochrome.svg",35+ "sizes": "any",36+ "type": "image/svg+xml",37+ "purpose": "monochrome"38+ }39+ ],40+ "shortcuts": [41+ {42+ "name": "Open incidents",43+ "short_name": "Incidents",44+ "description": "Open the demo incident board",45+ "url": "./#incidents",46+ "icons": [47+ {48+ "src": "./icon-192.png",49+ "sizes": "192x192",50+ "type": "image/png"51+ }52+ ]53+ }54+ ]55+}@@ -0,0 +1,21 @@
1+import { defineLofiApp } from "@nzip/lofi";2+import { app as schema } from "./schema.ts";3+4+/** Author-owned composition for the hosted demo at demo.lofi.host. */5+export const app = defineLofiApp({6+ name: "lofi-demo",7+ databaseName: "lofi-demo",8+ schema,9+ storage: "durable" as const,10+ // Identity is local-first: first boot opens a private, on-device account with11+ // no sign-in. The demo compiles without a managed Jazz app, so data stays in12+ // this browser unless the visitor enrolls their own sync ticket through the13+ // AccountGate island.14+ //15+ // Device credentials (WebAuthn/PRF) bind to their origin; the demo's16+ // production hostname is pinned so passkeys can enroll there, while17+ // localhost keeps working for development without configuration.18+ credentialOrigins: ["demo.lofi.host"] as readonly string[],19+ sync: { adapter: "jazz" as const },20+ repositoryUrl: "https://github.com/FelineStateMachine/lofi",21+});@@ -0,0 +1,174 @@
1+import { useState } from "preact/hooks";2+import { settleUiMutation } from "@nzip/lofi";3+import {4+ type BootProgress,5+ useBootProgress,6+ usePendingWrites,7+ useSyncStatus,8+} from "@nzip/lofi/preact";9+import {10+ type Incident,11+ type IncidentStatus,12+ type Severity,13+ useIncidentNotice,14+ useIncidents,15+} from "./use-incidents.ts";16+17+// A cold first visit waits on the engine download, not on storage; name the18+// wait it is actually in, with byte progress while the download runs.19+function loadingLabel(boot: BootProgress): string {20+ if (boot.phase !== "downloading") return "Opening persistent storage…";21+ const mb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);22+ return boot.totalBytes === null23+ ? `Downloading the app · ${mb(boot.loadedBytes)} MB…`24+ : `Downloading the app · ${mb(boot.loadedBytes)} of ${mb(boot.totalBytes)} MB…`;25+}26+27+const SEVERITIES: readonly Severity[] = ["smolder", "burn", "melt"];28+29+const COLUMNS: readonly { status: IncidentStatus; title: string }[] = [30+ { status: "burning", title: "Burning" },31+ { status: "contained", title: "Contained" },32+ { status: "out", title: "Out" },33+];34+35+const MOVES: Record<IncidentStatus, readonly { to: IncidentStatus; label: string }[]> = {36+ burning: [{ to: "contained", label: "Contain" }, { to: "out", label: "Extinguish" }],37+ contained: [{ to: "out", label: "Extinguish" }, { to: "burning", label: "Flare up" }],38+ out: [{ to: "burning", label: "Reignite" }],39+};40+41+function openedLabel(value: Incident["openedAt"]): string {42+ const opened = value instanceof Date ? value : new Date(String(value));43+ return opened.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });44+}45+46+/**47+ * Author-owned demo UI: one island over one encrypted table, structured as a48+ * three-state board. Reporting goes through a verb with effects; moving an49+ * incident between states goes through a plain verb; every row carries its50+ * real sync fate.51+ */52+export default function IncidentBoard() {53+ const { status, error, durability, incidents, failureKind, report, setStatus } = useIncidents();54+ const notice = useIncidentNotice();55+ const pending = usePendingWrites();56+ const boot = useBootProgress();57+ const [title, setTitle] = useState("");58+ const [severity, setSeverity] = useState<Severity>("burn");59+60+ return (61+ <section id="incidents" class="board" data-island="incidents">62+ <form63+ onSubmit={(event) => {64+ event.preventDefault();65+ const next = title.trim();66+ if (!next) return;67+ setTitle("");68+ // The verb's handle is thenable: settled here at saved, while the69+ // sync fate and its effects continue in the background.70+ void settleUiMutation(report(next, severity));71+ }}72+ >73+ <label for="new-incident">New incident</label>74+ <div class="composer">75+ <input76+ id="new-incident"77+ value={title}78+ onInput={(event) => setTitle(event.currentTarget.value)}79+ autocomplete="off"80+ />81+ <select82+ aria-label="Severity"83+ value={severity}84+ onChange={(event) => setSeverity(event.currentTarget.value as Severity)}85+ >86+ {SEVERITIES.map((option) => <option key={option} value={option}>{option}</option>)}87+ </select>88+ <button type="submit">Report</button>89+ </div>90+ </form>91+ <p class="state" role="status">92+ {status === "loading" && loadingLabel(boot)}93+ {status === "error" &&94+ `${failureKind === "read" ? "Read failed" : "Write failed"}: ${error}`}95+ {status === "ready" && `${incidents.length} incident(s) · ${96+ durability === "global"97+ ? "confirmed by the store"98+ : durability === "local"99+ ? "saved on this device"100+ : durability === "failed"101+ ? "write declined"102+ : "ready"103+ }`}104+ </p>105+ {pending.count > 0 && (106+ <p class="state" data-pending-writes={pending.count}>107+ {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync108+ </p>109+ )}110+ {notice && (111+ <p class="state" role="status" data-notice={notice.kind}>112+ {notice.text}113+ </p>114+ )}115+ <div class="columns">116+ {COLUMNS.map((column) => {117+ const rows = incidents.filter((incident) => incident.status === column.status);118+ return (119+ <section key={column.status} class="column" data-status={column.status}>120+ <h2>121+ {column.title} <span class="count">{rows.length}</span>122+ </h2>123+ {rows.length === 0124+ ? <p class="state column-empty">clear</p>125+ : (126+ <ul aria-label={column.title}>127+ {rows.map((incident) => (128+ <IncidentItem key={incident.id} incident={incident} setStatus={setStatus} />129+ ))}130+ </ul>131+ )}132+ </section>133+ );134+ })}135+ </div>136+ </section>137+ );138+}139+140+function IncidentItem(141+ { incident, setStatus }: {142+ incident: Incident;143+ setStatus: (id: string, status: IncidentStatus) => PromiseLike<unknown>;144+ },145+) {146+ const syncStatus = useSyncStatus(incident);147+ return (148+ <li class={`incident sev-${incident.severity}`}>149+ <div class="incident-head">150+ <span class="incident-title">{incident.title}</span>151+ <span class="incident-meta">152+ {incident.severity} · {openedLabel(incident.openedAt)}153+ </span>154+ </div>155+ <div class="incident-actions">156+ {MOVES[incident.status].map((move) => (157+ <button158+ key={move.to}159+ type="button"160+ class="move"161+ onClick={() => void settleUiMutation(setStatus(incident.id, move.to))}162+ >163+ {move.label}164+ </button>165+ ))}166+ {syncStatus !== "synced" && (167+ <span class="state" data-sync-status={syncStatus}>168+ {syncStatus === "waiting" ? "waiting to sync" : "declined"}169+ </span>170+ )}171+ </div>172+ </li>173+ );174+}@@ -0,0 +1,228 @@
1+import { useEffect, useRef } from "preact/hooks";2+3+/**4+ * Decorative full-viewport backdrop: domain-warped fbm smoke drifting up,5+ * a molten glow rising from the bottom edge, and sparse embers. Hand-rolled6+ * WebGL, no dependencies. Honors prefers-reduced-motion (one static frame),7+ * pauses while the tab is hidden, caps at ~30fps, and quietly does nothing8+ * when WebGL is unavailable (the stylesheet gradient stays behind it).9+ */10+11+const VERTEX_SOURCE = `12+attribute vec2 a_position;13+void main() {14+ gl_Position = vec4(a_position, 0.0, 1.0);15+}16+`;17+18+const FRAGMENT_SOURCE = `19+precision mediump float;20+21+uniform vec2 u_resolution;22+uniform float u_time;23+24+float hash(vec2 p) {25+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);26+}27+28+float noise(vec2 p) {29+ vec2 i = floor(p);30+ vec2 f = fract(p);31+ vec2 u = f * f * (3.0 - 2.0 * f);32+ float a = hash(i);33+ float b = hash(i + vec2(1.0, 0.0));34+ float c = hash(i + vec2(0.0, 1.0));35+ float d = hash(i + vec2(1.0, 1.0));36+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);37+}38+39+float fbm(vec2 p) {40+ float value = 0.0;41+ float amplitude = 0.5;42+ for (int i = 0; i < 5; i++) {43+ value += amplitude * noise(p);44+ p = p * 2.03 + vec2(11.7, 5.3);45+ amplitude *= 0.55;46+ }47+ return value;48+}49+50+float embers(vec2 uv, float t, float scale, float speed, float threshold) {51+ vec2 grid = vec2(scale, scale * 0.6);52+ vec2 p = uv * grid;53+ p.y -= t * speed;54+ vec2 cell = floor(p);55+ float seed = hash(cell);56+ if (seed < threshold) return 0.0;57+ vec2 center = fract(p) - 0.5;58+ center.x += (hash(cell + 7.0) - 0.5) * 0.6 + sin(t * 1.7 + seed * 40.0) * 0.12;59+ float flicker = 0.55 + 0.45 * sin(t * (2.0 + seed * 4.0) + seed * 90.0);60+ float glow = smoothstep(0.16, 0.0, length(center)) * flicker;61+ return glow * smoothstep(0.0, 0.25, uv.y) * smoothstep(1.05, 0.55, uv.y);62+}63+64+void main() {65+ vec2 uv = gl_FragCoord.xy / u_resolution;66+ vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0);67+ vec2 p = uv * aspect;68+ float t = u_time;69+70+ vec3 charcoal = vec3(0.047, 0.035, 0.031);71+ vec3 crimson = vec3(0.70, 0.07, 0.18);72+ vec3 magma = vec3(1.0, 0.24, 0.0);73+ vec3 emberTone = vec3(1.0, 0.62, 0.27);74+ vec3 smokeTone = vec3(0.36, 0.31, 0.28);75+76+ // Domain-warped smoke, advected upward.77+ vec2 drift = vec2(0.02 * sin(t * 0.11), -t * 0.045);78+ vec2 warp = vec2(79+ fbm(p * 2.1 + drift),80+ fbm(p * 2.1 + drift + vec2(4.2, 9.7))81+ );82+ float smoke = fbm(p * 2.6 + warp * 1.4 + vec2(0.0, -t * 0.09));83+ smoke = smoothstep(0.35, 0.95, smoke);84+85+ // Molten glow breathing along the bottom edge.86+ float ground = pow(1.0 - uv.y, 3.2);87+ float shimmer = 0.55 + 0.45 * fbm(vec2(p.x * 3.4, t * 0.22));88+ float heat = ground * shimmer;89+90+ vec3 color = charcoal;91+ color += smokeTone * smoke * (0.16 + 0.3 * heat);92+ color += mix(crimson, magma, shimmer) * heat * 0.55;93+ color += emberTone * pow(heat, 2.4) * 0.5;94+95+ float sparks = embers(uv, t, 26.0, 0.05, 0.965) +96+ embers(uv, t, 14.0, 0.028, 0.975) * 1.4;97+ color += emberTone * sparks;98+99+ // Vignette so content edges stay quiet.100+ float vignette = smoothstep(1.25, 0.45, length(uv - vec2(0.5, 0.42)));101+ color *= 0.72 + 0.28 * vignette;102+103+ gl_FragColor = vec4(color, 1.0);104+}105+`;106+107+const FRAME_INTERVAL_MS = 1000 / 30;108+const MAX_PIXEL_RATIO = 1.5;109+110+function compile(gl: WebGLRenderingContext, kind: number, source: string): WebGLShader | null {111+ const shader = gl.createShader(kind);112+ if (!shader) return null;113+ gl.shaderSource(shader, source);114+ gl.compileShader(shader);115+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {116+ gl.deleteShader(shader);117+ return null;118+ }119+ return shader;120+}121+122+function start(canvas: HTMLCanvasElement): () => void {123+ const gl = canvas.getContext("webgl", {124+ alpha: false,125+ antialias: false,126+ depth: false,127+ stencil: false,128+ powerPreference: "low-power",129+ });130+ if (!gl) return () => {};131+132+ const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SOURCE);133+ const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SOURCE);134+ const program = gl.createProgram();135+ if (!vertex || !fragment || !program) return () => {};136+ gl.attachShader(program, vertex);137+ gl.attachShader(program, fragment);138+ gl.linkProgram(program);139+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return () => {};140+ gl.useProgram(program);141+142+ const buffer = gl.createBuffer();143+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);144+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);145+ const position = gl.getAttribLocation(program, "a_position");146+ gl.enableVertexAttribArray(position);147+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);148+149+ const resolutionUniform = gl.getUniformLocation(program, "u_resolution");150+ const timeUniform = gl.getUniformLocation(program, "u_time");151+152+ const resize = () => {153+ const ratio = Math.min(globalThis.devicePixelRatio || 1, MAX_PIXEL_RATIO);154+ const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));155+ const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));156+ if (canvas.width !== width || canvas.height !== height) {157+ canvas.width = width;158+ canvas.height = height;159+ gl.viewport(0, 0, width, height);160+ }161+ };162+163+ const render = (seconds: number) => {164+ resize();165+ gl.uniform2f(resolutionUniform, canvas.width, canvas.height);166+ gl.uniform1f(timeUniform, seconds);167+ gl.drawArrays(gl.TRIANGLES, 0, 3);168+ };169+170+ const reducedMotion = globalThis.matchMedia("(prefers-reduced-motion: reduce)");171+ let frame = 0;172+ let lastFrameAt = 0;173+ let running = false;174+175+ const loop = (now: number) => {176+ frame = requestAnimationFrame(loop);177+ if (now - lastFrameAt < FRAME_INTERVAL_MS) return;178+ lastFrameAt = now;179+ render(now / 1000);180+ };181+182+ const stopLoop = () => {183+ running = false;184+ cancelAnimationFrame(frame);185+ };186+187+ const startLoop = () => {188+ if (running || reducedMotion.matches || document.hidden) return;189+ running = true;190+ frame = requestAnimationFrame(loop);191+ };192+193+ const applyMotionPreference = () => {194+ stopLoop();195+ // A developed, mid-drift frame reads as intentional art when static.196+ if (reducedMotion.matches) render(8);197+ else startLoop();198+ };199+200+ const onVisibility = () => {201+ if (document.hidden) stopLoop();202+ else startLoop();203+ };204+205+ applyMotionPreference();206+ reducedMotion.addEventListener("change", applyMotionPreference);207+ document.addEventListener("visibilitychange", onVisibility);208+ globalThis.addEventListener("resize", resize);209+210+ return () => {211+ stopLoop();212+ reducedMotion.removeEventListener("change", applyMotionPreference);213+ document.removeEventListener("visibilitychange", onVisibility);214+ globalThis.removeEventListener("resize", resize);215+ };216+}217+218+export default function MagmaBackdrop() {219+ const canvasRef = useRef<HTMLCanvasElement>(null);220+221+ useEffect(() => {222+ const canvas = canvasRef.current;223+ if (!canvas) return;224+ return start(canvas);225+ }, []);226+227+ return <canvas ref={canvasRef} class="magma-backdrop" aria-hidden="true" />;228+}@@ -0,0 +1,43 @@
1+import { useEffect, useState } from "preact/hooks";2+import { useBootProgress, usePendingWrites } from "@nzip/lofi/preact";3+4+/**5+ * Live facts in the top bar, read from the runtime and the browser: storage6+ * state, network state, and writes waiting to sync. Pull the cable and the7+ * network fact flips while the board keeps working; that behavior is the8+ * demo's whole argument, so it is measured, not asserted.9+ */10+export default function StatusStrip() {11+ const boot = useBootProgress();12+ const pending = usePendingWrites();13+ const [online, setOnline] = useState(true);14+15+ useEffect(() => {16+ const update = () => setOnline(navigator.onLine);17+ update();18+ globalThis.addEventListener("online", update);19+ globalThis.addEventListener("offline", update);20+ return () => {21+ globalThis.removeEventListener("online", update);22+ globalThis.removeEventListener("offline", update);23+ };24+ }, []);25+26+ const storage = boot.phase === "ready"27+ ? "open"28+ : boot.phase === "failed"29+ ? "failed"30+ : boot.phase === "downloading"31+ ? "downloading"32+ : "opening";33+34+ return (35+ <p class="status-strip" role="status">36+ <span data-fact="storage">on-device storage: {storage}</span>37+ <span data-fact="network" data-online={online ? "yes" : "no"}>38+ network: {online ? "online" : "offline"}39+ </span>40+ <span data-fact="pending">unsynced writes: {pending.count}</span>41+ </p>42+ );43+}@@ -0,0 +1,118 @@
1+import { useCallback, useEffect, useState } from "preact/hooks";2+import type { RowOf, WriteHandle } from "@nzip/lofi";3+import { useLiveQuery, useWrite } from "@nzip/lofi/preact";4+import { s } from "@nzip/lofi/schema";5+import { app } from "../app.ts";6+7+/**8+ * Author-owned domain module. Tables are the nouns of the app; verbs are what9+ * happens to them. This module declares two verbs over the `incidents` table,10+ * `reportIncident` with effects that run when the write's sync fate settles11+ * and `setIncidentStatus` as a plain verb, then wraps them in a domain hook.12+ */13+const incidentsTable = app.schema.incidents;14+15+/** The row type comes straight from the declared schema. */16+export type Incident = RowOf<typeof incidentsTable>;17+18+export type Severity = Incident["severity"];19+export type IncidentStatus = Incident["status"];20+21+/** A one-line consequence or compensation surfaced to the UI. */22+export type IncidentNotice = { kind: "synced" | "rejected"; text: string };23+24+// A tiny author-owned notice channel: effect handlers run outside any25+// component, so they publish through module state and hooks subscribe.26+let notice: IncidentNotice | null = null;27+const noticeListeners = new Set<() => void>();28+29+function publishNotice(next: IncidentNotice | null): void {30+ notice = next;31+ for (const listener of [...noticeListeners]) listener();32+}33+34+/**35+ * The reporting verb. Its effect units are declared once, here: the36+ * consequence runs when the store confirms the row, the compensation runs if37+ * a stale-policy write is denied, even if the app restarted in between.38+ */39+export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {40+ effects: [s.log("incident-reported")],41+ onSynced: (incident) => {42+ publishNotice({43+ kind: "synced",44+ text: `"${incident.title ?? "Incident"}" confirmed by the store`,45+ });46+ },47+ onRejected: (incident) => {48+ // The engine already rolled the denied row back out of local reads; this49+ // compensates what the user was told.50+ publishNotice({51+ kind: "rejected",52+ text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,53+ });54+ },55+});56+57+/** Moving an incident between states is a plain verb: same lifecycle. */58+export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));59+60+/** Subscribes to the latest effect notice; `null` until one is published. */61+export function useIncidentNotice(): IncidentNotice | null {62+ const [current, setCurrent] = useState<IncidentNotice | null>(notice);63+ useEffect(() => {64+ const listener = () => setCurrent(notice);65+ noticeListeners.add(listener);66+ listener();67+ return () => void noticeListeners.delete(listener);68+ }, []);69+ return current;70+}71+72+export function useIncidents() {73+ const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);74+ const [lastWrite, setLastWrite] = useState<WriteHandle<Incident> | null>(null);75+ const [writeError, setWriteError] = useState<string | null>(null);76+ const write = useWrite(lastWrite);77+78+ const report = useCallback((title: string, severity: Severity) => {79+ // The verb returns a WriteHandle: awaiting it means saved on this device;80+ // `.synced` and the declared effects follow the store's confirmation.81+ const handle = reportIncident({82+ title,83+ severity,84+ status: "burning",85+ openedAt: new Date(),86+ });87+ setLastWrite(handle);88+ setWriteError(null);89+ handle.saved.then(undefined, (error) => {90+ setWriteError(error instanceof Error ? error.message : String(error));91+ });92+ return handle;93+ }, []);94+ const setStatus = useCallback(95+ (id: string, status: IncidentStatus) => setIncidentStatus(id, { status }),96+ [],97+ );98+99+ const durability = write.stage === "synced"100+ ? "global" as const101+ : write.stage === "saved" || write.stage === "syncing"102+ ? "local" as const103+ : write.stage === "rejected"104+ ? "failed" as const105+ : "none" as const;106+107+ return {108+ status: query.status === "error" || writeError !== null ? "error" as const : query.status,109+ error: query.error ?? writeError,110+ durability,111+ lastWriteStage: write.stage,112+ lastWriteReason: write.reason,113+ incidents: query.rows,114+ failureKind: query.status === "error" ? "read" as const : "write" as const,115+ report,116+ setStatus,117+ };118+}@@ -0,0 +1,40 @@
1+---2+// Author-owned application shell.3+export interface Props {4+ title: string;5+}6+7+const { title } = Astro.props;8+const deploymentBase = import.meta.env.BASE_URL;9+const publicAsset = (name: string) => `${deploymentBase}${name}`;10+---11+12+<!doctype html>13+<html lang="en">14+ <head>15+ <meta charset="UTF-8" />16+ <meta name="viewport" content="width=device-width" />17+ <meta name="theme-color" content="#0c0908" />18+ <link rel="icon" href={publicAsset("favicon.svg")} type="image/svg+xml" />19+ <link rel="apple-touch-icon" href={publicAsset("apple-touch-icon.png")} />20+ <link rel="manifest" href={publicAsset("manifest.webmanifest")} />21+ <link22+ rel="preload"23+ href={publicAsset("fonts/chakra-petch-700.woff2")}24+ as="font"25+ type="font/woff2"26+ crossorigin27+ />28+ <title>{title}</title>29+ </head>30+ <body>31+ <main><slot /></main>32+ <script>33+ import { bootLofi } from "@nzip/lofi";34+ // Importing app.ts registers defineLofiApp before the runtime boots.35+ import { app } from "../app.ts";36+ void app;37+ void bootLofi();38+ </script>39+ </body>40+</html>@@ -0,0 +1,38 @@
1+---2+import AccountGate from "../islands/AccountGate";3+import IncidentBoard from "../islands/IncidentBoard";4+import MagmaBackdrop from "../islands/MagmaBackdrop";5+import StatusStrip from "../islands/StatusStrip";6+import { DeviceStatus } from "@nzip/lofi/preact";7+import Shell from "../layouts/Shell.astro";8+import { app } from "../app.ts";9+import "../styles/global.css";10+---11+12+<Shell title="lofi demo">13+ <MagmaBackdrop client:idle />14+ <header class="topbar">15+ <h1>lofi demo</h1>16+ <StatusStrip client:load />17+ </header>18+ <IncidentBoard client:load />19+ <details class="panel">20+ <summary>Account and sync</summary>21+ <AccountGate client:load />22+ </details>23+ <details class="panel">24+ <summary>Device report</summary>25+ <DeviceStatus client:load />26+ </details>27+ <footer>28+ <p>29+ Generated from <code>@nzip/lofi@__LOFI_DEMO_VERSION__</code> by the release workflow. The30+ incident board replaces the starter's task example; everything else is the generated app.31+ </p>32+ <p>33+ <a href="https://lofi.host">lofi.host</a>34+ <a href="https://lofi.host/docs/getting-started">docs</a>35+ <a href={app.repositoryUrl} rel="noreferrer">source</a>36+ </p>37+ </footer>38+</Shell>@@ -0,0 +1,9 @@
1+import { s } from "@nzip/lofi/schema";2+import { app } from "./schema.ts";3+4+export default s.definePermissions(app, ({ policy, session }) => {5+ policy.incidents.allowInsert.always();6+ policy.incidents.allowRead.where({ $createdBy: session.user_id });7+ policy.incidents.allowUpdate.where({ $createdBy: session.user_id });8+ policy.incidents.allowDelete.where({ $createdBy: session.user_id });9+});@@ -0,0 +1,16 @@
1+import { s } from "@nzip/lofi/schema";2+3+// Author-owned schema, declared through the lofi schema surface; UI islands4+// consume it only through public @nzip/lofi seams. The demo tracks fire5+// incidents instead of the starter's tasks, and declares the table with6+// s.privateTable so every column is encrypted at rest by default.7+const schema = {8+ incidents: s.privateTable("incidents", {9+ title: s.string(),10+ severity: s.enum("smolder", "burn", "melt"),11+ status: s.enum("burning", "contained", "out"),12+ openedAt: s.timestamp(),13+ }),14+};15+16+export const app = s.defineApp(schema);@@ -0,0 +1,600 @@
1+/*2+ * The demo's design language deliberately opposes the starter's calm3+ * mint-on-forest-green: charred blacks, molten gradient accents, smoke4+ * greys, sharp corners, condensed display headings over a monospace body.5+ * The page is structured as a working application: top bar with measured6+ * facts, the incident board, then account and device panels behind7+ * disclosures. The WebGL backdrop (.magma-backdrop) renders behind8+ * everything; the body gradient keeps the page intentional without WebGL.9+ */10+11+@font-face {12+ font-family: "Chakra Petch";13+ src: url("/fonts/chakra-petch-700.woff2") format("woff2");14+ font-weight: 700;15+ font-style: normal;16+ font-display: swap;17+}18+19+:root {20+ --char-0: #0c0908;21+ --char-1: #170f0a;22+ --char-2: #221510;23+ --char-line: #3a231a;24+ --ash: #cfbfb6;25+ --bone: #f6ebe2;26+ --smoke: #9c8a80;27+ --crimson: #b3122e;28+ --magma: #ff3d00;29+ --ember: #ff9d45;30+ --heat: linear-gradient(120deg, #b3122e, #ff3d00 55%, #ff9d45);31+ --focus: #ffcf99;32+ --alert: #ff8a75;33+ color: var(--ash);34+ background: var(--char-0);35+ font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;36+ font-size: 15px;37+ line-height: 1.65;38+}39+40+* {41+ box-sizing: border-box;42+}43+44+::selection {45+ background: var(--magma);46+ color: var(--char-0);47+}48+49+body {50+ margin: 0;51+ background:52+ radial-gradient(120% 55% at 50% 110%, rgba(255, 61, 0, 0.16), transparent 62%),53+ radial-gradient(70% 40% at 85% 0%, rgba(179, 18, 46, 0.1), transparent 70%),54+ var(--char-0);55+ min-height: 100vh;56+}57+58+.magma-backdrop {59+ position: fixed;60+ inset: 0;61+ width: 100%;62+ height: 100%;63+ z-index: -1;64+ pointer-events: none;65+}66+67+main {68+ margin: 0 auto;69+ max-width: 78rem;70+ min-height: 100vh;71+ padding: 2rem 1.5rem 3rem;72+ position: relative;73+}74+75+h1,76+h2,77+h3,78+summary {79+ font-family: "Chakra Petch", ui-sans-serif, system-ui, sans-serif;80+ font-weight: 700;81+ text-transform: uppercase;82+}83+84+/* The package and starter diagnostics panels render small kicker lines the85+ demo's structure does not use. */86+.eyebrow {87+ display: none;88+}89+90+.topbar {91+ align-items: baseline;92+ border-bottom: 1px solid var(--char-line);93+ display: flex;94+ flex-wrap: wrap;95+ gap: 0.5rem 1.5rem;96+ justify-content: space-between;97+ padding-bottom: 0.9rem;98+}99+100+.topbar h1 {101+ background: var(--heat);102+ background-clip: text;103+ -webkit-background-clip: text;104+ color: transparent;105+ filter: drop-shadow(0 0 16px rgba(255, 61, 0, 0.3));106+ font-size: 1.35rem;107+ letter-spacing: 0.02em;108+ margin: 0;109+}110+111+.status-strip {112+ color: var(--smoke);113+ display: flex;114+ flex-wrap: wrap;115+ font-size: 0.78rem;116+ gap: 0.4rem 1.4rem;117+ margin: 0;118+}119+120+.status-strip [data-fact="network"][data-online="no"] {121+ color: var(--alert);122+}123+124+.board {125+ background: linear-gradient(180deg, rgba(255, 61, 0, 0.05), transparent 30%), var(--char-2);126+ border: 1px solid var(--char-line);127+ border-radius: 2px;128+ margin-top: 1.5rem;129+ padding: 1.4rem;130+ position: relative;131+}132+133+.board::before {134+ background: var(--heat);135+ content: "";136+ height: 2px;137+ left: -1px;138+ position: absolute;139+ right: -1px;140+ top: -1px;141+}142+143+label {144+ color: var(--bone);145+ display: block;146+ font-weight: 750;147+ margin-bottom: 0.5rem;148+}149+150+.composer {151+ display: flex;152+ gap: 0.5rem;153+}154+155+input,156+select,157+button {158+ border: 1px solid var(--char-line);159+ border-radius: 2px;160+ font: inherit;161+ padding: 0.7rem 0.8rem;162+}163+164+input[type="text"],165+input:not([type]) {166+ background: #17100c;167+ caret-color: var(--ember);168+ color: var(--bone);169+ flex: 1;170+ min-width: 0;171+}172+173+select {174+ background: #17100c;175+ color: var(--bone);176+}177+178+button {179+ background: var(--heat);180+ border: 0;181+ color: #1a0703;182+ cursor: pointer;183+ font-weight: 800;184+ letter-spacing: 0.05em;185+ text-transform: uppercase;186+}187+188+button:hover {189+ box-shadow: 0 0 20px rgba(255, 61, 0, 0.4);190+}191+192+button:focus-visible,193+input:focus-visible,194+select:focus-visible,195+a:focus-visible,196+summary:focus-visible {197+ outline: 3px solid var(--focus);198+ outline-offset: 2px;199+}200+201+.state {202+ color: var(--smoke);203+ min-height: 1.5rem;204+}205+206+.columns {207+ display: grid;208+ gap: 1rem;209+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));210+ margin-top: 1rem;211+}212+213+.column {214+ background: var(--char-1);215+ border: 1px solid var(--char-line);216+ border-radius: 2px;217+ min-height: 16rem;218+ padding: 1rem;219+}220+221+.column h2 {222+ border-bottom: 1px solid var(--char-line);223+ color: var(--bone);224+ display: flex;225+ font-size: 0.95rem;226+ justify-content: space-between;227+ letter-spacing: 0.05em;228+ margin: 0 0 0.9rem;229+ padding-bottom: 0.6rem;230+}231+232+.column .count {233+ color: var(--ember);234+}235+236+.column-empty {237+ font-size: 0.8rem;238+}239+240+ul {241+ display: grid;242+ gap: 0.65rem;243+ list-style: none;244+ margin: 0;245+ padding: 0;246+}247+248+.incident {249+ background: var(--char-0);250+ border-left: 3px solid var(--magma);251+ display: grid;252+ gap: 0.5rem;253+ padding: 0.7rem 0.8rem;254+}255+256+.incident.sev-smolder {257+ border-left-color: var(--ember);258+}259+260+.incident.sev-burn {261+ border-left-color: var(--magma);262+}263+264+.incident.sev-melt {265+ border-left-color: var(--crimson);266+ box-shadow: inset 0 0 18px rgba(179, 18, 46, 0.22);267+}268+269+.incident-head {270+ display: grid;271+ gap: 0.15rem;272+}273+274+.incident-title {275+ color: var(--bone);276+ overflow-wrap: anywhere;277+}278+279+.incident-meta {280+ color: var(--smoke);281+ font-size: 0.75rem;282+}283+284+.incident-actions {285+ align-items: center;286+ display: flex;287+ flex-wrap: wrap;288+ gap: 0.4rem;289+}290+291+.incident-actions .state {292+ font-size: 0.75rem;293+ min-height: 0;294+}295+296+button.move {297+ background: transparent;298+ border: 1px solid var(--char-line);299+ color: var(--ember);300+ font-size: 0.72rem;301+ padding: 0.35rem 0.55rem;302+}303+304+button.move:hover {305+ border-color: var(--magma);306+ box-shadow: none;307+}308+309+[data-status="out"] .incident {310+ border-left-color: var(--char-line);311+ box-shadow: none;312+ opacity: 0.6;313+}314+315+[data-status="out"] .incident-title {316+ color: var(--smoke);317+ text-decoration: line-through;318+}319+320+.panel {321+ background: var(--char-1);322+ border: 1px solid var(--char-line);323+ border-radius: 2px;324+ margin-top: 1rem;325+}326+327+.panel summary {328+ color: var(--bone);329+ cursor: pointer;330+ font-size: 0.9rem;331+ letter-spacing: 0.05em;332+ padding: 0.9rem 1.2rem;333+}334+335+.panel summary::marker {336+ color: var(--magma);337+}338+339+.panel > :not(summary) {340+ margin: 0 1.2rem 1.2rem;341+}342+343+.account {344+ background: var(--char-0);345+ border: 1px solid var(--char-line);346+ border-radius: 2px;347+ color: var(--ash);348+ line-height: 1.6;349+ margin-top: 0;350+ padding: 1.4rem;351+}352+353+.account-out {354+ border-color: var(--char-line);355+}356+357+.account h2 {358+ color: var(--bone);359+ margin: 0 0 1rem;360+}361+362+.account-field {363+ margin: 0.75rem 0 0.25rem;364+}365+366+.account-field input {367+ max-width: 24rem;368+ width: 100%;369+}370+371+.account-field .account-note {372+ margin: 0.35rem 0 0;373+}374+375+.account-actions {376+ display: flex;377+ flex-wrap: wrap;378+ gap: 0.6rem;379+ margin: 0.5rem 0 0.25rem;380+}381+382+.account-secondary {383+ background: transparent;384+ border: 1px solid var(--char-line);385+ color: var(--bone);386+}387+388+.account button:disabled {389+ cursor: not-allowed;390+ opacity: 0.55;391+}392+393+.account-error {394+ color: var(--alert);395+}396+397+.account-note {398+ color: var(--smoke);399+ font-size: 0.9rem;400+}401+402+.account dl {403+ display: grid;404+ gap: 0.5rem;405+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));406+ margin: 0.5rem 0 0;407+}408+409+.account dl div {410+ background: var(--char-1);411+ border-left: 2px solid var(--char-line);412+ padding: 0.75rem;413+}414+415+.account dt {416+ color: var(--ember);417+ font-size: 0.72rem;418+ font-weight: 800;419+ letter-spacing: 0.08em;420+ text-transform: uppercase;421+}422+423+.account dd {424+ margin: 0.25rem 0 0;425+ overflow-wrap: anywhere;426+}427+428+.account-caps dd {429+ color: var(--bone);430+}431+432+.device-status {433+ background: var(--char-0);434+ border: 1px solid var(--char-line);435+ border-radius: 2px;436+ padding: 1.4rem;437+}438+439+.device-status h2 {440+ color: var(--bone);441+ margin: 0 0 0.5rem;442+}443+444+.device-status dl {445+ display: grid;446+ gap: 0.5rem;447+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));448+}449+450+.device-status dl div {451+ background: var(--char-1);452+ border-left: 2px solid var(--char-line);453+ padding: 0.75rem;454+}455+456+.device-status dt {457+ color: var(--ember);458+ font-size: 0.72rem;459+ font-weight: 800;460+ letter-spacing: 0.08em;461+ text-transform: uppercase;462+}463+464+.device-status dd {465+ margin: 0.25rem 0 0;466+}467+468+.device-category {469+ border-top: 1px solid var(--char-line);470+ margin-top: 1.25rem;471+ padding-top: 1rem;472+}473+474+.device-category h3 {475+ color: var(--bone);476+ font-size: 1rem;477+ margin: 0 0 0.15rem;478+}479+480+.runtime-recovery {481+ border: 1px solid var(--magma);482+ border-radius: 2px;483+ margin: 1rem 0;484+ padding: 1rem;485+}486+487+.runtime-recovery h4,488+.runtime-recovery p {489+ margin-top: 0;490+}491+492+.device-hook {493+ color: var(--smoke);494+ font-size: 0.8rem;495+ margin: 0 0 0.75rem;496+}497+498+.device-hook code {499+ color: var(--ember);500+}501+502+.pwa-actions {503+ background: var(--char-1);504+ border: 1px solid var(--char-line);505+ border-radius: 2px;506+ margin-top: 1rem;507+ padding: 1rem;508+}509+510+.pwa-actions h3 {511+ color: var(--bone);512+ margin: 0 0 0.75rem;513+}514+515+.pwa-actions p,516+.pwa-actions ol {517+ color: var(--ash);518+ line-height: 1.55;519+}520+521+.pwa-actions ol {522+ margin-bottom: 0;523+ padding-left: 1.4rem;524+}525+526+.pwa-actions .pwa-error {527+ color: var(--alert);528+}529+530+footer {531+ border-top: 1px solid var(--char-line);532+ color: var(--smoke);533+ font-size: 0.85rem;534+ margin-top: 2rem;535+ padding-top: 1rem;536+}537+538+footer code {539+ color: var(--ember);540+}541+542+footer a {543+ color: var(--ember);544+ margin-right: 1.2rem;545+}546+547+.topbar,548+.board,549+.panel,550+footer {551+ animation: rise 0.55s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;552+}553+554+.board {555+ animation-delay: 0.08s;556+}557+558+.panel:nth-of-type(1) {559+ animation-delay: 0.16s;560+}561+562+.panel:nth-of-type(2) {563+ animation-delay: 0.22s;564+}565+566+footer {567+ animation-delay: 0.28s;568+}569+570+@keyframes rise {571+ from {572+ opacity: 0;573+ transform: translateY(14px);574+ }575+576+ to {577+ opacity: 1;578+ transform: none;579+ }580+}581+582+@media (prefers-reduced-motion: reduce) {583+ *,584+ *::before,585+ *::after {586+ animation: none ;587+ transition: none ;588+ }589+}590+591+@media (max-width: 40rem) {592+ main {593+ padding: 1.5rem 1rem 2.5rem;594+ }595+596+ .composer {597+ align-items: stretch;598+ flex-direction: column;599+ }600+}@@ -164,6 +164,10 @@ The app is the only thing that needs hosting: sync is optional infrastructure th
164164 a managed Jazz app or at [a self-hosted sync node](/node) they run themselves — the deployment does165165 not change either way.166166 167+A worked example runs at [demo.lofi.host](https://demo.lofi.host): the starter app, generated from168+the published package by the release workflow and uploaded as `dist/` to Cloudflare Pages, with169+`dist/_headers.example` promoted to `_headers` so the policy below ships as a real response header.170+167171 ### Content-Security-Policy168172 169173 Every built page carries a strict Content-Security-Policy in a `<meta>` tag: scripts and styles are@@ -11,7 +11,8 @@ deno task dev
1111 ```1212 1313 Open the URL printed by Astro. The starter contains a task list, an account panel when sync is14-available, and device/runtime diagnostics.14+available, and device/runtime diagnostics. A hosted instance of this same starter, restyled, runs at15+[demo.lofi.host](https://demo.lofi.host); it is generated and deployed from each release tag.1516 1617 Verify the local-first baseline before changing anything:1718 @@ -0,0 +1,121 @@
1+2+3+/**4+ * Applies the demo overlay (apps/demo/overlay) onto a generated lofi app.5+ *6+ * Every overlay file replaces its counterpart in the generated project; a file7+ * with no counterpart is an error unless it is listed in {@link NEW_FILES}.8+ * Text files may carry the `__LOFI_DEMO_VERSION__` placeholder, which is9+ * replaced with the released package version.10+ *11+ * Usage: deno run -A tools/demo_overlay.ts --app <path> [--version <semver>]12+ */13+14+import { dirname, join, relative, resolve } from "node:path";15+import { fileURLToPath } from "node:url";16+import { LOFI_VERSION } from "../package/version.ts";17+18+/** Overlay files that intentionally have no counterpart in the starter. */19+export const NEW_FILES: readonly string[] = [20+ "src/islands/IncidentBoard.tsx",21+ "src/islands/MagmaBackdrop.tsx",22+ "src/islands/StatusStrip.tsx",23+ "src/islands/use-incidents.ts",24+ "public/fonts/chakra-petch-700.woff2",25+ "public/fonts/OFL.txt",26+];27+28+/** The placeholder substituted with the released version. */29+export const VERSION_PLACEHOLDER = "__LOFI_DEMO_VERSION__";30+31+const TEXT_EXTENSIONS = [32+ ".ts",33+ ".tsx",34+ ".astro",35+ ".css",36+ ".json",37+ ".webmanifest",38+ ".svg",39+ ".md",40+];41+42+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");43+44+/** The overlay source directory, resolved from the repository checkout. */45+export const OVERLAY_ROOT = join(repositoryRoot, "apps", "demo", "overlay");46+47+function isTextFile(path: string): boolean {48+ return TEXT_EXTENSIONS.some((extension) => path.endsWith(extension));49+}50+51+/** Lists overlay file paths relative to the overlay root, sorted. */52+export async function listOverlayFiles(root: string = OVERLAY_ROOT): Promise<string[]> {53+ const files: string[] = [];54+ async function walk(directory: string): Promise<void> {55+ for await (const entry of Deno.readDir(directory)) {56+ const path = join(directory, entry.name);57+ if (entry.isDirectory) await walk(path);58+ else if (entry.isFile) files.push(relative(root, path));59+ }60+ }61+ await walk(root);62+ return files.sort();63+}64+65+/** Copies the overlay onto the generated app and substitutes the version. */66+export async function applyOverlay(appDirectory: string, version: string): Promise<string[]> {67+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {68+ throw new Error(`--version must be an exact semantic version, got ${JSON.stringify(version)}`);69+ }70+ const appRoot = resolve(appDirectory);71+ const appStat = await Deno.stat(appRoot).catch(() => null);72+ if (appStat === null || !appStat.isDirectory) {73+ throw new Error(`generated app directory not found: ${appRoot}`);74+ }75+76+ const overlayFiles = await listOverlayFiles();77+ if (overlayFiles.length === 0) throw new Error(`overlay is empty: ${OVERLAY_ROOT}`);78+79+ for (const relativePath of overlayFiles) {80+ const target = join(appRoot, relativePath);81+ const counterpart = await Deno.stat(target).catch(() => null);82+ if (counterpart === null && !NEW_FILES.includes(relativePath)) {83+ throw new Error(84+ `overlay file ${relativePath} has no counterpart in the generated app; ` +85+ `the starter layout changed, or the file belongs in NEW_FILES`,86+ );87+ }88+ await Deno.mkdir(dirname(target), { recursive: true });89+ const source = join(OVERLAY_ROOT, relativePath);90+ if (isTextFile(relativePath)) {91+ const contents = await Deno.readTextFile(source);92+ await Deno.writeTextFile(target, contents.replaceAll(VERSION_PLACEHOLDER, version));93+ } else {94+ const bytes = await Deno.readFile(source);95+ if (new TextDecoder("utf-8", { fatal: false }).decode(bytes).includes(VERSION_PLACEHOLDER)) {96+ throw new Error(97+ `overlay file ${relativePath} carries the version placeholder ` +98+ `but is not recognized as a text file, so it would ship unsubstituted`,99+ );100+ }101+ await Deno.writeFile(target, bytes);102+ }103+ }104+ return overlayFiles;105+}106+107+if (import.meta.main) {108+ let appDirectory = "";109+ let version = LOFI_VERSION;110+ const args = [...Deno.args];111+ for (let index = 0; index < args.length; index++) {112+ const argument = args[index];113+ if (argument === "--app") appDirectory = args[++index] ?? "";114+ else if (argument === "--version") version = args[++index] ?? "";115+ else throw new Error(`unknown demo-overlay argument ${JSON.stringify(argument)}`);116+ }117+ if (appDirectory === "") throw new Error("--app <path to generated app> is required");118+ const applied = await applyOverlay(appDirectory, version);119+ console.log(`applied ${applied.length} overlay files onto ${resolve(appDirectory)}:`);120+ for (const path of applied) console.log(` ${path}`);121+}@@ -0,0 +1,36 @@
1+import { STARTER_FILES } from "../package/starter_template.ts";2+import { listOverlayFiles, NEW_FILES, OVERLAY_ROOT, VERSION_PLACEHOLDER } from "./demo_overlay.ts";3+import { join } from "node:path";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+Deno.test("every overlay file shadows a starter file or is allowlisted", async () => {10+ const overlayFiles = await listOverlayFiles();11+ assert(overlayFiles.length > 0, "the demo overlay must not be empty");12+ const starter = new Set(STARTER_FILES);13+ const orphans = overlayFiles.filter(14+ (path) => !starter.has(path) && !NEW_FILES.includes(path),15+ );16+ assert(17+ orphans.length === 0,18+ `overlay files without a starter counterpart (rename them with the starter ` +19+ `or add them to NEW_FILES): ${orphans.join(", ")}`,20+ );21+});22+23+Deno.test("allowlisted new files exist in the overlay", async () => {24+ const overlayFiles = new Set(await listOverlayFiles());25+ const missing = NEW_FILES.filter((path) => !overlayFiles.has(path));26+ assert(missing.length === 0, `NEW_FILES entries missing from the overlay: ${missing.join(", ")}`);27+});28+29+Deno.test("the demo landing page stamps the released version", async () => {30+ const index = await Deno.readTextFile(join(OVERLAY_ROOT, "src/pages/index.astro"));31+ assert(32+ index.includes(VERSION_PLACEHOLDER),33+ `src/pages/index.astro must carry ${VERSION_PLACEHOLDER} so visitors can see ` +34+ `which release produced the demo`,35+ );36+});@@ -278,6 +278,7 @@ const config: Config = {
278278 items: [279279 { label: "docs", to: "/docs" },280280 { label: "api", to: "/api" },281+ { label: "demo", href: "https://demo.lofi.host" },281282 {282283 label: "github",283284 href: "https://github.com/FelineStateMachine/lofi",@@ -311,6 +312,7 @@ const config: Config = {
311312 items: [312313 { label: "docs", to: "/docs" },313314 { label: "api", to: "/api" },315+ { label: "demo", href: "https://demo.lofi.host" },314316 {315317 label: "github",316318 href: "https://github.com/FelineStateMachine/lofi",@@ -46,8 +46,36 @@ const FEATURES: Array<{ title: string; body: ReactNode }> = [
4646 },4747 {4848 title: "Optional sync and recovery",49- body:50- "Users make that same account portable when you connect managed Jazz sync: a passkey where available, a recovery phrase as the portable fallback.",49+ body: (50+ <>51+ Users make that same account portable when you connect managed Jazz52+ sync: a passkey where available, a recovery phrase as the portable53+ fallback. Or they sync to <Link to="/node">a node they run</Link> — the54+ app needs zero changes either way.55+ </>56+ ),57+ },58+ {59+ title: "Server-blind fields",60+ body: (61+ <>62+ Columns are sealed on the client before they sync —{" "}63+ <code className="inline-mono">s.privateTable</code>{" "}64+ encrypts by default, and shared sealed columns extend that to groups.65+ The keys stay with users; the server relays what it cannot read.66+ </>67+ ),68+ },69+ {70+ title: "Verbs with effects",71+ body: (72+ <>73+ Tables are the nouns; mutations declared with{" "}74+ <code className="inline-mono">s.mutation</code>{" "}75+ are the verbs. Their sync-boundary effects run from a durable journal76+ on the device that performed the intent — once, even across restarts.77+ </>78+ ),5179 },5280 {5381 title: "Narrow collaboration templates",@@ -59,6 +87,11 @@ const FEATURES: Array<{ title: string; body: ReactNode }> = [
5987 </>6088 ),6189 },90+ {91+ title: "A shell that defends itself",92+ body:93+ "Generated apps ship a strict Content-Security-Policy, detect a forked storage container before it splits the account, and show honest progress on the one first load that needs the network.",94+ },6295 {6396 title: "Local-first test helpers",6497 body:@@ -79,7 +112,7 @@ export default function Home(): ReactNode {
79112 return (80113 <Layout81114 title="lofi"82- description="lofi generates installable Deno web apps that open immediately, keep working offline, and sync when users choose. Astro shell, Preact islands, Jazz 2 CRDT sync."115+ description="lofi generates installable Deno web apps that open immediately, keep working offline, and sync when users choose — with fields the server can't read. Astro shell, Preact islands, Jazz 2 CRDT sync."83116 >84117 <main className="landing">85118 {/* ===================== HERO: MARK + CLAIM ===================== */}@@ -111,6 +144,9 @@ export default function Home(): ReactNode {
111144 <Link className="btn btn--primary" to="/docs/getting-started">112145 Read the guide113146 </Link>147+ <Link className="btn" href="https://demo.lofi.host">148+ live demo149+ </Link>114150 <Link115151 className="btn"116152 href="https://github.com/FelineStateMachine/lofi"@@ -163,7 +199,8 @@ export default function Home(): ReactNode {
163199 <p>164200 Connect managed Jazz sync and a user can back up the account165201 they already have — recovering it with a passkey where166- supported, or a 24-word phrase. Data made offline comes along.202+ supported, or a 24-word phrase. Data made offline comes along,203+ and a fresh device can connect and recover in either order.167204 </p>168205 <p className="life-cmd">169206 <code className="inline-mono">deno task jazz:provision</code>@@ -191,6 +228,9 @@ export default function Home(): ReactNode {
191228 Edits from both sides survive and every replica converges on192229 the same result: same-field collisions resolve last-writer-wins,193230 everything else merges cleanly. That is what the CRDT buys you.231+ A reworked build of this starter runs installable at{" "}232+ <Link href="https://demo.lofi.host">demo.lofi.host</Link>,233+ deployed from the latest release.194234 </p>195235 </div>196236 <SyncDemo />@@ -334,16 +374,18 @@ export default function Home(): ReactNode {
334374 </li>335375 <li>336376 <b>The data layer is Jazz 2 alpha</b>, deliberately pinned337- to a reviewed version. Five known engine defects at the338- current pin are each documented in a decision record and339- covered by an automated canary that fails loudly when a340- version bump changes the behavior.377+ to a reviewed version. Known engine defects at the current378+ pin are documented in decision records and pinned by379+ automated canaries that fail loudly when a version bump380+ changes the behavior.341381 </li>342382 <li>343- <b>The sync server reads unencrypted columns.</b>{" "}344- <Link to="/docs/threat-model">The threat model</Link>{" "}345- states what the server can and cannot see, and which346- fields you can seal.383+ <b>Sealing has limits.</b>{" "}384+ Private and shared columns are encrypted on the client385+ before they sync, but structure, timing, and presence stay386+ visible, and sealing protects future content, not merged387+ history. <Link to="/docs/threat-model">The threat model</Link>{" "}388+ states exactly what the server can and cannot see.347389 </li>348390 <li>349391 <b>Browser floors are real.</b>{" "}@@ -72,7 +72,10 @@ export default function NodeHome(): ReactNode {
7272 <code className="inline-mono">lofisync1.</code>{" "}7373 string carries the store's identity and a bearer secret riding7474 the URL path, which is why the app needs zero changes to sync75- somewhere new. Click a field.75+ somewhere new. And when enrollment binds a ticket to its76+ device, the string alone stops being enough: a key that never77+ leaves the device signs a fresh proof at every connect. Click a78+ field.7679 </p>7780 </div>7881 <TicketAnatomy />@@ -136,9 +139,12 @@ export default function NodeHome(): ReactNode {
136139 reports itself unavailable instead of degrading silently.137140 </li>138141 <li>139- <b>Tickets are bearer credentials.</b>{" "}140- Plain http is for trusted LANs; beyond one, front the gate141- with TLS.142+ <b>Tickets start as bearer strings.</b>{" "}143+ Enrolling a provision ticket binds the derived credential144+ to a device key, so a stolen string alone no longer145+ connects — but binding defeats credential theft, not a146+ compromised page. Plain http is for trusted LANs; beyond147+ one, front the gate with TLS.142148 </li>143149 <li>144150 <b>Storage is SQLite or memory</b>, the engine's honest@@ -175,7 +181,16 @@ export default function NodeHome(): ReactNode {
175181 <Link to="/node/docs/tickets-explained">176182 Tickets explained177183 </Link>178- </b>: scopes, revocation, posture.184+ </b>: scopes, device binding, revocation.185+ </li>186+ <li>187+ <b>188+ <Link to="/node/docs/write-verdicts">189+ Write verdicts190+ </Link>191+ </b>: every rejection permanent or transient, never192+ folklore — plus the health contract and last-seen per193+ ticket.179194 </li>180195 <li>181196 <b>