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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
19 changes: 19 additions & 0 deletions doc/widgets/w-anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions doc/widgets/w-div.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<a name="state-variants"></a>
Expand Down
35 changes: 33 additions & 2 deletions lib/src/widgets/w_anchor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,39 @@ class _WAnchorState extends State<WAnchor> {
);
}

// 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,
Expand Down
11 changes: 10 additions & 1 deletion lib/src/widgets/w_checkbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions skills/wind-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

<!-- fluttersdk_wind 1.4.x | Skill v2.12.0 (2026-08-21) -->
<!-- fluttersdk_wind 1.4.x | Skill v2.12.1 (2026-08-21) -->

# Wind UI 1.3

Expand Down Expand Up @@ -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. |
Expand Down
10 changes: 8 additions & 2 deletions skills/wind-ui/references/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.
Expand Down
Loading
Loading