tikitaki 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/API.md +513 -0
  2. package/ARCHITECTURE.md +237 -0
  3. package/LICENSE +21 -0
  4. package/PUBLISHING.md +72 -0
  5. package/README.md +306 -2
  6. package/TESTING.md +793 -0
  7. package/dist/config.d.ts +156 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +161 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/dom-adapter.d.ts +5 -0
  12. package/dist/dom-adapter.d.ts.map +1 -0
  13. package/dist/dom-adapter.js +51 -0
  14. package/dist/dom-adapter.js.map +1 -0
  15. package/dist/dom-element.d.ts +5 -0
  16. package/dist/dom-element.d.ts.map +1 -0
  17. package/dist/dom-element.js +15 -0
  18. package/dist/dom-element.js.map +1 -0
  19. package/dist/dom-renderer.d.ts +61 -0
  20. package/dist/dom-renderer.d.ts.map +1 -0
  21. package/dist/dom-renderer.js +553 -0
  22. package/dist/dom-renderer.js.map +1 -0
  23. package/dist/dom.d.ts +6 -0
  24. package/dist/dom.d.ts.map +1 -0
  25. package/dist/dom.js +4 -0
  26. package/dist/dom.js.map +1 -0
  27. package/dist/gesture.d.ts +85 -0
  28. package/dist/gesture.d.ts.map +1 -0
  29. package/dist/gesture.js +415 -0
  30. package/dist/gesture.js.map +1 -0
  31. package/dist/index.d.ts +5 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +3 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/navigation.d.ts +34 -0
  36. package/dist/navigation.d.ts.map +1 -0
  37. package/dist/navigation.js +157 -0
  38. package/dist/navigation.js.map +1 -0
  39. package/dist/swipe-engine.d.ts +45 -0
  40. package/dist/swipe-engine.d.ts.map +1 -0
  41. package/dist/swipe-engine.js +720 -0
  42. package/dist/swipe-engine.js.map +1 -0
  43. package/package.json +98 -4
  44. package/src/config.ts +168 -0
  45. package/src/dom-adapter.ts +55 -0
  46. package/src/dom-element.ts +16 -0
  47. package/src/dom-renderer.ts +642 -0
  48. package/src/dom.ts +5 -0
  49. package/src/gesture.ts +548 -0
  50. package/src/index.ts +13 -0
  51. package/src/navigation.ts +198 -0
  52. package/src/swipe-engine.ts +805 -0
