:root { --primary-color: #00ff9d; --secondary-color: #00cc8f; --background-color: #000000; --text-color: #c8ccd0; --error-color: #ca4754; --correct-color: #00ff9d; --neutral-color: #646669; --caret-color: #00ff9d; --sub-color: #8b8e92; /* Panel and control outlines. Derived from the neutral so it tracks each theme, rather than being a sixth colour to keep in step by hand. */ --border-color: color-mix(in srgb, var(--neutral-color) 55%, transparent); /* Unified top offset below fixed header/toggle */ /* Reserved height for the top advertising banner, and the amount the header moves down when one is shown. --ad-banner-h is set on the root element by mountTopAdBanner and is 0px whenever the banner is hidden, so nothing below has to know whether a banner exists. */ --ad-banner-height: 3.75rem; --ad-banner-h: 0px; /* Set from the measured top rail by renderTopRail, because the rail is one row signed in and two signed out. The fallback covers the moment before the first measurement lands. */ --top-rail-h: 2.5rem; /* Where content starts. The banner sits beside the rail rather than above it, so this clears whichever of the two is taller, not their sum. */ --header-offset: calc(1rem + max(var(--top-rail-h), var(--ad-banner-h)) + 0.75rem); /* App padding variable so we can compensate where needed */ --app-padding: 1rem; /* Room at the foot of every screen for the fixed footer and the corner rails, so normal-flow content never ends up underneath them. */ --bottom-chrome: 5.5rem; /* Controls and the surfaces they sit on. The stylesheet had five radii in use (0, 2, 4, 6, 8 and 999px) with no rule for which applied where, so a button and its own input looked like different components. */ --radius: 4px; --radius-panel: 8px; --radius-pill: 999px; /* One border weight. Buttons were 2px and inputs 1px, so a field and the button next to it read as different weights of the same idea. The two start buttons keep 2px, where it is doing real work. */ --border-width: 1px; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } [data-theme="light"] { --primary-color: #067a4e; --secondary-color: #05663f; --background-color: #f6f6f4; --text-color: #2b2b2b; --error-color: #b3323f; --correct-color: #067a4e; --neutral-color: #8a8d90; --caret-color: #067a4e; --sub-color: #5c5f61; --record-color: #9a6b00; --warning-color: #a35b00; --player-2: #1667c2; --player-3: #b45309; --player-4: #7c3aed; --player-5: #be123c; } [data-theme="dark"] { --primary-color: #00ff9d; --secondary-color: #00cc8f; --background-color: #000000; --text-color: #c8ccd0; --error-color: #ca4754; --correct-color: #00ff9d; --neutral-color: #646669; --caret-color: #00ff9d; --sub-color: #8b8e92; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } [data-theme="dracula"] { --primary-color: #bd93f9; --secondary-color: #ff79c6; --background-color: #282a36; --text-color: #f8f8f2; --error-color: #ff5555; --correct-color: #bd93f9; --neutral-color: #6272a4; --caret-color: #bd93f9; --sub-color: #9aa5c4; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } [data-theme="nord"] { --primary-color: #88c0d0; --secondary-color: #81a1c1; --background-color: #2e3440; --text-color: #d8dee9; --error-color: #bf616a; --correct-color: #88c0d0; --neutral-color: #4c566a; --caret-color: #88c0d0; --sub-color: #93a1b8; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } [data-theme="solarized"] { --primary-color: #268bd2; --secondary-color: #2aa198; --background-color: #002b36; --text-color: #93a1a1; --error-color: #dc322f; --correct-color: #268bd2; --neutral-color: #586e75; --caret-color: #268bd2; --sub-color: #839496; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } [data-theme="gruvbox"] { --primary-color: #fabd2f; --secondary-color: #b8bb26; --background-color: #282828; --text-color: #ebdbb2; --error-color: #fb4934; --correct-color: #fabd2f; --neutral-color: #928374; --caret-color: #fabd2f; --sub-color: #bdae93; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } [data-theme="monokai"] { --primary-color: #a6e22e; --secondary-color: #66d9ef; --background-color: #272822; --text-color: #f8f8f2; --error-color: #f92672; --correct-color: #a6e22e; --neutral-color: #75715e; --caret-color: #a6e22e; --sub-color: #b0a98d; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; --player-3: #ff9f43; --player-4: #c678dd; --player-5: #ff6b8a; } * { margin: 0; padding: 0; box-sizing: border-box; } [hidden] { display: none !important; } body { margin: 0; padding: 0; font-family: 'JetBrains Mono', monospace; background-color: var(--background-color); color: var(--text-color); height: 100vh; /* Vertical scroll is allowed (and only appears when actually needed) -- a fixed "overflow: hidden" here silently clipped any screen whose content got taller than the viewport (a wide-but-short window, a longer passage, extra stat rows) with literally no way to reach the clipped part. Horizontal stays clipped since nothing should ever need to scroll sideways. */ overflow-x: hidden; overflow-y: auto; /* Cursor is set per-theme in JS (theme.js) - a hardcoded green crosshair here would look wrong on Dracula/Nord/etc. */ } .app { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; /* avoid vertical centering that pushes content down */ height: 100vh; padding: var(--app-padding); overflow: hidden; } /* The mark and wordmark together, fixed at the top-left of every screen. */ .logo { position: fixed; top: 1rem; left: 1rem; display: flex; align-items: center; gap: 0.5rem; font-size: 1.15rem; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 2px; cursor: pointer; line-height: 1; } .logo-mark { width: 1.5em; height: 1.5em; flex-shrink: 0; overflow: visible; } .logo-mark-chevron { stroke: var(--primary-color); fill: none; } .logo-mark-cursor { fill: var(--primary-color); } /* Plain inline, not a flex container. As inline-flex the two halves became flex items, which blockifies them, and the name read as "TYPER" then "PUNK" on separate lines to anything using innerText: a screen reader, a copy-paste, or a test. */ .logo-word { display: inline; white-space: nowrap; } .logo-word-a { color: var(--text-color); } .logo-word-b { color: var(--primary-color); } .logo:hover .logo-word-a { color: var(--primary-color); } .logo:hover .logo-word-b { color: var(--secondary-color, var(--primary-color)); } /* The cursor blinks, at the same rate as the typing caret, because it is the same idea. Anyone who has asked not to see motion gets it held still. */ @keyframes logo-cursor-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } } /* Only the menu's own title blinks. The corner mark is on every screen, and something blinking in the corner of every screen is a distraction rather than a flourish. */ .logo-hero .logo-mark-cursor { animation: logo-cursor-blink 1.06s steps(1, end) infinite; } @media (prefers-reduced-motion: reduce) { .logo-hero .logo-mark-cursor { animation: none; } } .logo:hover { color: var(--secondary-color); } /* Main Menu */ .main-menu { display: flex; flex-direction: column; align-items: center; /* Pinned near the top rather than vertically centered - centered left barely more room below the Start button than the mode popover needs, so on anything but a tall viewport the list ran past the bottom edge. Fixed higher, there's always real room underneath it. */ justify-content: flex-start; /* Plus the banner, which is fixed over the top of the page. Without the addition the title cleared it by 8px. */ padding-top: calc(6rem + var(--ad-banner-h)); min-height: 100vh; width: 100%; max-width: 700px; margin: 0 auto; padding-left: 2rem; padding-right: 2rem; padding-bottom: 2rem; overflow-y: auto; box-sizing: border-box; } /* The menu's own title is the full lockup at size. It is not fixed to the corner like the one every other screen carries: the menu had both, so the name appeared twice on the same screen. */ .logo-corner { cursor: default; opacity: 0.55; } .logo-corner:hover .logo-word-a { color: var(--text-color); } .logo-corner:hover .logo-word-b { color: var(--primary-color); } .logo-hero { position: static; /* Sized to the buttons beneath it rather than to nothing in particular: the menu buttons are 250px wide at 1.2rem, and this sits above them. */ font-size: 2.5rem; gap: 0; margin-bottom: 2rem; cursor: default; /* Three columns, the third empty and the same width as the first. That puts the wordmark in the exact centre of the row with the mark to its left, which is what has to line up with the buttons underneath. Centring the mark and the name together as one flex box put the name 16px right of everything below it. */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; width: 100%; } .logo-hero .logo-mark { grid-column: 1; justify-self: end; margin-right: 0.4em; width: 1.05em; height: 1.05em; } .logo-hero .logo-word { grid-column: 2; } .logo-hero:hover .logo-word-a { color: var(--text-color); } .logo-hero:hover .logo-word-b { color: var(--primary-color); } .settings-row-group { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; } .settings-row-group .menu-button.small { width: 230px; } .menu-stat .stat-label { font-size: 0.7rem; color: var(--sub-color); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.2rem; } .menu-stat .stat-value { font-size: 1.3rem; color: var(--primary-color); font-weight: bold; } /* Stats Screen */ .stats-screen { width: 100%; max-width: 700px; margin: 0 auto; padding: 2rem; margin-top: calc(var(--header-offset) - var(--app-padding)); min-height: calc(100vh - var(--header-offset)); box-sizing: border-box; padding-bottom: var(--bottom-chrome); display: flex; flex-direction: column; align-items: center; /* Vertically centred when the content is short - these screens were top-heavy, ending around y=510 on a 900px viewport with the rest of the page empty. "safe" keeps it flex-start once the content is taller than the viewport, so a long list is never cut off above the scroll origin; browsers that do not know the keyword ignore the declaration and keep the old flex-start behaviour. */ justify-content: safe center; overflow-y: auto; } .stats-screen h2 { font-size: 1.8rem; color: var(--primary-color); text-transform: uppercase; letter-spacing: 2px; margin: 1.5rem 0 1.5rem; } .stats-screen h3 { font-size: 1rem; color: var(--sub-color); text-transform: uppercase; letter-spacing: 2px; align-self: flex-start; margin: 2rem 0 0.75rem; } .stats-summary { width: 100%; display: flex; flex-wrap: wrap; justify-content: center; gap: 1.5rem; border: var(--border-width) solid var(--neutral-color); border-radius: var(--radius-panel); padding: 1.25rem 1rem; text-align: center; } /* Centred, not flex-start. These screens are a centred column, so a left-hugging empty state sat out of line with the button underneath it -- most visible on Friends, where "Sign in to add friends" and its Sign In button were on two different axes. */ .stats-empty { color: var(--sub-color); font-size: 0.9rem; align-self: center; text-align: center; max-width: 32rem; } .stats-pb-list { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; } .stats-pb-row { display: flex; align-items: center; gap: 1rem; border: var(--border-width) solid var(--neutral-color); border-radius: var(--radius); padding: 0.6rem 1rem; } .stats-pb-mode { flex: 1; text-align: left; } .stats-pb-wpm { color: var(--primary-color); font-weight: bold; } .stats-pb-date { color: var(--sub-color); font-size: 0.8rem; min-width: 90px; text-align: right; } .leaderboard-modes { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.4rem; margin-bottom: 0.5rem; } /* No width of its own, so it shrink-wrapped to 498px inside the 636px content column and the table sat narrower than everything above it. */ .leaderboard-results { width: 100%; } .leaderboard-list { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; } .leaderboard-row { display: flex; align-items: center; gap: 1rem; border: var(--border-width) solid var(--neutral-color); border-radius: var(--radius); padding: 0.6rem 1rem; } .leaderboard-rank { color: var(--sub-color); min-width: 2.5rem; } .leaderboard-name { flex: 1; text-align: left; /* A