srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--web/index.html2
-rw-r--r--web/src/adSlot.js8
-rw-r--r--web/src/cornerRail.js14
-rw-r--r--web/src/logo.js36
-rw-r--r--web/src/screens/icons.js15
-rw-r--r--web/src/screens/mainMenu.js16
-rw-r--r--web/src/settings.js6
-rw-r--r--web/src/styles.css120
-rw-r--r--web/src/topRail.js31
9 files changed, 161 insertions, 87 deletions
diff --git a/web/index.html b/web/index.html
index 3d390cb..755b983 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='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="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'%3E%3Crect width='28' height='28' fill='%23000'/%3E%3Cpolyline points='4,7 11,14 4,21' fill='none' stroke='%2300ff9d' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Crect x='15' y='17.6' width='9' height='3.4' rx='0.6' 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/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 &middot; 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);