Welcome, revamped — pin it, reach it, run agents, watch it
One story across three surfaces — a bird's-eye in-app welcome, the kolu.dev home page that now carries the whole guide, and a hero demo clip filmed by the e2e harness so it can't drift from the real app. The spine — one `tailscale serve` gives kolu both the HTTPS secure context for one-click PWA install and remote reach. Plan of record + build journal.
This note is the plan of record and build journal for kolu’s whole first-run story, which spans three surfaces that turned out to be one:
- a bird’s-eye in-app welcome — a revamp of the empty state, re-openable on demand ( #1199 );
- the kolu.dev home page, which started as a separate
/welcomeguide and was later folded in whole so a visitor understands kolu in one scroll ( #1213 ); - a hero demo clip at the top of that page — short, looping, and filmed by the e2e harness so it regenerates from source and can’t drift from the real app ( #1213 ).
The first two thread around one verified insight (the spine). The third is its own subsystem with its own hard-won pipeline — both are below.
The spine: one command unlocks two features
kolu’s two onboarding goals — pin it as an app and reach it from anywhere — look separate. They are the same action.
A self-hosted kolu is almost always served over plain HTTP on a LAN
(192.168.x.x) or Tailscale (100.x) address. You can still pin it from
there — Chrome’s Create shortcut → Open as window and iOS Safari’s Add to
Home Screen both work over http:// — but the frictionless one-click install
(the beforeinstallprompt prompt / omnibox install icon) and the OS app
badge need a secure context (HTTPS, or the loopback/localhost set). The
very same tailscale serve that makes kolu reachable from your phone gives it a
real https://…ts.net origin — so it unlocks the one-click install and
remote access in one command. Tell it as one story.
Verified facts (the plan rests on these)
Four load-bearing claims, each adversarially fact-checked against current (2025–26) primary sources.
- Tailscale Serve = a genuine secure context.
tailscale servereverse-proxies a local port and terminates TLS athttps://machine.tailnet.ts.netwith a real Let’s Encrypt cert (DNS-01 against*.ts.net; private key never leaves your box) — browsers accept it with no warnings, so install + service workers unlock. confirmed Caveats: enable MagicDNS + HTTPS Certificates once in the admin console; the full FQDN is mandatory (the bare hostname and the100.xIP are still plain HTTP); Serve is tailnet-only; the FQDN lands in public Certificate-Transparency logs, so don’t bake secrets into machine names. (Serve KB, HTTPS KB) beforeinstallpromptis Chromium-only. Chrome/Edge/Brave/etc. fire it; Firefox and Safari (desktop and iOS) never do — and Chrome/Edge on iOS run on WebKit, so they don’t fire it either. A custom JS Install button must start hidden and reveal only on the event. confirmed- iOS has no install API. The only path is the manual Share → Add to Home Screen, which works regardless of any event (since iOS 16.4 from Chrome/Edge/Firefox too, not just Safari). On iOS you render instructions, not a button. Requires HTTPS +
display: standalone+ anapple-touch-icon; avoid the deprecatedapple-mobile-web-app-capablemeta. confirmed
The in-app welcome
Revamp the empty state in place rather than add a modal — shipped as
packages/client/src/WelcomeMoments.tsx rendered above
packages/client/src/EmptyState.tsx‘s restore card and shortcut
list. The patterns worth stealing: VS Code Walkthroughs (a short,
re-openable checklist — not a forced tour; it explicitly warns against
excessive steps), Warp (one welcome surface, success measured as habit not
completion), Zed (empty-state-as-welcome, re-openable from the palette), and
Raycast’s EmptyView (the empty state carries the onboarding with actions).
The moments (cap ~3–5, hard). The three cards above — Pin it, Reach it anywhere, Run agents — sit above the existing restore card and shortcut list. Each is one verb + one line + one chip; depth lives behind the “Full guide → kolu.dev” link, not inline. Lean on kolu’s auto-detection (recent repos, recent agents in packages/client/src/wire.ts) so the welcome shows the user’s own data, reinforcing zero-setup.
Re-openable via the palette. The empty state only auto-shows at zero
terminals, so the welcome would vanish forever once you’re working. A stable
palette command — labelled “Tutorial” (alias “Welcome”) — re-summons it
anytime, mirroring VS Code’s openWalkthrough. Wired as an action in
packages/client/src/input/actions.ts and surfaced through
packages/client/src/commands.tsx.
No dismiss state — by design. Zero terminals always shows the welcome.
The way you “dismiss” it is to open a terminal; open zero again and it comes
right back. There is no welcomeSeen flag, nothing to persist, nothing to go
stale — the empty canvas is the trigger. The only addition is the “Tutorial”
palette command, with a feature-discoverability tip pointing at it (per
.claude/rules/conventions.md) so the re-open is discoverable.
The PWA card is context-aware
This is where the spine pays off. The card reads the runtime and renders one of three states — it never shows a button that can’t work:
Gate order (apply top-down — the first match wins):
1. already installed → render nothing
matchMedia('(display-mode: standalone)').matches
|| navigator.standalone === true
2. secure + beforeinstallprompt → real one-click Install button (Chromium)
3. secure, no event → per-platform manual instructions (table below)
4. !window.isSecureContext → manual-install instructions (browser menu /
Add to Home Screen) + recommend HTTPS via Tailscale for one-click + badge
Per-platform instruction set (render the literal glyph as inline SVG next to numbered steps — visual beats prose):
| Platform / browser | What kolu shows |
|---|---|
| Chromium desktop / Android, event captured | Real Install button → prompt() in the click handler |
| Android Chrome (no event) | Menu ⋮ → Install app |
| Android Firefox | Menu → Install (no event, but install works) |
| iOS — Safari / Chrome / Edge / Firefox (16.4+) | Illustrated Share ⬆ → Add to Home Screen (instructions, not a button) |
| Safari desktop (macOS Sonoma / Safari 17+) | File → Add to Dock (works on any page; no event) |
| Firefox desktop | Native install not shipped yet (experimental Taskbar Tabs) — suggest Chrome/Edge |
Any non-secure origin (http://) | Manual-pin steps — Chrome Create shortcut → Open as window, iOS Add to Home Screen — plus “HTTPS via Tailscale adds one-click + a badge.” Never a dead one-click button |
The kolu.dev home page
The full guide began as a separate website/src/pages/welcome.astro — a
Raycast-style skill ladder, each section copy-pasteable and ending at a
verifiable success state. It worked, but a second page split the story: the
home hero pointed at it, the visitor had to click through. So once the hero
demo (next section) could carry the explaining, the guide was folded into
the home page whole and /welcome deleted (
#1213 ). The goal,
stated plainly: the kolu.dev home page contains everything — you don’t need to
navigate elsewhere to understand kolu.
The home page (website/src/pages/index.astro) now runs: hero → the demo clip → features → the seven guide sections → latest post, in one scroll. The sections keep their skill-ladder shape and stable anchors:
- Quickstart
#quickstart— Nix install → running kolu; ends at “you should see kolu open with an empty canvas.” - First 5 minutes
#first— open a repo → launch an agent in a tile (mirrors in-app moment #3). - Core concepts
#concepts— canvas, tiles, dock, worktrees, command/sub-palette, auto-detection. - Pin it as an app
#install— the per-OS steps above, HTTP/secure-context caveat up front, Tailscale as the fix. - Reach it anywhere
#remote— the minimal Tailscale sequence (below), the Serve-vs-Funnel safety note. - Power features
#power— multi-agent at scale, keybindings, sessions. - FAQ / Troubleshooting
#faq— leads with “I see no Install button” → you’re on HTTP, use thets.netURL.
Every in-app card and empty-state hint deep-links to the matching anchor on
the home page — packages/client/src/WelcomeMoments.tsx points
GUIDE_URL at https://kolu.dev and links #remote etc. directly. This page
stays the single source of truth, kept in sync with in-app copy and the README.
The demo that films itself
The guide tells you what kolu does in prose. The thing prose can’t carry is
kolu actually doing it — so the top of the home page is a short, looping
clip of a real kolu, driven by the e2e harness and recorded off the screen.
A clip recorded by hand goes stale silently and can’t be refreshed when the UI
moves; this one is a build artifact, regenerated by just record hero-demo,
so it can’t drift from the app.
One clip ships today — hero-demo, the home hero — but the subsystem is built
for N. It’s one real workflow that exercises the whole surface at once: click
”+” to open claude on a cloned repo, ”+” again for codex on a second repo
in a light theme (Catppuccin Latte vs T1’s Vaughn) that buries the first
tile — so the dock groups two repos with live agent status — then click
claude’s dock row to raise its tile, open a file, select + comment on
it, copy the comment with the real button, and hand it to claude, which
edits the file with the change landing live in the open source view. The
comment-on-any-file → agent → result loop, end to end.
Crisp by construction. The load-bearing constraint was “no low-quality
nonsense,” and a screen clip has two quality ceilings. Capture above display
size: a headful Chrome at --force-device-scale-factor=2 under Xvfb, grabbed
with ffmpeg -f x11grab — ffmpeg samples the framebuffer on its own fixed
clock in physical pixels, so it’s structurally smooth and crisp at once
(the thing no in-Chrome capture API could do). Then transcode audio-free, never
GIF: an H.264 mp4 (crf 18 ≈ visually lossless for screen content) plus a VP9
webm (crf 32, served first because it’s smaller) plus an exact-frame WebP
poster.
A poster-first looping <video>. The asset lives at
website/public/demo/hero-demo.{mp4,webm,webp} (committed — Astro serves
public/ directly, single-MB each) and embeds with the pattern every serious
dev-tool site converges on:
<video autoplay muted loop playsinline preload="metadata" poster="/demo/hero-demo.webp">
<source src="/demo/hero-demo.webm" type="video/webm" /> <!-- VP9 first: smaller -->
<source src="/demo/hero-demo.mp4" type="video/mp4" /> <!-- H.264 fallback -->
</video>
muted (autoplay exemption), playsinline (or iOS forces fullscreen), webm
<source> first (the browser takes the first decodable one), poster = frame 1
(instant first paint, seamless swap). Gate autoplay on
prefers-reduced-motion: reduce — render the poster only, with a play control.
Reproducible, not hand-recorded. The capture rides a seeded recording module:
deterministic clone names, pinned viewport / DPR / fonts, and — because the
finale edits a real checkout — ensureClone reverts tracked files
(git checkout -- .) each run so the edit is always a fresh, visible change.
Architecture — through the electricity lens
Read against electricity.mdx,
infrastructure that ① is domain-agnostic, ② hides a hard volatility, and ③
graduates to a second consumer earns its own @kolu/* package; everything else
stays kolu domain. This work splits cleanly into electricity and domain on both
the welcome side and the screencast side.
① @kolu/surface-app
#1154 — the app-shell electricity. Owns
the manifest (installPwaManifest, whose ...extra passthrough absorbs the
enrichment above) and the headless “relationship-to-server” model
(useSurfaceApp()). Installability — window.isSecureContext + standalone
display-mode — is a sibling environment fact, so the canInstallPwa /
isInstalled signals belong in that same headless model. Graduates today
(drishti is the second consumer).
② @kolu/solid-pwa-install — a focused SolidJS adapter wrapping
@khmyznikov/pwa-install. Behind one socket (“install this web app”) it owns the
cross-browser install volatility — beforeinstallprompt capture, the
per-platform instruction screens, appinstalled — reading installability from
①. @kolu/surface-app may itself depend on it and compose the card, so a
consumer wires install exactly once. Passes all three tests; graduates to any
surface app.
③ The web-screencast engine — agnostic capture, a graduation candidate not
yet electricity. “Drive a headful browser at 2× under Xvfb, x11grab the
framebuffer, transcode to web assets” names no terminal/canvas/git; it hides a
hard volatility (Xvfb lifecycle, app-mode launch, the fps-vs-resolution wall,
x11grab/ffmpeg flags, SIGINT finalize, the ffmpeg-full nix gotcha). It lives
in its own folder with the dependency arrow pointing out
(packages/tests/screencast/engine.ts, nix deps in
screencast/shell.nix), flagged publish? — mint
@kolu/web-screencast only when a second consumer is real. The recordings
(packages/tests/screencast/recordings) are kolu domain: one file
per clip declaring { name, chrome, theme, display, viewport, drive(world) },
applying kolu display knobs inside drive via kolu shortcuts. The engine
exposes capture({ chrome, size }, drive) where drive(world) is a kolu-domain
closure — so the engine stays agnostic while each recording carries its own
declarative display properties.
④ kolu app — domain. The welcome itself is kolu’s story:
| Area | File | Change |
|---|---|---|
| In-app welcome | packages/client/src/WelcomeMoments.tsx | The 3 moments + the @kolu/solid-pwa-install card; GUIDE_URL → kolu.dev deep-links |
| Re-open command | packages/client/src/input/actions.ts, packages/client/src/commands.tsx | “Tutorial” action + palette entry — re-summons the welcome as an overlay |
| Discoverability tip | packages/client/src/settings/tips.ts, packages/client/src/settings/useTips.ts | Tip pointing at “Tutorial”; drop the PWA tip on an insecure origin. No welcomeSeen |
| Manifest call-site | packages/server/src/index.ts:254 | Pass description + maskable icon into installPwaManifest |
| Home page | website/src/pages/index.astro | The folded-in skill-ladder guide + the hero demo embed |
| Recording | packages/tests/screencast/recordings | hero-demo.recording.ts + shared helpers.ts |
No new server contract; no service worker (kolu has none by design).
Decisions
- No persistence. Zero terminals always shows the welcome; opening a terminal is the dismissal. No
welcomeSeen, nothing to go stale. - One page, no redirect. The
/welcomeguide folded into the home page whole — a visitor understands kolu in one scroll; in-app cards deep-link to its anchors. - Install ownership.
@kolu/solid-pwa-installowns the card UI + cross-browser volatility;@kolu/surface-appowns the manifest + installability signals and may depend on it — install is wired once. - Reuse the e2e harness for capture — the same step library that proves kolu works films it; no second recorder.
- Move the capture boundary outside Chrome —
Xvfb+x11grabof a headful 2× app-mode window; structurally smooth and crisp, which no in-Chrome API delivered. - Fork at transcode, never GIF — H.264 mp4 + VP9 webm + WebP poster, committed under
website/public/demo/. - Tailscale: instruct, never auto-run. The welcome documents
tailscale serve; it never runs it for you. Serve, never Funnel. No QR — a copy button on thets.netURL is enough. - No mobile welcome. Onboarding is desktop-only (packages/client/src/capabilities.ts:34).
Pitfalls
Log — build journal
A running record of this work’s research, dead-ends, and decisions — raw material for a future blog post. The in-app welcome + the kolu.dev guide shipped in #1199 ; the guide later folded into the home page alongside the hero demo in #1213 . The screencast arc, newest at the bottom:
1 · Plan + prior-art sweep. workflow #1 A fan-out
research workflow (terminal recorders vs browser capture · embed patterns · CI
determinism), adversarially verified, concluded: PTY recorders (vhs, asciinema)
can’t capture kolu’s xterm-on-canvas pixels — it’s a raster GUI, not a
replayable ANSI stream — so pixel capture via the existing Cucumber +
Playwright evidence harness is the path. Embed pattern: poster-first
autoplay/muted/loop/playsinline, mp4 + webm, reduced-motion.
2 · The crispness constraint. “Video needs to be crisp — no low-quality
nonsense.” This became the spine. The evidence path records Playwright
recordVideo = VP8 @ ~1 Mbit/s, 720p — fine for PR proof, too soft for a
marketing page.
3 · Spike A (VP8). Reused the harness (KOLU_EVIDENCE) on a real
worktree-agent scenario → 1280×720 VP8 → ffmpeg H.264. Smooth, but the text
shimmered. Verdict: “could be crispier.”
4 · The fps-vs-resolution wall. Chasing 2×, every in-Chrome capture API hit a
structural limit: CDP Page.startScreencast ignores deviceScaleFactor →
caps at CSS pixels; Page.captureScreenshot can do 2× but each 2560×1440 grab
is compositor-bound at ~260 ms → ~3 fps slideshow; page.screenshot()
serialises behind the driver → ~10 frames over a 4 s scenario; a capture loop
that started before load made frame 0 a blank white flash that strobed on every
loop. The lesson: real-time frame capture is either smooth-but-low-res or
high-res-but-too-slow. You can’t win it from inside Chrome.
5 · Reset + second research pass. workflow #2 A
second fan-out (virtual-time beginFrame · OS-level capture · purpose-built
recorders · native Playwright options), adversarially verified. Verdict: move
the capture boundary outside Chrome — Xvfb + ffmpeg -f x11grab of a headful
2× browser samples the framebuffer on its own fixed clock in physical pixels, so
it’s structurally smooth and crisp at once. Also surfaced: no top dev
tool ships an auto-captured hero clip — they’re “designed, not recorded” (Screen
Studio etc.). We deliberately take the reproducible-over-cinematic path.
6 · x11grab, working. Implemented KOLU_X11CAP: Xvfb, headful Chrome at
--force-device-scale-factor=2, ffmpeg -f x11grab at 30 fps, SIGINT to
flush the moov atom. Gotcha that cost a cycle: nixpkgs ffmpeg is built
--disable-xlib (no x11grab device — “Unrecognized option ‘draw_mouse’”) →
switched the e2e devShell to ffmpeg-full. Result: smooth + crisp. “B looks
good.”
7 · The browser-chrome realisation. x11grab records the whole window — the
clip showed Chrome’s tab strip + localhost:38425 address bar. Reads as “a
browser tab,” not “the app.” Fix: Chrome app-mode (--app=<url>) = the
chromeless window an installed PWA uses.
8 · Scope crystallised into a subsystem. A lowy/hickey-clean Recording
abstraction — data + script per clip, separate from the capture pipeline. The
engine stays agnostic; recordings carry the kolu-specific display knobs.
9 · Subsystem shipped.
#1213 The agnostic engine
(packages/tests/screencast/engine.ts, nix deps in
screencast/shell.nix), the Recording modules, a When I record "<name>"
dispatcher, and a just record recipe. Captured locally, not on pu: the
demo’s climax launches a real, authenticated agent, so it only renders on a
machine already running it — a deliberate, documented tradeoff (quality >
box-purity; reproducibility lives in the recipe + modules, which do run on pu).
10 · Refined to one hero clip. Dropped a planned pwa-install recording —
an in-clip browser→app transition isn’t reachable (bare Xvfb has no window
manager, so the Fullscreen API can’t drop Chrome’s chrome). Two harness fixes
made the dock track the live agent: under KOLU_X11CAP, omit the
KOLU_CLAUDE_*_DIR / KOLU_CODEX_DIR overrides so kolu watches the real
agent dirs; and a 240s timeout on the I record step (a real agent query exceeds
the default budget). The dock’s high-level state is data-bucket
(“working”/“awaiting”), not the raw data-agent-state — polling the wrong
one made clips run 70–150s.
11 · Polish → the comprehensive hero. Privacy: Claude Code’s banner prints
name/email/plan, so the identity-neutral half runs codex
(--ask-for-approval never --sandbox read-only; the --dangerously-bypass…
interactive mode shows a danger-confirm the prompt dismisses → codex exits) and
claude’s banner is cropped. The app-mode session auto-restores a terminal, so
the recording killAlls it for an empty opening canvas, then creates one
Vaughn-themed terminal on camera, trimStart skipping the load-in. The clip
grew into the full comment → agent → live-edit loop across two repos and two
themes — every on-camera click telegraphed with a coral SVG arrow (the earlier
glowing ring read as kolu’s own UI), sub-second pauses, and a held beat after the
dock flips to awaiting so the status change is unmissable. ~45s, 3200×1800
(1600×900 ×2), ~3.9 MB mp4 / ~2.9 MB webm. Reusable patterns factored into
helpers.ts.
Status: implemented — the welcome revamp in
#1199 ; the hero
demo + the folded-in home page in
#1213 ; built on
@kolu/surface-app
#1154 .