Files
runicgateway.com/src/styles/global.css
wtclaude 56473d4d51
All checks were successful
PR checks / checks (pull_request) Successful in 2m37s
feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site
PLAN.md §16 phase 17, built on the org lead's answers of 2026-10-22,
recorded as D75-D79 (count of record 79).

- D75: core marketing pages describe what a game module adds in core's
  words. "Game intelligence" became "What a game module adds"; each
  game's own list moved to src/data/games/ and renders only on the new
  /modules/uo/ and /modules/rust/. /modules/ is a card per game.
- The page-side capability check is per game (assertGameCoverage), the
  item phase 13 moved here.
- D76: the homepage keeps its game screenshot, labelled. screens.mjs
  entries may carry `game` (D67); Screenshot.astro and the app grid
  print the game's name and mark the figure data-game.
- D77: /app/ describes the Rust screens (released the same day).
- D78: /runicnpc/, the standalone page, is in the header and footer;
  the narrow header was re-measured so five links keep two rows.
- D79: checkTerms.mjs --built reads every built page's visible text
  after the build; game routes and data-game elements allow their
  game's words. Proven against ten plants. Still the twelfth check.
- Re-read fixes: /architecture/'s "two machines is also the maximum",
  Discord "guild" on /integrations/, and collection.mjs against what
  Steam sign-in and the Rust module keep (PLAY_DATA_SAFETY.md
  regenerated; the Play answers are unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 20:10:51 -05:00

500 lines
13 KiB
CSS

/* ============================================================================
runicgateway.com — the layout shell
============================================================================
Every value here is a token from tokens.css. No colour literal appears below
this comment; `scripts/checkTokens.mjs` fails the build if one does.
-------------------------------------------------------------------------- */
@import '@fontsource-variable/cinzel';
@import '@fontsource-variable/inter';
/* The SVG diagram vocabulary and the figure-beside-prose layout, shared by the
homepage's data path and `/architecture/`'s three. Its own file because it is
a self-contained language rather than part of the shell — see its header for
the two rules every diagram on this site follows. */
@import './diagram.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 1rem;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4 {
color: var(--head);
line-height: 1.2;
text-wrap: balance;
}
h1 {
font-family: var(--display);
font-weight: 600;
letter-spacing: 0.01em;
}
p {
text-wrap: pretty;
}
a {
color: var(--accent);
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
text-underline-offset: 0.18em;
}
a:hover {
color: var(--accent-bright);
text-decoration-color: currentColor;
}
:focus-visible {
outline: 2px solid var(--portal-bright);
outline-offset: 3px;
border-radius: var(--radius-input);
}
code,
pre,
kbd,
samp {
font-family: var(--mono);
font-size: 0.9em;
}
hr {
border: 0;
border-top: 1px solid var(--line-soft);
margin: 2.5rem 0;
}
img,
svg {
max-width: 100%;
height: auto;
}
/* ---- Page frame --------------------------------------------------------- */
.page {
width: 100%;
max-width: var(--page-max);
margin-inline: auto;
padding-inline: var(--gutter);
}
.site {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.site > main {
flex: 1;
}
/* The keyboard escape hatch past the header nav. Visible only when focused. */
.skip-link {
position: absolute;
left: var(--gutter);
top: 0;
z-index: 100;
transform: translateY(-140%);
padding: 0.6rem 1rem;
border: 1px solid var(--gold-deep);
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--ink);
font-size: 0.9rem;
text-decoration: none;
transition: transform 0.15s ease;
}
.skip-link:focus {
transform: translateY(12px);
}
/* ---- Header ------------------------------------------------------------- */
.site-header {
position: sticky;
top: 0;
z-index: 50;
min-height: var(--header-h);
border-bottom: 1px solid var(--line-soft);
background: color-mix(in srgb, var(--bg-deep) 88%, transparent);
backdrop-filter: blur(10px);
}
.site-header__inner {
display: flex;
min-height: var(--header-h);
align-items: center;
justify-content: space-between;
gap: var(--gutter);
}
.brand-lockup {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: var(--ink);
text-decoration: none;
}
.brand-lockup__mark {
width: 40px;
height: 40px;
flex: none;
}
.brand-lockup__name {
font-family: var(--display);
font-size: 1.12rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--gold);
}
.site-nav {
display: flex;
align-items: center;
gap: 0.25rem;
}
.site-nav a {
padding: 0.45rem 0.7rem;
border-radius: var(--radius-input);
color: var(--muted);
font-size: 0.94rem;
text-decoration: none;
}
.site-nav a:hover {
background: var(--panel-flat);
color: var(--ink);
}
.site-nav a[aria-current='page'] {
color: var(--gold);
}
/* Phase 1 left the mobile nav to phase 3, expecting a disclosure control. It got
a wrap instead, and deliberately: with four links there is nothing to disclose.
The lockup keeps the first row, the links take the second, and the whole thing
stays four keyboard stops with no state, no script and no duplicate markup —
all three of which a hamburger would have cost.
Phase 3 found the bug this fixes by rendering the homepage in a 390px frame:
the four links plus the lockup measured 433px against a 390px viewport, so
every phone got a horizontally scrolling page. Shrinking the type further was
the tempting fix and would only have moved the failure to the next narrow
screen. */
@media (max-width: 720px) {
.site-header__inner {
flex-wrap: wrap;
justify-content: flex-start;
row-gap: 0.15rem;
column-gap: 0.5rem;
padding-block: 0.55rem;
min-height: 0;
}
.site-nav {
flex-wrap: wrap;
width: 100%;
gap: 0;
/* Pull the first link's own padding back to the gutter so the row of links
lines up with the lockup above it rather than sitting indented. */
margin-left: -0.3rem;
}
/* Five links since phase 17 (D78, RunicNPC). Measured at 390px, the fifth pushed Search
onto a third row: the links ended at 326px, and the row's 24px gap plus the 32px
button needed 382 of the 366 available. A tighter gap, padding and type put all of it
back on the second row with room to spare, and every link still clears a 24px target. */
.site-nav a {
padding-inline: 0.3rem;
font-size: 0.84rem;
}
/* Search (D47) shares the second row with the links rather than taking a third. `order`
was the obvious way to lift it beside the lockup instead, and it was rejected: the tab
order follows the DOM, so a keyboard user would tab from the lockup down to the links
and back up to a button above them. Keeping the visual order the same as the focus
order is worth more here than the row it saves. */
.site-nav {
width: auto;
flex: 1 1 auto;
}
.site-search {
margin-left: auto;
}
}
/* ---- Footer ------------------------------------------------------------- */
.site-footer {
margin-top: 4rem;
border-top: 1px solid var(--line-soft);
background: var(--bg-deep);
padding-block: 2.5rem 2rem;
color: var(--dim);
font-size: 0.9rem;
}
.site-footer__cols {
display: grid;
gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.site-footer h2 {
margin: 0 0 0.7rem;
color: var(--muted);
font-family: var(--sans);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.site-footer ul {
margin: 0;
padding: 0;
list-style: none;
}
.site-footer li + li {
margin-top: 0.4rem;
}
.site-footer a {
color: var(--muted);
text-decoration: none;
}
.site-footer a:hover {
color: var(--accent-bright);
text-decoration: underline;
}
.site-footer__legal {
margin-top: 2.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line-soft);
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
align-items: baseline;
justify-content: space-between;
}
.site-footer__legal p {
margin: 0;
}
/* ---- Panels ------------------------------------------------------------- */
.panel {
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--panel-grad);
box-shadow: var(--shadow-card);
padding: 1.5rem;
}
.eyebrow {
margin: 0 0 0.75rem;
color: var(--gold);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.prose {
max-width: var(--measure);
}
/* ---- Status chip -------------------------------------------------------- */
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
background: var(--panel-flat);
color: var(--muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
.chip--version {
border-color: color-mix(in srgb, var(--gold-deep) 70%, transparent);
color: var(--gold);
}
.chip--draft {
border-color: color-mix(in srgb, var(--mode-maint) 55%, transparent);
color: var(--mode-maint);
}
.chip--live {
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
color: var(--mode-live);
}
/* ---- Sections ------------------------------------------------------------
The vertical rhythm every marketing page is built from. Here rather than in
a component because phases 4 to 6 add pages that must sit on the same grid,
and a per-page `padding-block` is how that stops being true. */
.section {
padding-block: clamp(2.5rem, 6vw, 4.5rem);
}
.section + .section {
padding-top: 0;
}
/* ---- Buttons -------------------------------------------------------------
Three variants, all the same box: solid for the one action a page wants,
outlined for the alternatives, and the demo's own below. Anchors, not
buttons — every one of them navigates, and the site runs no client JS. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.62rem 1.15rem;
border: 1px solid var(--line);
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--ink);
font-size: 0.96rem;
font-weight: 500;
line-height: 1.3;
text-decoration: none;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.btn:hover {
border-color: var(--gold-deep);
color: var(--ink);
}
/* The primary action reads as gold-on-dark rather than a filled gold slab: at
7.91:1 the token is a text colour, and `--gold-deep` is explicitly annotated
"rules, borders, UI edges. NEVER text." — so it carries the edge and the
wash, and the gold carries the label. */
.btn--primary {
border-color: var(--gold);
background: color-mix(in srgb, var(--gold-deep) 18%, transparent);
color: var(--gold-bright);
}
.btn--primary:hover {
background: color-mix(in srgb, var(--gold-deep) 30%, transparent);
color: var(--gold-bright);
}
.btn--ghost {
background: transparent;
color: var(--muted);
}
.btn--ghost:hover {
color: var(--ink);
}
/* ---- The demo slot -------------------------------------------------------
PLAN.md §15 / D12. A public demo instance is planned and out of scope, but
the site is built so that gaining one is a line in the mounted brand.json
rather than a rebuild — the same class of change as swapping a logo (§7).
Anything carrying `data-demo-url` is hidden while that attribute is empty,
which is the state a stock build ships in. `scripts/applyBrand.mjs` fills
both the attribute and the adjacent empty `href` at boot when the mount sets
`demoUrl`, and the element appears. The markup contract is:
<a class="demo-cta" href="" data-demo-url="">See it running</a>
Written here, before phase 3 writes that markup, because the rule and the
rewrite have to agree and they live in different files. */
/* `!important`, and it is earning its keep rather than papering over something.
This selector is specificity 0,1,0. So is a class — including the scoped class an
Astro component puts on the very same element — and a component's styles are emitted
AFTER this file, so any component that gives one of these elements a `display` wins on
source order alone. Phase 4 did exactly that: `/features/`'s `.demo-link` set
`display: inline-flex` for its arrow, and twelve links to a demo that does not exist
appeared on the page, each one pointing at `href=""` — which a browser resolves to the
page it is already on.
Nothing caught it. checkBrand.mjs verifies the ATTRIBUTES, and they were perfect; the
defect was three files away in the cascade. It was found by looking at the rendered
page, which is not a mechanism.
So the rule is stated as one: while there is no demo, these elements do not render, and
no component style may overrule that by accident. A component that genuinely needs to
lay one of these out sets every property except `display`. */
[data-demo-url=''] {
display: none !important;
}
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button
like its neighbours and takes the portal colour — the live signal, for the
one link on the site that leads to something actually running. */
.demo-cta {
border-color: var(--portal);
background: color-mix(in srgb, var(--portal-deep) 22%, transparent);
color: var(--portal-bright);
}
.demo-cta:hover {
border-color: var(--portal-bright);
background: color-mix(in srgb, var(--portal-deep) 34%, transparent);
color: var(--portal-bright);
}