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>
11 files changed,
+962
−0
CLAUDE.md+26 −0docs/marketing-screenshots.md+213 −0ios/LChatMac/LChatMac-Demo.entitlements+26 −0tools/dev+131 −0tools/marketing-shots/.gitignore+10 −0tools/marketing-shots/demo-lib.mjs+153 −0tools/marketing-shots/device-link.mjs+30 −0tools/marketing-shots/seed-team.mjs+140 −0tools/marketing-shots/serve.sh+89 −0tools/marketing-shots/tunnel.sh+72 −0tools/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); |
| +}); |