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: 2 additions & 2 deletions .claude/rules/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ className: '''
''',
```

## Container elision
## Box elision

A `Container` is emitted only when `styles.decoration != null` OR a non-empty `boxShadow` / `ringShadow` exists. Padding-only, text-only, and `shadow-none` paths must stay Container-free. `WindStyle.copyWith` must NOT fabricate an empty `BoxDecoration`, or the `decoration != null` gate breaks and every styled widget grows a needless Container.
WDiv's non-animated box is `_buildBox`, `Container.build`'s own primitives in its order (`LimitedBox` + expand for a null child, `Align`, `Padding` inset by the border widths, `DecoratedBox`, `ConstrainedBox`), and each is emitted only when its input exists: a `DecoratedBox` only when `styles.decoration != null` OR a non-empty `boxShadow` / `ringShadow` exists, a `Padding` only when non-zero. Padding-only, text-only, and `shadow-none` paths must stay free of a `DecoratedBox`. `WindStyle.copyWith` must NOT fabricate an empty `BoxDecoration`, or the `decoration != null` gate breaks and every styled widget grows a needless one. `test/widgets/w_div/sizing_test.dart` pins the geometry against `Container`'s; `AnimatedContainer` stays on the transition path.

## What never goes in `lib/src/widgets/`

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

## [Unreleased]

### Changed

- **A `WDiv` whose only state classes are `hover:` or `active:` is no longer a Tab or D-pad stop, and its wrapper builds no `Focus`.** Its auto-wrapped `WAnchor` installed a focus node for every interactive className, so a hover-only row was a keyboard stop that lit nothing and paid for a node attachment plus a `Semantics(focusable:)` on every build; a consumer's dashboard carried about 193 of them among 399 anchors per frame. `WDiv` now passes `trackFocus: className.contains('focus:')`. A `focus:` div keeps its node and stop exactly as before, focus-within still lights a ring through a hover-only div in between (the ring's own node covers descendants), and primary focus chained from an anchor above still passes through, because it travels in `WindAnchorStateProvider`, which stays unconditional with `MouseRegion`. Gesture anchors are untouched. What the hover-only div stops doing is publishing focus-within to its own children: a sibling label with a `focus:` class, as in `WDiv('hover:bg-gray-50', children: [WText('Email', className: 'focus:text-blue-600'), WInput(...)])`, used to light through the div's node while the field had focus and now stays unlit; adding any `focus:` class to the row brings the node back. A className string cannot see what its descendants style, so gating on them was not an option. (`lib/src/widgets/w_anchor.dart`, `lib/src/widgets/w_div.dart`, `test/widgets/w_anchor/dpad_activation_test.dart`)
- **`WDiv` builds its decorated box from primitives instead of a `Container`.** `Container` is a `StatelessWidget` that only stacks `Align`, `Padding`, `DecoratedBox` and `ConstrainedBox`, so it cost one element per decorated div on the hottest path (1032 per painted frame on the same dashboard). The primitives follow `Container.build`'s order and its three behaviours: a childless box expands through `LimitedBox`, padding is inset by the border widths, and `w-full` tightens the constraints so `max-w-*` still clamps it. A zero `Padding`, which `Container` emitted for every borderless decoration, is skipped. Geometry is pinned by `tester.getSize` cases written against the old `Container` first; `AnimatedContainer` and the inside-the-border clip are unchanged. `wrapperEmissions` now counts `DecoratedBox`, `ConstrainedBox`, `Padding`, `Align` and `LimitedBox` where it counted `Container`. Tests that read decoration off `find.byType(Container)` now read it off `DecoratedBox`. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/sizing_test.dart`, `test/utils/wind_perf_counters_test.dart`)
- **`WindContext.build` and the `h-full` fallback depend on `MediaQuery.sizeOf`, not the whole `MediaQueryData`.** Every parse subscribed every W-widget to all of `MediaQuery`, so each frame of a keyboard inset animation rebuilt every styled widget on screen. The size aspect still carries both axes, which `w-screen` / `h-screen` need, so `widthOf` would not do. A `viewInsets` change no longer rebuilds a plain `WDiv`; a size change still does. `wScreenIs` and `wScreenCurrent` read the same aspect, so a shell that swaps its tree on `wScreenIs(context, 'lg')` no longer rebuilds on every keyboard frame, and `WPopover` / `WSelect`'s flip checks do too. (`lib/src/parser/wind_context.dart`, `lib/src/widgets/w_div.dart`, `lib/src/utils/wind_helpers.dart`, `lib/src/widgets/w_popover.dart`, `lib/src/widgets/w_select.dart`, `test/parser/wind_context_test.dart`, `test/utils/wind_helpers_test.dart`)

