Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
14 kB · 344 lines
Astro
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345---/** * Every page: the head, the masthead, and a stack for whatever the page is. * * The masthead used to be built by main.ts. It is rendered here instead * because the blog has pages the app never runs on, and a nav that only * appears once JavaScript has loaded would be missing from all of them. */import { NAV_GROUPS } from "../destinations";import { BANNER_KEY, SITE_BANNER } from "../site-banner";import "../styles.css";
type Props = { title: string; description: string; /** Marks a masthead link as the current page. Omit where the app decides. */ current?: string; /** "article" for a post, so unfurlers stop calling it a site. */ ogType?: "website" | "article"; /** Absolute or site-relative. Falls back to the site's own card. */ image?: string; imageAlt?: string; /** AT-URI of the record this page was rendered from, if it has one. */ atUri?: string;};
const { title, description, current, atUri, ogType = "website", image = "/social-card.png", imageAlt = "The lance.blue logo and the text 'Find your mutuals. Salvage their mechs.'",} = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site);const imageUrl = new URL(image, Astro.site);
// The site's own card is a known size and says so. A page that brings its own// image does not, because nothing here has measured it, and dimensions that// disagree with the file are worse than dimensions that are missing.const DEFAULT_CARD = "/social-card.png";const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null;---
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{title}</title> <meta name="description" content={description} /> { /* The link preview. Prerendered pages carry their own; the app's client routes all share the tags on / , because no unfurler runs the page's JavaScript and there is one file behind every one of those addresses.
Keep this block near the top of the head. Slackbot reads only the first 32KB of the HTML, so anything large inlined above it costs the unfurl.
og:image has to be an absolute URL. Relative paths are resolved by some unfurlers and dropped by the rest, which is worse than a plain link. public/social-card.png comes from `npm run social-card`. */ } <link rel="canonical" href={canonical.href} /> { /* The canonical link's counterpart in ATProto. Only a page with a record carries it: a verifier resolves the href, so an empty one is worse than a missing tag. */ } {atUri && <link rel="site.standard.document" href={atUri} />} <meta property="og:type" content={ogType} /> <meta property="og:site_name" content="lance.blue" /> <meta property="og:url" content={canonical.href} /> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> <meta property="og:image" content={imageUrl.href} /> { cardSize && ( <> <meta property="og:image:type" content="image/png" /> <meta property="og:image:width" content={String(cardSize.width)} /> <meta property="og:image:height" content={String(cardSize.height)} /> </> ) } <meta property="og:image:alt" content={imageAlt} /> { /* twitter:* take name, not property; both spellings are common and one of them is wrong. There is no twitter:title or twitter:description here on purpose: X falls back to the og:* pair, and the tags that are left are the ones nothing falls back to. twitter:card is what tells Discord to show the image large instead of as a thumbnail. */ } <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:image" content={imageUrl.href} /> <meta name="twitter:image:alt" content={imageAlt} /> { /* Where a reader looks for the feed. On every page rather than only the blog's: autodiscovery is how a subscribe button finds one at all, and it is asked of whatever address the reader was handed. */ } <link rel="alternate" type="application/atom+xml" title="Blog · lance.blue" href="/atom.xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.ico" sizes="32x32" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> { /* The saved colour scheme, before anything is painted. account.ts is a module and runs after the first paint, so leaving this to it would show a light-mode reader a dark page first on every navigation — and the blog is a fresh page load per link.
Inline, so it cannot import web/src/theme.ts and cannot share the key with it. scripts/theme.test.mjs keeps the two spellings together. */ } <script is:inline> try { const saved = localStorage.getItem("lance.blue:theme"); if (saved === "light" || saved === "dark") document.documentElement.dataset.theme = saved; } catch { /* blocked storage: the browser's own preference stands */ } </script> <slot name="head" /> </head> <body> { /* The site-wide notice, when there is one. Above the masthead and not sticky with it: it is a thing to read once and scroll past, not chrome. Rendered here rather than by the app for the same reason the nav is — the blog's pages never run the app, and that is where most first visits land. site-banner.ts is where one is turned on. */ } { SITE_BANNER && ( <aside id="site-banner" class="site-banner" data-banner-id={SITE_BANNER.id} > <p class="site-banner-text"> {SITE_BANNER.text} {SITE_BANNER.href && SITE_BANNER.linkText && ( <a class="site-banner-link" href={SITE_BANNER.href} target="_blank" rel="noopener" > {SITE_BANNER.linkText} </a> )} </p> {/* The word is for a screen reader; the glyph is for everyone else. */} <button type="button" class="site-banner-close"> <span aria-hidden="true">×</span> <span class="visually-hidden">Dismiss this notice</span> </button> </aside> ) } { /* Before the paint, not in the module script: a banner this browser has already closed must never be seen again, and one removed a moment after the page draws is seen every time — and shoves the masthead down on its way out. Same trick and same reason as the theme's script in the head.
Unconditional, and it finds nothing on a page with no banner: the element it looks for is the only thing that decides, so this does not have to be told twice whether there is one. */ } <script is:inline define:vars={{ key: BANNER_KEY }}> try { const banner = document.getElementById("site-banner"); if (banner && localStorage.getItem(key) === banner.dataset.bannerId) banner.remove(); } catch { /* blocked storage: the notice stands */ } </script> { /* Brand and navigation. Outside #app because they outlive every screen change; on the app page main.ts only moves the current-page marker. */ } <header id="masthead" class="masthead"> <div class="masthead-inner"> <div class="brand-block"> <a class="brand" href="/#" aria-label="lance.blue home"> {/* Decorative: the wordmark beside it is the link's text. */} <img class="brand-mark" src="/logo.svg" alt="" /> <span class="brand-name" >lance<span class="brand-tld">.blue</span></span > </a> { /* What the site is: alpha. Outside the link rather than in it — inside, the anchor's aria-label would swallow it and a screen reader would never hear the word at all. This is the copy that is announced; the hero's and the footer's are hidden, because they repeat it on the same page. alpha-stamp.ts writes those two, and this one is spelled out here because the blog's pages never run the app. */ } <span class="stamp stamp-brand">Alpha</span> </div> <div class="masthead-end"> <nav class="nav" aria-label="Main"> { /* Four groups rather than one row of links: the way into a game, the things a player manages between games, what everybody else is doing, and the site's own pages. The separator is drawn by CSS off data-separator, so the gap and the rule are one decision in destinations.ts.
A link that means nothing to a stranger ships hidden and is revealed by account.ts once it has read the session. The markup is the same either way: this HTML is one file behind every address, so it cannot be built per reader.
A group whose second destination is `attached` is one split control - the filled way into a game, with the day's challenge on its end - so it is drawn without the gap the other groups have between their links. Two links rather than a button and a menu: both halves go somewhere, and there is nothing to open.
A destination that is under construction is a <span>, not a disabled <a>: there is no address behind it, so there is nothing to link to, nothing to focus and nothing to disable. It carries the site's other rubber stamp, the one the front page's unbuilt facts are marked with — soon-stamp.ts writes those, and this copy is spelled out here because the blog's pages never run the app. The stamp goes under the label rather than across it, so neither the name nor whatever the bar has wrapped beneath it is covered. The parenthesis after the label is what a screen reader gets instead of the stamp. */ } { NAV_GROUPS.map((group) => ( <div class="nav-group" data-separator={group.separator} data-split={ group.destinations.some((d) => d.attached) ? "" : undefined } > {group.destinations.map((d) => d.soon ? ( <span class="soon" data-route={d.id}> <span class="soon-label">{d.label}</span> <span class="stamp stamp-soon stamp-nav" aria-hidden="true" > Coming soon </span> <span class="visually-hidden"> (not ready yet)</span> </span> ) : ( <a class={ [ d.prominent && "prominent plate", d.attached && "attached plate", ] .filter(Boolean) .join(" ") || undefined } href={d.href} data-route={d.id} data-signed-in={d.hiddenSignedOut ? "" : undefined} hidden={d.hiddenSignedOut} aria-current={d.id === current ? "page" : undefined} > {d.label} </a> ), )} </div> )) } </nav> { /* Where account.ts says that the filled link changed from Play to Resume. Shipped empty and on every page, because a live region has to be on the page before the text it is to announce arrives — one added and written in the same breath announces nothing. */ } <p class="visually-hidden" role="status" id="nav-status"></p> { /* Filled in by theme.ts. Nothing here waits on the session: the colour scheme is a setting about this browser, and a reader who never signs in has the same say in it as a player. */ } <div class="theme" id="theme"></div> { /* Filled in by account.ts once it knows whether anyone is signed in. Empty rather than a Login button the layout guessed at: a signed-in player would watch it turn into their own avatar. */ } { /* ph-no-capture: account.ts puts the player's handle and avatar in here, and autocapture sends the visible text of what was clicked. The class stops it at this subtree. */ } <div class="account ph-no-capture" id="account"></div> </div> </div> </header> <slot /> { /* Every page, including the blog's, which the app never runs on. Its own script rather than main.ts's job for that reason. */ } <script> import { mountAccount } from "../account"; import { mountAnalytics } from "../analytics"; import { mountBanner } from "../site-banner"; import { mountTheme } from "../theme";
mountTheme(); mountAccount(); // Only the close button. Whether the banner is there at all was settled // before the paint, by the inline script above. mountBanner(); // Here rather than in main.ts because main.ts is the app's entry point // and never runs on the blog, which is where most first visits land. void mountAnalytics(); </script> </body></html>