diff options
| -rwxr-xr-x | README.md | 29 | ||||
| -rw-r--r-- | crates/server/.env.example | 18 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 9 | ||||
| -rw-r--r-- | web/src/screens/endScreen.js | 50 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 22 | ||||
| -rw-r--r-- | web/src/styles.css | 77 |
6 files changed, 156 insertions, 49 deletions
@@ -179,6 +179,35 @@ rather than warnings. Terminate TLS at the proxy and send HSTS from there. The application sets the other security headers itself. +### Secrets + +Every setting is read from the environment. Copy `crates/server/.env.example` +to `crates/server/.env` for local work. That file is gitignored and is the +only place a password or a client secret belongs. + +In production, prefer real environment variables to a file on disk. A systemd +unit can take them from `EnvironmentFile=`, with the file owned by root and +mode 600: + +```ini +[Service] +EnvironmentFile=/etc/typerpunk/env +ExecStart=/usr/local/bin/typerpunk-server +User=typerpunk +``` + +Container runtimes and hosting platforms have their own secret stores. The +server reads plain environment variables in every case, so nothing in the +application changes. + +Do not put a secret in `.env.example`, in a commit message, or in an issue. +If one is exposed, rotate it: change the database password, restart the +server, and invalidate sessions by clearing the `sessions` table. + +The first administrator is created by setting `TYPERPUNK_ADMIN_USERNAME` to an +account that has already registered. Unset it afterwards. It exists to create +the first administrator and to recover if the last one is removed. + ## Development ```bash diff --git a/crates/server/.env.example b/crates/server/.env.example index 8485826..b7501a1 100644 --- a/crates/server/.env.example +++ b/crates/server/.env.example @@ -1,3 +1,11 @@ +# Copy this file to .env and fill it in. .env is gitignored and must never be +# committed: it is the only place a password or a client secret belongs. +# +# In production, prefer real environment variables over a file. systemd units +# take EnvironmentFile= with the file owned by root and mode 600; container +# runtimes and hosting platforms have their own secret stores. The server reads +# plain environment variables either way, so nothing here has to change. + # Postgres. Create the database and role first: # sudo -u postgres createuser --pwprompt typerpunk # sudo -u postgres createdb -O typerpunk typerpunk @@ -13,6 +21,16 @@ FRONTEND_ORIGIN=http://localhost:4173 # texts.json in the process's working directory (the repo root, normally). TEXTS_JSON_PATH=texts.json +# The account promoted to administrator at startup. That account can appoint +# moderators from the Contribute screen. Leave it unset once the roles are +# assigned; it is only needed to create the first administrator, or to recover +# if the last one is removed. +TYPERPUNK_ADMIN_USERNAME= + +# Set to production to make the server refuse to start on an unsafe +# configuration rather than warn about it. +TYPERPUNK_ENV= + # Set to 1 behind TLS in production. Left off for local HTTP dev, where a # Secure cookie would silently never be set by the browser. COOKIE_SECURE=0 diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 22cd9cd..b291e64 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -102,7 +102,14 @@ fn build_app(app_state: Arc<AppState>) -> Router { async fn main() -> anyhow::Result<()> { // Ignored if absent - production deployments are expected to set real // env vars directly rather than ship a .env file. - let _ = dotenvy::dotenv(); + // dotenvy searches upward from the working directory, so a plain call + // finds .env only when the server is started from crates/server. The + // usual thing is to run it from the repository root, so that location is + // tried too. Neither is required: every setting has a default or is read + // straight from the environment. + if dotenvy::dotenv().is_err() { + let _ = dotenvy::from_filename("crates/server/.env"); + } tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js index b03c306..2787f76 100644 --- a/web/src/screens/endScreen.js +++ b/web/src/screens/endScreen.js @@ -165,46 +165,16 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s // passage happened to be on screen when it was measured. This claws the // room back in stages instead, each one only kicking in if the last // wasn't enough, so the page itself never needs to scroll to reach - // Play Again: shrink the graph down to a floor, then trim the button's - // top margin, and only as a last resort (a passage long enough that even - // both of those can't absorb it - realistically only reachable by - // pasting the full text-mode word buffer instead of actually typing it - // in the time given) let the passage preview itself scroll internally, - // which keeps every control below it reachable without the page - // scrolling. Runs once after layout settles. - requestAnimationFrame(() => { - const doc = document.documentElement; - // Re-measured after every stage rather than tracked as a running - // subtraction - margin-collapse and sub-pixel rounding meant an - // assumed "reduced height X means Y px less overflow" estimate - // drifted from the DOM's actual scrollHeight, consistently - // undershooting by a fixed amount however much was cut. - const overflowNow = () => doc.scrollHeight - doc.clientHeight; - if (overflowNow() <= 0) return; - - const graphBox = root.querySelector('.graph-container'); - const MIN_GRAPH_HEIGHT = 120; - const graphHeight = graphBox.getBoundingClientRect().height; - if (graphHeight > MIN_GRAPH_HEIGHT) { - const reduceBy = Math.min(overflowNow(), graphHeight - MIN_GRAPH_HEIGHT); - graphBox.style.height = `${graphHeight - reduceBy}px`; - redraw(); - } - if (overflowNow() <= 0) return; - - const buttons = root.querySelector('.end-screen-buttons'); - const buttonsMargin = parseFloat(getComputedStyle(buttons).marginTop); - if (buttonsMargin > 8) { - const reduceBy = Math.min(overflowNow(), buttonsMargin - 8); - buttons.style.marginTop = `${buttonsMargin - reduceBy}px`; - } - if (overflowNow() <= 0) return; - - const textBox = root.querySelector('.end-screen-text'); - const textHeight = textBox.getBoundingClientRect().height; - textBox.style.maxHeight = `${Math.max(80, textHeight - overflowNow() - 4)}px`; - textBox.style.overflowY = 'auto'; - }); + // There was an auto-fit routine here that forced this screen into one + // viewport: it shrank the graph to a 120px floor, then trimmed the Play + // Again margin, then capped the passage box at 80px with its own + // scrollbar. On an ordinary laptop window that left the two things the + // screen exists to show - the passage you just typed and the graph of + // how you typed it - both too small to read, and the passage clipped + // rather than scrolled to. + // + // A results screen is read, not acted on under time pressure, so the + // page scrolls instead. Nothing is hidden and nothing is squeezed. const tooltip = document.createElement('div'); tooltip.className = 'chart-tooltip'; diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js index 1a3ce87..2d27468 100644 --- a/web/src/screens/mainMenu.js +++ b/web/src/screens/mainMenu.js @@ -240,12 +240,26 @@ export function renderMainMenu(root, props) { function position() { const rect = groupEl.getBoundingClientRect(); const gap = 8; - const spaceBelow = window.innerHeight - rect.bottom - gap; + // Clear of the bottom edge: the corner rails and the footer live + // down there, and a panel that runs to the last pixel of the + // window reads as cut off rather than as a menu. + const bottomInset = 88; + const spaceBelow = window.innerHeight - rect.bottom - gap - bottomInset; + const spaceAbove = rect.top - gap - 16; + popoverEl.style.left = `${rect.left}px`; popoverEl.style.width = `${rect.width}px`; - popoverEl.style.top = `${rect.bottom + gap}px`; - popoverEl.style.bottom = 'auto'; - popoverEl.style.maxHeight = `${Math.max(100, spaceBelow)}px`; + // Opens upward when there is more room there, which is what a + // short window leaves. + if (spaceBelow < 220 && spaceAbove > spaceBelow) { + popoverEl.style.top = 'auto'; + popoverEl.style.bottom = `${window.innerHeight - rect.top + gap}px`; + popoverEl.style.maxHeight = `${Math.max(140, spaceAbove)}px`; + } else { + popoverEl.style.top = `${rect.bottom + gap}px`; + popoverEl.style.bottom = 'auto'; + popoverEl.style.maxHeight = `${Math.max(140, spaceBelow)}px`; + } } chevronEl.addEventListener('click', e => { e.stopPropagation(); diff --git a/web/src/styles.css b/web/src/styles.css index c56ac27..3e4aea4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -12,6 +12,9 @@ --header-offset: 3.5rem; /* 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; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; @@ -266,6 +269,7 @@ body { 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; @@ -1571,7 +1575,7 @@ body { away first if a genuinely tall passage would otherwise force a scrollbar, so it never fights that logic, it just guarantees normal breathing room in the common case. */ - padding-bottom: 2rem; + padding-bottom: var(--bottom-chrome); box-sizing: border-box; } @@ -2039,7 +2043,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { width: 100% !important; overflow-x: auto !important; margin: 0 auto 1rem auto !important; - height: 160px !important; + height: 220px !important; } .end-screen-buttons { position: static !important; @@ -2067,7 +2071,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { padding: 0 0.5rem !important; } .graph-container { - height: 180px !important; + height: 200px !important; padding: 0.5rem !important; } .endscreen-side-stat.wpm, .endscreen-side-stat.acc { @@ -2397,7 +2401,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { title and the buttons where it competed with them for attention. */ .menu-key-hint { position: fixed; - bottom: 1.25rem; + bottom: 3.25rem; left: 50%; transform: translateX(-50%); font-size: 0.75rem; @@ -3081,3 +3085,68 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { border-radius: 4px; padding: 0.1rem 0.35rem; } + + +/* The end screen is a flex column, so every child shrinks by default once the + content is taller than the viewport. That squeezed the two things the + screen exists to show: on a 650px window the passage came out 80px tall and + the graph 120px, both unreadable, and the passage was clipped rather than + scrolled to. They keep their size now and the page scrolls instead, which + is the correct trade for a screen you read rather than one you act on. */ +.end-screen-text, +.end-screen-graph-row, +.end-screen-stat-row, +.end-screen-attribution, +.code-explainer, +.mp-standings, +.end-screen-buttons { + flex-shrink: 0; +} + +/* Below this the chart stops carrying information. */ +.graph-container { + min-height: 180px; +} + +@media (max-height: 600px) { + .graph-container { + min-height: 150px; + } +} + + +/* Bottom chrome on a narrow screen. + The footer is centred and grows to the full width once the links wrap, so + at 375px it ran straight through both corner rails and covered Play Again, + which made the button unclickable. Stacked instead: rails on the bottom + line, footer above them, keyboard hint above that, and the reserved space + grows to match. */ +@media (max-width: 700px) { + :root { + --bottom-chrome: 12rem; + } + + .site-footer { + bottom: 4rem; + flex-wrap: wrap; + justify-content: center; + max-width: calc(100vw - 2rem); + row-gap: 0.2rem; + } + + /* Clears the footer even when its links wrap onto a second line, which + they do below about 400px. */ + .menu-key-hint { + bottom: 9.5rem; + } + + /* The narrow-width rule above sets this to 0.5rem, which is what left the + content underneath the chrome in the first place. */ + .end-screen { + padding-bottom: var(--bottom-chrome) !important; + } + + .stats-screen { + padding-bottom: var(--bottom-chrome); + } +} |