diff --git a/.prettierignore b/.prettierignore index 489a4c3..53b4748 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,9 @@ package.json package-lock.json public pnpm-lock.yaml +# The website lives in its own workspace with its own lint/format setup. +site/htdocs/** +# Third-party vendor/generated files that should not be reformatted. +**/~partytown/** +dist/** +node_modules/** diff --git a/eslint.config.mjs b/eslint.config.mjs index 0590d6d..39660c3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,12 @@ import globals from 'globals' import js from '@eslint/js' // I hate TS only using js export default [ + { + // The website lives in its own workspace with its own ESLint/Prettier setup. + // Third-party vendor/generated files (e.g. partytown) must never be linted + // here since they are not project sources. + ignores: ['site/htdocs/**', '**/~partytown/**', 'dist/**', 'node_modules/**', '.cache/**'] + }, js.configs.recommended, { languageOptions: { diff --git a/lib/core/navigationManager.js b/lib/core/navigationManager.js index 53a75e2..1bf14b1 100644 --- a/lib/core/navigationManager.js +++ b/lib/core/navigationManager.js @@ -193,23 +193,34 @@ class NavigationManager { } } } else if (mode === 'word') { - const words = text.match(/\b\w+\b/g) || [text] - let currentWordIndex = -1 - let charCount = 0 + // Build a list of {word, start} so the cursor jumps accurately + // even when there are punctuation/whitespace gaps between words. + const wordRegex = /\S+/g + const words = [] + let match + while ((match = wordRegex.exec(text)) !== null) { + words.push({ word: match[0], start: match.index }) + } + if (words.length === 0) { + if (screenReader) await screenReader.speak('No text to navigate', { priority: 'high' }) + return + } + let currentWordIndex = 0 for (let i = 0; i < words.length; i++) { - if (this.textPosition <= charCount + words[i].length) { + if (this.textPosition >= words[i].start) { currentWordIndex = i + } else { break } - charCount += text.indexOf(words[i], charCount) - charCount + words[i].length } if (direction === 'next') { if (currentWordIndex < words.length - 1) { currentWordIndex++ - this.textPosition = text.indexOf(words[currentWordIndex], charCount) - if (screenReader) await screenReader.speak(words[currentWordIndex], { priority: 'high' }) + this.textPosition = words[currentWordIndex].start + if (screenReader) + await screenReader.speak(words[currentWordIndex].word, { priority: 'high' }) } else { const nextIndex = this.getNextIndex('next') if (nextIndex !== -1) await this.focusElement(nextIndex) @@ -217,8 +228,9 @@ class NavigationManager { } else { if (currentWordIndex > 0) { currentWordIndex-- - this.textPosition = text.indexOf(words[currentWordIndex]) - if (screenReader) await screenReader.speak(words[currentWordIndex], { priority: 'high' }) + this.textPosition = words[currentWordIndex].start + if (screenReader) + await screenReader.speak(words[currentWordIndex].word, { priority: 'high' }) } else { const prevIndex = this.getNextIndex('prev') if (prevIndex !== -1) await this.focusElement(prevIndex) @@ -227,6 +239,10 @@ class NavigationManager { } else if (mode === 'sentence' || mode === 'paragraph') { const delimiter = mode === 'sentence' ? /[.!?]+\s*/ : /\n+\s*/ const items = text.split(delimiter).filter((s) => s.trim().length > 0) + if (items.length === 0) { + if (screenReader) await screenReader.speak('No text to navigate', { priority: 'high' }) + return + } let currentItemIndex = 0 let charCount = 0 diff --git a/lib/core/pageInterceptor.js b/lib/core/pageInterceptor.js index 5451db0..c64bedd 100644 --- a/lib/core/pageInterceptor.js +++ b/lib/core/pageInterceptor.js @@ -20,12 +20,19 @@ class PageInterceptor { log.info('Initializing Page Interceptor') const self = this + const MAX_INTERCEPT_ATTEMPTS = 50 // ~5s at 100ms interval + let attempts = 0 // Lazy interception or immediate if already present const intercept = () => { const originalPage = typeof Page !== 'undefined' ? Page : null if (!originalPage) { + attempts++ + if (attempts >= MAX_INTERCEPT_ATTEMPTS) { + log.error('Page global not found after max attempts; giving up') + return + } log.warn('Page global still not found, waiting...') setTimeout(intercept, 100) return diff --git a/lib/core/screenReader.js b/lib/core/screenReader.js index 02a88cb..a4d98b6 100644 --- a/lib/core/screenReader.js +++ b/lib/core/screenReader.js @@ -20,11 +20,13 @@ class ScreenReader extends EventEmitter { this.enabled = false this.muted = false this.isProcessing = false + this.speaking = false this.debugMode = false this.initialized = false this.initPromise = null this.debugStats = { totalSpoken: 0, errors: 0, startTime: Date.now() } this.lastSpoken = '' + this.lastSpokenAt = 0 } setDebugMode(enabled) { @@ -152,12 +154,25 @@ class ScreenReader extends EventEmitter { priority: mergedOptions.priority || 'normal' } - if (options.priority === 'high') { + // Collapse rapid duplicate announcements (e.g. the same focus change + // arriving multiple times from different event sources) to avoid + // spamming the user and burning TTS cycles. + const now = Date.now() + if ( + mergedOptions.priority !== 'high' && + processedText === this.lastSpoken && + now - this.lastSpokenAt < 500 + ) { + return true + } + + if (mergedOptions.priority === 'high') { this.queue.unshift(item) if (this.speaking) await this.stop(false) } else { this.queue.push(item) } + this.lastSpokenAt = now if (!this.isProcessing) return this.processQueue() return true diff --git a/lib/core/widgetInterceptor.js b/lib/core/widgetInterceptor.js index 41b4d77..3fac3c0 100644 --- a/lib/core/widgetInterceptor.js +++ b/lib/core/widgetInterceptor.js @@ -17,6 +17,8 @@ class WidgetInterceptor { const g = globalThis const self = this + const MAX_INTERCEPT_ATTEMPTS = 50 // ~5s at 100ms interval + let attempts = 0 const intercept = () => { // Intercept both global and hmUI versions @@ -62,6 +64,11 @@ class WidgetInterceptor { }) if (!found) { + attempts++ + if (attempts >= MAX_INTERCEPT_ATTEMPTS) { + log.error('createWidget not found after max attempts; giving up') + return + } log.warn('Could not find createWidget on globalThis, waiting...') setTimeout(intercept, 100) return diff --git a/lib/feedback/soundFeedback.js b/lib/feedback/soundFeedback.js index 1c26ffe..187a81d 100644 --- a/lib/feedback/soundFeedback.js +++ b/lib/feedback/soundFeedback.js @@ -1,11 +1,22 @@ import { create, id } from '@zos/media' import { log } from '@zos/utils' +// Module-level so the map isn't rebuilt on every play() call. +const SOUNDS = { + success: 'success.wav', + error: 'error.wav', + warning: 'warning.wav', + notification: 'notification.wav', + info: 'info.wav', + click: 'click.wav' +} + export class SoundFeedback { constructor(defaultVolume = 50) { this.volume = Math.min(Math.max(defaultVolume, 0), 100) this.player = null this.initialized = false + this._prepareHandler = null } init() { @@ -29,20 +40,23 @@ export class SoundFeedback { if (!this.initialized && !this.init()) return if (!this.player) return - const SOUNDS = { - success: 'success.wav', - error: 'error.wav', - warning: 'warning.wav', - notification: 'notification.wav', - info: 'info.wav', - click: 'click.wav' - } - try { const soundFile = SOUNDS[sound] - if (!soundFile) throw new Error(`Invalid sound type: ${sound}`) + if (!soundFile) { + log.warn(`Invalid sound type: ${sound}`) + return + } - if (!this.player) return + // Detach any previous prepare handler to avoid leaking listeners + // across back-to-back play() calls. + if (this._prepareHandler) { + try { + this.player.removeEventListener(this.player.event.PREPARE, this._prepareHandler) + } catch (_e) { + // ignore — some SDKs throw when removing an inactive listener + } + this._prepareHandler = null + } this.player.stop() this.player.setSource(this.player.source.FILE, { file: `audio/${soundFile}` }) @@ -52,9 +66,17 @@ export class SoundFeedback { if (result && this.player) { this.player.start() } - if (this.player) this.player.removeEventListener(this.player.event.PREPARE, onPrepare) + if (this.player) { + try { + this.player.removeEventListener(this.player.event.PREPARE, onPrepare) + } catch (_e) { + // ignore + } + } + this._prepareHandler = null } + this._prepareHandler = onPrepare this.player.addEventListener(this.player.event.PREPARE, onPrepare) this.player.prepare() } catch (error) { diff --git a/lib/interaction/gesture.js b/lib/interaction/gesture.js index 1fe6935..0ea9435 100644 --- a/lib/interaction/gesture.js +++ b/lib/interaction/gesture.js @@ -1,5 +1,6 @@ import { Gesture } from '@zos/sensor' import { log } from '@zos/utils' +import { back, push } from '@zos/router' import EventManager from '../core/eventManager.js' import ShortcutHandler from './shortcut.js' @@ -143,17 +144,40 @@ export class GestureHandler { } break case 'previous_page': - // router.back() + try { + back() + } catch (e) { + log.warn('router.back failed:', e) + } break case 'next_page': - // router.next() + try { + // Zepp OS router has no generic "forward"; if a target page was + // registered via config, push it; otherwise announce that we + // can't move forward so the user gets feedback. + const target = globalThis.ScreenReaderConfig?.nextPageUrl + if (target) { + push({ url: target }) + } else if (globalThis.ScreenReaderInstance) { + await globalThis.ScreenReaderInstance.speak('No next page available', { + priority: 'high' + }) + } + } catch (e) { + log.warn('router.push failed:', e) + } break case 'scroll_up': - // Simulate scroll up - break - case 'scroll_down': - // Simulate scroll down + case 'scroll_down': { + // Navigation-by-element is the accessible analogue of scrolling + // on a touch-centric UI: jump focus one interactive element in + // the chosen direction so TTS keeps narrating consistently. + const nav = globalThis.NavigationManagerInstance + if (nav) { + await nav.navigate(actionKey === 'scroll_up' ? 'prev' : 'next') + } break + } default: // Try to execute as a general shortcut if it matches await ShortcutHandler.execute(actionKey) diff --git a/lib/utils/emojis.js b/lib/utils/emojis.js index 2e5596d..a148a29 100644 --- a/lib/utils/emojis.js +++ b/lib/utils/emojis.js @@ -106,27 +106,42 @@ export const symbolsMap = { ',': 'Comma' } +// Escape a string so it can be embedded in a RegExp literal safely. +function escapeForRegExp(str) { + return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') +} + +// Sort keys so that longer sequences (e.g. emoji ZWJ combos, or variation +// selector forms) are tried before shorter, overlapping ones. Without this +// the alternation would match the shorter emoji first and leave orphaned +// combining codepoints like U+FE0F in the output. +function buildReplacer(map) { + const keys = Object.keys(map).sort((a, b) => b.length - a.length) + if (keys.length === 0) { + return { pattern: null, map } + } + const pattern = new RegExp(keys.map(escapeForRegExp).join('|'), 'g') + return { pattern, map } +} + +const emojiReplacer = buildReplacer(emojisMap) +const symbolReplacer = buildReplacer(symbolsMap) + +function applyReplacer({ pattern, map }, text) { + if (!pattern) return text + // Single pass replace preserves non-matching input and avoids the O(n*k) + // cost of iterating the whole dictionary for every call. Wrap with commas + // so TTS engines insert a small pause around the spoken description, + // matching the previous (pre-refactor) pacing that a11y users rely on. + return text.replace(pattern, (match) => `, ${map[match]}, `) +} + export function translateEmojis(text) { if (!text) return '' - let result = text - const keys = Object.keys(emojisMap) - for (let i = 0; i < keys.length; i++) { - const emoji = keys[i] - const translation = emojisMap[emoji] - result = result.replace(new RegExp(emoji, 'g'), `, ${translation}, `) - } - return result + return applyReplacer(emojiReplacer, String(text)) } export function translateSymbols(text) { if (!text) return '' - let result = text - const keys = Object.keys(symbolsMap) - for (let i = 0; i < keys.length; i++) { - const symbol = keys[i] - const translation = symbolsMap[symbol] - const escapedSymbol = symbol.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') - result = result.replace(new RegExp(escapedSymbol, 'g'), `, ${translation}, `) - } - return result + return applyReplacer(symbolReplacer, String(text)) } diff --git a/lib/utils/speechHistory.js b/lib/utils/speechHistory.js index 5e27d98..e1e2128 100644 --- a/lib/utils/speechHistory.js +++ b/lib/utils/speechHistory.js @@ -7,10 +7,17 @@ class SpeechHistory { add(text) { if (!text?.trim()) return - this.history.unshift({ - text, - timestamp: Date.now() - }) + const now = Date.now() + const previous = this.history[0] + // Coalesce the same announcement arriving within a short window so the + // history isn't flooded with duplicates from focus/TTS retries. + if (previous && previous.text === text && now - previous.timestamp < 1000) { + previous.timestamp = now + previous.count = (previous.count || 1) + 1 + return + } + + this.history.unshift({ text, timestamp: now, count: 1 }) if (this.history.length > this.maxSize) { this.history.pop()