From 78cef3a79738aa53b64758c54a5dc62467257fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 21 Aug 2026 01:27:59 +0300 Subject: [PATCH 1/2] fix(keyboard-actions): host the toolbar in the root overlay The toolbar is positioned in SCREEN terms: `bottom: viewInsets.bottom`, so that it sits on top of the keyboard. It was inserted into the NEAREST overlay, which is measured in its own box, and a nested overlay is not the screen. Measured on an iPhone in an app whose page host owns an Overlay inside a scroll view: two overlays, one 402x874 (the screen) and one 402x2146 (the scrolled content), both reporting the same 335pt inset. The entry went into the second, so `bottom: 335` placed the toolbar 335pt from the bottom of the CONTENT, roughly 1300pt below the viewport. It was present in the semantics tree the whole time and nowhere on the screen, which is the worst shape a bug like this can take: a tree assertion passes and the user still has no button. `rootOverlay: true` is the fix, and the new test is the shape that produced it: an Overlay nested in a 2400pt scroll view, asserting the toolbar's rect stays within the screen. It reports 2400 against a 600pt screen without the fix. --- lib/src/widgets/w_keyboard_actions.dart | 9 +++- test/widgets/w_keyboard_actions_test.dart | 59 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lib/src/widgets/w_keyboard_actions.dart b/lib/src/widgets/w_keyboard_actions.dart index 6c999e79..9f38c4cc 100644 --- a/lib/src/widgets/w_keyboard_actions.dart +++ b/lib/src/widgets/w_keyboard_actions.dart @@ -210,7 +210,14 @@ class _WKeyboardActionsState extends State { void _insertOrUpdateOverlay() { if (_overlayEntry == null) { _overlayEntry = OverlayEntry(builder: _buildToolbar); - Overlay.of(context).insert(_overlayEntry!); + // The ROOT overlay, never the nearest one. The toolbar is positioned in + // SCREEN terms (`bottom: viewInsets.bottom`, so it sits on the keyboard), + // and a nested overlay is measured in its own box: an app whose page host + // owns an overlay inside a scroll view is 2146pt tall, so `bottom: 335` + // there put the toolbar 335pt from the bottom of the CONTENT, roughly + // 1300pt below the viewport. It was inserted, it was in the semantics + // tree, and it was nowhere on the screen. + Overlay.of(context, rootOverlay: true).insert(_overlayEntry!); // 1. Start a per-frame platform guard while the toolbar is active. // The guard fires in the transient-callbacks phase (before build), so // `_overlayEntry.remove()` triggers the Overlay's internal rebuild in diff --git a/test/widgets/w_keyboard_actions_test.dart b/test/widgets/w_keyboard_actions_test.dart index 4aa0d5dd..8845ec46 100644 --- a/test/widgets/w_keyboard_actions_test.dart +++ b/test/widgets/w_keyboard_actions_test.dart @@ -401,4 +401,63 @@ void main() { } }); }); + + group('WKeyboardActions overlay host', () { + testWidgets('the toolbar lands on the screen even under a nested overlay', + (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + // An app whose page host owns an Overlay INSIDE a scroll view is the + // shape this failed on. The nested overlay is as tall as the scrolled + // content, and the toolbar positions itself in screen terms + // (`bottom: viewInsets.bottom`), so hosting it there put it hundreds of + // points below the viewport: present in the tree, absent from the + // screen. + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: SingleChildScrollView( + child: SizedBox( + height: 2400, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (_) => WKeyboardActions( + focusNodes: [focusNode], + child: TextField(focusNode: focusNode), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(const Duration(milliseconds: 16)); + + final Finder toolbar = find.byType(TextButton); + expect(toolbar, findsOneWidget); + + final double screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getRect(toolbar).bottom, + lessThanOrEqualTo(screenHeight), + reason: 'a toolbar below the viewport is a toolbar the user cannot ' + 'reach, however present it is in the tree', + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); } From 77c596ce29fc0547e4e93bd165d67d23f2d6e632 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 13:13:36 +0300 Subject: [PATCH 2/2] docs(keyboard-actions): record the root-overlay host across doc, skill and changelog The fix changed WHICH overlay hosts the toolbar, and nothing outside the implementation said which one it was. The class doc claimed "the app's Overlay" without qualification, `doc/widgets/w-keyboard-actions.md` was silent, and the skill's OverlayEntry section covered the theme-context caveat while never mentioning that host choice is a second, separate decision. Skill version 2.12.0. --- CHANGELOG.md | 4 ++++ doc/widgets/w-keyboard-actions.md | 2 ++ lib/src/widgets/w_keyboard_actions.dart | 4 +++- skills/wind-ui/SKILL.md | 4 ++-- skills/wind-ui/references/theme.md | 2 ++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edae21af..f22884ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`skills/wind-ui/references/design-culture.md`: the taste layer the skill never had.** Every other reference answers "does this token exist and what does it do"; nothing answered "which token should this be", so an agent handed a screen with no design spec picked plausible values and produced work that rendered correctly and looked wrong. The new file carries the three-level hierarchy with the type scale that implements it, the semantic / status / dark-surface color pair tables (routed through the seeded `primary` token rather than a literal `blue-600`), the spacing scale with the touch-target floors, HSL palette construction, the depth scale plus the border-free alternatives, mobile form / loading / empty / feedback / list patterns, the iOS navigation and gesture contracts, and a 14-row anti-pattern wall. It came from the distribution repo (`fluttersdk/ai`), which is a mirror: content that only lived there was one `rsync --delete` away from disappearing, and no wind consumer ever received it. Wired into SKILL.md section 11 and the section 14 reference table. Skill version 2.11.0. (`skills/wind-ui/references/design-culture.md`, `skills/wind-ui/SKILL.md`) +### Fixed + +- **`WKeyboardActions` hosted its toolbar in the nearest `Overlay`, so in a nested one the toolbar rendered off-screen.** The toolbar is positioned in screen terms (`bottom: viewInsets.bottom`, so it sits on top of the keyboard), and a nested overlay is measured in its own box rather than the screen's. Measured on an iPhone in an app whose page host owns an `Overlay` inside a scroll view: two overlays, one 402x874 (the screen) and one 402x2146 (the scrolled content), both reporting the same 335pt bottom inset. The entry went into the second, so `bottom: 335` put the toolbar 335pt from the bottom of the CONTENT, roughly 1300pt below the viewport. It was in the semantics tree the whole time and nowhere on the screen, which is the worst shape this bug can take: a tree assertion passes and the user still has nothing to press. It now inserts into `Overlay.of(context, rootOverlay: true)`, and consumers need no `Overlay` of their own since `MaterialApp` and `CupertinoApp` both provide the root one. (`lib/src/widgets/w_keyboard_actions.dart`, `doc/widgets/w-keyboard-actions.md`, `skills/wind-ui/references/theme.md`) + ### Quality - **48 branches had accumulated, 45 of them PRs that landed months ago.** `delete_branch_on_merge` was off, so every task branch outlived its merge and the list grew by one per PR since December 2025. It is on now, which handles everything from here without a workflow, a token or a cron: the setting fires on the merge event alone, touches only that PR's head branch, and cannot reach `master` or `v0` because both are protected. The 46 leftovers (45 merged, plus a branch from the abandoned release-please setup whose PR #80 was closed unmerged) are deleted. The tempting alternative, a scheduled stale-branch job, was rejected: with the setting on it would only ever catch branches that never merged, this repo has produced exactly one of those in its history, and its "untouched for N days" test cannot tell an abandoned branch from one you set down for a fortnight. A merge is a statement of intent; a date is not. Recorded in `CLAUDE.md` under Branching, because a policy nobody wrote down is not a policy. diff --git a/doc/widgets/w-keyboard-actions.md b/doc/widgets/w-keyboard-actions.md index f1400b1d..ad45e464 100644 --- a/doc/widgets/w-keyboard-actions.md +++ b/doc/widgets/w-keyboard-actions.md @@ -69,6 +69,8 @@ class _MyFormState extends State { } ``` +> **The toolbar is hosted in the ROOT `Overlay`, not the nearest one.** It positions itself in screen terms (`bottom: viewInsets.bottom`, so it sits on top of the keyboard), and a nested `Overlay` is measured in its own box rather than the screen's. An app whose page host owns an `Overlay` inside a scroll view gives that overlay the height of the scrolled content, so a nearest-overlay toolbar would render hundreds of points below the viewport: present in the widget tree, absent from the screen. You need no `Overlay` of your own for this to work; `MaterialApp` and `CupertinoApp` both provide the root one. + ## Constructor diff --git a/lib/src/widgets/w_keyboard_actions.dart b/lib/src/widgets/w_keyboard_actions.dart index 9f38c4cc..faf24ab9 100644 --- a/lib/src/widgets/w_keyboard_actions.dart +++ b/lib/src/widgets/w_keyboard_actions.dart @@ -8,7 +8,9 @@ import 'w_keyboard_platform.dart'; /// A Wind-styled wrapper that adds keyboard actions (Done button, navigation) /// to input fields, especially for iOS numeric keyboards. /// -/// The widget renders an above-keyboard toolbar in the app's `Overlay` while +/// The widget renders an above-keyboard toolbar in the app's ROOT `Overlay` +/// (the toolbar is positioned in screen terms, so a nested overlay measured in +/// its own box would place it off-screen) while /// any of its `focusNodes` holds focus, providing: /// - Done button for dismissing numeric keyboards on iOS /// - Up/Down navigation between multiple input fields diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 40e56ac6..a57ed97b 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.3: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.11.0 +version: 2.12.0 --- - + # Wind UI 1.3 diff --git a/skills/wind-ui/references/theme.md b/skills/wind-ui/references/theme.md index 7ce3bf1e..0bd03ba1 100644 --- a/skills/wind-ui/references/theme.md +++ b/skills/wind-ui/references/theme.md @@ -491,3 +491,5 @@ class _MyMenuState extends State { `WPopover` and `WSelect` already handle this internally. The caveat surfaces when you build a custom overlay (showing a tooltip via `OverlayEntry`, a custom popup, a tour overlay). The safe pattern in practice: don't call `WindParser.parse` directly from inside `OverlayEntry.builder`. Instead, build W-widgets inside the overlay; they parse with the right context themselves. + +**A second, separate decision: WHICH overlay hosts the entry.** `Overlay.of(context)` returns the nearest one, which is right for an entry positioned against surrounding content (a menu anchored to its trigger). An entry positioned in SCREEN terms needs `Overlay.of(context, rootOverlay: true)`, because a nested overlay is measured in its own box rather than the screen's. `WKeyboardActions` is the worked example: its toolbar sits at `bottom: viewInsets.bottom` so it lands on the keyboard, and in an app whose page host owned an `Overlay` inside a scroll view the nearest overlay was as tall as the scrolled content, so the toolbar rendered roughly 1300pt below the viewport. It was in the semantics tree the whole time, which is the worst shape this bug takes: a tree assertion passes and the user still has nothing to press.