feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s

Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.

Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):

- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
  `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
  test against an unseeded dev instance, captured before the theming work, and
  five of the fourteen are two-factor prompts. Shipping them would break D4.
  Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
  app on a validated deployment address, so a tester needs somewhere to point it.
  The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
  cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
  `androidApk.serviceable` is false because the published v0.5.0 build does not
  work. The panel stays and states that plainly rather than being removed.

Three mechanisms the plan did not anticipate:

- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
  so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
  takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
  source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires
  when a route has been built, and an on-demand route never produces a file, so
  the entry could never rot out.
- `npm test` — the five existing checks all read built output, and none of this
  logic appears there. A honeypot can stop working and leave the build identical.

Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.

Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:50:51 -05:00
parent fbd7bbe6fd
commit 1313e748ae
21 changed files with 3366 additions and 22 deletions

View File

@@ -0,0 +1,124 @@
---
/**
* The app's screenshot strip — defined now, empty until phase 9 (D26).
*
* ---------------------------------------------------------------------------------------
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING
* ---------------------------------------------------------------------------------------
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist —
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen:
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a
* development instance with no seeded content, before the theming work that changed how
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
*
* Shipping them would break two things at once: D4, which says real screenshots from the
* review stack rather than placeholders, and §1, because they would show an app that no
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
* it, and phase 9 is already the phase that stands up the review stack and seeds the
* content the web screenshots need. Adding an emulator pass to a rig that is being built
* anyway is most of the work already done, and it has the property that the phone shots
* and the browser shots then show the same deployment on the same day.
*
* The component exists rather than the page carrying a `TODO` because a defined shape is
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
*
* ---------------------------------------------------------------------------------------
* WHAT PHASE 9 SHOULD PUT HERE
* ---------------------------------------------------------------------------------------
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the
* seeded review stack, one per idea rather than one per screen: the shard hub with live
* data, the marketplace, a character sheet, the news list, the notification settings, and
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
* target — it was the number that happened to exist.
*
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
* the image, not branding an operator overrides (§7).
*/
/**
* One capture. `width` and `height` are the real pixel dimensions and are required rather
* than optional: without them the page reflows as each image decodes, and a strip of six
* phone screenshots is the worst possible place for that.
*
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
* annotated by inference is `any[]` and `astro check` is right to refuse it.
*/
interface Shot {
/** Site-absolute path under `/screens/`. */
src: string;
/** What the screen shows, for somebody who cannot see it. */
alt: string;
caption: string;
width: number;
height: number;
}
const shots: Shot[] = [];
---
{
shots.length > 0 && (
<section class="page section shots">
<h2>What it looks like</h2>
<p class="prose shots__lede">
Captured against a real deployment with real content, not mocked up. The app takes
its colours, type and navigation from the site it is connected to, so these show one
community's app rather than a neutral one.
</p>
<ul class="shots__grid">
{shots.map((shot) => (
<li class="shots__item">
<img
src={shot.src}
alt={shot.alt}
width={shot.width}
height={shot.height}
loading="lazy"
decoding="async"
/>
<p class="shots__caption">{shot.caption}</p>
</li>
))}
</ul>
</section>
)
}
<style>
.shots h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.shots__lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
.shots__grid {
display: grid;
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
.shots__item img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.shots__caption {
margin: 0.85rem 0 0;
color: var(--dim);
font-size: 0.88rem;
}
</style>