Files
recflare/apps/www/src/client/styles.css
T
2026-09-19 11:30:42 -04:00

1904 lines
38 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* RecFlare — a poster for a game that's up right now.
*
* The page's job is to show that real people are playing on this server, then get you
* into the game or the Discord. So the surface is warm-dark (the room the
* screenshots are lit against), and the logo orange (#FE7101) is spent only on actions
* and the brand mark — never as decoration, or it stops reading as "click this". Green
* means one thing throughout, healthy: the server is up, or the change saved.
*
* Type: Archivo for the nameplate, IBM Plex Sans for everything else. There is
* deliberately no monospace anywhere — this is a game, and mono type made the whole
* page read like a status dashboard.
*/
:root {
color-scheme: dark light;
--bg: #14100c;
--surface: #1e1813;
--surface-hi: #26201a;
--line: #33291f;
--text: #f5ede1;
--muted: #a8927c;
--accent: #fe7101;
--accent-hover: #ff8c33;
/* Dark ink on orange: white on this orange is only ~2.9:1 and fails AA. */
--accent-text: #17110a;
--live: #35d07f;
--error: #ff6b5e;
--ok: #35d07f;
--display: Archivo, system-ui, sans-serif;
--body: 'IBM Plex Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--radius: 10px;
}
@media (prefers-color-scheme: light) {
:root {
/* Off-white with just enough warmth to sit under the orange — not a cream wash. */
--bg: #f7f5f2;
--surface: #ffffff;
--surface-hi: #fbf9f6;
--line: #e2ddd6;
--text: #201a14;
--muted: #736656;
--accent: #e05f00;
--accent-hover: #c65300;
--accent-text: #fffaf4;
--live: #12855a;
--error: #c62f22;
--ok: #12855a;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 400 16px/1.6 var(--body);
-webkit-font-smoothing: antialiased;
}
/* Keyboard focus stays visible everywhere, including on the dark surfaces. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ---- Containers --------------------------------------------------------- */
.shell {
max-width: 440px;
margin: 0 auto;
padding: 40px 20px 80px;
}
/* The account dashboard: room for the tab rail without stranding the forms. */
.shell.wide {
max-width: 880px;
}
/* The homepage: the stage above brings its own top space and shares this width. */
.shell.home {
max-width: 1040px;
padding-top: 0;
}
/* ---- Navigation --------------------------------------------------------- */
.nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
max-width: 1040px;
margin: 0 auto;
padding: 20px;
border-bottom: 1px solid var(--line);
}
.brand {
display: inline-flex;
align-items: center;
gap: 9px;
font-family: var(--display);
font-weight: 800;
font-size: 1.2rem;
letter-spacing: -0.015em;
color: var(--text);
text-decoration: none;
}
.brand::before {
content: '';
width: 9px;
height: 9px;
border-radius: 2px;
background: var(--accent);
}
.nav-links {
display: flex;
align-items: center;
gap: 20px;
}
.nav-links a,
.linkish {
font-family: var(--body);
font-size: 0.95rem;
color: var(--muted);
text-decoration: none;
}
.linkish {
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.nav-links a:hover,
.nav-links a.active,
.linkish:hover {
color: var(--text);
}
/* ---- The stage (hero) --------------------------------------------------- */
/*
* Split down the middle: what this is and the way in on the left, a photo somebody
* actually took in game on the right. The photo is proof and never the payload — with
* no photo the frame holds its space and the left half carries the same words.
*/
.stage {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 48px;
max-width: 1040px;
margin: 0 auto;
padding: 56px 20px 16px;
}
/* min-width: 0 on both halves, or the split isn't one: a `1fr` track's automatic
minimum is its content's min-content width, so a wide child (the slideshow controls,
a long unbroken credit) grows its column past 50% and takes the space out of the
other one — which is how this last read as 30/70 with the buttons crushed. */
.stage-show {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
/* Fixed shape, filled by whatever lands: screenshots arrive at any aspect ratio, and a
frame that resized per photo would jog the headline beside it on every rotation. The
ratio is landscape rather than 4:3 so the photo doesn't tower over the column beside
it — equal columns still read unequal when one is half again as tall. */
.stage-frame {
position: relative;
aspect-ratio: 3 / 2;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-hi);
}
.stage-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
animation: photo-in 0.7s ease;
}
@keyframes photo-in {
from {
opacity: 0;
}
}
.stage-body {
min-width: 0;
}
/* No width cap here, unlike the headings below: this one shares a row with the photo,
and a headline that stops short of its column makes the split read as 30/70. */
.stage-title {
font-family: var(--display);
font-weight: 800;
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1;
letter-spacing: -0.03em;
margin: 0 0 16px;
text-wrap: balance;
}
/* The one accented word in the headline — it carries the whole emphasis, so the markup
always wraps exactly one word in <em>. */
.stage-title em {
font-style: normal;
color: var(--accent);
}
.stage-lede {
font-size: 1.05rem;
color: var(--muted);
margin: 0 0 28px;
}
/* The signup offer under the hero buttons. Deliberately quieter than a CTA — it sits
below the downloads without competing with them — but the link itself carries the
accent so it reads as the action it is. */
.stage-alt {
font-size: 0.925rem;
color: var(--muted);
margin: 18px 0 0;
}
.stage-alt a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.stage-alt a:hover {
text-decoration: underline;
}
/* Credit line and slideshow controls, under the photo rather than on it. */
.stage-foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px 20px;
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
min-height: 28px;
font-size: 0.8rem;
color: var(--muted);
}
/* Long usernames and room names wrap instead of widening the column. */
.credit {
min-width: 0;
overflow-wrap: anywhere;
}
/* Back / forward, with the position between them. Fixed width whatever the feed
length is — see the note on .stage-show for what a per-photo control did here. */
.steer {
display: flex;
align-items: center;
gap: 4px;
flex: none;
}
.steer button {
display: grid;
place-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.steer button:hover {
color: var(--text);
border-color: var(--muted);
}
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
.count {
font-variant-numeric: tabular-nums;
padding: 0 6px;
}
/* ---- What it is (below the stage) --------------------------------------- */
.about {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 32px 48px;
align-items: start;
padding: 56px 0 8px;
}
.about-title {
font-family: var(--display);
font-weight: 700;
font-size: clamp(1.35rem, 2.6vw, 1.75rem);
line-height: 1.15;
letter-spacing: -0.02em;
margin: 0 0 12px;
max-width: 34ch;
text-wrap: balance;
}
.about-lede {
font-size: 1.05rem;
color: var(--muted);
margin: 0;
max-width: 56ch;
}
/* Emphasis in the lede lifts to full text colour — bolder alone barely reads
against the muted grey it sits in. */
.about-lede strong {
font-weight: 600;
color: var(--text);
}
.about-side {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.about-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.status-block {
display: flex;
flex-direction: column;
gap: 7px;
}
.status-quip {
margin: 0;
font-size: 0.8rem;
font-style: italic;
color: var(--muted);
opacity: 0.8;
}
/* Status of the actual server, not decoration — see About. Set in plain sentence
case: uppercase mono made a friendly "we're up" read like a monitoring alert. */
.status {
display: inline-flex;
align-items: center;
gap: 9px;
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: var(--muted);
}
.status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
.status.online .dot {
background: var(--live);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent);
}
.status.online {
color: var(--text);
}
.status.down .dot {
background: var(--error);
}
.cta {
display: inline-block;
font-family: var(--body);
font-weight: 600;
font-size: 0.975rem;
text-decoration: none;
padding: 13px 30px;
border-radius: var(--radius);
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.cta:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.stage-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
/* Discord blurple, so the button is recognisable before the label is read. Fixed
colours rather than tokens: it sits on a photo, so it can't follow the theme. */
.cta.discord {
background: #5865f2;
border-color: #5865f2;
color: #fff;
}
.cta.discord:hover {
background: #4752c4;
border-color: #4752c4;
}
/* Secondary action: same shape, outlined instead of filled. */
.cta.ghost {
background: transparent;
border-color: var(--line);
color: var(--text);
}
.cta.ghost:hover {
background: var(--surface);
border-color: var(--muted);
}
/* ---- Footer ------------------------------------------------------------- */
.footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px 24px;
max-width: 1040px;
margin: 0 auto;
padding: 24px 20px 40px;
border-top: 1px solid var(--line);
font-size: 0.875rem;
color: var(--muted);
}
.footer nav {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.footer a {
color: var(--muted);
text-decoration: none;
}
.footer a:hover {
color: var(--text);
}
/* The licence link sits inside a sentence rather than the link row, so it needs an
underline to be findable at all. */
.footer span a {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--muted) 50%, transparent);
}
.footer span a:hover {
text-decoration-color: currentcolor;
}
/* ---- Cards and the account workspace ------------------------------------ */
h1 {
font-family: var(--display);
font-weight: 700;
font-size: 1.7rem;
letter-spacing: -0.02em;
margin: 0 0 24px;
}
h2 {
font-family: var(--display);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
margin: 0 0 14px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px;
margin-bottom: 16px;
}
.workspace {
display: grid;
grid-template-columns: 190px 1fr;
gap: 16px;
align-items: start;
}
.vtabs {
display: flex;
flex-direction: column;
gap: 4px;
}
.vtabs button {
text-align: left;
background: transparent;
border: 1px solid transparent;
color: var(--muted);
padding: 10px 13px;
border-radius: 8px;
cursor: pointer;
font-family: var(--body);
font-size: 0.9rem;
}
.vtabs button:hover {
color: var(--text);
background: var(--surface);
}
.vtabs button.active {
background: var(--surface);
border-color: var(--line);
color: var(--text);
font-weight: 600;
}
/* The active section's card carries its own margin; drop it inside the panel. */
.panel .card {
margin-bottom: 0;
}
.identity .handle {
font-size: 0.9rem;
color: var(--muted);
}
/* Sign in / create account, at the top of the auth card. Two of a kind, so they read as
one control rather than as two buttons competing with the orange submit below. */
.tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
}
.tabs button {
flex: 1;
background: transparent;
border: none;
color: var(--muted);
padding: 8px;
border-radius: 6px;
cursor: pointer;
font-family: var(--body);
font-size: 0.9rem;
}
.tabs button:hover {
color: var(--text);
}
.tabs button.active {
background: var(--surface-hi);
color: var(--text);
font-weight: 600;
}
/* ---- My rooms ----------------------------------------------------------- */
/*
* The owner's own room list. Rows rather than a grid of tiles: a room is identified by
* its name, and the counts underneath are the reason to look — both read left-to-right,
* which a tile would stack into a column of tiny type.
*/
.rooms {
list-style: none;
margin: 18px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.room {
padding-top: 12px;
border-top: 1px solid var(--line);
}
/* The first row sits directly under the intro copy, which already separates it. */
.room:first-child {
padding-top: 0;
border-top: none;
}
/* The whole row is the link to the room's page, so the target is the size of the thing
you're aiming at rather than the name alone. Padded out to the card edge and pulled
back by the same amount, so the hover fill covers the row instead of stopping short. */
.room-link {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 14px;
align-items: start;
margin: 0 -10px;
padding: 10px;
border-radius: 8px;
color: inherit;
text-decoration: none;
}
.room-link:hover {
background: var(--surface-hi);
}
.room-link:hover .room-name {
color: var(--accent);
}
/* Fixed 3:2 frame — room thumbnails are screenshots and arrive at any ratio, and a
per-room height would leave the names in a ragged column. */
.room-thumb {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-hi);
}
.room-body {
min-width: 0;
}
.room-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Room names have no spaces to break at, so let a long one wrap anywhere rather than
widen the row past the panel. */
.room-name {
font-family: var(--display);
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
/* Whether the room is visible to anyone else — the one piece of state an owner can't
see anywhere but here. Public gets the same green "healthy" reading as the server
status; every other value stays neutral, since Private isn't a fault. */
.badge {
flex: none;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
}
.badge.live {
color: var(--live);
border-color: color-mix(in srgb, var(--live) 45%, transparent);
}
/* Same pill in the accent `.warn` uses, for a control that isn't finished: a beta mark
is "worth knowing before you act", like a staged save — not a neutral fact like Private. */
.badge.beta {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.room-desc {
margin: 6px 0 0;
font-size: 0.875rem;
color: var(--muted);
/* Two lines: enough to tell rooms apart, not enough for one to own the list. */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
}
.room-stats {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---- One room's page ---------------------------------------------------- */
/* The way back out, above the page's own heading. */
.backlink {
margin: 0 0 16px;
font-size: 0.9rem;
}
.backlink a {
color: var(--muted);
text-decoration: none;
}
.backlink a:hover {
color: var(--text);
}
/* Wider thumbnail than the list's, and the name beside it rather than under: this is
the page about this one room, so the photo can carry some of the weight. */
.room-hero {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.room-hero-img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-hi);
}
/* The picture is its own upload control: a label around the image and a visually hidden
file input, so a click anywhere on it opens the picker. The hint is always drawn, not
only on hover — a touch screen has no hover, and nothing else says the picture can be
changed. */
.room-hero-media {
display: flex;
flex-direction: column;
gap: 10px;
}
.room-hero-media .error,
.room-hero-media .ok {
margin: 0;
}
.room-image-upload {
position: relative;
display: block;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.room-image-upload[aria-busy='true'] {
cursor: progress;
}
.room-image-upload .room-hero-img {
display: block;
}
.room-image-upload-hint {
position: absolute;
inset: auto 0 0;
padding: 6px 8px;
font-size: 0.8125rem;
text-align: center;
color: #fff;
background: rgb(0 0 0 / 0.6);
transition: background 120ms;
}
.room-image-upload:hover .room-image-upload-hint,
.room-image-upload:focus-within .room-image-upload-hint {
background: rgb(0 0 0 / 0.8);
}
/* Clipped to nothing rather than `display: none`, which would take it out of the tab
order; the ring is drawn on the label instead. */
.room-image-upload input[type='file'] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
opacity: 0;
}
.room-image-upload:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.room-hero-name {
font-family: var(--display);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: -0.02em;
margin: 0;
overflow-wrap: anywhere;
}
.room-hero-desc {
margin: 10px 0 0;
overflow-wrap: anywhere;
}
/* Label/value pairs. A two-column grid rather than a list of "Label: value" lines, so
the values line up and the page can be read down one column. */
.facts {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 8px 16px;
margin: 0;
font-size: 0.9rem;
}
.facts dt {
color: var(--muted);
}
.facts dd {
margin: 0;
overflow-wrap: anywhere;
}
.subrooms {
list-style: none;
margin: 18px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.subroom {
padding-top: 14px;
border-top: 1px solid var(--line);
}
.subroom:first-child {
padding-top: 0;
border-top: none;
}
.subroom-name {
font-family: var(--display);
font-weight: 700;
font-size: 0.975rem;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.subroom-meta,
.subroom-save {
margin: 6px 0 0;
font-size: 0.825rem;
color: var(--muted);
overflow-wrap: anywhere;
}
/* Something the owner probably wants to act on — an unpublished subroom, a staged save
nobody can see yet. Not an error: nothing is broken, it just isn't live. */
.warn {
color: var(--accent);
}
/*
* A scene-data blob and the link that downloads it. The KEY is the link, rather than a
* "Download" button beside it: the key is the thing an owner came to find (it's what
* `CurrentSave.DataBlob` holds, and what a bug report quotes), so making it the target
* keeps it readable and clickable without a second control competing for the row.
*/
.blob {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
margin-top: 8px;
font-size: 0.8rem;
}
.blob-label {
flex: none;
color: var(--muted);
}
/* Blob keys are `<date>/<uuid>` — long, and with only two places a line could break.
`anywhere` lets one wrap mid-key instead of pushing the card sideways. */
.blob-key {
color: var(--accent);
text-decoration: none;
overflow-wrap: anywhere;
min-width: 0;
}
.blob-key:hover {
text-decoration: underline;
}
.blob-note {
color: var(--muted);
overflow-wrap: anywhere;
}
/*
* Replacing a subroom's scene data. Boxed off from the download links above it: those
* only read the room, this one overwrites what players load, and the two shouldn't read
* as one row of blob controls.
*/
.blob-upload {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
}
.blob-upload label {
margin-bottom: 10px;
font-size: 0.8rem;
}
.blob-upload-head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 4px;
}
.blob-upload-title {
font-family: var(--display);
font-weight: 700;
font-size: 0.9rem;
}
/* Tighter than the shared `.muted` paragraph: it's a caveat under a heading, not body
copy, and the file picker should still be the first thing the eye lands on. */
.blob-upload-caveat {
margin: 0 0 10px;
font-size: 0.8rem;
}
/* The file picker draws its own button, so the shared input chrome would frame it a
second time. Padding stays, so the row lines up with the text field under it. */
.blob-upload input[type='file'] {
border: none;
background: none;
padding: 8px 0 0;
font-size: 0.8rem;
}
/* A checkbox is not a text field: the shared `input` rule would stretch it to the card's
full width and break it onto its own line, away from the words it labels. */
.check {
display: flex;
align-items: center;
gap: 8px;
}
.check input[type='checkbox'] {
display: inline-block;
width: auto;
margin: 0;
accent-color: var(--accent);
}
/* Smaller than the account forms' submit — this one sits inside a subroom row, not at
the foot of its own card. */
.blob-upload button[type='submit'] {
padding: 8px 14px;
font-size: 0.85rem;
}
/* ---- Forms -------------------------------------------------------------- */
label {
display: block;
margin-bottom: 14px;
font-size: 0.85rem;
color: var(--muted);
}
input,
select,
textarea {
display: block;
width: 100%;
margin-top: 6px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 0.95rem;
font-family: inherit;
}
textarea {
resize: vertical;
min-height: 76px;
}
/* Marks a field the form will submit without. Quiet, but next to the label rather than
inside the input, so it survives the field being filled in. */
.optional {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
opacity: 0.7;
margin-left: 4px;
}
/* Why a field is worth filling in, under the input it belongs to. Sits inside the label,
so it's read out with the field rather than as loose text after it. */
.hint {
display: block;
margin-top: 6px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--muted);
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 0;
border-color: transparent;
}
/* Where the Turnstile iframe mounts. It brings its own chrome, so this only reserves the
space (the widget is 300×65 at normal size) — otherwise the submit button jumps down
the moment the check finishes loading. */
.turnstile {
min-height: 65px;
margin-bottom: 14px;
}
button[type='submit'] {
border: none;
border-radius: 8px;
padding: 11px 18px;
font-family: var(--body);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
background: var(--accent);
color: var(--accent-text);
/* Hugs its label: a full-width orange bar for "save email" outshouts the hero. */
width: auto;
}
button[type='submit']:not(:disabled):hover {
background: var(--accent-hover);
}
button[type='submit']:disabled {
opacity: 0.55;
cursor: default;
}
/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under
the submit button, which hugs its label, so it needs its own separation from it. */
.swap {
margin: 16px 0 0;
}
.swap a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.swap a:hover {
text-decoration: underline;
}
/* ---- Utilities ---------------------------------------------------------- */
.big {
font-family: var(--display);
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 3px 0;
}
.muted {
color: var(--muted);
font-size: 0.875rem;
}
.error,
.ok {
font-size: 0.875rem;
margin: 0 0 12px;
}
.error {
color: var(--error);
}
.ok {
color: var(--ok);
}
/* ---- Stats ---------------------------------------------------------------- */
/*
* `/stats`: the head-count and its history. The line wears `--live`, the same green as the
* homepage's "online" dot, because it IS that signal drawn over time — and not the
* orange, which this page keeps for things you can press. Everything that isn't data
* (grid, ticks, the table) stays in `--line`/`--muted` so the line is the only loud thing.
*/
.shell.stats {
padding-top: 40px;
}
.stats-head h1 {
margin-bottom: 4px;
}
.stats-head p {
margin: 0 0 22px;
}
/* One control, four positions — the auth card's `.tabs`, but hugging its labels instead
of filling the row: a full-width bar over a chart reads as a heading. */
.stats-ranges {
display: inline-flex;
gap: 4px;
margin-bottom: 18px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.stats-ranges button {
background: transparent;
border: none;
color: var(--muted);
padding: 7px 14px;
border-radius: 6px;
cursor: pointer;
font-family: var(--body);
font-size: 0.9rem;
}
.stats-ranges button:hover {
color: var(--text);
}
.stats-ranges button.active {
background: var(--surface-hi);
color: var(--text);
font-weight: 600;
}
/* The last range's numbers, held while the next one loads. */
.stats-body {
transition: opacity 0.15s;
}
.stats-body.stale {
opacity: 0.5;
}
.stat-tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin: 0 0 16px;
}
.stat-tile {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 20px;
}
.stat-tile dt {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
color: var(--muted);
}
.stat-tile dd {
margin: 2px 0 0;
font-weight: 600;
font-size: 1.7rem;
line-height: 1.25;
letter-spacing: -0.01em;
}
/* The one number the page leads with. */
.stat-tile dd.hero {
font-size: 3rem;
line-height: 1.1;
}
.stat-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent);
}
.stats-card h2 {
margin-bottom: 2px;
}
.stats-card > .muted {
margin: 0 0 14px;
}
.stats-empty {
padding: 60px 0;
text-align: center;
}
.stats-chart {
position: relative;
border-radius: 6px;
/* A finger dragging along the line reads it; it shouldn't also scroll the page sideways. */
touch-action: pan-y;
}
.stats-chart svg {
display: block;
cursor: crosshair;
}
.stats-chart .grid {
stroke: var(--line);
stroke-width: 1;
}
.stats-chart .tick {
fill: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.stats-chart .series {
fill: none;
stroke: var(--live);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.stats-chart .series-dot {
fill: var(--live);
}
.stats-chart .area {
fill: var(--live);
opacity: 0.1;
}
.stats-chart .crosshair {
stroke: var(--muted);
stroke-width: 1;
}
/* Ringed in the surface colour so it stays legible where it sits on the line. */
.stats-chart .marker {
fill: var(--live);
stroke: var(--surface);
stroke-width: 2;
pointer-events: none;
}
.stats-tip {
position: absolute;
top: 8px;
display: flex;
flex-direction: column;
padding: 8px 12px;
background: var(--surface-hi);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
pointer-events: none;
}
.stats-tip strong {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
.stats-table {
margin-top: 14px;
font-size: 0.875rem;
}
.stats-table summary {
color: var(--muted);
cursor: pointer;
width: fit-content;
}
.stats-table summary:hover {
color: var(--text);
}
.stats-table-scroll {
max-height: 320px;
margin-top: 10px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.stats-table table {
width: 100%;
border-collapse: collapse;
}
.stats-table th,
.stats-table td {
padding: 7px 14px;
text-align: left;
border-bottom: 1px solid var(--line);
}
.stats-table th {
position: sticky;
top: 0;
background: var(--surface-hi);
font-weight: 600;
color: var(--muted);
}
.stats-table tr:last-child td {
border-bottom: none;
}
.stats-table th:last-child,
.stats-table td:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ---- Responsive --------------------------------------------------------- */
@media (max-width: 860px) {
/* One column: the words lead, the photo follows. */
.stage {
grid-template-columns: 1fr;
gap: 32px;
padding: 40px 20px 8px;
}
}
@media (max-width: 760px) {
/* One column: the copy first, then the links and the status under it. */
.about {
grid-template-columns: 1fr;
gap: 26px;
padding-top: 40px;
}
}
@media (max-width: 620px) {
.stage-actions .cta {
flex: 1 1 auto;
text-align: center;
}
.about-links .cta {
flex: 1 1 auto;
text-align: center;
}
.workspace {
grid-template-columns: 1fr;
}
/* Now leads on its own row; the two range figures share the one under it. */
.stat-tiles {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-tile:first-child {
grid-column: 1 / -1;
}
.stats-ranges {
display: flex;
}
.stats-ranges button {
flex: 1;
padding: 7px 4px;
}
.stats-card {
padding: 16px 12px;
}
.vtabs {
flex-direction: row;
flex-wrap: wrap;
}
.vtabs button {
border-color: var(--line);
}
/* The room page's hero and its fact table both stack: 260px of photo (or 150px of
label) beside a value leaves the value in a column too narrow to read. */
.room-hero {
grid-template-columns: 1fr;
}
.facts {
grid-template-columns: 1fr;
gap: 2px 0;
}
.facts dd {
margin-bottom: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---- Moderation panel --------------------------------------------------- */
/*
* The staff surface at /moderation (see Moderation.tsx). Deliberately plainer than the
* rest of the site: this is a working tool read for minutes at a time, so it trades the
* poster's generous spacing for density, and spends no orange except on the actions it
* shares with every other form. Nothing here is warmer than the tables it holds.
*/
/* The panel needs the width — six or seven columns don't fit the 880px the account
dashboard is sized for, and a moderator is at a desk. */
.shell.mod {
max-width: 1180px;
}
/* A row of filter controls that wraps instead of stacking: each one is short, and
stacked they push the results below the fold. */
.mod-filters {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: flex-end;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.mod-filters label {
margin-bottom: 0;
min-width: 150px;
flex: 1 1 150px;
}
.mod-filter-actions {
display: flex;
align-items: center;
gap: 12px;
}
.mod-filter-actions button {
margin: 0;
width: auto;
}
/* Tables are the one thing allowed to be wider than the page — a report row has more in
it than a phone is wide, and wrapping the cells would make it unscannable. */
.mod-scroll {
overflow-x: auto;
margin: 0 -22px;
padding: 0 22px;
}
.mod-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
white-space: nowrap;
}
.mod-table th {
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
padding: 0 12px 8px 0;
border-bottom: 1px solid var(--line);
}
.mod-table td {
padding: 10px 12px 10px 0;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.mod-table tr:last-child td {
border-bottom: none;
}
.mod-table button.linkish {
font-size: 0.875rem;
}
/* A name and the id under it. The id is always shown: rows carry ids, names are a
lookup on top, and a moderator acting on the wrong account is the failure to avoid. */
.mod-player {
white-space: nowrap;
}
/* The reporter's own words — the one cell allowed to wrap, clamped to a sensible width
so a long description doesn't push the actions off the table. Full text in the title. */
.mod-details {
white-space: normal;
max-width: 320px;
min-width: 180px;
color: var(--muted);
}
/* Red, unlike every other badge: a ban is the one state on these tables that someone
needs to notice without reading. */
.badge.mod-banned {
color: var(--error);
border-color: color-mix(in srgb, var(--error) 45%, transparent);
margin-left: 8px;
}
.mod-pager {
display: flex;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.mod-pager button {
margin: 0;
width: auto;
}
/* The evasion preview. A list rather than a table: each line is one account and one
reason, and it is read to decide whether to go ahead, not compared column by column. */
.mod-linked {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.9rem;
}
/* The ban form, pinned under the tables rather than floating over them: it carries the
evasion preview, which can run to several lines, and a modal that scrolls is worse
than a panel that is simply below. The accent border is what marks it as the thing
waiting on you. */
.mod-dialog {
border-color: color-mix(in srgb, var(--accent) 50%, var(--line));
}
.mod-dialog .card {
background: var(--surface-hi);
}
@media (max-width: 760px) {
.mod-filters label {
flex-basis: 100%;
}
}
/* ---- Player search ------------------------------------------------------ */
.player-search {
position: relative;
width: min(280px, 100%);
}
.player-search input {
margin: 0;
height: 40px;
padding: 8px 12px;
border-radius: 999px;
background: var(--surface);
border-color: var(--line);
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.player-search input:focus {
border-color: var(--accent);
background: var(--surface-hi);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
outline: none;
}
.player-search-bubble {
position: absolute;
z-index: 20;
top: calc(100% + 8px);
left: 0;
right: 0;
padding: 6px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
}
.player-search-result {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
padding: 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
font-family: var(--body);
}
.player-search-result:hover,
.player-search-result:focus-visible {
background: var(--surface-hi);
}
.player-search-avatar {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--surface-hi);
}
.player-search-name {
display: block;
overflow: hidden;
font-weight: 600;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-search-handle {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The search bar is a third thing in a row built for two, so the nav wraps when it runs
out of room rather than overflowing. No breakpoint: wrapping only happens when the row
doesn't fit, so wide screens are untouched. */
.nav {
flex-wrap: wrap;
}
/* ---- Public player profiles -------------------------------------------- */
.player-hero {
position: relative;
overflow: hidden;
padding: 0;
margin-bottom: 16px;
background: var(--surface);
}
.player-banner {
display: block;
width: 100%;
height: 220px;
object-fit: cover;
border-bottom: 1px solid var(--line);
background:
radial-gradient(
circle at 80% 20%,
color-mix(in srgb, var(--accent) 24%, transparent),
transparent 45%
),
linear-gradient(
135deg,
var(--surface-hi),
var(--surface)
);
}
.player-hero-body {
display: flex;
align-items: flex-end;
gap: 18px;
padding: 0 24px 24px;
min-height: 112px;
}
.player-hero:not(:has(.player-banner)) .player-hero-body {
align-items: center;
padding-top: 24px;
}
.player-avatar {
width: 112px;
height: 112px;
flex: none;
object-fit: cover;
margin-top: -56px;
border: 4px solid var(--surface);
border-radius: 50%;
background: var(--surface-hi);
box-shadow: 0 5px 20px rgb(0 0 0 / 30%);
}
/* The negative margin hangs the avatar over the banner's bottom edge. With no banner
there is nothing to hang over, and the card's `overflow: hidden` clips the top off. */
.player-hero:not(:has(.player-banner)) .player-avatar {
margin-top: 0;
}
.player-hero-body h1 {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2.1rem);
line-height: 1.05;
overflow-wrap: anywhere;
}
.player-hero-body .handle {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.player-hero + .card {
margin-top: 16px;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
/* The thumbnail's click target: a real button, for the keyboard, with none of its looks. */
.photo-button {
display: block;
padding: 0;
border: 0;
border-radius: 9px;
background: none;
cursor: zoom-in;
}
.photo-thumb {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface-hi);
transition:
transform 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.photo-thumb:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
box-shadow: 0 6px 18px rgb(0 0 0 / 22%);
}
/* The full-size popup. The dialog itself is invisible — just the photo, fitted to the
viewport, over the dimmed page. */
.photo-modal {
max-width: 94vw;
max-height: 94vh;
padding: 0;
border: 0;
background: none;
overflow: visible;
cursor: zoom-out;
}
.photo-modal::backdrop {
background: rgb(0 0 0 / 80%);
}
.photo-modal img {
display: block;
max-width: 94vw;
max-height: 94vh;
border-radius: 9px;
box-shadow: 0 10px 40px rgb(0 0 0 / 50%);
}
@media (max-width: 620px) {
.player-banner {
height: 160px;
}
.player-hero-body {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 0 18px 20px;
}
.player-hero:not(:has(.player-banner)) .player-hero-body {
align-items: flex-start;
}
.player-avatar {
width: 92px;
height: 92px;
margin-top: -46px;
border-width: 3px;
}
.player-hero-body h1 {
font-size: 1.5rem;
}
.photo-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}