Files
runicgateway.com/src/pages/app.astro
wtclaude 1313e748ae
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
feat(beta): phase 5 — the app page and the closed-beta signup
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>
2026-08-24 03:50:51 -05:00

351 lines
12 KiB
Plaintext

---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import Screenshots from '../components/app/Screenshots.astro';
import platform from '../data/platform.json';
import { appFeatures, requiresDeployment } from '../data/app.mjs';
import { playPolicy } from '../data/beta.mjs';
/**
* `/app/` — PLAN.md §10, phase 5.
*
* ---------------------------------------------------------------------------------------
* THE PAGE LEADS WITH A LIMITATION, ON PURPOSE
* ---------------------------------------------------------------------------------------
* Directly under the lede, before a single feature, this page says the app ships pointed at
* nothing and will not open until it is given the address of a deployment. That is an
* unusual thing to put above the fold and it is the right thing here, because the
* alternative is somebody installing a 13 MB client and discovering it on the first screen.
* §1's "understated honesty" is cheapest to keep exactly at the moment it costs a download.
*
* ---------------------------------------------------------------------------------------
* TWO WAYS TO GET IT, EQUALLY WEIGHTED — AND ONE OF THEM IS CURRENTLY OFF
* ---------------------------------------------------------------------------------------
* The org lead's call: the sideload and the beta get the same visual weight, with the beta
* arguing for itself on delivery and updates rather than on being the only door. Both
* panels are the same component, side by side, neither styled as the primary.
*
* The APK panel then has a second state, because the published build does not work. It
* renders `platform.androidApk.serviceable ? <the two download links> : <a plain statement
* that the build is being replaced>`. That flag is a person's judgement rather than a
* fetched fact — `checkFacts.mjs` asserts the assets EXIST but cannot assert they run — so
* turning the link back on is one boolean in `platform.json`, in the same commit as
* whatever release fixed it.
*
* Note what this deliberately does not do: it does not remove the panel. A page that simply
* omitted sideloading while the build is broken would read, to somebody who was told the
* APK exists, as a page hiding it.
*
* ---------------------------------------------------------------------------------------
* THE SCREENSHOT SLOT IS EMPTY AND THAT IS THE DECISION (D26)
* ---------------------------------------------------------------------------------------
* §10 promised "the 14 existing screenshots". They exist, and they are the wrong fourteen:
* a July trusted-device smoke test against an unseeded development instance, captured
* before the theming work landed, showing mostly login and two-factor screens over an
* almost empty home page. Shipping them would break D4 (real screenshots, from the review
* stack, not placeholders) and would show an app that no longer looks like that.
*
* So `Screenshots.astro` renders nothing until phase 9 fills it — the phase that already
* stands up the review stack and seeds presentable content, and now also captures the app
* against it, so the phone shots and the web shots show the same deployment. The component
* exists now so the slot has a defined shape and phase 9 is a data change.
*/
const title = 'The Android app';
const description =
'A native Android client for a Runic Gateway deployment: the shard, the site and your ' +
'account, themed by whichever community you point it at.';
const apk = platform.androidApk;
const release = platform.releases['Android-app'];
const releasePage = `${platform.gitea.base}/${platform.gitea.org}/Android-app/releases`;
const downloadBase = `${releasePage}/download/${release}`;
---
<Base title={title} description={description}>
<PageHeader eyebrow="On your phone" title="The app for a deployment you already use">
<p>
A native Android client — Kotlin and Compose, not a website in a frame. It shows the
live game data a deployment publishes, the news and wiki it hosts, and the parts of
your account that make sense on a phone.
</p>
<p>
It is <a href={releasePage} rel="noopener noreferrer">open source like everything else
here</a>, and it carries no Google messaging dependency: notifications arrive over a
server the operator runs.
</p>
</PageHeader>
<!-- The limitation, before the features. See the note above. -->
<section class="page section">
<div class="panel prereq">
<h2>{requiresDeployment.title}</h2>
<p>{requiresDeployment.body}</p>
</div>
</section>
<section class="page section">
<h2 class="app-h2">What it does</h2>
<p class="prose app-lede">
Grouped by what you would open it for. Most of this is conditional on the deployment
you connect to — a community that runs no game module has a news and account app, and
that is a legitimate way to run this.
</p>
{
appFeatures.map((group) => (
<section class="app-group">
<h3>{group.heading}</h3>
{group.blurb && <p class="app-group__blurb">{group.blurb}</p>}
<ul class="app-grid">
{group.items.map((item) => (
<li class="panel app-item">
<h4>{item.title}</h4>
<p>{item.body}</p>
{item.gate && (
<p class="app-item__gate">
<span class="app-item__gate-label">Needs</span>
{item.gate}
</p>
)}
</li>
))}
</ul>
</section>
))
}
</section>
<Screenshots />
<section class="page section" id="get-it">
<h2 class="app-h2">Two ways to get it</h2>
<p class="prose app-lede">
Neither is the &ldquo;real&rdquo; one. Sideloading works today and always will;
the closed test is how it reaches a phone through Play, with updates that install
themselves.
</p>
<div class="app-getgrid">
<div class="panel app-get">
<p class="eyebrow">Direct download</p>
<h3>The signed APK</h3>
{
apk.serviceable ? (
<>
<p>
Built and signed by the same CI that cuts every release. Android asks you to
allow installing from your browser or file manager the first time; the
checksum file is there so you can verify what you downloaded before you do.
</p>
<p class="app-get__actions">
<a class="btn btn--primary" href={`${downloadBase}/${apk.asset}`} rel="noopener noreferrer">
Download {release}
</a>
<a class="btn btn--ghost" href={`${downloadBase}/${apk.checksums}`} rel="noopener noreferrer">
Checksums
</a>
</p>
</>
) : (
<>
<p>
<strong>The published build is being replaced.</strong> {release} is on the
releases page but does not install and run correctly, so this page does not
link it — a download that wastes your time is worse than no download.
</p>
<p>
The next release restores this. Nothing about the app has been withdrawn and
the source has not moved; it is one build that went out wrong.
</p>
<p class="app-get__actions">
<a class="btn btn--ghost" href={releasePage} rel="noopener noreferrer">
The releases page
</a>
</p>
</>
)
}
<p class="app-get__foot">
Android {apk.minAndroid} or newer &middot; installs as <code>{platform.androidApplicationId}</code>
</p>
</div>
<div class="panel app-get">
<p class="eyebrow">Google Play</p>
<h3>The closed beta</h3>
<p>
Delivery through Play, and updates that arrive on their own instead of being
downloaded again. It is a closed test, so a place on it has to be granted — the
list is being collected now.
</p>
<p>
It has not opened yet, and the page says why in full rather than promising a date:
Play needs {playPolicy.testersRequired} people opted in for {playPolicy.testerDays}
{' '}days before the app can go any further, and a tester needs somewhere to point
it.
</p>
<p class="app-get__actions">
<a class="btn btn--primary" href="/beta/">Join the list</a>
</p>
<p class="app-get__foot">
No email is ever sent &middot; the address is used for the tester list and nothing else
</p>
</div>
</div>
</section>
<NotBuilt scope="app" title="What the app does not do" />
</Base>
<style>
.app-h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.app-lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
/* The prerequisite panel. Given a gold edge rather than a warning colour: it is a fact
about the product, not an error state, and D8's house style does not shout. */
.prereq {
border-color: var(--gold-deep);
}
.prereq h2 {
margin: 0 0 0.6rem;
color: var(--gold);
font-size: clamp(1.25rem, 2.6vw, 1.5rem);
}
.prereq p {
margin: 0;
max-width: var(--measure);
color: var(--text);
}
.app-group + .app-group {
margin-top: 2.75rem;
}
.app-group h3 {
margin: 0 0 0.4rem;
color: var(--head);
font-size: 1.25rem;
}
.app-group__blurb {
margin: 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.96rem;
}
.app-grid {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.app-item {
display: flex;
flex-direction: column;
}
.app-item h4 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1rem;
}
.app-item p {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* Same foot-of-card treatment as NotBuilt's exit condition, so "what this needs" reads
as the same kind of statement in the same place on every card in a row. */
.app-item__gate {
flex: 0;
margin: 1rem 0 0;
padding-top: 0.8rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.86rem;
}
.app-item__gate-label {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
/* Two columns that stay equal. `1fr 1fr` rather than auto-fit is the whole point of the
org lead's "equal billing": auto-fit would let the longer panel take more room and
turn a deliberate tie into an accidental winner. */
.app-getgrid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 720px) {
.app-getgrid {
grid-template-columns: 1fr;
}
}
.app-get {
display: flex;
flex-direction: column;
}
.app-get h3 {
margin: 0.35rem 0 0.75rem;
font-size: 1.3rem;
}
.app-get p {
margin: 0 0 0.9rem;
color: var(--muted);
font-size: 0.95rem;
}
.app-get__actions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
/* Pushes the buttons to the same line in both panels regardless of prose length —
the second half of keeping the billing equal. */
margin-top: auto;
padding-top: 0.4rem;
}
.app-get__foot {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.84rem;
}
.app-get__foot code {
font-family: var(--mono);
font-size: 0.92em;
}
</style>