eleven / messenger Public

tools: ./tools/dev shots — committed marketing-screenshot capture kit + doc (#1367)

* ios: add LChatMac-Demo.entitlements (isolated capture-kit build)

Minimal entitlements for the marketing-screenshot demo Mac build: only
app-sandbox, network.client, and files.user-selected.read-write. The shared
keychain-access-group (…messenger.shared), the app group, push, and the Sparkle
mach-lookup exceptions are dropped ON PURPOSE — with no shared keychain group the
build has its own empty keychain and can only show the seeded demo space, never
the real signed-in spaces. Applied only via `./tools/dev shots` command-line
build-setting overrides, so project.yml and normal Mac builds are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tools: add marketing-shots kit + ./tools/dev shots verbs

Fold the throwaway screenshot capture kit into the dev CLI as a committed,
hand-operable process. `./tools/dev shots`:

  up          build the isolated demo Mac app (if needed) + run a throwaway local
              server seeded (through the real E2EE protocol) with the fictional
              "The Commons" space; prints the local URL + a sign-in link
  tunnel      front it at a public https try-*.11relay.com origin (lchat try
              --local) with single-use Mac & iPhone sign-in links
  mac         launch the isolated "Eleven Demo" Mac app
  web-check   headless 2x screenshot of the seeded space, to eyeball the look

Implementation in tools/marketing-shots/ (serve.sh, tunnel.sh, seed-team.mjs,
demo-lib.mjs, device-link.mjs, web-check.mjs), mirroring how `dev up` wraps
lchat-dev-up.mjs. The demo Mac build's isolated identity (Demo entitlements +
…mac.capdemo bundle id) rides entirely on xcodebuild command-line overrides —
project.yml is never touched, so a plain `dev build mac` stays the real app.
Interactive Mac/iPhone captures stay manual (see docs/marketing-screenshots.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: document the marketing-screenshots capture process

Add docs/marketing-screenshots.md — the human-readable process + the WHY for
capturing the native Mac + iPhone hero-collage shots: the isolation rationale
(both LChatMac and LChatMac-Dev entitlements share the …messenger.shared
keychain group and read the real spaces; the Demo entitlements strip it; verify
with codesign), iPhone = real device (the simulator renders emoji as tofu), the
hero-ios.png bezel-reuse technique with a PIL-only composite snippet, the Mac
full-screen-capture-then-crop path, theme pairing, output locations, and
teardown. Wire the `shots` verbs into the CLAUDE.md Dev CLI section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: fix Mac theme guidance — natives stay Classic light, not Lit Room

The recovered runbook told the operator to set the Mac app to Lit Room, but
the shipped team-mac.png (and hero-mac.png) use the default Classic light/blue
theme — only the web frame is Lit Room. Following the old text would produce a
cream Mac shot that mismatches the collage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell f825405e72e2b2a88b40fdc2a09810cbcfb5041c parent 1c7f96b
11 files changed, +962 −0
  • CLAUDE.md +26 −0
  • docs/marketing-screenshots.md +213 −0
  • ios/LChatMac/LChatMac-Demo.entitlements +26 −0
  • tools/dev +131 −0
  • tools/marketing-shots/.gitignore +10 −0
  • tools/marketing-shots/demo-lib.mjs +153 −0
  • tools/marketing-shots/device-link.mjs +30 −0
  • tools/marketing-shots/seed-team.mjs +140 −0
  • tools/marketing-shots/serve.sh +89 −0
  • tools/marketing-shots/tunnel.sh +72 −0
  • tools/marketing-shots/web-check.mjs +72 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 45abca0..47de343 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -696,6 +696,13 @@ Simple command-line interface for common dev tasks. Run from anywhere in the rep
./tools/dev test web # Run web tests only
./tools/dev test go [args] # Run Go tests only (args go to `go test`,
# e.g. -run TestFoo ./internal/bot)
+./tools/dev shots <verb> # Marketing-screenshot kit (docs/marketing-screenshots.md):
+ # up build the isolated demo Mac app (if needed) +
+ # run a throwaway local server seeded with the
+ # fictional "The Commons" space (foreground)
+ # tunnel give it a public https try-*.11relay.com address
+ # mac launch the isolated "Eleven Demo" Mac app
+ # web-check <png> headless 2x screenshot of the seeded space
```
All build and test commands are thin wrappers around the underlying toolchain (xcodebuild, go, npm). The `dev` subcommands orchestrate the dev server (`tools/lchat-dev-up.mjs`) and app seeding scripts. The `run` commands automatically build if needed, so you can skip the explicit build step. **Reuse and extend this as the single entry point for all dev workflows** — don't hand-roll xcodebuild invocations or embed them in Claude prompts. When adding new dev commands, document them here and in the script's usage header.
@@ -719,6 +726,25 @@ Never install an alt (or any Debug build) to
/Applications — that's how a stale dev build ends up masquerading as the
released app.
+🤖 **Marketing screenshots (`./tools/dev shots <verb>`)** stand up a
+**fictional** demo space ("The Commons") for capturing the native Mac +
+iPhone shots in the website hero collage — the committed form of a
+capture recipe that used to live only in agent memory. Implementation in
+`tools/marketing-shots/`; full process + the WHY in
+`docs/marketing-screenshots.md`. `shots up` builds the isolated demo Mac app
+(if needed) and runs a throwaway local server seeded through the real E2EE
+protocol; `shots tunnel` fronts it at a public `try-*.11relay.com` origin for
+the native apps; `shots mac` launches it; `shots web-check <png>` grabs a
+headless 2× screenshot to eyeball the look. **Isolation is the point:** unlike
+a normal build or a dev alt (both share the `…messenger.shared`
+keychain-access-group, so they read the *real* signed-in spaces — an alt has
+grabbed Paul's private space before), the demo Mac build is signed with the
+minimal `LChatMac-Demo.entitlements` (no shared keychain → its own empty
+keychain), a distinct `…mac.capdemo` bundle id, applied purely as
+**command-line build-setting overrides** so `project.yml` is never touched and
+a plain `./tools/dev build mac` stays the real app. Interactive Mac/iPhone
+captures stay **manual** (the tool stands up the demo + points you at the doc).
+
Typical workflow:
```sh
diff --git a/docs/marketing-screenshots.md b/docs/marketing-screenshots.md
new file mode 100644
index 0000000..4c05d61
--- /dev/null
+++ b/docs/marketing-screenshots.md
@@ -0,0 +1,213 @@
+# 🤖 Marketing screenshots — native Mac + iPhone captures for the hero collage
+
+How we capture the native **Mac** and **iPhone** screenshots that sit in the
+marketing site's three-device hero collage (alongside a web frame). This is the
+committed, hand-operable form of a capture recipe that previously lived only in
+a throwaway branch and agent memory — the "outlive-AI" rule in action: anyone,
+human or agent, can stand the demo up and reproduce the shots from this doc plus
+`tools/marketing-shots/`, with no special knowledge encoded elsewhere.
+
+The whole flow is fictional and local. Nothing here ever points an app at a
+real or production instance, and the Mac app is built in an **isolated identity**
+so it can only ever show the seeded demo space — never anyone's real signed-in
+spaces (see *Isolation* below — it is the load-bearing safety property).
+
+The tool stands up the demo and points you here; the interactive captures
+themselves stay **manual** on purpose (a person frames and takes each shot).
+
+## Quick start (`./tools/dev shots`)
+
+```sh
+./tools/dev shots up # Terminal 1 — build the isolated demo Mac app (if
+ # needed) + run a throwaway local server seeded with
+ # the fictional "The Commons" space. Foreground:
+ # prints the local URL + a localhost sign-in link;
+ # Ctrl-C stops it.
+./tools/dev shots tunnel # Terminal 2 — give the local server a real public
+ # https try-*.11relay.com address (via `lchat try
+ # --local`) + single-use Mac & iPhone sign-in links.
+./tools/dev shots mac # Launch the isolated "Eleven Demo" Mac app.
+./tools/dev shots web-check shot.png # Headless 2x screenshot of the seeded
+ # space, to eyeball the look before capturing native.
+```
+
+Implementation lives in `tools/marketing-shots/` (`serve.sh`, `tunnel.sh`,
+`seed-team.mjs`, `demo-lib.mjs`, `device-link.mjs`, `web-check.mjs`) — the
+`shots` verbs are thin wrappers, mirroring how `./tools/dev up` wraps
+`tools/lchat-dev-up.mjs`. Runtime artifacts (the demo DB, state, PNGs) are
+gitignored.
+
+## The demo space: "The Commons"
+
+A fictional civic-tech team space that matches the web hero frame
+(`website/src/assets/hero/team-web.png`). It's seeded **through the real E2EE
+protocol** (`demo-lib.mjs` provisions each member via an admin key-invite → claim
+→ pubkey, then encrypts every thread/message client-side exactly as the browser
+does) — the server only ever holds ciphertext, so there is no plaintext
+shortcut. Six threads: **The commons 🌱** (the flagship — the exact web-frame
+conversation: Aoife / Marcus / Devi, a reply, and reactions), Standup, Shipping
+🚀, Field notes 📓, Roadmap, Watercooler ☕. You sign in as **Cass** (your own
+messages are the accent bubbles on the right, matching the web frame's reply).
+
+Eyeball it any time without a native build:
+
+```sh
+./tools/dev shots web-check /tmp/commons.png && open /tmp/commons.png
+```
+
+## Isolation — why this is safe (load-bearing)
+
+A normal Mac build reads the **real** account. Both `LChatMac.entitlements` and
+`LChatMac-Dev.entitlements` carry
+`keychain-access-groups = $(AppIdentifierPrefix)com.elevenmessenger.messenger.shared`,
+and a dev alt (`./tools/dev alt`) shares that same group **on purpose** — an alt
+is the real account, not a sandbox, and has grabbed Paul's private space before.
+That is exactly what we must NOT do for a marketing screenshot.
+
+So the demo Mac build is signed with **`ios/LChatMac/LChatMac-Demo.entitlements`**
+— minimal by design (only `app-sandbox`, `network.client`, and
+`files.user-selected.read-write`), with the shared keychain group (and the app
+group, push, and Sparkle exceptions) **removed**. No shared keychain group →
+the build has its **own empty keychain** and can only show the demo space you
+add. It also gets a distinct bundle id, `com.elevenmessenger.messenger.mac.capdemo`
+(its own sandbox container + Launch Services identity, so it runs beside the real
+app), and the display name "Eleven Demo". The real purple `AppIcon` is kept on
+purpose so the screenshot shows the real brand mark.
+
+**Crucially, none of this touches `project.yml`.** `./tools/dev shots` applies
+the demo identity purely as xcodebuild **command-line build-setting overrides**
+(`CODE_SIGN_ENTITLEMENTS=<abs demo path>`,
+`PRODUCT_BUNDLE_IDENTIFIER=…mac.capdemo`, `LCHAT_MAC_DISPLAY_NAME=Eleven Demo`),
+which outrank the target/config values in the generated project. A plain
+`./tools/dev build mac` (or `xcodegen generate`) therefore still resolves the
+normal entitlements (Release → `LChatMac.entitlements`, Debug →
+`LChatMac-Dev.entitlements`) and the real `com.elevenmessenger.messenger.mac`
+bundle id — the released app is never at risk.
+
+Verify the isolation on the built app any time — **no output means isolated**:
+
+```sh
+codesign -d --entitlements :- \
+ "ios/build-demo/Build/Products/Debug/Eleven Demo.app" | grep -i keychain
+# → (no output) = no keychain-access-groups = isolated, own empty keychain
+```
+
+## Capturing the Mac shot
+
+1. `./tools/dev shots up` (Terminal 1), then `./tools/dev shots tunnel`
+ (Terminal 2) for a real https origin the native apps can reach.
+2. `./tools/dev shots mac` to launch "Eleven Demo". In the app: **Spaces ▸ Add
+ from link…** and paste the **Mac** link `shots tunnel` printed (signs you in
+ as Cass). If a "Keep your access safe / Sign in" card appears, dismiss it with
+ "Remind me later".
+3. **Leave the Mac on its default Classic light theme** (light background, blue
+ accent bubbles) — that is what `hero-mac.png` uses and what the collage wants
+ for the natives; do **not** switch it to Lit Room (see *Theme pairing*). Open
+ **The commons 🌱** to mirror the web frame, or **Standup** for variety (it has
+ an own-bubble + a reaction).
+4. **Capture full-screen, then crop with PIL** — the robust path. Interactive
+ `screencapture -w` (click a window) and System Events window-bounds both rely
+ on Accessibility / Quartz (pyobjc), which isn't granted on the build Mac and
+ is unreliable when driven headlessly. Full-screen + crop needs none of that:
+
+ ```sh
+ screencapture -o ~/Desktop/team-mac-raw.png # -o = no window shadow; full screen, 2x retina
+ ```
+
+ Crop the app-window region to the collage aspect (`hero-mac.png` is
+ **1300×919**, aspect ≈ **1.415**) and scale to 1300×919:
+
+ ```python
+ from PIL import Image
+ im = Image.open("team-mac-raw.png") # full-screen 2x capture
+ # Pick a crop box (left, top, right, bottom) around the window, keeping
+ # (right-left)/(bottom-top) ≈ 1.415 so nothing is squashed on the resize.
+ box = (LEFT, TOP, RIGHT, BOTTOM)
+ im.crop(box).resize((1300, 919), Image.LANCZOS).save("team-mac.png")
+ ```
+
+## Capturing the iPhone shot — REAL DEVICE ONLY
+
+> ⚠️ **Do NOT use the iOS Simulator.** The iOS 26.3.1 simulator renders emoji as
+> tofu (□) — confirmed 2026-07-28, both in-app and in Safari. Use a real iPhone.
+
+1. `./tools/dev shots tunnel` prints an **iPhone** link. Open it **on the
+ device** (paste into Safari → Open in the app / join) — it signs the phone in
+ as Cass. The phone is the real app, so it'll show this demo space alongside
+ any real spaces; scroll to / open "The Commons" for the shot.
+2. Land on the space's **thread list** (the frame in `hero-ios.png`: title + the
+ six thread rows with previews).
+3. Screenshot on the device (**Volume-Up + Side button**) and AirDrop it to the
+ Mac. An iPhone 16 Pro shot is **1206×2622** (aspect ≈ 0.460).
+
+### Reusing the existing bezel frame
+
+The website's `hero-ios.png` (**760×1554**, RGBA, transparent surround) already
+has the Apple bezel baked in. Rather than re-render a device frame, drop the new
+screenshot into that same bezel. Its screen rect is **(41, 39)** with size
+**678×1476** (aspect ≈ 0.459 — the raw 1206×2622 shot's 0.460 is a near-perfect
+match, so scaling barely distorts), corners rounded at **r ≈ 92px**. PIL only
+(numpy is **not** installed on the build Mac):
+
+```python
+from PIL import Image, ImageDraw
+
+BEZEL = "hero-ios.png" # 760×1554, Apple bezel baked in, transparent surround
+RAW = "team-ios-raw.png" # on-device screenshot (e.g. 1206×2622, iPhone 16 Pro)
+OUT = "team-ios.png"
+
+X, Y, W, H, R = 41, 39, 678, 1476, 92 # screen rect + corner radius in the bezel
+
+frame = Image.open(BEZEL).convert("RGBA")
+shot = Image.open(RAW).convert("RGBA").resize((W, H), Image.LANCZOS)
+
+# Rounded-rect mask so the shot's corners tuck under the bezel's screen cutout.
+mask = Image.new("L", (W, H), 0)
+ImageDraw.Draw(mask).rounded_rectangle([0, 0, W, H], radius=R, fill=255)
+
+frame.paste(shot, (X, Y), mask)
+frame.save(OUT)
+```
+
+You may **read** `website/src/assets/hero/hero-ios.png` as the reference bezel,
+but the composite output belongs in the website repo — do not modify the website
+repo from a messenger-repo change.
+
+## Theme pairing
+
+The collage deliberately pairs a **warm** web frame (Lit Room cream) with
+**lighter** natives (Classic light / blue). So only the **web** frame is Lit
+Room; **both natives stay on the default Classic light** (blue accent) — the
+Mac app's out-of-the-box theme, matching `hero-mac.png` and the iPhone. Don't
+switch the Mac to Lit Room, or all three frames read as the same cream wash.
+
+## Where the outputs go
+
+The finished frames live in the **website** repo (not this one):
+
+- `website/src/assets/hero/team-web.png`, `team-mac.png`, `team-ios.png` — wired
+ into the homepage `.collage` in `website/src/index.njk`.
+- The original `hero-web.png` / `hero-mac.png` / `hero-ios.png` family (a
+ household space) is used on `/families/`.
+
+Committing and deploying the website is a separate, website-repo concern (its
+own deploy path) — out of scope for this kit, which only produces the raw + framed
+images.
+
+## Known cosmetic gaps (not seed bugs)
+
+- **"6 members · N online":** the header's *online* count reflects who is really
+ connected (you), so it reads "6 members" or "6 members · 1 online", not the
+ web frame's mocked "3 online". Faking more would need extra live connections —
+ not worth it for a still.
+- **Thread-list times** all read the same recent minute (the server stamps
+ `created_at` at seed time). In-thread times match the web ("Today 9:12 AM")
+ because those come from the encrypted payload.
+
+## Teardown
+
+- Ctrl-C the tunnel (Terminal 2) to retire the public address, then Ctrl-C the
+ server (Terminal 1). The demo DB is wiped on the next `shots up` anyway.
+- Quit "Eleven Demo".
+- Optional: `rm -rf ios/build-demo` and the gitignored
+ `tools/marketing-shots/.demo*` runtime files when the collage is done.
diff --git a/ios/LChatMac/LChatMac-Demo.entitlements b/ios/LChatMac/LChatMac-Demo.entitlements
new file mode 100644
index 0000000..05c6393
--- /dev/null
+++ b/ios/LChatMac/LChatMac-Demo.entitlements
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <!-- CAPTURE-KIT DEMO entitlements. Deliberately MINIMAL so this build has
+ its own EMPTY keychain and shows ONLY the seeded demo space — it can
+ never read Paul's real signed-in spaces. Compared to LChatMac.entitlements
+ these are dropped ON PURPOSE:
+ - keychain-access-groups (…messenger.shared) — the reason a normal
+ build reads the real account; removing it isolates this build's keychain.
+ - com.apple.security.application-groups (group.com.elevenmessenger.messenger)
+ — the shared avatar cache + MessageStore index container; not needed
+ for a throwaway screenshot build.
+ - com.apple.developer.usernotifications.communication — no push here.
+ - the Sparkle -spks/-spki mach-lookup exceptions — no auto-update here.
+ What remains is the minimum that signs cleanly under automatic signing
+ (team J5NDF5PCD8) for a sandboxed Mac app that loads a web instance and
+ lets you pick/save files. -->
+ <key>com.apple.security.app-sandbox</key>
+ <true/>
+ <key>com.apple.security.network.client</key>
+ <true/>
+ <key>com.apple.security.files.user-selected.read-write</key>
+ <true/>
+</dict>
+</plist>
diff --git a/tools/dev b/tools/dev
index adb744c..f3cfaf4 100755
--- a/tools/dev
+++ b/tools/dev
@@ -18,6 +18,11 @@
// ./tools/dev test web Run web tests only
// ./tools/dev test go [args] Run Go tests only (args forwarded to go test,
// e.g. -run TestFoo ./internal/bot)
+// ./tools/dev shots <verb> Marketing-screenshot kit (docs/marketing-screenshots.md):
+// up | tunnel | mac | web-check <png>. Stands up an
+// isolated, fictional demo space ("The Commons") for
+// native Mac + iPhone captures. Interactive captures
+// stay manual — see the doc.
//
// All commands assume you're at the repo root. Use ./tools/dev to invoke from anywhere.
@@ -386,6 +391,11 @@ const commands = {
run("go", ["test", ...cmdArgs, ...(named ? [] : ["./..."])], { cwd: REPO_ROOT });
console.log("✓ Go tests passed");
},
+
+ // Marketing-screenshot kit — a family of verbs under one command; the
+ // sub-verb (up/tunnel/mac/web-check) is cmdArgs[0]. shotsCmd is a hoisted
+ // function declaration below.
+ shots: () => shotsCmd(cmdArgs),
};
// Side-by-side dev alt of the Mac app (CLAUDE.md § Dev CLI): the same
@@ -459,6 +469,125 @@ async function altMac(args) {
run("open", [appPath]);
}
+// ── Marketing-screenshot kit (docs/marketing-screenshots.md) ─────────────────
+// Stands up a fictional demo space ("The Commons") for capturing native Mac +
+// iPhone shots for the website hero collage. The implementation scripts live in
+// tools/marketing-shots/ (mirroring how `up` wraps tools/lchat-dev-up.mjs); this
+// dispatcher wraps them and owns the ISOLATED Mac build.
+const SHOTS_DIR = path.join(REPO_ROOT, "tools", "marketing-shots");
+const DEMO_ENTITLEMENTS = path.join(IOS_DIR, "LChatMac", "LChatMac-Demo.entitlements");
+const DEMO_BUNDLE_ID = "com.elevenmessenger.messenger.mac.capdemo";
+const DEMO_DISPLAY_NAME = "Eleven Demo";
+const DEMO_DERIVED = path.join(IOS_DIR, "build-demo");
+const DEMO_APP = path.join(DEMO_DERIVED, "Build", "Products", "Debug", `${DEMO_DISPLAY_NAME}.app`);
+
+// Build the ISOLATED demo Mac app. The isolation + demo identity ride entirely
+// on COMMAND-LINE build settings so project.yml is NEVER touched — a normal
+// `./tools/dev build mac` keeps the real LChatMac.entitlements (shared keychain
+// group → reads the real signed-in spaces) and the real bundle id. Command-line
+// settings outrank the target/config values in the .pbxproj, so here they swap
+// in the minimal LChatMac-Demo.entitlements (own EMPTY keychain — can never read
+// Paul's real spaces) and a distinct .capdemo bundle id (own sandbox container +
+// Launch Services identity, runs beside the real app). The real purple AppIcon
+// is kept on purpose so captures show the real brand mark. Returns true on
+// success. Verify isolation any time:
+// codesign -d --entitlements :- "<…>/Eleven Demo.app" | grep -i keychain # → no output
+function buildDemoMac() {
+ if (needsXcodegen()) {
+ console.log("Generating Xcode project...");
+ run("xcodegen", ["generate"], { cwd: IOS_DIR });
+ }
+ console.log(`Building ${DEMO_DISPLAY_NAME} (isolated demo identity, ${DEMO_BUNDLE_ID})...`);
+ const result = spawnSync("xcodebuild", [
+ "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
+ "-scheme", "LChatMac",
+ "-configuration", "Debug",
+ "-destination", "generic/platform=macOS",
+ "-derivedDataPath", DEMO_DERIVED,
+ "-allowProvisioningUpdates",
+ "CODE_SIGN_STYLE=Automatic",
+ "DEVELOPMENT_TEAM=J5NDF5PCD8",
+ // Command-line overrides (highest precedence): swap in the isolated identity
+ // without editing project.yml. Absolute entitlements path so xcodebuild
+ // resolves it regardless of cwd.
+ `CODE_SIGN_ENTITLEMENTS=${DEMO_ENTITLEMENTS}`,
+ `PRODUCT_BUNDLE_IDENTIFIER=${DEMO_BUNDLE_ID}`,
+ `LCHAT_MAC_DISPLAY_NAME=${DEMO_DISPLAY_NAME}`,
+ "LCHAT_MAC_SPARKLE_AUTOCHECK=NO",
+ "build",
+ ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 });
+ if (result.status !== 0) {
+ console.error("Demo Mac build failed!");
+ if (result.error) console.error(String(result.error));
+ const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:"));
+ console.error(errs.slice(-20).join("\n") || result.stderr || "");
+ return false;
+ }
+ console.log(`✓ ${DEMO_DISPLAY_NAME}.app built (${DEMO_APP})`);
+ return true;
+}
+
+// Build the demo Mac app when it's missing or stale. `soft`: on failure warn +
+// return false instead of exiting (so `shots up` still stands up the web demo).
+function ensureDemoMacApp({ soft = false } = {}) {
+ const need = needsXcodegen() || !existsSync(DEMO_APP) || needsBuild(DEMO_APP);
+ if (!need) {
+ console.log(`${DEMO_DISPLAY_NAME}.app is up-to-date`);
+ return true;
+ }
+ const ok = buildDemoMac();
+ if (!ok) {
+ if (!soft) process.exit(1);
+ console.warn(`⚠ ${DEMO_DISPLAY_NAME}.app didn't build — the web demo will still come up.`);
+ console.warn(` Retry the native build later with: ./tools/dev shots mac`);
+ }
+ return ok;
+}
+
+function shotsUsage() {
+ console.log("shots — marketing-screenshot kit (docs/marketing-screenshots.md)\n");
+ console.log("Usage:");
+ console.log(" ./tools/dev shots up Build the demo Mac app (if needed) + run a");
+ console.log(" throwaway local server seeded with \"The Commons\"");
+ console.log(" (foreground; Ctrl-C stops it)");
+ console.log(" ./tools/dev shots tunnel Give it a public https try-*.11relay.com address");
+ console.log(" + single-use Mac & iPhone sign-in links");
+ console.log(" ./tools/dev shots mac Launch the isolated \"Eleven Demo\" Mac app");
+ console.log(" ./tools/dev shots web-check <png> Headless 2x screenshot of the seeded space");
+ console.log("\nInteractive Mac/iPhone captures are MANUAL — see docs/marketing-screenshots.md.");
+}
+
+async function shotsCmd(args) {
+ const verb = args[0] || "help";
+ const rest = args.slice(1);
+ switch (verb) {
+ case "up":
+ // Ensure the native app is ready (soft: a provisioning hiccup shouldn't
+ // block the web demo), then hand off to the foreground server+seed script.
+ ensureDemoMacApp({ soft: true });
+ await runAsync("bash", [path.join(SHOTS_DIR, "serve.sh"), ...rest]);
+ break;
+ case "tunnel":
+ await runAsync("bash", [path.join(SHOTS_DIR, "tunnel.sh"), ...rest]);
+ break;
+ case "mac":
+ ensureDemoMacApp({ soft: false });
+ console.log(`Launching ${DEMO_APP}`);
+ run("open", [DEMO_APP]);
+ break;
+ case "web-check":
+ await runAsync("node", [path.join(SHOTS_DIR, "web-check.mjs"), ...rest]);
+ break;
+ case "help":
+ shotsUsage();
+ break;
+ default:
+ console.error(`Unknown shots verb: ${verb}`);
+ shotsUsage();
+ process.exit(1);
+ }
+}
+
// Command names are one or two bare words ("up", "build mac"); everything
// after them belongs to the command. Match on the words alone — joining the
// whole argv made any flag part of the lookup key, so `up --clean` fell
@@ -496,6 +625,8 @@ if (!cmd || wantsHelp) {
console.log(" ./tools/dev test web Run web tests only");
console.log(" ./tools/dev test go [args] Run Go tests only (args go to go test,");
console.log(" e.g. -run TestFoo ./internal/bot)");
+ console.log(" ./tools/dev shots <verb> Marketing-screenshot kit: up | tunnel | mac |");
+ console.log(" web-check <png> (docs/marketing-screenshots.md)");
console.log("");
process.exit(wantsHelp ? 0 : 1);
}
diff --git a/tools/marketing-shots/.gitignore b/tools/marketing-shots/.gitignore
new file mode 100644
index 0000000..76140d2
--- /dev/null
+++ b/tools/marketing-shots/.gitignore
@@ -0,0 +1,10 @@
+# 🤖 throwaway demo runtime artifacts — never commit
+.demo.db
+.demo.db-wal
+.demo.db-shm
+.demo-state.json
+.demo-blobs/
+web-check.png
+*.png
+lchat-try.db*
+.demo.vapid
diff --git a/tools/marketing-shots/demo-lib.mjs b/tools/marketing-shots/demo-lib.mjs
new file mode 100644
index 0000000..16cb511
--- /dev/null
+++ b/tools/marketing-shots/demo-lib.mjs
@@ -0,0 +1,153 @@
+// 🤖 tools/marketing-shots/demo-lib.mjs — shared helpers for the screenshot kit.
+//
+// Seeds an E2EE demo space THROUGH THE REAL PROTOCOL (the server only ever
+// holds ciphertext, so there's no INSERT-plaintext shortcut). Every member is
+// provisioned via an admin key-invite → claim → /api/pubkey exactly the way the
+// browser does it; threads and messages are encrypted client-side here with the
+// very crypto the web app runs (web/static/crypto.js), then POSTed.
+//
+// This module is imported by seed-team.mjs, device-link.mjs, and web-check.mjs.
+// It is Node-only (uses global fetch + WebCrypto, both present in modern Node)
+// and imports the repo's real crypto/invite modules by relative path.
+
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import {
+ randomSecret,
+ deriveInvite,
+ generateUserKeypair,
+ generateKey,
+ encrypt,
+ wrapThreadKey,
+} from "../../web/static/crypto.js";
+import { buildInviteFragment } from "../../web/static/invite.js";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+export const STATE_PATH = path.join(HERE, ".demo-state.json");
+
+export function readState() {
+ if (!existsSync(STATE_PATH)) return null;
+ return JSON.parse(readFileSync(STATE_PATH, "utf8"));
+}
+export function writeState(s) {
+ writeFileSync(STATE_PATH, JSON.stringify(s, null, 2) + "\n");
+}
+
+export async function postJSON(url, body, headers = { "Content-Type": "application/json" }) {
+ const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ throw new Error(`POST ${url} -> HTTP ${res.status} ${detail}`.trim());
+ }
+ return res.json();
+}
+
+export function adminHeaders(adminToken) {
+ return { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` };
+}
+
+// Fully provision a member: mint an admin key-invite, claim it (so the member
+// gets a device token we can post as), generate its keypair, and register the
+// public key. Returns everything later steps need. When grantsAdmin, the member
+// becomes an admin (used for "Cass", the operator's signed-in identity).
+export async function provisionMember(base, adminToken, name, { grantsAdmin = false } = {}) {
+ const admin = adminHeaders(adminToken);
+ const T = randomSecret();
+ const d = await deriveInvite(T);
+ const create = await postJSON(
+ `${base}/api/admin/key-invites`,
+ { label: name, id: d.id, wrapped: "", claim_hash: d.claimHash, grants_admin: grantsAdmin },
+ admin,
+ );
+ const claim = await postJSON(`${base}/api/key-invite/${encodeURIComponent(d.id)}/claim`, {
+ claim_secret: d.claimSecret,
+ });
+ const kp = await generateUserKeypair();
+ await postJSON(`${base}/api/pubkey`, { token: claim.token, public_key: kp.publicKey });
+ return { name, userId: create.user_id, token: claim.token, publicKey: kp.publicKey, privateKey: kp.privateKey };
+}
+
+// Create a group thread named `name` with everyone in `members` (each a
+// provisionMember result). `creator` must be one of them. The thread key is
+// generated here and wrapped to each member's public key; the name rides
+// encrypted under the thread key. Returns { threadId, key } — key is the raw
+// AES CryptoKey, reused to encrypt this thread's messages.
+export async function createThread(base, name, creator, members) {
+ const group = await generateKey();
+ const memberKeys = [];
+ for (const m of members) {
+ memberKeys.push({ user_id: m.userId, wrapped: await wrapThreadKey(group.b64, m.publicKey) });
+ }
+ const nameBlob = await encrypt(group.key, { name });
+ const thread = await postJSON(`${base}/api/threads`, {
+ token: creator.token,
+ kind: "group",
+ name_blob: nameBlob,
+ members: memberKeys,
+ });
+ return { threadId: thread.thread_id, key: group.key };
+}
+
+// Post one encrypted chat message as `author` into a thread. `extra` folds in
+// optional fields (replyTo, action, …). Returns the server row { id, ... } so
+// callers can target it for replies/reactions. `ts` sets the DISPLAYED time
+// (the payload ts the web reads); server created_at is always "now".
+export async function postMessage(base, thread, author, text, ts, extra = {}) {
+ const blob = await encrypt(thread.key, {
+ name: author.name,
+ text,
+ ts,
+ uid: author.userId,
+ ...extra,
+ });
+ return postJSON(`${base}/api/messages?token=${encodeURIComponent(author.token)}`, {
+ thread_id: thread.threadId,
+ blob,
+ });
+}
+
+// Post a reaction. Reactions are ordinary encrypted messages of type:"reaction"
+// (NOT a separate frame) that clients fold into the target message's reaction
+// tally. targetSid is the reacted-to message's server id; targetUid its author.
+export async function postReaction(base, thread, reactor, targetMsg, targetAuthor, emoji, ts) {
+ const blob = await encrypt(thread.key, {
+ type: "reaction",
+ targetSid: targetMsg.id,
+ targetUid: targetAuthor.userId,
+ emoji,
+ name: reactor.name,
+ uid: reactor.userId,
+ ts,
+ on: true,
+ });
+ return postJSON(`${base}/api/messages?token=${encodeURIComponent(reactor.token)}`, {
+ thread_id: thread.threadId,
+ blob,
+ });
+}
+
+// Mint a fresh single-use "add a device" invite for an existing member (here
+// always Cass, the operator's identity), shipping that member's keypair wrapped
+// in the invite so opening the link signs a browser/native app straight in as
+// them. `origin` is the base the link should point at (localhost for the web
+// check / a Mac that can reach it, or the https tunnel origin for phones).
+// Each link is single-use — call again per device.
+export async function mintDeviceLink(base, adminToken, member, origin, label = "You") {
+ const T = randomSecret();
+ const d = await deriveInvite(T);
+ const wrapped = await encrypt(d.wrapKey, { pub: member.publicKey, priv: member.privateKey });
+ await postJSON(
+ `${base}/api/admin/key-invites`,
+ { id: d.id, wrapped, claim_hash: d.claimHash, user_id: member.userId },
+ adminHeaders(adminToken),
+ );
+ return `${origin.replace(/\/$/, "")}/join/join#${buildInviteFragment(T, label)}`;
+}
+
+// today(h, m) → epoch ms for h:m local time today (for plausible in-thread times).
+export function today(h, m) {
+ const d = new Date();
+ d.setHours(h, m, 0, 0);
+ return d.getTime();
+}
diff --git a/tools/marketing-shots/device-link.mjs b/tools/marketing-shots/device-link.mjs
new file mode 100755
index 0000000..a2e19c4
--- /dev/null
+++ b/tools/marketing-shots/device-link.mjs
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+// 🤖 tools/marketing-shots/device-link.mjs — mint a fresh "add a device" sign-in
+// link for the demo space's operator identity ("Cass"), pointed at whatever
+// origin you give.
+//
+// Each link is SINGLE-USE, so run this once per device you want to sign in:
+// node tools/marketing-shots/device-link.mjs # link on the seeded base (localhost)
+// node tools/marketing-shots/device-link.mjs https://try-abc.11relay.com # link on the tunnel origin (phone)
+//
+// Reads .demo-state.json (written by seed-team.mjs) for the base, admin token,
+// and Cass's keypair. Prints just the link on stdout.
+
+import { readState, mintDeviceLink } from "./demo-lib.mjs";
+
+const origin = process.argv[2] || null;
+
+async function main() {
+ const s = readState();
+ if (!s) {
+ console.error("device-link: no .demo-state.json — run seed-team.mjs first.");
+ process.exit(1);
+ }
+ const link = await mintDeviceLink(s.base, s.adminToken, s.cass, origin || s.base, "You");
+ console.log(link);
+}
+
+main().catch((e) => {
+ console.error("device-link:", e.message);
+ process.exit(1);
+});
diff --git a/tools/marketing-shots/seed-team.mjs b/tools/marketing-shots/seed-team.mjs
new file mode 100755
index 0000000..90d4dff
--- /dev/null
+++ b/tools/marketing-shots/seed-team.mjs
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+// 🤖 tools/marketing-shots/seed-team.mjs — seed the "The Commons" civic-tech
+// demo space.
+//
+// Seeds a running local lchat instance (started by serve.sh / `./tools/dev
+// shots up`) with a multi-thread team space that matches the marketing web
+// frame (website .../hero/team-web.png): the space is "The Commons", its
+// flagship all-members thread "The commons 🌱" carries the exact web-frame
+// conversation (Aoife / Marcus / Devi + a reply + reactions), and five more
+// team threads give the Mac/iPhone thread list a realistic spread.
+//
+// Everything is FICTIONAL. Seeding goes through the real E2EE protocol (see
+// demo-lib.mjs) — the server only ever sees ciphertext.
+//
+// Usage: node tools/marketing-shots/seed-team.mjs --base http://127.0.0.1:8848 --admin <token>
+// --base the running instance (default http://127.0.0.1:8848, or $LCHAT_BASE)
+// --admin its admin token (default $LCHAT_ADMIN_TOKEN)
+// --link-origin origin to print the device link on (default = --base)
+//
+// Idempotency: this always seeds a FRESH set of threads/messages. serve.sh
+// starts from a clean DB each run, so re-seeding a clean DB is the norm; running
+// it twice against the SAME DB would duplicate the space (harmless for captures,
+// but not intended). It rewrites .demo-state.json and mints a fresh device link.
+
+import {
+ provisionMember,
+ createThread,
+ postMessage,
+ postReaction,
+ mintDeviceLink,
+ writeState,
+ today,
+} from "./demo-lib.mjs";
+
+function arg(name, def) {
+ const i = process.argv.indexOf(`--${name}`);
+ return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
+}
+
+const base = arg("base", process.env.LCHAT_BASE || "http://127.0.0.1:8848");
+const adminToken = arg("admin", process.env.LCHAT_ADMIN_TOKEN || "");
+const linkOrigin = arg("link-origin", base);
+if (!adminToken) {
+ console.error("seed-team: no admin token (pass --admin <token> or set LCHAT_ADMIN_TOKEN)");
+ process.exit(1);
+}
+
+async function main() {
+ // Members (6). Cass is the operator's signed-in identity (admin); the other
+ // five are the people whose messages fill the space.
+ console.error("seed-team: provisioning members…");
+ const cass = await provisionMember(base, adminToken, "Cass", { grantsAdmin: true });
+ const aoife = await provisionMember(base, adminToken, "Aoife");
+ const marcus = await provisionMember(base, adminToken, "Marcus");
+ const devi = await provisionMember(base, adminToken, "Devi");
+ const niamh = await provisionMember(base, adminToken, "Niamh");
+ const tomas = await provisionMember(base, adminToken, "Tomás");
+ const everyone = [cass, aoife, marcus, devi, niamh, tomas];
+
+ // Threads are created + populated in ascending recency so "The commons 🌱"
+ // ends up the most-recently-active → top of the list (like the family shot's
+ // "Everyone"). Cass never posts LAST in a thread, so no preview reads "You:".
+ console.error("seed-team: creating threads + messages…");
+
+ // ── Watercooler ☕ ──────────────────────────────────────────────
+ {
+ const t = await createThread(base, "Watercooler ☕", cass, everyone);
+ await postMessage(base, t, aoife, "anyone tried the new place on Dame St?", today(8, 2));
+ await postMessage(base, t, tomas, "coffee run in 10, who's in ☕", today(8, 6));
+ }
+
+ // ── Roadmap ────────────────────────────────────────────────────
+ {
+ const t = await createThread(base, "Roadmap", cass, everyone);
+ await postMessage(base, t, marcus, "what's the one thing that unblocks the most people?", today(8, 15));
+ await postMessage(base, t, niamh, "moved the transparency portal to next sprint", today(8, 20));
+ }
+
+ // ── Field notes 📓 ──────────────────────────────────────────────
+ {
+ const t = await createThread(base, "Field notes 📓", cass, everyone);
+ await postMessage(base, t, niamh, "librarian in Phibsboro wants the bin map printed as a poster", today(8, 25));
+ await postMessage(base, t, devi, "three councillors asked where the data came from today 📓", today(8, 30));
+ }
+
+ // ── Shipping 🚀 ────────────────────────────────────────────────
+ {
+ const t = await createThread(base, "Shipping 🚀", cass, everyone);
+ await postMessage(base, t, marcus, "bin map's passing lighthouse, merging now", today(8, 34));
+ await postMessage(base, t, aoife, "bin map v2 is live — dark mode + cycle lanes 🚀", today(8, 38));
+ }
+
+ // ── Standup ────────────────────────────────────────────────────
+ // A richer thread with a Cass (own) bubble + a reaction, so it's a good
+ // ALTERNATIVE for the Mac open-thread (variety from the web frame).
+ {
+ const t = await createThread(base, "Standup", cass, everyone);
+ await postMessage(base, t, marcus, "y'day: shipped the bin map. today: vote-day banner. blockers: none", today(8, 40));
+ const mine = await postMessage(base, t, cass, "nice — I'll take the councillor data requests", today(8, 42));
+ await postReaction(base, t, marcus, mine, cass, "👍", today(8, 43));
+ await postMessage(base, t, devi, "scraper's green again after the site redesign", today(8, 45));
+ await postMessage(base, t, marcus, "pushed the vote-day banner. live now", today(8, 47));
+ }
+
+ // ── The commons 🌱 (flagship — matches team-web.png exactly) ────
+ const commons = await createThread(base, "The commons 🌱", cass, everyone);
+ await postMessage(base, commons, aoife, "housing numbers came out as another 94-page pdf 🙃", today(9, 12));
+ const marcusMsg = await postMessage(base, commons, marcus, "the bin map shipped in four days. 3,000 users.", today(9, 13));
+ // Reactions on Marcus's message → ❤️ ×1, 🔥 ×2 (as in the web frame). Posted
+ // BEFORE the final message so the thread's last row is a normal message
+ // (a trailing reaction would blank the list preview).
+ await postReaction(base, commons, aoife, marcusMsg, marcus, "❤️", today(9, 13));
+ await postReaction(base, commons, devi, marcusMsg, marcus, "🔥", today(9, 13));
+ await postReaction(base, commons, niamh, marcusMsg, marcus, "🔥", today(9, 14));
+ // Cass's reply to Marcus (the orange/own bubble with the quoted context).
+ await postMessage(base, commons, cass, "ugly and useful beats perfect and pending", today(9, 15), {
+ replyTo: { id: marcusMsg.id, name: "Marcus", snippet: "the bin map shipped in four days. 3,000 users." },
+ });
+ // Devi's message is LAST → the list preview reads "Devi: scraper tonight then…"
+ await postMessage(base, commons, devi, "scraper tonight then. map live before the vote 🌱", today(9, 16));
+
+ // Persist what device-link.mjs needs to re-mint links for Cass.
+ writeState({
+ base,
+ adminToken,
+ seededAt: new Date().toISOString(),
+ spaceName: "The Commons",
+ cass: { userId: cass.userId, publicKey: cass.publicKey, privateKey: cass.privateKey },
+ members: everyone.map((m) => ({ name: m.name, userId: m.userId })),
+ });
+
+ const link = await mintDeviceLink(base, adminToken, cass, linkOrigin, "You");
+ console.error("seed-team: done.");
+ console.log(link); // stdout = just the link, so callers can capture it
+}
+
+main().catch((e) => {
+ console.error("seed-team:", e.message);
+ process.exit(1);
+});
diff --git a/tools/marketing-shots/serve.sh b/tools/marketing-shots/serve.sh
new file mode 100755
index 0000000..760d74b
--- /dev/null
+++ b/tools/marketing-shots/serve.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# 🤖 tools/marketing-shots/serve.sh — build (if needed) + run the local demo
+# instance, seed the "The Commons" civic-tech space, print the URLs + a
+# localhost sign-in link. Wrapped by `./tools/dev shots up`.
+#
+# Foreground: it starts the server, seeds it, prints everything, then blocks so
+# the space stays live for capturing. Ctrl-C stops it. The DB + state are wiped
+# on each start (a clean, reproducible space every run) unless --keep.
+#
+# ./tools/marketing-shots/serve.sh # fresh seed on port 8848
+# ./tools/marketing-shots/serve.sh --keep # reuse the existing DB (don't re-seed)
+# LCHAT_DEMO_PORT=9000 ./tools/marketing-shots/serve.sh
+#
+# Data + isolation: DB lives at tools/marketing-shots/.demo.db (gitignored,
+# wiped each run). Admin token is a throwaway fixed value. Nothing here touches
+# any real/production instance.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT"
+KIT="$ROOT/tools/marketing-shots"
+
+PORT="${LCHAT_DEMO_PORT:-8848}"
+DB="$KIT/.demo.db"
+ADMIN="${LCHAT_DEMO_ADMIN:-capkit-demo-admin}"
+GROUP_NAME="The Commons"
+KEEP=0
+[ "${1:-}" = "--keep" ] && KEEP=1
+
+# Build the binary from THIS worktree if it's missing (or --rebuild).
+if [ ! -x "$ROOT/lchat" ] || [ "${1:-}" = "--rebuild" ]; then
+ echo "serve: building lchat from this worktree…"
+ GOTOOLCHAIN=auto go build -o "$ROOT/lchat" .
+fi
+
+# Refuse to trample another process on the port.
+if lsof -ti "tcp:$PORT" >/dev/null 2>&1; then
+ echo "serve: something is already listening on port $PORT — stop it or set LCHAT_DEMO_PORT." >&2
+ exit 1
+fi
+
+if [ "$KEEP" = "0" ]; then
+ rm -f "$DB" "$DB-wal" "$DB-shm" "$KIT/.demo-state.json"
+fi
+
+echo "serve: starting server on 127.0.0.1:$PORT (db $(basename "$DB"))…"
+LCHAT_DB="$DB" LCHAT_ADMIN_TOKEN="$ADMIN" LCHAT_GROUP_NAME="$GROUP_NAME" LCHAT_SKIP_SETUP=1 \
+ ADDR="127.0.0.1:$PORT" "$ROOT/lchat" &
+SERVER_PID=$!
+trap 'echo; echo "serve: stopping server…"; kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
+
+# Wait for it to answer.
+for _ in $(seq 1 100); do
+ curl -sf "http://127.0.0.1:$PORT/api/version" >/dev/null 2>&1 && break
+ sleep 0.1
+done
+curl -sf "http://127.0.0.1:$PORT/api/version" >/dev/null 2>&1 || { echo "serve: server didn't come up" >&2; exit 1; }
+
+LINK=""
+if [ "$KEEP" = "0" ] || [ ! -f "$KIT/.demo-state.json" ]; then
+ echo "serve: seeding The Commons…"
+ LINK="$(node tools/marketing-shots/seed-team.mjs --base "http://127.0.0.1:$PORT" --admin "$ADMIN")"
+else
+ echo "serve: --keep: reusing existing space; minting a fresh sign-in link…"
+ LINK="$(node tools/marketing-shots/device-link.mjs "http://127.0.0.1:$PORT")"
+fi
+
+cat <<EOF
+
+──────────────────────────────────────────────────────────────────────
+ ✔ Demo space "The Commons" is live.
+
+ Local URL: http://127.0.0.1:$PORT/
+ Admin: http://127.0.0.1:$PORT/admin#token=$ADMIN
+ Web check: ./tools/dev shots web-check shot.png (headless 2x screenshot)
+
+ Sign-in link (localhost — one-time, signs you in as "Cass"):
+ $LINK
+
+ For the NATIVE apps use the tunnel instead (real https):
+ In another terminal: ./tools/dev shots tunnel
+ It prints https://try-*.11relay.com links to open in the Mac app + on the iPhone.
+
+ Ctrl-C stops the server.
+──────────────────────────────────────────────────────────────────────
+
+EOF
+
+wait "$SERVER_PID"
diff --git a/tools/marketing-shots/tunnel.sh b/tools/marketing-shots/tunnel.sh
new file mode 100755
index 0000000..3fd9220
--- /dev/null
+++ b/tools/marketing-shots/tunnel.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# 🤖 tools/marketing-shots/tunnel.sh — lend the local demo instance a real public
+# https address via `lchat try --local`, so the native apps (especially the
+# iPhone) can join over real TLS. Prints the try-*.11relay.com origin +
+# ready-to-open sign-in links. Wrapped by `./tools/dev shots tunnel`.
+#
+# Prereqs: `./tools/dev shots up` is already running (this tunnels its port). The
+# tunnel uses the production gateway tunnel.11ch.at — it carries only the same
+# encrypted traffic any hosted space would; the instance it fronts is your local
+# demo DB.
+#
+# ./tools/marketing-shots/tunnel.sh
+#
+# Foreground: it holds the tunnel open (Ctrl-C retires the address). Each printed
+# sign-in link is SINGLE-USE — for another device run:
+# node tools/marketing-shots/device-link.mjs <origin>
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT"
+KIT="$ROOT/tools/marketing-shots"
+
+PORT="${LCHAT_DEMO_PORT:-8848}"
+if ! curl -sf "http://127.0.0.1:$PORT/api/version" >/dev/null 2>&1; then
+ echo "tunnel: nothing answering on 127.0.0.1:$PORT — start ./tools/dev shots up first." >&2
+ exit 1
+fi
+if [ ! -f "$KIT/.demo-state.json" ]; then
+ echo "tunnel: no .demo-state.json — run ./tools/dev shots up first." >&2
+ exit 1
+fi
+
+LOG="$(mktemp -t capkit-tunnel.XXXXXX)"
+echo "tunnel: opening a public address for 127.0.0.1:$PORT via tunnel.11ch.at…"
+"$ROOT/lchat" try --local "127.0.0.1:$PORT" >"$LOG" 2>&1 &
+TRY_PID=$!
+trap 'echo; echo "tunnel: retiring the address…"; kill "$TRY_PID" 2>/dev/null || true; rm -f "$LOG"' EXIT INT TERM
+
+# Wait for the gateway to assign an origin (grep it out of the try output).
+ORIGIN=""
+for _ in $(seq 1 120); do
+ ORIGIN="$(grep -oE 'https://try-[a-z0-9.-]+' "$LOG" | head -1 || true)"
+ [ -n "$ORIGIN" ] && break
+ kill -0 "$TRY_PID" 2>/dev/null || { echo "tunnel: lchat try exited early:" >&2; cat "$LOG" >&2; exit 1; }
+ sleep 0.5
+done
+[ -n "$ORIGIN" ] || { echo "tunnel: gateway didn't assign an address in time:" >&2; cat "$LOG" >&2; exit 1; }
+
+# Mint two fresh single-use sign-in links on the public origin (Mac + iPhone).
+MAC_LINK="$(node tools/marketing-shots/device-link.mjs "$ORIGIN")"
+IOS_LINK="$(node tools/marketing-shots/device-link.mjs "$ORIGIN")"
+
+cat <<EOF
+
+──────────────────────────────────────────────────────────────────────
+ ✔ Public address: $ORIGIN
+
+ Mac app — Spaces ▸ Add from link… and paste (signs in as "Cass"):
+ $MAC_LINK
+
+ iPhone — open this link ON THE DEVICE (Safari → Open in the app / join):
+ $IOS_LINK
+
+ Each link is single-use. Need another device? run:
+ node tools/marketing-shots/device-link.mjs $ORIGIN
+
+ Ctrl-C retires the address (the space's data stays in tools/marketing-shots/.demo.db).
+──────────────────────────────────────────────────────────────────────
+
+EOF
+
+wait "$TRY_PID"
diff --git a/tools/marketing-shots/web-check.mjs b/tools/marketing-shots/web-check.mjs
new file mode 100755
index 0000000..0c823c2
--- /dev/null
+++ b/tools/marketing-shots/web-check.mjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+// 🤖 tools/marketing-shots/web-check.mjs — headless screenshot of the seeded
+// demo space, so you can eyeball that it matches the marketing web frame before
+// capturing native.
+//
+// Mints a fresh sign-in link for Cass, opens the space in headless Chromium,
+// switches to the Lit Room theme (warm cream, as in team-web.png), opens the
+// flagship "The commons 🌱" thread, and writes a 2x screenshot.
+//
+// Usage: node tools/marketing-shots/web-check.mjs [output.png] [--thread "The commons"]
+// default output: tools/marketing-shots/web-check.png
+// Requires the demo server to be running (`./tools/dev shots up`) and
+// .demo-state.json to exist (seed-team.mjs). Playwright is resolved from the
+// npx cache (override with $LCHAT_PLAYWRIGHT).
+
+import { readState, mintDeviceLink } from "./demo-lib.mjs";
+
+const PW =
+ process.env.LCHAT_PLAYWRIGHT ||
+ "/Users/paulcampbell/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs";
+
+function arg(name, def) {
+ const i = process.argv.indexOf(`--${name}`);
+ return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
+}
+
+const out =
+ process.argv[2] && !process.argv[2].startsWith("--")
+ ? process.argv[2]
+ : "tools/marketing-shots/web-check.png";
+const threadName = arg("thread", "The commons");
+
+async function main() {
+ const s = readState();
+ if (!s) {
+ console.error("web-check: no .demo-state.json — run seed-team.mjs first.");
+ process.exit(1);
+ }
+ const link = await mintDeviceLink(s.base, s.adminToken, s.cass, s.base, "You");
+
+ const { chromium } = await import(PW);
+ const browser = await chromium.launch();
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 1024 }, deviceScaleFactor: 2 });
+ const page = await ctx.newPage();
+
+ // Sign in via the add-device link (join.js claims it + stores keys). The link
+ // shows a confirm card first; "Sign in here in the browser" completes it.
+ await page.goto(link, { waitUntil: "networkidle" });
+ await page.getByText("Sign in here in the browser").click();
+ await page.waitForSelector("#thread-list .thread-row", { timeout: 20000 });
+ // Pick the Lit Room theme + light appearance, matching the web frame.
+ await page.evaluate(() => {
+ localStorage.setItem("lchat_theme", "litroom");
+ localStorage.setItem("lchat_appearance", "light");
+ });
+ await page.reload({ waitUntil: "networkidle" });
+
+ // Open the flagship thread from the list.
+ await page.waitForSelector("#thread-list .thread-row", { timeout: 20000 });
+ const row = page.locator(".thread-row", { hasText: threadName }).first();
+ await row.click();
+ await page.waitForTimeout(1500); // let the feed fold + render
+
+ await page.screenshot({ path: out });
+ console.error(`web-check: wrote ${out}`);
+ await browser.close();
+}
+
+main().catch((e) => {
+ console.error("web-check:", e.message);
+ process.exit(1);
+});