/* One stylesheet. Dark by default, because the game is. */ /* --- the faces ----------------------------------------------------------- Four jobs, four families, self-hosted. Nothing is fetched from a font CDN: a third party in the critical path of the first paint is a third party who can take the page down, and the whole site is otherwise one bucket. The files are in src/, not public/, so that Vite fingerprints them and they can be served immutable. A relative url() below is what puts them through the asset pipeline; moving them to public/ would serve them uncached on a path this stylesheet then has to hardcode. Each is the woff2 latin and latin-ext subset from Google Fonts, with that API's own unicode-range kept: the site's copy is ASCII, so latin-ext is downloaded only when a handle or a post has an accent in it. Licences and versions are in src/fonts/README.md. All four are OFL 1.1, whose text is committed beside them because the licence requires it, and whose reserved font names are why none of the files is subsetted or renamed. Weights are only the ones the stylesheet asks for. scripts/fonts.test.mjs checks that in both directions, so an unused file fails and so does a weight a rule wants and no face supplies. */ @font-face { font-family: "Chakra Petch"; font-style: normal; font-weight: 600; font-display: swap; src: url("./fonts/chakra-petch-600-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Chakra Petch"; font-style: normal; font-weight: 600; font-display: swap; src: url("./fonts/chakra-petch-600-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Chakra Petch"; font-style: normal; font-weight: 700; font-display: swap; src: url("./fonts/chakra-petch-700-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Chakra Petch"; font-style: normal; font-weight: 700; font-display: swap; src: url("./fonts/chakra-petch-700-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 400; font-display: swap; src: url("./fonts/barlow-semi-condensed-400-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 400; font-display: swap; src: url("./fonts/barlow-semi-condensed-400-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 500; font-display: swap; src: url("./fonts/barlow-semi-condensed-500-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 500; font-display: swap; src: url("./fonts/barlow-semi-condensed-500-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 600; font-display: swap; src: url("./fonts/barlow-semi-condensed-600-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 600; font-display: swap; src: url("./fonts/barlow-semi-condensed-600-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 700; font-display: swap; src: url("./fonts/barlow-semi-condensed-700-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Barlow Semi Condensed"; font-style: normal; font-weight: 700; font-display: swap; src: url("./fonts/barlow-semi-condensed-700-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Saira Semi Condensed"; font-style: normal; font-weight: 400; font-display: swap; src: url("./fonts/saira-semi-condensed-400-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Saira Semi Condensed"; font-style: normal; font-weight: 400; font-display: swap; src: url("./fonts/saira-semi-condensed-400-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: "Roboto Mono"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("./fonts/roboto-mono-400-700-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: "Roboto Mono"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("./fonts/roboto-mono-400-700-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } :root { /* Dark unless the browser asks for light, and unless the masthead's theme control has been used — that writes data-theme on this element and the two rules below narrow color-scheme to one value. Every colour that depends on the scheme is a light-dark() pair here rather than a second block of tokens under a media query. A media query cannot see an explicit choice that disagrees with the browser's, and a token that appears in one block and not the other keeps its dark value on a light page — which is the bug the camo editor already had once. */ color-scheme: dark light; --bg: light-dark(#f4f6fa, #0b1017); --panel: light-dark(#ffffff, #131b26); --line: light-dark(#d8dfea, #22303f); --ink: light-dark(#15202b, #e6edf5); --muted: light-dark(#5a6b80, #8ea0b5); --accent: light-dark(#1f5fd0, #4c8dff); --danger: light-dark(#b3261e, #ff8a7a); /* What is printed on a filled --accent, and the one token whose two halves are not the same idea. The accent is a dark blue in light mode and a bright one in dark mode, so the ink that reads on it flips with it: white was 5.8:1 on the light accent and 3.2:1 on the dark one, under AA's 4.5:1 and the reason every filled button on the site failed. The page's own dark background on the dark accent is 6.0:1. Only a filled button uses this. --accent as text or as an outline is unchanged, and so is the blue itself. */ --on-accent: light-dark(#ffffff, #0b1017); /* The way back into a match that is still going: the same filled link on the bar, in the one colour on the page that is not the site's blue. Green rather than a second blue, because the whole point of it is that a player can tell at a glance that the button is not the one they left. --danger's red is what a failed sign-in and a lost match are written in, and --stamp's amber belongs to the alpha stamp. --on-resume is --on-accent's trick for --on-accent's reason: white reads on the dark green of light mode and not on the bright one of dark mode, where the page's own background does. contrast.test.mjs pins both pairings, so neither half of either can move on its own. */ --resume: light-dark(#0f7a4a, #35d08a); --on-resume: light-dark(#ffffff, #0b1017); /* The insignia's teal. Reserved for the masthead, so the brand mark and the site chrome are visibly the same object. */ --brand: light-dark(#0e7a8c, #4fc1d6); /* The alpha stamp's ink, and nothing else on the page is this colour — which is the point of it having a token rather than borrowing one. Amber and not --danger's red: the site works, it is only unfinished, and the red is what a failed sign-in and a lost match are already written in. */ --stamp: light-dark(#9a5411, #ffb066); /* Where a firepower curve does its best, and nothing else on the page is this colour either. Orange rather than --accent's blue because the curve itself is drawn in the accent: the mark has to be legible *against* the line it sits on, and a second blue would read as part of it. Not --stamp, which is the alpha stamp's alone - the two are near neighbours and are never on screen together, and a curve outliving the stamp is the point of it having a token of its own. */ --peak: light-dark(#a1560f, #f0a355); /* A figure that is the *absence* of a problem: a design whose sinks shed more than its guns make. Blue for cool, quieter than --accent so a stat painted in it does not read as something to click, and one step only — the scale it belongs to is warm and hot, and a second gradient at the cold end would make three colours out of a yes-or-no. */ --cool: light-dark(#3d6a99, #8fb4dd); /* The four jobs the faces above are for. Named by job rather than by family so that swapping one is one line here, which is how this set was chosen: the alternatives were compared by overriding exactly these four. --font-display is the loud one and is used sparingly — the wordmark, the hero's headline, card titles. --font-labels is the same family at a lighter weight on the small tracked capitals, so the chrome and the headline are visibly one object. --font-text is what sentences are set in, and it is semi-condensed because the hero's copy column is a fixed 28rem. --font-facts is the fact strip only, so those three cards read as plates rather than as more body copy; it is the closest relative to the display face on the page, which is the point. --font-data is for figures and for anything that is really an address: battle values, the build stamp, the handle field. Tabular, so a column of numbers lines up. */ --font-display: "Chakra Petch", ui-sans-serif, system-ui, sans-serif; --font-labels: "Chakra Petch", ui-sans-serif, system-ui, sans-serif; --font-text: "Barlow Semi Condensed", ui-sans-serif, system-ui, sans-serif; --font-facts: "Saira Semi Condensed", ui-sans-serif, system-ui, sans-serif; --font-data: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace; --radius: 6px; --measure: 34rem; /* How wide the content column is on this page. Four screens set their own value, so this is a per-page number and nothing shared between pages may read it — the masthead used to, and the bar changed width on every navigation. #app is the only thing that gets it. */ --shell: var(--measure); } /* What the theme control does, and all it does. Narrowing color-scheme is the whole mechanism: every light-dark() on the page resolves against it, form controls and scrollbars included. theme.ts sets the attribute, and the inline script in Base.astro sets it again before the first paint. */ :root[data-theme="light"] { color-scheme: light; } :root[data-theme="dark"] { color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); /* 1rem, not 16px. Every other size in this file is already relative, so a reader who raises their browser's default text size got a masthead and a footer at the new size around body copy still pinned to 16px — the chrome grew and the words they came for did not. The root element is where that setting lands, and this is the one declaration that was ignoring it. 1rem is 16px unless the reader has said otherwise, so nothing moves for anyone who has not. 1.05rem, not 1rem: --font-text is semi-condensed, so it sets a smaller x-height than the system sans it replaced and reads a step down at the same declared size. This is the step back. */ font: 1.05rem/1.55 var(--font-text); display: flex; flex-direction: column; align-items: center; } /* --- the type ------------------------------------------------------------ Which of the four faces each part of the page is set in. Grouped here rather than spread across the rules that own everything else about those parts, because the grouping is the decision: four families over one page only holds together if the boundaries between them can be read in one place. Sizes, weights and tracking stay with their components. The lists are closed on purpose. Anything not named here inherits --font-text from body, which is the right default for a sentence and the wrong one for a small tracked capital — a new label belongs in the second list. */ /* The loud face: the wordmark, the hero's headline, every card title. */ .brand-name, .hero-copy h1, .page-head h1, .profile-name, .daily h2, .signin h2, .card h2, .mode-card h3, .post-list h2, .post :is(h2, h3, h4) { font-family: var(--font-display); } /* The same family on the small tracked capitals, which is most of the chrome: the bar, the eyebrows, the stat captions, the alpha stamp. */ .nav a, .nav .soon, .account-button, .theme-button, .theme-label, .stamp, .daily-eyebrow, .daily-soon, .daily-label, .pilot-label, .fact h2 { font-family: var(--font-labels); } /* The fact strip's copy, and nothing else. Its headings are labels and stay in the label face above, so what changes is the three paragraphs: a second text face marks them as captions on the cards rather than a continuation of the hero's sentence. 0.92rem because Saira runs a touch smaller than Barlow. */ .fact p { font-family: var(--font-facts); font-size: 0.92rem; } /* Figures, and everything that is really an address rather than prose. Six of these carried a system monospace of their own until this list existed, which is how the site came to have two: one for the numbers on the front page and another for a DID on the pilot card. The :not() is the era tile on the daily card. It is a .daily-value like the two battle values beside it — same class, same size, same weight — but what it holds is "FedCom Civil War", and a proper noun in the figures face is both wrong and wide enough to wrap the tile onto two lines. */ .daily-value:not(.daily-era), .pilot-value, .pilot-did, .pilot-hover-did, .match-id, .camo-value, .camo-unit-kind, .dead-path, .signin input, .typeahead input, footer .debug { font-family: var(--font-data); font-variant-numeric: tabular-nums; } /* The reading measure is right for a page of text and too narrow for three preview blocks side by side, so the editor screen widens the shell. Still set on body rather than on #app because the screens are told apart by what they render, and body is the only element above all of them. */ body:has(.camo-editor) { --shell: 70rem; } /* A player's page is a picture beside a column of type, and the reading measure is the width of the column alone: at that width the handle and the portrait fight over the same 30rem. */ body:has(.profile) { --shell: 46rem; } /* A match reads across one line — id, status, when, the way in — and the reading measure is not wide enough to hold four things without them colliding. */ body:has(.matches) { --shell: 46rem; } /* Play, keyed on the Modes rather than on the list above them. The list is the same one the matches played list shows and wants the same measure, but it takes itself away when nothing is live — so a width that depended on it would snap back to the reading measure at exactly the moment the daily card moves to the top of the screen. The Modes are on Play, only on Play, and never removed. */ body:has(.mode-rows) { --shell: 46rem; } /* And wider when the daily card is up. The card is a board, a column of text and the ground tile side by side, and 46rem folds the tile onto the blurb; it wants the room the front page gives it. Nothing scheduled means no card to make room for, and Play drops back to the line above. */ body:has(.mode-rows):has(.daily) { --shell: 56rem; } /* And the archive of past challenges, which is a stack of that same card and wants the same room. Without this it fell back to the reading measure: the text side of the card holds its 24rem floor, so the whole of the missing width came off the board, and every fight was drawn in a column narrower than one machine. */ body:has(.daily-archive) { --shell: 56rem; } /* Hangar is two panes with a strip across both: a library of designs, each a sprite and a row of figures, beside the force being built out of them. The reading measure fits neither, and at 46rem the strip's segments are too narrow to carry a name. */ body:has(.hangar) { --shell: 70rem; } /* The challenge screen holds a scenario list with a drawn preview beside it and a slot list with a callout beside that; the reading measure would stack all four. */ body:has(.challenge) { --shell: 60rem; } /* Home is a composition, not a page of text: it needs room for the copy at its reading measure and the insignia beside it. Signed in or out — the pilot card stands where the sign-in form stands, so the panel is the same shape. */ body:has(.hero) { --shell: 56rem; } /* A stack of blocks with one gap between them, so no screen has to remember to space its own cards. */ #app { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: var(--shell); padding: 2rem 1.25rem 4rem; } #app[aria-busy="true"]::after { content: "Loading…"; color: var(--muted); } /* render() parks focus here when a screen change destroyed whatever held it, so a screen reader names the new page and the next Tab carries on from it. Neither is a control the player aimed at, so neither gets a ring. Only tabindex="-1" is quietened: anything reachable by Tab keeps its own. */ #app[tabindex="-1"]:focus, #app h1[tabindex="-1"]:focus { outline: none; } /* --- site banner --------------------------------------------------------- One notice above the masthead, when site-banner.ts has one to give. It is not chrome: it does not stick, it scrolls away with the top of the page, and closing it takes it off this browser for good. The alpha stamp's amber, which is the site's colour for "this is unfinished" and already the only thing on the page wearing it. A banner in --accent would read as something to do; a banner in --danger would read as something wrong. This is neither. */ .site-banner { display: flex; /* The body is a centred column, so an ordinary block in it is as wide as the content and not as wide as the window. The masthead states the same width for the same reason. */ width: 100%; align-items: baseline; justify-content: center; gap: 0.75rem; padding: 0.5rem 1.25rem; background: color-mix(in srgb, var(--stamp) 12%, var(--bg)); border-bottom: 1px solid color-mix(in srgb, var(--stamp) 35%, transparent); font-family: var(--font-text); font-size: 0.95rem; } .site-banner-text { margin: 0; color: var(--ink); } .site-banner-link { margin-left: 0.5ch; color: var(--stamp); font-weight: 600; } /* A glyph, not a word: the word is there for a screen reader and the button is 2rem square for a thumb. */ .site-banner-close { flex: none; padding: 0; width: 2rem; height: 2rem; background: none; border: 0; color: var(--muted); font-size: 1.2rem; line-height: 1; cursor: pointer; } .site-banner-close:hover { color: var(--ink); } /* --- masthead ------------------------------------------------------------ Insignia, wordmark and nav on one line, sticky, about 3.5rem tall. It is the only chrome on the page, so it carries the brand on its own: the mark, the name with the teal tld, and a teal seam along the bottom edge that starts under the mark and fades out across the bar. Edge to edge, and the same on every screen. The bar used to take the page's --shell so the wordmark lined up with the content below it, which worked while there was one content width; there are four now, so it gave four mastheads and the chrome jumped sideways on every navigation. Nothing under 3.5rem tall is worth that, and a fixed inner width of its own would only pick one screen's measure to agree with. Below 34rem this is what the bar already looked like, so the narrow rules are unchanged. */ .masthead { position: sticky; top: 0; z-index: 10; width: 100%; /* A touch lighter than the page at the top, fading back into it, so the bar reads as a surface the content scrolls under. */ background: linear-gradient( 180deg, color-mix(in srgb, var(--panel) 60%, var(--bg)), var(--bg) ); border-bottom: 1px solid var(--line); } .masthead-inner { display: flex; align-items: stretch; justify-content: space-between; gap: 1rem; width: 100%; min-height: 3.5rem; padding: 0 1.25rem; } /* The nav, the theme control and the account travel together at the right-hand end, so they sit after the last destination rather than the nav being pushed to the middle of the bar between two things. */ .masthead-end { display: flex; align-items: stretch; gap: 0.6rem; } /* The seam: teal under the wordmark, gone a little past it. It used to hang off the inner so that it would start under the insignia rather than out in the bar's margin; the inner has no margin now, so it sits on the bar itself. Fixed lengths, not percentages. 10rem and 24rem are what 30% and 70% came to on the 34rem bar the fade was drawn against; as percentages of a bar that is now as wide as the window they stretched into a teal wash across the whole top of the page. */ .masthead::after { content: ""; position: absolute; inset: auto 0 -1px; height: 1px; background: linear-gradient( 90deg, var(--brand) 0, color-mix(in srgb, var(--brand) 40%, transparent) 10rem, transparent 24rem ); } .brand { display: flex; align-self: center; align-items: center; gap: 0.55rem; color: inherit; text-decoration: none; } .brand-mark { display: block; width: auto; height: 2rem; } /* Capitals, to match the hero's headline: the bar and the hero are the first two things read on the page, and one in caps and one not reads as two decisions. 650 is gone with the variable face it was interpolated from — --font-display ships 600 and 700, and 700 is the weight the headline uses. */ .brand-name { font-size: 0.95rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; } .brand-tld { color: var(--brand); } .nav { display: flex; align-items: stretch; } /* The bar is three runs of links, not one row: Play, the things a player manages between games, and the site's own pages. What sets a run apart is in destinations.ts as `separator`, and drawn here — a plain gap for the first boundary, a hairline for the second. The hairline is the stronger of the two on purpose: the reader is crossing from the game to the site. */ .nav-group { display: flex; align-items: stretch; gap: 0.1rem; } /* One split control: the filled way into a game with the day's challenge on its end, drawn as two links against each other rather than with the group's own hairline gap between them. Both halves go somewhere - there is no menu to open - so the second is a link like the first and not a button. The segment is outlined rather than filled: account.ts recolours the prominent half to green when a match is still going, and a second filled half would either follow it there or sit beside it in the wrong blue. An outline is neither. */ .nav-group[data-split] { gap: 0; } /* The notches make the joint: the filled half keeps the top-left one and gives up the corner the segment starts at, and the segment takes the bottom-right. Together they are the shape one plate would have had. */ .nav-group[data-split] a.prominent { margin-right: 0; clip-path: polygon(var(--notch) 0, 100% 0, 100% 100%, 0 100%, 0 var(--notch)); } .nav a.attached { align-self: center; padding: 0.3rem 0.7rem; border: 1px solid var(--line); border-left: 0; color: var(--ink); font-size: 0.7rem; clip-path: polygon( 0 0, 100% 0, 100% calc(100% - var(--notch)), calc(100% - var(--notch)) 100%, 0 100% ); } .nav a.attached:hover { color: var(--accent); } .nav a.attached[aria-current="page"] { border-bottom-color: var(--line); color: var(--accent); } .nav-group[data-separator="space"] { margin-left: 0.9rem; } .nav-group[data-separator="rule"] { position: relative; margin-left: 0.9rem; padding-left: 0.9rem; } /* Short and centred rather than full-height: a rule the height of the bar would read as a border between two panels. */ .nav-group[data-separator="rule"]::before, .account::before { content: ""; position: absolute; left: 0; top: 50%; width: 1px; height: 1.1rem; transform: translateY(-50%); background: var(--line); } /* Full-height hit targets, so the current-page marker can sit on the bar's own bottom edge rather than floating above it. */ .nav a { display: flex; align-items: center; padding: 0 0.7rem; margin-bottom: -1px; border-bottom: 2px solid transparent; color: var(--muted); font-size: 0.76rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; text-decoration: none; } /* `display: flex` above beats the browser's own rule for [hidden], so a destination the layout shipped hidden would be on the bar for everyone. */ .nav a[hidden] { display: none; } /* A destination with nothing behind it yet. Sized and spaced exactly like the links beside it — it is a section of the site, named early on purpose — and then stamped, which says in words what the dashed underline here used to say in a line nobody has to read as anything. Not a link and not focusable, so there is nothing to click and nothing for a keyboard to land on; the label carries "(not ready yet)" for a screen reader, because a stamp says nothing to one. A column, so the stamp goes under the name rather than across it. The label is not dimmed: nothing lies over it now, and `--muted` under an opacity is how this site's readable text has stopped being readable before. What says the destination is not a destination yet is the stamp, in words. */ .nav .soon { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0 0.7rem; color: var(--muted); font-size: 0.76rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; cursor: default; } .nav a:hover { color: var(--ink); } .nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--brand); } .brand:focus-visible, .nav a:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 3px; } /* The one link that starts a game rather than reading about one. Filled like the global `button` rule (same --accent/--on-accent pairing, same radius) instead of the plain uppercase links beside it, but sized to the bar: it does not stretch full height or claim the page-scale button's own padding. */ .nav a.prominent { align-self: center; margin: 0 0.2rem; padding: 0.3rem 0.85rem 0.3rem 1rem; border: 1px solid transparent; color: var(--on-accent); background: var(--accent); --plate-ring: var(--on-accent); } .nav a.prominent:hover { color: var(--on-accent); opacity: 0.85; } .nav a.prominent[aria-current="page"] { border-bottom-color: transparent; } .nav a.prominent:focus-visible { outline: 0; box-shadow: inset 0 0 0 2px var(--plate-ring); } /* The same link when the player has a match still going. account.ts adds the class and swaps the label with it, so the colour and the word change together: the fill is what says at a glance that the bar is not what it was, and "Resume" is what says it to a screen reader. */ .nav a.prominent.resume { color: var(--on-resume); background: var(--resume); } .nav a.prominent.resume:focus-visible { --plate-ring: var(--on-resume); } /* Narrow: everything shrinks a step, but every label stays written out. Seven destinations still do not need a menu behind a button — Operations was the eighth, and is in the account menu now. This used to start at 30rem, which was where five labels stopped fitting. Seven of them, three as long as Leaderboards, run out of room a good deal sooner. */ @media (max-width: 52rem) { .masthead-inner { padding: 0 0.9rem; } .brand-mark { height: 1.75rem; } .brand-name { font-size: 1rem; } .nav a, .nav .soon { padding: 0 0.45rem; font-size: 0.7rem; letter-spacing: 0.05em; } /* The groups keep their separators here; what they give up is the room around them, which is the part that costs a label its place. */ .nav-group[data-separator="space"], .nav-group[data-separator="rule"] { margin-left: 0.5rem; } .nav-group[data-separator="rule"] { padding-left: 0.5rem; } } /* A second line. A signed-in player's eight labels and the account control together are wider than the bar well before a phone, tightening the type bought a label or two of that back and never all of it, and the labels that fell off the right-hand edge fell off silently — there was no scrollbar and no menu, they were simply gone. So the bar wraps: who is here stays on the first line with the mark, because that is the one thing that has to be reachable from every screen, and the destinations take the line under it. It costs about 2rem of height and it is the whole nav rather than as much of the nav as fitted. A menu behind a button is still the better answer eventually; this is the one that is not a menu nobody can find. */ @media (max-width: 68rem) { .masthead-inner { flex-wrap: wrap; align-items: center; padding: 0.35rem 0.9rem; } /* The bar's right-hand end stops being a box of its own here, so the nav can wrap under the mark rather than under the account control: `contents` hands the nav, the theme control and the account to .masthead-inner as its own items, and the order below is what puts them back in bar order. */ .masthead-end { display: contents; } /* Both against the right-hand edge, with the mark left where it is. */ .theme { order: 1; margin-left: auto; } .account { order: 2; } .nav { order: 3; flex-basis: 100%; /* Wraps again inside itself on a phone, where one line of ten labels is still too many: the groups break across lines rather than off the edge. */ flex-wrap: wrap; row-gap: 0.2rem; justify-content: flex-end; padding-top: 0.15rem; } } /* One step tighter again. A line of its own is not enough on a phone: ten labels on it are still wider than a 360px screen, and the ones past the edge are as gone as they were before the bar wrapped. */ @media (max-width: 40rem) { .nav a, .nav .soon { padding: 0 0.25rem; font-size: 0.66rem; letter-spacing: 0; } .account-button { padding: 0 0.3rem; font-size: 0.66rem; letter-spacing: 0; } } /* Below this the wordmark and the nav genuinely collide, so the mark carries the brand on its own — it is the same insignia, at the same place. Five labels run out of room sooner than three did, hence 26rem rather than 22. */ @media (max-width: 26rem) { .brand-name { display: none; } .masthead-inner { padding: 0.35rem 0.5rem; } /* The last of the spacing the groups are set apart by. The rule stays: it is one pixel wide and it is the only thing saying where the game ends and the site begins. */ .nav-group[data-separator="space"], .nav-group[data-separator="rule"] { margin-left: 0.3rem; } .nav-group[data-separator="rule"] { padding-left: 0.3rem; } } /* --- the account control ------------------------------------------------- The right-hand end of the masthead: Login, or the avatar of whoever is signed in with a menu under it. Built by account.ts on every page. The box is reserved at its signed-out width so the nav does not jump sideways when the session comes back. Positioned, and with a left margin, for the hairline it carries: who is here is the last thing on the bar and is set off from the rest of it the same way the site's pages are set off from the game's. The rule itself is drawn with the nav's, up in the masthead block. */ .account { position: relative; display: flex; align-items: stretch; justify-content: flex-end; min-width: 4.5rem; margin-left: 0.3rem; padding-left: 0.9rem; } .account-control { position: relative; display: flex; align-items: stretch; } /* Sized to the nav rather than to the page: the same uppercase label, the same full-height hit target, and none of the global button's fill — this is one more thing on the bar, not a call to action sitting on it. */ .account-button, .theme-button { display: flex; align-self: stretch; align-items: center; gap: 0.45rem; padding: 0 0.7rem; border: 0; border-radius: 0; background: none; color: var(--muted); font-size: 0.76rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; } .account-button:hover, .theme-button:hover { color: var(--ink); } .account-button:focus-visible, .theme-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 3px; } /* A handle is a domain name and can be long. It is the avatar that identifies the account here; the text is a courtesy and gives way first. */ .account-handle { /* Enough for a handle of about twenty characters, which is what a domain-shaped one comes to. Anything longer is cut rather than allowed to push the nav across the bar; the menu under it writes the name out. */ max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: 0; font-size: 0.8rem; } .avatar { display: grid; place-items: center; flex: 0 0 auto; width: 1.75rem; height: 1.75rem; overflow: hidden; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); } .avatar-image { width: 100%; height: 100%; object-fit: cover; } /* What stands in until the picture has decoded, and what stays for an account that has no picture at all. */ .avatar-monogram { color: var(--ink); font-size: 0.8rem; font-weight: 700; letter-spacing: 0; } /* Both panels that hang off the bar. Same surface, same shadow, same edge — they are two of one thing, and the account's is the one that sets the width. */ .account-menu, .theme-menu { position: absolute; top: 100%; right: 0; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 0.15rem; min-width: 12rem; max-width: min(16rem, calc(100vw - 1.8rem)); padding: 0.4rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); box-shadow: 0 12px 28px rgb(4 8 14 / 0.45); } .account-menu[hidden], .theme-menu[hidden] { display: none; } /* Whose account this is. Not a link, and it wraps rather than being cut off: an unverified account is named by its DID here, and half a DID is no use. */ .account-who { margin: 0; padding: 0.35rem 0.5rem 0.45rem; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; } /* The rows are a link and a button, so both are written down to the same plain thing rather than either being left to its own defaults. */ .account-item { align-self: stretch; width: 100%; padding: 0.4rem 0.5rem; border: 0; border-radius: 4px; background: none; color: var(--ink); font: inherit; font-size: 0.85rem; font-weight: 500; text-align: left; text-decoration: none; } .account-item:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); } .account-signout { margin-top: 0.15rem; border-top: 1px solid var(--line); border-radius: 0 0 4px 4px; color: var(--muted); } .account-menu .error { margin: 0.25rem 0 0; padding: 0 0.5rem; font-size: 0.75rem; } /* Narrow: the avatar is the control. A handle beside it costs more room than the nav has, and the menu still names the account. */ @media (max-width: 34rem) { .account-handle { display: none; } } @media (max-width: 30rem) { .masthead-end { gap: 0.25rem; } .account { min-width: 0; } .account-button, .theme-button { padding: 0 0.45rem; font-size: 0.7rem; letter-spacing: 0.05em; } } /* --- the theme control --------------------------------------------------- Its neighbour on the bar, and deliberately quieter: an icon with no label, because the account is who you are and this is only how the page is drawn. It is on every page, the blog's included, and it never waits on a session — which is the whole reason it is no longer a row in the account menu. */ .theme { display: flex; align-items: stretch; } .theme-control { position: relative; display: flex; align-items: stretch; } .theme-button { /* No text to space out, so the label's tracking would only widen the box. */ padding: 0 0.5rem; letter-spacing: 0; } .theme-icon { display: block; width: 1.05rem; height: 1.05rem; } /* Narrower than the account's: one label and three short words is all that is in it, and matching a menu holding a handle would leave it half empty. */ .theme-menu { min-width: 10.5rem; } /* A label above the choices rather than beside them: "System" on its own says nothing about what it sets, and the group needs the name either way. */ .theme-rows { display: flex; flex-direction: column; gap: 0.3rem; padding: 0.35rem 0.5rem 0.4rem; } .theme-label { color: var(--muted); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; } /* One segmented control, so the three buttons read as one choice. The gap is drawn by the container's own background showing through. */ .theme-choices { display: flex; gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: var(--line); } .theme-choice { flex: 1; padding: 0.3rem 0.25rem; border: 0; border-radius: 0; background: var(--panel); color: var(--muted); font: inherit; font-size: 0.78rem; text-align: center; } .theme-choice:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 8%, var(--panel)); } .theme-choice[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 18%, var(--panel)); color: var(--ink); font-weight: 600; } .theme-choice:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } /* --- the alpha stamp ----------------------------------------------------- One word, in three sizes, in the three places alpha-stamp.ts describes. A rubber stamp rather than a chip: ruled edge, tracked-out capitals, set at an angle to whatever it is sitting on, and translucent enough that the surface under it shows through the way paper shows through ink. Every length is in em, so the whole mark scales from font-size alone and a variant sets one number instead of five. */ .stamp { display: inline-block; padding: 0.15em 0.45em 0.1em; border: 0.14em solid currentColor; border-radius: 0.2em; color: var(--stamp); font-size: 0.62rem; /* 700, not 800: --font-labels tops out at 700, and a weight the family does not have is synthesised rather than refused — the regular smeared sideways. fonts.test.mjs is what caught this one. */ font-weight: 700; line-height: 1.3; letter-spacing: 0.24em; /* The tracking is added after the last letter as well, so the word sits left of centre in its own box. The indent pays that back. */ text-indent: 0.24em; text-transform: uppercase; white-space: nowrap; transform: rotate(-6deg); opacity: 0.85; } /* Beside the wordmark, and the one copy on the page a screen reader is given. It stays at the narrowest width, where the wordmark itself is dropped: the mark and the stamp are a smaller thing to fit than the name was, and the status is worth more room than the name it is stamped on. */ .brand-block { display: flex; align-items: center; gap: 0.5rem; } /* The bar is a flex row and the stamp is the smallest thing in it, so it is the first thing a browser would take the space out of. It is a fixed mark: what gives instead is the wordmark, which the narrow rules drop outright. */ .stamp-brand { flex: none; } /* Over the landing panel's top corner, across the cropped insignia. Big enough to read as stamped on the composition rather than pinned to it, and given the second rule that a stamp this size can carry without the two lines closing up. */ .stamp-hero { position: absolute; top: clamp(1.1rem, 3vw, 2rem); right: clamp(1.1rem, 3vw, 2.25rem); /* Above the mark, which is at -1 inside the hero's own stacking context. */ z-index: 1; padding: 0.2em 0.6em 0.15em; font-size: clamp(0.78rem, 1.7vw, 1.05rem); outline: 0.08em solid currentColor; outline-offset: 0.18em; transform: rotate(-9deg); pointer-events: none; } /* Above the version line, at the bottom of every page. Barely off square: the footer is a stack of short lines and a hard angle down there reads as a mistake rather than as the mark at the top of the page. */ .stamp-foot { margin-bottom: 0.45rem; transform: rotate(-3deg); } /* Phone: the hero's copy is a column the full width of the panel and the insignia has moved into the corner the stamp wants, so the big one goes. The masthead's is on the same screen, two lines above. */ @media (max-width: 40rem) { .stamp-hero { display: none; } } /* --- coming soon --------------------------------------------------------- The same rubber stamp, saying which pieces of the site are not there rather than that the site as a whole is unfinished. Four places, each of them a promise somebody could otherwise act on: the front-page facts that describe something nothing has been built for, the Modes on Play that cannot be started, the pilot card's figures that nothing reports, and the bar's two destinations with no address behind them. soon-stamp.ts and layouts/Base.astro write them. --muted and not --stamp's amber. The amber is the alpha stamp's alone, and every "there is nothing here yet" mark the site already had — the future mode cards on Play, the pilot's unrecorded figures — is in the muted ink. Two stamps in two colours on one page also keep saying two things; two stamps in one colour would read as one mark repeated. */ .stamp-soon { color: var(--muted); } /* Over a fact that is not true yet — across the middle of the card, over the words rather than tucked into a corner beside them. Ink on paper: the mark is on the plate, and the plate keeps saying what it said. Sized off the card and not off the viewport: the strip is three columns wide at the top of this file and one column on a phone, so the card gets *wider* as the window narrows and a vw would shrink the mark exactly where it has the most room. `cqw` is a percentage of the card, which is the thing the stamp has to span, so one number holds at every column count. The stamp is about 11.6 times as wide as its type, so 5.6cqw lands it at two thirds of the card. Given its own ground rather than laid straight over the sentence. Two marks in --muted crossing each other is two unreadable marks: the words under the stamp go, which is what a stamp does to a page, and every word still visible keeps the contrast it had. Nothing is dimmed — `--muted` under an opacity is how this site's readable text has stopped being readable before, and contrast.test.mjs exists to catch exactly that. The ground is short of opaque on purpose. A ghost of the sentence through it is what makes this ink on paper rather than a sticker sat on top. */ .fact-soon { position: relative; container-type: inline-size; } .stamp-fact, .stamp-mode { position: absolute; top: 50%; left: 50%; /* Above the card's own text, which is in the normal flow at auto. */ z-index: 1; padding: 0.25em 0.5em 0.2em; background: color-mix(in srgb, var(--panel) 88%, transparent); transform: translate(-50%, -50%) rotate(-9deg); opacity: 1; pointer-events: none; } .stamp-fact { font-size: clamp(0.72rem, 5.6cqw, 1.1rem); } /* Over a Mode on Play. A smaller share of a smaller card: a Mode is a heading and one short line, so a mark at the facts' two thirds would be the card. The floor is lower for the same reason — two of these sit in a row that wraps to one card per line, and the card is at its narrowest well before the strip's is. */ .stamp-mode { font-size: clamp(0.62rem, 4.2cqw, 0.92rem); } /* Under the caption on a pilot-card figure nothing records. The one place the mark does not lie over anything: four of these sit across the card, so a tile is a number and a six-letter caption wide and has no middle to cross. It goes in the flow under the caption instead, at the smallest size the stamp is drawn at anywhere. */ .stamp-tile { margin-top: 0.15rem; padding: 0.12em 0.3em 0.08em; border-width: 0.14em; font-size: 0.4rem; letter-spacing: 0.06em; text-indent: 0.06em; transform: rotate(-4deg); } /* Under the label, in the flow, the way the pilot card's tiles carry it. A destination is one short word and the stamp is wider than the word, so there is no way to lay this one across the name and still have the name: turned flat it covered the word outright, and turned up on its side at 70 degrees it struck through the middle of it and through whatever the bar had wrapped underneath — LEADERBOARDS and OBSERVE were unreadable at every width, and on a wrapped bar the two marks came down on ABOUT and UPDATES on the row below. A stamp that hides a label says less than no stamp at all. So it goes under, and the rule is that it never covers text: this and `.stamp-tile` are the two the surface has no middle to spare, and both sit in the flow. One line, and small enough that "Coming soon" is about as wide as the word it sits under, so the bar keeps the width its labels ask for. What it costs instead is height — one short line under two of the ten — which the bar has and the row does not. Barely off square, like the footer's: a hard angle on a mark this small reads as a mistake. */ .stamp-nav { margin-top: 0.15rem; padding: 0.1em 0.3em 0.05em; border-width: 0.12em; color: var(--muted); font-size: 0.42rem; line-height: 1.2; letter-spacing: 0.06em; text-indent: 0.06em; white-space: nowrap; transform: rotate(-3deg); opacity: 1; pointer-events: none; } /* The bar's own narrow step, kept here rather than beside the masthead: `.stamp` is declared further down this stylesheet than the masthead is, so a rule up there that names nothing but .stamp-nav is overwritten by the base mark before a browser ever paints it. The alpha stamp's phone rule sits in this section for the same reason. */ @media (max-width: 40rem) { .stamp-nav { font-size: 0.4rem; letter-spacing: 0.04em; text-indent: 0.04em; } } /* Phone: the stamp steps aside, the way the hero's does, and the dashed underline it replaced comes back to mark the label on its own. In the flow the mark costs width as well as height, and down here there is none of either to give: ten labels are already wider than the screen, and "Coming soon" is wider than "Observe" once the labels have given up their letter-spacing. It is also, by this size, type too small to be words. The dashes say the same thing in the room there is. A screen reader is told either way, by the "(not ready yet)" after the label, which is the copy that does not depend on how wide anything is. The underline is on the text rather than on the element's bottom edge: the bar's bottom edge is where the current-page marker lives, and a dashed line down there reads as a half-marked current page. */ @media (max-width: 30rem) { .stamp-nav { display: none; } .nav .soon-label { text-decoration: underline dashed; text-underline-offset: 0.35em; text-decoration-thickness: 1px; } } /* --- page heads ---------------------------------------------------------- For the screens that have something to say above their content: the sign-in pitch, and the ones that have to explain what went wrong. A screen whose title would only repeat the nav item the player clicked has no page head at all, and puts its h1 in .visually-hidden instead. */ .page-head { margin: 0 0 0.5rem; } .page-head h1 { margin: 0; font-size: 1.65rem; line-height: 1.25; letter-spacing: -0.02em; text-wrap: balance; } .page-head .lead { margin: 0.5rem 0 0; color: var(--muted); max-width: var(--measure); text-wrap: pretty; } /* --- the landing hero ---------------------------------------------------- One framed panel. The copy and the sign-in form sit in a column on the left; the insignia is oversized, hung off the top and right, and cropped by the frame — which is the whole composition. It is deliberately not a centred mark over a centred headline. The mark is masked so it fades out towards the copy: that keeps it from sitting behind anything anyone has to read, without needing a scrim over the artwork. */ .hero { position: relative; /* A stacking context of its own, so the mark's negative z-index stays inside this panel. The panel deliberately does not clip: the handle field's suggestions hang below it and an overflow rule here would cut them off. What crops the insignia is .hero-mark. */ isolation: isolate; min-height: 25rem; padding: clamp(1.5rem, 4vw, 2.75rem); border: 1px solid var(--line); border-radius: calc(var(--radius) * 2); background: radial-gradient( 82% 110% at 84% 22%, color-mix(in srgb, var(--brand) 15%, transparent), transparent 62% ), linear-gradient( 158deg, color-mix(in srgb, var(--panel) 88%, var(--brand)), var(--bg) 74% ); } /* The frame that does the cropping: the panel's own shape, clipping whatever the insignia hangs over the edge. Behind the copy but in front of the panel's background — a negative z-index child paints after the parent's background and before its text. */ .hero-mark { position: absolute; inset: 0; z-index: -1; overflow: hidden; border-radius: inherit; pointer-events: none; /* Fades out towards the copy, which keeps the artwork from sitting behind anything anyone has to read without needing a scrim over it. */ mask-image: linear-gradient( 100deg, transparent 0 24%, rgb(0 0 0 / 0.5) 48%, #000 76% ); } /* A quarter of the patch is off the right edge on purpose: the frame cuts through the hex rather than framing it, which is the difference between a cropped mark and a big logo sitting in the corner. */ .hero-insignia { position: absolute; display: block; top: 2%; right: -25%; height: 104%; width: auto; /* Behind the copy in every sense: it is the loudest thing in the panel at full strength, and the words have to win. */ opacity: 0.92; } /* The light the system sits in. */ .hero-mark::before { content: ""; position: absolute; inset: 12% -8% 12% 46%; border-radius: 50%; background: radial-gradient( closest-side, color-mix(in srgb, var(--brand) 26%, transparent), transparent ); filter: blur(1.75rem); } .hero-copy { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 1.15rem; max-width: 28rem; } /* The floor of the clamp is in rem, so it grows with the reader's text size while the viewport does not: at 200% text on a 320px phone "Sensors," alone is wider than the screen, and one word that cannot break took the whole document sideways with it. `anywhere` and not `break-word`, which was tried first and did nothing: both break a word only when it will not fit, but only `anywhere` lets that break count towards the intrinsic width. `break-word` leaves the h1's min-content width at the longest word, so the element — and the page with it — is still sized by "Sensors,". Capping the size against vw instead would have kept the word whole by refusing the reader the text size they asked for, which is the thing this stylesheet is not allowed to do. It is not pretty where it fires: at 200% text on a phone the headline breaks mid-word. Nothing fires at an ordinary text size, where the words fit and the headline is unchanged. `hyphens: auto` was tried to soften it and did nothing measurable in the browser this was checked in, so it is not here pretending to. */ .hero-copy h1 { margin: 0; max-width: 18ch; /* Capitals, which is where --font-display's squared drawing shows: the same word in lowercase reads as a typeface with an accent, and in caps it reads as something stencilled onto a panel. The tracking goes positive for it — caps need air where the lowercase needed pulling together. */ text-transform: uppercase; font-size: clamp(1.9rem, 4.6vw, 2.6rem); line-height: 1.1; letter-spacing: 0.02em; text-wrap: balance; overflow-wrap: anywhere; /* Ink at the top falling towards the brand teal at the bottom, filled through the glyphs. Shallow on purpose: at 30% teal it reads as light across metal rather than as coloured type, and it is the one thing on the page that ties the headline to the insignia behind it. -webkit-text-fill-color is what actually paints the transparent glyphs, and it is inherited — which is why .hero-em below has to set it back rather than only setting color. Both prefixed and unprefixed background-clip: the unprefixed property is what browsers support now, the prefixed one is what older Safari answers to. */ background: linear-gradient( 172deg, var(--ink) 8%, color-mix(in srgb, var(--ink) 30%, var(--brand)) 98% ); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } /* The same teal dash as the masthead's seam and the fact strip's headings, at the top of the page. It is a block child of the h1 rather than a rule above it so that it moves with the headline at every width, and it keeps its own background: background-clip on the parent clips the parent's paint to the parent's glyphs, and a pseudo-element has none. */ .hero-copy h1::before { content: ""; display: block; width: 3.4rem; height: 2px; margin-bottom: 0.95rem; background: var(--brand); opacity: 0.85; } /* The word the headline lands on. -webkit-text-fill-color, not just color: the gradient above sets that property to transparent and it inherits, so a child that only sets color paints nothing. */ .hero-em { color: var(--brand); -webkit-text-fill-color: var(--brand); } .hero-lead { margin: 0; max-width: 26rem; font-size: 1.02rem; color: var(--muted); text-wrap: pretty; } /* The host name the pitch ends on, which host-cycle.ts rotates. Every name is in here, all in the one grid cell, so the box is as wide as the longest of them and stays that width for good. That is the whole point of it: the names are different lengths, this one sits at the end of a paragraph that wraps, and a box that changed width would re-wrap the sentence under the reader every few seconds. The slack after a short name is at the end of the last line, where there is nothing to see. Start-aligned, so each name sits against the "on " before it rather than floating in the middle of the box. Hidden with visibility rather than display, because display: none would stop the unshown names sizing the box and the width would be back to changing. A shade heavier than the line around it, and each name in its host's own colours over a halo of their second one — host-cycle.ts sets the two, because which colour belongs to Blacksky is a fact about Blacksky. The fallbacks are what the sentence would look like with the colours taken away. */ .hero-cycle { display: inline-grid; vertical-align: top; perspective: 340px; font-weight: 500; } .hero-cycle .cycle-word { grid-area: 1 / 1; justify-self: start; white-space: nowrap; visibility: hidden; color: var(--host-ink, var(--ink)); /* The second colour is an outline that fades rather than stops. Four shadows of the one colour at widening radii: the tight ones stack up almost solid against the letters, which is the only place a blurred colour still has the chroma to read as itself, and each one further out is weaker, so the edge feathers instead of ending. A single wide shadow washes out to white; a hard -webkit-text-stroke holds the hue but draws a line around the word. host-cycle.ts supplies the colour flat, with no alpha of its own, so how far it carries is decided once here rather than seven times there. */ text-shadow: 0 0 0.035em color-mix(in srgb, var(--host-glow, transparent) 90%, transparent), 0 0 0.08em color-mix(in srgb, var(--host-glow, transparent) 75%, transparent), 0 0 0.17em color-mix(in srgb, var(--host-glow, transparent) 50%, transparent), 0 0 0.65em color-mix(in srgb, var(--host-glow, transparent) 32%, transparent); } /* The one being shown, and — for the length of a swap — the one being replaced. cycle-here is the state with no motion attached, which is where the first name starts and where a player who asked for less motion stays. */ .hero-cycle .cycle-here, .hero-cycle .cycle-in, .hero-cycle .cycle-out { visibility: visible; } /* The form is a panel on a panel, so it is slightly translucent and lifted rather than another flat card the same colour as its surroundings. .hero-outage is what stands in the same place when the control plane cannot be reached, so it is the same panel — named here rather than given a look of its own, because a second set of numbers is a second thing to keep in step. */ .hero .signin, .hero .hero-outage { width: 100%; max-width: 27rem; margin-top: 0.15rem; background: color-mix(in srgb, var(--panel) 84%, transparent); backdrop-filter: blur(6px); box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 0.18); } /* The one thing on the page to press. */ .hero .signin button[type="submit"] { padding: 0.7rem 1.4rem; font-size: 1rem; } /* --- the daily challenge ------------------------------------------------- One fight a day, on the front page, whoever is looking at it. The board on the left and the figures on the right down to a phone, where they stack. The same blocks dress the archive at #daily, one card per past day, so a challenge looks the same wherever it is read. */ /* One line: the label, the way to the archive just after it, and the day this challenge ran pushed to the far end. */ .daily-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.6rem; } .daily-soon { margin-left: auto; } .daily-eyebrow { margin: 0; color: var(--brand); font-size: 0.62rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; } /* The same words the masthead uses for a destination with nothing behind it, and the same dashed "there is nothing to press here". */ .daily-soon { margin: 0; padding: 0.1rem 0.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); font-size: 0.6rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .daily h2 { margin: 0.35rem 0 0.2rem; font-size: 1.25rem; } .daily-blurb { margin: 0 0 0.9rem; max-width: var(--measure); } /* The board keeps its own aspect ratio and gives up width first: the figures beside it are readable at any size and the map is not. It holds the tags and the watermark, so it stays tight to the canvas - the screen it sits in is `.daily-board` below, and anything positioned against a frame that was taller than its picture would drift off the machines it names. */ .daily-map-frame { min-width: 0; /* A hairline where the hex field stops. The screen around it is taller than any board, and without this the texture just ends mid-bezel. */ outline: 1px solid rgb(255 255 255 / 8%); } /* Pixel art: scaled up by whole hexes on a wide screen, and never smoothed. */ .daily-map { display: block; width: 100%; height: auto; image-rendering: pixelated; } /* The row under the card: the way into the fight, the scenario file, and the way to the archive. Play is the filled button every other primary action on the site is; the download is outlined beside it, since it leaves the site. */ .daily-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin: 1rem 0 0; font-size: 0.9rem; } /* The site's button rule sets align-self: flex-start, which is right in a card column and wrong in this row of links. */ .daily-play { align-self: center; } /* Why Play is disabled, or what it is worth. Empty on the day's own challenge until the control plane says otherwise, and an empty one must not leave the row's gap on both sides of nothing. */ .daily-play-note:empty { display: none; } /* One control, two halves, cut like a plate rather than rounded like a chip. The outer corners are notched - the lit half at its top left, the grey half at its bottom right - so the pair reads as one stamped object with a diagonal running through it, and a 2px seam of card between them keeps the two halves legible as two targets. Both halves carry the same --notch rather than inheriting one from the group: each is a plate, and a plate sets its own. */ .daily-launch { display: inline-flex; align-items: stretch; gap: 2px; } .daily-launch > .daily-play, .daily-launch > .daily-download { --notch: 0.6rem; } /* Both halves are plates (see .plate): the lit one cut at its top left, the grey one at its bottom right, so the pair carries one shape between them. */ .daily-launch > .daily-play { margin: 0; } .daily-download { display: inline-flex; align-items: center; padding: 0.4rem 0.8rem; text-decoration: none; /* A step smaller than a plate's default label: this half is quiet, and it carries a file name rather than an order. */ font-size: 0.75rem; /* Tracked capitals are wide, and this half is a file name: broken over two lines it stops reading as a label on a plate. */ white-space: nowrap; /* The grey half: present, clearly secondary, and clearly attached to the lit half rather than floating beside it. Opaque rather than a wash - over a dark card a 12% white was invisible, and an invisible half of a split button is just a button with a wide gap after it. */ color: color-mix(in srgb, var(--ink) 80%, var(--panel)); background: color-mix(in srgb, var(--ink) 22%, var(--panel)); border: 0; /* Grey rather than accent-filled, so the ring that shows on it is the accent one a quiet plate takes by default. */ --plate-ring: var(--accent); } .daily-download:hover, .daily-download:focus-visible { color: var(--ink); background: color-mix(in srgb, var(--ink) 34%, var(--panel)); } /* A machine's name beside it on the board. daily.ts places every tag from the same `spotsFor` that stands the sprite, so a label cannot drift off the machine it names, and clamps each one to the slot that unit owns. */ .daily-map-frame { position: relative; } .daily-unit-tag { position: absolute; /* Centred under the machine, which is itself centred in its slot. The width is an inline style, because only daily.ts knows how many machines share a row - and it is what guarantees a tag cannot reach its neighbour. */ display: grid; justify-items: center; text-align: center; transform: translateX(-50%); line-height: 1.1; letter-spacing: 0.04em; text-transform: uppercase; pointer-events: none; /* At spaces only: a designation broken mid-word reads as a different machine (STILETT/O). A name with no space in it that still will not fit is the one case allowed to overrun its box, and it is rarer than a broken one. */ overflow-wrap: normal; word-break: keep-all; /* Floating over unit art and hex lines, so it carries its own contrast rather than relying on whatever happens to be behind it. */ text-shadow: 0 1px 2px rgb(0 0 0 / 90%), 0 0 6px rgb(0 0 0 / 80%); } .daily-unit-chassis { font-size: 0.58rem; font-weight: 700; color: rgb(233 238 245); } /* The variant, under the chassis it belongs to and in that force's colour, so a tag says which side its machine is on without a second legend. */ .daily-unit-model { font-size: 0.54rem; font-variant-numeric: tabular-nums; color: rgb(151 186 240); } /* Every side but the player's is opposition, however many of them there are. Keyed on "not the player" rather than on side 1, which left the second and third enemy lances reading in the player's own blue. `--side-colour` is that force's exact shade, set inline beside data-side, so three lances of one team read as three shades of their team's red rather than as one flat opposition colour. The literal is the fallback for a tag drawn before the variable lands. */ .daily-unit-tag:not([data-side="0"]) .daily-unit-model { color: var(--side-colour, rgb(240 150 158)); } /* Tonnage and price under each force's battle value: the two figures a player reads the shape of a lance from, and neither is worth the room the battle value gets. */ .daily-bv-sub { margin: 0.1rem 0 0; font-size: 0.62rem; font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--ink) 48%, transparent); } /* Which battle value this is. MegaMek computes BV 2.0, and a player comparing a fight against a printed record sheet needs to know which of the two systems the numbers came from. */ .daily-bv-system { display: block; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.14em; color: color-mix(in srgb, var(--brand) 72%, transparent); } /* The era, over the empty ground between the two forces. Large and faint: it is the one thing on the card that is atmosphere rather than information, and it sits in the only band of the board no machine ever stands in. */ /* Centred by default. daily.ts moves it to 41% - the gap between the north band (0-38%) and the centre band (42-72%) - but only when a force actually deploys in the centre, which is the only case that would collide. */ .daily-era-watermark { position: absolute; inset-inline: 0; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; gap: 0.5rem; pointer-events: none; font-size: 0.82rem; font-weight: 700; letter-spacing: 0.34em; /* The letter-spacing is on the right of the last glyph too, which throws a centred line visibly left. */ text-indent: 0.34em; text-transform: uppercase; white-space: nowrap; color: rgb(233 238 245 / 30%); } /* Bigger than the inline era symbol elsewhere, and no extra opacity: the mask paints in currentcolor, which is already the faint wash above. */ .daily-era-watermark > span:not(.daily-era-icon) { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .daily-era-watermark .daily-era-icon { width: 1.7rem; height: 1.7rem; flex: none; } /* The years the era covers, quieter than its name and without the tracking - a date read as letterspaced caps is hard to take in as a number. */ .daily-era-span { font-size: 0.72rem; font-weight: 400; letter-spacing: 0.06em; text-indent: 0; opacity: 0.72; font-variant-numeric: tabular-nums; } /* --- the three facts ----------------------------------------------------- What is true today, including the part that is a limitation. */ .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.75rem; } .fact { padding: 0.9rem 1rem; border: 1px solid var(--line); border-radius: var(--radius); background: color-mix(in srgb, var(--panel) 55%, transparent); } .fact h2 { margin: 0 0 0.35rem; color: var(--brand); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.11em; text-transform: uppercase; } /* The same teal dash as the seam under the masthead, so the strip belongs to the same object as the bar above it. */ .fact h2::before { content: ""; display: block; width: 1.4rem; height: 2px; margin-bottom: 0.55rem; background: var(--brand); opacity: 0.75; } .fact p { margin: 0; color: var(--muted); font-size: 0.88rem; text-wrap: pretty; } .fact a { color: var(--accent); } @media (max-width: 46rem) { .facts { grid-template-columns: 1fr; } } /* Phone: the panel is a column, so the mark moves to the top corner and is cropped harder. It is the same object in the same place — behind the words, coming in from the right — at the size that leaves the headline room. */ @media (max-width: 40rem) { .hero { min-height: 0; border-radius: var(--radius); } .hero-insignia { top: -4rem; right: -22%; /* Sized off the viewport, not the panel: the headline gets taller as the column narrows, and a fixed mark would end up underneath all of it. */ height: clamp(12rem, 70vw, 17rem); opacity: 0.45; } /* Fades towards the bottom left rather than just the left: the headline and then the form are below, and the mark has to be gone before it gets there. */ .hero-mark { mask-image: linear-gradient( 35deg, transparent 0 26%, rgb(0 0 0 / 0.2) 52%, #000 86% ); } .hero-copy, .hero .signin { max-width: none; } /* Kept off the mark's corner: the accented word is the same teal as the artwork, and the two must not land on each other. */ .hero-copy h1 { max-width: 16ch; } } .signin, .card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1.25rem; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); } .card h2 { margin: 0; font-size: 1.1rem; } /* The sign-in form says what it is for before it asks for anything, so it has a heading and a line of explanation of its own. */ .signin h2 { margin: 0; font-size: 1.02rem; letter-spacing: -0.01em; text-wrap: balance; } .signin-blurb { margin: 0 0 0.15rem; color: var(--muted); font-size: 0.9rem; text-wrap: pretty; } /* Sign in, beside whatever way out the frame around the form has added — the dialog's Not now. A row, so the way out never reads as the next step. */ .signin-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; } label { font-size: 0.9rem; color: var(--muted); } input { padding: 0.6rem 0.7rem; font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); } input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* Handle autocomplete. The dropdown lives in a closed shadow root, so the only way to dress it is the custom properties and ::part() names the component publishes. Its --radius default is our own token already, and it reads the theme through --panel and --line, so light mode follows for free. */ .typeahead { display: block; /* The handle field sits in a flex column where the bare input used to, and a growable basis would stretch it down the whole form. */ flex: 0 0 auto; min-width: 0; /* Beats the component's own system-ui default, which is not our body font. */ font: inherit; --color-background: var(--panel); --color-border: var(--line); --color-shadow: #000; --color-hover: var(--line); --color-avatar-fallback: var(--line); } .typeahead input { width: 100%; } .typeahead::part(user) { cursor: pointer; } button { align-self: flex-start; padding: 0.6rem 1.1rem; font: inherit; font-weight: 600; color: var(--on-accent); background: var(--accent); border: 1px solid transparent; cursor: pointer; } /* The quiet plate. Outlined, it lost its line along both cut edges and read as a frame someone had taken a bite out of; filled, it is the treatment the daily card's grey half and the waiting screen's way out already wear. Opaque rather than a wash - over a dark card a 12% white is invisible. */ button.secondary { color: color-mix(in srgb, var(--ink) 85%, var(--panel)); background: color-mix(in srgb, var(--ink) 20%, var(--panel)); border-color: transparent; font-weight: 500; } button.secondary:hover:not(:disabled) { color: var(--ink); background: color-mix(in srgb, var(--ink) 32%, var(--panel)); } button[disabled] { opacity: 0.6; cursor: progress; } /* A button scaled to the row it sits in rather than to the page. Used by the camo editor's action rows and by the match list. The notch comes down with it: a 0.5rem cut on a 0.8rem button eats the label. */ .small-btn { font-size: 0.8rem; padding: 0.35rem 0.6rem 0.35rem 0.7rem; --notch: 0.35rem; } /* --- notched plates ------------------------------------------------------- The site's stamped shape, and the default for anything that reads as a button: the top-left and bottom-right corners taken off at 45 degrees, so an action reads as a plate bolted to the page rather than a rounded chip. .plate-tl and .plate-br cut one corner each, for the halves of a split control that make the shape up between them, and .plate is for the things that are not