diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..119b12f --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "name": "sensor", + "install": "bash .cursor/install.sh", + "start": "exec node scripts/demo-server.js" +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 0000000..91ef2da --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ! -f package-lock.json ]]; then + echo "package-lock.json is not on this revision; skipping dependency install" + exit 0 +fi + +npm ci +npx playwright install --with-deps chromium diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b430ffa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + + pages: + needs: check + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + - name: Collect static demo + run: | + mkdir -p _site/other + cp index.html sensors.js styles.css effects.css _site/ + cp other/*.html _site/other/ + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f95add --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..138d2b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# AGENTS.md + +Инструкция для облачных и локальных агентов в репозитории `sensor` (GyroShine). + +## Что это + +Небольшая фронтенд-библиотека без сборки и без runtime-зависимостей. Класс `GyroShine` читает наклон устройства и отдаёт его событием `change`. Демо и интеграции пишут две CSS-переменные на `:root`: + +- `--gyro-gamma-percent` — горизонталь, строка `"0.00"`–`"100.00"`, нейтраль `"50.00"` +- `--gyro-beta-percent` — вертикаль, тот же диапазон + +Визуальные блики живут в CSS (`calc`, градиенты, `box-shadow`), внутри `@media (prefers-reduced-motion: no-preference)`. + +Приоритет источников: Telegram Mini Apps → `RelativeOrientationSensor` → `deviceorientation` → указатель (мышь и перо, не touch). + +## Структура + +| Путь | Зачем | +| --- | --- | +| `sensors.js` | Библиотека. Единственный файл, который подключают проекты. ES-модуль, `export default class GyroShine`. | +| `index.html`, `styles.css`, `effects.css` | Демо для GitHub Pages. | +| `other/` | Черновики из статьи. Не часть публичного API. | +| `AI.md` | Промпт, как встроить блики в чужой UI. | +| `test/` | Юнит-тесты расчёта углов и CSS-процентов, плюс жизненный цикл слушателей. | +| `e2e/` | Playwright: синтетический `deviceorientation` и fallback указателя. | +| `scripts/demo-server.js` | Статика без зависимостей, порт `4173`. | + +Именованные экспорты из `sensors.js` (`deviceOrientationToNorm`, `normsToCssVars` и соседние функции) нужны тестам. Стабильный API для пользователей — default-класс, его опции, методы и события. + +## Как запустить демо + +```bash +npm ci +npm run demo +``` + +Открыть http://127.0.0.1:4173 . На десктопе блики следуют за указателем. На iOS 13+ чекбокс «Отслеживание сенсоров» выключен, пока пользователь сам его не включит: `DeviceOrientationEvent.requestPermission()` работает только из user gesture и только по HTTPS (localhost тоже secure context). + +## Как тестировать + +```bash +npm test # node:test, без браузера +npm run test:e2e # Playwright, Chromium +npm run lint +``` + +Юнит-тесты не поднимают страницу. E2E ждёт `scripts/demo-server.js` (Playwright сам его стартует). Перед первым e2e локально: `npx playwright install --with-deps chromium` (в Cloud Agent это делает `.cursor/install.sh`). + +## Правила + +- Не добавлять runtime-зависимости. React, бандлеры и полифиллы библиотеке не нужны. Dev-зависимости допустимы только для lint и e2e. +- Держать `sensors.js` одним файлом и маленьким. Проекты скачивают его как есть, отдельный бандл не собирается. Ориентир размера — тест в `test/tilt.test.js`. +- Не ломать публичный API: `new GyroShine(options)`, `start()`, `stop()`, `on()`, `off()`, `animate`, событие `change` с `detail.gammaPercent` / `detail.betaPercent` (строки с двумя знаками), событие `lowbattery`. Новые опции и события добавлять можно, старые имена и формат `change` — нет. +- `respectReducedMotion` по умолчанию `true`. Не отключать это молча. +- `requestPermission()` вызывать синхронно внутри `start()`, до любого `await`. Иначе iOS теряет user gesture. +- Вне secure context не подписываться на датчики движения — только указатель. +- `stop()` обязан снимать `deviceorientation`, `pointermove`, слушатель `RelativeOrientationSensor`, интервал батареи и подписку Telegram. +- Не публиковать пакет в npm и не менять `version` в `package.json`. Поле стоит `"0.0.0"` только потому, что npm его требует; `"private": true` обязателен. +- Не переписывать `AI.md` и рецепты в README без запроса: это инструкция для встраивания эффектов в другие проекты. + +## CI и демо + +- `.github/workflows/ci.yml` — install, lint, unit, e2e на pull request и push. +- Тот же workflow выкладывает статику (`index.html`, `sensors.js`, CSS, `other/`) на GitHub Pages при push в `main`. В настройках репозитория источник Pages должен быть **GitHub Actions**. diff --git a/AI.md b/AI.md index 92c8ec6..05b471f 100644 --- a/AI.md +++ b/AI.md @@ -44,7 +44,7 @@ gyro.on('change', (e) => { gyro.start(); ``` -На iOS Safari `start()` должен вызываться из обработчика пользовательского действия (клик), иначе запрос разрешения на доступ к сенсорам не сработает. +На iOS Safari `start()` должен вызываться из обработчика пользовательского действия (клик), иначе запрос разрешения на доступ к сенсорам не сработает. Датчики движения доступны только в secure context (HTTPS или localhost); иначе остаётся fallback на указатель. ### React @@ -479,7 +479,8 @@ input[type="checkbox"].toggle:checked::after { |----------|-----|-------------|----------| | `refreshRate` | number | 42 | Частота опроса датчиков (мс) | | `animate` | boolean | false | JS-интерполяция (spring/lerp) | -| `useSpring` | boolean | false | Пружинная физика (false = lerp) | +| `useSpring` | boolean | true | Пружинная физика (false = lerp) | +| `respectReducedMotion` | boolean | true | Не слушать датчики при `prefers-reduced-motion: reduce` | | `stiffness` | number | 0.12 | Жёсткость пружины (0.01–0.3) | | `damping` | number | 0.82 | Затухание пружины (0.5–0.95) | | `lerpSpeed` | number | 0.09 | Скорость lerp (если useSpring=false) | diff --git a/README.md b/README.md index 5555a67..07c6460 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ [Демку](https://alexstep.github.io/sensor/) лучше смотреть с мобилки, а ещё лучше открыть [миниапп в телеграм](https://t.me/calendar0bot/ui_example) +Локально: `npm ci`, затем `npm run demo` — страница на http://127.0.0.1:4173 . Проверки: `npm test`, `npm run test:e2e`, `npm run lint`. Пакет в npm не публикуется. + ## Быстрый старт @@ -17,16 +19,18 @@ Инструкция: https://raw.githubusercontent.com/alexstep/sensor/main/AI.md ``` -или ручками: +или ручками (`sensors.js` — ES-модуль, не классический script): ```html - - ``` @@ -40,7 +44,7 @@ | 2 | GravitySensor / Accelerometer | Chrome, Android | | 3 | deviceorientation | Safari, Firefox и др. | -На iOS Safari автоматически запрашивается разрешение пользователя. +На iOS 13+ Safari `start()` вызывает `DeviceOrientationEvent.requestPermission()`. Это нужно делать из обработчика клика или тапа и только в secure context (HTTPS или localhost). Если разрешения нет, библиотека переходит на указатель (мышь/перо). При `prefers-reduced-motion: reduce` датчики не включаются. ## API @@ -54,7 +58,9 @@ const gyro = new GyroShine({ stiffness: 0.12, // жёсткость пружины (0.01–0.3) damping: 0.82, // затухание пружины (0.5–0.95) lerpSpeed: 0.09, // скорость lerp (если useSpring = false) - minBatteryLevel: 0.4, // мин. заряд батареи (0 = не проверять) + minBattery: 0.4, // мин. заряд батареи (0 = не проверять) + useMouse: true, // fallback на мышь/перо, если датчиков нет + respectReducedMotion: true, // не двигать блики при prefers-reduced-motion debug: false, // вывод логов в консоль }) ``` @@ -89,6 +95,8 @@ gyro.on('change', e => { Нейтральное положение устройства - `50.00` / `50.00`. +**`permissionneeded`** — iOS не выдал разрешение (нет user gesture, отказ или ошибка). Можно показать кнопку и снова вызвать `start()` из её обработчика. + **`lowbattery`** - вызывается при низком заряде (датчики автоматически останавливаются): ```js diff --git a/e2e/demo.spec.js b/e2e/demo.spec.js new file mode 100644 index 0000000..674f9c2 --- /dev/null +++ b/e2e/demo.spec.js @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test' + +async function cssVar(page, name) { + return page.evaluate((varName) => { + return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() + }, name) +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'RelativeOrientationSensor', { + configurable: true, + writable: true, + value: undefined, + }) + }) +}) + +async function armOrientation(page) { + const box = page.getByRole('checkbox', { name: 'Отслеживание сенсоров' }) + if (await box.isChecked()) await box.uncheck() + await box.check() +} + +test('deviceorientation moves the CSS variables', async ({ page }) => { + await page.goto('/') + const themeButton = page.getByRole('button', { name: 'Рандомная тема' }) + await expect(themeButton).toBeVisible() + await themeButton.click() + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme || '')).not.toBe('') + await armOrientation(page) + + await expect.poll(async () => { + await page.evaluate(() => { + window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { + alpha: 0, + beta: 0, + gamma: 70, + absolute: false, + })) + }) + return cssVar(page, '--gyro-gamma-percent') + }).toBe('100.00') + + await expect.poll(async () => cssVar(page, '--gyro-beta-percent')).toBe('0.00') +}) + +test('pointer fallback moves the shine when orientation is silent', async ({ page }) => { + await page.goto('/') + + await expect.poll(async () => { + await page.mouse.move(0, 0) + return cssVar(page, '--gyro-gamma-percent') + }).toBe('60.00') + await expect.poll(async () => cssVar(page, '--gyro-beta-percent')).toBe('60.00') +}) + +test('turning sensors off removes the orientation reaction', async ({ page }) => { + await page.goto('/') + await expect.poll(async () => { + await page.mouse.move(0, 0) + return cssVar(page, '--gyro-gamma-percent') + }).toBe('60.00') + + const sensors = page.getByRole('checkbox', { name: 'Отслеживание сенсоров' }) + await sensors.uncheck() + const frozen = await cssVar(page, '--gyro-gamma-percent') + + await page.evaluate(() => { + window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { + alpha: 0, + beta: 0, + gamma: 70, + absolute: false, + })) + }) + await page.mouse.move(1000, 400) + + await expect.poll(async () => cssVar(page, '--gyro-gamma-percent')).toBe(frozen) + expect(frozen).not.toBe('100.00') +}) + +test('prefers-reduced-motion stays at the neutral point', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/') + + await expect.poll(async () => cssVar(page, '--gyro-gamma-percent')).toBe('50.00') + + await page.evaluate(() => { + window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { + alpha: 0, + beta: 0, + gamma: 70, + absolute: false, + })) + }) + await page.mouse.move(0, 0) + + await expect.poll(async () => cssVar(page, '--gyro-gamma-percent')).toBe('50.00') + await expect.poll(async () => cssVar(page, '--gyro-beta-percent')).toBe('50.00') +}) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..49143bf --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,36 @@ +export default [ + { + files: ['sensors.js', 'scripts/**/*.js', 'test/**/*.js', 'e2e/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + window: 'readonly', + navigator: 'readonly', + document: 'readonly', + performance: 'readonly', + DeviceOrientationEvent: 'readonly', + RelativeOrientationSensor: 'readonly', + CustomEvent: 'readonly', + Event: 'readonly', + EventTarget: 'readonly', + console: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + matchMedia: 'readonly', + process: 'readonly', + queueMicrotask: 'readonly', + URL: 'readonly', + getComputedStyle: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + 'no-undef': 'error', + }, + }, +] diff --git a/index.html b/index.html index 0aa20b8..0c4e342 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ GitHub
- +
@@ -88,9 +88,14 @@ useSpringToggle.disabled = !motionEnabled || !animateToggle.checked; } - // В мобильном safari не включаем motion , потому что ещё разрешение надо запрашивать - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - if (!isIOS || window.Telegram?.WebApp?.platform === 'ios') sensorsToggle.checked = true; + // iOS и мобильный Chromium: requestPermission() только из user gesture. + // На десктопе (pointer: fine) чекбокс остаётся включённым — сразу работает мышь. + // В Telegram iOS разрешение запрашивает хост, автостарт оставляем. + const needsGesture = typeof DeviceOrientationEvent !== 'undefined' + && typeof DeviceOrientationEvent.requestPermission === 'function'; + const primaryCoarse = window.matchMedia('(pointer: coarse)').matches; + const telegramIOS = window.Telegram?.WebApp?.platform === 'ios'; + if (needsGesture && primaryCoarse && !telegramIOS) sensorsToggle.checked = false; cssShinesToggle.addEventListener('change', () => { if (cssShinesToggle.checked) { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8bd5636 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1020 @@ +{ + "name": "sensor", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sensor", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.56.1", + "eslint": "^10.12.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4f33d13 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "sensor", + "private": true, + "version": "0.0.0", + "description": "CSS shine effects driven by device orientation. Not published to npm.", + "type": "module", + "scripts": { + "demo": "node scripts/demo-server.js", + "lint": "eslint sensors.js scripts test e2e", + "test": "node --test test/tilt.test.js", + "test:e2e": "playwright test", + "test:all": "npm test && npm run test:e2e" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "eslint": "^10.12.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..9315388 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: 'e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:4173', + permissions: ['accelerometer', 'gyroscope', 'magnetometer'], + }, + webServer: { + command: 'node scripts/demo-server.js', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/scripts/demo-server.js b/scripts/demo-server.js new file mode 100644 index 0000000..afc39c4 --- /dev/null +++ b/scripts/demo-server.js @@ -0,0 +1,55 @@ +import http from 'node:http' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const port = Number(process.env.PORT || 4173) +const host = process.env.HOST || '0.0.0.0' + +const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.ico': 'image/x-icon', +} + +const blocked = new Set(['node_modules', '.git', 'test', 'e2e', '.github', '.cursor']) + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`) + let pathname = decodeURIComponent(url.pathname) + if (pathname.endsWith('/')) pathname += 'index.html' + + const parts = pathname.split('/').filter(Boolean) + if (parts.some((part) => part === '.' || part === '..' || blocked.has(part))) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }) + res.end('Not found') + return + } + + const file = path.resolve(root, `.${pathname}`) + if (file !== root && !file.startsWith(`${root}${path.sep}`)) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }) + res.end('Forbidden') + return + } + + fs.readFile(file, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }) + res.end('Not found') + return + } + const type = types[path.extname(file)] || 'application/octet-stream' + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' }) + res.end(data) + }) +}) + +server.listen(port, host, () => { + console.log(`demo http://127.0.0.1:${port}`) +}) diff --git a/sensors.js b/sensors.js index 438bab3..ab6cb93 100644 --- a/sensors.js +++ b/sensors.js @@ -6,7 +6,7 @@ * 1. Telegram Mini Apps API — внутри приложения Telegram на iOS/Android * 2. RelativeOrientationSensor — Sensor API (Chrome, Android) * 3. deviceorientation — fallback для Safari и остальных браузеров - * 4. mousemove — fallback для десктопов (включён по умолчанию) + * 4. pointermove — fallback для десктопа (мышь и перо, без touch; включён по умолчанию) * * Все источники (кроме мыши) используют blend-подход для горизонтали: * при наклонённом телефоне — отслеживаем наклон (gravity/gamma), @@ -24,8 +24,17 @@ * stiffness: 0.12, * damping: 0.82 * }); - * gyro.on('change', ({ gammaPercent, betaPercent }) => { ... }); + * gyro.on('change', (e) => { + * e.detail.gammaPercent + * e.detail.betaPercent + * }); * gyro.start(); + * + * iOS 13+: DeviceOrientationEvent.requestPermission() вызывается синхронно + * из start(), поэтому start() нужно звать из обработчика клика/тапа. + * Датчики движения работают только в secure context (HTTPS или localhost). + * При prefers-reduced-motion: reduce слушатели не вешаются. + * Если датчиков нет — fallback на pointermove (мышь и перо, не touch). */ const DEFAULT_REFRESH_RATE = 42; const SENSOR_FREQUENCY = 60 @@ -49,6 +58,72 @@ const BETA_RANGE = 45 // При типичном удержании (~45°) uprightness ≈ 0.71 — blend = 0, чистый gravity. const UPRIGHT_START = 0.85 const UPRIGHT_RANGE = 0.13 +const SENSOR_START_TIMEOUT = 1500 +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' + +export function clamp(value, min = -1, max = 1) { + return Math.min(max, Math.max(min, value)) +} + +/** 0–100 percent → CSS string with two decimals ("50.00"). */ +export function formatPercent(percent) { + return (Math.round(percent * 100) / 100).toFixed(2) +} + +/** Normalized tilt -1..1 → CSS percent. 0 is the neutral "50.00". */ +export function normToPercent(norm) { + return formatPercent((norm + 1) * 50) +} + +export function normsToCssVars(gammaNorm, betaNorm) { + return { + gammaPercent: normToPercent(gammaNorm), + betaPercent: normToPercent(betaNorm), + } +} + +/** + * Blend gravity-gamma with yaw when the phone is nearly upright. + * @returns {{ blend: number, azimuthGamma: number, newBase: number }} + */ +export function computeYawBlend(uprightness, angleDeg, baseDeg) { + const blend = clamp((uprightness - UPRIGHT_START) / UPRIGHT_RANGE, 0, 1) + + let newBase = baseDeg ?? angleDeg + let azimuthGamma = 0 + + if (blend < 0.01) { + newBase = angleDeg + } else { + let delta = angleDeg - newBase + if (delta > 180) delta -= 360 + if (delta < -180) delta += 360 + azimuthGamma = clamp(delta / GAMMA_RANGE) + } + + return { blend, azimuthGamma, newBase } +} + +/** + * deviceorientation Euler angles → normalized gamma/beta. + * Returns null when the sample must be ignored. + * @param {{ alpha?: number|null, beta?: number|null, gamma?: number|null }} angles + * @param {number|null} alphaBase + * @returns {{ gammaNorm: number, betaNorm: number, alphaBase: number } | null} + */ +export function deviceOrientationToNorm(angles, alphaBase) { + const { alpha, beta, gamma } = angles + if (gamma == null || beta == null) return null + if (beta > 90) return null + + const betaNorm = clamp((beta - BETA_OFFSET) / BETA_RANGE) + const gravityGamma = clamp(gamma / GAMMA_RANGE) + const uprightness = Math.sin(beta * DEG2RAD) + const yaw = computeYawBlend(uprightness, alpha ?? 0, alphaBase) + const gammaNorm = gravityGamma * (1 - yaw.blend) + yaw.azimuthGamma * yaw.blend + + return { gammaNorm, betaNorm, alphaBase: yaw.newBase } +} export default class GyroShine extends EventTarget { // === STATE === @@ -79,7 +154,12 @@ export default class GyroShine extends EventTarget { #twaOrientation = null #batteryCheckInterval = null #deviceOrientationHandler = null - #mouseMoveHandler = null + #pointerHandler = null + #onSensorReading = null + #generation = 0 + #active = false + #motionMedia = null + #onMotionMediaChange = null /** * @param {Object} options @@ -91,7 +171,8 @@ export default class GyroShine extends EventTarget { * @param {number} [options.lerpSpeed=0.09] — скорость линейной интерполяции (0.01–0.2) * @param {number} [options.minBattery=0.4] — минимальный уровень батареи (0 = выкл) * @param {boolean} [options.debug=false] — вывод логов в консоль - * @param {boolean} [options.useMouse=true] — использовать мышь как fallback на десктопе + * @param {boolean} [options.useMouse=true] — fallback на указатель (мышь/перо) на десктопе + * @param {boolean} [options.respectReducedMotion=true] — не слушать датчики при prefers-reduced-motion */ constructor(options = {}) { super() @@ -106,6 +187,7 @@ export default class GyroShine extends EventTarget { debug : options.debug ?? false, minBattery : options.minBattery ?? 0.4, useMouse : options.useMouse ?? true, + respectReducedMotion: options.respectReducedMotion ?? true, }; } @@ -134,7 +216,7 @@ export default class GyroShine extends EventTarget { // ============================================================= #clamp(value, min = -1, max = 1) { - return Math.min(max, Math.max(min, value)) + return clamp(value, min, max) } #throttle(fn, delay) { @@ -162,28 +244,7 @@ export default class GyroShine extends EventTarget { * @returns {{ blend: number, azimuthGamma: number, newBase: number }} */ #computeYawBlend(uprightness, angleDeg, baseDeg) { - // blend: 0 (телефон наклонён, используем gravity) - // 1 (телефон вертикален, используем azimuth) - const blend = this.#clamp((uprightness - UPRIGHT_START) / UPRIGHT_RANGE, 0, 1) - - let newBase = baseDeg ?? angleDeg - let azimuthGamma = 0 - - if (blend < 0.01) { - // Пока blend ≈ 0, непрерывно обновляем базу. - // Когда телефон перейдёт в вертикальное положение, - // база "заморозится" на последнем значении — и дельта - // начнёт расти от нуля, обеспечивая плавный старт. - newBase = angleDeg - } else { - let delta = angleDeg - newBase - // Обработка перехода через ±180° (wraparound) - if (delta > 180) delta -= 360 - if (delta < -180) delta += 360 - azimuthGamma = this.#clamp(delta / GAMMA_RANGE) - } - - return { blend, azimuthGamma, newBase } + return computeYawBlend(uprightness, angleDeg, baseDeg) } #log(...args) { @@ -272,8 +333,8 @@ export default class GyroShine extends EventTarget { this.#prevGP = gp this.#prevBP = bp - this.#detail.gammaPercent = (gp / 100).toFixed(2) - this.#detail.betaPercent = (bp / 100).toFixed(2) + this.#detail.gammaPercent = formatPercent(gammaPercent) + this.#detail.betaPercent = formatPercent(betaPercent) this.dispatchEvent(new CustomEvent('change', { detail: this.#detail })) } @@ -288,42 +349,111 @@ export default class GyroShine extends EventTarget { * blend: при наклонённом телефоне — gamma, при вертикальном — delta alpha. */ #handleDeviceOrientation = e => { - if (e.gamma == null || e.beta == null) return - if (e.beta > 90) return + const tilt = deviceOrientationToNorm( + { alpha: e.alpha, beta: e.beta, gamma: e.gamma }, + this.#alphaBase, + ) + if (!tilt) return + this.#alphaBase = tilt.alphaBase + this.#setTarget(tilt.gammaNorm, tilt.betaNorm) + } - // --- Beta (вертикаль) --- - // beta 0° = лежит, 45° = в руке (нейтраль), 90° = стоит вертикально. - // Нормализуем так, чтобы BETA_OFFSET (45°) было серединой (0). - const betaNorm = this.#clamp((e.beta - BETA_OFFSET) / BETA_RANGE) + /** + * Указатель на десктопе (мышь и перо). Touch не учитываем: + * иначе скролл на телефоне перебивает гироскоп. + * Позиция → gammaNorm/betaNorm (-1..1) с ослаблением 0.2. + */ + #handlePointerMove = e => { + if (e.pointerType === 'touch') return + const width = window.innerWidth + const height = window.innerHeight + if (!width || !height) return + const gammaNorm = (e.clientX / width) * 2 - 1 + const betaNorm = (e.clientY / height) * 2 - 1 + this.#setTarget(-0.2 * gammaNorm, -0.2 * betaNorm) + } - // --- Gamma (горизонталь) с blend --- + #emitNeutral() { + this.#targetGamma = 0 + this.#targetBeta = 0 + this.#currentGamma = 0 + this.#currentBeta = 0 + this.#velocityGamma = 0 + this.#velocityBeta = 0 + this.#prevGP = -1 + this.#prevBP = -1 + this.#emitValues(50, 50) + } - // 1. Gravity-подход: gamma реагирует на физический наклон лево/право. - // Хорошо работает при наклонённом телефоне, но "замирает" при beta ≈ 90° - // потому что наклон и yaw становятся неразличимы (gimbal lock). - const gravityGamma = this.#clamp(e.gamma / GAMMA_RANGE) + #motionBlocked() { + return this.config.respectReducedMotion !== false && this.#prefersReducedMotion() + } - // 2. Yaw-подход: дельта alpha реагирует на поворот вокруг вертикальной оси. - // Не зависит от beta, но нужна начальная точка отсчёта. - const uprightness = Math.sin(e.beta * DEG2RAD) - const { blend, azimuthGamma, newBase } = this.#computeYawBlend(uprightness, e.alpha ?? 0, this.#alphaBase) - this.#alphaBase = newBase + #prefersReducedMotion() { + if (typeof matchMedia !== 'function') return false + try { + return matchMedia(REDUCED_MOTION_QUERY).matches + } catch { + return false + } + } - // 3. Итоговый gamma: интерполяция между двумя подходами. - // blend = 0 → чистый gravity, blend = 1 → чистый azimuth. - const gammaNorm = gravityGamma * (1 - blend) + azimuthGamma * blend + #isSecureContext() { + if (typeof window === 'undefined') return true + if (typeof window.isSecureContext === 'boolean') return window.isSecureContext + return true + } - this.#setTarget(gammaNorm, betaNorm) + #watchMotionPreference() { + if (this.config.respectReducedMotion === false) return + if (typeof matchMedia !== 'function') return + if (this.#motionMedia) return + + this.#motionMedia = matchMedia(REDUCED_MOTION_QUERY) + this.#onMotionMediaChange = (event) => { + if (!this.#active) return + if (event.matches) { + this.#detachSources() + this.#emitNeutral() + return + } + void this.start() + } + this.#motionMedia.addEventListener('change', this.#onMotionMediaChange) } - /** - * Обработчик мыши (десктоп-фоллбэк). - * Позиция курсора → gammaNorm/betaNorm (-1..1) с ослаблением 0.2. - */ - #handleMouseMove = e => { - const gammaNorm = (e.clientX / window.innerWidth) * 2 - 1; - const betaNorm = (e.clientY / window.innerHeight) * 2 - 1 - this.#setTarget(-0.2 * gammaNorm, -0.2 * betaNorm) + #unwatchMotionPreference() { + if (this.#motionMedia && this.#onMotionMediaChange) { + this.#motionMedia.removeEventListener('change', this.#onMotionMediaChange) + } + this.#motionMedia = null + this.#onMotionMediaChange = null + } + + #armRuntime() { + this.#startBatteryCheck() + this.#startAnimationLoop() + this.#watchMotionPreference() + } + + async #readBatteryLevel() { + if (this.config.minBattery <= 0) return null + if (typeof navigator === 'undefined' || typeof navigator.getBattery !== 'function') return null + try { + const battery = await navigator.getBattery() + if (!battery || typeof battery.level !== 'number') return null + return battery.level + } catch { + return null + } + } + + #batteryTooLow(level) { + if (level == null || level >= this.config.minBattery) return false + this.#warn(`Battery low (${(level * 100).toFixed(0)}%), stopping sensors`) + this.stop() + this.dispatchEvent(new CustomEvent('lowbattery', { detail: { level } })) + return true } // ============================================================= @@ -388,30 +518,45 @@ export default class GyroShine extends EventTarget { * экрана (portrait/landscape), не нужно компенсировать вручную. */ async #initOrientationSensor() { - if (!window.RelativeOrientationSensor) return false + const SensorCtor = window.RelativeOrientationSensor + if (typeof SensorCtor !== 'function') return false + let sensor try { - this.#sensor = new RelativeOrientationSensor({ + sensor = new SensorCtor({ frequency: SENSOR_FREQUENCY, referenceFrame: 'screen', }) + this.#sensor = sensor - this.#sensor.addEventListener('reading', () => { - const tilt = this.#quaternionToTilt(this.#sensor) + this.#onSensorReading = () => { + if (this.#sensor !== sensor) return + const tilt = this.#quaternionToTilt(sensor) this.#setTarget(tilt.gammaNorm, tilt.betaNorm) - }) + } + sensor.addEventListener('reading', this.#onSensorReading) const ok = await new Promise(resolve => { - this.#sensor.addEventListener('reading', () => resolve(true), { once: true }) - this.#sensor.addEventListener('error', e => { - this.#error('OrientationSensor error:', e.error.message) - resolve(false) + let settled = false + const finish = (value) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + const timer = setTimeout(() => finish(false), SENSOR_START_TIMEOUT) + sensor.addEventListener('reading', () => finish(true), { once: true }) + sensor.addEventListener('error', (event) => { + const message = event?.error?.message ?? 'unknown' + this.#error('OrientationSensor error:', message) + finish(false) }, { once: true }) - this.#sensor.start() + sensor.start() }) - if (!ok) { - this.#sensor = null + if (!ok || this.#sensor !== sensor) { + this.#releaseSensor(sensor) + if (this.#sensor === sensor) this.#sensor = null return false } @@ -419,11 +564,22 @@ export default class GyroShine extends EventTarget { return true } catch (err) { this.#warn('RelativeOrientationSensor init failed:', err.message) - this.#sensor = null + if (sensor) this.#releaseSensor(sensor) + if (this.#sensor === sensor) this.#sensor = null return false } } + #releaseSensor(sensor) { + if (!sensor) return + if (this.#onSensorReading) sensor.removeEventListener('reading', this.#onSensorReading) + try { + sensor.stop() + } catch { + // Sensor may already be stopped or failed before start(). + } + } + /** * Преобразует кватернион ориентации в gammaNorm/betaNorm с blend-подходом. * @@ -501,39 +657,64 @@ export default class GyroShine extends EventTarget { this.#log('Using deviceorientation') } - #initMouse() { + #initPointer() { if (!this.config.useMouse) return - if (!this.#mouseMoveHandler) { - this.#mouseMoveHandler = this.#throttle(this.#handleMouseMove, this.config.refreshRate) + if (!this.#pointerHandler) { + this.#pointerHandler = this.#throttle(this.#handlePointerMove, this.config.refreshRate) } - window.addEventListener('mousemove', this.#mouseMoveHandler, { - passive: true, - }) - - this.#log('Using mouse fallback') + window.addEventListener('pointermove', this.#pointerHandler, { passive: true }) + this.#log('Using pointer fallback') } - async #requestIOSPermission() { + /** + * Вызывает requestPermission() синхронно, до любых await в start(). + * На iOS 13+ разрешение выдаётся только из user gesture и только по HTTPS. + * @returns {Promise<'unavailable'|'insecure'|'skipped'|'granted'|'denied'>} + */ + #kickIOSPermission() { if (typeof DeviceOrientationEvent === 'undefined' || typeof DeviceOrientationEvent.requestPermission !== 'function') { - return true + return Promise.resolve('unavailable') } - try { - this.#log('Requesting iOS permission...') - const result = await DeviceOrientationEvent.requestPermission() + if (!this.#isSecureContext()) return Promise.resolve('insecure') - if (result === 'granted') { - this.#log('iOS permission granted') - return true - } + const activation = typeof navigator !== 'undefined' ? navigator.userActivation : undefined + if (activation && activation.isActive === false) { + this.#warn('iOS motion permission needs a user gesture') + this.dispatchEvent(new CustomEvent('permissionneeded')) + return Promise.resolve('skipped') + } - this.#warn('iOS permission denied') - return false + this.#log('Requesting iOS permission...') + try { + return DeviceOrientationEvent.requestPermission() + .then((result) => { + if (result === 'granted') { + this.#log('iOS permission granted') + return 'granted' + } + // Chromium может вернуть "prompt", если диалог не закрыт выбором. + // Это не отказ: остаётся fallback и событие permissionneeded. + if (result === 'prompt') { + this.#warn('Motion permission still prompt') + this.dispatchEvent(new CustomEvent('permissionneeded', { detail: { result } })) + return 'skipped' + } + this.#warn('iOS permission denied') + this.dispatchEvent(new CustomEvent('permissionneeded', { detail: { result } })) + return 'denied' + }) + .catch((err) => { + this.#error('iOS permission error:', err?.message) + this.dispatchEvent(new CustomEvent('permissionneeded', { detail: { error: err?.message } })) + return 'denied' + }) } catch (err) { - this.#error('iOS permission error:', err.message) - return false + this.#error('iOS permission error:', err?.message) + this.dispatchEvent(new CustomEvent('permissionneeded', { detail: { error: err?.message } })) + return Promise.resolve('denied') } } @@ -542,28 +723,17 @@ export default class GyroShine extends EventTarget { // ============================================================= async #checkBattery() { - if (this.config.minBattery <= 0) return true - - const battery = await navigator.getBattery?.() - if (!battery) return true - - if (battery.level < this.config.minBattery) { - this.#warn(`Battery low (${(battery.level * 100).toFixed(0)}%), stopping sensors`) - this.stop() - this.dispatchEvent( - new CustomEvent('lowbattery', { - detail: { level: battery.level }, - }), - ) - return false - } - + const level = await this.#readBatteryLevel() + if (this.#batteryTooLow(level)) return false return true } #startBatteryCheck() { if (this.config.minBattery <= 0) return - this.#batteryCheckInterval = setInterval(() => this.#checkBattery(), BATTERY_CHECK_INTERVAL) + this.#stopBatteryCheck() + this.#batteryCheckInterval = setInterval(() => { + void this.#checkBattery() + }, BATTERY_CHECK_INTERVAL) } #stopBatteryCheck() { @@ -580,40 +750,96 @@ export default class GyroShine extends EventTarget { /** * Запускает отслеживание ориентации. * Автоматически выбирает лучший доступный источник данных. + * На iOS вызывайте из обработчика клика: requestPermission() требует user gesture. */ async start() { - const batteryOk = await this.#checkBattery() - if (!batteryOk) return + const gen = ++this.#generation + this.#detachSources() + this.#unwatchMotionPreference() + this.#active = true + + if (this.#motionBlocked()) { + this.#emitNeutral() + this.#watchMotionPreference() + return + } - this.#startBatteryCheck() - this.#startAnimationLoop() + // Уровень батареи читаем параллельно, но не await-им до запроса разрешения: + // await до requestPermission() сбрасывает user gesture на iOS. + const levelPromise = this.#readBatteryLevel() - if (this.#initTelegramAPI()) return + if (this.#initTelegramAPI()) { + const level = await levelPromise + if (gen !== this.#generation || !this.#active) return + if (this.#batteryTooLow(level)) return + this.#armRuntime() + return + } + + const permissionPromise = this.#kickIOSPermission() + const level = await levelPromise + if (gen !== this.#generation || !this.#active) return + if (this.#batteryTooLow(level)) return + + this.#armRuntime() - const hasPermission = await this.#requestIOSPermission() - if (!hasPermission) return + if (!this.#isSecureContext()) { + console.warn('[GyroShine] Motion sensors need a secure context (HTTPS or localhost). Using pointer fallback.') + await permissionPromise + if (gen !== this.#generation || !this.#active) return + this.#initPointer() + return + } - if (await this.#initOrientationSensor()) return + const permission = await permissionPromise + if (gen !== this.#generation || !this.#active) return + + if (permission === 'denied' || permission === 'skipped' || permission === 'insecure') { + this.#initPointer() + return + } + + if (await this.#initOrientationSensor()) { + if (gen !== this.#generation || !this.#active) { + this.#detachSources() + return + } + return + } + if (gen !== this.#generation || !this.#active) return this.#initDeviceOrientation() - this.#initMouse() + this.#initPointer() } - /** Останавливает отслеживание ориентации и освобождает ресурсы. */ + /** Останавливает отслеживание ориентации и снимает все слушатели. */ stop() { + this.#generation++ + this.#active = false + this.#detachSources() + this.#unwatchMotionPreference() + this.#log('Stopped') + } + + #detachSources() { this.#stopBatteryCheck() this.#stopAnimationLoop() if (this.#twaOrientation) { - this.#twaOrientation.stop() + try { + this.#twaOrientation.stop() + } catch { + // Telegram может уже остановить датчик. + } window.Telegram?.WebApp?.offEvent('deviceOrientationChanged', this.#twaHandler) this.#twaHandler = null this.#twaOrientation = null } if (this.#sensor) { - this.#sensor.stop() + this.#releaseSensor(this.#sensor) this.#sensor = null + this.#onSensorReading = null } if (this.#deviceOrientationHandler) { @@ -621,16 +847,13 @@ export default class GyroShine extends EventTarget { this.#deviceOrientationHandler = null } - if (this.#mouseMoveHandler) { - window.removeEventListener('mousemove', this.#mouseMoveHandler) - this.#mouseMoveHandler = null + if (this.#pointerHandler) { + window.removeEventListener('pointermove', this.#pointerHandler) + this.#pointerHandler = null } - // Сброс yaw-баз для корректного рестарта this.#alphaBase = null this.#azimuthBase = null - - this.#log('Stopped') } /** @param {string} eventName @param {Function} callback */ diff --git a/test/tilt.test.js b/test/tilt.test.js new file mode 100644 index 0000000..65aad81 --- /dev/null +++ b/test/tilt.test.js @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { describe, test } from 'node:test' +import GyroShine, { + computeYawBlend, + deviceOrientationToNorm, + formatPercent, + normToPercent, + normsToCssVars, +} from '../sensors.js' + +test('formatPercent rounds to two decimals', () => { + assert.equal(formatPercent(50), '50.00') + assert.equal(formatPercent(0), '0.00') + assert.equal(formatPercent(100), '100.00') + assert.equal(formatPercent(64.285714), '64.29') +}) + +test('normToPercent maps -1..1 onto 0..100 with 50 at rest', () => { + assert.equal(normToPercent(-1), '0.00') + assert.equal(normToPercent(0), '50.00') + assert.equal(normToPercent(1), '100.00') + assert.equal(normToPercent(0.5), '75.00') + assert.deepEqual(normsToCssVars(-1, 1), { + gammaPercent: '0.00', + betaPercent: '100.00', + }) +}) + +test('deviceorientation ignores empty and upside-down samples', () => { + assert.equal(deviceOrientationToNorm({ alpha: 0, beta: 45, gamma: null }, null), null) + assert.equal(deviceOrientationToNorm({ alpha: 0, beta: null, gamma: 0 }, null), null) + assert.equal(deviceOrientationToNorm({ alpha: 0, beta: 91, gamma: 0 }, null), null) +}) + +test('flat-in-hand pose is neutral gamma and a held beta of 45°', () => { + const tilt = deviceOrientationToNorm({ alpha: 10, beta: 45, gamma: 0 }, null) + assert.ok(tilt) + assert.equal(tilt.gammaNorm, 0) + assert.equal(tilt.betaNorm, 0) + assert.deepEqual(normsToCssVars(tilt.gammaNorm, tilt.betaNorm), { + gammaPercent: '50.00', + betaPercent: '50.00', + }) +}) + +test('gamma ±70° reaches the horizontal edges while the phone is in hand', () => { + const right = deviceOrientationToNorm({ alpha: 0, beta: 0, gamma: 70 }, null) + const left = deviceOrientationToNorm({ alpha: 0, beta: 0, gamma: -70 }, null) + assert.ok(right && left) + assert.deepEqual(normsToCssVars(right.gammaNorm, right.betaNorm), { + gammaPercent: '100.00', + betaPercent: '0.00', + }) + assert.deepEqual(normsToCssVars(left.gammaNorm, left.betaNorm), { + gammaPercent: '0.00', + betaPercent: '0.00', + }) +}) + +test('a 35° tilt while the phone is in hand is 75% gamma', () => { + const tilt = deviceOrientationToNorm({ alpha: 0, beta: 45, gamma: 35 }, null) + assert.ok(tilt) + assert.equal(tilt.gammaNorm, 0.5) + assert.equal(tilt.betaNorm, 0) + assert.equal(normToPercent(tilt.gammaNorm), '75.00') +}) + +test('upright yaw uses alpha delta instead of a dead gamma', () => { + const tilt = deviceOrientationToNorm({ alpha: 35, beta: 90, gamma: 0 }, 0) + assert.ok(tilt) + assert.equal(tilt.gammaNorm, 0.5) + assert.equal(tilt.betaNorm, 1) + assert.equal(tilt.alphaBase, 0) + assert.deepEqual(normsToCssVars(tilt.gammaNorm, tilt.betaNorm), { + gammaPercent: '75.00', + betaPercent: '100.00', + }) +}) + +test('yaw blend freezes the base once the phone is upright and wraps ±180°', () => { + const tilted = computeYawBlend(0.5, 40, 10) + assert.equal(tilted.blend, 0) + assert.equal(tilted.azimuthGamma, 0) + assert.equal(tilted.newBase, 40) + + const upright = computeYawBlend(1, 35, 0) + assert.equal(upright.blend, 1) + assert.equal(upright.azimuthGamma, 0.5) + assert.equal(upright.newBase, 0) + + const wrapped = computeYawBlend(1, -170, 170) + assert.equal(wrapped.blend, 1) + assert.ok(Math.abs(wrapped.azimuthGamma - 20 / 70) < 1e-12) +}) + +test('sensors.js stays a single small file', () => { + const bytes = fs.statSync(new URL('../sensors.js', import.meta.url)).size + assert.ok(bytes < 40_000, `sensors.js is ${bytes} bytes`) +}) + +function installBrowser({ + secure = true, + userActive = false, + requestPermission, + sensor, + batteryLevel, + reducedMotion = false, +} = {}) { + const listeners = new Map() + const windowMock = { + isSecureContext: secure, + innerWidth: 1000, + innerHeight: 500, + addEventListener(type, fn) { + const list = listeners.get(type) ?? [] + list.push(fn) + listeners.set(type, list) + }, + removeEventListener(type, fn) { + const list = (listeners.get(type) ?? []).filter((item) => item !== fn) + listeners.set(type, list) + }, + } + if (sensor !== undefined) windowMock.RelativeOrientationSensor = sensor + + const mediaListeners = [] + const defineGlobal = (name, value) => { + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }) + } + + defineGlobal('window', windowMock) + defineGlobal('navigator', { + userActivation: { isActive: userActive, hasBeenActive: userActive }, + getBattery: batteryLevel == null + ? undefined + : async () => ({ level: batteryLevel }), + }) + defineGlobal('matchMedia', (query) => ({ + matches: reducedMotion && query.includes('prefers-reduced-motion'), + addEventListener(_type, fn) { mediaListeners.push(fn) }, + removeEventListener(_type, fn) { + const index = mediaListeners.indexOf(fn) + if (index >= 0) mediaListeners.splice(index, 1) + }, + })) + + if (requestPermission) { + defineGlobal('DeviceOrientationEvent', { requestPermission }) + } else { + delete globalThis.DeviceOrientationEvent + } + + return { + listeners, + mediaListeners, + count(type) { + return (listeners.get(type) ?? []).length + }, + emit(type, event) { + for (const fn of [...(listeners.get(type) ?? [])]) fn(event) + }, + } +} + +describe('GyroShine lifecycle', { concurrency: 1 }, () => { + test('deviceorientation updates CSS percents and stop removes listeners', async () => { + const fake = installBrowser() + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + const events = [] + gyro.on('change', (e) => events.push({ ...e.detail })) + + await gyro.start() + assert.equal(fake.count('deviceorientation'), 1) + assert.equal(fake.count('pointermove'), 1) + + fake.emit('deviceorientation', { alpha: 0, beta: 45, gamma: 0 }) + assert.deepEqual(events.at(-1), { gammaPercent: '50.00', betaPercent: '50.00' }) + + fake.emit('deviceorientation', { alpha: 0, beta: 0, gamma: 70 }) + assert.deepEqual(events.at(-1), { gammaPercent: '100.00', betaPercent: '0.00' }) + + gyro.stop() + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 0) + + const before = events.length + fake.emit('deviceorientation', { alpha: 0, beta: 0, gamma: -70 }) + assert.equal(events.length, before) + }) + + test('pointer fallback ignores touch and maps the corner to a small offset', async () => { + const fake = installBrowser() + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + const events = [] + gyro.on('change', (e) => events.push({ ...e.detail })) + await gyro.start() + + fake.emit('pointermove', { pointerType: 'touch', clientX: 0, clientY: 0 }) + assert.equal(events.length, 0) + + fake.emit('pointermove', { pointerType: 'mouse', clientX: 0, clientY: 0 }) + assert.deepEqual(events.at(-1), { gammaPercent: '60.00', betaPercent: '60.00' }) + gyro.stop() + }) + + test('iOS permission is not requested without a user gesture', async () => { + let calls = 0 + const fake = installBrowser({ + requestPermission: async () => { + calls += 1 + return 'granted' + }, + }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + let needed = 0 + gyro.on('permissionneeded', () => { needed += 1 }) + + await gyro.start() + assert.equal(calls, 0) + assert.equal(needed, 1) + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 1) + + gyro.stop() + assert.equal(fake.count('pointermove'), 0) + assert.equal(fake.mediaListeners.length, 0) + }) + + test('a prompt result is not treated as a permanent denial', async () => { + const fake = installBrowser({ + userActive: true, + requestPermission: async () => 'prompt', + }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + let needed = 0 + gyro.on('permissionneeded', () => { needed += 1 }) + await gyro.start() + assert.equal(needed, 1) + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 1) + gyro.stop() + }) + + test('iOS permission from a user gesture subscribes to deviceorientation', async () => { + const fake = installBrowser({ + userActive: true, + requestPermission: async () => 'granted', + }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + await gyro.start() + assert.equal(fake.count('deviceorientation'), 1) + gyro.stop() + assert.equal(fake.count('deviceorientation'), 0) + }) + + test('insecure context never subscribes to deviceorientation', async () => { + const fake = installBrowser({ secure: false }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false, debug: true }) + await gyro.start() + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 1) + gyro.stop() + }) + + test('prefers-reduced-motion keeps the neutral CSS values', async () => { + const fake = installBrowser({ reducedMotion: true }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0 }) + const events = [] + gyro.on('change', (e) => events.push({ ...e.detail })) + await gyro.start() + assert.deepEqual(events.at(-1), { gammaPercent: '50.00', betaPercent: '50.00' }) + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 0) + gyro.stop() + }) + + test('low battery stops before sensors are attached', async () => { + const fake = installBrowser({ batteryLevel: 0.1 }) + const gyro = new GyroShine({ minBattery: 0.4, refreshRate: 0, respectReducedMotion: false }) + const levels = [] + gyro.on('lowbattery', (e) => levels.push(e.detail.level)) + await gyro.start() + assert.deepEqual(levels, [0.1]) + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 0) + }) + + test('a failed orientation sensor falls back and stop releases it', async () => { + let stopped = false + class BadSensor extends EventTarget { + start() { + queueMicrotask(() => this.dispatchEvent(new Event('error'))) + } + stop() { stopped = true } + } + const fake = installBrowser({ sensor: BadSensor }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + await gyro.start() + assert.equal(fake.count('deviceorientation'), 1) + assert.equal(fake.count('pointermove'), 1) + gyro.stop() + assert.equal(stopped, true) + assert.equal(fake.count('deviceorientation'), 0) + }) + + test('a live orientation sensor does not also listen for deviceorientation', async () => { + class GoodSensor extends EventTarget { + quaternion = [0, 0, 0, 1] + start() { + queueMicrotask(() => this.dispatchEvent(new Event('reading'))) + } + stop() {} + } + const fake = installBrowser({ sensor: GoodSensor }) + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + const events = [] + gyro.on('change', (e) => events.push({ ...e.detail })) + await gyro.start() + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 0) + assert.equal(events.at(-1)?.betaPercent, '0.00') + assert.equal(events.at(-1)?.gammaPercent, '50.00') + gyro.stop() + }) + + test('start is idempotent and does not stack listeners', async () => { + const fake = installBrowser() + const gyro = new GyroShine({ minBattery: 0, refreshRate: 0, respectReducedMotion: false }) + await gyro.start() + await gyro.start() + assert.equal(fake.count('deviceorientation'), 1) + assert.equal(fake.count('pointermove'), 1) + gyro.stop() + assert.equal(fake.count('deviceorientation'), 0) + assert.equal(fake.count('pointermove'), 0) + }) +})