srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--web/index.html2
-rw-r--r--web/src/app.js3
-rw-r--r--web/src/logo.js46
-rw-r--r--web/src/screens/accountScreen.js3
-rw-r--r--web/src/screens/contributeScreen.js3
-rw-r--r--web/src/screens/endScreen.js3
-rw-r--r--web/src/screens/friendsScreen.js3
-rw-r--r--web/src/screens/leaderboardScreen.js3
-rw-r--r--web/src/screens/lyricsScreen.js3
-rw-r--r--web/src/screens/mainMenu.js22
-rw-r--r--web/src/screens/multiplayerScreen.js5
-rw-r--r--web/src/screens/passiveMode.js3
-rw-r--r--web/src/screens/placeholderScreen.js3
-rw-r--r--web/src/screens/publicProfileScreen.js3
-rw-r--r--web/src/screens/statsScreen.js3
-rw-r--r--web/src/screens/storeScreen.js3
-rw-r--r--web/src/screens/typingGame.js3
-rw-r--r--web/src/styles.css317
-rw-r--r--web/src/topRail.js89
-rw-r--r--web/tests/test_account_and_store.py5
20 files changed, 452 insertions, 73 deletions
diff --git a/web/index.html b/web/index.html
index 2533d25..3d390cb 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TyperPunk</title>
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Cpolyline points='6,9 14,16 6,23' fill='none' stroke='%2300ff9d' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3Crect x='17' y='20' width='10' height='3' fill='%2300ff9d'/%3E%3C/svg%3E">
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Cpolyline points='7,10 13,16 7,22' fill='none' stroke='%2300e5ff' stroke-width='3' stroke-linecap='square'/%3E%3Cpolyline points='7,9 13,15 7,21' fill='none' stroke='%2300ff9d' stroke-width='3' stroke-linecap='square'/%3E%3Crect x='6' y='24' width='20' height='2.5' fill='%2300ff9d'/%3E%3Crect x='16' y='15' width='9' height='9' fill='%2300ff9d'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
diff --git a/web/src/app.js b/web/src/app.js
index 923d122..4ef6c2e 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -153,7 +153,8 @@ export function startApp(root, localTexts) {
// Let an open popover close itself first - one Escape should not both
// dismiss a menu and navigate away from the screen behind it.
const openPopover = document.querySelector(
- '.mode-popover:not([hidden]), .rail-settings-panel:not([hidden]), .lang-popover:not([hidden]), .theme-gallery-popover:not([hidden])');
+ '.mode-popover:not([hidden]), .rail-settings-panel:not([hidden]), .settings-panel:not([hidden]), '
+ + '.custom-panel:not([hidden]), .lang-popover:not([hidden]), .theme-gallery-popover:not([hidden])');
if (openPopover) {
openPopover.hidden = true;
return;
diff --git a/web/src/logo.js b/web/src/logo.js
new file mode 100644
index 0000000..5a2f9d8
--- /dev/null
+++ b/web/src/logo.js
@@ -0,0 +1,46 @@
+// The TyperPunk mark.
+//
+// The mark is a prompt: a chevron, a baseline, and a block cursor resting on
+// it. It says "text being entered at a terminal" without spelling anything
+// out, and it stays legible at 16 pixels, which a more detailed drawing would
+// not. The chevron is drawn twice, the lower copy offset and in the secondary
+// colour, which is the one deliberate stylistic note.
+//
+// Everything is drawn in currentColor and the theme's own variables, so the
+// mark follows the palette rather than carrying its own.
+
+/// The mark on its own, for a favicon, a tab, or anywhere the name is already
+/// present. Sized by its container.
+export const LOGO_MARK = `
+<svg class="logo-mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
+ <g fill="none" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter">
+ <polyline class="logo-mark-ghost" points="7,10 13,16 7,22" />
+ <polyline class="logo-mark-chevron" points="7,9 13,15 7,21" />
+ </g>
+ <rect class="logo-mark-baseline" x="6" y="24" width="20" height="2.5" />
+ <rect class="logo-mark-cursor" x="16" y="15" width="9" height="9" />
+</svg>`;
+
+/// Mark and wordmark together. The wordmark is split so the two halves of the
+/// name carry different weight: "Typer" is what it does, "Punk" is what it
+/// looks like, and the accent colour goes on the second.
+export function logoLockup({ tag = 'div', className = 'logo', action = 'menu' } = {}) {
+ return `<${tag} class="${className}"${action ? ` data-action="${action}"` : ''}>
+ ${LOGO_MARK}
+ <span class="logo-word"><span class="logo-word-a">Typer</span><span class="logo-word-b">Punk</span></span>
+ </${tag}>`;
+}
+
+/// The same mark as a standalone SVG document, for the favicon. Colours are
+/// literal here because a favicon is rendered outside the page and cannot
+/// read its variables.
+export function faviconDataUri({ accent = '#00ff9d', ghost = '#00e5ff', background = '#000000' } = {}) {
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">`
+ + `<rect width="32" height="32" fill="${background}"/>`
+ + `<polyline points="7,10 13,16 7,22" fill="none" stroke="${ghost}" stroke-width="3" stroke-linecap="square"/>`
+ + `<polyline points="7,9 13,15 7,21" fill="none" stroke="${accent}" stroke-width="3" stroke-linecap="square"/>`
+ + `<rect x="6" y="24" width="20" height="2.5" fill="${accent}"/>`
+ + `<rect x="16" y="15" width="9" height="9" fill="${accent}"/>`
+ + `</svg>`;
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
+}
diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js
index 5f3bdfe..b65e2d6 100644
--- a/web/src/screens/accountScreen.js
+++ b/web/src/screens/accountScreen.js
@@ -1,4 +1,5 @@
import { CLOSE_ICON } from './icons.js';
+import { logoLockup } from '../logo.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -61,7 +62,7 @@ export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats,
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Account</h2>
<div class="account-panel">${formMarkup(user)}</div>
</div>
diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js
index 4c47852..b84ee40 100644
--- a/web/src/screens/contributeScreen.js
+++ b/web/src/screens/contributeScreen.js
@@ -1,4 +1,5 @@
import { escapeHtml } from '../util.js';
+import { logoLockup } from '../logo.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -72,7 +73,7 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Contribute a passage</h2>
${!user ? `
<div class="stats-empty">Sign in to submit a passage. Everything submitted is reviewed before anyone types it.</div>
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
index fa3b188..d9dc6fc 100644
--- a/web/src/screens/endScreen.js
+++ b/web/src/screens/endScreen.js
@@ -1,4 +1,5 @@
import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js';
+import { logoLockup } from '../logo.js';
import { escapeHtml } from '../util.js';
import { attachTooltips } from '../tooltip.js';
import { onThemeChange } from '../theme.js';
@@ -59,7 +60,7 @@ export function renderEndScreen(root, { stats, text, attribution, category, expl
root.innerHTML = `
<div class="end-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<div class="end-screen-text"><div class="text-display"></div></div>
<div class="attribution end-screen-attribution">&mdash; ${escapeHtml(attribution || 'Unknown')}${category ? `<span class="attribution-pack">${escapeHtml(category)}</span>` : ''}</div>
${explanation ? `<div class="code-explainer"><span class="code-explainer-label">What this does</span>${escapeHtml(explanation)}</div>` : ''}
diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js
index e121f4c..c19809e 100644
--- a/web/src/screens/friendsScreen.js
+++ b/web/src/screens/friendsScreen.js
@@ -1,4 +1,5 @@
import { CLOSE_ICON } from './icons.js';
+import { logoLockup } from '../logo.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -80,7 +81,7 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats,
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Friends</h2>
${user ? signedInMarkup() : signedOutMarkup()}
</div>
diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js
index 8aeda98..4eb0564 100644
--- a/web/src/screens/leaderboardScreen.js
+++ b/web/src/screens/leaderboardScreen.js
@@ -1,4 +1,5 @@
import { escapeHtml } from '../util.js';
+import { logoLockup } from '../logo.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -49,7 +50,7 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Leaderboard</h2>
<div class="leaderboard-modes">
${MODES.map(m => `<button class="menu-button small quiet${m.key === selectedMode ? ' active' : ''}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')}
diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js
index 95af6b2..b669891 100644
--- a/web/src/screens/lyricsScreen.js
+++ b/web/src/screens/lyricsScreen.js
@@ -1,4 +1,5 @@
import { CLOSE_ICON } from './icons.js';
+import { logoLockup } from '../logo.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -32,7 +33,7 @@ export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, o
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Lyrics</h2>
<div class="stats-placeholder">${escapeHtml(bodyText())}</div>
${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''}
diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js
index f812509..743f4b4 100644
--- a/web/src/screens/mainMenu.js
+++ b/web/src/screens/mainMenu.js
@@ -1,4 +1,5 @@
import { STATS_ICON, SETTINGS_ICON, CHEVRON_DOWN_ICON, LEADERBOARD_ICON, STORE_ICON } from './icons.js';
+import { logoLockup } from '../logo.js';
import { parseCustomContent } from '../customText.js';
import { escapeHtml } from '../util.js';
import { getSettings, updateSettings } from '../settings.js';
@@ -8,6 +9,13 @@ import { wordListTiers } from '../wordGenerator.js';
import { renderTopRail } from '../topRail.js';
import { renderSiteFooter } from '../siteFooter.js';
+/// Whether this is a development machine. The Dev controls below are shown
+/// only here: on a deployed site they are a way to fake a result.
+function isLocalDev() {
+ const h = window.location.hostname;
+ return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.local');
+}
+
function label(m) {
if (m === 'random') return 'Random';
if (m === 'words') return 'Words';
@@ -111,7 +119,7 @@ export function renderMainMenu(root, props) {
root.innerHTML = `
<div class="main-menu">
- <h1>TyperPunk</h1>
+ ${logoLockup({ tag: 'h1', className: 'logo logo-hero', action: null })}
<div class="menu-options">
<div class="start-group sp-group">
<button class="menu-button start-main" data-action="pick-mode">Single Player</button>
@@ -166,14 +174,16 @@ export function renderMainMenu(root, props) {
</div>
</div>
</div>
+ ${isLocalDev() ? `
<div class="settings-hint">Dev</div>
- <button class="menu-button small" data-action="simulate-test" data-tooltip="Jump straight to the end screen with fake results, no typing required.">Simulate Test &rarr; End Screen</button>
+ <button class="menu-button small" data-action="simulate-test" data-tooltip="Jump straight to the end screen with fake results, no typing required.">Simulate Test &rarr; End Screen</button>` : ''}
<button class="menu-button small quiet" data-action="close-settings">Close</button>
</div>
<div class="custom-panel" hidden>
- <div class="custom-hint">Paste text, notes, code, subtitles (.srt/.vtt), or lyrics (.lrc) - typed in order, not randomized. Drag a file in, or:</div>
- <textarea class="custom-textarea" placeholder="Paste text, notes, code, or lyrics here... (or drop a file anywhere in this box)"></textarea>
+ <div class="panel-title">Custom text</div>
+ <div class="custom-hint">Typed in the order you give it, not shuffled. Notes, code, subtitles (.srt, .vtt) and lyrics (.lrc) all work. Long documents are split into segments and keep your place between visits.</div>
+ <textarea class="custom-textarea" placeholder="Paste here, or drop a file anywhere in this box"></textarea>
<div class="custom-stats" hidden></div>
<div class="custom-panel-row">
<label class="menu-button small file-label">
@@ -202,8 +212,6 @@ export function renderMainMenu(root, props) {
<div class="custom-error"></div>
</div>
- <div class="logo">TyperPunk</div>
-
<div class="menu-key-hint">Enter to start &middot; Esc for the menu</div>
<div class="corner-rail-left">
@@ -462,7 +470,7 @@ export function renderMainMenu(root, props) {
soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`;
});
- root.querySelector('[data-action="simulate-test"]').addEventListener('click', onSimulateTest);
+ root.querySelector('[data-action="simulate-test"]')?.addEventListener('click', onSimulateTest);
const passiveBtn = root.querySelector('[data-action="start-passive"]');
if (passiveBtn) passiveBtn.addEventListener('click', onStartPassive);
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index a69080d..878f659 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -1,4 +1,5 @@
import { escapeHtml } from '../util.js';
+import { logoLockup } from '../logo.js';
import { CLOSE_ICON, RACER_SPRITES, RACER_SPRITE_IDS } from './icons.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -84,7 +85,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Multiplayer</h2>
<div class="account-panel">
<input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}">
@@ -172,7 +173,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>${auto ? 'Finding a race' : `Room ${escapeHtml(roomCode)}`}</h2>
<div class="settings-hint">${auto
? 'Racing whoever else is looking right now.'
diff --git a/web/src/screens/passiveMode.js b/web/src/screens/passiveMode.js
index e690d3c..d18334c 100644
--- a/web/src/screens/passiveMode.js
+++ b/web/src/screens/passiveMode.js
@@ -1,4 +1,5 @@
import { getTheme, toggleTheme, onThemeChange } from '../theme.js';
+import { logoLockup } from '../logo.js';
import { themeIcon } from './icons.js';
import { escapeHtml } from '../util.js';
@@ -44,7 +45,7 @@ export function renderPassiveMode(root, { name, chunks, onExit }) {
root.innerHTML = `
<div class="passive-mode">
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<div class="passive-source">${escapeHtml(name)} ยท passive mode</div>
<div class="passive-lines">
<div class="passive-line prev"></div>
diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js
index e03dabf..d632adb 100644
--- a/web/src/screens/placeholderScreen.js
+++ b/web/src/screens/placeholderScreen.js
@@ -1,4 +1,5 @@
import { CLOSE_ICON } from './icons.js';
+import { logoLockup } from '../logo.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -13,7 +14,7 @@ export function renderPlaceholderScreen(root, { title, description, onBack, onSh
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>${escapeHtml(title)}</h2>
<div class="stats-placeholder prose">${escapeHtml(description)}</div>
</div>
diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js
index 1479df6..da8b3fd 100644
--- a/web/src/screens/publicProfileScreen.js
+++ b/web/src/screens/publicProfileScreen.js
@@ -1,4 +1,5 @@
import { escapeHtml } from '../util.js';
+import { logoLockup } from '../logo.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -53,7 +54,7 @@ export function renderPublicProfileScreen(root, { username, onBack, onShowStats,
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2 class="public-profile-heading">${escapeHtml(username)}</h2>
<div class="public-profile-body"></div>
</div>
diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js
index 9b00181..b15bf51 100644
--- a/web/src/screens/statsScreen.js
+++ b/web/src/screens/statsScreen.js
@@ -1,4 +1,5 @@
import { CLOSE_ICON } from './icons.js';
+import { logoLockup } from '../logo.js';
import { getProfileStats } from '../profileStats.js';
import { getAllPersonalBests } from '../pb.js';
import { getWeakCharDetails } from '../keyStats.js';
@@ -42,7 +43,7 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Stats</h2>
${profile.testsCompleted > 0 ? `
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 7c9a047..5299582 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -1,4 +1,5 @@
import { escapeHtml } from '../util.js';
+import { logoLockup } from '../logo.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -168,7 +169,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
root.innerHTML = `
<div class="stats-screen">
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
<h2>Store</h2>
${bodyMarkup()}
</div>
diff --git a/web/src/screens/typingGame.js b/web/src/screens/typingGame.js
index 6e2ac66..b58e11f 100644
--- a/web/src/screens/typingGame.js
+++ b/web/src/screens/typingGame.js
@@ -1,4 +1,5 @@
import { calculateStats, emptyStats } from '../stats.js';
+import { logoLockup } from '../logo.js';
import { escapeHtml } from '../util.js';
import { highlightClasses } from '../customText.js';
import { getSettings } from '../settings.js';
@@ -15,7 +16,7 @@ export function renderTypingGame(root, { game, text, attribution, category, expl
const { hideLiveStats, caretBlink, blindMode } = getSettings();
root.innerHTML = `
<div class="typing-game${hideLiveStats ? ' hide-live-stats' : ''}${caretBlink ? '' : ' no-caret-blink'}">
- <div class="logo" data-action="menu">TyperPunk</div>
+ ${logoLockup()}
${progress ? `<div class="segment-progress">${escapeHtml(progress)}</div>` : ''}
<div class="text-container">
<div class="text-display${language ? ' code' : ''}"></div>
diff --git a/web/src/styles.css b/web/src/styles.css
index b47f21a..f51bb65 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -15,7 +15,12 @@
so nothing below has to know whether a banner exists. */
--ad-banner-height: 6.5rem;
--ad-banner-h: 0px;
- --header-offset: calc(3.5rem + var(--ad-banner-h));
+ /* 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: below the banner, below the rail, plus a gap. */
+ --header-offset: calc(1rem + var(--top-rail-h) + 0.75rem + var(--ad-banner-h));
/* 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
@@ -205,17 +210,87 @@ body {
overflow: hidden;
}
+/* The mark and wordmark together, fixed at the top-left of every screen. */
.logo {
position: fixed;
top: calc(1rem + var(--ad-banner-h));
left: 1rem;
- font-size: 1.2rem;
- color: var(--primary-color);
+ 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;
- transition: color 0.2s ease;
+ line-height: 1;
+}
+
+.logo-mark {
+ width: 1.5em;
+ height: 1.5em;
+ flex-shrink: 0;
+ overflow: visible;
+}
+
+.logo-mark-chevron,
+.logo-mark-baseline,
+.logo-mark-cursor {
+ stroke: var(--primary-color);
+ fill: var(--primary-color);
+}
+
+.logo-mark-chevron {
+ fill: none;
+}
+
+/* The offset copy underneath. Faint enough to read as a print misregistration
+ rather than a second chevron. */
+.logo-mark-ghost {
+ stroke: var(--secondary-color, var(--primary-color));
+ opacity: 0.55;
+}
+
+/* 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%, 45% { opacity: 1; }
+ 55%, 100% { opacity: 0.15; }
+}
+
+.logo-mark-cursor {
+ animation: logo-cursor-blink 1.6s steps(1, end) infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .logo-mark-cursor {
+ animation: none;
+ }
}
.logo:hover {
@@ -232,8 +307,10 @@ body {
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;
- padding-top: 6rem;
- height: 100vh;
+ /* 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;
@@ -244,12 +321,23 @@ body {
box-sizing: border-box;
}
-.main-menu h1 {
- font-size: 4rem;
- color: var(--primary-color);
+/* 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-hero {
+ position: static;
+ font-size: 3.25rem;
+ gap: 0.75rem;
margin-bottom: 2rem;
- text-transform: uppercase;
- letter-spacing: 2px;
+ cursor: default;
+}
+
+.logo-hero:hover .logo-word-a {
+ color: var(--text-color);
+}
+
+.logo-hero:hover .logo-word-b {
+ color: var(--primary-color);
}
.settings-row-group {
@@ -721,11 +809,15 @@ body {
were the same control under two names. .quiet is the one with a stated
role, and is defined with the rest of the hierarchy below. */
+/* Left aligned, and as wide as the panel. Centred at 340px inside a 480px
+ panel it broke into three ragged lines that were harder to read than the
+ sentence deserved. */
.custom-hint {
font-size: 0.8rem;
- color: var(--neutral-color);
- text-align: center;
- max-width: 340px;
+ line-height: 1.5;
+ color: var(--sub-color);
+ text-align: left;
+ width: 100%;
}
.settings-panel {
@@ -734,8 +826,6 @@ body {
display: flex;
flex-direction: column;
align-items: center;
- /* Tightened alongside the two-column split: .app is overflow:hidden, so
- anything past the viewport bottom is clipped rather than scrollable. */
gap: 0.55rem;
border: var(--border-width) solid var(--neutral-color);
border-radius: var(--radius-panel);
@@ -744,6 +834,54 @@ body {
text-align: center;
}
+/* The settings controls are buttons, but .quiet removes their border, so a
+ panel of them read as a list of plain text labels with no sign that any of
+ it could be clicked. Inside this panel they keep an outline. */
+.settings-panel .menu-button.quiet,
+.custom-panel .menu-button.quiet {
+ border-color: var(--border-color);
+ color: var(--text-color);
+}
+
+.settings-panel .menu-button.quiet:hover,
+.custom-panel .menu-button.quiet:hover {
+ border-color: var(--primary-color);
+}
+
+/* The checkbox was the browser's default control in a panel where nothing
+ else was. */
+.settings-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--sub-color);
+ cursor: pointer;
+}
+
+.settings-row input[type="checkbox"] {
+ appearance: none;
+ width: 1rem;
+ height: 1rem;
+ margin: 0;
+ /* A dark border on a dark panel was invisible. This is a control, so it
+ has to look like one before it is checked, not only after. */
+ border: var(--border-width) solid var(--sub-color);
+ border-radius: 2px;
+ background: transparent;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.settings-row input[type="checkbox"]:checked {
+ background: var(--primary-color);
+ border-color: var(--primary-color);
+}
+
+.settings-row:hover input[type="checkbox"] {
+ border-color: var(--primary-color);
+}
+
.settings-row {
display: flex;
align-items: center;
@@ -802,6 +940,15 @@ body {
.theme-swatch-preview[data-swatch="gruvbox"] { background: linear-gradient(90deg, #282828 60%, #fabd2f 60%); }
.theme-swatch-preview[data-swatch="monokai"] { background: linear-gradient(90deg, #272822 60%, #a6e22e 60%); }
+/* A dialog says what it is. Both of these opened with no title at all. */
+.panel-title {
+ font-size: 1rem;
+ color: var(--text-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ text-align: left;
+}
+
.custom-panel {
width: 100%;
max-width: 480px;
@@ -814,6 +961,52 @@ body {
margin: 1.5rem auto 0 auto;
}
+/* Both of these are dialogs, and neither was built as one.
+
+ They sat in the menu's normal flow, below the Start buttons, which put the
+ settings panel at y=506 with a height of 437 on a 900px viewport: it ran
+ 140px off the bottom, and .app is overflow:hidden so there was nothing to
+ scroll. Worse, neither had a background, so the fixed footer links rendered
+ through the middle of them and, being higher in the stacking order,
+ swallowed the clicks. Playwright could not press the settings panel's own
+ Close button: the footer intercepted every attempt.
+
+ Centred, opaque, above the fixed chrome, and scrolling internally when the
+ content is taller than the screen. */
+.settings-panel:not([hidden]),
+.custom-panel:not([hidden]) {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 120;
+ margin: 0;
+ max-height: min(82vh, 44rem);
+ overflow-y: auto;
+ background: var(--background-color);
+ border-color: var(--border-color);
+ box-sizing: border-box;
+ /* Dims the page behind without needing a separate backdrop element. */
+ box-shadow: 0 0 0 100vmax color-mix(in srgb, var(--background-color) 78%, transparent);
+}
+
+/* The settings controls are buttons, but .quiet removes their border, so a
+ panel of them read as a list of plain text labels with nothing to suggest
+ any of it could be clicked. Inside these panels they keep an outline. */
+.settings-panel .menu-button.quiet,
+.custom-panel .menu-button.quiet,
+.rail-settings-panel .menu-button.quiet {
+ border-color: var(--border-color);
+ color: var(--text-color);
+}
+
+.settings-panel .menu-button.quiet:hover,
+.custom-panel .menu-button.quiet:hover,
+.rail-settings-panel .menu-button.quiet:hover {
+ border-color: var(--primary-color);
+ color: var(--primary-color);
+}
+
.custom-textarea {
width: 100%;
min-height: 120px;
@@ -1207,14 +1400,25 @@ body {
/* How many friends are online, beside the Friends control in the top-right
rail. Readable text rather than a corner badge, so it can say what it
counts. */
+/* How many friends are online, under the Friends icon. A bare number: the
+ icon above it already says what is being counted. */
.online-count {
- font-size: 0.75rem;
- letter-spacing: 1px;
- text-transform: uppercase;
- color: var(--primary-color);
+ min-width: 1.1rem;
+ padding: 0 0.25rem;
+ border-radius: var(--radius-pill, 999px);
+ background: var(--primary-color);
+ color: var(--background-color);
+ font-size: 0.65rem;
+ font-weight: 700;
+ line-height: 1.25;
+ text-align: center;
white-space: nowrap;
}
+.online-count[hidden] {
+ display: none;
+}
+
.corner-rail-right {
position: fixed;
bottom: 1rem;
@@ -1297,8 +1501,8 @@ body {
/* Mobile adjustments */
@media (max-width: 768px) {
- .main-menu h1 {
- font-size: 2.5rem;
+ .logo-hero {
+ font-size: 2.1rem;
margin-bottom: 1.5rem;
}
@@ -2260,9 +2464,19 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
top: calc(1rem + var(--ad-banner-h));
right: 1rem;
z-index: 100;
+ /* Two rows: the icons, and the sign-in links under them. As one row the
+ two text links made the rail 307px wide, which is what collided with
+ the wordmark on a phone. */
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.25rem;
+}
+
+.top-rail-icons {
display: flex;
flex-direction: row;
- align-items: center;
+ align-items: flex-start;
gap: 0.6rem;
}
@@ -2339,7 +2553,43 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.auth-control {
display: flex;
align-items: center;
- gap: 0.4rem;
+ gap: 0.3rem;
+}
+
+.auth-control[hidden] {
+ display: none;
+}
+
+.top-rail-sep {
+ color: color-mix(in srgb, var(--sub-color) 55%, transparent);
+ font-size: 0.75rem;
+}
+
+/* Friends sits above its count, so the count does not widen the icon row. */
+.friends-control,
+.account-icon-control {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.15rem;
+}
+
+/* The account's own picture once signed in. There is no upload, so it is the
+ initial over a colour derived from the whole username. */
+.rail-avatar {
+ background: var(--avatar-colour, var(--primary-color));
+ border-color: transparent;
+ color: #fff;
+}
+
+.rail-avatar:hover {
+ border-color: var(--text-color);
+}
+
+.rail-avatar-initial {
+ font-size: 0.85rem;
+ font-weight: 700;
+ line-height: 1;
}
/* Text links, not bordered boxes - three outlined squares plus two outlined
@@ -2958,11 +3208,10 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
wordmark and Escape already did, and the least reachable of the three. */
.screen-close {
position: absolute;
- /* The top rail is fixed to the viewport's top-right and is wide enough to
- reach into the content column on any viewport narrower than about
- 1350px, which is most of them. At top: 0 the close button sat directly
- underneath the rail's buttons. It starts below the rail instead. */
- top: 1.5rem;
+ /* The content column already starts below the rail, so a small offset
+ inside it is enough. The rail's own height feeds --header-offset, which
+ is what puts this clear of it whether the rail has one row or two. */
+ top: 0.25rem;
right: 0;
width: 2.25rem;
height: 2.25rem;
@@ -3340,7 +3589,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
letter-spacing: 1px;
}
- .top-rail {
+ .top-rail-icons {
gap: 0.35rem;
}
@@ -3349,14 +3598,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
padding: 0.5rem 0.2rem;
}
- /* Sign Up goes. Sign In reaches the same screen, which offers both, so
- the second link only costs width. */
- .top-rail-link.accent {
- display: none;
- }
-
.screen-close {
- top: 2.75rem;
+ top: 0.25rem;
}
}
diff --git a/web/src/topRail.js b/web/src/topRail.js
index e8418f6..0373f5b 100644
--- a/web/src/topRail.js
+++ b/web/src/topRail.js
@@ -1,4 +1,4 @@
-import { LANGUAGE_ICON, FRIENDS_ICON } from './screens/icons.js';
+import { LANGUAGE_ICON, FRIENDS_ICON, ACCOUNT_ICON } from './screens/icons.js';
import { LANGUAGES, languageLabel } from './languages.js';
import { getSettings, updateSettings } from './settings.js';
import { renderThemeButton } from './themeButton.js';
@@ -14,12 +14,33 @@ import { escapeHtml } from './util.js';
// navigation. Screens with their own top-right controls (the typing screen's
// Restart and Zen Finish) pass them in as `extras` so everything shares one
// row instead of each fixed-positioning itself into a collision.
+/// The letter shown on an account's avatar. Falls back to a dot rather than
+/// an empty circle for a name that starts with something unprintable.
+function initialOf(username) {
+ const ch = (username || '').trim()[0];
+ return ch ? ch.toUpperCase() : '\u00b7';
+}
+
+/// A stable colour per account, from a hash of the whole name rather than the
+/// first letter, so two people whose names start alike still look different.
+function avatarColour(username) {
+ let hash = 0;
+ for (const ch of username || '') hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
+ return `hsl(${hash % 360} 65% 45%)`;
+}
+
export function renderTopRail(root, { onShowAccount, onShowFriends, extras = [] } = {}) {
const wrap = document.createElement('div');
wrap.className = 'top-rail';
root.appendChild(wrap);
- for (const el of extras) wrap.appendChild(el);
+ // Row one holds every icon. Row two holds the sign-in links, which used
+ // to sit inline and made the row far wider than the icons needed.
+ const icons = document.createElement('div');
+ icons.className = 'top-rail-icons';
+ wrap.appendChild(icons);
+
+ for (const el of extras) icons.appendChild(el);
const langGroup = document.createElement('div');
langGroup.className = 'lang-group';
@@ -33,17 +54,25 @@ export function renderTopRail(root, { onShowAccount, onShowFriends, extras = []
<div class="lang-note">Applies to Words, Timed, Zen and Practice.</div>
</div>
`;
- wrap.appendChild(langGroup);
+ icons.appendChild(langGroup);
- const cleanupTheme = renderThemeButton(wrap);
+ const cleanupTheme = renderThemeButton(icons);
const friends = document.createElement('div');
friends.className = 'friends-control';
if (onShowFriends) {
+ // The count sits under the icon rather than beside it, so it does not
+ // widen the row and reads as a property of the button above it.
friends.innerHTML = `<button class="corner-icon-button" data-action="rail-friends" aria-label="Friends" data-tooltip="Friends">${FRIENDS_ICON}</button>
<span class="online-count" data-badge="friends-online" hidden></span>`;
}
- wrap.appendChild(friends);
+ icons.appendChild(friends);
+
+ // The account control, immediately right of Friends: your identity and
+ // who it connects you to, in that order.
+ const accountIcon = document.createElement('div');
+ accountIcon.className = 'account-icon-control';
+ icons.appendChild(accountIcon);
const auth = document.createElement('div');
auth.className = 'auth-control';
@@ -76,17 +105,52 @@ export function renderTopRail(root, { onShowAccount, onShowFriends, extras = []
// Identity ------------------------------------------------------------
function paintAuth() {
const user = getUser();
+
+ // Signed out it is a plain person icon; signed in it is the account's
+ // own picture. There is no avatar upload, so the picture is built
+ // from the name: the initial over a colour derived from the whole
+ // username, which gives every account a stable, distinct mark.
+ accountIcon.innerHTML = user
+ ? `<button class="corner-icon-button rail-avatar" data-action="account" aria-label="Your account" data-tooltip="Signed in as ${escapeHtml(user.username)}">
+ <span class="rail-avatar-initial">${escapeHtml(initialOf(user.username))}</span>
+ </button>`
+ : `<button class="corner-icon-button" data-action="account" aria-label="Account" data-tooltip="Sign in to save stats and join the leaderboard">${ACCOUNT_ICON}</button>`;
+ const avatarBtn = accountIcon.querySelector('.rail-avatar');
+ if (avatarBtn) avatarBtn.style.setProperty('--avatar-colour', avatarColour(user.username));
+
+ // Signed in, the name is already on the avatar's tooltip, so the row
+ // below is only needed while signed out.
+ auth.hidden = !!user;
auth.innerHTML = user
- ? `<button class="top-rail-link signed-in" data-action="account" data-tooltip="Your account">${escapeHtml(user.username)}</button>`
+ ? ''
: `<button class="top-rail-link" data-action="signin" data-tooltip="Sign in to save stats and join the leaderboard">Sign In</button>
+ <span class="top-rail-sep">|</span>
<button class="top-rail-link accent" data-action="signup" data-tooltip="Create an account">Sign Up</button>`;
attachTooltips(auth);
- auth.querySelectorAll('button').forEach(btn => {
- btn.addEventListener('click', () => onShowAccount?.());
- });
+ attachTooltips(accountIcon);
+ [...auth.querySelectorAll('button'), ...accountIcon.querySelectorAll('button')]
+ .forEach(btn => btn.addEventListener('click', () => onShowAccount?.()));
+ }
+ // The rail is one row when signed in and two when signed out, so its
+ // height is not a constant the stylesheet can hold. Everything that has to
+ // start below it - the content column, and the close button at that
+ // column's top-right - reads this. Without it the close button sat under
+ // the rail again the moment the rail grew a second row.
+ function publishHeight() {
+ const h = Math.round(wrap.getBoundingClientRect().height);
+ if (h > 0) document.documentElement.style.setProperty('--top-rail-h', `${h}px`);
}
- paintAuth();
- const unsubscribeAuth = onAuthChange(paintAuth);
+
+ function paintAuthAndMeasure() {
+ paintAuth();
+ // After layout, not during: the row has to exist before it can be
+ // measured.
+ requestAnimationFrame(publishHeight);
+ }
+
+ paintAuthAndMeasure();
+ const unsubscribeAuth = onAuthChange(paintAuthAndMeasure);
+ window.addEventListener('resize', publishHeight);
// Friends ------------------------------------------------------------
const friendsBtn = friends.querySelector('[data-action="rail-friends"]');
@@ -102,7 +166,7 @@ export function renderTopRail(root, { onShowAccount, onShowFriends, extras = []
}
if (!friendsOnlineEl) return;
friendsOnlineEl.hidden = !friendsOnline;
- friendsOnlineEl.textContent = `${friendsOnline} online`;
+ friendsOnlineEl.textContent = String(friendsOnline);
}
paintCounts(getCounts());
const unsubscribeCounts = onCountsChange(paintCounts);
@@ -113,6 +177,7 @@ export function renderTopRail(root, { onShowAccount, onShowFriends, extras = []
document.addEventListener('click', handleOutsideClick);
return () => {
+ window.removeEventListener('resize', publishHeight);
unsubscribeCounts();
document.removeEventListener('click', handleOutsideClick);
unsubscribeAuth();
diff --git a/web/tests/test_account_and_store.py b/web/tests/test_account_and_store.py
index 9252cdb..5668e2b 100644
--- a/web/tests/test_account_and_store.py
+++ b/web/tests/test_account_and_store.py
@@ -17,7 +17,10 @@ def run():
page = browser.new_page(viewport={"width": 1280, "height": 900})
username = random_username("store")
register_and_login(page, username)
- assert page.locator('.auth-control button').count() > 0
+ # Signed in, the identity control is the account's own avatar in
+ # the icon row. The Sign In and Sign Up links below it are gone.
+ assert page.locator('.rail-avatar').count() > 0, "no avatar after signing in"
+ assert page.locator('.auth-control button').count() == 0, "sign-in links still shown while signed in"
page.click('[data-action="store"]')
page.wait_for_selector(".store-item-row", timeout=10000)