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