react-native-gtkx 0.3.0-alpha.1 → 0.4.0-alpha.2

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 (151) hide show
  1. package/README.md +1 -1
  2. package/dist/adw/index.d.ts +3 -2
  3. package/dist/adw/index.js +21 -4
  4. package/dist/adw/index.js.map +1 -1
  5. package/dist/adw/widgets.generated.d.ts +1 -1
  6. package/dist/adw/widgets.generated.js +2 -2
  7. package/dist/adw/widgets.generated.js.map +1 -1
  8. package/dist/animated/create-animated.d.ts +2 -0
  9. package/dist/animated/create-animated.js +2 -0
  10. package/dist/animated/create-animated.js.map +1 -1
  11. package/dist/animated/event.d.ts +15 -0
  12. package/dist/animated/event.js +80 -0
  13. package/dist/animated/event.js.map +1 -0
  14. package/dist/animated/index.d.ts +4 -0
  15. package/dist/animated/index.js +8 -0
  16. package/dist/animated/index.js.map +1 -1
  17. package/dist/animated/spring.d.ts +10 -0
  18. package/dist/animated/spring.js +20 -6
  19. package/dist/animated/spring.js.map +1 -1
  20. package/dist/animated/timing.d.ts +10 -0
  21. package/dist/animated/timing.js +21 -13
  22. package/dist/animated/timing.js.map +1 -1
  23. package/dist/apis/host.gtkx.js +70 -11
  24. package/dist/apis/host.gtkx.js.map +1 -1
  25. package/dist/common/navigation-stack.d.ts +6 -3
  26. package/dist/common/navigation-stack.js +8 -1
  27. package/dist/common/navigation-stack.js.map +1 -1
  28. package/dist/common/widget.js +2 -11
  29. package/dist/common/widget.js.map +1 -1
  30. package/dist/components/animated.d.ts +1 -0
  31. package/dist/components/animated.js +28 -4
  32. package/dist/components/animated.js.map +1 -1
  33. package/dist/components/app-registry.js +32 -8
  34. package/dist/components/app-registry.js.map +1 -1
  35. package/dist/components/request-animation-frame.d.ts +33 -0
  36. package/dist/components/request-animation-frame.js +83 -0
  37. package/dist/components/request-animation-frame.js.map +1 -0
  38. package/dist/components/scroll-view.js +60 -2
  39. package/dist/components/scroll-view.js.map +1 -1
  40. package/dist/components/virtualized-list.js +1 -4
  41. package/dist/components/virtualized-list.js.map +1 -1
  42. package/dist/dnd/autoscroll.d.ts +44 -0
  43. package/dist/dnd/autoscroll.js +176 -0
  44. package/dist/dnd/autoscroll.js.map +1 -0
  45. package/dist/dnd/grid-order.d.ts +45 -0
  46. package/dist/dnd/grid-order.js +151 -0
  47. package/dist/dnd/grid-order.js.map +1 -0
  48. package/dist/dnd/grid.d.ts +44 -0
  49. package/dist/dnd/grid.js +397 -0
  50. package/dist/dnd/grid.js.map +1 -0
  51. package/dist/dnd/gtk-controllers.d.ts +16 -2
  52. package/dist/dnd/gtk-controllers.js +23 -3
  53. package/dist/dnd/gtk-controllers.js.map +1 -1
  54. package/dist/dnd/index.d.ts +4 -2
  55. package/dist/dnd/index.js +9 -2
  56. package/dist/dnd/index.js.map +1 -1
  57. package/dist/dnd/order-state.d.ts +14 -0
  58. package/dist/dnd/order-state.js +55 -0
  59. package/dist/dnd/order-state.js.map +1 -0
  60. package/dist/dnd/order.d.ts +19 -0
  61. package/dist/dnd/order.js +19 -0
  62. package/dist/dnd/order.js.map +1 -1
  63. package/dist/dnd/sortable.d.ts +29 -4
  64. package/dist/dnd/sortable.js +308 -115
  65. package/dist/dnd/sortable.js.map +1 -1
  66. package/dist/dnd/types.d.ts +236 -14
  67. package/dist/dnd/types.js +30 -0
  68. package/dist/dnd/types.js.map +1 -1
  69. package/dist/gesture-handler-compat/attach-context.d.ts +29 -0
  70. package/dist/gesture-handler-compat/attach-context.js +90 -0
  71. package/dist/gesture-handler-compat/attach-context.js.map +1 -0
  72. package/dist/gesture-handler-compat/detector-runtime.d.ts +14 -3
  73. package/dist/gesture-handler-compat/detector-runtime.js +15 -5
  74. package/dist/gesture-handler-compat/detector-runtime.js.map +1 -1
  75. package/dist/gesture-handler-compat/detector.js +57 -2
  76. package/dist/gesture-handler-compat/detector.js.map +1 -1
  77. package/dist/gesture-handler-compat/gesture-state-manager.d.ts +5 -0
  78. package/dist/gesture-handler-compat/gesture-state-manager.js +77 -0
  79. package/dist/gesture-handler-compat/gesture-state-manager.js.map +1 -0
  80. package/dist/gesture-handler-compat/index.d.ts +2 -2
  81. package/dist/gesture-handler-compat/index.js +38 -33
  82. package/dist/gesture-handler-compat/index.js.map +1 -1
  83. package/dist/gesture-handler-compat/recognizer.d.ts +12 -1
  84. package/dist/gesture-handler-compat/recognizer.js +23 -9
  85. package/dist/gesture-handler-compat/recognizer.js.map +1 -1
  86. package/dist/gesture-handler-compat/tag-registry.d.ts +14 -0
  87. package/dist/gesture-handler-compat/tag-registry.js +19 -0
  88. package/dist/gesture-handler-compat/tag-registry.js.map +1 -0
  89. package/dist/globals/index.d.ts +47 -0
  90. package/dist/globals/index.js +181 -0
  91. package/dist/globals/index.js.map +1 -0
  92. package/dist/globals/install.d.ts +1 -0
  93. package/dist/globals/install.js +14 -0
  94. package/dist/globals/install.js.map +1 -0
  95. package/dist/gtkx/bridge/adw-namespace.d.ts +2 -0
  96. package/dist/gtkx/bridge/adw-namespace.js +26 -0
  97. package/dist/gtkx/bridge/adw-namespace.js.map +1 -0
  98. package/dist/gtkx/bridge/adw.d.ts +23 -0
  99. package/dist/gtkx/bridge/adw.js +93 -0
  100. package/dist/gtkx/bridge/adw.js.map +1 -0
  101. package/dist/gtkx/bridge/color-scheme-parse.d.ts +11 -0
  102. package/dist/gtkx/bridge/color-scheme-parse.js +42 -0
  103. package/dist/gtkx/bridge/color-scheme-parse.js.map +1 -0
  104. package/dist/gtkx/bridge/color-scheme-portal.d.ts +24 -0
  105. package/dist/gtkx/bridge/color-scheme-portal.js +172 -0
  106. package/dist/gtkx/bridge/color-scheme-portal.js.map +1 -0
  107. package/dist/gtkx/bridge/core.d.ts +25 -0
  108. package/dist/gtkx/bridge/core.js +69 -0
  109. package/dist/gtkx/bridge/core.js.map +1 -0
  110. package/dist/gtkx/bridge/drag-layer.d.ts +27 -0
  111. package/dist/gtkx/bridge/drag-layer.js +177 -0
  112. package/dist/gtkx/bridge/drag-layer.js.map +1 -0
  113. package/dist/gtkx/bridge/index.d.ts +1 -27
  114. package/dist/gtkx/bridge/index.js +12 -71
  115. package/dist/gtkx/bridge/index.js.map +1 -1
  116. package/dist/gtkx/bridge/widgets.generated.adw.d.ts +1 -0
  117. package/dist/gtkx/bridge/widgets.generated.adw.js +16 -0
  118. package/dist/gtkx/bridge/widgets.generated.adw.js.map +1 -0
  119. package/dist/gtkx/bridge/widgets.generated.d.ts +0 -1
  120. package/dist/gtkx/bridge/widgets.generated.js +3 -3
  121. package/dist/gtkx/bridge/widgets.generated.js.map +1 -1
  122. package/dist/index.js +20 -0
  123. package/dist/index.js.map +1 -1
  124. package/dist/mcp/data/generated.d.ts +41 -20
  125. package/dist/mcp/data/generated.js +46 -20
  126. package/dist/mcp/data/generated.js.map +1 -1
  127. package/dist/metro/index.d.ts +1 -0
  128. package/dist/metro/index.js +12 -0
  129. package/dist/metro/index.js.map +1 -1
  130. package/dist/navigation/sidebar.js +2 -45
  131. package/dist/navigation/sidebar.js.map +1 -1
  132. package/dist/reanimated-compat/animatable-value.d.ts +158 -0
  133. package/dist/reanimated-compat/animatable-value.js +316 -0
  134. package/dist/reanimated-compat/animatable-value.js.map +1 -0
  135. package/dist/reanimated-compat/animation.d.ts +34 -13
  136. package/dist/reanimated-compat/animation.js +97 -25
  137. package/dist/reanimated-compat/animation.js.map +1 -1
  138. package/dist/reanimated-compat/mutable.js +6 -6
  139. package/dist/reanimated-compat/mutable.js.map +1 -1
  140. package/dist/reanimated-compat/updater-animations.js +32 -12
  141. package/dist/reanimated-compat/updater-animations.js.map +1 -1
  142. package/dist/runner/host-dev.js +5 -1
  143. package/dist/runner/host-dev.js.map +1 -1
  144. package/dist/runner/host.js +9 -1
  145. package/dist/runner/host.js.map +1 -1
  146. package/dist/vite/index.js +130 -0
  147. package/dist/vite/index.js.map +1 -1
  148. package/package.json +1 -1
  149. package/dist/gtkx/bridge/theme.d.ts +0 -4
  150. package/dist/gtkx/bridge/theme.js +0 -5
  151. package/dist/gtkx/bridge/theme.js.map +0 -1
@@ -174,7 +174,7 @@ export const PORTABLE_APIS = [
174
174
  name: "AppRegistry",
175
175
  subpath: "react-native",
176
176
  supported: "`registerComponent`, `runApplication(appKey, {title,width,height,initialProps,chrome,actionAccels,breakpoints,applicationActions,windowActions,windowControllers})`, `getAppKeys`",
177
- differences: 'desktop window parameters; `chrome: "content"` uses an AdwApplicationWindow with no window titlebar — the app\'s HeaderBars (navigation) become the chrome. `actionAccels` binds accelerators to action names on the `GtkApplication`; `breakpoints` reaches `AdwApplicationWindow`\'s own prop and only does anything under `chrome: "content"` (a dev warning fires otherwise). **`applicationActions`/`windowActions`/`windowControllers` are deprecated** — reach for [`<ApplicationActions>`/`<WindowActions>`/`<WindowControllers>`](platform-layer.md#actions-and-shortcuts-declared-in-the-app-tree) instead; they still work unchanged',
177
+ differences: 'desktop window parameters; `chrome: "content"` uses an AdwApplicationWindow with no window titlebar when this app declares `"Adw-1"` — the app\'s HeaderBars (navigation) become the chrome — and falls back to the plain `GtkApplicationWindow` `chrome: "system"` already uses when it does not (see [Plain GTK profile](#plain-gtk-profile) below): request `chrome: "content"` unconditionally and get HeaderBar-as-chrome where Adw exists, an ordinary window otherwise, with no branching of the app\'s own. `actionAccels` binds accelerators to action names on the `GtkApplication`; `breakpoints` reaches `AdwApplicationWindow`\'s own prop and only does anything under `chrome: "content"` WITH `"Adw-1"` declared — otherwise one of two dev warnings fires instead, each at most once per app run: `chrome: "system"` names the wrong chrome, `chrome: "content"` with no `"Adw-1"` names that instead. **`applicationActions`/`windowActions`/`windowControllers` are deprecated** — reach for [`<ApplicationActions>`/`<WindowActions>`/`<WindowControllers>`](platform-layer.md#actions-and-shortcuts-declared-in-the-app-tree) instead; they still work unchanged',
178
178
  },