package/API.md ADDED
@@ -0,0 +1,513 @@
1
+ # API
2
+
3
+ Текущая версия использует функциональные фабрики `createNavigationModel`,
4
+ `createGestureEngine`, `createDOMRenderer` и `createSwipeEngine`. Прежние имена
5
+ обозначают интерфейсы возвращаемых операций; `DOMRenderer` больше не имеет
6
+ параметра типа. Методы используют замыкания и могут вызываться без receiver.
7
+ Настройки и строковые токены находятся в src/config.ts; исходные импорты не
8
+ содержат .js, а сборка формирует корректные ESM specifiers и declarations.
9
+
10
+ ## Реализовано на этапе 1
11
+
12
+ Экспорт `tikitaki` предоставляет `NavigationModel` и типы:
13
+
14
+ ```ts
15
+ interface Position {
16
+ readonly row: number;
17
+ readonly col: number;
18
+ }
19
+
20
+ type Direction = 'up' | 'down' | 'left' | 'right';
21
+ type Grid<T> = readonly (readonly T[])[];
22
+ ```
23
+
24
+ `NavigationModel<T>` — синхронная модель, независимая от DOM. Экспорт модели
25
+ на этапе alpha не означает готовность контрактов полного SwipeEngine.
26
+
27
+ | Вызов | Результат |
28
+ | -------------------------------------------------- | ---------------------------------------------------------------- |
29
+ | `createNavigationModel<T>(grid, initialPosition?)` | Проверяет и копирует структуру сетки; по умолчанию `(0, 0)` |
30
+ | `getPosition(): Position` | Замороженная текущая позиция |
31
+ | `getItem(position): T` | Значение ячейки без перемещения |
32
+ | `getNeighbor(direction): Position \| null` | Замороженный сосед либо `null` на границе; состояние не меняется |
33
+ | `navigate(direction): boolean` | Один соседний переход; `false` на границе |
34
+ | `goTo(position): boolean` | Прямой переход; `false` для текущей позиции |
35
+ | `updateGrid(grid): void` | Атомарная замена сетки и коррекция позиции |
36
+
37
+ ## Направления и границы
38
+
39
+ | Направление свайпа | Допустимая целевая позиция |
40
+ | ------------------ | -------------------------- |
41
+ | `up` | `(row + 1, 0)` |
42
+ | `down` | `(row - 1, 0)` |
43
+ | `left` | `(row, col + 1)` |
44
+ | `right` | `(row, col - 1)` |
45
+
46
+ На границе полностью сохраняется позиция, включая столбец. Вертикальный
47
+ переход не запоминает прежние столбцы. Один вызов `navigate` — максимум одна
48
+ соседняя ячейка. `goTo` допускает любой валидный адрес.
49
+
50
+ ## Валидация и владение данными
51
+
52
+ - Grid — непустой массив непустых массивов, без пропусков индексов.
53
+ `undefined`, `null` и произвольные объекты допустимы как значения `T`.
54
+ - Координаты — неотрицательные safe integers в пределах сетки.
55
+ - `TypeError`: неверная форма сетки/строки/позиции, пропуски индексов,
56
+ ненумерические координаты или неизвестное направление.
57
+ - `RangeError`: пустая сетка/строка, дробные, отрицательные, небезопасные,
58
+ бесконечные координаты, `NaN` или позиция вне сетки.
59
+ - Невалидный `goTo`, `getItem`, `navigate` или `updateGrid` не меняет состояние.
60
+ - Структура сетки копируется и замораживается. Объекты содержимого сохраняют
61
+ идентичность и остаются под управлением вызывающей стороны.
62
+ - Полученную позицию нельзя изменять. Сохранённый снимок позиции не меняется
63
+ при последующих переходах. Экземпляры независимы.
64
+
65
+ При `updateGrid` сначала валидируется весь новый ввод. После успешной проверки:
66
+
67
+ ```text
68
+ row = min(previous.row, newGrid.length - 1)
69
+ col = min(previous.col, newGrid[row].length - 1)
70
+ ```
71
+
72
+ Это политика коррекции при замене данных; правило `col = 0` относится к успешным
73
+ вертикальным переходам, а не к обновлению сетки.
74
+
75
+ ## Gesture Engine — реализовано на этапе 2
76
+
77
+ Экспортируются `GestureEngine`, замороженный `DEFAULT_GESTURE_OPTIONS` и типы
78
+ `GestureOptions`, `GestureContext`, `PointerSample`, `GestureState`, `GestureAxis`,
79
+ `GestureOffset`, `GestureSnapshot`, `GestureSettlement`.
80
+
81
+ ```ts
82
+ interface GestureContext {
83
+ readonly width: number;
84
+ readonly height: number;
85
+ readonly neighbors: Readonly<Record<Direction, boolean>>;
86
+ }
87
+
88
+ interface PointerSample {
89
+ readonly pointerId: number;
90
+ readonly x: number;
91
+ readonly y: number;
92
+ readonly time: number;
93
+ }
94
+
95
+ type GestureState = 'idle' | 'tracking' | 'dragging' | 'settling';
96
+ type GestureAxis = 'x' | 'y';
97
+
98
+ interface GestureOffset {
99
+ readonly x: number;
100
+ readonly y: number;
101
+ }
102
+
103
+ interface GestureSnapshot {
104
+ readonly state: GestureState;
105
+ readonly axis: GestureAxis | null;
106
+ readonly pointerId: number | null;
107
+ readonly offset: GestureOffset;
108
+ readonly destroyed: boolean;
109
+ }
110
+
111
+ interface GestureSettlement {
112
+ readonly direction: Direction | null;
113
+ readonly from: GestureOffset;
114
+ readonly to: GestureOffset;
115
+ readonly duration: number;
116
+ }
117
+ ```
118
+
119
+ `neighbors` содержит все четыре флага: вычисляйте их через
120
+ `model.getNeighbor(direction) !== null`. Размеры — CSS px, конечные числа в
121
+ `(0, Number.MAX_SAFE_INTEGER]`. Пока контейнер имеет нулевой размер, владелец
122
+ не должен создавать или обновлять контекст жеста.
123
+
124
+ `x/y` — CSS px (обычно `clientX/clientY`), `time` — монотонные ms (обычно
125
+ `PointerEvent.timeStamp`). Координаты конечны и по модулю не больше
126
+ `Number.MAX_SAFE_INTEGER`; время в `[0, Number.MAX_SAFE_INTEGER]`.
127
+ `pointerId` — неотрицательный safe integer. Тип устройства не влияет на расчёт.
128
+ Новый жест может начинаться с новой временной шкалы; внутри жеста более старые
129
+ samples игнорируются. Совпадающее время допустимо и не создаёт деления на ноль.
130
+
131
+ ### Параметры
132
+
133
+ Все поля `GestureOptions` — readonly числа. Конструктор принимает
134
+ `Partial<GestureOptions>`; отсутствующие поля берутся из defaults.
135
+
136
+ | Параметр | Единицы | Default | Допустимый диапазон |
137
+ | ------------------- | -------------------------------------------------- | ------- | --------------------- |
138
+ | `distanceThreshold` | CSS px итогового смещения от начала по locked axis | 80 | Конечное число `> 0` |
139
+ | `velocityThreshold` | CSS px/ms, модуль недавней скорости по locked axis | 0.5 | Конечное число `> 0` |
140
+ | `axisLockThreshold` | CSS px, максимум `abs(dx), abs(dy)` | 8 | Конечное число `> 0` |
141
+ | `animationDuration` | ms плана завершения/возврата | 300 | Конечное число `>= 0` |
142
+ | `edgeResistance` | Безразмерный коэффициент | 0.35 | `[0, 1]` |
143
+
144
+ Пороги включительны: `abs(delta) >= distanceThreshold` **или**
145
+ `abs(velocity) >= velocityThreshold`, при наличии соседа. Используется физическое
146
+ смещение, а не сжатое сопротивлением. Итоговое нулевое смещение не переключает
147
+ ячейку. Если быстрая недавняя скорость направлена против итогового смещения,
148
+ переход отменяется даже при достаточном расстоянии. Медленная коррекция не
149
+ отменяет достаточное расстояние. После пересечения начала возможен обратный переход.
150
+
151
+ Ось фиксируется при достижении axisLockThreshold, когда `abs(dx) != abs(dy)`;
152
+ точная диагональ остаётся tracking. Скорость использует текущий отрезок движения
153
+ за последние 80 ms с интерполяцией границы окна. На развороте или возобновлении
154
+ после неподвижного sample отрезок начинается с предыдущего sample. Пауза перед
155
+ pointerup входит в оценку скорости и подавляет старый flick. История ограничена
156
+ 64 точками и разреживается примерно через 2 ms, а не накапливается за весь жест.
157
+
158
+ Для доступного соседа drag ограничен одним размером viewport по оси.
159
+ На границе: `sign(delta) * extent * edgeResistance * abs(delta) / (extent + abs(delta))`.
160
+ Сопротивление непрерывно, симметрично, ограничено `extent * edgeResistance`;
161
+ при нуле коэффициента экран неподвижен. Граница никогда не даёт переход.
162
+
163
+ ### Методы и lifecycle
164
+
165
+ | Вызов | Результат |
166
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
167
+ | `createGestureEngine(context, options?)` | Проверяет и копирует контекст и параметры; состояние idle |
168
+ | `getOptions(): Readonly<GestureOptions>` | Замороженные фактические параметры |
169
+ | `getSnapshot(): GestureSnapshot` | Замороженный снимок; offset тоже заморожен |
170
+ | `pointerDown(sample): boolean` | `true`, если начал tracking; при занятости/destroy — `false` |
171
+ | `pointerMove(sample): boolean` | `true`, если sample принят; чужой ID, старое время или отсутствие активного жеста — `false` |
172
+ | `pointerUp(sample): GestureSettlement \| null` | Учитывает финальные координаты; выдаёт план из dragging, tap завершает без плана |
173
+ | `pointerCancel(pointerId): GestureSettlement \| null` | Для активного ID: возврат dragging через settling, tracking → idle |
174
+ | `lostPointerCapture(pointerId): GestureSettlement \| null` | Та же семантика, что у pointerCancel |
175
+ | `finishSettling(plan): boolean` | Единожды завершает только текущий исходный объект плана |
176
+ | `updateContext(context): void` | Атомарно заменяет размеры/соседей и сбрасывает активный жест или settling |
177
+ | `destroy(): void` | Терминальный идемпотентный сброс; освобождает историю и токен |
178
+
179
+ План — замороженный объект и токен завершения одновременно. `direction: null`
180
+ означает возврат в `(0, 0)`; иначе target — ровно один размер viewport в сторону
181
+ движения. Новые жесты блокируются до `finishSettling(plan)`, в том числе при
182
+ `duration: 0`: в этом случае владелец завершает план сразу после получения.
183
+ Во время settling `getSnapshot().offset` остаётся `plan.from`; анимационный
184
+ прогресс будет хранить renderer. Повторные pointerup/cancel/lost capture в
185
+ settling не создают новых планов и не отменяют переход.
186
+
187
+ `finishSettling` сравнивает объект по ссылке: копия, старый план и план другого
188
+ экземпляра возвращают `false`. После валидного updateContext/destroy старый план
189
+ тоже возвращает `false`. Уничтоженный движок игнорирует ввод и updateContext;
190
+ snapshot показывает idle и `destroyed: true`. Повторная инициализация — новый экземпляр.
191
+
192
+ `TypeError`: неверная форма объектов, неизвестный option, ненумерический параметр
193
+ или sample, отсутствующий/неboolean neighbor. `RangeError`: неконечные числа
194
+ (`NaN`, Infinity), значения вне диапазона и неверный pointerId.
195
+ Ошибка валидации не меняет состояние. Значения полей options, явно равные
196
+ `undefined`, не подменяются defaults. Заблокированный pointerDown и события
197
+ без активного жеста игнорируются до валидации; при активном жесте форма sample
198
+ валидируется до проверки ID/времени. У cancel/lost capture проверяется ID.
199
+
200
+ ### Граница интеграции
201
+
202
+ Владелец нормализует Pointer Events и фильтрует неподходящие кнопки/устройства,
203
+ берёт capture при принятом pointerdown и освобождает при окончании/отмене.
204
+ GestureEngine сам не вызывает DOM APIs и не меняет NavigationModel.
205
+
206
+ После анимации проверять токен **до изменения модели**:
207
+
208
+ ```ts
209
+ if (gesture.finishSettling(plan)) {
210
+ if (plan.direction !== null) {
211
+ model.navigate(plan.direction);
212
+ }
213
+ gesture.updateContext(contextFromModel(model));
214
+ }
215
+ ```
216
+
217
+ `contextFromModel` — функция владельца, вычисляющая контекст после navigate.
218
+ При обновлении сетки
219
+ или геометрии владелец согласованно отменяет визуальную транзакцию и capture,
220
+ после валидного обновления передаёт новый context. Невалидный context не прерывает
221
+ текущий жест. Публичные DOM bindings остаются следующим этапом; renderer описан ниже.
222
+
223
+ ## DOM Renderer — реализовано на этапе 3
224
+
225
+ Импорт из `tikitaki/dom`: `DOMRenderer`, `createDOMAdapter` и следующие типы.
226
+ Headless импорт `tikitaki` не требует DOM libraries в TypeScript consumer.
227
+
228
+ ```ts
229
+ interface MountedCell<T> {
230
+ update(item: T, position: Position): void;
231
+ unmount(): void;
232
+ }
233
+
234
+ interface CellAdapter<T> {
235
+ mount(host: HTMLElement, item: T, position: Position): MountedCell<T>;
236
+ }
237
+
238
+ interface NavigationSource<T> {
239
+ getPosition(): Position;
240
+ getNeighbor(direction: Direction): Position | null;
241
+ getItem(position: Position): T;
242
+ }
243
+
244
+ interface RendererSize {
245
+ readonly width: number;
246
+ readonly height: number;
247
+ }
248
+
249
+ interface DOMRendererOptions<T> {
250
+ readonly container: HTMLElement;
251
+ readonly navigation: NavigationSource<T>;
252
+ readonly adapter: CellAdapter<T>;
253
+ readonly onResize?: (size: RendererSize) => void;
254
+ }
255
+ ```
256
+
257
+ NavigationModel подходит как NavigationSource. Размеры берутся из clientWidth и
258
+ clientHeight в CSS px; контейнеру нужно задать размеры в CSS. Нулевой размер
259
+ допустим для скрытого контейнера: содержимое монтируется, viewport скрывается,
260
+ движение блокируется до положительного resize. Требуется активный документ
261
+ Google Chrome с ResizeObserver и requestAnimationFrame.
262
+
263
+ | Вызов | Семантика |
264
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------- |
265
+ | `createDOMRenderer<T>(options)` | Валидирует источник, монтирует окно, выполняет initial paint, подключает ResizeObserver |
266
+ | `getSize(): RendererSize` | Замороженный cached размер; не читает layout |
267
+ | `setOffset(offset): boolean` | Принимает последнее смещение для следующего RAF; при занятости/hidden/destroy/lifecycle возвращает false |
268
+ | `animate(plan): Promise<boolean>` | true после финального кадра, false при невозможности или отмене; без очереди |
269
+ | `sync(): void` | Читает источник, отменяет анимацию, обновляет окно с offset 0 и рисует синхронно |
270
+ | `reset(): void` | Отменяет анимацию/drag, сбрасывает offset и рисует; не вызывает update содержимого |
271
+ | `destroy(): void` | Идемпотентно отменяет RAF/Promise, отключает observer, unmount всех ячеек и восстанавливает контейнер |
272
+
273
+ ### Координаты и планы
274
+
275
+ Смещение `GestureOffset` должно быть конечным, по одной оси, не дальше одного
276
+ viewport. Невалидные setOffset вызывают TypeError/RangeError до изменения offset.
277
+ Pointer moves коалесцируются в один RAF без чтения размеров. В обычных кадрах
278
+ settling также выполняются только записи transforms.
279
+
280
+ `animate` принимает GestureSettlement. Для up target `(0, -height)`, down
281
+ `(0, height)`, left `(-width, 0)`, right `(width, 0)`. Это направление движения
282
+ экрана: сосед up исходно находится снизу. Для `direction: null` target `(0, 0)`.
283
+ From/to должны лежать на одной оси, совпадать с текущей геометрией; duration —
284
+ конечное число `>= 0` ms. Отсутствующий сосед даёт false; некорректный план
285
+ отклоняет Promise, не нарушая текущий визуальный кадр.
286
+
287
+ При ненулевой duration первый RAF показывает from, затем `ease(t) = 1 - (1 - t)^3` до to. Duration 0
288
+ завершается за один RAF. Promise не меняет NavigationModel: после true владелец
289
+ проверяет токен жеста, применяет navigate и вызывает renderer.sync(). Выживший
290
+ целевой host сохраняется и оказывается в `(0, 0)` без повторного mount.
291
+
292
+ Во время анимации другой animate возвращает false, setOffset — false. Любые
293
+ reset/sync/destroy и фактический resize завершают pending Promise с false.
294
+ После true target остаётся нарисованным до sync/reset. Новые жесты до commit
295
+ должен блокировать владелец/GestureEngine. После destroy sync/reset — no-op,
296
+ движение/анимация возвращают false, getSize возвращает последний снимок.
297
+
298
+ ### Окно и владение DOM
299
+
300
+ Renderer хранит максимум пять hosts keyed по координатам. Не создаёт
301
+ несуществующих соседей; вертикальные адреса всегда `(row ± 1, 0)`. Сохранившиеся
302
+ hosts получают handle.update при каждом sync, даже если item не сменил identity;
303
+ ушедшие handle.unmount вызываются перед удалением host. Mount/update/unmount
304
+ синхронны, позиции заморожены. Адаптер владеет потомками host и освобождает свои
305
+ подписки и framework-ресурсы. Renderer потомков не очищает и не стилизует.
306
+
307
+ Shell role/inert/aria-hidden выставляются до mount/update; соседние ячейки inert
308
+ и скрыты от accessibility tree. При продвижении соседа его содержимое остаётся
309
+ в том же host. Render callbacks не должны менять принадлежащие renderer
310
+ свойства shell. Вызовы setOffset/animate из lifecycle блокируются; рекурсивный
311
+ sync вызывает исключение. Destroy разрешён и остаётся идемпотентным.
312
+
313
+ `createDOMAdapter(renderCell)` — удобный адаптер для обычного HTML. Callback
314
+ возвращает отдельный HTMLElement: mount вставляет его, update заменяет результат,
315
+ unmount удаляет. Возврат того же своего элемента допустим; уже принадлежащий
316
+ другому DOM-дереву элемент отклоняется. Это не способ преобразования
317
+ Vue/React-компонентов: для них нужен отдельный CellAdapter.
318
+
319
+ Renderer добавляет одну собственную viewport-оболочку и сохраняет прочих детей
320
+ контейнера. Position меняется на relative только для static контейнера;
321
+ overflow/touch-action задаются локально. Destroy восстанавливает исходные
322
+ inline значения и priorities только если свойство всё ещё равно установленному
323
+ движком: пользовательские изменения сохраняются. Глобальные styles не меняются.
324
+ Конкурирующие renderers одного контейнера запрещены; после destroy можно создать новый.
325
+
326
+ ### Resize и ошибки
327
+
328
+ ResizeObserver только планирует кадр. В нём читается размер, пересчитываются
329
+ позиции, сбрасывается offset, отменяется анимация и затем вызывается onResize
330
+ с замороженным размером. Повторные уведомления коалесцируются, неизменившийся
331
+ размер не прерывает анимацию. Владелец должен сбросить GestureEngine и capture
332
+ при изменении геометрии. При нулевом размере нужно приостановить ввод; не
333
+ передавать нулевые размеры в GestureEngine (его контекст требует положительных
334
+ значений). После появления контейнера можно обновить контекст и продолжить.
335
+
336
+ Невалидная форма options/source/adapter вызывает TypeError; неверные адреса
337
+ соседей или геометрия — RangeError. Полный снимок source читается до изменений:
338
+ ошибка чтения/валидации при sync сохраняет прежнее визуальное состояние и Promise.
339
+ Ошибки lifecycle после начала reconciliation приводят к destroy окна и пробрасываются.
340
+ Все unmount выполняются даже при исключениях, после чего cleanup может бросить
341
+ AggregateError. Если mount бросает до возврата handle, он обязан сам освободить
342
+ созданные внешние ресурсы; renderer в любом случае удаляет свою оболочку.
343
+ Исключение onResize также приводит к cleanup и пробрасывается в RAF.
344
+
345
+ Renderer не подписывается на pointer events и не управляет capture. Эти ресурсы
346
+ принадлежат SwipeEngine.
347
+
348
+ ## SwipeEngine: этап 4
349
+
350
+ `import { createSwipeEngine, type SwipeEngineOptions } from 'tikitaki/dom'`.
351
+ Фабрика связывает независимые слои; `tikitaki` остаётся без DOM declarations.
352
+
353
+ ```ts
354
+ interface SwipeEngineBaseOptions<T> {
355
+ readonly container: HTMLElement;
356
+ readonly grid: Grid<T>;
357
+ readonly initialPosition?: Position;
358
+ readonly gesture?: Partial<GestureOptions>;
359
+ readonly onPositionChange?: (position: Position, previous: Position) => void;
360
+ readonly onError?: (error: Error) => void;
361
+ }
362
+
363
+ type SwipeEngineOptions<T> = SwipeEngineBaseOptions<T> &
364
+ (
365
+ | { readonly renderCell: (item: T, position: Position) => HTMLElement; readonly adapter?: never }
366
+ | { readonly adapter: CellAdapter<T>; readonly renderCell?: never }
367
+ );
368
+ ```
369
+
370
+ Нужен HTMLElement из активного документа с Pointer Events/capture, RAF и
371
+ ResizeObserver. Один контейнер принадлежит одному renderer/движку. Размеры
372
+ задаются снаружи через CSS. Нулевой размер допустим: ввод и анимированная
373
+ навигация приостановлены до положительного resize. Настройки жестов и defaults
374
+ совпадают с GestureEngine. Сетка и координаты валидируются моделью.
375
+
376
+ Передать ровно один из `renderCell` или `adapter`; это проверяется в TypeScript
377
+ и runtime. `renderCell` использует обычный DOM-адаптер, который может заменять
378
+ содержимое при sync. Для сохранения собственного состояния используйте CellAdapter.
379
+ Framework-specific компоненты требуют mount/update/unmount своего фреймворка.
380
+
381
+ | Метод | Семантика |
382
+ | --------------------------------------- | -------------------------------------------------------------------------------------------- |
383
+ | `getPosition(): Position` | Замороженная последняя успешно показанная позиция, доступна после destroy |
384
+ | `navigate(direction): Promise<boolean>` | Анимация к одному соседу; позиция меняется после последнего кадра и sync |
385
+ | `goTo(position): Promise<boolean>` | Немедленный переход к любой допустимой позиции без анимации; работает и при нулевых размерах |
386
+ | `updateGrid(grid): void` | Синхронная замена сетки и sync; сохраняет допустимые координаты, иначе ограничивает row/col |
387
+ | `destroy(): void` | Терминальная идемпотентная очистка, без callback позиции |
388
+
389
+ ### Promise, занятость и обновление данных
390
+
391
+ `true` означает успешное изменение позиции и DOM с завершённым callback.
392
+ `false` означает границу, ту же позицию, занятость, отмену или destroy.
393
+ `navigate` также возвращает false для нулевого viewport. Новые команды не ставятся
394
+ в очередь во время tracking/dragging/settling или структурного изменения.
395
+ Невалидные аргументы живого движка отклоняют Promise даже при занятости;
396
+ проверка не прерывает текущий жест/переход.
397
+
398
+ `updateGrid` сначала полностью валидирует новую сетку. Невалидная замена бросает
399
+ исключение и сохраняет текущую транзакцию. Валидная отменяет анимацию с false,
400
+ освобождает capture, сбрасывает жесты и синхронизирует DOM. Callback вызывается
401
+ только при изменении координат; смена содержимого на прежних координатах не вызывает его.
402
+ Вызов updateGrid из adapter lifecycle запрещён и бросает исключение.
403
+
404
+ Resize отменяет текущую транзакцию с false, сбрасывает offset/capture/жест,
405
+ обновляет геометрию без перехода и callback. После destroy navigate/goTo дают
406
+ false, updateGrid ничего не делает; валидация новых аргументов уже не выполняется.
407
+ Все незавершённые Promise завершаются при отмене/destroy; поздний кадр не может
408
+ зафиксировать старый переход поверх нового.
409
+
410
+ ### Callbacks и ошибки
411
+
412
+ Callbacks синхронные. onPositionChange вызывается после успешного sync DOM и
413
+ снятия блокировки: можно сразу вызвать navigate/goTo/updateGrid/destroy.
414
+ При создании движка и возврате отменённого жеста callback не вызывается.
415
+ Исключение callback не откатывает уже показанную позицию: исходный Promise
416
+ отклоняется, а следующая операция продолжает работать. Для updateGrid оно
417
+ пробрасывается синхронно. Асинхронные callbacks не ожидаются.
418
+
419
+ Ошибка adapter lifecycle во время изменения приводит к destroy с освобождением
420
+ всех доступных ресурсов и отклонению операции; getPosition сохраняет последнюю
421
+ успешную позицию. Ошибки cleanup могут объединяться в AggregateError.
422
+ Ошибка конструктора очищает уже созданные ресурсы перед исключением.
423
+ Программные navigate/goTo возвращают ошибки через Promise, updateGrid/destroy
424
+ бросают синхронно. В обработчиках Pointer Events ошибки передаются onError;
425
+ по умолчанию — window.reportError (либо ErrorEvent). Исключение самого onError
426
+ передаётся этому же стандартному механизму.
427
+
428
+ ### Pointer Events и cleanup
429
+
430
+ Принимаются primary mouse/touch/pen с основной кнопкой; повторное касание
431
+ не заменяет активное. Capture ставится на исходный Element, сохраняя нативный
432
+ tap/click кнопок. В dragging подавляются случайный pointer click, выделение и
433
+ native drag; keyboard click с detail 0 сохраняется. Дочерние вложенные движки
434
+ владеют своими событиями через composedPath и локальный data-tikitaki-engine.
435
+
436
+ pointercancel/lostpointercapture запускают возврат без изменения позиции.
437
+ Window listeners дополнительно проверяют capture при move/up/cancel, включая
438
+ удаление захватившего элемента до первого движения. При destroy удаляются
439
+ контейнерные и window listeners, capture, RAF, observer, hosts и ссылки на
440
+ callbacks/adapter/модель. Локальные styles и marker восстанавливаются, если
441
+ пользователь их не изменил. После destroy можно создать новый экземпляр.
442
+
443
+ ## Framework adapters: этап 5
444
+
445
+ Это отдельные необязательные пакеты: `@tikitaki_gagarinbefree/react` и `@tikitaki_gagarinbefree/vue`.
446
+ Они объявляют framework/core peer dependencies и не входят в `tikitaki` exports
447
+ или runtime/peer dependencies. ESM и declarations собираются в packages/*/dist.
448
+ Примеры и lifecycle: [React](./packages/react/README.md),
449
+ [Vue](./packages/vue/README.md); работающие demos — в demos/react и demos/vue.
450
+
451
+ | Экспорт | Контракт |
452
+ | -------------------------------------------------------- | ----------------------------------------------------------------- |
453
+ | `createReactCellAdapter<T>(): ReactCellBridge<T>` | Создаёт adapter и внешний snapshot store для одного engine view |
454
+ | `ReactCellPortals<T>({ bridge, renderCell }): ReactNode` | useSyncExternalStore + portals в hosts, внутри родительского root |
455
+ | `createVueCellAdapter<T>(): VueCellBridge<T>` | Создаёт adapter и readonly shallowRef cells |
456
+ | `VueCellPortals<T>({ bridge, renderCell }): VNodeChild` | Функциональный Vue-компонент, который создаёт Teleport в hosts |
457
+
458
+ React bridge имеет readonly `adapter`, `getSnapshot()` и `subscribe(listener)`.
459
+ Subscribe возвращает идемпотентный unsubscribe. Vue bridge имеет readonly
460
+ `adapter` и `cells: Readonly<ShallowRef<readonly VueCell<T>[]>>`.
461
+ Запись ячейки содержит readonly `key`, `host: HTMLElement`, `item: T`, `position`.
462
+ Массивы, записи и скопированные позиции заморожены; item сохраняет identity и
463
+ может быть изменяемым. Vue не оборачивает hosts/items в proxy.
464
+
465
+ Один bridge относится к одному экземпляру движка и одному Portals-компоненту.
466
+ Создавайте bridge один раз через useState factory или в setup. Передавайте
467
+ `bridge.adapter` в SwipeEngine вместо renderCell; framework renderCell передаётся
468
+ Portals-компоненту и возвращает ReactNode/VNodeChild. Контейнер должен быть без
469
+ framework children; framework управляет только потомками предоставленных hosts.
470
+
471
+ mount регистрирует host с новым ключом, update сохраняет ключ и component state,
472
+ unmount удаляет запись. Повторный unmount и update старого handle ничего не делают.
473
+ Повторный mount того же host после unmount получает новый ключ. Одновременный
474
+ дублирующий mount host вызывает TypeError. Исчезнувшие соседи освобождаются,
475
+ сохранившиеся координаты получают новые props без принудительного remount.
476
+
477
+ Переход и destroy синхронизируют shells и adapter snapshots синхронно, а framework
478
+ content/lifecycle — в framework commit. Для assert содержимого используйте async
479
+ act в React и nextTick в Vue после engine Promise. Promise ядра не гарантирует
480
+ завершения framework effects. Не вызывайте hooks прямо в renderCell: возвращайте
481
+ дочерний компонент. Внешний provider/контекст сохраняется через portals/Teleport.
482
+
483
+ React error boundaries и Vue errorHandler/onErrorCaptured обрабатывают ошибки
484
+ рендеринга/effects компонентов. Они не перехватываются engine.onError, поскольку
485
+ выполняются вне синхронного CellAdapter lifecycle. Владелец UI обязан вызывать
486
+ engine.destroy при cleanup; удаление одного Portals-компонента не уничтожает engine.
487
+ При обновлении props grid вызывайте updateGrid, не пересоздавайте engine на каждый
488
+ render. Vue engine храните в shallowRef, для явного владения ресурсом.
489
+ SSR не создаёт DOM engine; установка выполняется в useEffect/onMounted.
490
+
491
+ ## Mouse wheel navigation
492
+
493
+ The DOM controller handles vertical wheel input only inside its owned cell viewport.
494
+ Positive deltaY navigates to the next row; negative deltaY navigates to the previous
495
+ row. Successful row changes reset col to zero; boundaries preserve the entire position.
496
+ Each wheel burst starts at most one transition. A quiet interval of 160ms separates
497
+ bursts (WHEEL_IDLE_MS in src/config.ts), and conflicting pointer gestures or settling
498
+ transitions block wheel navigation without queuing. Delta units do not affect direction.
499
+ The page does not scroll while vertical wheel input is consumed over the viewport,
500
+ including at boundaries. Outside the viewport, page and map scrolling remain native.
501
+ Horizontal gestures, Shift-wheel, Ctrl/Meta-wheel zoom, and events already cancelled
502
+ by child content are ignored. destroy() removes the non-passive wheel listener.
503
+
504
+ ## Svelte and Angular adapters
505
+
506
+ `@tikitaki_gagarinbefree/svelte` exports `createSvelteCellAdapter<T>()`, a readonly cell store,
507
+ and the `portal` action. Render keyed cells under the parent Svelte application.
508
+ `@tikitaki_gagarinbefree/angular` exports `createAngularCellAdapter<T>(createView)`. Its factory
509
+ receives `{ $implicit: item, position }` and must return a parent-owned embedded
510
+ view, normally from `ViewContainerRef.createEmbeddedView`. Both implement the
511
+ core CellAdapter contract, preserve surviving component identity during updates,
512
+ and clean up owned content on unmount. Framework commits remain asynchronous:
513
+ use Svelte tick or Angular rendering stabilization before assertions.