All checks were successful
PR checks / checks (pull_request) Successful in 55s
PLAN.md §9. Builds /privacy and /terms, links them from the footer on every page,
and generates the Play Data Safety notes from the same inventory the policy renders.
Four decisions taken by the org lead before either page was written, recorded in
§9 under "How phase 6 built the legal pages":
D30 DNS-only records, so the reverse proxy on the host keeps the only access
log. Described qualitatively — the retention belongs to the proxy, and a
policy that quotes a number the deployment does not enforce is worse than
one that does not.
D31 Eighteen or older. Above the children's-consent threshold everywhere in the
EEA, so consent works with no parental-consent machinery this form could not
honestly operate. Four surfaces render it from src/data/legal.mjs, and every
one says plainly that nothing verifies it.
D32 No governing-law clause. Nothing of value is contracted for here.
D33 PLAY_DATA_SAFETY.md is generated from src/data/collection.mjs and checked in
CI, so the published policy and the answers given to Google cannot drift.
/privacy is three separately-scoped sections because "we" means three different
parties: this site (one form, no cookies, no third-party requests), the Android app
(we operate no server it talks to — the rows are what the DEVICE holds), and a
self-hosted deployment (the operator is the controller, not us). Every row names the
file it was read out of, because a policy is the document most likely to be written
from a template and least likely to be re-read against the software.
/terms governs only what we run: this site, the beta list, and the APK we publish.
The software is governed by its licence, and a community's deployment by that
community — a terms page claiming authority over every install of a GPL program is
the thing a generated template gets wrong.
Also here:
- the age clause changed CONSENT_TEXT, so CONSENT_VERSION gained a suffix; rows
written from now on carry the new sentence and older rows keep theirs
- PLANNED_ROUTES is now empty — these were its last two entries, and its reverse
check is what forced the deletion; the list stays for phases 7 and 8
- test/legal.test.mjs asserts the structural promises no build check can see,
including that every mapped Play row still answers "not collected, not shared"
- --check normalises line endings: the repo has no .gitattributes and Windows
checkouts are CRLF, so a byte comparison would fail for every Windows developer
while passing in CI
Verified: npm run verify green end to end (tokens, brand, data safety, astro check,
36 tests, build, 214 links, 19 facts), both pages walked in a browser, and neither
overflows at 390px. One defect the checks could not see and a look could: the
retention line was being pushed to the foot of the tallest card in its row, opening
a void in the middle of the short ones.
Co-Authored-By: Claude <noreply@anthropic.com>
648 lines
21 KiB
Plaintext
648 lines
21 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';
|
|
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 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 · 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>
|