### Fixed

- **`WindPerfCounters.inheritedReads` now counts the `windTheme` and `mediaQuerySize` reads `WindContext.build` makes on every `WindParser.parse`, not only the ones WInput and the `h-full` paths already recorded.** `WindContext.build` (`lib/src/parser/wind_context.dart:70-71`) calls `WindTheme.dataOf(context)` and `MediaQuery.of(context).size` on every W-widget build, but before this fix only `WInput`'s brightness fallback and `WDiv`'s `h-full` render-layer box called `recordInheritedRead`, so a plain `WDiv`/`WText` build under-reported both keys. (`lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`)

### Added

- **`WAnchor.trackFocus`.** Defaults to `true`; `false` drops the `Focus` node of a gestureless anchor. Ignored when the anchor has a gesture. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`)
- **`WindPerfCounters.recordInheritedRead` takes a `WindInheritedRead` enum value instead of a free string.** The four keys (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`) were previously any `String`, so a typo at a call site would silently sit outside the pinned four and never show up in `stats()`. The stats map keys are unchanged: the enum serializes through `.name`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/widgets/w_text.dart`, `lib/src/widgets/w_icon.dart`, `lib/src/widgets/w_svg.dart`, `lib/src/widgets/w_input.dart`, `lib/src/widgets/w_div.dart`, `lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`)

- **`WindPerfCounters` gains `widgetBuilds`, `wrapperEmissions` and `inheritedReads`.** The existing six counters (`cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`) answered "how much did the style cache do" and "how many `WDiv`/`WText` builds ran", but nothing counted every OTHER W-widget's builds, the Flutter (or Wind-authored render-layer) wrapper widgets Wind inserts around content (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), or the four inherited-widget reads on the hot path (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). `widgetBuilds` and `wrapperEmissions` are `Map<String, int>` keyed by type name; `inheritedReads` is fixed to the four keys above. Every increment is one inline call next to the emission or the read it counts, behind the existing `enabled` flag, so a disabled counter still costs one bool check and nothing is added, removed or reordered in the widget tree. `Wind.installPerfResolver()` publishes all nine keys through `WindDebugRegistry.currentPerf?.stats()`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/wind_facade.dart`, `lib/src/widgets/*.dart`, `test/utils/wind_perf_counters_test.dart`, `doc/core-concepts/debugging.md`, `skills/wind-ui/`)
- **`Wind.installDebugResolver()` / `Wind.installPerfResolver()` widen from `kDebugMode` to `!kReleaseMode`.** A profile build (`flutter run --profile`) now carries both resolvers, so a performance measurement session can run against a build that is actually representative of production timing; release still tree-shakes both. The debug resolver suspends `WindPerfCounters` while it resolves a widget, so a diagnostic snapshot taken during a measurement session adds nothing to `inheritedReads` or the cache counters. (`lib/src/wind_facade.dart`, `test/wind_facade_test.dart`)

---

## [1.7.0] - 2026-09-27
Expand Down
21 changes: 13 additions & 8 deletions doc/core-concepts/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,14 @@ If neither applies, you can skip this step. Wind itself does not depend on the r

### Wiring it up

Call `Wind.installDebugResolver()` once during app startup, inside a `kDebugMode` guard. The call is idempotent (safe to call multiple times) and is automatically a no-op in release builds, so the bridge is tree-shaken out of production binaries.
Call `Wind.installDebugResolver()` once during app startup, inside a `!kReleaseMode` guard (so a profile build still carries it). The call is idempotent (safe to call multiple times) and is automatically a no-op in release builds, so the bridge is tree-shaken out of production binaries.

