diff options
Diffstat (limited to 'web/src')
| -rw-r--r-- | web/src/adSlot.js | 8 | ||||
| -rw-r--r-- | web/src/cornerRail.js | 14 | ||||
| -rw-r--r-- | web/src/logo.js | 36 | ||||
| -rw-r--r-- | web/src/screens/icons.js | 15 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 16 | ||||
| -rw-r--r-- | web/src/settings.js | 6 | ||||
| -rw-r--r-- | web/src/styles.css | 120 | ||||
| -rw-r--r-- | web/src/topRail.js | 31 |
8 files changed, 160 insertions, 86 deletions
diff --git a/web/src/adSlot.js b/web/src/adSlot.js index b87ae4f..75a0d31 100644 --- a/web/src/adSlot.js +++ b/web/src/adSlot.js @@ -29,8 +29,12 @@ import { getUser, onAuthChange } from './auth.js'; // own name. const SIZES = { - // Roughly a leaderboard unit, and a mobile banner below the breakpoint. - banner: { w: 728, h: 90, mobileW: 320, mobileH: 100 }, + // A full banner, and the mobile banner below the breakpoint. Both are + // standard units. The taller 728x90 leaderboard was used first and is the + // more common desktop size, but at 90px it was deeper than the icon rail + // beside it, so it set how far down the whole page started. At 60px it is + // shorter than the rail and costs the layout nothing. + banner: { w: 468, h: 60, mobileW: 320, mobileH: 50 }, // A wide skyscraper, the usual unit for a page margin. skyscraper: { w: 160, h: 600, mobileW: 160, mobileH: 600 }, }; diff --git a/web/src/cornerRail.js b/web/src/cornerRail.js index 3fa0268..fd7a1e7 100644 --- a/web/src/cornerRail.js +++ b/web/src/cornerRail.js @@ -2,12 +2,6 @@ import { STATS_ICON, SETTINGS_ICON, LEADERBOARD_ICON, STORE_ICON } from './scree import { attachTooltips } from './tooltip.js'; import { getSettings, updateSettings } from './settings.js'; -const SOUND_THEMES = ['off', 'click', 'mech']; -function soundThemeLabel(theme) { - if (theme === 'click') return 'Click'; - if (theme === 'mech') return 'Mechanical'; - return 'Off'; -} // The icon rails, in the same places on every screen - the main menu builds // the identical clusters inline (see mainMenu.js) because it owns its own @@ -31,7 +25,6 @@ export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowL <button class="menu-button small quiet" data-action="rail-toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button> <button class="menu-button small quiet" data-action="rail-toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button> <button class="menu-button small quiet" data-action="rail-toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button> - <button class="menu-button small quiet" data-action="rail-cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${soundThemeLabel(settings.soundTheme)}</button> </div> </div> </div> @@ -78,13 +71,6 @@ export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowL updateSettings({ blindMode: next }); blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`; }); - const soundThemeBtn = left.querySelector('[data-action="rail-cycle-sound-theme"]'); - soundThemeBtn.addEventListener('click', () => { - const current = getSettings().soundTheme; - const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length]; - updateSettings({ soundTheme: next }); - soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`; - }); const leaderboardBtn = right.querySelector('[data-action="rail-leaderboard"]'); if (leaderboardBtn) leaderboardBtn.addEventListener('click', onShowLeaderboard); diff --git a/web/src/logo.js b/web/src/logo.js index 5a2f9d8..7cc689d 100644 --- a/web/src/logo.js +++ b/web/src/logo.js @@ -1,10 +1,14 @@ // 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. +// The mark is a shell prompt: a chevron and the underscore cursor that sits +// after it, waiting for input. Two shapes, nothing else. It reads at 16 +// pixels, which is the only size that really has to work, and it is the +// gesture anyone who has used a terminal recognises without being told. +// +// An earlier version stacked a chevron, a full-width baseline and a large +// filled block, plus an offset ghost copy of the chevron behind it. Five +// shapes in a 32 pixel square is a drawing, not a mark: the parts crowded +// each other and none of them read. // // Everything is drawn in currentColor and the theme's own variables, so the // mark follows the palette rather than carrying its own. @@ -12,13 +16,11 @@ /// 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" /> +<svg class="logo-mark" viewBox="0 0 28 28" aria-hidden="true" focusable="false"> + <g class="logo-mark-chevron" fill="none" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"> + <polyline points="4,7 11,14 4,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" /> + <rect class="logo-mark-cursor" x="15" y="17.6" width="9" height="3.4" rx="0.6" /> </svg>`; /// Mark and wordmark together. The wordmark is split so the two halves of the @@ -34,13 +36,11 @@ export function logoLockup({ tag = 'div', className = 'logo', action = 'menu' } /// 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}"/>` +export function faviconDataUri({ accent = '#00ff9d', background = '#000000' } = {}) { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">` + + `<rect width="28" height="28" fill="${background}"/>` + + `<polyline points="4,7 11,14 4,21" fill="none" stroke="${accent}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>` + + `<rect x="15" y="17.6" width="9" height="3.4" rx="0.6" fill="${accent}"/>` + `</svg>`; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js index 462c6b0..c42d57c 100644 --- a/web/src/screens/icons.js +++ b/web/src/screens/icons.js @@ -149,3 +149,18 @@ Object.assign(FLAIR_ICONS, { moth: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4L4 8L6 16L12 13Z"/><path d="M12 4L20 8L18 16L12 13Z"/><rect x="11" y="4" width="2" height="16"/></svg>`, reactor: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L21 20H3Z"/><rect x="10" y="12" width="4" height="5" fill="var(--background-color)"/></svg>`, }); + +// A speaker, and the same speaker with the waves struck through. Two icons +// rather than one that changes colour: whether sound is on has to be readable +// at a glance without comparing it to anything. +export const SOUND_ON_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <polygon points="4,9 8,9 13,5 13,19 8,15 4,15" fill="currentColor" stroke="none"/> + <path d="M16.5 8.5a5 5 0 0 1 0 7"/> + <path d="M19 6a8.5 8.5 0 0 1 0 12"/> +</svg>`; + +export const SOUND_OFF_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <polygon points="4,9 8,9 13,5 13,19 8,15 4,15" fill="currentColor" stroke="none"/> + <line x1="16" y1="9" x2="21" y2="15"/> + <line x1="21" y1="9" x2="16" y2="15"/> +</svg>`; diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js index 743f4b4..759bf6d 100644 --- a/web/src/screens/mainMenu.js +++ b/web/src/screens/mainMenu.js @@ -62,12 +62,6 @@ function looksLikeText(str) { const WORD_COUNTS = [10, 25, 50, 100]; const TIME_DURATIONS = [15, 30, 60, 120]; -const SOUND_THEMES = ['off', 'click', 'mech']; -function soundThemeLabel(theme) { - if (theme === 'click') return 'Click'; - if (theme === 'mech') return 'Mechanical'; - return 'Off'; -} function wordListTierLabel(tier) { if (tier === 'extended') return 'Extended'; @@ -170,7 +164,6 @@ export function renderMainMenu(root, props) { <button class="menu-button small quiet" data-action="toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button> <button class="menu-button small quiet" data-action="toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button> <button class="menu-button small quiet" data-action="toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button> - <button class="menu-button small quiet" data-action="cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${escapeHtml(soundThemeLabel(settings.soundTheme))}</button> </div> </div> </div> @@ -212,6 +205,8 @@ export function renderMainMenu(root, props) { <div class="custom-error"></div> </div> + ${logoLockup({ className: 'logo logo-corner', action: null })} + <div class="menu-key-hint">Enter to start · Esc for the menu</div> <div class="corner-rail-left"> @@ -462,13 +457,6 @@ export function renderMainMenu(root, props) { updateSettings({ blindMode: next }); blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`; }); - const soundThemeBtn = root.querySelector('[data-action="cycle-sound-theme"]'); - soundThemeBtn.addEventListener('click', () => { - const current = getSettings().soundTheme; - const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length]; - updateSettings({ soundTheme: next }); - soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`; - }); root.querySelector('[data-action="simulate-test"]')?.addEventListener('click', onSimulateTest); diff --git a/web/src/settings.js b/web/src/settings.js index 37e10f0..d51bdaa 100644 --- a/web/src/settings.js +++ b/web/src/settings.js @@ -19,7 +19,11 @@ let settings = { caretBlink: true, hideLiveStats: false, blindMode: false, - soundTheme: 'off', + // On by default. A browser will not start an audio context before a user + // gesture, and the first keystroke is one, so the first tone plays with + // the first character typed rather than being swallowed. Anyone who does + // not want it has the speaker in the top rail. + soundTheme: 'mech', wordListTier: 'common', // Which vocabulary the generated-word modes draw from (see languages.js). language: 'en', diff --git a/web/src/styles.css b/web/src/styles.css index ee132d9..375f257 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -16,14 +16,15 @@ 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: 6.5rem; + --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: below the banner, below the rail, plus a gap. */ - --header-offset: calc(1rem + var(--top-rail-h) + 0.75rem + var(--ad-banner-h)); + /* 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 @@ -216,7 +217,7 @@ body { /* The mark and wordmark together, fixed at the top-left of every screen. */ .logo { position: fixed; - top: calc(1rem + var(--ad-banner-h)); + top: 1rem; left: 1rem; display: flex; align-items: center; @@ -236,22 +237,13 @@ body { overflow: visible; } -.logo-mark-chevron, -.logo-mark-baseline, -.logo-mark-cursor { - stroke: var(--primary-color); - fill: var(--primary-color); -} - .logo-mark-chevron { + stroke: var(--primary-color); 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; +.logo-mark-cursor { + fill: var(--primary-color); } /* Plain inline, not a flex container. As inline-flex the two halves became @@ -282,16 +274,19 @@ body { /* 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; } + 0%, 50% { opacity: 1; } + 50.01%, 100% { opacity: 0; } } -.logo-mark-cursor { - animation: logo-cursor-blink 1.6s steps(1, end) infinite; +/* 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-mark-cursor { + .logo-hero .logo-mark-cursor { animation: none; } } @@ -327,12 +322,48 @@ body { /* 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; - font-size: 3.25rem; - gap: 0.75rem; + /* 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 { @@ -2495,7 +2526,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { themselves into it. */ .top-rail { position: fixed; - top: calc(1rem + var(--ad-banner-h)); + top: 1rem; right: 1rem; z-index: 100; /* Two rows: the icons, and the sign-in links under them. As one row the @@ -2982,6 +3013,13 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { on :active, so a click on a button that opens a panel elsewhere on screen gave no feedback at the point of contact. */ .menu-button:active:not(:disabled), +/* A rail control that is switched on. The sound toggle is the only one where + the state is not obvious from the icon alone at a glance. */ +.corner-icon-button.active { + border-color: var(--primary-color); + color: var(--primary-color); +} + .corner-icon-button:active, .end-screen-button:active { transform: translateY(1px); @@ -3879,19 +3917,19 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { letter-spacing: 1px; } +/* The unit itself, centred at the top of the page. Not a full-width bar: as + one it read as a second chrome layer stretching past the content on both + sides, and it pushed the wordmark and the icon rail down out of the corners + they belong in. On a wide window all three share the top line. */ .ad-banner-top { position: fixed; - top: 0; - left: 0; - right: 0; - height: var(--ad-banner-height); - z-index: 90; + top: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 85; display: flex; align-items: center; justify-content: center; - background: var(--background-color); - border-bottom: var(--border-width) solid var(--border-color); - box-sizing: border-box; } .ad-banner-top[hidden] { @@ -3902,9 +3940,6 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { display: flex; align-items: center; justify-content: center; - width: 100%; - padding: 0 1rem; - box-sizing: border-box; } /* No top margin in the banner: the bar centres it already. */ @@ -3926,6 +3961,19 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { box-sizing: border-box; } +/* Below this the wordmark, the unit and the icon rail do not fit on one line, + so the banner drops beneath the header row instead of colliding with it, + and the content column clears both. */ +@media (max-width: 980px) { + .ad-banner-top { + top: calc(1rem + var(--top-rail-h) + 0.5rem); + } + + :root { + --header-offset: calc(1rem + var(--top-rail-h) + 0.5rem + var(--ad-banner-h) + 0.75rem); + } +} + /* The page margins. Hidden by default and shown only on a window wide enough to hold them beside the content without crowding it: the widest content column is 1040px, and two 160px rails with gaps need 1424px before they fit @@ -3977,7 +4025,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { } :root { - --ad-banner-height: 7rem; + --ad-banner-height: 3.125rem; } } diff --git a/web/src/topRail.js b/web/src/topRail.js index 0373f5b..6d8c8dc 100644 --- a/web/src/topRail.js +++ b/web/src/topRail.js @@ -1,4 +1,4 @@ -import { LANGUAGE_ICON, FRIENDS_ICON, ACCOUNT_ICON } from './screens/icons.js'; +import { LANGUAGE_ICON, FRIENDS_ICON, ACCOUNT_ICON, SOUND_ON_ICON, SOUND_OFF_ICON } from './screens/icons.js'; import { LANGUAGES, languageLabel } from './languages.js'; import { getSettings, updateSettings } from './settings.js'; import { renderThemeButton } from './themeButton.js'; @@ -58,6 +58,12 @@ export function renderTopRail(root, { onShowAccount, onShowFriends, extras = [] const cleanupTheme = renderThemeButton(icons); + // Keystroke sound. It was a line inside the settings dialog and off by + // default, so the usual way to discover it was to be told it existed. + const sound = document.createElement('div'); + sound.className = 'sound-control'; + icons.appendChild(sound); + const friends = document.createElement('div'); friends.className = 'friends-control'; if (onShowFriends) { @@ -152,6 +158,29 @@ export function renderTopRail(root, { onShowAccount, onShowFriends, extras = [] const unsubscribeAuth = onAuthChange(paintAuthAndMeasure); window.addEventListener('resize', publishHeight); + // Sound --------------------------------------------------------------- + // On by default, set to the mechanical tone. The speaker here is how it + // gets turned off, which is why it is in the rail rather than buried in + // the settings dialog where it used to live. + const SOUND_THEMES = ['off', 'click', 'mech']; + const SOUND_LABELS = { off: 'Off', click: 'Click', mech: 'Mechanical' }; + + function paintSound() { + const current = getSettings().soundTheme || 'off'; + const on = current !== 'off'; + sound.innerHTML = `<button class="corner-icon-button${on ? ' active' : ''}" data-action="rail-sound" + aria-label="Keystroke sound: ${SOUND_LABELS[current]}" + data-tooltip="Keystroke sound: ${SOUND_LABELS[current]}. Click to cycle.">${on ? SOUND_ON_ICON : SOUND_OFF_ICON}</button>`; + attachTooltips(sound); + sound.querySelector('button').addEventListener('click', () => { + const now = getSettings().soundTheme || 'off'; + const next = SOUND_THEMES[(SOUND_THEMES.indexOf(now) + 1) % SOUND_THEMES.length]; + updateSettings({ soundTheme: next }); + paintSound(); + }); + } + paintSound(); + // Friends ------------------------------------------------------------ const friendsBtn = friends.querySelector('[data-action="rail-friends"]'); if (friendsBtn) friendsBtn.addEventListener('click', onShowFriends); |