Files
runicgateway.com/src/pages/beta.astro
wtclaude de9d25bbe7
All checks were successful
PR checks / checks (pull_request) Successful in 1m26s
feat(validation): phase 11 — the walk that found what the checks could not
The checks were green before this phase started and are green now. What found
anything was the part no script does: fifty pages at three widths in a real
browser, a signup walked against its store, and a full brand mount applied and
restarted.

D51 — the chrome and the head follow the mount; the consent sentence does not.
With a complete brand.json mounted, forty-nine pages came back rebranded and
/beta did not. applyBrand.mjs rewrites files in dist/client and /beta renders
per request, so its HTML never exists as a file to rewrite; liveBrand() was
there for exactly that and was used for betaOptInUrl alone. Everything around
the form — title, OG tags, header lockup, footer Source and Discord links —
came from the shared chrome, and the shared chrome was baked. renderBrand()
picks by Astro.isPrerendered, in one place, so the other forty-nine keep taking
the value the boot rewrite will replace. CONSENT_TEXT stays a constant: it is
stored verbatim in a person's row, so following a mounted name would change the
recorded text of a consent already given.

D52 — the documentation half gets phase 10's skip-link fix. Starlight's skip
link targets the page <h1>, which is no more focusable than the <main> phase 10
fixed, so following it moved the viewport and not the focus on forty pages. A
PageTitle override adds tabindex="-1".

D53 — no twelfth check. The external-link sweep (73 of 74 alive) and the
brand-mount walk stay throwaway scripts: one would make the build depend on
other people's uptime and the other needs Chrome on the runner.

Also recorded and deliberately not fixed: Starlight's heading anchor links
measure under 24px at 390, and are exempt under SC 2.5.8's Equivalent clause
because the mobile table of contents links to every one of the same anchors.

npm run verify green — fourteen steps, both suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 15:16:08 -05:00

654 lines
22 KiB
Plaintext

