diff --git a/public/css/components.css b/public/css/components.css index e074e77f..6eae3bb8 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -1030,6 +1030,12 @@ .topbar__center { display: flex; flex-direction: column; align-items: center; gap: .03rem; text-align: center; } .topbar__title { display: flex; align-items: center; gap: .18rem; font-size: calc(.3 * var(--t)); font-weight: 700; letter-spacing: .1em; line-height: 1.2; } .topbar__sep { width: 1px; height: .28rem; background: var(--line-2); } +/* the lobby's ⚙ / 📖 / install squares: a size up from `sm` and spaced apart; on touch screens at least 40 px each with + a 10 px gap, so their 44 px hit areas (css/devices.css) never overlap and a tap lands on the intended one */ +.topbar__tools { display: flex; align-items: center; gap: .14rem; } +.topbar__tools .btn--square.btn--sm { --sq: .46rem; height: var(--sq); min-width: var(--sq); } +.sp-coarse .topbar__tools { gap: max(.16rem, 10px); } +.sp-coarse .topbar__tools .btn--square.btn--sm { --sq: max(.56rem, 40px); } .topbar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; } .section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: calc(.2 * var(--t)); font-weight: 700; letter-spacing: .08em; } @@ -1038,6 +1044,15 @@ .me-chip { display: flex; align-items: center; gap: .12rem; padding: .06rem .18rem .06rem .06rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); } .me-chip__text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; } .me-chip__name { font-weight: 700; font-size: calc(.18 * var(--t)); max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* the lobby's chip doubles as the 统计数据 entry */ +.me-chip--btn { + font: inherit; color: inherit; text-align: left; cursor: pointer; + transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out); +} +.me-chip--btn:hover { border-color: var(--mint-700); background: rgba(34, 42, 38, .6); } +.me-chip--btn:active { transform: translateY(1px); } +.me-chip__stats { flex: none; width: .2rem; height: .2rem; margin-left: .04rem; color: var(--mint-500); opacity: .7; } +.me-chip--btn:hover .me-chip__stats { opacity: 1; } .crash { align-items: center; justify-content: center; } .crash__box { diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 53e58a5a..190970de 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -520,6 +520,9 @@ inside the label, so the label lays glyph and text out like the button itself */ .settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; } .settings-btn .btn__icon { color: var(--mint-500); } +/* square (the lobby's gear alone): the label box drops out so the gear sizes and centres against the button, like an `icon` */ +.settings-btn.btn--square .btn__label { display: contents; } +.settings-btn.btn--square .btn__icon { width: 62%; height: 62%; } /* the gear glyph is drawn smaller in its box than the book */ .set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; } .set-row { display: grid; grid-template-columns: minmax(0, 3.3rem) minmax(0, 1fr) auto; align-items: center; gap: .18rem; min-height: .44rem; } /* The label column may shrink (the label wraps) instead of pushing its control out of the modal: at 设置 →「文字大小」 diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js index 902648c0..71777520 100644 --- a/public/js/screens/lobby.js +++ b/public/js/screens/lobby.js @@ -309,25 +309,28 @@ export function LobbyScreen() {
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')} <${PingPill} ms=${conn.ping} online=${online} /> + +
+ <${SettingsButton} class="lobby-settings" variant="secondary" square=${true} /> + <${GuideButton} class="lobby-guide" variant="secondary" square=${true} /> + <${PwaInstallButton} class="lobby-pwa" square=${true} /> +
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT

${t('选择模拟协议')}

- <${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')} <${ResumeMatchButton} /> - <${PwaInstallButton} class="lobby-pwa" /> - <${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} /> - <${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} /> <${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} /> -
+
+ + <${Icon} name="chart" class="me-chip__stats" /> +
diff --git a/public/js/ui/components.js b/public/js/ui/components.js index ea56ad73..ec4d57aa 100644 --- a/public/js/ui/components.js +++ b/public/js/ui/components.js @@ -76,6 +76,8 @@ export const ICONS = { wifiOff: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3zM2.4 1 23 21.6 21.6 23 1 2.4z' }, expand: { d: 'M3 3h7v2H5v5H3zm11 0h7v7h-2V5h-5zM3 14h2v5h5v2H3zm16 0h2v7h-7v-2h5z' }, collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' }, + // 添加到桌面 (add to home screen): a house outline with a + inside + homePlus: { d: 'M12 2 22 10.5V21H2V10.5zm0 2.6-8 6.8V19h16v-7.6zM11 10h2v2.5h2.5v2H13V17h-2v-2.5H8.5v-2H11z', eo: true }, rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true }, book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true }, // 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye') diff --git a/public/js/ui/device.js b/public/js/ui/device.js index e61219db..42905938 100644 --- a/public/js/ui/device.js +++ b/public/js/ui/device.js @@ -163,13 +163,17 @@ export function FullscreenButton({ class: cls = '' }) { `; } -/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */ -export function PwaInstallButton({ class: cls = '', size = 'sm' }) { +/** + * Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. + * `square`: the glyph alone (the lobby's top bar, beside ⚙ / 📖), the name stays title / aria-label. + */ +export function PwaInstallButton({ class: cls = '', size = 'sm', square = false }) { const [available, setAvailable] = useState(pwaInstall.available); useEffect(() => pwaInstall.subscribe(setAvailable), []); if (!available) return null; - return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls} - data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}`; + return html`<${Button} variant="secondary" size=${size} icon="homePlus" square=${square} class=${cls} + title=${t('添加到桌面')} aria-label=${t('添加到桌面')} + data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${square ? null : t('添加到桌面')}`; } export function updateRotateHintPwaI18n(doc = globalThis.document) { diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js index 1ab6cffe..313f54e3 100644 --- a/public/js/ui/settings.js +++ b/public/js/ui/settings.js @@ -289,11 +289,12 @@ export function SettingsModal({ open, onClose }) { * The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title * screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it * (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only. - * @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props + * `square`: the gear alone (the lobby's top bar), the name stays title / aria-label — like GuideButton's. + * @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string, square?: boolean }} props */ -export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) { +export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置'), square = false }) { const [open, setOpen] = useState(false); - return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)} - title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label} + return html`<${Button} variant=${variant} size=${size} square=${square} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)} + title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${square ? null : label} ${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`; } diff --git a/test/ui/settings-entry.e2e.test.js b/test/ui/settings-entry.e2e.test.js index 71979c2b..f81374ec 100644 --- a/test/ui/settings-entry.e2e.test.js +++ b/test/ui/settings-entry.e2e.test.js @@ -36,7 +36,7 @@ describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'se const settingsBeforeGuide = (page, screen, guideClass) => page.evaluate((s, g) => { const btn = document.querySelector(`${s} [data-testid="settings-btn"]`); const guide = document.querySelector(`${s} .${g}`); - return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent) && btn.querySelector('svg.btn__icon') != null; + return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent || btn.getAttribute('aria-label')) && btn.querySelector('svg.btn__icon') != null; }, screen, guideClass); const modalText = (page) => page.evaluate(() => document.querySelector('.modal')?.textContent.replace(/\s+/g, ' ').trim() ?? null); @@ -53,7 +53,7 @@ describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'se await page.waitForSelector('.lobby-screen', { timeout: 15000 }); await sleep(500); - assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__right', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar'); + assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__left', 'lobby-guide'), '设置 (gear) sits right before 玩法说明 (book) on the left of the lobby\'s top bar'); assert.equal(await modalText(page), null, 'closed until asked'); await page.click('.lobby-screen [data-testid="settings-btn"]'); await page.waitForSelector('.modal', { timeout: 5000 }); diff --git a/test/ui/stats.e2e.test.js b/test/ui/stats.e2e.test.js index 8cd268ea..67d88f38 100644 --- a/test/ui/stats.e2e.test.js +++ b/test/ui/stats.e2e.test.js @@ -88,8 +88,8 @@ describe('统计数据 page (real server, headless Chrome)', { skip: !ENABLED && } const texts = (c, sel) => c.page.$$eval(sel, (els) => els.map((e) => e.innerText.replace(/\s+/g, ' ').trim())); const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; }); - const openStats = async (c, from = '.lobby-screen .topbar__right .btn') => { - await c.click(from, '统计'); + const openStats = async (c, from = '.lobby-screen .me-chip--btn') => { + await c.click(from, from.includes('me-chip') ? null : '统计'); await c.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 }); }; const toast = (c) => c.page.waitForFunction(() => document.querySelector('.toast')?.textContent || false, { timeout: 8000 }).then((h) => h.jsonValue()); @@ -305,7 +305,7 @@ describe('统计数据: the real flows (a fast real server, headless Chrome)', { assert.equal(env.records[0].players[0].roundsPassed, 1, 'one round cleared — the server\'s own count for a quit in round 2'); assert.ok(env.records[0].players[0].bandId, 'its strategy'); // the page: one game counted, one left out, both in the history - await c.click('.lobby-screen .topbar__right .btn', '统计'); + await c.click('.lobby-screen .me-chip--btn'); await c.page.waitForSelector('.st .st-card', { visible: true }); assert.equal((await c.page.$$eval('.st-card__num', (els) => els.map((e) => e.textContent)))[0], '1'); assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);