feat(home): phase 3 — the homepage
All checks were successful
PR checks / checks (pull_request) Successful in 49s
All checks were successful
PR checks / checks (pull_request) Successful in 49s
Replaces phase 1's scaffold with the real homepage: hero, the data path as
inline SVG, the self-hosted argument, all five capability groups, and the
get-started CTA. Three decisions the org lead took first are recorded in
PLAN.md as D17-D19.
The data path is drawn generically and captioned specifically (D17): the nodes
say "your game server" and "sidecar", the sub-labels and caption name ServUO and
uo-link. The SVG is aria-hidden because the four numbered steps beside it carry
the same path in prose — one telling, not two.
The capability list is data with a check behind it (D18). Every Game-intelligence
item names the module-uo capability slug it comes from, and the build fails if
the page and platform.json disagree either way. That needed a fifteenth fact in
checkFacts.mjs: §12 named the capability list as an externally-sourced fact and
nothing re-read it, so the chain rested on someone remembering. It also found
that the site was omitting two of the module's eight capabilities — guilds and
city governors are now listed, in the page and in §10.
The hero leads with the emblem (D19), derived from whichever logo.png is in
force so one file still changes the hero, header, tab icon and app icon
together.
Also here, both found by standing the build up rather than by review:
- checkBrand.mjs now enforces the demo slot's markup contract. applyBrand.mjs
reveals the demo link by replacing an exact pair of empty attributes; an
attribute inserted between them produces a build where the mount sets a demo
URL, the boot log says nothing and the link never appears. Both halves are
checked and the literal is derived from the expression applyBrand.mjs uses,
so they cannot drift.
- The header nav overflowed at 390px — four links plus the lockup measured
433px against a 390px viewport, so every phone got a horizontally scrolling
page. Phase 1 left this to phase 3 expecting a disclosure control; it got a
wrap instead, because with four links there is nothing to disclose and a
hamburger costs state, script and duplicate markup.
Verified on a clean checkout of this commit: all four checks, astro check, a
production build, a live /brand/* smoke, and a demo URL mounted and reverted.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
167
src/components/home/Capabilities.astro
Normal file
167
src/components/home/Capabilities.astro
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
|
||||
* each one is `/features/`'s job in phase 4, and repeating it here would create a second
|
||||
* copy to keep true.
|
||||
*
|
||||
* The call below is the point of the exercise: it throws, and therefore fails the build, if
|
||||
* the "Game intelligence" list and the module's own declared capabilities have drifted
|
||||
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
|
||||
* this makes the page honest against `platform.json`, which is the half that was missing.
|
||||
*
|
||||
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
|
||||
* list is described there as "as load-bearing as the rest", and a homepage that lists only
|
||||
* what exists while quietly omitting the well-known things that do not is the exact failure
|
||||
* §1 is written to prevent.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
---
|
||||
|
||||
<section class="page section caps">
|
||||
<p class="eyebrow">What it does</p>
|
||||
<h2>A community site, and a window into the game</h2>
|
||||
<p class="prose caps__lede">
|
||||
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
|
||||
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
|
||||
a different game without a fork.
|
||||
</p>
|
||||
|
||||
<div class="caps__grid">
|
||||
{
|
||||
capabilityGroups.map((group) => (
|
||||
<section class:list={['panel', 'caps__group', group.items.length > 8 && 'caps__group--wide']}>
|
||||
<header class="caps__group-head">
|
||||
<h3>{group.title}</h3>
|
||||
{group.moduleSupplied && <span class="chip">Module-supplied</span>}
|
||||
</header>
|
||||
|
||||
<p class="caps__summary">{group.summary}</p>
|
||||
|
||||
<ul class="caps__items">
|
||||
{group.items.map((item) => (
|
||||
<li>{item.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="caps__foot prose">
|
||||
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
|
||||
integration, more than one game module active at once, a second game module. They are
|
||||
listed rather than left out, on <a href="/features/">features</a> and{' '}
|
||||
<a href="/integrations/">integrations</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.caps h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.caps__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caps__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.caps__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.caps__group-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.caps__group h3 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.caps__summary {
|
||||
margin: 0.6rem 0 1rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.caps__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.caps__items li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.caps__items li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* A drawn marker rather than a list bullet: it takes the portal colour, so it
|
||||
tracks a mounted theme the way a `list-style` glyph would not. */
|
||||
.caps__items li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--portal);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Five groups in a three-column grid leaves a hole, and the one group that is
|
||||
twice the length of the others is the obvious thing to put in it. Game
|
||||
intelligence takes both remaining slots on the top row and sets its items
|
||||
in two columns, which fills the row and gives the module-supplied group the
|
||||
prominence it has earned by being the only one that is module-supplied.
|
||||
|
||||
The width is read from the content — a group long enough to need it gets it
|
||||
— rather than named, so a future group of that size lands the same way.
|
||||
|
||||
Guarded by a width query because `span 2` in a grid that is only one column
|
||||
wide is an overflow, not a layout. */
|
||||
@media (min-width: 62rem) {
|
||||
.caps__group--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.caps__group--wide .caps__items {
|
||||
columns: 2;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* `columns` would otherwise break an item across the column boundary, and a
|
||||
capability split over two columns reads as two capabilities. */
|
||||
.caps__group--wide .caps__items li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.caps__foot {
|
||||
margin: 2rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
302
src/components/home/DataPath.astro
Normal file
302
src/components/home/DataPath.astro
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
|
||||
* geometry, used where it explains something, and no raster anywhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
|
||||
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
|
||||
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||
* repositories in order to connect a game server — and because the tagline promises a
|
||||
* platform, not a UO product.
|
||||
*
|
||||
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
|
||||
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
|
||||
* implementation of this shape. An operator running a shard has to see themselves in the
|
||||
* picture on the first screen.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SVG IS aria-hidden
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not because it is decorative — it is the opposite — but because the steps beside it carry
|
||||
* the same four stages in full prose, at real font sizes, in reading order. A `role="img"`
|
||||
* with a `<desc>` would make a screen reader read the same path twice, and the second
|
||||
* telling would be the worse one. The picture is for people who can see it; the list is the
|
||||
* canonical version and everyone gets it.
|
||||
*
|
||||
* That also means the diagram must never gain a fact the list does not have.
|
||||
*
|
||||
* The concentric rings behind the nodes are the emblem's own geometry, centred on the
|
||||
* boundary line — the one place in the picture where the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section datapath">
|
||||
<div class="datapath__head">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>One path, one direction</h2>
|
||||
<p class="prose">
|
||||
Everything the website knows about your game arrives the same way. There is no second
|
||||
route in, and nothing on the internet can reach the game to ask.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="datapath__body">
|
||||
<div class="datapath__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The emblem's concentric rings, centred on the boundary. Drawn first so the
|
||||
panels sit over them. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="252" r="112" />
|
||||
<circle cx="190" cy="252" r="158" />
|
||||
<circle cx="190" cy="252" r="204" />
|
||||
</g>
|
||||
|
||||
<!-- Loopback hop: same host, no network involved. -->
|
||||
<path class="spine" d="M190 92 V140" />
|
||||
<path class="arrow" d="M190 148 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The network hop, and the only one. Drawn in the portal colour because this is
|
||||
the live feed, and the live signal is cyan everywhere on the site. -->
|
||||
<path class="spine spine--live" d="M190 224 V272" />
|
||||
<path class="arrow arrow--live" d="M190 280 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M190 356 V404" />
|
||||
<path class="arrow" d="M190 412 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The boundary the whole design exists to draw. -->
|
||||
<path class="boundary" d="M8 252 H372" />
|
||||
<text class="boundary-label" x="372" y="245" text-anchor="end">the network</text>
|
||||
|
||||
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="50">Your game server</text>
|
||||
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
|
||||
|
||||
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="182">Sidecar</text>
|
||||
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
|
||||
|
||||
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="314">Runic Gateway</text>
|
||||
<text class="node-sub" x="42" y="336">your public website</text>
|
||||
|
||||
<rect class="node" x="20" y="412" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="446">Browser and app</text>
|
||||
<text class="node-sub" x="42" y="468">anyone you choose to let in</text>
|
||||
</svg>
|
||||
|
||||
<p class="datapath__caption">
|
||||
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
|
||||
contract; the implementations are what plug into it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="datapath__steps">
|
||||
<li>
|
||||
<h3>Your game server</h3>
|
||||
<p>
|
||||
A plugin inside the server dials <strong>out</strong> to the sidecar over loopback.
|
||||
The game never listens for anything, so there is nothing on it to find. Events go
|
||||
onto a bounded queue and the game moves on — a sidecar that is wedged or missing
|
||||
cannot slow the world down.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>The sidecar</h3>
|
||||
<p>
|
||||
A small service beside the game, and the only piece of the bridge anything else can
|
||||
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
|
||||
a mismatched pair is refused rather than misread, and it answers only your website's
|
||||
backend, over an authenticated WebSocket and REST.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Runic Gateway</h3>
|
||||
<p>
|
||||
Your site ingests the live feed and fans it back out on two streams: a public one
|
||||
carrying an allowlist of safe events, and a staff-only one carrying the rest. That
|
||||
split is a security boundary, not a preference. When the game is down the site stays
|
||||
up and shows it as offline.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Browser and app</h3>
|
||||
<p>
|
||||
The web client reads same-origin JSON and server-sent events. The Android app talks
|
||||
to the same documented API with bearer tokens. Neither has any idea where the game
|
||||
server is, because neither is ever told.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.datapath__head h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.datapath__head .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.datapath__body {
|
||||
display: grid;
|
||||
gap: clamp(1.75rem, 4vw, 3rem);
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.datapath__figure {
|
||||
position: sticky;
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.datapath__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- The drawing ------------------------------------------------------
|
||||
SVG presentation attributes cannot take a var(), so every colour here is
|
||||
set as a CSS property on a class instead. That is also what keeps
|
||||
checkTokens.mjs satisfied: no literal reaches the markup. */
|
||||
.node {
|
||||
fill: var(--panel-b);
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.node--self {
|
||||
fill: var(--panel-a);
|
||||
stroke: var(--gold-deep);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
fill: var(--head);
|
||||
font-family: var(--sans);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-sub {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.spine {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.spine--live {
|
||||
stroke: var(--portal);
|
||||
filter: drop-shadow(0 0 6px var(--portal-deep));
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--gold-deep);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.arrow--live {
|
||||
fill: var(--portal);
|
||||
}
|
||||
|
||||
.boundary {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 5;
|
||||
}
|
||||
|
||||
.boundary-label {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rings {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 1;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
/* ---- The list ---------------------------------------------------------- */
|
||||
.datapath__steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.datapath__steps li {
|
||||
position: relative;
|
||||
padding-left: 3.25rem;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.datapath__steps li + li {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.datapath__steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.datapath__steps h3 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.datapath__steps p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.datapath__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Sticky is a wide-screen affordance: the figure should scroll away with
|
||||
everything else once it is above the list rather than beside it. */
|
||||
.datapath__figure {
|
||||
position: static;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/home/GetStarted.astro
Normal file
117
src/components/home/GetStarted.astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
|
||||
* "installation path": a "Runic Gateway install" is two independent installs. The installer
|
||||
* binary sets up the shard side only and never contacts the website; the website is a
|
||||
* separate Docker deployment.
|
||||
*
|
||||
* That belongs on the homepage rather than being saved for the docs. It is the single
|
||||
* misunderstanding most likely to make an evaluator think the software is broken, it costs
|
||||
* two sentences to prevent, and §13 calls the installation path the priority of the whole
|
||||
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
|
||||
*
|
||||
* The two halves are ordered site-first because that is the order they must be done in: the
|
||||
* shard side ends by pasting four values into the site's admin panel, which has to exist.
|
||||
*
|
||||
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
|
||||
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
|
||||
* this file that nothing checks and that a later phase would have to remember to fix.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section start">
|
||||
<div class="panel start__panel">
|
||||
<p class="eyebrow">Getting started</p>
|
||||
<h2>An install is two installs</h2>
|
||||
<p class="start__lede prose">
|
||||
This trips up almost everyone once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other. Doing them in
|
||||
order takes an evening.
|
||||
</p>
|
||||
|
||||
<div class="start__halves">
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">1</span> The site</h3>
|
||||
<p>
|
||||
A Docker Compose deployment on whatever host serves your community — a small VPS is
|
||||
plenty. Pull the images, bring it up, create the first admin, then install a game
|
||||
module from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">2</span> The game side</h3>
|
||||
<p>
|
||||
One binary, run on the machine the game server already lives on. It syncs the plugin,
|
||||
installs the sidecar as a service, and prints four values. You paste those into
|
||||
Admin → Shard, and the two halves find each other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Read the install guide</a>
|
||||
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">Ask on Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.start__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.start h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.start__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.start__halves {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.start__half h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start__num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.start__half p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.start__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
</style>
|
||||
183
src/components/home/Hero.astro
Normal file
183
src/components/home/Hero.astro
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The hero (PLAN.md §13 phase 3).
|
||||
*
|
||||
* The org lead chose an emblem hero over a type-only one: the mark carries recognition
|
||||
* across the site, the Android launcher icon and the Play listing, and showing it large is
|
||||
* what makes those three read as one product (D11, §11).
|
||||
*
|
||||
* It costs what D16 already accepted — the emblem is raster illustration, so a mounted
|
||||
* `theme.css` recolours everything around it and not the mark itself. Replacing the mark
|
||||
* means replacing `logo.png`, and because every size here is derived on request from
|
||||
* whichever `logo.png` is in force (D14), that one file changes the hero, the header, the
|
||||
* tab icon and the installed app icon together.
|
||||
*
|
||||
* The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted
|
||||
* theme. That is deliberate: the part that can track the operator's palette does.
|
||||
*
|
||||
* The <h1> is the tagline rather than the product name. The name is in the header, in the
|
||||
* page title and in the footer; a visitor who has just arrived needs the sentence more than
|
||||
* the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15).
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="page hero__inner">
|
||||
<div class="hero__copy">
|
||||
<p class="eyebrow">Self-hosted community platform</p>
|
||||
|
||||
<h1>{brand.tagline}</h1>
|
||||
|
||||
<p class="hero__lede">
|
||||
{brand.siteName} is a community website for a game server — accounts, teams, forums, a
|
||||
wiki, news and a full admin panel — with a one-way bridge that puts the server's live
|
||||
world on the public site. The game itself never listens on the internet.
|
||||
</p>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Install it</a>
|
||||
<a class="btn btn--ghost" href="/features/">See what it does</a>
|
||||
|
||||
{/*
|
||||
The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock
|
||||
build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a
|
||||
mounted `brand.json` sets `demoUrl`, and the link appears.
|
||||
|
||||
The attribute pair is a literal contract with that script — `href` immediately
|
||||
followed by `data-demo-url`, both empty, in this order. Astro preserves attribute
|
||||
order, so what is written here is what ends up in the HTML it searches for. Do not
|
||||
insert an attribute between them.
|
||||
*/}
|
||||
<a class="btn demo-cta" href="" data-demo-url="">See it running</a>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero__mark">
|
||||
{/*
|
||||
`alt=""` because the emblem is the product's mark sitting beside the product's own
|
||||
sentence — announcing it would add nothing a reader of the <h1> does not have.
|
||||
|
||||
Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below
|
||||
through the route's own classifier, so a plausible-but-underivable size fails the
|
||||
build rather than 404ing in production.
|
||||
*/}
|
||||
<picture>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="/brand/logo-256.avif 256w, /brand/logo-384.avif 384w, /brand/logo-512.avif 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
/>
|
||||
<img
|
||||
src="/brand/logo-384.webp"
|
||||
srcset="/brand/logo-256.webp 256w, /brand/logo-384.webp 384w, /brand/logo-512.webp 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
width="384"
|
||||
height="384"
|
||||
alt=""
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-block: clamp(2.5rem, 7vw, 5rem) clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: clamp(1.5rem, 5vw, 3.5rem);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.hero__copy {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: clamp(2.1rem, 5.2vw, 3.35rem);
|
||||
}
|
||||
|
||||
.hero__lede {
|
||||
margin: 1.15rem 0 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.13rem);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.9rem;
|
||||
}
|
||||
|
||||
.hero .chips {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
/* ---- The mark ---------------------------------------------------------
|
||||
The glow is a radial gradient mixed from the portal tokens rather than a
|
||||
literal, so a mounted theme.css moves it with the rest of the palette.
|
||||
It is behind the emblem and outside the flow, so it costs no layout. */
|
||||
.hero__mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hero__mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 50% auto auto 50%;
|
||||
translate: -50% -50%;
|
||||
width: 150%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-pill);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--portal-deep) 34%, transparent) 0%,
|
||||
color-mix(in srgb, var(--portal-deep) 8%, transparent) 45%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__mark img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: clamp(176px, 26vw, 320px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero__inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* The mark leads on a narrow screen: it is the fastest thing to recognise,
|
||||
and stacking it under the copy would push it below the fold entirely. */
|
||||
.hero__mark {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
src/components/home/SelfHosted.astro
Normal file
107
src/components/home/SelfHosted.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* The self-hosted argument (PLAN.md §10, the `/` row).
|
||||
*
|
||||
* Every claim below is from §2's verified state, and each is deliberately the kind of thing
|
||||
* that can be checked by running the software rather than by trusting the page. Where a
|
||||
* claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8)
|
||||
* is a house style, and a hedge in small print is the opposite of it.
|
||||
*
|
||||
* Nothing here is a version or a number, so nothing here needs `platform.json`. If a card
|
||||
* ever gains one, it reads it from there like everything else (§12).
|
||||
*/
|
||||
|
||||
const points = [
|
||||
{
|
||||
title: 'It runs on your box',
|
||||
body:
|
||||
'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' +
|
||||
'compiles on your server. One command up, one command back.',
|
||||
},
|
||||
{
|
||||
title: 'The game stays off the internet',
|
||||
body:
|
||||
'The game host opens no inbound port. The sidecar beside it is the only exposed ' +
|
||||
'part of the bridge, and it answers exactly one caller: your website.',
|
||||
},
|
||||
{
|
||||
title: 'Branding is data, not a rebuild',
|
||||
body:
|
||||
'Name, colours, logo and contact address live in a mounted file. The same image ' +
|
||||
'runs as any community — including this site, which is built the same way.',
|
||||
},
|
||||
{
|
||||
title: 'No analytics, anywhere',
|
||||
body:
|
||||
'This site has no trackers, no third-party requests and no cookie banner, because ' +
|
||||
'it collects nothing. Your deployment talks to the services you configure, and to ' +
|
||||
'nothing you did not.',
|
||||
},
|
||||
{
|
||||
title: 'Documented, not just working',
|
||||
body:
|
||||
'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' +
|
||||
'API you build against is the API that is actually there.',
|
||||
},
|
||||
{
|
||||
title: 'Free software',
|
||||
body:
|
||||
'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' +
|
||||
'are running does not.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="page section selfhosted">
|
||||
<p class="eyebrow">Why self-hosted</p>
|
||||
<h2>Your server, your data, your rules</h2>
|
||||
<p class="prose selfhosted__lede">
|
||||
There is no hosted tier and no account with us. The whole thing is software you run,
|
||||
which is the only arrangement under which "the game is not on the internet" can mean
|
||||
anything.
|
||||
</p>
|
||||
|
||||
<ul class="selfhosted__grid">
|
||||
{
|
||||
points.map((point) => (
|
||||
<li class="panel">
|
||||
<h3>{point.title}</h3>
|
||||
<p>{point.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selfhosted h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.selfhosted__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selfhosted__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.selfhosted__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.selfhosted__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user