feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
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:
645
src/pages/beta.astro
Normal file
645
src/pages/beta.astro
Normal file
@@ -0,0 +1,645 @@
|
||||
---
|
||||
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';
|
||||
const description =
|
||||
'Join the list for the Runic Gateway 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
|
||||
“coming soon”. 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 in Discord to have it deleted and it will be.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside class="beta-count">
|
||||
<p class="beta-count__n">{signed}</p>
|
||||
<p class="beta-count__label">
|
||||
on the list · 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;
|
||||
margin-top: 0.2rem;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
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>
|
||||
Reference in New Issue
Block a user