srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-xREADME.md29
-rw-r--r--crates/server/.env.example18
-rw-r--r--crates/server/src/main.rs9
-rw-r--r--web/src/screens/endScreen.js50
-rw-r--r--web/src/screens/mainMenu.js22
-rw-r--r--web/src/styles.css77
6 files changed, 156 insertions, 49 deletions
diff --git a/README.md b/README.md
index 7d00e3f..615a2ec 100755
--- a/README.md
+++ b/README.md
@@ -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);
+ }
+}