Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
34 changes: 25 additions & 9 deletions lib/core/navigationManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,32 +193,44 @@ 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)
}
} 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)
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions lib/core/pageInterceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion lib/core/screenReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/core/widgetInterceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 34 additions & 12 deletions lib/feedback/soundFeedback.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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}` })
Expand All @@ -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) {
Expand Down
36 changes: 30 additions & 6 deletions lib/interaction/gesture.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down
49 changes: 32 additions & 17 deletions lib/utils/emojis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
15 changes: 11 additions & 4 deletions lib/utils/speechHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading