Files
runicgateway.com/src/pages/privacy.astro
wtclaude 094c606238
Some checks failed
PR checks / checks (pull_request) Failing after -31s
feat(legal): /delete-account/, the page Google Play is given (D85, D86)
Step 5 of docs/website/ACCOUNT_DELETION.md (§4.5). The page explains how
to delete an account on a community's site and in the app, what goes,
what stays and for how long, and who to ask when you cannot sign in. It
cannot delete anything, because we hold no account.

- The facts are one deployment row in collection.mjs (deletion.goes,
  .stays with a keep each, .staff), in the website's own words. /privacy
  section 3 renders it as prose, the page renders its lists, and
  PLAY_DATA_SAFETY.md prints them under the console's deletion questions.
- The app rows that a deletion touches (session, trust token, push,
  inbox snapshot) say so. The Data Safety notes now answer "yes" to
  account deletion and name the URL. lastUpdated is 2026-10-30.
- D85: linked from the footer's legal bar, /privacy sections 2 and 3,
  and /app/'s "Two ways to get it".
- D86: someone who cannot sign in is sent to their community's operator,
  never to us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 01:25:48 -05:00

409 lines
14 KiB
Plaintext

---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import { assertScopeNonEmpty, collectedIn } from '../data/collection.mjs';
import { legal } from '../data/legal.mjs';
import { brand } from '../lib/brand.mjs';
/**
* `/privacy` — PLAN.md §9, built in phase 6. The URL given to Google Play.
*
* ---------------------------------------------------------------------------------------
* THREE SCOPES, NEVER MERGED
* ---------------------------------------------------------------------------------------
* §9's structure is the substance of the page rather than its layout. Runic Gateway is
* self-hosted software, so "we" means three different parties depending on which sentence
* you are reading, and a policy that blurred them would be wrong in both directions at
* once: it would claim responsibility for data we cannot see, and it would let a player
* believe this page governs the community site they actually use.
*
* So the page is three separately-scoped sections with the boundary stated in each, and
* the rows come from `src/data/collection.mjs` — the same array `scripts/playDataSafety.mjs`
* answers the console form from (D33). A published policy and a Play declaration that
* disagree is the failure this repository already builds machinery against elsewhere.
*
* ---------------------------------------------------------------------------------------
* NO ADDRESS IN THIS FILE
* ---------------------------------------------------------------------------------------
* The contact route is `brand.contactEmail`, read from the mounted `brand.json`. D13
* publishes a personal address on the promise that replacing it with `privacy@` later
* costs one file copy, and `checkFacts.mjs` fails the build if an address is typed into any
* source file. This is the page most likely to want to — a privacy policy is where an
* address belongs — which is exactly why the rule has to hold here.
*/
const title = 'Privacy';
const description =
'What this site collects, what the Android app holds on your device, and what a ' +
'self-hosted deployment is responsible for.';
/* A section with nothing under it reads as a claim rather than an omission. */
for (const scope of ['site', 'app', 'deployment']) assertScopeNonEmpty(scope);
const sections = [
{
id: 'this-site',
number: 1,
heading: 'This website',
controller: 'We are responsible for this section.',
lede:
'There are no cookies, no analytics, no tracking scripts and no third-party ' +
'requests of any kind — not as a policy we promise to keep, but as a description of ' +
'what the pages load. The only thing this site ever asks you for is an email ' +
'address for the Android beta, and only if you choose to give one.',
rows: collectedIn('site'),
},
{
id: 'the-app',
number: 2,
heading: 'The Android app',
controller: 'We operate no server the app talks to.',
lede:
'This is the part that makes the app unusual, and it is worth reading rather than ' +
'skimming. The app ships pointed at nothing: on first run it asks for the address ' +
'of a Runic Gateway site and nothing else in the app works until one is entered and ' +
'validated. That site is run by whoever runs that community. Everything you do in ' +
'the app happens between your phone and their server, and there is no account with ' +
'us, no service of ours in the middle, and no copy of anything on our side — because ' +
'we do not operate one.',
/* The deletion page Play is given (D85). The app is where most people will look. */
link: { href: '/delete-account/', text: 'How to delete the account you use in the app' },
rows: collectedIn('app'),
},
{
id: 'deployments',
number: 3,
heading: 'Self-hosted deployments',
controller: 'The operator of that deployment is responsible, not us.',
lede:
'Runic Gateway is software people install on their own machines. If you play on a ' +
'community that runs it, your account lives on their server, under their control ' +
'and their policy — this page is not it. What follows is an inventory of what the ' +
'software collects, so that an operator can see plainly what they are taking on, ' +
'and a player can see what to ask their operator about.',
link: { href: '/delete-account/', text: 'What deleting an account takes, and what it keeps' },
rows: collectedIn('deployment'),
},
];
---
<Base title={title} description={description}>
<PageHeader eyebrow="Privacy" title="Who holds what, and for how long">
<p>
Written from what the code does rather than from a template — every entry below was
read out of the file that implements it, and the file is named. It is deliberately
specific in the places a policy is usually vague, because the vague places are the
ones that matter.
</p>
<p>
Three sections, because there are three different answers to “who has this”. Read the
one that applies to you; the boundaries between them are real.
</p>
</PageHeader>
<section class="page section legal-meta">
<p class="legal-meta__line">
<span class="chip chip--version">Last updated {legal.lastUpdated}</span>
<span class="legal-meta__age">You must be {legal.minimumAge} or older to sign up for the beta.</span>
</p>
</section>
<nav class="page section legal-toc" aria-label="Sections">
<ol>
{
sections.map((section) => (
<li>
<a href={`#${section.id}`}>
<span class="legal-toc__n">{section.number}</span>
<span>
<strong>{section.heading}</strong>
<span class="legal-toc__who">{section.controller}</span>
</span>
</a>
</li>
))
}
</ol>
</nav>
{
sections.map((section) => (
<section class="page section legal-sec" id={section.id}>
<div class="legal-sec__head">
<p class="eyebrow">Section {section.number}</p>
<h2>{section.heading}</h2>
<p class="legal-sec__who">{section.controller}</p>
<p class="prose legal-sec__lede">{section.lede}</p>
{section.link && (
<p class="legal-sec__link">
<a href={section.link.href}>{section.link.text}</a>
</p>
)}
</div>
<ul class="legal-rows">
{section.rows.map((row) => (
<li class="panel legal-row">
<h3>{row.title}</h3>
<p class="legal-row__body">{row.body}</p>
<p class="legal-row__keep">
<span class="legal-row__keep-label">How long</span>
{row.retention.summary}
{row.retention.detail && (
<span class="legal-row__keep-detail">{row.retention.detail}</span>
)}
</p>
</li>
))}
</ul>
</section>
))
}
<section class="page section legal-sec" id="your-choices">
<div class="legal-sec__head">
<p class="eyebrow">Section 4</p>
<h2>Removing your address, and asking questions</h2>
<p class="legal-sec__who">This applies to section 1 only — the beta list.</p>
<p class="prose legal-sec__lede">
We hold one piece of information about you and it is the address you typed into the
beta form. Ask for it to be removed and it will be erased rather than marked: what
stays behind is a date and the fact that a removal happened, so we can confirm we
did it without keeping the thing you asked us to let go of.
</p>
</div>
<div class="panel legal-panel">
<ul class="legal-ways">
<li>
<h3>By email</h3>
<p>
Say which address to remove. There is no form to fill in and no account to prove
— knowing the address is all that is needed, because it is all that is stored.
</p>
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
</li>
<li>
<h3>On Discord</h3>
<p>
The same request works in the <a href="/community/">community Discord</a>, which
is generally the faster of the two.
</p>
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">
Join the Discord
</a>
</li>
</ul>
<p class="legal-panel__note">
One consequence of erasing rather than flagging, stated because it is the honest
reading and not a caveat we would rather you missed: afterwards the list cannot tell
your address from one it has never seen. Asking twice gets the same answer as asking
about a stranger, and signing up again later is an ordinary new signup.
</p>
</div>
</section>
<section class="page section legal-sec" id="changes">
<div class="legal-sec__head">
<p class="eyebrow">Section 5</p>
<h2>Changes to this page</h2>
<p class="prose legal-sec__lede">
If what the software collects changes, this page changes with it in the same
release — the entries above are generated from a single inventory in the source, so
a change to what is stored and a change to what this page says are the same edit.
The date at the top is the last time that happened. This site sends no email at all,
so there is no notification to send you when it does; the page itself is the record.
</p>
</div>
</section>
</Base>
<style>
/* ---- The header strip -------------------------------------------------- */
.legal-meta {
padding-top: 0;
padding-bottom: 0;
}
.legal-meta__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.legal-meta__age {
color: var(--dim);
font-size: 0.9rem;
}
/* ---- Contents ---------------------------------------------------------- */
.legal-toc ol {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.legal-toc a {
display: flex;
gap: 0.85rem;
height: 100%;
padding: 0.9rem 1rem;
border: 1px solid var(--line-soft);
border-radius: var(--radius-panel);
background: var(--panel-flat);
color: inherit;
text-decoration: none;
}
.legal-toc a:hover {
border-color: var(--gold-deep);
}
.legal-toc__n {
flex: none;
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border: 1px solid var(--gold-deep);
border-radius: var(--radius-pill);
color: var(--gold);
font-family: var(--display);
font-size: 0.9rem;
}
.legal-toc__who {
display: block;
margin-top: 0.2rem;
color: var(--dim);
font-size: 0.85rem;
}
/* ---- A section --------------------------------------------------------- */
.legal-sec__head {
margin-bottom: 1.25rem;
}
.legal-sec h2 {
margin: 0 0 0.4rem;
font-size: clamp(1.5rem, 3vw, 2rem);
}
/* The boundary sentence. Gold, because on this page it is the load-bearing line of
each section rather than a subtitle — a reader who takes only one sentence from a
section should take this one. */
.legal-sec__who {
margin: 0 0 0.75rem;
color: var(--gold);
font-size: 0.95rem;
}
.legal-sec__lede {
margin: 0;
color: var(--muted);
}
.legal-sec__link {
margin: 0.75rem 0 0;
font-size: 0.95rem;
}
.legal-rows {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}
.legal-row {
display: flex;
flex-direction: column;
padding: clamp(1.1rem, 3vw, 1.5rem);
}
.legal-row h3 {
margin: 0 0 0.55rem;
font-size: 1.06rem;
}
/* Deliberately NOT `flex: 1`, which is what the cards elsewhere on the site use to
line their buttons up. These bodies differ in length by a factor of four — the hash
entry earns its paragraph, the consent entry needs two sentences — and pushing the
retention line to the bottom of the tallest card in the row opened a void in the
middle of the short ones that read as missing content rather than as alignment.
Caught by looking at the built page, which is the only thing that catches it. */
.legal-row__body {
margin: 0 0 1.1rem;
color: var(--muted);
font-size: 0.95rem;
}
.legal-row__keep {
margin: 0;
padding-top: 0.9rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.88rem;
}
.legal-row__keep-label {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.legal-row__keep-detail {
display: block;
margin-top: 0.45rem;
}
/* ---- The two panels at the foot ---------------------------------------- */
.legal-panel {
padding: clamp(1.25rem, 4vw, 2.25rem);
}
.legal-ways {
display: grid;
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.legal-ways h3 {
margin: 0 0 0.4rem;
font-size: 1.02rem;
}
.legal-ways p {
margin: 0 0 1rem;
color: var(--muted);
font-size: 0.95rem;
}
.legal-panel__note {
margin: 1.75rem 0 0;
padding-top: 1.25rem;
border-top: 1px solid var(--line-soft);
max-width: var(--measure);
color: var(--dim);
font-size: 0.9rem;
}
</style>