diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a79968..12e358d 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. - **A multi-line `className` hid its flex tokens from the row and column composers, and this project's own style guide is what put them there.** `.claude/rules/widgets.md` and `SKILL.md` both instruct a className covering 3+ concerns to be a triple-quoted string with one concern per line, so `flex-1` at the end of a line arrives at the composition helpers as `flex-1\n`. Three of them split on a single space and therefore never matched it. Two consequences, both reproduced: a `flex-1` child of an `overflow-hidden` row got wrapped a second time and threw `Incorrect use of ParentDataWidget` (`_selfWrapsInFlex`), and a `w-24 shrink-0` child in a crowded row shrank to its 50pt flex share instead of holding 96pt (`_hasShrinkZero`). The third site, `_hasExplicitCrossWidth`, is corrected for consistency with its own documented "in ANY state or breakpoint variant" intent, but no observable failure could be produced for it: a stretched column child still renders at the width it asked for, so the miss costs a redundant wrapper rather than a wrong layout. All five token scans in `WDiv` now share one hoisted `_whitespaceRegex`, which also stops the two that already split on whitespace from allocating a fresh `RegExp` on every pass through the composition path. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/multiline_classname_test.dart`) +- **Every hoverable `WDiv` announced itself as a button that cannot be pressed.** `WDiv` auto-wraps into a `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state this widget tracks, and `WAnchor` published `Semantics(button: true)` unconditionally: with no gesture behind it. So a decorative card styled `hover:bg-slate-100` reached assistive technology as a control. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled `Latency` whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. A gestureless, UNLABELLED `WAnchor` now publishes no semantics node of its own and lets its descendants speak, which is what a state propagator should do (an explicit `semanticLabel` still publishes the named node either way, which is how a disabled control reports that it exists and is unavailable; `WDiv`'s auto-wrap never passes one): the same card keeps its `Latency` label and loses the role. An anchor with `onTap`, `onLongPress` or `onDoubleTap` is unchanged, so `WButton` and every real control keep their single named button node with its tap action, and an explicit `semanticLabel` still wins for the icon-only case that has no child text to merge. One claim to retire with this: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice, though it published two button nodes in the widget tree. The inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. A reading that counts raw tree nodes rather than platform nodes will report a duplicate announcement that no screen reader ever made. `MergeSemantics` goes with the role rather than staying, and that was measured too: keeping it made a gestureless wrapper ABSORB a descendant control's role and actions, so a locked region tile in a consumer app swallowed the display-only `WCheckbox` inside it and published itself as "US West, button" carrying the checkbox's tap. That is the same bogus claim in a new place, so a styling-only wrapper now publishes nothing at all. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/widgets.md`) + +- **`WCheckbox` offered a tap action for `onChanged: null`.** The anchor's callback was gated on `disabled` alone, so a caller rendering a display-only checkbox (a read-only summary row, or a tile whose own tap drives the toggle) still installed `() => onChanged?.call(!value)`: a control that announces itself as pressable and runs a no-op when pressed. Found in a consumer's region picker, where `IgnorePointer(child: WCheckbox(value: selected, onChanged: null))` published a 16x16 nameless node with a tap action inside a tile that was already doing the work; `IgnorePointer` blocks pointers and leaves the semantics node in place. The gate is now `disabled || onChanged == null`, and the `checked` state is still reported either way, so a read-only checkbox still tells assistive technology whether it is ticked. (`lib/src/widgets/w_checkbox.dart`) + ### Quality - **The registry dispatch could never fire, so 1.4.0 shipped a skill the registry never received.** `dispatch-to-registry.yml` declared `release: [published]`, but the release is created inside `publish.yml`'s `github-release` job by `gh release create` running under `GH_TOKEN: ${{ github.token }}`, and GitHub does not start workflow runs from events raised by `GITHUB_TOKEN`. The trigger was added on 2026-08-03 and 1.4.0 was the first release after it, so it had exactly one chance and missed: the run history showed nothing since 2026-08-03, both entries there being `workflow_dispatch` and the retired `push` trigger. It reached `fluttersdk/ai` only because it was dispatched by hand. The cost of this failure mode is that it is silent, since the publish workflow goes green either way and the only symptom is end users installing a skill a version behind. `publish.yml` now calls the workflow directly with `needs: github-release`, which removes the cross-workflow event entirely and also guarantees the dispatch happens after pub.dev has accepted the release rather than in parallel with it. The dead `release` trigger is gone and `workflow_call` replaces it; `workflow_dispatch` stays as the manual escape hatch. Secrets are passed by name rather than `secrets: inherit`, since the called workflow needs exactly two and this repo pins every action by SHA and runs zizmor over the result. (`.github/workflows/dispatch-to-registry.yml`, `.github/workflows/publish.yml`) diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index e45bf56..7b08352 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -175,6 +175,25 @@ WindTheme( ) ``` +## Accessibility + +A `WAnchor` that carries a gesture (`onTap`, `onLongPress` or `onDoubleTap`) publishes one `Semantics(button: true)` node, and `MergeSemantics` folds the child's text into it so the control gets its name from what it renders. Pass `semanticLabel` when the child has no readable text, such as an icon-only anchor; that label replaces the child subtree rather than concatenating with it. + +A `WAnchor` with no gesture **and no `semanticLabel`** publishes nothing of its own. It is the state propagator in that mode, which is how `WDiv` uses it to serve `hover:`, `focus:` and `active:` classes (the auto-wrap never passes a label), and a decorative surface must not announce itself as a button with no action behind it. + +`semanticLabel` is the exception, and deliberately so: setting it always publishes the named button node, gestures or not. It is how a DISABLED control still tells assistive technology that a control is there and currently unavailable, which is information the user needs. That makes the label a statement of intent rather than a formatting choice: set it on a control, never on decoration. + +```dart +// One button node named "Save". +WAnchor(onTap: save, child: const WText('Save')) + +// One button node named "Save", announced disabled, with no tap action. +const WAnchor(isDisabled: true, semanticLabel: 'Save', child: WText('Save')) + +// No node at all: hover styling only. +const WAnchor(child: WDiv(className: 'hover:bg-slate-100', child: WText('Card'))) +``` + ## Related Documentation - [WButton](./w-button.md) - High-level button widget built on WAnchor. diff --git a/doc/widgets/w-div.md b/doc/widgets/w-div.md index 77a9776..c217632 100644 --- a/doc/widgets/w-div.md +++ b/doc/widgets/w-div.md @@ -114,6 +114,8 @@ Precedence: inline `backgroundColor` wins over any `bg-*` / `dark:bg-*` resolved `WDiv` automatically becomes interactive when state-based prefixes like `hover:`, `focus:`, or `active:` are used in the `className`. Under the hood, it wraps the content in a `WAnchor` to detect gestures and focus. +That wrap is styling only: the auto-wrapped `WAnchor` carries no gesture, so it publishes no `button` node to assistive technology. A `WDiv` with `hover:bg-slate-100` styles on hover and still announces as whatever its content is, rather than as a control that does nothing when activated. + For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](w-anchor.md) or [WButton](w-button.md). diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 1db7f15..8b6a550 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -259,8 +259,39 @@ class _WAnchorState extends State { ); } - // 2. No explicit label: keep the MergeSemantics path so the descendant - // Text/WText nodes collapse into this node and supply the name. + // 2. No gestures and no explicit label: publish nothing of our own and let + // the child's descendants speak for themselves. + // + // `WDiv` auto-wraps itself in a gestureless `WAnchor` whenever its + // className carries `hover:`, `focus:` or `active:`, purely to get the + // hover and focus state this widget tracks (see `w_div.dart`'s + // `isInteractive` branch). Announcing that as a button made a claim the + // widget cannot keep: `WDiv(className: 'hover:bg-slate-100', child: + // WText('Latency'))` published a button node labelled "Latency" whose + // action set was `focus` alone, with no `tap`, so a screen reader offered + // a control that does nothing when activated. The real tap surface still + // reaches this method with its gestures and keeps its single button node. + // + // Note for anyone re-measuring: the nesting `WAnchor(onTap:) > + // WDiv(hover:...)` did NOT announce twice, though it did put two button + // nodes in the widget tree. The inner one carried `isMergedIntoParent`, + // so it was folded into the real tap surface and never sent to the + // platform. Count platform nodes, not tree nodes. + // + // `MergeSemantics` goes with the role, and the measurement is why. Keeping + // it made a gestureless wrapper ABSORB a descendant control's role and + // actions: a locked region tile in a consumer app, whose own anchor has no + // gesture, swallowed the display-only `WCheckbox` inside it and published + // itself as "US West, button" with the checkbox's tap. That is the same + // bogus claim in a new place. A styling-only wrapper publishes nothing and + // lets each descendant speak for itself. + if (!hasGestures) { + return result; + } + + // 3. Gestures, no explicit label: keep the MergeSemantics path so the + // descendant Text/WText nodes collapse into this node and supply the + // name. return MergeSemantics( child: Semantics( button: true, diff --git a/lib/src/widgets/w_checkbox.dart b/lib/src/widgets/w_checkbox.dart index 2f7a78d..6431230 100644 --- a/lib/src/widgets/w_checkbox.dart +++ b/lib/src/widgets/w_checkbox.dart @@ -125,7 +125,16 @@ class WCheckbox extends StatelessWidget { // Flutter web accessibility tree. child: MergeSemantics( child: WAnchor( - onTap: disabled ? null : () => onChanged?.call(!value), + // Gated on `onChanged` as well as `disabled`. A caller passing + // `onChanged: null` is rendering a display-only checkbox (a read-only + // summary row, or a tile whose own tap drives the toggle), and + // installing `() => onChanged?.call(!value)` for it published a + // pressable control whose activation runs a no-op. Measured in a + // consumer's region picker: a 16x16 nameless node carrying a tap + // action, inside a tile that was already doing the work. + onTap: disabled || onChanged == null + ? null + : () => onChanged!.call(!value), isDisabled: disabled, states: activeStates, child: WDiv( diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 2ef0db4..a2c0e03 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.4: 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.12.0 +version: 2.12.1 --- - + # Wind UI 1.3 @@ -78,7 +78,7 @@ The headline 25 (table below) are the ones an agent reaches for daily. Two more | `WIcon` | Display | `icon: IconData` | Material icons; use `Icons.*_outlined` variants by convention. Reads `text-*` for size AND color (overloaded). Inherits from `DefaultTextStyle` when className is absent. Inline color prop: `foregroundColor`. | | `WImage` | Display | none (requires `src` or `image`) | Network (URL) or asset (prefix `asset://path`) or `ImageProvider`. `object-cover` default. | | `WSvg` / `WSvg.string` | Display | `src` / `svg` | Vector graphics. `fill-*` / `stroke-*` for color. `preserve-colors` token disables tint for multi-color SVGs (QR codes, logos). | -| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)`. | +| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)` only when it carries a gesture, or when `semanticLabel` is set. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | | `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index c97eb59..1e2c164 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -228,7 +228,7 @@ Apply via `ColorFilter.mode(color, BlendMode.srcIn)`. The `preserve-colors` toke ### `WAnchor` -Low-level state propagator. Tracks hover and focus; provides `WindAnchorStateProvider` to descendants. Emits `Semantics(button: true)` for accessibility / E2E. +Low-level state propagator. Tracks hover and focus; provides `WindAnchorStateProvider` to descendants. Emits `Semantics(button: true)` for accessibility / E2E **when it carries a gesture, or when `semanticLabel` is set**. A gestureless, unlabelled anchor (what `WDiv` auto-wraps into for `hover:` / `focus:` / `active:`) publishes no node of its own, so a hoverable card announces as its content instead of as a button with no tap action behind it. An explicit `semanticLabel` always publishes the node, which is how a DISABLED control still reports that it exists and is unavailable; set it on a control, never on decoration. ```dart const WAnchor({ @@ -245,7 +245,13 @@ const WAnchor({ ``` Structure (outermost → innermost): -`MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` → `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. +Three accessibility paths, checked in this order: + +1. `semanticLabel != null`: `Semantics(button: true, enabled: !isDisabled, label: semanticLabel, onTap/onLongPress lifted, excludeSemantics: true)` → the rest below. The label replaces the child subtree rather than concatenating with it, and the node is published whether or not a gesture exists. +2. No label and no gesture: no node of its own. +3. No label, with a gesture: `MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` → the rest below. + +The rest, in all three: `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. State tracking: - Hover: `MouseRegion.onEnter` / `onExit` set `_isHovering`; calls `setState` only on change. diff --git a/test/widgets/w_anchor_test.dart b/test/widgets/w_anchor_test.dart index bc1beac..7558e4d 100644 --- a/test/widgets/w_anchor_test.dart +++ b/test/widgets/w_anchor_test.dart @@ -666,6 +666,132 @@ void main() { final SemanticsNode node = tester.getSemantics(find.byType(WAnchor)); expect(node.flagsCollection.isButton, isTrue); }); + + testWidgets('a hoverable WDiv keeps its name and drops the button role', + (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // `WDiv` auto-wraps itself in a gestureless `WAnchor` whenever its + // className carries `hover:`, purely to track the hover state. That + // used to publish a button node with `focus` as its only action, so a + // decorative card was offered as a control that cannot be activated. + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + className: 'px-4 py-3 rounded-lg hover:bg-slate-100', + child: WText('Latency'), + ), + ), + ); + + expect(_buttonNodes(tester), isEmpty); + // The content still announces; only the false role is gone. + expect(find.text('Latency'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('a gestureless anchor is not announced as a button', + (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + wrapWithTheme( + const WAnchor( + child: WText('Decoration'), + ), + ), + ); + + expect(_buttonNodes(tester), isEmpty); + handle.dispose(); + }); + + testWidgets('an explicit semanticLabel publishes the node either way', + (tester) async { + // The documented exception to the rule above, and the reason the guard + // is not extended to cover it: the label branch is checked FIRST, so a + // labelled anchor is announced as a button with or without a gesture. + // That is what lets a DISABLED control report that it exists and is + // unavailable, which the disabled test below pins from the other side. + // Setting the label is therefore a statement that this is a control. + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + wrapWithTheme( + const WAnchor( + semanticLabel: 'Close', + child: Icon(Icons.close), + ), + ), + ); + + final List buttons = _buttonNodes(tester); + expect(buttons, hasLength(1)); + final SemanticsData data = buttons.single.getSemanticsData(); + expect(data.label, 'Close'); + // Named, but honest about having nothing to activate. + expect(data.hasAction(SemanticsAction.tap), isFalse); + handle.dispose(); + }); + + testWidgets( + 'a tappable anchor around a hover: WDiv still announces once, named', + (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // A regression guard, not a reproducer: this shape published one + // platform node before the change too, because the inner gestureless + // anchor's node was `isMergedIntoParent` and folded into the real tap + // surface. It is here so the early return cannot take the name or the + // tap action with it. + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'px-4 py-3 rounded-lg hover:bg-slate-100', + child: WText('Monitors'), + ), + ), + ), + ); + + final List buttons = _buttonNodes(tester); + expect(buttons, hasLength(1)); + // The merged data, not `node.label`: a merging node's own label stays + // empty and the absorbed descendants supply the name. + final SemanticsData data = buttons.single.getSemanticsData(); + expect(data.label, contains('Monitors')); + expect(data.hasAction(SemanticsAction.tap), isTrue); + handle.dispose(); + }); }); }); } + +/// Every button node the platform would actually receive. +/// +/// Two filters matter. `tester.getSemantics` resolves the node for one widget, +/// so the walk covers the whole tree; and a node with `isMergedIntoParent` is +/// folded into its parent's data and never sent to assistive technology, so +/// counting one reports a duplicate that does not exist. Getting that second +/// filter wrong is what made an earlier reading of this bug claim a double +/// announcement. +List _buttonNodes(WidgetTester tester) { + final List found = []; + + void walk(SemanticsNode node) { + if (!node.isMergedIntoParent && + node.getSemanticsData().flagsCollection.isButton) { + found.add(node); + } + node.visitChildren((SemanticsNode child) { + walk(child); + return true; + }); + } + + walk(tester.getSemantics(find.byType(MaterialApp))); + + return found; +} diff --git a/test/widgets/w_checkbox_test.dart b/test/widgets/w_checkbox_test.dart index fac2618..0599350 100644 --- a/test/widgets/w_checkbox_test.dart +++ b/test/widgets/w_checkbox_test.dart @@ -244,6 +244,59 @@ void main() { ); expect(node.flagsCollection.isChecked, CheckedState.isFalse); }); + + testWidgets('a null onChanged publishes no tap action', (tester) async { + // `onChanged: null` is how a caller renders a display-only checkbox: a + // read-only summary row, or a tile whose own tap drives the toggle. The + // anchor's callback was gated on `disabled` alone, so such a checkbox + // still installed `() => onChanged?.call(!value)`, which announces a + // pressable control whose activation runs a no-op. Measured in a + // consumer's region picker: a 16x16 nameless node with a tap action + // inside a tile that was doing the work itself. + await tester.pumpWidget( + wrapWithTheme(const WCheckbox(value: false)), + ); + await tester.pump(); + + // The tap lives on the inner anchor's node, not on the outer + // `Semantics(container: true)` one that `getSemantics` resolves, so the + // question is whether ANY node in the checkbox's subtree offers it. + expect(_subtreeOffersTap(tester), isFalse); + // The state is still reported; only the false affordance is gone. + final SemanticsNode node = tester.getSemantics( + find.byType(WCheckbox), + ); + expect(node.flagsCollection.isChecked, CheckedState.isFalse); + }); + + testWidgets('a non-null onChanged still publishes a tap action', + (tester) async { + await tester.pumpWidget( + wrapWithTheme(WCheckbox(value: false, onChanged: (_) {})), + ); + await tester.pump(); + + expect(_subtreeOffersTap(tester), isTrue); + }); }); }); } + +/// Whether any node in the rendered checkbox's semantics subtree offers a tap. +bool _subtreeOffersTap(WidgetTester tester) { + bool found = false; + + void walk(SemanticsNode node) { + if (node.getSemanticsData().hasAction(SemanticsAction.tap)) { + found = true; + } + node.visitChildren((SemanticsNode child) { + walk(child); + return true; + }); + } + + walk(tester.getSemantics(find.byType(WCheckbox))); + + return found; +}