GitHub template for small static PWAs on thatsit.app, built on the shared kit and hosted on Cloudflare Pages. Vite + TypeScript, installable, works offline after the first visit.
- Edit
app.config.json:nameis the Pages project and the subdomain (<name>.thatsit.app), plustitle,descriptionand the iconcolor. - Run
npm run iconsto regenerate the icons from that color. - Replace the starter in
src/app-main.tswith your app (see Layouts), and deletee2e/starter.spec.ts(it tests the starter shell and buttons).
<html data-shell> in index.html is the switch. The kit's kit.css uses it to pin html and body to height: 100% with overflow: hidden and no overscroll, so the document never scrolls or bounces (iOS would drag the header with it) and only the app's own panes scroll. Without it the page scrolls normally.
- Single page (choose this for simple apps): remove
data-shellfromindex.htmland render content directly inapp-main, with notk-shellortk-header.tk-sheet,tk-toastsandsharedStyleswork as before. - App with navigation: keep
data-shelland thetk-shell+tk-headerstructure from the starter.
app-main must render into its shadow root (don't override createRenderRoot); the generic tests wait on that.
e2e/app.spec.ts is layout-agnostic and stays in every app; the shell assertions live in e2e/starter.spec.ts, which you delete with the starter section.
- Import the kit and Lit only by URL (
https://kit.thatsit.app/kit.js,https://kit.thatsit.app/lit.js), neverfrom "lit": a second Lit copy breaks styles. - No custom colors, fonts or radii. Use the kit tokens. The one exception is the icon color in
app.config.json. - Components and tokens are documented at https://kit.thatsit.app/.
npm run dev, build, preview, typecheck (tsc), test (vitest), e2e (Playwright, Chromium; set BASE_URL to test a deployed site instead of building locally), icons, kit-types (fetches the kit's .d.ts into .kit/; runs automatically before dev, build and typecheck).
Apps can read system stats and keep their own small JSON store on spark-gateway, a backend reachable only from Michael's tailnet.
- Set
"gateway": trueinapp.config.json. The build then defines__GATEWAY_URL__and adds the gateway origin toconnect-srcindist/_headers(public/_headersstays as is). Without the flag nothing changes. - Import the client:
import { kv, system, GatewayError, OffTailnetError } from "./gateway.ts"andimport "./tailnet-only.ts". The store is your app's own (/apps/<name>/kv/...). Ids match[A-Za-z0-9_-]{1,128}, collections[a-z0-9-]{1,64}, values are JSON objects up to 256 KiB (50 MiB per app). Results (types exported fromgateway.ts):kv.get<T>(collection, id)->KvGet<T>{ value, updatedAt }. A missing id throwsGatewayErrorwithstatus404: treat 404 as missing.kv.put<T>(collection, id, value)->{ updatedAt };kv.del(collection, id)->{ deleted: true }(404 if missing).kv.list<T>(collection, { limit?, cursor? })->KvList<T>{ items: [{ id, value, updatedAt }], cursor? }, newest first.limitdefaults to 50, max 200; passcursorback for the next page, it is absent on the last one.system.models()->Models{ models: [{ id, status, loaded, state? }] };system.gpu()->Gpu{ gpus: [{ name, utilizationPercent }], memory: { totalBytes, availableBytes } }(utilizationPercentmay benull);system.sessions()->Sessions{ sessions: [{ id, cwd, name?, model?, startedAt, lastActivityAt }] }.- Calling any of them without
"gateway": truethrowsset "gateway": true in app.config.json.
- Catch
OffTailnetError(the request never got an answer) and render<tk-tailnet-only @retry=${...}>. AGatewayError(hasstatus) is a real HTTP error, not a connectivity problem (a 2xx that is not JSON also throws one,invalid JSON from gateway).
type Note = { title: string };
try {
const { items, cursor } = await kv.list<Note>("notes"); // first 50; kv.list("notes", { cursor }) for more
this.notes = items.map((i) => i.value);
try {
this.pinned = (await kv.get<Note>("notes", "pinned")).value;
} catch (e) {
if (!(e instanceof GatewayError && e.status === 404)) throw e; // 404: no such note yet
}
} catch (e) {
if (e instanceof OffTailnetError) this.offline = true;
else throw e;
}
// render(): this.offline ? html`<tk-tailnet-only @retry=${() => this.load()}></tk-tailnet-only>` : ...Tests never reach the gateway: stub it with page.route("https://*.ts.net:*/**", ...) (fulfill with JSON, or abort() for the off-tailnet state). e2e/gateway.spec.ts runs only when gateway is true.
PR previews: add a line Gateway: thats-it-dev/spark-gateway#<N> (N from 1 to 499) to the PR body to build the preview against that gateway PR's preview (port 9500+N). The sticky comment then shows Gateway preview: <url>.
.github/workflows/deploy.yml runs typecheck, tests and e2e, then uploads dist with wrangler:
- Push to
main: production deploy of the Pages project<name>. The deploy is skipped whilenameis stillapp-templatein a repo other than the template itself, so editapp.config.jsonfirst. - Pull request: preview deploy of the branch. The link is posted as one sticky PR comment (updated on each push) and written to the job summary.
- Fork PRs have no secrets, so they run the checks but skip the deploy.
The org secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID supply the credentials.
Use the cf CLI; find exact commands with cf cli search "<task>".
- Create the project with production branch
main:cf pages create <name>. - Attach the custom domain
<name>.thatsit.app:cf pages domains create. - Add a proxied CNAME
<name>pointing to the project'ssubdomainfrom thecf pages createoutput (usually<name>.pages.dev, but Cloudflare adds a suffix such as<name>-930.pages.devwhen that name is taken):cf dns records create.
This is how kit.thatsit.app was set up. The thatsit-app pi skill will do these steps automatically.