```dart
import 'package:flutter/foundation.dart';
import 'package:fluttersdk_wind/fluttersdk_wind.dart';

void main() {
if (kDebugMode) {
if (!kReleaseMode) {
Wind.installDebugResolver();
}
runApp(const MyApp());
Expand All @@ -190,7 +190,7 @@ Fields whose value is null are omitted; the map only carries what the widget act

### Release-build safety

`Wind.installDebugResolver()` is a no-op in release builds. The `kDebugMode` guard in user code further ensures dart2js / dart2native tree-shakes the entire branch on every platform. There is no production cost from leaving the call in place.
`Wind.installDebugResolver()` and `Wind.installPerfResolver()` are no-ops in release builds (`!kReleaseMode`, not `kDebugMode`, so a profile build still carries them for a profiling session). The guard in user code further ensures dart2js / dart2native tree-shakes the entire branch in release. There is no production cost from leaving the call in place.

<a name="aggregate-performance-counters"></a>
## Aggregate Performance Counters
Expand Down Expand Up @@ -218,23 +218,28 @@ Counting is off by default and every increment sits behind one static bool check
```dart
WindPerfCounters.enabled = true;
// ... drive the interaction you want to measure ...
print(WindPerfCounters.cacheHits); // and cacheMisses, cacheBypasses
print(WindPerfCounters.wDivBuilds); // and wTextBuilds
print(WindPerfCounters.cacheHits); // and cacheMisses, cacheBypasses
print(WindPerfCounters.wDivBuilds); // and wTextBuilds
print(WindPerfCounters.widgetBuilds); // {'WButton': 3, 'WDiv': 12, ...}
print(WindPerfCounters.wrapperEmissions); // {'DecoratedBox': 5, 'MouseRegion': 2, ...}
print(WindPerfCounters.inheritedReads); // mediaQuerySize, mediaQueryBrightness, windTheme, defaultTextStyle
```

`widgetBuilds` counts every W-widget with a build method, by its type name (`'WButton'`, `'WDiv'`, ...); `wDivBuilds`/`wTextBuilds` stay as dedicated counters alongside it rather than being folded in, since they are the pinned six-key contract's own fields. `wrapperEmissions` counts the Flutter (or Wind-authored render-layer) wrapper widget Wind inserts at each composition-pipeline branch (`DecoratedBox`, `ConstrainedBox`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), one inline call beside the construction it never changes. `inheritedReads` is fixed to four keys: `mediaQuerySize` (a `MediaQuery.sizeOf(context)` read, which depends on the size aspect only), `mediaQueryBrightness` (`MediaQuery.maybePlatformBrightnessOf`), `windTheme` (`WindTheme.maybeDataOf`), `defaultTextStyle` (`DefaultTextStyle.of`).

`WindParser.clearCache()` resets the counters as well as the cache, so a hit rate is always reported against the cache it was measured on. That is also why a theme change mid-measurement zeroes them.

### Reading them from outside

`Wind.installPerfResolver()` publishes the totals through the diagnostics contracts package, the same bridge `installDebugResolver()` uses and a separate slot on the same registry. It is idempotent, `kDebugMode`-gated, and installing it costs nothing on its own since counting stays off until the flag is set:
`Wind.installPerfResolver()` publishes the totals through the diagnostics contracts package, the same bridge `installDebugResolver()` uses and a separate slot on the same registry. It is idempotent, `!kReleaseMode`-gated (so a profile build still carries it), and installing it costs nothing on its own since counting stays off until the flag is set:

```dart
if (kDebugMode) {
if (!kReleaseMode) {
Wind.installDebugResolver(); // per-Element state
Wind.installPerfResolver(); // aggregate counters
}
```

A tool then reads `WindDebugRegistry.currentPerf?.stats()` and gets exactly six `int` keys: `cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`. The `null` when no resolver is registered is meaningful and worth passing through rather than flattening to zeros: it is what lets a caller tell "wind never installed one" from "the counters really are zero".
A tool then reads `WindDebugRegistry.currentPerf?.stats()` and gets exactly nine keys: `cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds` (all `int`), plus `widgetBuilds`, `wrapperEmissions`, `inheritedReads` (all `Map<String, int>`). The `null` when no resolver is registered is meaningful and worth passing through rather than flattening to zeros: it is what lets a caller tell "wind never installed one" from "the counters really are zero".

`fluttersdk_dusk` reads this during a `dusk:perf_begin` / `dusk:perf_end` session.
8 changes: 8 additions & 0 deletions doc/widgets/w-anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const WAnchor({
Set<String>? states,
MouseCursor? mouseCursor,
String? semanticLabel,
bool trackFocus = true,
})
```

Expand All @@ -71,6 +72,7 @@ const WAnchor({
| `states` | `Set<String>?` | `null` | Custom states for dynamic styling (e.g., `{'active'}`). |
| `mouseCursor` | `MouseCursor?` | `null` | Custom cursor. Defaults to click when interactive. |
| `semanticLabel` | `String?` | `null` | Accessible name for icon-only controls. When set, excludes the child subtree from semantics so the label overrides any child text; prefer it for icon-only controls rather than controls that already expose readable text. |
| `trackFocus` | `bool` | `true` | Whether a gestureless anchor installs a focus node. `WDiv` passes `false` when its className has no `focus:` class. Ignored when the anchor has a gesture, which always keeps its node. |

## Layout Modes

Expand Down Expand Up @@ -140,8 +142,14 @@ WAnchor(
)
```

A `WDiv` whose only state classes are `hover:` or `active:` carries no focus node at all (its wrapper gets `trackFocus: false`): it cannot draw focus, so a stop on it was a key press that lit nothing. Hover, press and the primary focus chained down from an anchor above it still pass through.

One thing it no longer publishes is focus-within to its own descendants. A `focus:` class on a label reads the nearest wrapper, so in `WDiv(className: 'hover:bg-gray-50', children: [WText('Email', className: 'focus:text-blue-600'), WInput(...)])` the label stays gray while the field has focus. Give the row any `focus:` class of its own (`hover:bg-gray-50 focus:bg-gray-50`) and its wrapper keeps the node, so the label lights again.

Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in.

One consequence to know: whether the node exists follows the className, so a `WDiv` that adds or drops a `focus:` class at runtime changes its subtree's shape, and the children below remount and lose their `State` (a `WInput`'s text among it). Keep a `focus:` class present on a div whose children hold state, and switch what it paints instead.

What the wrapper inherits is deliberately narrow: the ancestor's PRIMARY focus, never its focus-within. It passes that signal on rather than stopping at it, because any `hover:` or `active:` class on a div in between creates a second wrapper whose own node never holds focus, and a ring two wrappers deep would otherwise stay dark. The two are different questions and `WindAnchorState` now exposes both. A tappable card containing a text field reports focus-within the whole time the user types, so a wrapper inheriting that would light up even when it sits *beside* the field rather than around it:

```dart
Expand Down
2 changes: 1 addition & 1 deletion doc/widgets/w-div.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Precedence: inline `backgroundColor` wins over any `bg-*` / `dark:bg-*` resolved

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.

It is not a keyboard or remote stop either. Inside a tappable `WAnchor` the wrapper inherits that anchor's `focus` and `disabled` rather than claiming a stop of its own, so the control is reached once and the ring is drawn on the element the user activates. Standing alone, with no anchor above it, the `WDiv` keeps its own focus node and stays reachable. `hover` is never inherited, so sibling divs inside one anchor still highlight independently. See [WAnchor](./w-anchor.md#keyboard-and-remote-control).
It is not a keyboard or remote stop either. Inside a tappable `WAnchor` the wrapper inherits that anchor's `focus` and `disabled` rather than claiming a stop of its own, so the control is reached once and the ring is drawn on the element the user activates. Standing alone, with no anchor above it, a `WDiv` carrying `focus:` keeps its own focus node and stays reachable; one whose only state classes are `hover:` or `active:` has no focus node and is never a stop, since it has nothing to show when focused. `hover` is never inherited, so sibling divs inside one anchor still highlight independently. See [WAnchor](./w-anchor.md#keyboard-and-remote-control).

For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](w-anchor.md) or [WButton](w-button.md).

Expand Down
19 changes: 16 additions & 3 deletions lib/src/debug_resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'parser/wind_parser.dart';
// transitively re-exported by wind_parser.dart. Importing it here
// is mandatory; without it Dart fails with "Undefined name 'WindStyle'".
import 'parser/wind_style.dart';
import 'utils/wind_perf_counters.dart';

/// Concrete WindDebugResolver wired into the registry at app boot
/// via `Wind.installDebugResolver()`. Resolves up to 7 fields (5 always
Expand All @@ -26,9 +27,21 @@ class WindDebugResolverImpl implements WindDebugResolver {
if (className == null || className.isEmpty) {
return const {};
}
// 2. Resolve context + style at snapshot time (Element IS a BuildContext)
final WindContext ctx = WindContext.build(element);
final WindStyle style = WindParser.parse(className, element);
// 2. Resolve context + style at snapshot time (Element IS a BuildContext).
// Counting is suspended around it: this is snapshot work, and since
// the resolver also runs in profile builds, next to a measurement
// session, counting it would add one inherited read and one cache
// lookup per inspected widget to reads no build caused.
final bool counting = WindPerfCounters.enabled;
WindPerfCounters.enabled = false;
final WindContext ctx;
final WindStyle style;
try {
ctx = WindContext.build(element);
style = WindParser.parse(className, element);
} finally {
WindPerfCounters.enabled = counting;
}
// 3. Build the field map: 5 fields always present, bgColor + textColor
// conditional on non-null resolved values (up to 7 total) when the
// widget is a valid W-widget.
Expand Down
Loading
Loading