Appearance
Scaffolding a new Poe app
End-to-end workflow for creating a new app from a natural-language prompt. Scaffold → schema → UI → tests → ship.
Step 1: Scaffold
Pick a template: react, preact, solidjs, vanilla-js, phaserjs. Pick phaserjs for real-time 2D games with sprites, world coordinates, or arcade physics — it ships with the Phaser 3 dep, vite configured to inline assets and dynamic imports for the Poe sandbox iframe, and a dynamic-import seam so happy-dom unit tests stay Phaser-free.
The react, preact, and solidjs templates ship Tailwind v4 preconfigured (tailwindcss + @tailwindcss/postcss deps, postcss.config.js, @import "tailwindcss" in app/styles.css) — utility classes work out of the box. vanilla-js and phaserjs do not; if you need Tailwind there, wire it up the same way (deps + postcss config + @import "tailwindcss" in app/styles.css) before relying on utility classes, otherwise they will silently render as no-op strings.
bash
poe-tiles apps init <name> --template <t>
cd <name> && bun installGenerated files
<name>/
├── app/ # entry point + backend wiring
│ └── src/
│ ├── entry.tsx # Poe.setupStore(clientConfig); await store.waitForBootstrap(); render UI
│ └── backend.ts # re-exports appBackendConfig as default
├── synced-store/ # store contract
│ ├── schema.ts
│ ├── mutators.ts
│ ├── mutators.test.ts # colocated unit tests
│ ├── client-config.ts
│ └── backend-config.ts
├── ui/ # store-agnostic components
│ ├── App.{tsx,ts}
│ └── App.test.happydom.tsx # colocated UI tests
├── tests/ # e2e (Playwright) + setup-dom helper
├── scripts/doctor.sh # toolchain health check
├── client.ts # client-safe re-exports
├── package.json
├── tsconfig.json
├── vite.config.ts
└── playwright.config.tsStep 1.5: Verify scaffold
bash
cd <app-dir>
bun run test:allTreat a fired timeout as a real failure — find/fix the hanging test, don't extend the limit. test:all chains type-check → test → build → playwright install → test:playwright.
Step 2: Schema and mutators
Read @../synced-store/SKILL.md now before continuing — Step 2 depends on it.
Walk every cross-turn / cross-player handoff and pick a visibility tier per piece of state. Skipping this is the #1 cause of mid-implementation rewrites.
Tiers:
- Public (
ctx.table(...)) — synced to everyone in the instance. - Per-user private (
ctx.privateOfUser(userId).table(...)) — role/user-specific (active player's input, in-flight LLM prompt, drafts). - Server-only (
ctx.serverOnly().table(...)) — secrets the server uses but never exposes (answer keys, RNG seeds).
Don't design around tiers with client-side hiding/encryption — synced-store enforces at the server boundary.
Before writing schema, list every table with its tier AND every cross-player handoff with the table backing it; state the design to the user for confirmation. For turn-based games answer:
- Active player's view this turn? → typically
privateOfUser(activePlayer)written by the prior player's mutator. - Mid-turn state surviving refresh (e.g. in-flight bot call)? →
privateOfUser(self), NOT sessionStorage / component state. - Hidden during play, revealed at end? →
serverOnly()during play, copied to public on reveal.
If any answer is "figure out later," figure it out now.
Either keep generic app* names (simplest) or rename across synced-store/* + client.ts + app/src/entry.{tsx,ts} + app/src/backend.ts + ui/App.{tsx,ts} together.
Lift patterns from a reference app, not code: lobby+slot shape, onAddUser / onRemoveUser hooks (wired in backend-config.ts), turn-validation order (throw BEFORE if (!ctx.isServer) return), entries().toArray() returning [EntryKey, T] with EntryKey.itemKey: string, store.query(tx => tx.userId) for user id.
Step 3: UI
Replace the stub in ui/App.{tsx,ts}.
Appreceives{ store }prop.store.subscribe()for reads,store.mutate.<name>()for writes.Semantic HTML IDs on interactive elements (Playwright targets).
Don't import from
poe-tiles-sdkin the UI — only use thestoreprop.store.userIdis not exposed at the top level. Read viastore.query(async (tx) => tx.userId).store.subscribe(tx => tx.table("foo").entries().toArray(), entries => ...)returnsArray<[EntryKey, T]>. Destructure as[k, v], key isk.itemKey(NOTas string).Avatars + names from
$userInfo. Pull from the$userInfosystem table — never raw user IDs. Pair$usersmembership with$userInfolookup. Subscribe once, buildMap<userId, PoeUserInfo>:ts// PoeUserInfo (from @poe/synced-store-system-mutators): { userId, username, displayName, profilePicture, isDev? } // displayName / profilePicture are required strings but may be EMPTY — always fall back. store.subscribe( (tx) => tx.table("$userInfo").entries().toArray(), (entries) => { const next = new Map<string, PoeUserInfo>(); for (const [, v] of entries) next.set((v as PoeUserInfo).userId, v as PoeUserInfo); setUserInfo(next); }, ); // <img src={info.profilePicture || PLACEHOLDER} alt={info.displayName || info.username} /> // Name fallback: info?.displayName || info?.username || userId — empty strings count as missing.Use
||not??so empty strings fall through.
Step 4: Mutator tests
Complete the UI before writing UI tests. Mutator tests bind to schema (Step 2), so write those now; happy-dom + Playwright wait until Step 5. UI churns fast Steps 2–3 — UI tests against in-flux UI get rewritten 3–5×.
Blank-mode test files = test.todo() placeholders + page-loads smoke. Tests colocated with source:
synced-store/mutators.test.ts(now) —createPoeAppTestHarnessunit tests; create / update / delete / edge cases. Easily hits high coverage onsynced-store/.ui/App.test.happydom.tsx(Step 5) — happy-dom UI tests render<App>with a harness store. Only tests counting towardui/App.tsxdiff coverage (Bun--coverageskips browser code).tests/e2e.test.playwright.ts(Step 5) — Playwright E2E withTestServer. Lives intests/(not colocated). Each test: wait for a visible UI element,waitForBlobFrame(page)for the iframe, uniqueinstanceId.
With substantial UI: write one happy-dom test per major UI state (lobby / playing / generating / reveal). Without these, bun run pre-commit passes locally but CI's check-diff-coverage (80% threshold) fails — App.tsx routinely lands 30–50% with only a lobby test, and merging requires a human-attested oath in the PR description. One assertion per state is enough; drive transitions via store.mutate.<...> (faster, deterministic) or DOM clicks.
E2E note: Multi-client E2E tests (e.g., "alice draws, bob sees it") need synced-store backend bundles in the SDK tarball. If cross-client sync fails with ENOENT: synced-store-backend.js, republish with bun run publish-tar from packages/poe-tiles-sdk/. Single-client E2E always works.
Step 5: UI tests + full check
UI is functionally complete. Write:
- Happy-dom in
ui/App.test.happydom.tsx— one assertion per major UI state. - Playwright E2E in
tests/e2e.test.playwright.ts— main user flows.
Then:
bash
cd <app-dir>
bun run test:allSame timeout: 90000. Iterate until green.
Step 5.5: Fill in app metadata
The scaffold ships three listing-page fields as TODO placeholders. Fill them in yourself — do not ask the user. Derive content from what the app actually does (its synced-store/schema.ts, ui/App.*, and the original prompt). After this step there must be zero literal TODO strings in any of the three artifacts.
README.md— long description rendered on the app's landing page. Rewrite the scaffold stub with:- A 1–2 sentence pitch derived from the app's actual behaviour.
- A
## What you can dobullet list (3–5 bullets, bold lead-ins) describing real features grounded in the schema/UI — not invented ones. - Optionally a short closing section (e.g.
## Built on Synced-Store). - Keep under 16 KiB UTF-8.
.poe-app.json→shortDescription— replace the placeholder with one specific sentence (≤140 chars). Say what the user does in the app, not what the app "is for". Examples: "Online chess for two players.", "Shared todo list synced across devices.", "Real-time multiplayer draw-and-guess party game." Avoid leading filler like "An app to…".assets/screenshot.png— 720x720 square. Build the app, then run the bundled screenshot test:bashbun run build && bunx playwright test screenshot.test.playwright.tsThe test loads the app inside the iframe sandbox and writes
assets/screenshot.png. If the defaultbodyreadiness check produces a blank or half-rendered image, edittests/screenshot.test.playwright.tsto wait for a specific selector (matching what the existinge2e.test.playwright.tswaits for is usually the right move) and add a smallpage.waitForTimeout(800)for canvas/3D apps so the render loop paints a frame. Re-run after any meaningful UI change.Mobile viewport / DPR. The scaffold sets
viewport: { width: 360, height: 360 }, deviceScaleFactor: 2. The CSS viewport stays under Tailwind'ssmbreakpoint (640px) so apps render their true mobile layout (single column, no desktop side-panels), while the 2x DPR still produces a 720x720 PNG. Do not raise the CSS width to 720+ to "see more" — that triggerssm:/md:styles and the captured image will misrepresent the mobile app.
Then verify: grep -rn 'TODO' README.md .poe-app.json should return nothing. Only after that proceed to Step 6.
Step 6: Publish
Build + publish under the current user's POE_API_KEY:
bash
cd <app-dir> && bun run publish-to-poe-tilesReport the appUrl from the output as a clickable markdown link the user can try.
If POE_API_KEY is missing, warn and skip. Keys: https://poe.com/api/keys.
Final step: Report
## App Scaffolded
**Name:** <app-name>
**Location:** poe-tiles/<app-name>
**Draft PR:** <PR URL from /commit>
### What it does
<1-2 sentence summary>
### Tests
- Type check: passing
- Unit tests: X passing
- E2E tests: X passing