179
179
  {
180
180
  name: "Platform",
@@ -197,8 +197,8 @@ export const PORTABLE_APIS = [
197
197
  {
198
198
  name: "Appearance",
199
199
  subpath: "react-native",
200
- supported: "`getColorScheme`, `setColorScheme` (AdwStyleManager), `addChangeListener`",
201
- differences: "—",
200
+ supported: "`getColorScheme`, `setColorScheme` (AdwStyleManager; on the plain-GTK profile — no `Adw-1` — sourced from the `org.freedesktop.appearance` `color-scheme` desktop-portal setting instead, with live updates via its `SettingChanged` signal, falling back to `Gtk.Settings:gtk-application-prefer-dark-theme` when no portal answers), `addChangeListener`",
201
+ differences: "On the plain-GTK profile, `setColorScheme` is local-only exactly like the Adw path neither ever writes a SYSTEM-wide preference (`AdwStyleManager` and the fallback `Gtk.Settings` toggle are both per-process only; the portal itself has no write API, by design). With no portal reachable and no explicit `setColorScheme` call yet, the reported scheme is whatever `Gtk.Settings` already defaults to (light) rather than any observed system value",
202
202
  },
203
203
  {
204
204
  name: "useColorScheme",
@@ -215,8 +215,8 @@ export const PORTABLE_APIS = [
215
215
  {
216
216
  name: "Alert",
217
217
  subpath: "react-native",
218
- supported: "`alert(title, message, buttons, options)` → Adw.AlertDialog",
219
- differences: "`cancel`/`destructive`/`isPreferred` styles",
218
+ supported: "`alert(title, message, buttons, options)` → Adw.AlertDialog (`Gtk.AlertDialog`, GTK ≥ 4.10, on the plain-GTK profile — no `Adw-1`)",
219
+ differences: "`cancel`/`destructive`/`isPreferred` styles. On the plain-GTK profile, `destructive`/`isPreferred` appearance (Adw's `ResponseAppearance`) is lost — `Gtk.AlertDialog` has no equivalent, every button renders the same; default/cancel mapping is preserved. `cancelable: false` with no `cancel`-style button cannot be honored there either: `Gtk.AlertDialog` has no way to block Esc/window-close dismissal the way `Adw.AlertDialog.setCanClose(false)` does — add a `cancel`-style button for identical behavior on both profiles",
220
220
  },
221
221
  {
222
222
  name: "Linking",
@@ -275,8 +275,8 @@ export const PORTABLE_APIS = [
275
275
  {
276
276
  name: "Animated",
277
277
  subpath: "react-native",
278
- supported: "`Value`, `timing`, `spring`, `sequence`, `parallel`, `delay`, `loop`, `interpolate` (numbers and deg/rad strings, clamp/extend/identity), `ValueXY` (`setValue`/`setOffset`/`flattenOffset`/`extractOffset`, `getLayout`, `getTranslateTransform`) — the value a `PanResponder` drag writes to",
279
- differences: "`useNativeDriver` is ignored (with a warning); the direct path is native-speed anyway; `Animated.event` is not implemented write the value directly (`pan.setValue({x: g.dx, y: g.dy})`), which is what it would do",
278
+ supported: "`Value`, `timing`, `spring`, `sequence`, `parallel`, `delay`, `loop`, `interpolate` (numbers and deg/rad strings, clamp/extend/identity), `ValueXY` (`setValue`/`setOffset`/`flattenOffset`/`extractOffset`, `getLayout`, `getTranslateTransform`) — the value a `PanResponder` drag writes to; `event(argMapping, config?)` — the arg mapping is positional over the callback's own arguments (an array entry maps that argument, `null` skips it), traversed recursively into plain objects with a leaf that is a `Value`/`ValueXY` written on every call; works attached to `ScrollView`'s `onScroll` (mapping the one event argument) and to `PanResponder` callbacks such as `onPanResponderMove` (mapping `gestureState`, the second argument — RN supports mapping either one); `config.listener` is called with the same arguments, after the mapping has run",
279
+ differences: "`useNativeDriver` is ignored (with a warning); the direct path is native-speed anyway — and because there is no native side to hand the event to, `Animated.event` always returns the plain JS handler, `useNativeDriver` true or false, rather than sometimes returning the opaque attach/detach object upstream does. A mapped path the real event does not carry is tolerated at any depth (silently left unset) rather than thrown, which is a deliberate widening of RN's own traversal — its direct property access throws one level above a missing leaf",
280
280
  },
281
281
  {
282
282
  name: "Easing",
@@ -284,6 +284,12 @@ export const PORTABLE_APIS = [
284
284
  supported: "linear/ease/quad/cubic/in/out/inOut/bezier",
285
285
  differences: "—",
286
286
  },
287
+ {
288
+ name: "`requestAnimationFrame`/`cancelAnimationFrame`",
289
+ subpath: "react-native",
290
+ supported: '**A global, not a module export** — same as in RN itself, which installs both from its own bootstrap (`InitializeCore`) rather than exporting them from `"react-native"`. This platform dropped that bootstrap entirely (there is no mobile environment to polyfill, "the runtime IS Node"), which held for everything except this one browser-ism library code reaches for directly — `react-native-sortables` crashes the whole process at mount without it (docs/research/upstream-libraries.md, "A third experiment"). Installed as a side effect of the package entry (`src/index.ts`), the one module both toolchains load before any app code runs; built on `components/frame-scheduler.ts`\'s `glibScheduler` — the same clock `Animated`/`Reanimated` already share, not a second timer. An id is returned; the callback receives a monotonic, high-resolution timestamp; a callback requested while a batch is running lands on the NEXT frame (never the one currently flushing); cancelling is silent, including for an unknown or already-delivered handle; one callback throwing is reported (`console.error`) and does not stop its siblings in the same batch',
291
+ differences: "parity in behavior; not parity in mechanism — there is no native frame on a Linux desktop the way there is on iOS/Android, so this rides the same ~60 fps GLib timer `Animated` does, the way the DOM's `requestAnimationFrame` stands in for it on react-native-web",
292
+ },
287
293
  {
288
294
  name: "version",
289
295
  subpath: "react-native",
@@ -762,13 +768,18 @@ export const DOC_CHUNKS = [
762
768
  {
763
769
  doc: "docs/api.md",
764
770
  heading: "API modules",
765
- text: '| Export | Supported | Differences |\n| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `StyleSheet` | `create`, `flatten`, `compose`, `absoluteFill(Object)`, `hairlineWidth` | — |\n| `PlatformColor` | Adwaita variables: `PlatformColor("accent-bg-color")` → `var(--...)`, `@named` | names are Adwaita, not iOS/Android |\n| `AppRegistry` | `registerComponent`, `runApplication(appKey, {title,width,height,initialProps,chrome,actionAccels,breakpoints,applicationActions,windowActions,windowControllers})`, `getAppKeys` | desktop window parameters; `chrome: "content"` uses an AdwApplicationWindow with no window titlebar — the app\'s HeaderBars (navigation) become the chrome. `actionAccels` binds accelerators to action names on the `GtkApplication`; `breakpoints` reaches `AdwApplicationWindow`\'s own prop and only does anything under `chrome: "content"` (a dev warning fires otherwise). **`applicationActions`/`windowActions`/`windowControllers` are deprecated** — reach for [`<ApplicationActions>`/`<WindowActions>`/`<WindowControllers>`](platform-layer.md#actions-and-shortcuts-declared-in-the-app-tree) instead; they still work unchanged |\n| `Platform` | `OS: "linux"`, `Version` (GTK), `select` (linux → native → default), `isTV`, `isTesting` | — |\n| `Dimensions` | `get("window"/"screen")`, `addEventListener("change")` | main window only (transient windows are ignored) |\n| `useWindowDimensions` | reactive main-window dimensions | — |\n| `Appearance` | `getColorScheme`, `setColorScheme` (AdwStyleManager), `addChangeListener` | — |\n| `useColorScheme` | reactive theme | — |\n| `AppState` | `currentState` active/background, `addEventListener` | driven by the window\'s `is-active` |\n| `Alert` | `alert(title, message, buttons, options)` → Adw.AlertDialog | `cancel`/`destructive`/`isPreferred` styles |\n| `Linking` | `openURL`, `canOpenURL` (http/https/mailto/file), `getInitialURL` (null), `addEventListener("url")` | system launcher; no deep-link delivery on desktop yet — "url" subscriptions never fire |\n| `InteractionManager` | `runAfterInteractions(task?)` (cancellable, then-able), `createInteractionHandle`/`clearInteractionHandle`, `addListener` | navigation transitions register interactions, so screen work deferred with `runAfterInteractions` waits for the push/pop slide |\n| `DevSettings` | `addMenuItem(title, handler)` (entries in the Dev Menu — Ctrl+Shift+D in `run-linux --dev`, the react-native-windows shortcut), `reload(reason?)` | silent no-ops in release builds, like RN |\n| `I18nManager` | `isRTL` (live: GTK\'s read of the locale text direction), `doLeftAndRightSwapInRTL`, `getConstants` | `allowRTL`/`forceRTL`/`swapLeftAndRightInRTL` are accepted no-ops (mobile persistence has no desktop store) |\n| `BackHandler` | `addEventListener("hardwareBackPress")`, `exitApp` | no hardware back key on desktop — subscriptions are honored but nothing fires them yet |\n| `findNodeHandle` | a stable integer per mounted widget, resolvable back to it; accepted by `measureLayout` as its first argument, alongside a handle object. Takes what RN takes — a component handle, a node handle (returned unchanged), `null`/`undefined`. A windowed list resolves to the `ScrollView` it renders, as RN\'s `FlatList` resolves through to its own scroll view | The tag identifies the WIDGET, not the ref: two refs onto one view report the same number and a re-render that rebuilt the handle object does not change it. It reaches nothing native — there is no `UIManager` to hand a tag to — so it is worth exactly what this platform can resolve it to: `measureLayout`, and identity. `null` for anything that is not a mounted host view (RN\'s answer too) |\n| `Keyboard` | `addListener` (honoured, never fires), `removeAllListeners`, `dismiss`, `isVisible` (always false), `metrics` (always undefined), `scheduleLayoutAnimation` | This is the SOFTWARE keyboard, and a desktop has none: every event it carries describes a panel occluding the app, so none can fire. Subscriptions are real and `remove()` pairs with them (a fake subscription would turn an unmount into a crash) — the same shape as `BackHandler` above, and what react-native-windows inherits from RN core, whose emitter is only ever fed on iOS and Android. **`dismiss()` is a no-op and deliberately not RN\'s**: RN blurs the focused input as the only way to retract the keyboard, and doing that here would let a library\'s gesture steal focus from a form |\n| `LogBox` | `ignoreLogs`, `ignoreAllLogs`, `install`, `uninstall` — accepted and ignored | RN\'s LogBox is a full-screen dev OVERLAY, and `ignoreLogs` has never filtered the console — it keeps a warning out of the yellow box. There is no overlay here, so the console output after the call is already the console output RN would have had, and nothing observable is lost. Called by `react-native-draggable-flatlist` on every `NestableDraggableFlatList` render, to silence a nesting warning this platform does not emit either |\n| `PanResponder` | `create(config)` -> `panHandlers` (spread onto a `View`), full `gestureState` (`dx`/`dy`, `vx`/`vy`, `x0`/`y0`, `moveX`/`moveY`, `numberActiveTouches`) — **react-native\'s own file, vendored unmodified** (MIT, `Libraries/Interaction/PanResponder.js`), running on our reproduction of RN\'s `touchHistory` store | multi-touch `gestureState` is single-touch here (one pointer), and `onShouldBlockNativeResponder`\'s return value is not consumed yet. `onPanResponderTerminationRequest` is asked when an ancestor tries to take the gesture and when an enclosing `ScrollView` scrolls; every other termination is GTK\'s decision and arrives as `onPanResponderTerminate` unasked (see `View`) |\n| `Animated` | `Value`, `timing`, `spring`, `sequence`, `parallel`, `delay`, `loop`, `interpolate` (numbers and deg/rad strings, clamp/extend/identity), `ValueXY` (`setValue`/`setOffset`/`flattenOffset`/`extractOffset`, `getLayout`, `getTranslateTransform`) — the value a `PanResponder` drag writes to | `useNativeDriver` is ignored (with a warning); the direct path is native-speed anyway; `Animated.event` is not implemented — write the value directly (`pan.setValue({x: g.dx, y: g.dy})`), which is what it would do |\n| `Easing` | linear/ease/quad/cubic/in/out/inOut/bezier | — |\n| `version` | package version | extension |\n\n`StyleProp<T>` defaults its type argument, and `ViewStyle`/`TextStyle`/`ImageStyle` are exported as aliases of the one flat style bag this platform has — so `StyleProp<ViewStyle>`, the way ordinary React Native code writes it, compiles here unchanged. `Platform.OS` is typed as the full `PlatformOSType` union (plus `"linux"`), not the `"linux"` literal: comparing it against another platform is a runtime question, and RN\'s own types let that compile everywhere.\n\nStyles (which keys go where and what is unsupported) — [style system table](../packages/react-native-gtkx/src/style/README.md). Includes `boxShadow` (RN 0.76) and `outlineColor`/`outlineOffset`/`outlineStyle`/`outlineWidth` (RN 0.77): both are what Adwaita\'s own theme uses for the `.card`/`.boxed-list` frame and for every focus ring, so they are the difference between a React Native style approximating the platform look and reproducing it — see [research/react-native-first-showcase.md](research/react-native-first-showcase.md).',
771
+ text: '| Export | Supported | Differences |\n| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `StyleSheet` | `create`, `flatten`, `compose`, `absoluteFill(Object)`, `hairlineWidth` | — |\n| `PlatformColor` | Adwaita variables: `PlatformColor("accent-bg-color")` → `var(--...)`, `@named` | names are Adwaita, not iOS/Android |\n| `AppRegistry` | `registerComponent`, `runApplication(appKey, {title,width,height,initialProps,chrome,actionAccels,breakpoints,applicationActions,windowActions,windowControllers})`, `getAppKeys` | desktop window parameters; `chrome: "content"` uses an AdwApplicationWindow with no window titlebar when this app declares `"Adw-1"` — the app\'s HeaderBars (navigation) become the chrome — and falls back to the plain `GtkApplicationWindow` `chrome: "system"` already uses when it does not (see [Plain GTK profile](#plain-gtk-profile) below): request `chrome: "content"` unconditionally and get HeaderBar-as-chrome where Adw exists, an ordinary window otherwise, with no branching of the app\'s own. `actionAccels` binds accelerators to action names on the `GtkApplication`; `breakpoints` reaches `AdwApplicationWindow`\'s own prop and only does anything under `chrome: "content"` WITH `"Adw-1"` declared — otherwise one of two dev warnings fires instead, each at most once per app run: `chrome: "system"` names the wrong chrome, `chrome: "content"` with no `"Adw-1"` names that instead. **`applicationActions`/`windowActions`/`windowControllers` are deprecated** — reach for [`<ApplicationActions>`/`<WindowActions>`/`<WindowControllers>`](platform-layer.md#actions-and-shortcuts-declared-in-the-app-tree) instead; they still work unchanged |\n| `Platform` | `OS: "linux"`, `Version` (GTK), `select` (linux → native → default), `isTV`, `isTesting` | — |\n| `Dimensions` | `get("window"/"screen")`, `addEventListener("change")` | main window only (transient windows are ignored) |\n| `useWindowDimensions` | reactive main-window dimensions | — |\n| `Appearance` | `getColorScheme`, `setColorScheme` (AdwStyleManager; on the plain-GTK profile — no `Adw-1` — sourced from the `org.freedesktop.appearance` `color-scheme` desktop-portal setting instead, with live updates via its `SettingChanged` signal, falling back to `Gtk.Settings:gtk-application-prefer-dark-theme` when no portal answers), `addChangeListener` | On the plain-GTK profile, `setColorScheme` is local-only exactly like the Adw path — neither ever writes a SYSTEM-wide preference (`AdwStyleManager` and the fallback `Gtk.Settings` toggle are both per-process only; the portal itself has no write API, by design). With no portal reachable and no explicit `setColorScheme` call yet, the reported scheme is whatever `Gtk.Settings` already defaults to (light) rather than any observed system value |\n| `useColorScheme` | reactive theme | — |\n| `AppState` | `currentState` active/background, `addEventListener` | driven by the window\'s `is-active` |\n| `Alert` | `alert(title, message, buttons, options)` → Adw.AlertDialog (`Gtk.AlertDialog`, GTK ≥ 4.10, on the plain-GTK profile — no `Adw-1`) | `cancel`/`destructive`/`isPreferred` styles. On the plain-GTK profile, `destructive`/`isPreferred` appearance (Adw\'s `ResponseAppearance`) is lost — `Gtk.AlertDialog` has no equivalent, every button renders the same; default/cancel mapping is preserved. `cancelable: false` with no `cancel`-style button cannot be honored there either: `Gtk.AlertDialog` has no way to block Esc/window-close dismissal the way `Adw.AlertDialog.setCanClose(false)` does — add a `cancel`-style button for identical behavior on both profiles |\n| `Linking` | `openURL`, `canOpenURL` (http/https/mailto/file), `getInitialURL` (null), `addEventListener("url")` | system launcher; no deep-link delivery on desktop yet — "url" subscriptions never fire |\n| `InteractionManager` | `runAfterInteractions(task?)` (cancellable, then-able), `createInteractionHandle`/`clearInteractionHandle`, `addListener` | navigation transitions register interactions, so screen work deferred with `runAfterInteractions` waits for the push/pop slide |\n| `DevSettings` | `addMenuItem(title, handler)` (entries in the Dev Menu — Ctrl+Shift+D in `run-linux --dev`, the react-native-windows shortcut), `reload(reason?)` | silent no-ops in release builds, like RN |\n| `I18nManager` | `isRTL` (live: GTK\'s read of the locale text direction), `doLeftAndRightSwapInRTL`, `getConstants` | `allowRTL`/`forceRTL`/`swapLeftAndRightInRTL` are accepted no-ops (mobile persistence has no desktop store) |\n| `BackHandler` | `addEventListener("hardwareBackPress")`, `exitApp` | no hardware back key on desktop — subscriptions are honored but nothing fires them yet |\n| `findNodeHandle` | a stable integer per mounted widget, resolvable back to it; accepted by `measureLayout` as its first argument, alongside a handle object. Takes what RN takes — a component handle, a node handle (returned unchanged), `null`/`undefined`. A windowed list resolves to the `ScrollView` it renders, as RN\'s `FlatList` resolves through to its own scroll view | The tag identifies the WIDGET, not the ref: two refs onto one view report the same number and a re-render that rebuilt the handle object does not change it. It reaches nothing native — there is no `UIManager` to hand a tag to — so it is worth exactly what this platform can resolve it to: `measureLayout`, and identity. `null` for anything that is not a mounted host view (RN\'s answer too) |\n| `Keyboard` | `addListener` (honoured, never fires), `removeAllListeners`, `dismiss`, `isVisible` (always false), `metrics` (always undefined), `scheduleLayoutAnimation` | This is the SOFTWARE keyboard, and a desktop has none: every event it carries describes a panel occluding the app, so none can fire. Subscriptions are real and `remove()` pairs with them (a fake subscription would turn an unmount into a crash) — the same shape as `BackHandler` above, and what react-native-windows inherits from RN core, whose emitter is only ever fed on iOS and Android. **`dismiss()` is a no-op and deliberately not RN\'s**: RN blurs the focused input as the only way to retract the keyboard, and doing that here would let a library\'s gesture steal focus from a form |\n| `LogBox` | `ignoreLogs`, `ignoreAllLogs`, `install`, `uninstall` — accepted and ignored | RN\'s LogBox is a full-screen dev OVERLAY, and `ignoreLogs` has never filtered the console — it keeps a warning out of the yellow box. There is no overlay here, so the console output after the call is already the console output RN would have had, and nothing observable is lost. Called by `react-native-draggable-flatlist` on every `NestableDraggableFlatList` render, to silence a nesting warning this platform does not emit either |\n| `PanResponder` | `create(config)` -> `panHandlers` (spread onto a `View`), full `gestureState` (`dx`/`dy`, `vx`/`vy`, `x0`/`y0`, `moveX`/`moveY`, `numberActiveTouches`) — **react-native\'s own file, vendored unmodified** (MIT, `Libraries/Interaction/PanResponder.js`), running on our reproduction of RN\'s `touchHistory` store | multi-touch `gestureState` is single-touch here (one pointer), and `onShouldBlockNativeResponder`\'s return value is not consumed yet. `onPanResponderTerminationRequest` is asked when an ancestor tries to take the gesture and when an enclosing `ScrollView` scrolls; every other termination is GTK\'s decision and arrives as `onPanResponderTerminate` unasked (see `View`) |\n| `Animated` | `Value`, `timing`, `spring`, `sequence`, `parallel`, `delay`, `loop`, `interpolate` (numbers and deg/rad strings, clamp/extend/identity), `ValueXY` (`setValue`/`setOffset`/`flattenOffset`/`extractOffset`, `getLayout`, `getTranslateTransform`) — the value a `PanResponder` drag writes to; `event(argMapping, config?)` — the arg mapping is positional over the callback\'s own arguments (an array entry maps that argument, `null` skips it), traversed recursively into plain objects with a leaf that is a `Value`/`ValueXY` written on every call; works attached to `ScrollView`\'s `onScroll` (mapping the one event argument) and to `PanResponder` callbacks such as `onPanResponderMove` (mapping `gestureState`, the second argument — RN supports mapping either one); `config.listener` is called with the same arguments, after the mapping has run | `useNativeDriver` is ignored (with a warning); the direct path is native-speed anyway — and because there is no native side to hand the event to, `Animated.event` always returns the plain JS handler, `useNativeDriver` true or false, rather than sometimes returning the opaque attach/detach object upstream does. A mapped path the real event does not carry is tolerated at any depth (silently left unset) rather than thrown, which is a deliberate widening of RN\'s own traversal — its direct property access throws one level above a missing leaf |\n| `Easing` | linear/ease/quad/cubic/in/out/inOut/bezier | — |\n| `requestAnimationFrame`/`cancelAnimationFrame` | **A global, not a module export** — same as in RN itself, which installs both from its own bootstrap (`InitializeCore`) rather than exporting them from `"react-native"`. This platform dropped that bootstrap entirely (there is no mobile environment to polyfill, "the runtime IS Node"), which held for everything except this one browser-ism library code reaches for directly — `react-native-sortables` crashes the whole process at mount without it (docs/research/upstream-libraries.md, "A third experiment"). Installed as a side effect of the package entry (`src/index.ts`), the one module both toolchains load before any app code runs; built on `components/frame-scheduler.ts`\'s `glibScheduler` — the same clock `Animated`/`Reanimated` already share, not a second timer. An id is returned; the callback receives a monotonic, high-resolution timestamp; a callback requested while a batch is running lands on the NEXT frame (never the one currently flushing); cancelling is silent, including for an unknown or already-delivered handle; one callback throwing is reported (`console.error`) and does not stop its siblings in the same batch | parity in behavior; not parity in mechanism — there is no native frame on a Linux desktop the way there is on iOS/Android, so this rides the same ~60 fps GLib timer `Animated` does, the way the DOM\'s `requestAnimationFrame` stands in for it on react-native-web |\n| `version` | package version | extension |\n\n`StyleProp<T>` defaults its type argument, and `ViewStyle`/`TextStyle`/`ImageStyle` are exported as aliases of the one flat style bag this platform has — so `StyleProp<ViewStyle>`, the way ordinary React Native code writes it, compiles here unchanged. `Platform.OS` is typed as the full `PlatformOSType` union (plus `"linux"`), not the `"linux"` literal: comparing it against another platform is a runtime question, and RN\'s own types let that compile everywhere.\n\nStyles (which keys go where and what is unsupported) — [style system table](../packages/react-native-gtkx/src/style/README.md). Includes `boxShadow` (RN 0.76) and `outlineColor`/`outlineOffset`/`outlineStyle`/`outlineWidth` (RN 0.77): both are what Adwaita\'s own theme uses for the `.card`/`.boxed-list` frame and for every focus ring, so they are the difference between a React Native style approximating the platform look and reproducing it — see [research/react-native-first-showcase.md](research/react-native-first-showcase.md).',
766
772
  },
767
773
  {
768
774
  doc: "docs/api.md",
769
775
  heading: "Key differences from React Native (summary)",
770
776
  text: "1. **Desktop, not mobile**: `Modal` is a real window; `runApplication` accepts a title and dimensions; gestures are mouse-driven (hover works, no touch gestures);\n2. **Node.js runtime**: all of npm/Node is available (fs, sqlite, napi) — \"native modules\" are written as regular Node modules; RN libraries with iOS/Android code do not work;\n3. **Layout is exactly RN's**: every container runs a custom GtkLayoutManager that obeys only the Yoga engine — GTK widget minimums never leak into the layout, windows shrink freely, and `Dimensions.get(\"window\")` reports the app viewport (the window's content area under the headerbar, like RN's app window);\n4. **Text**: the ellipsis is opt-in via `numberOfLines`, exactly like RN; plain text wraps naturally and an unbreakable word wider than its box clips to it (a text leaf always clips; a container paint-overflows until its style says otherwise — see `overflow` below);\n5. **transform** is paint-only, like RN: `translateX/Y`, `scale`, `scaleX`, `scaleY` and `rotate`/`rotateZ` apply to any component's style (not just `Animated.View`), the array composes left to right as in RN and CSS, and the origin is the view's centre. A transformed child honestly draws past its container over siblings (later siblings stay on top unless a `zIndex` says otherwise — see 10 below) without moving any ancestor, and GTK routes input through the transform, so a rotated view is clickable in its rotated shape — unless the container asks to clip, and `overflow: \"hidden\"` on it cuts the transformed child off at the edge exactly as it cuts off an untransformed one. Rotation and scale reach the widget as the `GskTransform` of its allocation (`docs/research/transforms.md`); 3D (`rotateX`/`rotateY`/`perspective`), `skewX`/`skewY`, `matrix` and `transformOrigin` are not supported;\n6. **Animations never auto-stop**: the desktop \"reduce animations\" hint is not applied automatically (GTK-side animations are kept on to match `Animated`, which runs on its own timers) — honoring reduced motion stays an app-level opt-in, as in RN;\n7. **Lists are windowed like RN's**: FlatList/SectionList mount only the rows around the viewport (prefix-sum offsets, `estimatedItemSize` refined by real measurements or exact `getItemLayout`); sticky headers translate the REAL widget (no duplicate) and `inverted` follows the RN chat contract — `contentOffset` counts from the end where `data[0]` renders. The one RefreshControl compromise: desktop has no pull gesture, so `refreshing`/`onRefresh` are API-compatible but the trigger is app chrome (a button/shortcut);\n8. The package ships compiled (`dist/`: ESM + `.d.ts` alongside, sources embedded in the maps); consumers — Metro (`react-native-gtkx/metro` preset) and vite (preset) — both consume the built output. Requires Node ≥ 24 (the gtkx runtime floor; the run-linux host also relies on `module.registerHooks`).\n9. **`zIndex` orders paint AND picking, per sibling group.** GTK4 has no z-order property, so the container widget does it: it allocates its children in Yoga's order and _snapshots_ them in `zIndex` order, and a widget covered by a higher-painting sibling declines `gtk_widget_pick()` so input lands where the pixels are. Layout is untouched — only the paint pass sorts. RN's rules, checked rather than assumed and each pinned by a test: it applies whatever `position` is (CSS needs a non-`static` `position`; RN does not, and neither does this); equal values keep document order (the sort is stable); `undefined` is `0` and negatives are legal and paint below silent siblings; and it is **per sibling group only** — it creates no stacking context that escapes the parent, so a child cannot paint above its parent's sibling. That last rule is the one that decides what you write: to lift a dragged chip over a drop-zone row, put the `zIndex` on the chip's ROW, exactly as on iOS and Android. Animated (`Animated.View`, `useAnimatedStyle`) on the same terms as `opacity` — one widget write, no Yoga pass. **One divergence**: `contains()` is GTK's only per-point hook and it is consulted after a widget's children, so an _interactive native leaf_ inside a covered sibling — a `TextInput`, a `Switch`, a `ScrollView` viewport, a raw GTK widget in a slot — still takes the press even where a raised view covers it. `Text` and `Image` do not (they have no press prop here, so while something is raised they are excluded from picking and the press reaches their nearest `View`), which is also why a `pointerEvents: \"box-none\"` View whose only child is a `Text` falls through to what is behind it while a sibling in that container is raised. Measurements, the probe, the mutation check and the real-pointer proof: [research/z-index.md](research/z-index.md).\n\n10. **Pre-commit hooks regenerate derived data**: editing this file (or the other generator inputs) and forgetting to run `scripts/generate-mcp-data.mjs` no longer fails CI — the pre-commit hook regenerates `packages/react-native-gtkx/src/mcp/data/generated.ts` and stages it for you.",
771
777
  },
778
+ {
779
+ doc: "docs/api.md",
780
+ heading: "Globals",
781
+ text: "React Native installs a whole global environment at startup —\n`Libraries/Core/setUp*.js`, run once from `InitializeCore` before any app\ncode — not just the API modules above. This platform's runtime is Node, so\nmost of that is already there natively; the rest is installed by\n`packages/react-native-gtkx/src/globals/index.ts`, called once from the top\nof the package's own entry point (`src/index.ts`) — the one place both\ntoolchains share, since both alias `react-native` onto this package (see\nPackage aliases below). Verified empirically on both the vite dev path and\nthe Metro/run-linux host, not assumed from reading RN's source.\n\n**Already node-native, nothing installed** — `fetch`/`Headers`/`Request`/\n`Response`, `Blob`/`File`, `WebSocket`, `URL`/`URLSearchParams`,\n`AbortController`/`AbortSignal`, `structuredClone`, `TextEncoder`/\n`TextDecoder`, `atob`/`btoa`, `queueMicrotask`, `setImmediate`/\n`clearImmediate`, `performance` (`.now()` confirmed monotonic), `crypto`,\n`DOMException`, and `console` (Node's own already has `group`/\n`groupCollapsed`/`groupEnd`, so RN's console polyfill — which only replaces\n`console` when a native `nativeLoggingHook` exists — is a no-op on both\ntoolchains here). `FormData` is node-native too, for the spec: **it does\nNOT understand react-native's own `formData.append('photo', {uri, type,\nname})` file-entry shape** — Node coerces the object with `String(value)`\nand silently sends the literal text `\"[object Object]\"` instead of the\nfile. Filed as its own task\n(`.claude/epics/component-gaps/xhr-formdata-uri-upload.md`) alongside\n`XMLHttpRequest`, which is not node-native at all (Node has never shipped\nit) and RN installs unconditionally\n(`Libraries/Core/setUpXHR.js`). `FileReader` is the same story —\n`Libraries/Core/setUpXHR.js` installs it too, Node has no equivalent (its\n`Blob` has `.text()`/`.arrayBuffer()`/`.stream()` but nothing like\n`readAsDataURL()`) — filed separately\n(`.claude/epics/component-gaps/filereader-missing.md`), since reading an\nin-memory `Blob` is independent of the network stack. Redirect/cookie-jar\nbehavioural differences between Node's `fetch` and RN's are out of scope —\nnoted, not chased.\n\n**Installed for parity** (`src/globals/index.ts`, each guarded so an\nexisting global always wins):\n\n- **`window = globalThis`, `self = globalThis`** — the very first thing\n react-native's own setup chain does\n (`Libraries/Core/setUpGlobals.js`, ahead of everything else in\n `InitializeCore`). Node has neither. This exists on a real RN app for the\n same reason it is worth having here: an isomorphic library's\n `typeof window !== \"undefined\"` check — often meaning \"not a server/SSR\n context, safe to run browser-shaped init\" — should read the same way on\n this platform as on any other RN platform.\n- **`navigator.product = \"ReactNative\"`** — the ecosystem's standard\n environment-detection idiom\n (`Libraries/Core/setUpNavigator.js`). Node ≥ 21 already ships a minimal\n `navigator` (`.userAgent` only, e.g. `\"Node.js/24\"` — RN itself never\n sets `userAgent`, so that value is this platform's own, not RN's), so\n this mirrors RN's exact fallback (`Object.defineProperty` onto the\n existing object) rather than assuming a bare one needs creating. Judgment\n call, resolved by evidence rather than caution: every real, currently\n running third-party package in this platform's dependency graph —\n `@gorhom/bottom-sheet`, `@gorhom/portal`, `react-native-drawer-layout`,\n the real `react-native-reanimated-dnd`, `react-native-sortables`,\n `@react-navigation/native` — was grepped directly (source, not memory)\n and **none of them read `navigator.product`**; they branch on\n `Platform.OS` instead (e.g. `react-native-drawer-layout`'s\n `Drawer.native.tsx`: `swipeEnabled = Platform.OS !== 'web' &&\nPlatform.OS !== 'windows' && Platform.OS !== 'macos'`, already correctly\n `true` here since `Platform.OS` is `\"linux\"`). The real upstream\n `react-native-reanimated`/`react-native-gesture-handler` — the two\n libraries this platform substitutes with its own compat surfaces,\n precisely because their real implementations cannot run here — were also\n checked (their actual npm packages, not the compat shims): their\n `PlatformChecker`/web-detection code also uses `Platform.OS`, not\n `navigator.product`. So installing it changes no observed behaviour\n today. It is set anyway because \"RN semantics are the contract\"\n (`CLAUDE.md`) and this is exactly what a real RN app's global environment\n reports — a future library reading it (the idiom exists for a reason:\n `whatwg`-style isomorphic packages use it to pick their RN code path over\n a browser assumption) should get the RN answer, not Node's bare\n `\"Node.js/NN\"` `userAgent` with no `product` at all, which resembles\n neither a browser nor RN.\n- **`requestIdleCallback`/`cancelIdleCallback`** — RN's real one\n (`Libraries/Core/setUpTimers.js`) is a TurboModule with true native idle\n scheduling; Node has none. This installs the standard web-fallback shape\n every userland \"requestidlecallback polyfill\" package uses: fires on the\n next macrotask (`setTimeout(…, options.timeout ?? 1)`), reports a fixed\n 50ms budget through `timeRemaining()`, and `didTimeout` is always\n `false`. Good enough for \"run this off the current tick, eventually\" —\n not a real scheduling primitive, and code that depends on genuine idle\n detection should not rely on it.\n- **`global.alert`** — RN's own (`Libraries/Core/setUpAlert.js`) forwards a\n single string to `Alert.alert('Alert', text)`; this does the same against\n the platform's real `Alert` (Adw.AlertDialog) module above.\n- **`ErrorUtils`** — not in RN's `InitializeCore` JS chain at all, but\n required by it: `Libraries/Core/setUpErrorHandling.js` reads\n `global.ErrorUtils` and expects it to already exist. On a real RN app it\n does, via `@react-native/js-polyfills`' `error-guard.js`, which\n `@react-native/metro-config`'s `getDefaultConfig` prepends to **every**\n Metro bundle unconditionally — independent of, and not disabled by, this\n platform's own `serializer.getModulesRunBeforeMainModule: () => []`.\n Confirmed empirically: the Metro/run-linux host already had `ErrorUtils`\n before this change (Metro's own polyfill), the vite dev path did not —\n a genuine cross-toolchain gap this closes, with a faithful port of the\n real polyfill (`setGlobalHandler`/`reportError`/`reportFatalError`/\n `applyWithGuard`/`guard`; the default handler rethrows, exactly RN's\n un-hooked behaviour).\n\n**`__DEV__`** is bundler-provided, not something either RN or this globals\nmodule sets, and both presets were checked directly rather than assumed:\nthe vite preset (`src/vite/index.ts`) defines it from vite's own `mode`\n(`__DEV__: JSON.stringify(env.mode !== \"production\")`) — found necessary\nafter a library reading it at module scope (`@gorhom/bottom-sheet`'s logger\nand four of its components) crashed the bundle with `ReferenceError:\n__DEV__ is not defined` on the vite path, where nothing else supplies it.\nThe Metro path gets it from the app's own stock `@react-native/metro-config`\npreset (`metro-transform-plugins`' inline-requires transform, driven by\n`--dev`/`NODE_ENV`), unrelated to this platform's `withLinuxPlatform` wrap.\n\n**`requestAnimationFrame`/`cancelAnimationFrame`** are a confirmed\nRN-parity gap too (real native RN provides both on every platform) but are\ndeliberately **not** installed here — a parallel, focused effort owns them\n(off `components/frame-scheduler.ts`'s GTK frame clock, with real\nnext-frame/cancel semantics this module has no reason to duplicate or\nrace). Both belong in `src/globals/index.ts` next to the installers above\nonce that work lands.\n\n**Not applicable, by architecture**: RN's Fabric-era DOM-compatibility\nglobals (`Node`, `Element`, `HTMLElement`, `Document`, `Event`,\n`EventTarget`, `CustomEvent`, `DOMRect(ReadOnly/List)`, `HTMLCollection`,\n`NodeList` — `react-native`'s own `src/private/setup/setUpDOM.js`) exist to\nback Fabric's public-instance DOM-traversal API on top of its C++ shadow\ntree. This platform has neither Fabric nor a shadow tree — its own React\nreconciler drives GTK widgets and Yoga directly — so there is no shadow\ntree to expose through a DOM-shaped facade, and installing these globals\nwithout one behind them would be a facade over nothing.",
782
+ },
772
783
  {
773
784
  doc: "docs/api.md",
774
785
  heading: "Package aliases",
@@ -784,10 +795,15 @@ export const DOC_CHUNKS = [
784
795
  heading: "Configuring the package aliases",
785
796
  text: 'Both presets take an `aliases` option: **deltas keyed by package name**, not a\nreplacement list. Anything you do not mention keeps its default, which is the\npoint — a list you have to re-state in full is a list that can silently lose\nan entry, and `ssr.noExternal` losing three of these six names is what put the\nreal `react-native-gesture-handler` into a Linux app.\n\n```ts\n// vite.config.ts\nimport { reactNativeGtkx } from "react-native-gtkx/vite"\n\nexport default defineConfig({\n plugins: [\n reactNativeGtkx({\n aliases: {\n // false — drop one of ours, so the real package loads\n "react-native-reanimated-dnd": false,\n // string — exact name or subpath, tail transplanted\n "my-pkg": "my-pkg/linux",\n // { pattern, replace } — for the rare case where the subpath\n // layouts differ\n "weird-pkg": { pattern: /^weird-pkg\\/lib\\/(.+)$/, replace: "impl/$1" },\n },\n }),\n ],\n})\n```\n\n```ts\n// metro.config.ts — the same object, the same semantics\nexport default withLinuxPlatform(getDefaultConfig(__dirname), {\n aliases: { "react-native-reanimated-dnd": false },\n})\n```\n\nPrefer the string form. It is anchored to the package name by construction,\nwhich is not a nicety: `react-native-reanimated-dnd` is a lookalike of\n`react-native-reanimated`, and `react-native-worklets-core` is a real,\nunrelated package (VisionCamera\'s) that looks like `react-native-worklets` —\na loose prefix rewrite sends either onto a subpath that does not exist. Reach\nfor `{ pattern, replace }` only when a package\'s subpath layout does not match\nits target\'s.\n\nBecause the rules are data rather than functions, the preset validates them\nwhen your config loads, and says what is wrong:\n\n- **an unknown key with `false`** — the aliases that exist are named, so a\n typo cannot silently do nothing;\n- **an overlapping pattern** — "your pattern also matches\n `react-native-reanimated-dnd`, which is declared separately". Two rules\n claiming one specifier would make resolution order-dependent;\n- **an unanchored pattern, or one with the `g`/`y` flag** — the first matches\n inside longer specifiers, the second carries a `lastIndex` between calls;\n- **a target that is not a module specifier** — a relative or absolute path,\n or one ending in `/`;\n- **`react-native`** — it cannot be dropped or retargeted, and the message\n says why: it is the platform, not one of the substituted packages.\n\nOn the vite path the option also drives `ssr.noExternal`, which is derived\nfrom the table rather than written out beside it. Every name in the table\nstays inside vite\'s pipeline — including the ones you turn off, deliberately:\nan un-aliased package still imports `react-native` at module scope, and that\nimport only reaches the platform alias if Node never gets the package first.',
786
797
  },
798
+ {
799
+ doc: "docs/api.md",
800
+ heading: "Plain GTK profile",
801
+ text: '`gtkx.config.ts`\'s `libraries` need not include `"Adw-1"`:\n\n```ts\nimport { defineConfig } from "@gtkx/config"\n\nexport default defineConfig({\n libraries: ["Gtk-4.0"],\n applicationId: "com.example.myapp",\n})\n```\n\nAn app declared this way never links libadwaita — no theming, no\n`Adw.StyleManager`, no Adwaita widgets — which is a real, supported profile,\nnot an unsupported edge case: `react-native-gtkx`\'s own bridge is split into\na core module with zero Adw imports (everything `View`/`Text`/`ScrollView`/\n`Modal`/`Animated`/gestures/`FlatList`/etc. — the whole surface documented\nabove — needs) and a separate Adw module, loaded only when the codegen store\nthis config produces actually has Adw bindings. `spike/plain-gtk` in this\nrepo is exactly this profile, checked in as its own install and its own\n`gtkx codegen` run, and is what every claim below is proven against — a\ngenuinely Adw-less store, not a mocked one.\n\n**Three API rows above have a plain-GTK fallback**, so the same app code\nruns on both profiles without branching on which one it got:\n\n- **`AppRegistry`**\'s `chrome: "content"` falls back to the plain\n `GtkApplicationWindow` `chrome: "system"` already uses, instead of\n throwing — see the `AppRegistry` row above. This is why requesting\n `chrome: "content"` unconditionally is the right default for a portable\n app: HeaderBar-as-chrome where Adw exists, an ordinary window chrome\n where it does not, with no `if (adwAvailable())` of the app\'s own to\n write. `breakpoints` degrades the same way `chrome: "content"` under the\n wrong chrome always has — accepted, ignored, one dev warning (warn-once),\n this one naming `"Adw-1"` as the reason instead of the chrome mismatch.\n- **`Alert.alert`** falls back to `Gtk.AlertDialog` (GTK ≥ 4.10) — see the\n `Alert` row above for exactly what is preserved (button order, default/\n cancel mapping, callbacks) and lost (`destructive`/`isPreferred`\n appearance; `cancelable: false` with no `cancel`-style button).\n- **`Appearance`/`useColorScheme`** fall back to the\n `org.freedesktop.appearance` desktop portal\'s `color-scheme` setting\n (live updates via its `SettingChanged` signal), then to\n `Gtk.Settings:gtk-application-prefer-dark-theme` when no portal answers —\n see the `Appearance` row above. The contract is identical either way:\n always `"light"`/`"dark"`, change events still fire, and `setColorScheme`\n is local to this process on both profiles, never a system-wide write.\n\n**Two subpaths need Adw unconditionally and refuse without it**:\n`react-native-gtkx/adw` (the Adwaita widget bindings) and\n`react-native-gtkx/navigation` (built on `AdwHeaderBar`/`Adw.NavigationView`\n— see [Navigation](#navigation-react-native-gtkxnavigation) below). Importing\neither on this profile throws by name, naming the fix:\n\n```\n[react-native-gtkx] "@gtkx/jsx/adw" requires "Adw-1" in this app\'s\ngtkx.config.ts `libraries` — see docs/api.md (the plain-GTK profile) for\nwhat needs Adw unconditionally and what falls back without it.\n```\n\n`react-native-gtkx/common`\'s `NavigationStack`/`NavigationStackPage` are the\nsame story at component granularity rather than import granularity: the\nsubpath itself imports fine (its barrel also carries Adw-free exports —\n`Widget`, `Icon`, `SlotContent`), and only actually **rendering** one of\nthese two throws, naming the component instead of the raw specifier\n(`[react-native-gtkx] NavigationStack requires "Adw-1" in this app\'s\ngtkx.config.ts \\`libraries\\` — ...`) — there is no fallback to degrade to,\nunlike the three rows above, because there is no non-Adwaita stand-in for\n`Adw.NavigationView` this platform ships.',
802
+ },
787
803
  {
788
804
  doc: "docs/api.md",
789
805
  heading: "Navigation (`react-native-gtkx/navigation`)",
790
- text: 'A [react-navigation](https://reactnavigation.org) stack navigator backed by\n`Adw.NavigationView` — native Adwaita page transitions, the HeaderBar back\nbutton and back gestures stay in sync with react-navigation state (the\nreact-native-windows / native-stack model). Requires the optional peer\n`@react-navigation/native` (v8).\n\n`@react-navigation/native@8` itself peers on `react-native: "*"` (unlike\n`@react-navigation/core@8`, which has no react-native peer at all). If your\napp has no `react-native` package anywhere in its tree — a vite+gtkx app\nwith no Metro side, exactly what `examples/gallery` demonstrates —\n`npm install` will print an unmet-peer-dependency warning for it. This is\nharmless: react-native-gtkx never imports anything from the `react-native`\npackage, so nothing actually needs it at runtime; the warning is npm being\nstrict about a peer range upstream declared loosely (`"*"` — any version\nsatisfies it, npm just wants the package present at all).\n\n```tsx\nimport { NavigationContainer } from "@react-navigation/native"\nimport { createStackNavigator } from "react-native-gtkx/navigation"\n\n// Run the app with chrome: "content" — the navigator\'s HeaderBars ARE the\n// window chrome (the default system chrome would add a second titlebar):\n// AppRegistry.runApplication(name, { ..., chrome: "content" })\n\nconst Stack = createStackNavigator()\n\nconst App = () => (\n <NavigationContainer>\n <Stack.Navigator>\n <Stack.Screen\n name="Home"\n component={HomeScreen}\n />\n <Stack.Screen\n name="Details"\n component={DetailsScreen}\n options={{ title: "Details page" }}\n />\n </Stack.Navigator>\n </NavigationContainer>\n)\n```\n\n- Screen `options`: `title` (HeaderBar title, defaults to the route name),\n `headerShown` (default true).\n- `createSidebarNavigator` — the desktop drawer equivalent on\n `Adw.NavigationSplitView`: a persistent native sidebar (`AdwActionRow`\n per screen, in a GtkListBox with Adwaita `navigation-sidebar` styling)\n selects between parallel screens (TabRouter semantics). Navigator prop\n `sidebarTitle`; screen `options`: `title`, `icon` (Adwaita symbolic icon\n name for the row\'s prefix), `color` (a CSS color for a colored-dot\n prefix instead of `icon` — the two are mutually exclusive per row,\n `color` wins if both are set), `count` (a badge suffix, hidden when 0 or\n unset). Run the app with `chrome: "content"` so the split view\'s\n HeaderBars are the window chrome (`examples/gallery` is built on it).\n Navigator prop `headerButtons` packs declarative native buttons into the\n content HeaderBar end (`{id, icon, tooltip, onPress}`, `icon` is an\n Adwaita symbolic name) — the gallery\'s color-scheme toggle uses it.\n Navigator prop `collapseWidth` (sp): below this width the split view\n collapses to the sidebar or the content pane alone, through a native\n `Adw.Breakpoint` wrapping the view in an `AdwBreakpointBin` — NOT a\n `useWindowDimensions` conditional (see docs/platform-layer.md, "Two ways\n to react to size"); the property flip happens inside GTK\'s own\n allocation pass, costing no React render for the resize itself. Unset by\n default — no `AdwBreakpointBin` is mounted at all, so existing consumers\n see no behavior change. Any route becoming active while collapsed\n reveals content (`AdwNavigationSplitView.showContent`, a plain native\n property write, not React state) — a row click OR a programmatic\n `navigate()`/`jumpTo()`; the native back button that then appears\n reverses it. Re-selecting the same, already-active row after that also\n reveals content again — GTK\'s `row-selected` does not refire for a\n re-click with no selection change, so this is driven by `row-activated`\n (fires on every click) in addition. The reverse direction — the split\n view\'s own back button, Escape or back gesture hiding content again — is\n observed too: it fires a `sidebarShown` event\n (`navigation.addListener("sidebarShown", …)`) on the currently active\n route, the same event-map protocol `createStackNavigator`\'s\n `transitionStart`/`transitionEnd` use. Nothing in react-navigation state\n changes when this fires — TabRouter has no "closed" concept, the same\n route stays focused, only the pane did — so it exists purely for an app\n that wants to react (`examples/tasks-nav`\'s `ContentScreen` resets its\n own in-screen "open task" state on it). Never fired for content being\n revealed (that direction is already an ordinary state change) or when\n `collapseWidth` is unset. Resizing back above `collapseWidth` and then\n back below it again does NOT reset `showContent` or the selection —\n confirmed empirically, not assumed — both simply persist across the\n round trip, the same size-class behavior a mobile master-detail app\n relies on; see docs/research/navigation-extensibility.md for the\n evidence.\n- **Which rung to reach for.** Three ways to put content in the sidebar,\n cheapest first — the same ladder react-navigation\'s own `tabBarIcon` →\n `drawerLabel` → `drawerContent` climbs: (1) `title`/`icon`/`color`/`count`\n above — the convenience; composes an `AdwActionRow`. (2) `sidebarRow`\n (screen option, below) — draw one row yourself; the navigator keeps the\n list and everything attached to it (selection, click → `jumpTo`, staying\n in step with navigation state, the collapsed reveal). (3) `sidebarContent`\n (navigator prop, below) — draw the whole pane, routing surface included.\n The reason rungs 2 and 3 exist at all, plainly: **`AdwActionRow` carries\n Adwaita\'s OWN row metrics, not a default this package picked** — measured\n at roughly 104px per row (with a prefix and/or count laid out) against\n ~40px for a plain title-only row — and nothing passed to\n `title`/`icon`/`color`/`count` changes that height. A screen on rung 1\n has no lever for it; wanting a different height or density means climbing\n to `sidebarRow` or `sidebarContent` instead.\n- Sidebar navigator props `minWidth` / `minHeight` (px, default 360×294 —\n GNOME\'s own adaptive floor): the narrowest size this navigator\'s UI\n supports, applied to the `AdwBreakpointBin` that `collapseWidth` mounts.\n Ignored when `collapseWidth` is unset, since no bin exists then. Adwaita\n cannot measure a breakpoint bin — what it contains changes with the\n breakpoints — so the bin reports a minimum of ZERO and warns that\n `width-request`/`height-request` must be set. Under `chrome: "content"`\n the bin is the window\'s own child, so that zero IS the window\'s floor:\n the window resizes straight past what the pane inside can draw, and\n Adwaita clips the pane instead of adapting it ("AdwNavigationSplitView\n exceeds AdwBreakpointBin width: requested 469 px, 360 px available" in\n the journal, felt as a list running off the right edge with its trailing\n controls cut away). An app whose content HeaderBar needs more than the\n default must raise it — measure the pane rather than guessing: a\n segmented control as `headerTitle` costs ~110px on its own and, unlike a\n title label, cannot ellipsize. `examples/tasks-nav` passes `480` for\n exactly that reason; the value stays below its `collapseWidth`, so the\n collapsed layout is still fully reachable.\n- Sidebar screen options `headerLeft` / `headerRight` / `headerTitle`:\n `() => ReactNode` — the content HeaderBar\'s own start/end/title, per\n screen, on top of the one navigator-wide default. This is what lets one\n screen\'s header change shape with ITS OWN selection (a filter toggle\n group for a list, a back button plus star/trash for an open item):\n call `navigation.setOptions({ headerLeft, headerRight, headerTitle })`\n from inside the screen, in an effect keyed on whatever local state\n decides its shape — no stack involved, and no new navigator API beyond\n the options themselves (`useNavigationBuilder` already re-resolves\n descriptor options on every `setOptions` call). `headerTitle` replaces\n the HeaderBar\'s title widget outright (unset, the page\'s own title\n shows automatically, as before). A screen\'s own `headerButtons`\n (`HeaderButton[]`, same shape as the navigator prop) replaces the\n navigator-level default entirely for that screen. **Caveat, found\n while testing this**: `setOptions` MERGES into the previously resolved\n options rather than replacing them — a call that omits `headerRight`\n does not clear a `headerRight` a PREVIOUS call set, it leaves it in\n place. A screen that flips between shapes must give every one of these\n four keys an explicit value (`undefined` counts as a real overwrite; an\n absent key does not) on every call, not just the ones currently in use.\n- Sidebar screen option `group`: `string` — the section this row belongs to.\n Consecutive screens sharing a `group` get one Adwaita section header above\n the first of them, attached with `GtkListBox.set_header_func` — the\n mechanism GNOME\'s own sidebars use. A header attached this way is a\n DECORATION owned by the row below it, not a row: it is outside the list\'s\n selection model and outside its focus chain, so the arrow keys and Tab walk\n straight past it and assistive technology never announces a row that cannot\n be activated. A header faked as a non-selectable `GtkListBoxRow` gets none\n of that. Grouping follows ROW ORDER, so screens in one group are declared\n together and a group name reappearing after a gap starts a second header\n rather than reordering anything; leave it unset on every screen (the\n default) and the list is flat. `examples/gallery` groups its sections into\n React Native / gtkx / Modules.\n- Sidebar screen option `sidebarRow`: `() => ReactNode` — draw the row\n yourself instead of letting `title`/`icon`/`color`/`count` compose one.\n Those four are a convenience, not the ceiling: they build an\n `AdwActionRow`, which brings Adwaita\'s own row metrics with it, so an app\n wanting a different shape, density or height had nothing to reach for.\n Return anything a `GtkListBoxRow` can hold — React Native content, GTK\n widgets, a differently-configured Adwaita row. The navigator keeps owning\n row BEHAVIOUR (selection, click → `jumpTo`, staying in step with\n navigation state, the collapsed reveal), so a custom row cannot drift out\n of sync with the router; only what is drawn changes. A screen that passes\n none of `icon`/`color`/`count` gets a compact `GtkListBoxRow` + label\n automatically — `AdwActionRow`\'s height is right when there IS a prefix\n and a count to lay out and pure cost when there is not. The next rung up\n is `sidebarContent`, below, for replacing the whole pane rather than one\n row.\n- Sidebar navigator prop `sidebarContent`:\n `(props: SidebarContentProps) => ReactNode` — replaces the ENTIRE sidebar\n pane\'s body, for a sidebar that needs sections, a search field, a footer,\n or anything a flat list of rows cannot express. The sidebar\'s children\n stop being "one row per screen": you draw what you like, and navigation\n is just the `jumpTo` you were handed. `SidebarContentProps` carries\n `routes` (key, name, resolved options, title, `focused`), `focusedIndex`\n and `jumpTo(name)` — use those rather than dispatching yourself, so\n selection cannot drift from navigation state. The pane\'s AdwHeaderBar and\n `sidebarTitle` still belong to the navigator: this is the body under it,\n not the chrome. Mounted as React Native content (a layout root filling\n the pane); a sidebar built from GTK widgets wraps its own tree in\n `WidgetContent`, the same escape hatch `contentLayout: "widget"` is for a\n screen body. Reach for `sidebarRow` (above) first if you only want a\n different ROW — it keeps the navigator\'s list and everything attached to\n it; this one hands over the whole pane, routing included. A sidebar with\n a search field above the list and a footer below it, still driven by the\n navigator\'s own routing:\n\n ```tsx\n <Sidebar.Navigator\n sidebarContent={({ routes, focusedIndex, jumpTo }) => (\n <View style={{ flex: 1 }}>\n <SearchField onSubmit={filterRoutes} />\n <ScrollView style={{ flex: 1 }}>\n {routes.map((route, index) => (\n <Pressable\n key={route.key}\n onPress={() => jumpTo(route.name)}\n >\n <Text\n style={{\n padding: 8,\n fontWeight: index === focusedIndex ? "700" : "400",\n }}\n >\n {route.title}\n </Text>\n </Pressable>\n ))}\n </ScrollView>\n <StorageUsageFooter />\n </View>\n )}\n >\n <Sidebar.Screen\n name="Inbox"\n component={InboxScreen}\n />\n <Sidebar.Screen\n name="Trash"\n component={TrashScreen}\n />\n </Sidebar.Navigator>\n ```\n\n `route.title` is already resolved (`options.title`, falling back to the\n route name) — no need to read `options.title` yourself. `jumpTo` reveals\n the content pane when collapsed, same as a native row click; the\n navigator, not this callback, decides that.\n\n- Sidebar navigator props `sidebarHeaderLeft` / `sidebarHeaderRight` /\n `sidebarHeaderTitle`: `() => ReactNode` — the SIDEBAR pane\'s own\n AdwHeaderBar start/end/title, the exact counterparts of the content\n header\'s `headerLeft`/`headerRight`/`headerTitle`. Until these existed the\n sidebar header was a hard-coded `<AdwHeaderBar />` and `sidebarTitle` (a\n plain string) was the only thing an app could set on it at all, so a\n sidebar\'s own "new item" action — where GNOME puts it, next to the pane\n title — had nowhere to go and ended up on the content header instead\n (`examples/tasks-nav` shipped with two indistinguishable `+` buttons for\n exactly this reason). `sidebarHeaderTitle` replaces the title widget the\n same way a screen\'s `headerTitle` does; unset, `sidebarTitle` renders as\n before. Content is mounted through the same `HeaderSlotContent` root the\n content header uses, so React Native content lays out as a horizontal,\n content-hugging cluster flush with natively packed buttons — do not\n hand-roll an `IntrinsicContent` here, a bare Yoga root defaults to\n `column` and pushes the window controls onto a second row. These are\n navigator PROPS rather than screen options on purpose: there is one\n sidebar pane shared by every screen, so its chrome sits at the level\n `sidebarTitle`/`sidebarContent` already do, and the `sidebar` prefix marks\n which header a name refers to. There is deliberately no\n `sidebarHeaderButtons` convenience mirroring `headerButtons` — arbitrary\n content is the primitive, and a one-button call site reads no better as a\n `{id, icon, tooltip, onPress}` record than as the `GtkButton` it already\n is; add it only if a real call site is worse without it.\n\n- Sidebar screen option `contentLayout`: `"react-native"` (default) or\n `"widget"` — what the screen\'s body IS. The default mounts it in a Yoga\n layout root that fills the pane, so `<View style={{ flex: 1 }}>` behaves\n the way it does anywhere else. `"widget"` packs the body into the page\n directly, with no layout root in between, for a screen whose body is a\n GTK widget tree (a `GtkScrolledWindow` around an `AdwClamp` around a\n `.boxed-list` `GtkListBox`, say): GTK\'s own sizing — `vexpand`, a list\'s\n natural height — then applies normally. **Under the default a widget tree\n collapses instead**, and quietly: every widget becomes a single Yoga LEAF\n measured for its own natural size, so a container renders its first child,\n drops the rest, and reports the ~1px it can shrink to, with no error\n anywhere. `examples/tasks-nav` is built this way. Mixing is per screen,\n not per subtree — a `"widget"` screen that wants React Native content\n somewhere inside it wraps that part in `SlotContent` itself.\n- Stack screen options `headerLeft` / `headerRight`: `() => ReactNode` —\n real RN content in the HeaderBar (inputs included), hosted by an\n intrinsic-size root; `headerButtons` render after `headerRight`\n (hn-app\'s header search filter is the demo).\n- Stack screen option `gestureEnabled: false` disables the native back\n button, Escape and the back gesture for that screen (the page\'s\n Adwaita `can-pop`); a programmatic `goBack` still pops. `usePreventRemove`\n works through the same mechanism — a prevented route reports\n `can-pop: false`, so no native pop can race react-navigation state; the\n route pops once the app lifts the guard (e.g. after its own\n confirmation dialog).\n- Stack screen option `animation` maps onto `Adw.NavigationView`\'s\n `animate-transitions` — GTK has exactly one transition style, not a\n choice of styles like iOS/Android, so the option collapses to a\n boolean: `"none"` turns transitions off, any other value (including\n native-stack\'s own style names, e.g. `"slide_from_bottom"`, `"fade"`)\n turns them on, with the standard Adwaita transition rather than the\n one asked for. Requesting a specific type still animates — it is not\n silently treated as `"none"` — and warns once in development.\n `animate-transitions` is a property of the whole view, not a per-page\n one, so there is no per-screen granularity to offer: the value used is\n read from whichever screen is currently on top of the visible stack,\n recomputed on every navigation. Setting it once via `screenOptions`\n (the same value for every screen) is the reliable way to use this —\n the per-screen case only matters if different screens genuinely\n disagree, and even then only the active one\'s value is observed.\n Interactive swipe-back gestures always animate regardless of this\n setting — Adwaita\'s own behavior, not overridable here.\n- The factories are typed: `createStackNavigator<ParamList>()` gives\n typed `Screen` configs and `StackScreenProps<ParamList, Route>` for\n screen components (`SidebarScreenProps` likewise).\n- The stack navigator emits `transitionStart` / `transitionEnd` on a\n screen\'s `navigation` object, matching `@react-navigation/stack` and\n `@react-navigation/native-stack` exactly: `{ data: { closing: boolean } }`,\n `closing: false` for the screen being pushed in, `closing: true` for the\n screen being popped out. A screen that stays mounted without actually\n entering or leaving (e.g. the screen underneath a push) gets neither\n event, same as upstream. Two things worth knowing before relying on\n timing:\n - **`transitionEnd` is tied to `AdwNavigationPage`\'s own `shown`/`hidden`\n signals** — contrary to an earlier version of this page, Adwaita DOES\n expose a transition-finished signal (four of them, in fact: `showing`,\n `shown`, `hiding`, `hidden`, all per-page). `transitionEnd` on the\n entering screen fires on that screen\'s `shown`; on the leaving screen\n it fires on `hidden`. `transitionDuration` (default 400 ms) is a\n fallback only, used when a page\'s own signal never arrives — a\n signal-less environment, or a page skipped entirely by a multi-hop\n pop (popping past an intermediate screen never fires anything on it,\n since it was never the one actually on screen during the transition).\n When transitions are not animated, the real signals still fire —\n immediately — so `transitionEnd` is not delayed by the fallback\n window either.\n - **Native pops do not fire these events at all today.** A user-driven\n pop (the Adwaita back button, Escape, the back gesture) is handled by\n the widget itself before this package\'s code is told about it, so\n there is nothing to hook a `transitionStart` into. Only\n programmatic navigation (`navigate`, `goBack`, `dispatch`, …) fires\n `transitionStart`/`transitionEnd`.\n- The sidebar navigator emits `sidebarShown` (`{ data: undefined }`) on a\n screen\'s `navigation` object — the collapsed-mode counterpart of a native\n pop, and the one case where a native, user-driven interaction (the split\n view\'s own back button, Escape, the back gesture) DOES get an event: the\n widget-level property that changes (`showContent`) has no\n react-navigation state behind it at all, so there is no state change for\n an app to observe any other way. Fired on the active route only when\n `showContent` goes from shown back to hidden, and only while\n `collapseWidth` is set; never fired for content being revealed (that\n already shows up as an ordinary focused-route change).\n- The rest of the react-navigation surface — `useNavigation`, `useRoute`,\n `useFocusEffect`, `useIsFocused`, `useNavigationContainerRef`,\n `CommonActions`, `StackActions`, `usePreventRemove`, `NavigationContainer`\n and everything else — comes from `@react-navigation/native` directly, not\n from this package. **Breaking change**: earlier versions re-exported a\n subset of these names from `react-native-gtkx/navigation`; the re-export\n was removed because it was never complete (anything beyond the subset\n still required importing from `@react-navigation/native`, so it was one\n more place to look rather than a convenience). This package\'s navigation\n entry point now exports exactly its own surface: `createStackNavigator`,\n `createSidebarNavigator`, and the option/prop types around them.\n- Each screen mounts its own layout root inside the page: the page\'s\n content allocation is that screen\'s viewport.\n- Differences from `@react-navigation/native-stack`: `headerRight`/custom\n header widgets are not supported yet; deep-link "url" events never fire\n on desktop (see `Linking`).',
806
+ text: 'A [react-navigation](https://reactnavigation.org) stack navigator backed by\n`Adw.NavigationView` — native Adwaita page transitions, the HeaderBar back\nbutton and back gestures stay in sync with react-navigation state (the\nreact-native-windows / native-stack model). Requires the optional peer\n`@react-navigation/native` (v8), and requires `"Adw-1"` in this app\'s\n`gtkx.config.ts` `libraries` unconditionally — see [Plain GTK\nprofile](#plain-gtk-profile) above for what importing this subpath does\nwithout it.\n\n`@react-navigation/native@8` itself peers on `react-native: "*"` (unlike\n`@react-navigation/core@8`, which has no react-native peer at all). If your\napp has no `react-native` package anywhere in its tree — a vite+gtkx app\nwith no Metro side, exactly what `examples/gallery` demonstrates —\n`npm install` will print an unmet-peer-dependency warning for it. This is\nharmless: react-native-gtkx never imports anything from the `react-native`\npackage, so nothing actually needs it at runtime; the warning is npm being\nstrict about a peer range upstream declared loosely (`"*"` — any version\nsatisfies it, npm just wants the package present at all).\n\n```tsx\nimport { NavigationContainer } from "@react-navigation/native"\nimport { createStackNavigator } from "react-native-gtkx/navigation"\n\n// Run the app with chrome: "content" — the navigator\'s HeaderBars ARE the\n// window chrome (the default system chrome would add a second titlebar):\n// AppRegistry.runApplication(name, { ..., chrome: "content" })\n\nconst Stack = createStackNavigator()\n\nconst App = () => (\n <NavigationContainer>\n <Stack.Navigator>\n <Stack.Screen\n name="Home"\n component={HomeScreen}\n />\n <Stack.Screen\n name="Details"\n component={DetailsScreen}\n options={{ title: "Details page" }}\n />\n </Stack.Navigator>\n </NavigationContainer>\n)\n```\n\n- Screen `options`: `title` (HeaderBar title, defaults to the route name),\n `headerShown` (default true).\n- `createSidebarNavigator` — the desktop drawer equivalent on\n `Adw.NavigationSplitView`: a persistent native sidebar (`AdwActionRow`\n per screen, in a GtkListBox with Adwaita `navigation-sidebar` styling)\n selects between parallel screens (TabRouter semantics). Navigator prop\n `sidebarTitle`; screen `options`: `title`, `icon` (Adwaita symbolic icon\n name for the row\'s prefix), `color` (a CSS color for a colored-dot\n prefix instead of `icon` — the two are mutually exclusive per row,\n `color` wins if both are set), `count` (a badge suffix, hidden when 0 or\n unset). Run the app with `chrome: "content"` so the split view\'s\n HeaderBars are the window chrome (`examples/gallery` is built on it).\n Navigator prop `headerButtons` packs declarative native buttons into the\n content HeaderBar end (`{id, icon, tooltip, onPress}`, `icon` is an\n Adwaita symbolic name) — the gallery\'s color-scheme toggle uses it.\n Navigator prop `collapseWidth` (sp): below this width the split view\n collapses to the sidebar or the content pane alone, through a native\n `Adw.Breakpoint` wrapping the view in an `AdwBreakpointBin` — NOT a\n `useWindowDimensions` conditional (see docs/platform-layer.md, "Two ways\n to react to size"); the property flip happens inside GTK\'s own\n allocation pass, costing no React render for the resize itself. Unset by\n default — no `AdwBreakpointBin` is mounted at all, so existing consumers\n see no behavior change. Any route becoming active while collapsed\n reveals content (`AdwNavigationSplitView.showContent`, a plain native\n property write, not React state) — a row click OR a programmatic\n `navigate()`/`jumpTo()`; the native back button that then appears\n reverses it. Re-selecting the same, already-active row after that also\n reveals content again — GTK\'s `row-selected` does not refire for a\n re-click with no selection change, so this is driven by `row-activated`\n (fires on every click) in addition. The reverse direction — the split\n view\'s own back button, Escape or back gesture hiding content again — is\n observed too: it fires a `sidebarShown` event\n (`navigation.addListener("sidebarShown", …)`) on the currently active\n route, the same event-map protocol `createStackNavigator`\'s\n `transitionStart`/`transitionEnd` use. Nothing in react-navigation state\n changes when this fires — TabRouter has no "closed" concept, the same\n route stays focused, only the pane did — so it exists purely for an app\n that wants to react (`examples/tasks-nav`\'s `ContentScreen` resets its\n own in-screen "open task" state on it). Never fired for content being\n revealed (that direction is already an ordinary state change) or when\n `collapseWidth` is unset. Resizing back above `collapseWidth` and then\n back below it again does NOT reset `showContent` or the selection —\n confirmed empirically, not assumed — both simply persist across the\n round trip, the same size-class behavior a mobile master-detail app\n relies on; see docs/research/navigation-extensibility.md for the\n evidence.\n- **Which rung to reach for.** Three ways to put content in the sidebar,\n cheapest first — the same ladder react-navigation\'s own `tabBarIcon` →\n `drawerLabel` → `drawerContent` climbs: (1) `title`/`icon`/`color`/`count`\n above — the convenience; composes an `AdwActionRow`. (2) `sidebarRow`\n (screen option, below) — draw one row yourself; the navigator keeps the\n list and everything attached to it (selection, click → `jumpTo`, staying\n in step with navigation state, the collapsed reveal). (3) `sidebarContent`\n (navigator prop, below) — draw the whole pane, routing surface included.\n The reason rungs 2 and 3 exist at all, plainly: **`AdwActionRow` carries\n Adwaita\'s OWN row metrics, not a default this package picked** — measured\n at roughly 104px per row (with a prefix and/or count laid out) against\n ~40px for a plain title-only row — and nothing passed to\n `title`/`icon`/`color`/`count` changes that height. A screen on rung 1\n has no lever for it; wanting a different height or density means climbing\n to `sidebarRow` or `sidebarContent` instead.\n- Sidebar navigator props `minWidth` / `minHeight` (px, default 360×294 —\n GNOME\'s own adaptive floor): the narrowest size this navigator\'s UI\n supports, applied to the `AdwBreakpointBin` that `collapseWidth` mounts.\n Ignored when `collapseWidth` is unset, since no bin exists then. Adwaita\n cannot measure a breakpoint bin — what it contains changes with the\n breakpoints — so the bin reports a minimum of ZERO and warns that\n `width-request`/`height-request` must be set. Under `chrome: "content"`\n the bin is the window\'s own child, so that zero IS the window\'s floor:\n the window resizes straight past what the pane inside can draw, and\n Adwaita clips the pane instead of adapting it ("AdwNavigationSplitView\n exceeds AdwBreakpointBin width: requested 469 px, 360 px available" in\n the journal, felt as a list running off the right edge with its trailing\n controls cut away). An app whose content HeaderBar needs more than the\n default must raise it — measure the pane rather than guessing: a\n segmented control as `headerTitle` costs ~110px on its own and, unlike a\n title label, cannot ellipsize. `examples/tasks-nav` passes `480` for\n exactly that reason; the value stays below its `collapseWidth`, so the\n collapsed layout is still fully reachable.\n- Sidebar screen options `headerLeft` / `headerRight` / `headerTitle`:\n `() => ReactNode` — the content HeaderBar\'s own start/end/title, per\n screen, on top of the one navigator-wide default. This is what lets one\n screen\'s header change shape with ITS OWN selection (a filter toggle\n group for a list, a back button plus star/trash for an open item):\n call `navigation.setOptions({ headerLeft, headerRight, headerTitle })`\n from inside the screen, in an effect keyed on whatever local state\n decides its shape — no stack involved, and no new navigator API beyond\n the options themselves (`useNavigationBuilder` already re-resolves\n descriptor options on every `setOptions` call). `headerTitle` replaces\n the HeaderBar\'s title widget outright (unset, the page\'s own title\n shows automatically, as before). A screen\'s own `headerButtons`\n (`HeaderButton[]`, same shape as the navigator prop) replaces the\n navigator-level default entirely for that screen. **Caveat, found\n while testing this**: `setOptions` MERGES into the previously resolved\n options rather than replacing them — a call that omits `headerRight`\n does not clear a `headerRight` a PREVIOUS call set, it leaves it in\n place. A screen that flips between shapes must give every one of these\n four keys an explicit value (`undefined` counts as a real overwrite; an\n absent key does not) on every call, not just the ones currently in use.\n- Sidebar screen option `group`: `string` — the section this row belongs to.\n Consecutive screens sharing a `group` get one Adwaita section header above\n the first of them, attached with `GtkListBox.set_header_func` — the\n mechanism GNOME\'s own sidebars use. A header attached this way is a\n DECORATION owned by the row below it, not a row: it is outside the list\'s\n selection model and outside its focus chain, so the arrow keys and Tab walk\n straight past it and assistive technology never announces a row that cannot\n be activated. A header faked as a non-selectable `GtkListBoxRow` gets none\n of that. Grouping follows ROW ORDER, so screens in one group are declared\n together and a group name reappearing after a gap starts a second header\n rather than reordering anything; leave it unset on every screen (the\n default) and the list is flat. `examples/gallery` groups its sections into\n React Native / gtkx / Modules.\n- Sidebar screen option `sidebarRow`: `() => ReactNode` — draw the row\n yourself instead of letting `title`/`icon`/`color`/`count` compose one.\n Those four are a convenience, not the ceiling: they build an\n `AdwActionRow`, which brings Adwaita\'s own row metrics with it, so an app\n wanting a different shape, density or height had nothing to reach for.\n Return anything a `GtkListBoxRow` can hold — React Native content, GTK\n widgets, a differently-configured Adwaita row. The navigator keeps owning\n row BEHAVIOUR (selection, click → `jumpTo`, staying in step with\n navigation state, the collapsed reveal), so a custom row cannot drift out\n of sync with the router; only what is drawn changes. A screen that passes\n none of `icon`/`color`/`count` gets a compact `GtkListBoxRow` + label\n automatically — `AdwActionRow`\'s height is right when there IS a prefix\n and a count to lay out and pure cost when there is not. The next rung up\n is `sidebarContent`, below, for replacing the whole pane rather than one\n row.\n- Sidebar navigator prop `sidebarContent`:\n `(props: SidebarContentProps) => ReactNode` — replaces the ENTIRE sidebar\n pane\'s body, for a sidebar that needs sections, a search field, a footer,\n or anything a flat list of rows cannot express. The sidebar\'s children\n stop being "one row per screen": you draw what you like, and navigation\n is just the `jumpTo` you were handed. `SidebarContentProps` carries\n `routes` (key, name, resolved options, title, `focused`), `focusedIndex`\n and `jumpTo(name)` — use those rather than dispatching yourself, so\n selection cannot drift from navigation state. The pane\'s AdwHeaderBar and\n `sidebarTitle` still belong to the navigator: this is the body under it,\n not the chrome. Mounted as React Native content (a layout root filling\n the pane); a sidebar built from GTK widgets wraps its own tree in\n `WidgetContent`, the same escape hatch `contentLayout: "widget"` is for a\n screen body. Reach for `sidebarRow` (above) first if you only want a\n different ROW — it keeps the navigator\'s list and everything attached to\n it; this one hands over the whole pane, routing included. A sidebar with\n a search field above the list and a footer below it, still driven by the\n navigator\'s own routing:\n\n ```tsx\n <Sidebar.Navigator\n sidebarContent={({ routes, focusedIndex, jumpTo }) => (\n <View style={{ flex: 1 }}>\n <SearchField onSubmit={filterRoutes} />\n <ScrollView style={{ flex: 1 }}>\n {routes.map((route, index) => (\n <Pressable\n key={route.key}\n onPress={() => jumpTo(route.name)}\n >\n <Text\n style={{\n padding: 8,\n fontWeight: index === focusedIndex ? "700" : "400",\n }}\n >\n {route.title}\n </Text>\n </Pressable>\n ))}\n </ScrollView>\n <StorageUsageFooter />\n </View>\n )}\n >\n <Sidebar.Screen\n name="Inbox"\n component={InboxScreen}\n />\n <Sidebar.Screen\n name="Trash"\n component={TrashScreen}\n />\n </Sidebar.Navigator>\n ```\n\n `route.title` is already resolved (`options.title`, falling back to the\n route name) — no need to read `options.title` yourself. `jumpTo` reveals\n the content pane when collapsed, same as a native row click; the\n navigator, not this callback, decides that.\n\n- Sidebar navigator props `sidebarHeaderLeft` / `sidebarHeaderRight` /\n `sidebarHeaderTitle`: `() => ReactNode` — the SIDEBAR pane\'s own\n AdwHeaderBar start/end/title, the exact counterparts of the content\n header\'s `headerLeft`/`headerRight`/`headerTitle`. Until these existed the\n sidebar header was a hard-coded `<AdwHeaderBar />` and `sidebarTitle` (a\n plain string) was the only thing an app could set on it at all, so a\n sidebar\'s own "new item" action — where GNOME puts it, next to the pane\n title — had nowhere to go and ended up on the content header instead\n (`examples/tasks-nav` shipped with two indistinguishable `+` buttons for\n exactly this reason). `sidebarHeaderTitle` replaces the title widget the\n same way a screen\'s `headerTitle` does; unset, `sidebarTitle` renders as\n before. Content is mounted through the same `HeaderSlotContent` root the\n content header uses, so React Native content lays out as a horizontal,\n content-hugging cluster flush with natively packed buttons — do not\n hand-roll an `IntrinsicContent` here, a bare Yoga root defaults to\n `column` and pushes the window controls onto a second row. These are\n navigator PROPS rather than screen options on purpose: there is one\n sidebar pane shared by every screen, so its chrome sits at the level\n `sidebarTitle`/`sidebarContent` already do, and the `sidebar` prefix marks\n which header a name refers to. There is deliberately no\n `sidebarHeaderButtons` convenience mirroring `headerButtons` — arbitrary\n content is the primitive, and a one-button call site reads no better as a\n `{id, icon, tooltip, onPress}` record than as the `GtkButton` it already\n is; add it only if a real call site is worse without it.\n\n- Sidebar screen option `contentLayout`: `"react-native"` (default) or\n `"widget"` — what the screen\'s body IS. The default mounts it in a Yoga\n layout root that fills the pane, so `<View style={{ flex: 1 }}>` behaves\n the way it does anywhere else. `"widget"` packs the body into the page\n directly, with no layout root in between, for a screen whose body is a\n GTK widget tree (a `GtkScrolledWindow` around an `AdwClamp` around a\n `.boxed-list` `GtkListBox`, say): GTK\'s own sizing — `vexpand`, a list\'s\n natural height — then applies normally. **Under the default a widget tree\n collapses instead**, and quietly: every widget becomes a single Yoga LEAF\n measured for its own natural size, so a container renders its first child,\n drops the rest, and reports the ~1px it can shrink to, with no error\n anywhere. `examples/tasks-nav` is built this way. Mixing is per screen,\n not per subtree — a `"widget"` screen that wants React Native content\n somewhere inside it wraps that part in `SlotContent` itself.\n- Stack screen options `headerLeft` / `headerRight`: `() => ReactNode` —\n real RN content in the HeaderBar (inputs included), hosted by an\n intrinsic-size root; `headerButtons` render after `headerRight`\n (hn-app\'s header search filter is the demo).\n- Stack screen option `gestureEnabled: false` disables the native back\n button, Escape and the back gesture for that screen (the page\'s\n Adwaita `can-pop`); a programmatic `goBack` still pops. `usePreventRemove`\n works through the same mechanism — a prevented route reports\n `can-pop: false`, so no native pop can race react-navigation state; the\n route pops once the app lifts the guard (e.g. after its own\n confirmation dialog).\n- Stack screen option `animation` maps onto `Adw.NavigationView`\'s\n `animate-transitions` — GTK has exactly one transition style, not a\n choice of styles like iOS/Android, so the option collapses to a\n boolean: `"none"` turns transitions off, any other value (including\n native-stack\'s own style names, e.g. `"slide_from_bottom"`, `"fade"`)\n turns them on, with the standard Adwaita transition rather than the\n one asked for. Requesting a specific type still animates — it is not\n silently treated as `"none"` — and warns once in development.\n `animate-transitions` is a property of the whole view, not a per-page\n one, so there is no per-screen granularity to offer: the value used is\n read from whichever screen is currently on top of the visible stack,\n recomputed on every navigation. Setting it once via `screenOptions`\n (the same value for every screen) is the reliable way to use this —\n the per-screen case only matters if different screens genuinely\n disagree, and even then only the active one\'s value is observed.\n Interactive swipe-back gestures always animate regardless of this\n setting — Adwaita\'s own behavior, not overridable here.\n- The factories are typed: `createStackNavigator<ParamList>()` gives\n typed `Screen` configs and `StackScreenProps<ParamList, Route>` for\n screen components (`SidebarScreenProps` likewise).\n- The stack navigator emits `transitionStart` / `transitionEnd` on a\n screen\'s `navigation` object, matching `@react-navigation/stack` and\n `@react-navigation/native-stack` exactly: `{ data: { closing: boolean } }`,\n `closing: false` for the screen being pushed in, `closing: true` for the\n screen being popped out. A screen that stays mounted without actually\n entering or leaving (e.g. the screen underneath a push) gets neither\n event, same as upstream. Two things worth knowing before relying on\n timing:\n - **`transitionEnd` is tied to `AdwNavigationPage`\'s own `shown`/`hidden`\n signals** — contrary to an earlier version of this page, Adwaita DOES\n expose a transition-finished signal (four of them, in fact: `showing`,\n `shown`, `hiding`, `hidden`, all per-page). `transitionEnd` on the\n entering screen fires on that screen\'s `shown`; on the leaving screen\n it fires on `hidden`. `transitionDuration` (default 400 ms) is a\n fallback only, used when a page\'s own signal never arrives — a\n signal-less environment, or a page skipped entirely by a multi-hop\n pop (popping past an intermediate screen never fires anything on it,\n since it was never the one actually on screen during the transition).\n When transitions are not animated, the real signals still fire —\n immediately — so `transitionEnd` is not delayed by the fallback\n window either.\n - **Native pops do not fire these events at all today.** A user-driven\n pop (the Adwaita back button, Escape, the back gesture) is handled by\n the widget itself before this package\'s code is told about it, so\n there is nothing to hook a `transitionStart` into. Only\n programmatic navigation (`navigate`, `goBack`, `dispatch`, …) fires\n `transitionStart`/`transitionEnd`.\n- The sidebar navigator emits `sidebarShown` (`{ data: undefined }`) on a\n screen\'s `navigation` object — the collapsed-mode counterpart of a native\n pop, and the one case where a native, user-driven interaction (the split\n view\'s own back button, Escape, the back gesture) DOES get an event: the\n widget-level property that changes (`showContent`) has no\n react-navigation state behind it at all, so there is no state change for\n an app to observe any other way. Fired on the active route only when\n `showContent` goes from shown back to hidden, and only while\n `collapseWidth` is set; never fired for content being revealed (that\n already shows up as an ordinary focused-route change).\n- The rest of the react-navigation surface — `useNavigation`, `useRoute`,\n `useFocusEffect`, `useIsFocused`, `useNavigationContainerRef`,\n `CommonActions`, `StackActions`, `usePreventRemove`, `NavigationContainer`\n and everything else — comes from `@react-navigation/native` directly, not\n from this package. **Breaking change**: earlier versions re-exported a\n subset of these names from `react-native-gtkx/navigation`; the re-export\n was removed because it was never complete (anything beyond the subset\n still required importing from `@react-navigation/native`, so it was one\n more place to look rather than a convenience). This package\'s navigation\n entry point now exports exactly its own surface: `createStackNavigator`,\n `createSidebarNavigator`, and the option/prop types around them.\n- Each screen mounts its own layout root inside the page: the page\'s\n content allocation is that screen\'s viewport.\n- Differences from `@react-navigation/native-stack`: `headerRight`/custom\n header widgets are not supported yet; deep-link "url" events never fire\n on desktop (see `Linking`).',
791
807
  },
792
808
  {
793
809
  doc: "docs/api.md",
@@ -807,17 +823,22 @@ export const DOC_CHUNKS = [
807
823
  {
808
824
  doc: "docs/api.md",
809
825
  heading: "Which one to reach for",
810
- text: "There is one drag-and-drop API. Three sentences cover every case:\n\n- **Porting an app that already uses `react-native-reanimated-dnd`** — change\n nothing. Both presets alias the package name; the imports stay as they are.\n- **Writing a new app** — import from `react-native-gtkx/dnd`. Same names,\n same props, so the code also reads correctly to anyone who knows the\n library, and the file can move to a shared location later.\n- **Reordering by row id rather than by array index** — a `Droppable` around\n a `Draggable` per row, inside one `DropProvider`. `Sortable` owns an array\n and reports positions, which is the right shape when the component owns the\n order; when a store owns it, filters it and sorts it, the id-keyed pair is\n the one that fits. `examples/tasks-nav/src/components/task-row.tsx` is a\n worked example.\n\n`List`/`ListRow` used to offer a second, id-keyed reorder of their own. They\nare gone from `react-native-gtkx/common` entirely — they were the Adwaita\nlist _appearance_ written in React Native, and that is an app's business (see\n[platform-layer.md](platform-layer.md#listlistrowlistseparator-were-here-and-are-not-any-more)).\nNothing about `List` has anything to do with dragging any more.\n\n| Export | Notes |\n| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `DropProvider` | Scopes a set of draggables and droppables. Renders a `View` (upstream renders a fragment) because `onDragging` needs a widget. `ref` gives `getDroppedItems()` and `requestPositionUpdate()`. |\n| `Draggable`, `Draggable.Handle`, `useDraggable` | The drag source. With a handle, the `GtkDragSource` attaches to the **handle's** widget, so the rest of the item stays pressable. |\n| `Droppable`, `useDroppable` | The drop target. `capacity` is enforced in GDK's `::accept`, so a full zone shows the no-drop cursor. |\n| `Sortable`, `SortableItem`, `SortableItem.Handle`, `useSortable`, `useSortableList` | Drag-to-reorder. The component owns the order (upstream's contract); read the settled one from `onDrop`'s `allPositions`. |\n| `DraggableState`, `ScrollDirection`, `SortableDirection`, `HorizontalScrollDirection` | The enums, unchanged. |\n| `listToObject`, `objectMove`, `clamp` | The utilities, as plain functions rather than worklets. |\n| `SharedValueLike<T>` | What `SharedValue<T>` degrades to: `{ value: T }` without the worklet crossing. Reads and writes work; they just do not animate. |",
826
+ text: "There is one drag-and-drop API. Three sentences cover every case:\n\n- **Porting an app that already uses `react-native-reanimated-dnd`** — change\n nothing. Both presets alias the package name; the imports stay as they are.\n- **Writing a new app** — import from `react-native-gtkx/dnd`. Same names,\n same props, so the code also reads correctly to anyone who knows the\n library, and the file can move to a shared location later.\n- **Reordering by row id rather than by array index** — a `Droppable` around\n a `Draggable` per row, inside one `DropProvider`. `Sortable` owns an array\n and reports positions, which is the right shape when the component owns the\n order; when a store owns it, filters it and sorts it, the id-keyed pair is\n the one that fits. `examples/tasks-nav/src/components/task-row.tsx` is a\n worked example.\n\n`List`/`ListRow` used to offer a second, id-keyed reorder of their own. They\nare gone from `react-native-gtkx/common` entirely — they were the Adwaita\nlist _appearance_ written in React Native, and that is an app's business (see\n[platform-layer.md](platform-layer.md#listlistrowlistseparator-were-here-and-are-not-any-more)).\nNothing about `List` has anything to do with dragging any more.",
827
+ },
828
+ {
829
+ doc: "docs/api.md",
830
+ heading: "A dead zone in the REAL `Sortable`/`SortableGrid`, ported unchanged",
831
+ text: "Porting an app onto the real package (unaliased, or via `DND_IMPL=real` —\nthe gallery's Upstream sortables section) reproduces a dead zone that is\nupstream's own arithmetic, not a compat-surface distortion this platform\nintroduces — checked directly against the published source and, for the\ngrid, an independent real-pointer measurement; see\n[research/dnd-collision-feel.md](research/dnd-collision-feel.md) for the\nfull reasoning. Both `useSortable` and `useGridSortable` floor the dragged\nitem's own rect onto a slot boundary from its TOP-LEFT corner: crossing a\nneighbour TOWARD index 0 takes about one pixel of travel; crossing one AWAY\nfrom it takes the neighbour's entire size in that axis — the item has to\narrive exactly on top of it. Measured (grid, `research/dnd-hover-flicker.md`\n§5) and read from source (list): the gallery's own `Sortable` (`ROW_HEIGHT\n= 56`) needs ~56px away from index 0 and ~1px toward it; its `SortableGrid`\n(74px tiles + 8px gaps) needs the full 82px away and ~1px toward.\n\n**This repo's own mirror deliberately diverges here, on purpose, both\ndirections symmetric.** `Sortable`/`SortableGrid` reorder by tracking the\ndragged item's own rect too — `fromIndex * slotSize` plus the pointer's delta\nsince the drag began, upstream's exact shape (`useEdgeAutoscroll`'s\n`GtkDropControllerMotion`, which already watches every motion event for edge\nautoscroll, reports each one to this tracking via a new `onDragMotion`\ncallback rather than a second controller) — but resolves the slot the item\nlands on by ROUNDING that position rather than upstream's own FLOOR: the\ndragged item's CENTRE against a slot's centre, not its top-left corner\nagainst the slot's origin (`order.ts`'s `resolveTrackedIndex`,\n`grid-order.ts`'s `resolveTrackedGridIndex` — `getGridCellFromCoordinates`\nitself is untouched, upstream parity, kept for its own row in this doc).\nMeasured with a real pointer (`tests/gtk/dnd/collision-thresholds.gtk.test.tsx`):\na 100px row/cell needs **~50-60px either way** — away from index 0 AND\ntoward it, centre grab AND an edge grab — not upstream's one-pixel-vs-full-item\nsplit. The origin that tracking measures against is the drag's own grab\npoint (`DragSourceControllers`'s `onGrab`, converted to the list's container\ncoordinates), never the first motion sample after a drag begins: under fast\npointer motion that sample can already be displaced past GDK's own\ndrag-start threshold, which would silently undercount every reading taken\nfrom it. Per-motion-event cost: the tracked-position update and slot\nresolution this adds is pure arithmetic, no FFI hop at all — ~0.003 µs median\n(settling from ~0.01 µs on the first JIT round), next to the drag layer's own\n~1.76 µs (two real GTK property writes per motion event,\n[research/dnd-differential.md](research/dnd-differential.md)). This was a\nchange to the mirror's OWN reorder mechanism, not to `Draggable`/`Droppable`'s\ndrop-zone hit-testing, which stays GDK's (`collisionAlgorithm` stays\naccepted-and-ignored there, below) — see\n[research/dnd-collision-feel.md](research/dnd-collision-feel.md) for the\ninvestigation this decision followed from.\n\n| Export | Notes |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `DropProvider` | Scopes a set of draggables and droppables. Renders a `View` (upstream renders a fragment) because `onDragging` needs a widget. `ref` gives `getDroppedItems()` and `requestPositionUpdate()`. |\n| `Draggable`, `Draggable.Handle`, `useDraggable` | The drag source. With a handle, the `GtkDragSource` attaches to the **handle's** widget, so the rest of the item stays pressable. |\n| `Droppable`, `useDroppable` | The drop target. `capacity` is enforced in GDK's `::accept`, so a full zone shows the no-drop cursor. |\n| `Sortable`, `SortableItem`, `SortableItem.Handle`, `useSortable`, `useSortableList`, `useHorizontalSortable`, `useHorizontalSortableList` | Drag-to-reorder, vertical (default) or horizontal (`direction=\"horizontal\"`). The component owns the order (upstream's contract); read the settled one from `onDrop`'s `allPositions`. Reorder-by-crossing does not care which axis a list scrolls along, so the horizontal hooks are the same mechanism under upstream's own separate names. |\n| `SortableGrid`, `SortableGridItem`, `SortableGridItem.Handle`, `useGridSortable`, `useGridSortableList` | The 2-D sibling: cells reorder the same way, in a real Yoga `flexWrap` grid rather than upstream's absolutely-positioned cells. No list-level `onMove`/`onDragStart`/`onDrop`/`onDragging` — same as upstream's own `SortableGridProps`, which has none either; wire them on each `SortableGridItem`. |\n| `DraggableState`, `ScrollDirection`, `SortableDirection`, `HorizontalScrollDirection`, `GridOrientation`, `GridStrategy`, `GridScrollDirection` | The enums, unchanged. |\n| `listToObject`, `objectMove`, `clamp` | The list utilities, as plain functions rather than worklets. |\n| `calculateGridPosition`, `calculateIndexFromRowColumn`, `listToGridObject`, `getGridCellFromCoordinates`, `reorderGridInsert`, `reorderGridSwap`, `calculateGridContentDimensions`, `findItemIdAtIndex` | The grid utilities, same reasoning. `getGridCellFromCoordinates` floors onto the cell whose top-left corner is at or before the point — upstream's own behaviour, not a bug this port fixes. |\n| `SharedValueLike<T>` | What `SharedValue<T>` degrades to: `{ value: T }` without the worklet crossing. Reads and writes work; they just do not animate. |",
811
832
  },
812
833
  {
813
834
  doc: "docs/api.md",
814
835
  heading: "Differences from `react-native-reanimated-dnd`",
815
- text: "The dragged view never moves — GDK carries a `Gtk.WidgetPaintable` of it\nabove every window, with the theme's own cursors and hit testing against the\nreal widget tree, including widgets React Native never created. Everything\nbelow follows from that one fact.\n\n| Prop | Behaviour here |\n| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `preDragDelay` | Accepted, ignored. GDK's `gtk-dnd-drag-threshold` already separates a tap from a drag. |\n| `collisionAlgorithm` | Accepted, ignored. GDK hit-tests the pointer; `\"center\"` is the closest of the three. |\n| `requestPositionUpdate()` | No-op. Nothing caches a slot rectangle, because GDK re-hit-tests every motion. |\n| `onLayoutUpdateComplete` | Accepted, ignored — there is no layout pass to complete. |\n| `itemHeight`, `estimatedItemHeight`, `enableDynamicHeights`, `useFlatList`, `containerHeight` | Accepted, ignored. Yoga lays rows out at their natural height, and there is no autoscroll for `containerHeight` to feed. |\n| `dragAxis`, `dragBoundsRef`, `animationFunction` | **Unsupported.** All three describe where the dragged view goes, and it never went anywhere. Kept in the type so a file shared with iOS and Android still compiles. |\n| `dropAlignment`, `dropOffset` | **Unsupported**, same reason. |\n| `positions`, `lowerBound`, `autoScrollDirection`, `itemHeights` | Real `{ value }` boxes (`SharedValueLike`), not `SharedValue`. Forwarding them with `{...rest}` works, reads work, writes do not animate. |\n| `SortableGrid`, `SortableGridItem`, `useGridSortable*`, `useHorizontalSortable*`, `SortableDirection.Horizontal` | **Not implemented.** Importing them fails at build time; passing `Horizontal` throws. |\n| Autoscroll near a container edge during a drag | Not implemented. |\n| Sortable list height | Rows are in flow layout, so the list is as tall as its rows — not `itemsCount × itemHeight`. |",
836
+ text: "The dragged view never moves — GDK carries a `Gtk.WidgetPaintable` of it\nabove every window, with the theme's own cursors and hit testing against the\nreal widget tree, including widgets React Native never created. Everything\nbelow follows from that one fact.\n\n**A dragged `Draggable`/`SortableItem` escapes any `overflow: hidden`\nancestor automatically — not a prop, the same way GDK's own drag icon is not\none.** GDK's icon already escapes any clip in this process's own tree (it is\na compositor surface, not a descendant of anything here), but that is all it\nis — a cue at the cursor this process cannot introspect. While a drag is in\nflight, a second, non-interactive `Gtk.Picture` showing a live\n`Gtk.WidgetPaintable` of the dragged row is added to a `Gtk.Overlay` wrapped\nonce around each window's real content, escaping every ancestor's clip the\nsame way any `Overlay` child does. The original dims to reduced opacity for\nthe drag's duration (restored to whatever it was, not hardcoded) rather than\ndisappearing — `react-native-draggable-flatlist`'s `activeOpacity` and\nsimilar libraries do the same. Because a `Gtk.WidgetPaintable` is a LIVE view\nof the widget it observes, GDK's own icon and this copy dim along with the\noriginal; the three are one underlying render. The copy takes no input\n(`can-target: false`) and neither hit-testing nor the responder path changes\n— both still resolve against the original widget, unchanged. Zero React\nrenders happen per frame: positioning is two widget property writes\n(`setMarginStart`/`setMarginTop`) per motion event, ~1.76 µs median, measured\nand reasoned about next to `zIndex`'s own per-call cost in\n[research/dnd-differential.md](research/dnd-differential.md#a-window-level-drag-layer-the-escape-zindex-cannot-reach).\nReparenting the dragged widget itself into the overlay was tried first and\nrefused — a 100×100 card came out 800×600 under the new parent's own size\nnegotiation, and an unmount mid-flight stranded the widget outside the tree\nReact still owned; the same document has the detail.\n\n| Prop | Behaviour here |\n| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `preDragDelay` | Accepted, ignored. GDK's `gtk-dnd-drag-threshold` already separates a tap from a drag. |\n| `collisionAlgorithm` | Accepted, ignored. GDK hit-tests the pointer; `\"center\"` is the closest of the three. |\n| `requestPositionUpdate()` | No-op. Nothing caches a slot rectangle, because GDK re-hit-tests every motion. |\n| `onLayoutUpdateComplete` | Accepted, ignored — there is no layout pass to complete. |\n| `itemHeight`, `estimatedItemHeight`, `enableDynamicHeights`, `useFlatList`, `containerHeight` | Accepted, ignored. Yoga lays rows out at their natural height, and there is no autoscroll for `containerHeight` to feed. |\n| `dragAxis`, `dragBoundsRef`, `animationFunction` | **Unsupported.** All three describe where the dragged view goes, and it never went anywhere. Kept in the type so a file shared with iOS and Android still compiles. |\n| `dropAlignment`, `dropOffset` | **Unsupported**, same reason. |\n| `positions`, `lowerBound`, `autoScrollDirection`, `itemHeights` | Real `{ value }` boxes (`SharedValueLike`), not `SharedValue`. Forwarding them with `{...rest}` works, reads work, writes do not animate. |\n| `SortableGrid`, `SortableGridItem`, `useGridSortable*`, `useHorizontalSortable*`, `SortableDirection.Horizontal` | **Not implemented.** Importing them fails at build time; passing `Horizontal` throws. |\n| Autoscroll near a container edge during a drag | Not implemented. |\n| Sortable list height | Rows are in flow layout, so the list is as tall as its rows — not `itemsCount × itemHeight`. |\n\n| Prop | Behaviour here |\n| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `preDragDelay` | Accepted, ignored. GDK's `gtk-dnd-drag-threshold` already separates a tap from a drag. |\n| `collisionAlgorithm` | Accepted, ignored. GDK hit-tests the pointer; `\"center\"` is the closest of the three. |\n| `requestPositionUpdate()` | No-op. Nothing caches a slot rectangle, because GDK re-hit-tests every motion. |\n| `onLayoutUpdateComplete` | Accepted, ignored — there is no layout pass to complete. |\n| `itemHeight`, `estimatedItemHeight`, `enableDynamicHeights`, `useFlatList`, `containerHeight`, `containerWidth` | Accepted, ignored. Yoga lays rows out at their natural height, and the mirror's own `ScrollView` measures its own viewport for autoscroll rather than trusting a hint. |\n| `dragAxis`, `dragBoundsRef`, `animationFunction` | **Unsupported.** All three describe where the dragged view goes, and it never went anywhere. Kept in the type so a file shared with iOS and Android still compiles. |\n| `dropAlignment`, `dropOffset` | **Unsupported**, same reason. |\n| `positions`, `lowerBound`/`leftBound`, `autoScrollDirection`/`autoScrollHorizontalDirection`, `itemHeights` | Real `{ value }` boxes (`SharedValueLike`), not `SharedValue`. Forwarding them with `{...rest}` works, reads work; `autoScrollDirection`/`autoScrollHorizontalDirection` are now genuinely written by the autoscroll below, the rest do not animate. |\n| `SortableDirection.Horizontal`, `useHorizontalSortable`, `useHorizontalSortableList` | Implemented. Reorder-by-crossing does not care which axis a list scrolls along — the tracked position (see the dead-zone section above) reads whichever coordinate the axis cares about — so this is `Sortable`/`useSortable`'s own machinery with a horizontal `ScrollView` and `leftBound`/`autoScrollHorizontalDirection` plumbing, not a second implementation. `gap`/`paddingHorizontal` are real Yoga layout on the content container, not hints. |\n| `SortableGrid`, `SortableGridItem`, `useGridSortable`, `useGridSortableList` | Implemented. The grid is a real Yoga `flexWrap` layout — fixed-size cells, a fixed cross-axis dimension (`columns`/`rows` × `itemWidth`/`itemHeight`) — rather than upstream's absolutely-positioned cells at a `useAnimatedStyle`-computed `top`/`left`; the same row/column arithmetic (`calculateGridPosition`) places them, a different engine paints it. `getGridCellFromCoordinates` floors onto the cell whose top-left corner is at or before a point, exactly matching upstream. `SortableGridItem`'s `isBeingRemoved` removal animation is accepted and ignored, same reason as `animationFunction` above. `scrollEnabled` is accepted and ignored too — this platform's `ScrollView` has no prop to disable input the way upstream's does. |\n| Autoscroll near a container edge during a drag | Implemented for `Sortable` and `SortableGrid`: a `GtkDropControllerMotion` on the list's own viewport reports how close the drag sits to an edge, and a `Gtk.Widget` tick callback nudges the real `GtkAdjustment` toward it for as long as it stays there — an imperative per-frame write, no React render either way. One difference from upstream: the scroll runs at a constant speed while the edge band is occupied, rather than easing into a 1500ms glide, because there is no timing engine here to ease with. Not wired into the standalone `useSortableList`/`useHorizontalSortableList`/`useGridSortableList` hooks, which build no `ScrollView` of their own to drive. |\n| Sortable list height | Rows are in flow layout, so the list is as tall as its rows — not `itemsCount × itemHeight`. |",
816
837
  },
817
838
  {
818
839
  doc: "docs/api.md",
819
840
  heading: "`react-native-gesture-handler` (`react-native-gtkx/gesture-handler`)",
820
- text: "**Not a port of RNGH.** The semantics are reimplemented over this platform's\nown responder system, the same way `react-native-gtkx/reanimated` and\n`react-native-gtkx/dnd` are — upstream's implementation is the blueprint, not\na dependency. Two of the four reasons\n[research/gestures.md](research/gestures.md) originally gave for refusing RNGH\nexpired when Reanimated shipped; the other two (no `exports` map on its\n`src/web/`, and a react-native-windows precedent that has been a literal\n`// NO-OP` since 2.8.0) stand, which is why nothing is vendored.\n[research/gesture-detector.md](research/gesture-detector.md) has the\nmeasurements the design rests on.\n\nBoth presets alias the package name onto this subpath, so a ported app changes\nnothing in its source.\n\n```tsx\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\n\nconst offset = useSharedValue(0)\nconst start = useSharedValue(0)\n\nconst pan = Gesture.Pan()\n .activeOffsetY([-10, 10])\n // Capture where the view already is. `translationY` is measured from where\n // THIS gesture activated, so it starts at zero on every new grab — writing\n // `offset.value = event.translationY` instead would throw away everything\n // the view had accumulated and snap it back toward its origin the second\n // time you grab it.\n .onStart(() => {\n start.value = offset.value\n })\n .onUpdate((event) => {\n offset.value = start.value + event.translationY\n })\n\n;<GestureDetector gesture={pan}>\n <Animated.View style={[styles.card, animatedStyle]} />\n</GestureDetector>\n```\n\n`Tap` and `LongPress` are the same state machine with different predicates —\none recognizer, one event stream, one grant channel:\n\n```tsx\nconst doubleTap = Gesture.Tap()\n .numberOfTaps(2)\n // The tap-vs-drag rule: a press that travels further than this is a drag,\n // and stops being a tap.\n .maxDistance(10)\n .onStart(() => setZoomed((on) => !on))\n\nconst hold = Gesture.LongPress()\n .minDuration(400)\n // Fires with the pointer standing still — a long press activates on its\n // timer, not on the next movement.\n .onStart((event) => openMenuAfter(event.duration))\n```\n\n| Export | Behaviour |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `GestureHandlerRootView` | **Implemented, faithfully.** A `View` with `style ?? { flex: 1 }` — note that an explicit `style` _replaces_ the default rather than merging with it, which is what upstream does in all three of its implementations. Its other job, marking the subtree as gesture-arbitrating, is already this platform's: the responder system's lock is global, so there is nothing to scope. |\n| `GestureDetector` | **Implemented, and it adds no widget.** It renders its single child unchanged and reaches that child's widget through the handle the child already exposes, the same seam `createAnimatedComponent` uses. Its recognizer's responder props are merged into the child's, so a child with its own `onTouchStart` keeps working. `userSelect`, `touchAction` and `enableContextMenu` are Web-only upstream and are accepted and ignored. |\n| `Gesture.Pan()`, `Gesture.Tap()`, `Gesture.LongPress()`, `Gesture.Native()` | **Implemented**, all four over one state machine — the same event stream, the same grant channel, different predicates. See the config tables below. |\n| `Gesture.Pinch()`, `Gesture.Rotation()` | **Implemented, and they need a TOUCHPAD.** The same state machine, fed by `GtkGestureZoom`/`GtkGestureRotate` instead of by the pointer — see [the touchpad gestures](#gesturepinch-and-gesturerotation--the-two-that-need-a-touchpad) below. A mouse cannot produce either: with no touchpad attached they simply never begin. |\n| `usePanGesture()`, `useTapGesture()`, `useLongPressGesture()`, `useNativeGesture()`, `usePinchGesture()`, `useRotationGesture()`, `useFlingGesture()`, `useManualGesture()`, `useHoverGesture()` | **Implemented**, over the same recognizers. Nine hooks and ten recognizers, which is upstream's own count rather than a gap: `src/v3/hooks/gestures/` has nine directories and no `forceTouch`, `SingleGesture` omits ForceTouch from its union, and `useForceTouchGesture` exists nowhere in 3.1.0 — so `Gesture.ForceTouch()` is the whole API upstream offers for it. Upstream deprecated all twelve `Gesture.*` statics in 3.1.0 in favour of hooks, and its hook renamed the callbacks: `onStart` → `onActivate`, `onEnd` → `onDeactivate`, `onTouchesCancelled` → `onTouchesCancel`, no `onChange`, and `canceled` on the ending event instead of a second `success` argument. Both spellings are honoured as written. |\n| `Gesture.Race()`, `Gesture.Simultaneous()`, `Gesture.Exclusive()` | **Implemented as list-builders** over the three relation maps, with no mechanism of their own — see [the relations](#cross-gesture-relations) below. One `GestureDetector` may hold a composition, which mounts several recognizers on the one child and still adds no widget. |\n| `useCompetingGestures()`, `useSimultaneousGestures()`, `useExclusiveGestures()` | **Implemented**, the hook spelling of the same three, over the same lists. `useCompetingGestures` is `Gesture.Race()` under upstream's better name. |\n| `Gesture.Fling()`, `Gesture.Manual()`, `Gesture.Hover()`, `Gesture.ForceTouch()` | **Implemented**, and each was refused until now for a different reason — see [the last four](#the-last-four-fling-manual-hover-and-force-touch). `Fling` and `Manual` were reachable and unwritten. `Hover` was refused on a judgement about the test rig that was simply wrong, and is now the most fully verified of the four. `ForceTouch` needs a pressure-reporting **stylus**: it is driven by `GtkGestureStylus`, which is stylus-only, so a mouse produces no events for it at all. |\n| `Directions`, `HoverEffect`, `MouseButton`, `PointerType` | **Implemented**, as the plain enums they are upstream, with every value pinned by a test. `Directions` is required rather than merely harmless — `Gesture.Fling().direction()` takes those bits. `PointerType` became meaningful with `ForceTouch`, the first kind whose events are honestly not a mouse. `HoverEffect` and `MouseButton` are **inert**, exactly as they are off their platforms upstream, and are exported because the knobs that take them (`.effect()`, `.mouseButton()`) are already accepted-and-inert: a knob that takes a number while refusing the constant naming that number is incoherent. |\n| `State` | **Implemented**, as the plain enum it is upstream: `UNDETERMINED` 0, `FAILED` 1, `BEGAN` 2, `CANCELLED` 3, `ACTIVE` 4, `END` 5. Every payload carries a faithful `state`, and two of the libraries this targets compare it by value, so all six numbers are pinned by a test against 3.1.0 — a silently different one would go on compiling and quietly answer false. |\n| `ScrollView`, `FlatList`, `TextInput`, `Switch`, `Pressable` | **Implemented as the platform's own components, by identity.** Upstream builds each with `createNativeWrapper(RN.X, { disallowInterruption: true, shouldCancelWhenOutside: false })` — an RN component with a `NativeViewGestureHandler` attached, so its arbitration knows about the native scrolling underneath. Here the responder system IS that arbitration, every one of these already speaks it, and `Gesture.Native()` is how a gesture is declared over one explicitly — so the wrapper has nothing to add and the re-export is the component itself. They are here because they are RENDERED: two of the three measured consumers hand `FlatList`/`ScrollView` to `Animated.createAnimatedComponent()` at module scope. |\n| `TouchableOpacity`, `TouchableHighlight`, `TouchableWithoutFeedback` | **Implemented as the platform's own**, same reasoning. Out of scope by preference and unavoidable in fact: `@gorhom/bottom-sheet` re-exports all three from its own public entry as `BottomSheetTouchable` on every platform except iOS, so it is upstream's export rather than an app's choice. |\n| everything else | **Throws**, naming the symbol — and every remaining refusal now carries its reason rather than being a bare stub. Three groups: the RNGH **1.x component API** (`PanGestureHandler` and the eight other `*GestureHandler` components, `legacy_createNativeWrapper`), which is a second public surface over the same recognizers that upstream deprecated before it deprecated the builder, and which none of the four target libraries still uses; the **button family** (`RawButton`, `BaseButton`, `RectButton`, `BorderlessButton`, plus `TouchableNativeFeedback`, the deprecated `Touchable` mixin and `RefreshControl`), which is not RN components with a handler attached but RNGH's own native button views, with an Android ripple, `rippleColor`/`rippleRadius` and an `activeOpacity` applied by a widget this platform does not have; and the **tag registry** (`GestureStateManager`, `VirtualGestureDetector`, `InterceptingGestureDetector`), which needs a process-wide handler-tag lookup this platform deliberately does not keep — identity here is the mounted detector. The twelve `Legacy*` aliases inherit whichever of those applies. See [what stays refused, and why](#what-stays-refused-and-why). |",
841
+ text: "**Not a port of RNGH.** The semantics are reimplemented over this platform's\nown responder system, the same way `react-native-gtkx/reanimated` and\n`react-native-gtkx/dnd` are — upstream's implementation is the blueprint, not\na dependency. Two of the four reasons\n[research/gestures.md](research/gestures.md) originally gave for refusing RNGH\nexpired when Reanimated shipped; the other two (no `exports` map on its\n`src/web/`, and a react-native-windows precedent that has been a literal\n`// NO-OP` since 2.8.0) stand, which is why nothing is vendored.\n[research/gesture-detector.md](research/gesture-detector.md) has the\nmeasurements the design rests on.\n\nBoth presets alias the package name onto this subpath, so a ported app changes\nnothing in its source.\n\n```tsx\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\"\n\nconst offset = useSharedValue(0)\nconst start = useSharedValue(0)\n\nconst pan = Gesture.Pan()\n .activeOffsetY([-10, 10])\n // Capture where the view already is. `translationY` is measured from where\n // THIS gesture activated, so it starts at zero on every new grab — writing\n // `offset.value = event.translationY` instead would throw away everything\n // the view had accumulated and snap it back toward its origin the second\n // time you grab it.\n .onStart(() => {\n start.value = offset.value\n })\n .onUpdate((event) => {\n offset.value = start.value + event.translationY\n })\n\n;<GestureDetector gesture={pan}>\n <Animated.View style={[styles.card, animatedStyle]} />\n</GestureDetector>\n```\n\n`Tap` and `LongPress` are the same state machine with different predicates —\none recognizer, one event stream, one grant channel:\n\n```tsx\nconst doubleTap = Gesture.Tap()\n .numberOfTaps(2)\n // The tap-vs-drag rule: a press that travels further than this is a drag,\n // and stops being a tap.\n .maxDistance(10)\n .onStart(() => setZoomed((on) => !on))\n\nconst hold = Gesture.LongPress()\n .minDuration(400)\n // Fires with the pointer standing still — a long press activates on its\n // timer, not on the next movement.\n .onStart((event) => openMenuAfter(event.duration))\n```\n\n| Export | Behaviour |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `GestureHandlerRootView` | **Implemented, faithfully.** A `View` with `style ?? { flex: 1 }` — note that an explicit `style` _replaces_ the default rather than merging with it, which is what upstream does in all three of its implementations. Its other job, marking the subtree as gesture-arbitrating, is already this platform's: the responder system's lock is global, so there is nothing to scope. |\n| `GestureDetector` | **Implemented, and it adds no widget.** It renders its single child unchanged and reaches that child's widget through the handle the child already exposes, the same seam `createAnimatedComponent` uses. Its recognizer's responder props are merged into the child's, so a child with its own `onTouchStart` keeps working. `userSelect`, `touchAction` and `enableContextMenu` are Web-only upstream and are accepted and ignored. |\n| `Gesture.Pan()`, `Gesture.Tap()`, `Gesture.LongPress()`, `Gesture.Native()` | **Implemented**, all four over one state machine — the same event stream, the same grant channel, different predicates. See the config tables below. |\n| `Gesture.Pinch()`, `Gesture.Rotation()` | **Implemented, and they need a TOUCHPAD.** The same state machine, fed by `GtkGestureZoom`/`GtkGestureRotate` instead of by the pointer — see [the touchpad gestures](#gesturepinch-and-gesturerotation--the-two-that-need-a-touchpad) below. A mouse cannot produce either: with no touchpad attached they simply never begin. |\n| `usePanGesture()`, `useTapGesture()`, `useLongPressGesture()`, `useNativeGesture()`, `usePinchGesture()`, `useRotationGesture()`, `useFlingGesture()`, `useManualGesture()`, `useHoverGesture()` | **Implemented**, over the same recognizers. Nine hooks and ten recognizers, which is upstream's own count rather than a gap: `src/v3/hooks/gestures/` has nine directories and no `forceTouch`, `SingleGesture` omits ForceTouch from its union, and `useForceTouchGesture` exists nowhere in 3.1.0 — so `Gesture.ForceTouch()` is the whole API upstream offers for it. Upstream deprecated all twelve `Gesture.*` statics in 3.1.0 in favour of hooks, and its hook renamed the callbacks: `onStart` → `onActivate`, `onEnd` → `onDeactivate`, `onTouchesCancelled` → `onTouchesCancel`, no `onChange`, and `canceled` on the ending event instead of a second `success` argument. Both spellings are honoured as written. |\n| `Gesture.Race()`, `Gesture.Simultaneous()`, `Gesture.Exclusive()` | **Implemented as list-builders** over the three relation maps, with no mechanism of their own — see [the relations](#cross-gesture-relations) below. One `GestureDetector` may hold a composition, which mounts several recognizers on the one child and still adds no widget. |\n| `useCompetingGestures()`, `useSimultaneousGestures()`, `useExclusiveGestures()` | **Implemented**, the hook spelling of the same three, over the same lists. `useCompetingGestures` is `Gesture.Race()` under upstream's better name. |\n| `Gesture.Fling()`, `Gesture.Manual()`, `Gesture.Hover()`, `Gesture.ForceTouch()` | **Implemented**, and each was refused until now for a different reason — see [the last four](#the-last-four-fling-manual-hover-and-force-touch). `Fling` and `Manual` were reachable and unwritten. `Hover` was refused on a judgement about the test rig that was simply wrong, and is now the most fully verified of the four. `ForceTouch` needs a pressure-reporting **stylus**: it is driven by `GtkGestureStylus`, which is stylus-only, so a mouse produces no events for it at all. |\n| `GestureStateManager` | **Implemented — refused, then reversed 2026-08-05.** Upstream's standalone `activate(handlerTag)` / `fail(handlerTag)` / `deactivate(handlerTag)`, routed through the same `stateManager` object `Gesture.Manual()`'s own `onTouches*` callbacks already receive, resolved from the tag through a new registry — see [the reversal](#gesturestatemanager--the-refusal-a-real-consumer-outgrew) below. |\n| `Directions`, `HoverEffect`, `MouseButton`, `PointerType` | **Implemented**, as the plain enums they are upstream, with every value pinned by a test. `Directions` is required rather than merely harmless — `Gesture.Fling().direction()` takes those bits. `PointerType` became meaningful with `ForceTouch`, the first kind whose events are honestly not a mouse. `HoverEffect` and `MouseButton` are **inert**, exactly as they are off their platforms upstream, and are exported because the knobs that take them (`.effect()`, `.mouseButton()`) are already accepted-and-inert: a knob that takes a number while refusing the constant naming that number is incoherent. |\n| `State` | **Implemented**, as the plain enum it is upstream: `UNDETERMINED` 0, `FAILED` 1, `BEGAN` 2, `CANCELLED` 3, `ACTIVE` 4, `END` 5. Every payload carries a faithful `state`, and two of the libraries this targets compare it by value, so all six numbers are pinned by a test against 3.1.0 — a silently different one would go on compiling and quietly answer false. |\n| `ScrollView`, `FlatList`, `TextInput`, `Switch`, `Pressable` | **Implemented as the platform's own components, by identity.** Upstream builds each with `createNativeWrapper(RN.X, { disallowInterruption: true, shouldCancelWhenOutside: false })` — an RN component with a `NativeViewGestureHandler` attached, so its arbitration knows about the native scrolling underneath. Here the responder system IS that arbitration, every one of these already speaks it, and `Gesture.Native()` is how a gesture is declared over one explicitly — so the wrapper has nothing to add and the re-export is the component itself. They are here because they are RENDERED: two of the three measured consumers hand `FlatList`/`ScrollView` to `Animated.createAnimatedComponent()` at module scope. |\n| `TouchableOpacity`, `TouchableHighlight`, `TouchableWithoutFeedback` | **Implemented as the platform's own**, same reasoning. Out of scope by preference and unavoidable in fact: `@gorhom/bottom-sheet` re-exports all three from its own public entry as `BottomSheetTouchable` on every platform except iOS, so it is upstream's export rather than an app's choice. |\n| everything else | **Throws**, naming the symbol — and every remaining refusal now carries its reason rather than being a bare stub. Three groups: the RNGH **1.x component API** (`PanGestureHandler` and the eight other `*GestureHandler` components, `legacy_createNativeWrapper`), which is a second public surface over the same recognizers that upstream deprecated before it deprecated the builder, and which none of the four target libraries still uses; the **button family** (`RawButton`, `BaseButton`, `RectButton`, `BorderlessButton`, plus `TouchableNativeFeedback`, the deprecated `Touchable` mixin and `RefreshControl`), which is not RN components with a handler attached but RNGH's own native button views, with an Android ripple, `rippleColor`/`rippleRadius` and an `activeOpacity` applied by a widget this platform does not have; and the **two experimental detectors** (`VirtualGestureDetector`, `InterceptingGestureDetector`) — one drives a gesture with no view at all, the other needs an irrevocable claim this platform cannot take back. The twelve `Legacy*` aliases inherit whichever of those applies. See [what stays refused, and why](#what-stays-refused-and-why). |",
821
842
  },
822
843
  {
823
844
  doc: "docs/api.md",
@@ -852,12 +873,12 @@ export const DOC_CHUNKS = [
852
873
  {
853
874
  doc: "docs/api.md",
854
875
  heading: "The last four: fling, manual, hover and force touch",
855
- text: "**All four ship, and the interesting part is that each was refused for a\ndifferent reason — only one of which turned out to be about the platform.**\nThe recon that opened this work grouped them together and its own note said two\nof them were \"reachable today and just unwritten\". Re-examined one at a time:\n\n| Recognizer | Why it was refused | What re-examining it found |\n| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Gesture.Fling()` | unwritten | Nothing was blocking it. It is a velocity predicate and a direction predicate over the machine every other kind runs on. |\n| `Gesture.Manual()` | unwritten | The smallest of the four: two constant predicates. The work was making `GestureStateManager`'s four methods real transitions rather than two transitions and two deferred flags. |\n| `Gesture.Hover()` | \"no input to run on\" | **Wrong, and inherited rather than measured.** A hover needs no button — it needs `motion_absolute` and nothing else, which is the one request the injection harness has always had. `Pressable` has shipped hover on the same GTK controller since long before this epic. It is now the most fully verified of the four. |\n| `Gesture.ForceTouch()` | needs pressure, which nothing here reports | **True of every ordinary input, and not true of the rig.** No Wayland pointer protocol carries pressure — but the tablet protocol does, and a stylus is a kernel object. See below. |\n\n#### `Gesture.Fling()` — velocity, not distance\n\nThe thing to get right, and the thing a naive test does not catch: a fling is\nnot \"the pointer travelled 200px to the right\", because a slow drag travels\nexactly as far. Upstream guards it twice and both are reproduced.\n\n| Method / rule | Behaviour |\n| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `direction` | **Implemented**, as the bitmask it is: `Directions.LEFT \\| Directions.RIGHT` accepts either. Defaults to `Directions.RIGHT`. Setting two axis bits also opens the DIAGONAL between them, with a wider cone — `UP \\| RIGHT` accepts a 45° flick that neither `UP` nor `RIGHT` accepts alone. |\n| the cones | Upstream's: 30° around each axis (±15°) and 60° around each diagonal (±30°), which tile the circle exactly. |\n| `minVelocity` | **700 units per second**, upstream's `DEFAULT_MIN_VELOCITY`, compared strictly. Not configurable upstream and not here. |\n| the deadline | **800ms** from the press, upstream's `DEFAULT_MAX_DURATION_MS`. A press that has not flung by then FAILS, whatever it is doing. |\n| `numberOfPointers` | **Implemented, and compared for EQUALITY** against the most pointers the interaction ever had — so a two-finger fling is honestly unreachable on a one-pointer platform rather than silently single-finger, the same shape `LongPress` has. |\n| when it decides | **On every move, not on the release.** A fling activates the instant it is fast enough and pointed the right way, with the button still down; the release is only the last chance. |\n| the progression | BEGAN → ACTIVE → END in one synchronous breath, with **no `onUpdate` ever** — upstream overrides `activate()` to call `end()`. `Fling` is therefore discrete, and neither spelling offers `onUpdate`. |\n\n**One documented difference from upstream, and it is the velocity itself.**\nUpstream fits a second-degree least-squares polynomial over up to 20 samples\ninside a 300ms horizon (`VelocityTracker`) and takes the linear coefficient.\nThis platform's `velocityX`/`velocityY` are the last inter-event delta — which\nis what `Pan().minVelocity()` has always used here and what every payload\nreports. `Fling` reads the same number its own event carries rather than a\nsecond, better one nothing else can see. The consequence is that this fling is\nmore sensitive to a single long frame than upstream's; the deadline and the\ncone are unaffected.\n\n#### `Gesture.Manual()` — the app owns the state machine\n\nNo configuration of its own, in either spelling, which is upstream's shape\n(`ManualGesture` adds zero builder methods; v3's `ManualGestureProperties` is\n`Record<string, never>`). It begins on the press and then decides nothing: the\n`GestureStateManager` handed to `onTouchesDown`/`onTouchesMove`/`onTouchesUp`/\n`onTouchesCancel` is the whole API.\n\n| Method | Behaviour |\n| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `.begin()` | UNDETERMINED → BEGAN. |\n| `.activate()` | BEGAN → ACTIVE, **through the ordinary arbitration**. It is a request, not a decision: it can come back parked behind `requireExternalGestureToFail`, or cancelled. Forced past `manualActivation`, as upstream's web state manager forces it. |\n| `.end()` | BEGAN or ACTIVE → END, successfully. |\n| `.fail()` | BEGAN or ACTIVE → FAILED. |\n\n**One deliberate deviation, forced by the platform.** Upstream's Manual does not\nend when the pointers lift — its documentation says so explicitly. Half of that\nis reproduced exactly: a Manual still BEGAN when the pointer comes up **stays\nBEGAN**, holding nothing. The other half is not reachable. A gesture that is\nACTIVE here is holding an _interaction_ — the responder lock, the GTK sequence,\nthe suspended scrollers — and that interaction ends when the button does.\nStaying ACTIVE past it would mean holding a lock that no longer exists,\nreceiving no further events of any kind, and never reporting an ending at all.\nSo an ACTIVE Manual ends with the interaction, successfully. `onTouchesUp` fires\nfirst and carries the state manager, so an app that wants a different ending has\nthe event to write it in.\n\n#### `Gesture.Hover()` — the refusal that did not survive contact\n\nDriven by `GtkEventControllerMotion` — the same controller `Pressable` uses for\nits `hovered` state — through the same channel `Pinch` and `Rotation` arrive on.\nIt goes straight to ACTIVE on the crossing with no threshold at all (upstream's\n`begin(); activate();` on one event), reports `x`/`y` in the gesture view's own\ncoordinates while the pointer moves inside, and ENDs — not cancels — when the\npointer leaves.\n\n**It never takes the responder**, for the same structural reason the touchpad\ngestures do not: the responder lock is a lock over an interaction, an\ninteraction starts with a press, and a hover has none. There is no session to\ntake and no GTK sequence to claim. A hover therefore cannot exclude a press.\n\n| Method | Behaviour |\n| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `.effect()` / `hoverEffect` | **Accepted, inert.** iOS's own pointer effect; nothing in upstream's web handler branches on it either. |\n| `hitSlop`, `enabled` | **Implemented**, including the shrinking (negative) form of `hitSlop`. |\n| the callbacks | **Implemented**; `Hover` is continuous, so `onUpdate`/`onChange` report travel and `changeX`/`changeY` carry the delta. |\n| `mouseButton` | Inert for this kind on both platforms — upstream's hover entry points never consult a button. |\n\n**One thing to know before putting a hover next to something.** Mutual exclusion\nis upstream's default and is reproduced, and a hover activates whenever the\npointer crosses in — so a hover entering while a pan on another view is still\nBEGAN will cancel that pan. Upstream behaves the same way and works around it\nper-use: its own `Pressable` sets `manualActivation` on the hover recognizer\nprecisely to stop it blocking a `Gesture.Native()`. Declare\n`simultaneousWithExternalGesture` (or `Gesture.Simultaneous()`) between a hover\nand anything sharing its screen. Inventing an exemption here would be a second\narbitration rule upstream does not have.\n\n#### `Gesture.ForceTouch()` — pressure, and exactly how far it is verified\n\n**Upstream does not implement this off iOS at all**, so there is no web\nbehaviour to restate and the semantics below come from the documented contract:\nthere is no `src/web/handlers/ForceTouchGestureHandler.ts`, the web `Gestures`\nregistry has nine entries and this is not one of them, the legacy component\nresolves to a `ForceTouchFallback` that warns once and renders its children\nunchanged, and there is no v3 hook.\n\n| Method | Behaviour |\n| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `minForce` | **Implemented**, defaulting to upstream's documented **0.2**. Non-strict at the bound, like every other activation threshold here. Note that upstream's 0.2 is a doc comment rather than a constant — no JavaScript in 3.1.0 assigns it, because the real default lives in iOS code. |\n| `maxForce` | **Implemented** as a ceiling that FAILS the gesture before activation and CANCELS it after — the shape `LongPress`'s `maxDistance` has. Unset means no ceiling, which is upstream's shape too. |\n| `feedbackOnActivation` | **Accepted, inert.** There is no haptic device on this platform. |\n| `force`, `forceChange` | On every payload. `forceChange` is a **difference** (upstream's calculator subtracts, where the one for `Pinch` divides); on the first update it is the force itself. |\n| `pointerType` | **`STYLUS`**, and this is the only kind that does not say `MOUSE`. A pressure reading can only have come from a tablet tool. |\n\n**Where it comes from, and what a mouse does.** `GtkGestureStylus`, whose\n`down`/`motion`/`up` signals carry `get_axis(GDK_AXIS_PRESSURE)` already\nnormalised to `[0, 1]` — which is upstream's documented range, so nothing is\nrescaled. The controller is left at GTK's default `stylus-only`, so **a mouse\nproduces no events for it whatsoever**. That is deliberate and it is what keeps\na `ForceTouch` from quietly activating at pressure 0 on a machine with no\ntablet; a GTK test injects a real mouse press and drag over one and asserts that\nnothing fires.\n\n**How far it IS verified, measured rather than asserted.** A uinput virtual pen\ntablet (`tests/gtk/support/virtual-stylus.{py,ts}`, built from libinput's own\nlitest Wacom descriptor) drives the shipped recognizer through the whole real\nchain — kernel, evdev, libinput, compositor, GDK, `GtkGestureStylus` — under\n`spike/gesture-detector/run-stylus.sh`. It reports `begin=1 start=1 updates=13\nend=1 success=true`, activation at force **0.2298** against a `minForce` of 0.2,\nmonotonically rising forces to **1.000**, `pointerType: STYLUS`, a `maxForce`\ncancellation at **0.5196** against a ceiling of 0.5 with no update after it, and\nzero callbacks on a card the pen never touched. What it cannot do is run inside\nthe vitest suite, whose compositor is started with `WLR_BACKENDS=headless\nWLR_LIBINPUT_NO_DEVICES=1` and enumerates no input devices at all — the same\nsplit `Pinch` and `Rotation` already live with, and a property of the\ncompositor rather than of the gesture.",
876
+ text: "**All four ship, and the interesting part is that each was refused for a\ndifferent reason — only one of which turned out to be about the platform.**\nThe recon that opened this work grouped them together and its own note said two\nof them were \"reachable today and just unwritten\". Re-examined one at a time:\n\n| Recognizer | Why it was refused | What re-examining it found |\n| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Gesture.Fling()` | unwritten | Nothing was blocking it. It is a velocity predicate and a direction predicate over the machine every other kind runs on. |\n| `Gesture.Manual()` | unwritten | The smallest of the four: two constant predicates. The work was making `GestureStateManager`'s four methods real transitions rather than two transitions and two deferred flags. |\n| `Gesture.Hover()` | \"no input to run on\" | **Wrong, and inherited rather than measured.** A hover needs no button — it needs `motion_absolute` and nothing else, which is the one request the injection harness has always had. `Pressable` has shipped hover on the same GTK controller since long before this epic. It is now the most fully verified of the four. |\n| `Gesture.ForceTouch()` | needs pressure, which nothing here reports | **True of every ordinary input, and not true of the rig.** No Wayland pointer protocol carries pressure — but the tablet protocol does, and a stylus is a kernel object. See below. |\n\n#### `Gesture.Fling()` — velocity, not distance\n\nThe thing to get right, and the thing a naive test does not catch: a fling is\nnot \"the pointer travelled 200px to the right\", because a slow drag travels\nexactly as far. Upstream guards it twice and both are reproduced.\n\n| Method / rule | Behaviour |\n| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `direction` | **Implemented**, as the bitmask it is: `Directions.LEFT \\| Directions.RIGHT` accepts either. Defaults to `Directions.RIGHT`. Setting two axis bits also opens the DIAGONAL between them, with a wider cone — `UP \\| RIGHT` accepts a 45° flick that neither `UP` nor `RIGHT` accepts alone. |\n| the cones | Upstream's: 30° around each axis (±15°) and 60° around each diagonal (±30°), which tile the circle exactly. |\n| `minVelocity` | **700 units per second**, upstream's `DEFAULT_MIN_VELOCITY`, compared strictly. Not configurable upstream and not here. |\n| the deadline | **800ms** from the press, upstream's `DEFAULT_MAX_DURATION_MS`. A press that has not flung by then FAILS, whatever it is doing. |\n| `numberOfPointers` | **Implemented, and compared for EQUALITY** against the most pointers the interaction ever had — so a two-finger fling is honestly unreachable on a one-pointer platform rather than silently single-finger, the same shape `LongPress` has. |\n| when it decides | **On every move, not on the release.** A fling activates the instant it is fast enough and pointed the right way, with the button still down; the release is only the last chance. |\n| the progression | BEGAN → ACTIVE → END in one synchronous breath, with **no `onUpdate` ever** — upstream overrides `activate()` to call `end()`. `Fling` is therefore discrete, and neither spelling offers `onUpdate`. |\n\n**One documented difference from upstream, and it is the velocity itself.**\nUpstream fits a second-degree least-squares polynomial over up to 20 samples\ninside a 300ms horizon (`VelocityTracker`) and takes the linear coefficient.\nThis platform's `velocityX`/`velocityY` are the last inter-event delta — which\nis what `Pan().minVelocity()` has always used here and what every payload\nreports. `Fling` reads the same number its own event carries rather than a\nsecond, better one nothing else can see. The consequence is that this fling is\nmore sensitive to a single long frame than upstream's; the deadline and the\ncone are unaffected.\n\n#### `Gesture.Manual()` — the app owns the state machine\n\nNo configuration of its own, in either spelling, which is upstream's shape\n(`ManualGesture` adds zero builder methods; v3's `ManualGestureProperties` is\n`Record<string, never>`). It begins on the press and then decides nothing: the\n`GestureStateManager` handed to `onTouchesDown`/`onTouchesMove`/`onTouchesUp`/\n`onTouchesCancel` is the whole API.\n\n| Method | Behaviour |\n| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `.begin()` | UNDETERMINED → BEGAN. |\n| `.activate()` | BEGAN → ACTIVE, **through the ordinary arbitration**. It is a request, not a decision: it can come back parked behind `requireExternalGestureToFail`, or cancelled. Forced past `manualActivation`, as upstream's web state manager forces it. |\n| `.end()` | BEGAN or ACTIVE → END, successfully. |\n| `.fail()` | BEGAN or ACTIVE → FAILED. |\n\n**One deliberate deviation, forced by the platform.** Upstream's Manual does not\nend when the pointers lift — its documentation says so explicitly. Half of that\nis reproduced exactly: a Manual still BEGAN when the pointer comes up **stays\nBEGAN**, holding nothing. The other half is not reachable. A gesture that is\nACTIVE here is holding an _interaction_ — the responder lock, the GTK sequence,\nthe suspended scrollers — and that interaction ends when the button does.\nStaying ACTIVE past it would mean holding a lock that no longer exists,\nreceiving no further events of any kind, and never reporting an ending at all.\nSo an ACTIVE Manual ends with the interaction, successfully. `onTouchesUp` fires\nfirst and carries the state manager, so an app that wants a different ending has\nthe event to write it in.\n\n#### `GestureStateManager` — the refusal a real consumer outgrew\n\n**Refused → reversed 2026-08-05.** The original reasoning (docs/api.md's\nformer \"What stays refused\" row) was correct about upstream's OLD export:\n`GestureStateManager.create(tag)`, a factory that looks a mounted handler up\nby tag in a global `NodeManager`. This platform's manager — the one handed to\n`Gesture.Manual()`'s `onTouchesDown`/`onTouchesMove`/`onTouchesUp`/\n`onTouchesCancel` above — was always implemented; what was missing was the\nprocess-wide tag→handler registry the factory needs, and its absence was\ndeliberate: identity here is the mounted detector, and [relations](#cross-gesture-relations)\nresolve an app's own gesture object to a tag lazily, at press, for exactly the\nreason that avoids ever needing one.\n\n**The fact that changed it is `react-native-gesture-handler` 3.1.0's export\nitself, not this platform.** Read from the installed package rather than\nassumed: the value actually re-exported under this name is `./v3`'s\n`GestureStateManager` (`src/v3/gestureStateManager.ts`) — three STATIC\nmethods keyed by a handler tag, `activate`/`fail`/`deactivate` — and the old\n`.create(tag)` factory survives only as a TYPE (`LegacyGestureStateManagerType`)\nthe package no longer exports a value for. `react-native-sortables`'\n`integrations/gesture-handler/adapters/v3.ts` calls exactly the new shape:\n`GestureStateManager.activate(event.handlerTag)` from its own `onTouchesMove`,\nonce a drag survives its `dragActivationDelay` (200ms default) — the ordinary\ncase for any real drag, not an edge one. This platform's export threw on the\nproperty READ before that call could ever happen (docs/research/upstream-libraries.md,\n\"Wall 4, confirmed\").\n\n**What reversing it took.** A registry, `gesture-handler-compat/tag-registry.ts`:\nhandler tag → mounted recognizer, populated the instant `GestureDetector`'s\nruntime mints one and forgotten the instant it is disposed — a second,\nindependent map from ./relations' spec→tag one, which stays untouched and\nstill resolves lazily. `GestureStateManager.activate`/`fail`/`deactivate` look\nthe tag up and call `.activate()`/`.fail()`/`.end()` on that recognizer's own\nstate manager — the same object, same machinery, same arbitration loop\n`Gesture.Manual()`'s table above documents. A tag naming no mounted recognizer\n— never minted, or already unmounted — is not thrown on: it warns in\ndevelopment and does nothing, the same no-op ./recognizer's own state checks\nalready give an out-of-order call.\n\n| Method | Behaviour |\n| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |\n| `.activate(handlerTag)` | `stateManager.activate()` on the recognizer that tag names — BEGAN → ACTIVE, through the ordinary arbitration loop, exactly as above. |\n| `.fail(handlerTag)` | `stateManager.fail()` — BEGAN or ACTIVE → FAILED. |\n| `.deactivate(handlerTag)` | `stateManager.end()` — BEGAN or ACTIVE → END, successfully. Upstream's other name for the same transition. |\n| unknown tag | **No-op**, warned in development. Not upstream's shape (a native lookup miss), but the closest match: loud without being fatal. |\n\n#### `Gesture.Hover()` — the refusal that did not survive contact\n\nDriven by `GtkEventControllerMotion` — the same controller `Pressable` uses for\nits `hovered` state — through the same channel `Pinch` and `Rotation` arrive on.\nIt goes straight to ACTIVE on the crossing with no threshold at all (upstream's\n`begin(); activate();` on one event), reports `x`/`y` in the gesture view's own\ncoordinates while the pointer moves inside, and ENDs — not cancels — when the\npointer leaves.\n\n**It never takes the responder**, for the same structural reason the touchpad\ngestures do not: the responder lock is a lock over an interaction, an\ninteraction starts with a press, and a hover has none. There is no session to\ntake and no GTK sequence to claim. A hover therefore cannot exclude a press.\n\n| Method | Behaviour |\n| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `.effect()` / `hoverEffect` | **Accepted, inert.** iOS's own pointer effect; nothing in upstream's web handler branches on it either. |\n| `hitSlop`, `enabled` | **Implemented**, including the shrinking (negative) form of `hitSlop`. |\n| the callbacks | **Implemented**; `Hover` is continuous, so `onUpdate`/`onChange` report travel and `changeX`/`changeY` carry the delta. |\n| `mouseButton` | Inert for this kind on both platforms — upstream's hover entry points never consult a button. |\n\n**One thing to know before putting a hover next to something.** Mutual exclusion\nis upstream's default and is reproduced, and a hover activates whenever the\npointer crosses in — so a hover entering while a pan on another view is still\nBEGAN will cancel that pan. Upstream behaves the same way and works around it\nper-use: its own `Pressable` sets `manualActivation` on the hover recognizer\nprecisely to stop it blocking a `Gesture.Native()`. Declare\n`simultaneousWithExternalGesture` (or `Gesture.Simultaneous()`) between a hover\nand anything sharing its screen. Inventing an exemption here would be a second\narbitration rule upstream does not have.\n\n#### `Gesture.ForceTouch()` — pressure, and exactly how far it is verified\n\n**Upstream does not implement this off iOS at all**, so there is no web\nbehaviour to restate and the semantics below come from the documented contract:\nthere is no `src/web/handlers/ForceTouchGestureHandler.ts`, the web `Gestures`\nregistry has nine entries and this is not one of them, the legacy component\nresolves to a `ForceTouchFallback` that warns once and renders its children\nunchanged, and there is no v3 hook.\n\n| Method | Behaviour |\n| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `minForce` | **Implemented**, defaulting to upstream's documented **0.2**. Non-strict at the bound, like every other activation threshold here. Note that upstream's 0.2 is a doc comment rather than a constant — no JavaScript in 3.1.0 assigns it, because the real default lives in iOS code. |\n| `maxForce` | **Implemented** as a ceiling that FAILS the gesture before activation and CANCELS it after — the shape `LongPress`'s `maxDistance` has. Unset means no ceiling, which is upstream's shape too. |\n| `feedbackOnActivation` | **Accepted, inert.** There is no haptic device on this platform. |\n| `force`, `forceChange` | On every payload. `forceChange` is a **difference** (upstream's calculator subtracts, where the one for `Pinch` divides); on the first update it is the force itself. |\n| `pointerType` | **`STYLUS`**, and this is the only kind that does not say `MOUSE`. A pressure reading can only have come from a tablet tool. |\n\n**Where it comes from, and what a mouse does.** `GtkGestureStylus`, whose\n`down`/`motion`/`up` signals carry `get_axis(GDK_AXIS_PRESSURE)` already\nnormalised to `[0, 1]` — which is upstream's documented range, so nothing is\nrescaled. The controller is left at GTK's default `stylus-only`, so **a mouse\nproduces no events for it whatsoever**. That is deliberate and it is what keeps\na `ForceTouch` from quietly activating at pressure 0 on a machine with no\ntablet; a GTK test injects a real mouse press and drag over one and asserts that\nnothing fires.\n\n**How far it IS verified, measured rather than asserted.** A uinput virtual pen\ntablet (`tests/gtk/support/virtual-stylus.{py,ts}`, built from libinput's own\nlitest Wacom descriptor) drives the shipped recognizer through the whole real\nchain — kernel, evdev, libinput, compositor, GDK, `GtkGestureStylus` — under\n`spike/gesture-detector/run-stylus.sh`. It reports `begin=1 start=1 updates=13\nend=1 success=true`, activation at force **0.2298** against a `minForce` of 0.2,\nmonotonically rising forces to **1.000**, `pointerType: STYLUS`, a `maxForce`\ncancellation at **0.5196** against a ceiling of 0.5 with no update after it, and\nzero callbacks on a card the pen never touched. What it cannot do is run inside\nthe vitest suite, whose compositor is started with `WLR_BACKENDS=headless\nWLR_LIBINPUT_NO_DEVICES=1` and enumerates no input devices at all — the same\nsplit `Pinch` and `Rotation` already live with, and a property of the\ncompositor rather than of the gesture.",
856
877
  },
857
878
  {
858
879
  doc: "docs/api.md",
859
880
  heading: "What stays refused, and why",
860
- text: "The refusals that remain are decisions rather than gaps, and each now carries\nits reason in the source as well as here. Re-checked against the four target\nlibraries rather than assumed: sweeping the shipped sources of\n`@gorhom/bottom-sheet` 5.2.14, `react-native-draggable-flatlist` 4.0.3,\n`react-native-drawer-layout` 4.2.9 and `react-native-reanimated-dnd` 2.0.0 for\nevery symbol still refused finds exactly one hit — `RefreshControl` in\n`@gorhom/bottom-sheet` — and it is not this package's: it comes from\n`react-native`, as a type in one file and as a value only in a `.android.tsx`\nsibling, which Metro on this platform never resolves. **Nothing reaches for a\nbutton, a legacy handler component, or any `Legacy*` alias.** The `Touchable`\nsubset shipped earlier remains the only thing upstream's own exports forced.\n\n| Refused | Why |\n| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| the nine `*GestureHandler` components and `legacy_createNativeWrapper` | The RNGH **1.x component API**, deprecated upstream years before the builder was, and migrated off by all four target libraries. Reimplementing it means a second public surface over the same recognizers, with its own `onGestureEvent`/`onHandlerStateChange` shape and its own prop plumbing, for zero measured consumers. `createNativeWrapper` specifically has nothing to add: the responder system IS the arbitration it registers with, every component here already speaks it, and `Gesture.Native()` is how a gesture is declared over one explicitly. |\n| `RawButton`, `BaseButton`, `RectButton`, `BorderlessButton` | Not RN components with a handler attached — RNGH's own **native button views**, in Java and Objective-C, with an Android ripple, `rippleColor`/`rippleRadius`, `borderless` drawable selection and an `activeOpacity` applied by the native view. There is no GTK widget with those semantics and no way to fake the ripple, so any implementation would be a `Pressable` wearing another name. |\n| `TouchableNativeFeedback`, `Touchable`, `RefreshControl` | Android's ripple by another name; RN's deprecated mixin; and pull-to-refresh, which needs a scroll gesture this `ScrollView` does not expose and a spinner widget this platform does not have. |\n| `GestureStateManager`, `VirtualGestureDetector` | Upstream's standalone `create(tag)` factory looks a mounted handler up by tag in a global `NodeManager`. **The manager an app actually uses is implemented** it is the one handed into `onTouches*`, and it is what drives `Gesture.Manual()`. What is missing is the process-wide tag→handler registry, and its absence is deliberate: identity here is the mounted detector, and relations resolve an app's gesture object to a tag lazily so that nothing has to be looked up in a global map. Upstream deprecates this export in favour of the hook API in the same breath. |\n| `InterceptingGestureDetector` | 3.1.0's new experimental detector, which intercepts events destined for views BELOW it. On this platform that would mean claiming a GTK sequence before deciding — and `CLAIMED` is irrevocable here, so \"intercept, look, maybe give it back\" is not expressible. |\n| `GestureDetectorType` | A TYPE upstream, not a value. Type positions never reach this module at all (the alias is a bundler alias; `tsc` resolves the real package's types from node_modules), so a runtime value under that name could only be read by code that has already gone wrong. |\n| the twelve `Legacy*` aliases | Each is 3.x's escape hatch back to the 2.x implementation of a component whose 3.x spelling is either implemented here already or refused above with its own reason. Where the modern name works, the alias would be a second name carrying a promise — \"this behaves like 2.x did\" — that this platform cannot keep, never having implemented 2.x to differ from. `LegacyDrawerLayoutAndroid` is refused twice over: React Native itself does not ship `DrawerLayoutAndroid` off Android, and `@react-navigation/drawer` reaches for `react-native-drawer-layout`, which runs here. |",
881
+ text: "The refusals that remain are decisions rather than gaps, and each now carries\nits reason in the source as well as here. Re-checked against the four target\nlibraries rather than assumed: sweeping the shipped sources of\n`@gorhom/bottom-sheet` 5.2.14, `react-native-draggable-flatlist` 4.0.3,\n`react-native-drawer-layout` 4.2.9 and `react-native-reanimated-dnd` 2.0.0 for\nevery symbol still refused finds exactly one hit — `RefreshControl` in\n`@gorhom/bottom-sheet` — and it is not this package's: it comes from\n`react-native`, as a type in one file and as a value only in a `.android.tsx`\nsibling, which Metro on this platform never resolves. **Nothing reaches for a\nbutton, a legacy handler component, or any `Legacy*` alias.** The `Touchable`\nsubset shipped earlier remains the only thing upstream's own exports forced.\n\n| Refused | Why |\n| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| the nine `*GestureHandler` components and `legacy_createNativeWrapper` | The RNGH **1.x component API**, deprecated upstream years before the builder was, and migrated off by all four target libraries. Reimplementing it means a second public surface over the same recognizers, with its own `onGestureEvent`/`onHandlerStateChange` shape and its own prop plumbing, for zero measured consumers. `createNativeWrapper` specifically has nothing to add: the responder system IS the arbitration it registers with, every component here already speaks it, and `Gesture.Native()` is how a gesture is declared over one explicitly. |\n| `RawButton`, `BaseButton`, `RectButton`, `BorderlessButton` | Not RN components with a handler attached — RNGH's own **native button views**, in Java and Objective-C, with an Android ripple, `rippleColor`/`rippleRadius`, `borderless` drawable selection and an `activeOpacity` applied by the native view. There is no GTK widget with those semantics and no way to fake the ripple, so any implementation would be a `Pressable` wearing another name. |\n| `TouchableNativeFeedback`, `Touchable`, `RefreshControl` | Android's ripple by another name; RN's deprecated mixin; and pull-to-refresh, which needs a scroll gesture this `ScrollView` does not expose and a spinner widget this platform does not have. |\n| `VirtualGestureDetector` | 3.1.0's new experimental detector, which drives a gesture with no view at all. `gesture-handler-compat/tag-registry.ts` answers \"which mounted recognizer does this number mean\", not \"mint a recognizer with nothing to measure\" every recognizer here is still built by a mounted `GestureDetector` wrapping exactly one child, and giving it none is a structural feature the [`GestureStateManager` reversal](#gesturestatemanager--the-refusal-a-real-consumer-outgrew) below did not add. **Not the same refusal `GestureStateManager` had** see that entry for why the two used to be grouped and no longer are. |\n| `InterceptingGestureDetector` | 3.1.0's new experimental detector, which intercepts events destined for views BELOW it. On this platform that would mean claiming a GTK sequence before deciding — and `CLAIMED` is irrevocable here, so \"intercept, look, maybe give it back\" is not expressible. |\n| `GestureDetectorType` | A TYPE upstream, not a value. Type positions never reach this module at all (the alias is a bundler alias; `tsc` resolves the real package's types from node_modules), so a runtime value under that name could only be read by code that has already gone wrong. |\n| the twelve `Legacy*` aliases | Each is 3.x's escape hatch back to the 2.x implementation of a component whose 3.x spelling is either implemented here already or refused above with its own reason. Where the modern name works, the alias would be a second name carrying a promise — \"this behaves like 2.x did\" — that this platform cannot keep, never having implemented 2.x to differ from. `LegacyDrawerLayoutAndroid` is refused twice over: React Native itself does not ship `DrawerLayoutAndroid` off Android, and `@react-navigation/drawer` reaches for `react-native-drawer-layout`, which runs here. |",
861
882
  },
862
883
  {
863
884
  doc: "docs/api.md",
@@ -892,7 +913,7 @@ export const DOC_CHUNKS = [
892
913
  {
893
914
  doc: "docs/api.md",
894
915
  heading: "Differences from `react-native-reanimated`",
895
- text: "| Behaviour | Here |\n| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Animatable properties | `opacity`, `transform` and colours — see the boundary table. Layout properties are refused with the transform to use instead; anything else warns once by name. Both land on the next render. |\n| Animated values | Numbers only. `withTiming(\"#ff0000\")` throws rather than animating nothing: animate a number and map it with `interpolateColor`, which is what upstream's own examples do. |\n| `interpolateColor` colour spaces | `'RGB'` and `'HSV'`. `'LAB'` throws by name — upstream's is a vendored slice of culori fed 0-255 channels where culori documents 0-1, so matching it would mean matching the scaling. |\n| `interpolateColor` inputs | Colour strings only, and not `PlatformColor` — a theme colour has no value until GTK resolves it against the live theme, so it has nothing to blend. Both cases throw and say which one happened. |\n| `processColor` | Throws. It returns RN's packed AARRGGBB integer, whose only consumer is a native module; a colour's destination here is a GTK stylesheet, which takes strings. |\n| `runOnUI` / `runOnJS` | Schedule rather than run inline, and return `void`, as upstream. A UI hop is a **task**, an RN hop a microtask — so a UI hop is still the later of the two, but it does not wait for a frame the way upstream's _web_ build does (`requestAnimationFrame` stands in there for a UI runtime the web has not got; React Native's real one does not wait either). Waiting cost a `scheduleOnUI(measure)`/`scheduleOnRN(use it)` round trip a whole frame, which is longer than the gap between two GTK pointer events — see `docs/research/dnd-hover-flicker.md`. |\n| `SharedValue.addListener` | Accepts upstream's `(listenerID, listener)` **and** this platform's animated-node `(callback) => id`. Both callers are real, and supporting only one fails silently. |\n| Worklet closure capture | Live lexical capture, not the plugin's by-value snapshot. Only observable for a worklet closing over a reassigned plain `let`, which is already a bug on mobile. |\n| `withSpring` rest condition | Upstream stops on remaining energy relative to initial energy; the platform's solver stops on displacement and speed thresholds, derived here from the same energy budget. The stopping point differs by well under a pixel. |\n| `withDecay` config validation | Throws at the `withDecay()` call rather than on the animation's first frame. Same errors (`clamp` shape, `velocityFactor > 0`, `rubberBandEffect` needing a `clamp`), one line earlier. |\n| `ReduceMotion`, `useReducedMotion` | The enum is mirrored and every value behaves as `Never`; `useReducedMotion()` is always false. GNOME's `gtk-enable-animations` is not read yet. |\n| `reanimatedVersion` | The upstream version this surface mirrors, not a claim to be that package. |\n| `LinearTransition` size changes | The position animates (as a translation); a width or height change lands immediately. A layout transition animates a rect the ENGINE produced, so it has no style to pin a subtree to — the carve-out `useAnimatedStyle` has for a driven `width`/`height` does not reach it. |\n| Layout-animation properties | `opacity`, `transform` and position. `width`/`height` are applied rather than driven (above); anything else a builder asks for is named once, by property, in a warning. |\n| Builder methods | `.restDisplacementThreshold()` and `.restSpeedThreshold()` are accepted and ignored — this platform's spring derives its rest condition from the same energy budget instead (see the row above). `.reduceMotion()` is accepted and ignored for the reason `useReducedMotion()` is always false. |\n| `entering` / `exiting` ownership | A layout animation owns `opacity` and `transform` for as long as it runs, so a `useAnimatedStyle` driving the same property on the same view during a fade is two writers on one slot. Upstream has the same rule. |\n| The layout-animation catalog | 60 of the 76 presets, all five `*Transition` builders, `Keyframe` and `LayoutAnimationConfig`. `Flip*` needs a 3D rotation and `LightSpeed*` a skew; both throw by name. `rotate` is carried as degrees rather than upstream's `'90deg'`/`'5rad'` strings — a numeric animation cannot carry a unit, and the matrix that reaches GTK is identical. A builder's own `.rotate()` and a `.withInitialValues()` angle still take either spelling. |\n| `CurvedTransition` size easings | `.easingWidth()` and `.easingHeight()` are accepted and ignored, for the reason in the `LinearTransition` row above: a size change lands immediately rather than being driven, so there is no curve to apply to it. The two position easings are honoured. |\n| `SharedTransition` | Throws. It needs three things that do not exist here: a `sharedTransitionTag` prop, an overlay above the navigation stack, and a retention that REPARENTS the leaving widget rather than holding it in place. Upstream's own web path does not implement it either. |\n| `getAnimatedStyle`, `setUpTests` | Throw. Upstream returns the style object its updater produced, which exists on mobile because its Jest path mirrors it onto the component; here a style is taken apart at bind time — opacity to the widget, colours to a private CSS provider, the whole `transform` array folded into one matrix — so there is no such object to return. Assert the widget instead (`getOpacity()`, `computeBounds()`), which is what every GTK test in this repo does and is strictly stronger. |",
916
+ text: "| Behaviour | Here |\n| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Animatable properties | `opacity`, `transform` and colours — see the boundary table. Layout properties are refused with the transform to use instead; anything else warns once by name. Both land on the next render. |\n| Animated values | Numbers, and plain objects/arrays whose leaves are numbers — upstream's real `AnimatableValue`, minus colour strings: `withTiming(\"#ff0000\")` still throws rather than animating nothing, since colours go through `interpolateColor` instead, which is what upstream's own examples do too. `withTiming({x: 10, y: 20})`/`withSpring` interpolate every leaf on the same curve/clock (a nested object recurses; an array's own elements are always numbers, never nested — upstream's own asymmetry, `reanimated-compat/animatable-value.ts`), the callback fires once per animation rather than once per leaf, and composition through `withDelay`/`withSequence`/`withRepeat` carries a shape exactly as it carries a number. A target whose shape does not match the value it is animating from throws, naming the leaf, rather than silently dropping the mismatched key the way upstream's own from-value-driven walk does. A key that previously held a plain NUMBER seeds an animation from it (unchanged); a key that previously held a plain OBJECT does not — upstream's `prepareAnimation` has no branch for a plain data object either, so it is seeded at the target exactly like a key that was absent. Per-frame cost of an `{x, y}` `withTiming`: about 2× a single number's own frame (0.24 µs against 0.12 µs, VM-measured median of 15 × 100,000 frames, `spike/bench-vector-animated-values.ts`) — before the result ever reaches a style property, which is what the boundary table above actually costs. |\n| `interpolateColor` colour spaces | `'RGB'` and `'HSV'`. `'LAB'` throws by name — upstream's is a vendored slice of culori fed 0-255 channels where culori documents 0-1, so matching it would mean matching the scaling. |\n| `interpolateColor` inputs | Colour strings only, and not `PlatformColor` — a theme colour has no value until GTK resolves it against the live theme, so it has nothing to blend. Both cases throw and say which one happened. |\n| `processColor` | Throws. It returns RN's packed AARRGGBB integer, whose only consumer is a native module; a colour's destination here is a GTK stylesheet, which takes strings. |\n| `runOnUI` / `runOnJS` | Schedule rather than run inline, and return `void`, as upstream. A UI hop is a **task**, an RN hop a microtask — so a UI hop is still the later of the two, but it does not wait for a frame the way upstream's _web_ build does (`requestAnimationFrame` stands in there for a UI runtime the web has not got; React Native's real one does not wait either). Waiting cost a `scheduleOnUI(measure)`/`scheduleOnRN(use it)` round trip a whole frame, which is longer than the gap between two GTK pointer events — see `docs/research/dnd-hover-flicker.md`. |\n| `SharedValue.addListener` | Accepts upstream's `(listenerID, listener)` **and** this platform's animated-node `(callback) => id`. Both callers are real, and supporting only one fails silently. |\n| Worklet closure capture | Live lexical capture, not the plugin's by-value snapshot. Only observable for a worklet closing over a reassigned plain `let`, which is already a bug on mobile. |\n| `withSpring` rest condition | Upstream stops on remaining energy relative to initial energy; the platform's solver stops on displacement and speed thresholds, derived here from the same energy budget. The stopping point differs by well under a pixel. |\n| `withDecay` config validation | Throws at the `withDecay()` call rather than on the animation's first frame. Same errors (`clamp` shape, `velocityFactor > 0`, `rubberBandEffect` needing a `clamp`), one line earlier. |\n| `ReduceMotion`, `useReducedMotion` | The enum is mirrored and every value behaves as `Never`; `useReducedMotion()` is always false. GNOME's `gtk-enable-animations` is not read yet. |\n| `reanimatedVersion` | The upstream version this surface mirrors, not a claim to be that package. |\n| `LinearTransition` size changes | The position animates (as a translation); a width or height change lands immediately. A layout transition animates a rect the ENGINE produced, so it has no style to pin a subtree to — the carve-out `useAnimatedStyle` has for a driven `width`/`height` does not reach it. |\n| Layout-animation properties | `opacity`, `transform` and position. `width`/`height` are applied rather than driven (above); anything else a builder asks for is named once, by property, in a warning. |\n| Builder methods | `.restDisplacementThreshold()` and `.restSpeedThreshold()` are accepted and ignored — this platform's spring derives its rest condition from the same energy budget instead (see the row above). `.reduceMotion()` is accepted and ignored for the reason `useReducedMotion()` is always false. |\n| `entering` / `exiting` ownership | A layout animation owns `opacity` and `transform` for as long as it runs, so a `useAnimatedStyle` driving the same property on the same view during a fade is two writers on one slot. Upstream has the same rule. |\n| The layout-animation catalog | 60 of the 76 presets, all five `*Transition` builders, `Keyframe` and `LayoutAnimationConfig`. `Flip*` needs a 3D rotation and `LightSpeed*` a skew; both throw by name. `rotate` is carried as degrees rather than upstream's `'90deg'`/`'5rad'` strings — a numeric animation cannot carry a unit, and the matrix that reaches GTK is identical. A builder's own `.rotate()` and a `.withInitialValues()` angle still take either spelling. |\n| `CurvedTransition` size easings | `.easingWidth()` and `.easingHeight()` are accepted and ignored, for the reason in the `LinearTransition` row above: a size change lands immediately rather than being driven, so there is no curve to apply to it. The two position easings are honoured. |\n| `SharedTransition` | Throws. It needs three things that do not exist here: a `sharedTransitionTag` prop, an overlay above the navigation stack, and a retention that REPARENTS the leaving widget rather than holding it in place. Upstream's own web path does not implement it either. |\n| `getAnimatedStyle`, `setUpTests` | Throw. Upstream returns the style object its updater produced, which exists on mobile because its Jest path mirrors it onto the component; here a style is taken apart at bind time — opacity to the widget, colours to a private CSS provider, the whole `transform` array folded into one matrix — so there is no such object to return. Assert the widget instead (`getOpacity()`, `computeBounds()`), which is what every GTK test in this repo does and is strictly stronger. |",
896
917
  },
897
918
  {
898
919
  doc: "docs/api.md",
@@ -1097,7 +1118,7 @@ export const DOC_CHUNKS = [
1097
1118
  {
1098
1119
  doc: "docs/gtkx-rc4-notes.md",
1099
1120
  heading: "How each was checked against rc.4",
1100
- text: 'The rule is that a changelog entry is a claim and the removal condition is\nthe test, so each row was re-run on the real runtime rather than read about.\n\n- **`use-signal-stale-handler`** — `tests/gtk/bridge/use-signal-upstream.gtk.test.tsx`\n calls gtkx\'s own hook directly on a `memo` component; it is an `it.fails`\n guard that starts passing the day the defect is gone. On rc.4 it still\n fails, and it is the "1 expected fail" the whole suite reports.\n- **`runtime-dedupe`** — the first two attempts at a probe both said "no\n abort", and both were wrong, which is worth recording: Node caches a native\n addon by the resolved path of the `.node` FILE, so a second copy of the thin\n `@gtkx/native` JS wrapper shares one addon instance and one Rust static, and\n a second `init()` on it returns normally — on rc.3 exactly as on rc.4. The\n failure needs two DISTINCT `.node` files in one process (an app with its own\n `@gtkx/native-linux-*-gnu` plus a nested one under the library), each\n carrying its own `glib::log::WRITER_FUNC`. Built that way, rc.4 dies:\n `gtkx: GLib-ERROR: g_log_set_writer_func() called multiple times`, exit 134,\n core dumped. Neither half of the condition is met — the init is not\n idempotent, and while the error names the symbol it does not name the\n duplicate package, which is the part that would make it debuggable.\n- **`prop-portal`** — enumerated the real module objects on the runtime rather\n than reading the `.d.ts`. `@gtkx/react` exports exactly `createPortal`,\n `createRoot`, `quit`, `rootElement`, `useApplication`, `useBindSetting`,\n `useParentWindow`, `useProperty`, `useSetting`, `useSignal`;\n `@gtkx/react/internal` exports `applyWrite`, `createApplicationComponent`,\n `createElementComponent`, `createReconcilerRoot`, `createWindowComponent`,\n `getAccessibleMetadata`, `isRootElement`, `setReconcilerErrorHandler`,\n `useMergedRef`. No value in either is `"gtkx:prop"`, and there is no\n slot-aware portal. rc.4 also made the fallback worse rather than better:\n importing `@gtkx/react/dist/components/element.js` now fails with _"not\n exported under the conditions [node, development, import]"_, so restating\n the literal is the only route left. The literal itself did not move —\n `const Prop = "gtkx:prop"` is unchanged in rc.4\'s `components/element.tsx`\n (only a doc comment above it was deleted), which the passing\n `WindowActions`/`WindowControllers`/breakpoint suites confirm functionally.\n- **`renderhook-no-window`** — `RenderHookOptions` still carries only\n `wrapper` and `initialProps`, no `container`, and on the runtime\n `renderHook` took the toplevel count from 0 to 0 while `render` took it\n from 0 to 1 in the same file.',
1121
+ text: 'The rule is that a changelog entry is a claim and the removal condition is\nthe test, so each row was re-run on the real runtime rather than read about.\n\n- **`use-signal-stale-handler`** — `tests/gtk/bridge/use-signal-upstream.gtk.test.tsx`\n calls gtkx\'s own hook directly on a `memo` component; it is an `it.fails`\n guard that starts passing the day the defect is gone. On rc.4 it still\n fails, and it is the "1 expected fail" the whole suite reports.\n- **`runtime-dedupe`** — the first two attempts at a probe both said "no\n abort", and both were wrong, which is worth recording: Node caches a native\n addon by the resolved path of the `.node` FILE, so a second copy of the thin\n `@gtkx/native` JS wrapper shares one addon instance and one Rust static, and\n a second `init()` on it returns normally — on rc.3 exactly as on rc.4. The\n failure needs two DISTINCT `.node` files in one process (an app with its own\n `@gtkx/native-linux-*-gnu` plus a nested one under the library), each\n carrying its own `glib::log::WRITER_FUNC`. Built that way, rc.4 dies:\n `gtkx: GLib-ERROR: g_log_set_writer_func() called multiple times`, exit 134,\n core dumped. Neither half of the condition is met — the init is not\n idempotent, and while the error names the symbol it does not name the\n duplicate package, which is the part that would make it debuggable.\n\n **Not the only SIGABRT out of this subsystem.** A second, separate crash —\n a Rust panic inside the `writer_trampoline` `log_set_writer_func` installs\n (registered fine, once) rather than a double-registration — twice took\n down a CI worker fork under `tests/gtk/dnd/collision-thresholds.gtk.test.tsx`\n (2026-08-04, runs 30903167960 and 30904467362). Not a new\n `RC4-WORKAROUND` row: the trigger was our own bug (`scroll-view.tsx`\'s\n `syncAdjustmentRange` calling `Gtk.Adjustment.configure()` with an invalid\n range, retried every frame), now fixed, not gtkx drift to absorb in the\n bridge. Full backtrace and the upstream ask (harden `writer_trampoline`\n with `catch_unwind`) are in\n [docs/upstream-gtkx.md](upstream-gtkx.md#2-a-panic-inside-the-glib-log-writer-trampoline-aborts-the-whole-process-not-just-the-offending-log-call).\n\n- **`prop-portal`** — enumerated the real module objects on the runtime rather\n than reading the `.d.ts`. `@gtkx/react` exports exactly `createPortal`,\n `createRoot`, `quit`, `rootElement`, `useApplication`, `useBindSetting`,\n `useParentWindow`, `useProperty`, `useSetting`, `useSignal`;\n `@gtkx/react/internal` exports `applyWrite`, `createApplicationComponent`,\n `createElementComponent`, `createReconcilerRoot`, `createWindowComponent`,\n `getAccessibleMetadata`, `isRootElement`, `setReconcilerErrorHandler`,\n `useMergedRef`. No value in either is `"gtkx:prop"`, and there is no\n slot-aware portal. rc.4 also made the fallback worse rather than better:\n importing `@gtkx/react/dist/components/element.js` now fails with _"not\n exported under the conditions [node, development, import]"_, so restating\n the literal is the only route left. The literal itself did not move —\n `const Prop = "gtkx:prop"` is unchanged in rc.4\'s `components/element.tsx`\n (only a doc comment above it was deleted), which the passing\n `WindowActions`/`WindowControllers`/breakpoint suites confirm functionally.\n- **`renderhook-no-window`** — `RenderHookOptions` still carries only\n `wrapper` and `initialProps`, no `container`, and on the runtime\n `renderHook` took the toplevel count from 0 to 0 while `render` took it\n from 0 to 1 in the same file.',
1101
1122
  },
1102
1123
  {
1103
1124
  doc: "docs/gtkx-rc4-notes.md",
@@ -1322,7 +1343,7 @@ export const DOC_CHUNKS = [
1322
1343
  {
1323
1344
  doc: "docs/api.md",
1324
1345
  heading: "AppRegistry",
1325
- text: 'AppRegistry — Supported: `registerComponent`, `runApplication(appKey, {title,width,height,initialProps,chrome,actionAccels,breakpoints,applicationActions,windowActions,windowControllers})`, `getAppKeys`. Differences: desktop window parameters; `chrome: "content"` uses an AdwApplicationWindow with no window titlebar — the app\'s HeaderBars (navigation) become the chrome. `actionAccels` binds accelerators to action names on the `GtkApplication`; `breakpoints` reaches `AdwApplicationWindow`\'s own prop and only does anything under `chrome: "content"` (a dev warning fires otherwise). **`applicationActions`/`windowActions`/`windowControllers` are deprecated** — reach for [`<ApplicationActions>`/`<WindowActions>`/`<WindowControllers>`](platform-layer.md#actions-and-shortcuts-declared-in-the-app-tree) instead; they still work unchanged',
1346
+ text: 'AppRegistry — Supported: `registerComponent`, `runApplication(appKey, {title,width,height,initialProps,chrome,actionAccels,breakpoints,applicationActions,windowActions,windowControllers})`, `getAppKeys`. Differences: desktop window parameters; `chrome: "content"` uses an AdwApplicationWindow with no window titlebar when this app declares `"Adw-1"` — the app\'s HeaderBars (navigation) become the chrome — and falls back to the plain `GtkApplicationWindow` `chrome: "system"` already uses when it does not (see [Plain GTK profile](#plain-gtk-profile) below): request `chrome: "content"` unconditionally and get HeaderBar-as-chrome where Adw exists, an ordinary window otherwise, with no branching of the app\'s own. `actionAccels` binds accelerators to action names on the `GtkApplication`; `breakpoints` reaches `AdwApplicationWindow`\'s own prop and only does anything under `chrome: "content"` WITH `"Adw-1"` declared — otherwise one of two dev warnings fires instead, each at most once per app run: `chrome: "system"` names the wrong chrome, `chrome: "content"` with no `"Adw-1"` names that instead. **`applicationActions`/`windowActions`/`windowControllers` are deprecated** — reach for [`<ApplicationActions>`/`<WindowActions>`/`<WindowControllers>`](platform-layer.md#actions-and-shortcuts-declared-in-the-app-tree) instead; they still work unchanged',
1326
1347
  },
1327
1348
  {
1328
1349
  doc: "docs/api.md",
@@ -1342,7 +1363,7 @@ export const DOC_CHUNKS = [
1342
1363
  {
1343
1364
  doc: "docs/api.md",
1344
1365
  heading: "Appearance",
1345
- text: "Appearance — Supported: `getColorScheme`, `setColorScheme` (AdwStyleManager), `addChangeListener`. Differences: —",
1366
+ text: "Appearance — Supported: `getColorScheme`, `setColorScheme` (AdwStyleManager; on the plain-GTK profile — no `Adw-1` — sourced from the `org.freedesktop.appearance` `color-scheme` desktop-portal setting instead, with live updates via its `SettingChanged` signal, falling back to `Gtk.Settings:gtk-application-prefer-dark-theme` when no portal answers), `addChangeListener`. Differences: On the plain-GTK profile, `setColorScheme` is local-only exactly like the Adw path neither ever writes a SYSTEM-wide preference (`AdwStyleManager` and the fallback `Gtk.Settings` toggle are both per-process only; the portal itself has no write API, by design). With no portal reachable and no explicit `setColorScheme` call yet, the reported scheme is whatever `Gtk.Settings` already defaults to (light) rather than any observed system value",
1346
1367
  },
1347
1368
  {
1348
1369
  doc: "docs/api.md",
@@ -1357,7 +1378,7 @@ export const DOC_CHUNKS = [
1357
1378
  {
1358
1379
  doc: "docs/api.md",
1359
1380
  heading: "Alert",
1360
- text: "Alert — Supported: `alert(title, message, buttons, options)` → Adw.AlertDialog. Differences: `cancel`/`destructive`/`isPreferred` styles",
1381
+ text: "Alert — Supported: `alert(title, message, buttons, options)` → Adw.AlertDialog (`Gtk.AlertDialog`, GTK ≥ 4.10, on the plain-GTK profile — no `Adw-1`). Differences: `cancel`/`destructive`/`isPreferred` styles. On the plain-GTK profile, `destructive`/`isPreferred` appearance (Adw's `ResponseAppearance`) is lost — `Gtk.AlertDialog` has no equivalent, every button renders the same; default/cancel mapping is preserved. `cancelable: false` with no `cancel`-style button cannot be honored there either: `Gtk.AlertDialog` has no way to block Esc/window-close dismissal the way `Adw.AlertDialog.setCanClose(false)` does — add a `cancel`-style button for identical behavior on both profiles",
1361
1382
  },
1362
1383
  {
1363
1384
  doc: "docs/api.md",
@@ -1407,13 +1428,18 @@ export const DOC_CHUNKS = [
1407
1428
  {
1408
1429
  doc: "docs/api.md",
1409
1430
  heading: "Animated",
1410
- text: "Animated — Supported: `Value`, `timing`, `spring`, `sequence`, `parallel`, `delay`, `loop`, `interpolate` (numbers and deg/rad strings, clamp/extend/identity), `ValueXY` (`setValue`/`setOffset`/`flattenOffset`/`extractOffset`, `getLayout`, `getTranslateTransform`) — the value a `PanResponder` drag writes to. Differences: `useNativeDriver` is ignored (with a warning); the direct path is native-speed anyway; `Animated.event` is not implemented write the value directly (`pan.setValue({x: g.dx, y: g.dy})`), which is what it would do",
1431
+ text: "Animated — Supported: `Value`, `timing`, `spring`, `sequence`, `parallel`, `delay`, `loop`, `interpolate` (numbers and deg/rad strings, clamp/extend/identity), `ValueXY` (`setValue`/`setOffset`/`flattenOffset`/`extractOffset`, `getLayout`, `getTranslateTransform`) — the value a `PanResponder` drag writes to; `event(argMapping, config?)` — the arg mapping is positional over the callback's own arguments (an array entry maps that argument, `null` skips it), traversed recursively into plain objects with a leaf that is a `Value`/`ValueXY` written on every call; works attached to `ScrollView`'s `onScroll` (mapping the one event argument) and to `PanResponder` callbacks such as `onPanResponderMove` (mapping `gestureState`, the second argument — RN supports mapping either one); `config.listener` is called with the same arguments, after the mapping has run. Differences: `useNativeDriver` is ignored (with a warning); the direct path is native-speed anyway — and because there is no native side to hand the event to, `Animated.event` always returns the plain JS handler, `useNativeDriver` true or false, rather than sometimes returning the opaque attach/detach object upstream does. A mapped path the real event does not carry is tolerated at any depth (silently left unset) rather than thrown, which is a deliberate widening of RN's own traversal — its direct property access throws one level above a missing leaf",
1411
1432
  },
1412
1433
  {
1413
1434
  doc: "docs/api.md",
1414
1435
  heading: "Easing",
1415
1436
  text: "Easing — Supported: linear/ease/quad/cubic/in/out/inOut/bezier. Differences: —",
1416
1437
  },
1438
+ {
1439
+ doc: "docs/api.md",
1440
+ heading: "`requestAnimationFrame`/`cancelAnimationFrame`",
1441
+ text: '`requestAnimationFrame`/`cancelAnimationFrame` — Supported: **A global, not a module export** — same as in RN itself, which installs both from its own bootstrap (`InitializeCore`) rather than exporting them from `"react-native"`. This platform dropped that bootstrap entirely (there is no mobile environment to polyfill, "the runtime IS Node"), which held for everything except this one browser-ism library code reaches for directly — `react-native-sortables` crashes the whole process at mount without it (docs/research/upstream-libraries.md, "A third experiment"). Installed as a side effect of the package entry (`src/index.ts`), the one module both toolchains load before any app code runs; built on `components/frame-scheduler.ts`\'s `glibScheduler` — the same clock `Animated`/`Reanimated` already share, not a second timer. An id is returned; the callback receives a monotonic, high-resolution timestamp; a callback requested while a batch is running lands on the NEXT frame (never the one currently flushing); cancelling is silent, including for an unknown or already-delivered handle; one callback throwing is reported (`console.error`) and does not stop its siblings in the same batch. Differences: parity in behavior; not parity in mechanism — there is no native frame on a Linux desktop the way there is on iOS/Android, so this rides the same ~60 fps GLib timer `Animated` does, the way the DOM\'s `requestAnimationFrame` stands in for it on react-native-web',
1442
+ },
1417
1443
  {
1418
1444
  doc: "docs/api.md",
1419
1445
  heading: "version",