---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import { liveBrand } from '../lib/brand.mjs';
import { isFull, liveCount } from '../lib/betaStore.mjs';
import { issueFormToken, OUTCOME, submit, isSuccess } from '../lib/betaSignup.mjs';
import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/beta.mjs';
/**
* `/beta/` — the closed-beta signup. PLAN.md §8, phase 5.
*
* ---------------------------------------------------------------------------------------
* THE SECOND ROUTE THAT EXECUTES PER REQUEST — AND IT HANDLES ITS OWN POST (D28)
* ---------------------------------------------------------------------------------------
* §6 lists the dynamic surface as `GET /brand/*` and `POST /api/beta-signup`. The org lead
* amended that on 2026-08-24: this page is the endpoint, and there is no `/api/` route.
*
* The reason is the one thing the endpoint shape cannot do. A separate API route has to
* answer a browser somehow — as JSON, which means the form only works with JavaScript, or
* as a redirect, which means an invalid address returns the person to a blank form with no
* explanation of what went wrong. Both are worse than they sound on a page whose entire job
* is conversion (§8 says to write it to convert), and the first is worse still on a site
* that has no analytics and no third-party anything: a form that silently does nothing for
* a reader with scripts off is a form that has no way of telling anyone it is broken.
*
* Handling the POST here costs one on-demand route and buys a form that works with
* JavaScript disabled, renders every outcome in the real layout, and needs no client-side
* code at all — so nothing on this page has to argue with the strict CSP either.
*
* ---------------------------------------------------------------------------------------
* TWO GATES, BOTH STATED, NEITHER HIDDEN (D27)
* ---------------------------------------------------------------------------------------
* The beta cannot start yet for two independent reasons — no Play track, and nowhere for a
* tester to point the app (see `beta.mjs` for both in full). The page collects addresses
* anyway, because the list is what makes the first batch possible on day one, and says
* plainly that it is a list rather than a queue that is moving. The demo is rendered
* through `NotBuilt` so the absence appears in the same shape it takes everywhere else on
* the site rather than as an apology invented for this page.
*
* ---------------------------------------------------------------------------------------
* WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT
* ---------------------------------------------------------------------------------------
* When `betaOptInUrl` is mounted, the confirmation screen prints the Play opt-in link. That
* is only safe because of how Play's closed testing works: the link admits addresses that
* are already on the tester list and refuses everyone else. It is what lets D7's "the site
* sends no email" hold — Google does not notify testers on the email-list path either, so
* something has to carry the link, and a page the person is already looking at is a better
* channel than an email nobody can send.
*/
export const prerender = false;
const brand = liveBrand();
/**
* A POST is a submission; anything else is somebody arriving. `Astro.request.formData()`
* parses both `application/x-www-form-urlencoded` and `multipart/form-data`, and this form
* is the former — no file input, nothing to stream.
*
* The `try` is not defensive dressing. A malformed body throws here, and the person who
* would see that stack trace is somebody whose browser or proxy mangled a request, not an
* attacker — they should get the form back with a message, the same as a stale token.
*/
let result = null;
if (Astro.request.method === 'POST') {
try {
const form = await Astro.request.formData();
result = submit({
form,
// `x-forwarded-for` is whatever the proxy in front of this container puts there, and
// its first entry is the client as that proxy saw it. It is trusted only as far as
// rate limiting, and it is hashed before it is stored — see betaStore.mjs. Behind a
// proxy that does not set it, everyone shares one bucket, which fails toward refusing
// signups rather than toward accepting abuse.
ip:
Astro.request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
Astro.clientAddress,
userAgent: Astro.request.headers.get('user-agent'),
});
} catch (error) {
console.error('[beta] could not read the submitted form:', error);
result = { outcome: OUTCOME.ERROR };
}
}
/**
* The cap is read per render so the form closes the moment it is reached, and so a store
* that cannot be opened at all does not take the page down with it — a `/beta` that shows
* the argument and admits the form is unavailable is worth more than a 500.
*/
let full = false;
let signed = 0;
let storeDown = false;
try {
full = isFull();
signed = liveCount();
} catch (error) {
console.error('[beta] the signup store is not available:', error);
storeDown = true;
}
const showForm = !storeDown && !full && !isSuccess(result?.outcome);
/**
* The message for each outcome. One object rather than a chain of conditionals in the
* markup, so a new outcome added to `OUTCOME` without a message here is visibly missing
* rather than silently rendering an empty box.
*
* The duplicate case says exactly what the added case says, on purpose. §8's rule: an
* answer that distinguished them would turn this form into a way of asking whether any
* given address is in the beta.
*/
const NOTICES = {
[OUTCOME.ADDED]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.DUPLICATE]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.DECOY]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.STALE]: {
tone: 'warn',
title: 'This form had been open a while.',
body: 'Nothing was submitted. Here it is again — the details you typed were not kept.',
},
[OUTCOME.TOO_FAST]: {
tone: 'warn',
title: 'That was submitted faster than the page could be read.',
body:
'Nothing was recorded. If you are a person and not a script, wait a moment and send ' +
'it again — the check is a crude one and it is occasionally wrong about people.',
},
[OUTCOME.LIMITED]: {
tone: 'warn',
title: 'Too many attempts from your connection.',
body:
'Try again later. The limit counts attempts rather than signups, so a few mistyped ' +
'addresses can reach it — nothing has gone wrong with your place on the list.',
},
[OUTCOME.FULL]: {
tone: 'warn',
title: 'The list is closed for now.',
body: 'It has reached its cap. Discord is the place to hear when it reopens.',
},
[OUTCOME.INVALID_EMAIL]: {
tone: 'warn',
title: "That address doesn't look right.",
body: 'Check it and send it again. It has to be the Google account you use on your phone.',
},
[OUTCOME.NO_CONSENT]: {
tone: 'warn',
title: 'The consent box was not ticked.',
body: 'The address cannot be stored without it, so nothing was recorded.',
},
[OUTCOME.ERROR]: {
tone: 'warn',
title: 'Something went wrong at our end.',
body:
'Your address was not recorded. This is worth reporting in Discord if it keeps ' +
'happening — it means the site has a problem, not that you do.',
},
};
const notice = result ? NOTICES[result.outcome] : null;
/** Only ever shown on a success screen, and only when the track exists. */
const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : '';
const title = 'The closed beta';
// Interpolated rather than written out, because this is the head: it becomes the meta
// description and `og:description`, and phase 11 made the rest of this page's head follow
// the mount. A description naming a brand the title does not is worse than either.
const description = `Join the list for the ${brand.siteName} Android app closed test. No email is ever sent.`;
const formToken = issueFormToken();
---
<Base title={title} description={description}>
<PageHeader eyebrow="Android" title="Join the closed test">
<p>
The <a href="/app/">Android app</a> is heading for Google Play by way of a closed
test. This is the list of people who want a place on it.
</p>
<p>
It has not opened yet, and the two reasons are below rather than behind a
&ldquo;coming soon&rdquo;. Adding your address now means you are in the first batch
rather than hearing about it afterwards.
</p>
</PageHeader>
{
notice && (
<section class="page section beta-notice-wrap">
<div class={`panel beta-notice beta-notice--${notice.tone}`} role="status">
<h2>{notice.title}</h2>
<p>{notice.body}</p>
{isSuccess(result?.outcome) && (
<div class="beta-next">
<h3>What happens next</h3>
<ol>
<li>
Batches are added to the tester list by hand — there is no way to automate
it, so it happens when a person sits down to do it.
</li>
<li>
{optInUrl ? (
<>
Open the opt-in link with the same Google account once you have been
added. It only works for addresses already on the list, so it is safe
to share this page but not useful to.
<br />
<a class="btn btn--primary beta-next__optin" href={optInUrl} rel="noopener noreferrer">
The Play opt-in link
</a>
</>
) : (
<>
When the test track exists you will need to open its opt-in link with
the same Google account. It is not created yet, so there is nothing to
link here — this page will show it as soon as there is.
</>
)}
</li>
<li>
<a href={brand.discordInvite} rel="noopener noreferrer">Discord</a> carries
the announcement for each batch. It has to: this site sends no email, to
you or to anyone, ever.
</li>
</ol>
</div>
)}
</div>
</section>
)
}
<section class="page section">
<h2 class="beta-h2">What it is waiting on</h2>
<p class="prose beta-lede">
Two things, neither of which is a date. Both are visible from outside, so there is no
reason to be vague about them.
</p>
<ol class="beta-gates">
<li class="panel beta-gate">
<p class="eyebrow">Gate one</p>
<h3>The test track</h3>
<p>
The developer account exists; the closed test does not yet. Google needs
{' '}{playPolicy.testersRequired} testers opted in continuously for
{' '}{playPolicy.testerDays} days before the app can be put forward for a
production release, which is exactly why the list is being built before the track
opens rather than after.
</p>
<p class="beta-gate__foot">
Play's testing rules as published on {playPolicy.verifiedOn}, and they have changed
before.
</p>
</li>
<li class="panel beta-gate">
<p class="eyebrow">Gate two</p>
<h3>Somewhere to point it</h3>
<p>
The app is a client and ships pointed at nothing — its first screen asks for the
address of a site running this platform. So a tester needs a deployment, and the
public demo is the one being built for that. Until it is running, a place on the
test would be a place to install an app with nothing behind it.
</p>
<p class="beta-gate__foot">
If you already run a Runic Gateway deployment, this gate does not apply to you —
say so in Discord.
</p>
</li>
</ol>
</section>
<section class="page section">
<h2 class="beta-h2">What a tester needs</h2>
<ul class="beta-reqs">
{
requirements.map((requirement) => (
<li class="panel beta-req">
<h3>{requirement.title}</h3>
<p>{requirement.body}</p>
</li>
))
}
</ul>
</section>
<section class="page section" id="form">
<h2 class="beta-h2">The list</h2>
{
storeDown && (
<div class="panel beta-notice beta-notice--warn">
<h3>The form is unavailable.</h3>
<p>
Signups cannot be recorded at the moment — this is a fault at our end and it has
been logged. Everything else on this page is still true.
</p>
</div>
)
}
{
!storeDown && full && !notice && (
<div class="panel beta-notice beta-notice--warn">
<h3>The list is closed for now.</h3>
<p>
It has reached its cap of {limits.totalCap}. Discord is the place to hear when it
reopens.
</p>
</div>
)
}
{
showForm && (
<div class="beta-formwrap">
{/*
Posts to itself with no fragment. `#form` was the obvious thing to write and it
is wrong twice: Chrome does not honour a fragment on a POST response anyway, and
if it did it would scroll past the notice — which renders under the page header
and is the thing the person needs to read. Landing at the top is the behaviour,
so the markup should say so rather than ask for something else and get it.
*/}
<form class="panel beta-form" method="post" action="/beta/">
<p class="beta-form__intro">
One field and a box to tick. The address has to be the Google account you use
on the phone you would test with — Play matches the tester list against the
account, not the device.
</p>
<label class="beta-form__label" for="beta-email">
Google account email
</label>
<input
class="beta-form__input"
id="beta-email"
type="email"
name={fields.EMAIL}
autocomplete="email"
inputmode="email"
required
maxlength="254"
placeholder="you@example.com"
/>
{/*
The honeypot. Hidden from people in three independent ways because any one of
them alone is a browser quirk away from being visible to somebody using a
screen reader or a text browser: off-screen, removed from the accessibility
tree, and excluded from tab order. `autocomplete="off"` matters most of all —
a browser that helpfully fills this in would fail a real person's signup.
*/}
<div class="beta-form__decoy" aria-hidden="true">
<label for="beta-website">Website</label>
<input
id="beta-website"
type="text"
name={fields.HONEYPOT}
tabindex="-1"
autocomplete="off"
/>
</div>
<input type="hidden" name={fields.ISSUED} value={formToken} />
<label class="beta-form__consent">
<input type="checkbox" name={fields.CONSENT} value="yes" required />
<span>{CONSENT_TEXT}</span>
</label>
<button class="btn btn--primary beta-form__submit" type="submit">
Add me to the list
</button>
<p class="beta-form__foot">
Stored: the address, the wording above, the date, and a one-way hash of your
connection used only to rate-limit this form. Never your IP address itself.
Ask to have it deleted and it will be erased. The{' '}
<a href="/privacy/">privacy page</a> says all of this in full, including how
to ask.
</p>
</form>
<aside class="beta-count">
<p class="beta-count__n">{signed}</p>
<p class="beta-count__label">
on the list &middot; cap {limits.totalCap}
</p>
<p class="beta-count__note">
Published because a number nobody can see is a number people assume. Play needs
{' '}{playPolicy.testersRequired} to actually opt in, which is a different and
harder number than this one.
</p>
</aside>
</div>
)
}
</section>
<NotBuilt scope="beta" title="What is not in place yet" />
</Base>
<style>
.beta-h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.beta-lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
/* The result of a submission, directly under the header where a person's eye already is
after a page reload. `role="status"` so it is announced rather than silently replacing
the form for anyone not looking at the screen. */
.beta-notice-wrap {
padding-top: 0;
}
.beta-notice h2,
.beta-notice h3 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.beta-notice p {
margin: 0;
max-width: var(--measure);
color: var(--text);
}
.beta-notice--ok {
border-color: var(--mode-live);
}
.beta-notice--ok h2 {
color: var(--mode-live);
}
.beta-notice--warn {
border-color: var(--gold-deep);
}
.beta-notice--warn h2,
.beta-notice--warn h3 {
color: var(--gold);
}
.beta-next {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line-soft);
}
.beta-next h3 {
margin: 0 0 0.75rem;
color: var(--head);
font-size: 1.05rem;
}
.beta-next ol {
margin: 0;
padding-left: 1.25rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.beta-next li + li {
margin-top: 0.75rem;
}
.beta-next__optin {
margin-top: 0.85rem;
}
.beta-gates,
.beta-reqs {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.beta-gate h3,
.beta-req h3 {
margin: 0.35rem 0 0.6rem;
color: var(--gold);
font-size: 1.1rem;
}
.beta-gate p,
.beta-req p {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.beta-gate__foot {
margin-top: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.86rem;
}
/* The form and the counter. The counter is deliberately narrow and secondary — it is
context for the decision, not the reason to make it. */
.beta-formwrap {
display: grid;
gap: 1rem;
align-items: start;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
@media (max-width: 780px) {
.beta-formwrap {
grid-template-columns: 1fr;
}
}
.beta-form__intro {
margin: 0 0 1.5rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.beta-form__label {
display: block;
margin-bottom: 0.4rem;
color: var(--head);
font-size: 0.9rem;
font-weight: 600;
}
.beta-form__input {
display: block;
width: 100%;
max-width: 26rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--ink);
font-family: var(--sans);
font-size: 1rem;
}
.beta-form__input:focus-visible {
border-color: var(--portal);
outline: 2px solid var(--portal-bright);
outline-offset: 1px;
}
/* Off-screen rather than `display: none`: a bot that reads CSS skips a hidden field, and
one that does not read CSS fills this in. Kept in the layout and out of everything
else — see the markup for why all three of these are needed. */
.beta-form__decoy {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.beta-form__consent {
display: flex;
gap: 0.7rem;
align-items: flex-start;
margin: 1.5rem 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
cursor: pointer;
}
.beta-form__consent input {
flex: none;
/* 24px exactly — WCAG 2.2 SC 2.5.8's minimum target size, which this box missed at
1.05rem (17px). It is the only control on the site a person has to hit precisely,
and it is on the page a phone is most likely to arrive at, so the phase 10 walk
measuring it at 17x17 on a 390px viewport was worth acting on rather than
explaining away. */
width: 1.5rem;
height: 1.5rem;
accent-color: var(--portal);
}
.beta-form__submit {
margin-bottom: 1.5rem;
}
.beta-form__foot {
margin: 0;
padding-top: 1rem;
border-top: 1px solid var(--line-soft);
max-width: var(--measure);
color: var(--dim);
font-size: 0.85rem;
}
.beta-count {
padding: 1.5rem;
border: 1px solid var(--line-soft);
border-radius: var(--radius-panel);
text-align: center;
}
.beta-count__n {
margin: 0;
color: var(--gold);
font-family: var(--display);
font-size: 3rem;
line-height: 1;
}
.beta-count__label {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.beta-count__note {
margin: 1rem 0 0;
padding-top: 0.9rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.82rem;
text-align: left;
}
</style>