From d7c1c7b35e9d0995eed41d437404d5255529cf17 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:23:00 +0200 Subject: Give TyperPunk a mark, and fix the settings and custom text dialogs Branding. The name was set in the body typeface and that was the whole of it. There is a mark now: a chevron, a baseline, and a block cursor resting on it, which reads as text being entered at a prompt and stays legible at 16 pixels. The chevron is drawn twice, the lower copy offset and in the secondary colour, which is the one stylistic note. The wordmark is split so the accent falls on the second half. Everything is drawn in the theme's own variables, so the mark follows the palette rather than carrying its own, and the favicon is the same drawing. The menu carried the name twice, as its own title and as the fixed corner mark. The corner mark exists for screens with no title of their own, so the menu keeps only the title, now the full lockup at size. The settings panel. Measured at 1280x900 it opened at y=506 with a height of 437, so it ran 140px past the bottom of a 900px window, and .app is overflow:hidden so there was nothing to scroll. It had no background either, so the fixed footer links rendered through the middle of it and, sitting higher in the stacking order, took the clicks: Playwright could not press the panel's own Close button because the footer intercepted every attempt. Both it and the custom text panel are dialogs now. Centred, opaque, above the fixed chrome, scrolling internally, with the page dimmed behind them. Their controls are buttons, but .quiet removes a button's border, so a panel of them read as a list of plain text labels with nothing to suggest any of it could be clicked. They keep an outline inside these panels. The checkbox was the browser's default control in a panel where nothing else was, and its border was too dark to see against the panel. Escape did not close either of them. The handler looked for .rail-settings-panel, which is a different panel on a different screen. A Dev section with a button that jumps straight to the results screen with invented figures was shipping to every visitor. It is limited to localhost. The top rail. The account control has moved into the icon row, right of Friends, and is the account's own picture once signed in: there is no avatar upload, so it is the initial over a colour hashed from the whole username, which gives every account a stable and distinct mark. Sign In and Sign Up sit on their own row beneath. The friends count is a badge under its icon rather than the words "2 online" beside it. That took the rail from 307px wide to 189px, which is what had been overlapping the wordmark by 75px at 390px. But it also made the rail taller, which put the close button back underneath it, so the rail now publishes its measured height and the content column starts below whatever that is. The rail is one row signed in and two signed out, so this is not a constant the stylesheet could hold. The wordmark was inline-flex, which blockified its two halves, so the name read as two lines to innerText: a screen reader, a copy-paste, or a test. The advertising slot has moved from inside the results screen to a banner across the top of the page, where a navigation bar usually goes. The header moves down to make room rather than being covered by it. It stays off the typing screen, the passive reader and the race lobby. Custom text: the panel had no title, and its hint was centred at 340px inside a 480px panel, which broke one sentence into three ragged lines. 3 browser tests pass. The store test now asserts the avatar replaces the sign-in links rather than expecting a button that has moved. --- web/src/topRail.js | 89 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 12 deletions(-) (limited to 'web/src/topRail.js') 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 = []
Applies to Words, Timed, Zen and Practice.
`; - 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 = ` `; } - 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 + ? `` + : ``; + 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 - ? `` + ? '' : ` + | `; 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(); -- cgit v1.2.3