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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "sensor",
"install": "bash .cursor/install.sh",
"start": "exec node scripts/demo-server.js"
}
10 changes: 10 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
blob-report/
playwright/.cache/
64 changes: 64 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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**.
5 changes: 3 additions & 2 deletions AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ gyro.on('change', (e) => {
gyro.start();
```

На iOS Safari `start()` должен вызываться из обработчика пользовательского действия (клик), иначе запрос разрешения на доступ к сенсорам не сработает.
На iOS Safari `start()` должен вызываться из обработчика пользовательского действия (клик), иначе запрос разрешения на доступ к сенсорам не сработает. Датчики движения доступны только в secure context (HTTPS или localhost); иначе остаётся fallback на указатель.

### React

Expand Down Expand Up @@ -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) |
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 не публикуется.


## Быстрый старт

Expand All @@ -17,16 +19,18 @@
Инструкция: https://raw.githubusercontent.com/alexstep/sensor/main/AI.md
```

или ручками:
или ручками (`sensors.js` — ES-модуль, не классический script):
```html
<script src="sensors.js"></script>
<script>
<script type="module">
import GyroShine from './sensors.js'

const gyro = new GyroShine()
gyro.start()
gyro.on('change', e => {
document.documentElement.style.setProperty('--gyro-gamma-percent', e.detail.gammaPercent)
document.documentElement.style.setProperty('--gyro-beta-percent', e.detail.betaPercent)
})
// На iOS start() нужно вызвать из клика/тапа. Датчики работают только по HTTPS.
gyro.start()
</script>
```

Expand All @@ -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

Expand All @@ -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, // вывод логов в консоль
})
```
Expand Down Expand Up @@ -89,6 +95,8 @@ gyro.on('change', e => {

Нейтральное положение устройства - `50.00` / `50.00`.

**`permissionneeded`** — iOS не выдал разрешение (нет user gesture, отказ или ошибка). Можно показать кнопку и снова вызвать `start()` из её обработчика.

**`lowbattery`** - вызывается при низком заряде (датчики автоматически останавливаются):

```js
Expand Down
101 changes: 101 additions & 0 deletions e2e/demo.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
36 changes: 36 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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',
},
},
]
13 changes: 9 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/alexstep/sensor" class="github-link" target="_blank" rel="noopener noreferrer" aria-label="GitHub">GitHub</a>
<form action="javascript:void(0);" onsubmit="return false;">
<fieldset>
<label for="motion" title="Отслеживание наклона устройства">CSS блики</label>
<label for="css_shines" title="Отслеживание наклона устройства">CSS блики</label>
<input type="checkbox" id="css_shines" class="toggle" checked>
</fieldset>
<fieldset>
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading