- commit
- c369cddfb8dcee18170deec9f5673050f8a78807
- parent
- f07125e0907558d4e203fe0a2cf4b426daa6651c
- author
- Dami <dami.etoile@gmail.com>
| M | portal/ | 10 | +++++----- |
| M | portal/ | 12 | ++++++------ |
| M | portal/ | 2 | +- |
| M | portal/ | 14 | ++++++++++++++ |
| M | portal/ | 20 | ++++++++++++++++++++ |
| M | portal/ | 24 | +++++++++++++++--------- |
| M | portal/ | 16 | ++++++++++++++++ |
| M | portal/ | 4 | +++- |
| M | portal/ | 10 | +++++----- |
@@ -38,13 +38,13 @@ export function audioButtonPresentation({ available, muted, state, detail }) {
3838 ? detail3939 : normalizedState;4040 41- let glyph;41+ let label;4242 if (!available || muted || normalizedState === 'error' || normalizedState === 'unavailable') {43- glyph = '๐';43+ label = 'vol off';4444 } else if (normalizedState === 'playing') {45- glyph = '๐';45+ label = 'vol on';4646 } else {47- glyph = 'audio ...';47+ label = 'vol ...';4848 }4949 5050 const action = available@@ -52,7 +52,7 @@ export function audioButtonPresentation({ available, muted, state, detail }) {
5252 : 'Audio unavailable';5353 const audibleState = muted && available ? 'muted' : normalizedState;5454 return {55- glyph,55+ label,5656 ariaLabel: `${action}. Audio ${audibleState}. ${normalizedDetail}`,5757 title: `Audio ${audibleState}: ${normalizedDetail}`,5858 };@@ -54,27 +54,27 @@ test('new audio sessions preserve seeded mute state and isolate mutable state',
5454 assert.notEqual(muted.messageTracker, fresh.messageTracker);5555 });5656 57-test('audio button uses a speaker glyph with a mute action while playing', () => {57+test('audio button uses a text-on label with a mute action while playing', () => {5858 assert.deepEqual(audioButtonPresentation({5959 available: true,6060 muted: false,6161 state: 'playing',6262 detail: 'bounded Opus playback',6363 }), {64- glyph: '๐',64+ label: 'vol on',6565 ariaLabel: 'Mute audio. Audio playing. bounded Opus playback',6666 title: 'Audio playing: bounded Opus playback',6767 });6868 });6969 70-test('audio button uses a muted glyph with an unmute action while muted', () => {70+test('audio button uses a text-off label with an unmute action while muted', () => {7171 const presentation = audioButtonPresentation({7272 available: true,7373 muted: true,7474 state: 'playing',7575 detail: 'bounded Opus playback',7676 });77- assert.equal(presentation.glyph, '๐');77+ assert.equal(presentation.label, 'vol off');7878 assert.match(presentation.ariaLabel, /^Unmute audio\. Audio muted\./);7979 });8080 @@ -84,13 +84,13 @@ test('audio button distinguishes priming from unavailable', () => {
8484 muted: false,8585 state: 'priming',8686 detail: 'Waiting for bounded Opus prebuffer',87- }).glyph, 'audio ...');87+ }).label, 'vol ...');8888 const unavailable = audioButtonPresentation({8989 available: false,9090 muted: false,9191 state: 'unavailable',9292 detail: 'not connected',9393 });94- assert.equal(unavailable.glyph, '๐');94+ assert.equal(unavailable.label, 'vol off');9595 assert.match(unavailable.ariaLabel, /^Audio unavailable\./);9696 });@@ -164,7 +164,7 @@
164164 view only ยท input unavailable165165 </button>166166 <button type="button" class="control-toggle disabled" id="audio-toggle" data-controller-focus onclick="toggleAudioMute()" aria-disabled="true" aria-label="Audio unavailable. Not connected" title="Audio unavailable: not connected">167- ๐167+ vol off168168 </button>169169 </div>170170 <div class="bottombar-end">@@ -6,6 +6,20 @@ export const MAX_POINTER_SURFACE_WIDTH = 7680;
66 export const MIN_POINTER_SURFACE_HEIGHT = 64;77 export const MAX_POINTER_SURFACE_HEIGHT = 4320;88 9+export function inputCapabilityLabel(capabilities = {}) {10+ const accepted = [];11+ const pointer = capabilities.relativePointer === true12+ || capabilities.absolutePointer === true;13+ if (pointer && capabilities.keyboard === true) accepted.push('kbm');14+ else {15+ if (pointer) accepted.push('mouse');16+ if (capabilities.keyboard === true) accepted.push('keyboard');17+ }18+ if (capabilities.text === true) accepted.push('text');19+ if (capabilities.gamepad === true) accepted.push('controller');20+ return accepted.length > 0 ? accepted.join(' + ') : 'view only';21+}22+923 function roundedRelativeAxis(value) {1024 if (!Number.isFinite(value)) return 0;1125 const rounded = Math.round(value);@@ -10,6 +10,7 @@ import {
1010 RelativePointerAccumulator,1111 advanceRemotePointerPosition,1212 browserPointerLockLossRequiresControlExit,13+ inputCapabilityLabel,1314 mapCanvasPointToSurface,1415 resolvePointerSurfaceSize,1516 restoreRejectedPointerMotion,@@ -19,6 +20,25 @@ import {
1920 browserMouseButtonCode,2021 } from './input-state.mjs';2122 23+test('input capability labels group keyboard/mouse and rename gamepad for the UI', () => {24+ assert.equal(inputCapabilityLabel({25+ relativePointer: true,26+ absolutePointer: false,27+ keyboard: true,28+ text: false,29+ gamepad: true,30+ }), 'kbm + controller');31+ assert.equal(inputCapabilityLabel({32+ absolutePointer: true,33+ keyboard: true,34+ }), 'kbm');35+ assert.equal(inputCapabilityLabel({ gamepad: true }), 'controller');36+ assert.equal(inputCapabilityLabel({ relativePointer: true }), 'mouse');37+ assert.equal(inputCapabilityLabel({ keyboard: true }), 'keyboard');38+ assert.equal(inputCapabilityLabel({ text: true }), 'text');39+ assert.equal(inputCapabilityLabel({}), 'view only');40+});41+2242 test('relative motion forwards finite safe deltas without surface scaling', () => {2343 assert.deepEqual(2444 scaleRelativePointerDelta(10, -5, 1280, 800, 640, 400),@@ -5,6 +5,7 @@ import {
55 validatePointerSurfaceDimensions,66 validatePointerPositionFeedback,77 browserMouseButtonCode,8+ inputCapabilityLabel,89 } from './input-state.mjs';910 import { createInputRuntime } from './input-runtime.mjs';1011 import { createControlRuntime } from './control-runtime.mjs';@@ -328,7 +329,7 @@ function updateAudioUI() {
328329 state: session.state,329330 detail: session.stateDetail,330331 });331- toggle.textContent = presentation.glyph;332+ toggle.textContent = presentation.label;332333 toggle.classList.toggle('active', session.available && !session.muted);333334 toggle.classList.toggle('disabled', !session.available);334335 toggle.setAttribute('aria-disabled', session.available ? 'false' : 'true');@@ -791,14 +792,7 @@ async function toggleControl() {
791792 }792793 793794 function describeInputCapabilities() {794- const accepted = [];795- const capabilities = connectionState.inputCapabilities;796- if (capabilities.relativePointer) accepted.push('relative pointer');797- else if (capabilities.absolutePointer) accepted.push('pointer');798- if (capabilities.keyboard) accepted.push('keyboard');799- if (capabilities.text) accepted.push('text');800- if (capabilities.gamepad) accepted.push('gamepad');801- return accepted.length > 0 ? accepted.join(' + ') : 'view only';795+ return inputCapabilityLabel(connectionState.inputCapabilities);802796 }803797 804798 function pointerInputAvailable() {@@ -930,6 +924,18 @@ window.addEventListener('mouseup', (e) => {
930924 releaseMouseButton(e);931925 }, { capture: true });932926 927+function suppressLocalClickDuringRelativeControl(e) {928+ if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;929+ // mousedown/mouseup are forwarded to the host above, but WebKit synthesizes930+ // a separate click at the native cursor's frozen local position. Prevent931+ // that click from reaching the underlying Portal control that acquired the932+ // cursor, where it would immediately toggle controlling mode back off.933+ e.preventDefault();934+ e.stopImmediatePropagation();935+}936+937+window.addEventListener('click', suppressLocalClickDuringRelativeControl, { capture: true });938+933939 window.addEventListener('contextmenu', (e) => {934940 if (!controlRuntime.active || !pointerInputAvailable()) return;935941 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;@@ -20,3 +20,19 @@ test('mouse buttons use the same WebKit event family as motion and scrolling', (
2020 );2121 assert.doesNotMatch(buttonHandlers, /pointer(?:down|up|cancel)|pointerType|PointerCapture/);2222 });23+24+test('relative control consumes the synthetic local click before UI toggles see it', () => {25+ assert.match(26+ main,27+ /window\.addEventListener\('click', suppressLocalClickDuringRelativeControl, \{ capture: true \}\);/,28+ );29+30+ const clickGuard = main.slice(31+ main.indexOf('function suppressLocalClickDuringRelativeControl'),32+ main.indexOf("window.addEventListener('contextmenu'"),33+ );34+ assert.match(clickGuard, /controlRuntime\.active/);35+ assert.match(clickGuard, /connectionState\.inputCapabilities\.relativePointer/);36+ assert.match(clickGuard, /e\.preventDefault\(\)/);37+ assert.match(clickGuard, /e\.stopImmediatePropagation\(\)/);38+});@@ -37,5 +37,7 @@ test('stream diagnostics remain exclusive to the Info panel', () => {
3737 for (const id of streamIds) assert.match(panel, new RegExp(`id="${id}"`));3838 assert.match(html, /id="audio-toggle"[^>]*aria-label="Audio unavailable\. Not connected"/);3939 assert.match(html, /type="button"[^>]*id="audio-toggle"/);40- assert.match(css, /#audio-toggle\s*\{[^}]*min-width:\s*40px;[^}]*min-height:\s*32px;/s);40+ assert.match(html, /id="audio-toggle"[^>]*>[\s\n]*vol off[\s\n]*<\/button>/);41+ assert.doesNotMatch(html, /๐|๐|๐/);42+ assert.match(css, /#audio-toggle\s*\{[^}]*min-width:\s*80px;[^}]*min-height:\s*36px;/s);4143 });@@ -272,11 +272,11 @@ body.native-pointer-control * { cursor: none !important; }
272272 display: inline-flex;273273 align-items: center;274274 justify-content: center;275- min-width: 40px;276- min-height: 32px;277- padding: 6px 10px;278- font-family: system-ui, sans-serif;279- font-size: 16px;275+ min-width: 80px;276+ min-height: 36px;277+ padding: 8px 12px;278+ font-family: var(--mono);279+ font-size: 12px;280280 line-height: 1;281281 white-space: nowrap;282282 touch-action: manipulation;