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>
351 lines
12 KiB
Plaintext
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 “real” 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 · 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 · 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>
|