From 691eae21bc4bb806a71387186a68cb1682db5474 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 09:49:52 -0400 Subject: [PATCH 1/6] feat: add Atlas tooling primitives --- packages/mix/CHANGELOG.md | 4 + packages/mix/lib/mix.dart | 1 + .../widget_state_style_override.dart | 29 ++ packages/mix/lib/src/core/style_builder.dart | 9 +- .../widget_state_style_override_test.dart | 110 +++++ packages/mix_protocol/CHANGELOG.md | 11 + packages/mix_protocol/lib/mix_protocol.dart | 1 + .../src/inspection/document_inspection.dart | 417 ++++++++++++++++++ .../lib/src/schema/common_codecs.dart | 40 +- .../mix_protocol/test/common_codecs_test.dart | 26 ++ .../test/document_inspection_test.dart | 107 +++++ scripts/exports.dart | 3 + 12 files changed, 741 insertions(+), 17 deletions(-) create mode 100644 packages/mix/lib/src/core/providers/widget_state_style_override.dart create mode 100644 packages/mix/test/src/core/widget_state_style_override_test.dart create mode 100644 packages/mix_protocol/lib/src/inspection/document_inspection.dart create mode 100644 packages/mix_protocol/test/document_inspection_test.dart diff --git a/packages/mix/CHANGELOG.md b/packages/mix/CHANGELOG.md index 8c48c5c21..035c37b54 100644 --- a/packages/mix/CHANGELOG.md +++ b/packages/mix/CHANGELOG.md @@ -2,6 +2,10 @@ ### New features +- **Style-state override scope:** `WidgetStateStyleOverride` lets tooling and + tests force widget-state variants through normal `style` resolution, taking + precedence over controllers and nested interaction providers without + changing component behavior. - **`CssKeywordLinearTransform`:** Adds a reusable bounds-aware `GradientTransform` for CSS linear-gradient keyword directions, so Tailwind corner gradients can round-trip through schema tooling without losing visual diff --git a/packages/mix/lib/mix.dart b/packages/mix/lib/mix.dart index 94e2af4af..7460e2b39 100644 --- a/packages/mix/lib/mix.dart +++ b/packages/mix/lib/mix.dart @@ -40,6 +40,7 @@ export 'src/core/prop_source.dart'; export 'src/core/providers/style_provider.dart'; export 'src/core/providers/style_spec_provider.dart'; export 'src/core/providers/widget_state_provider.dart'; +export 'src/core/providers/widget_state_style_override.dart'; export 'src/core/spec.dart'; export 'src/core/style.dart' hide StyleElement; export 'src/core/style_builder.dart'; diff --git a/packages/mix/lib/src/core/providers/widget_state_style_override.dart b/packages/mix/lib/src/core/providers/widget_state_style_override.dart new file mode 100644 index 000000000..0e29e2928 --- /dev/null +++ b/packages/mix/lib/src/core/providers/widget_state_style_override.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Overrides widget states used for style resolution below this scope. +/// +/// This is intended for tooling and tests that need to render a component in a +/// specific visual state without changing the component's interaction state. +/// When present, the override takes precedence over explicit controllers and +/// ordinary [WidgetStateProvider] scopes for [StyleBuilder] resolution. +class WidgetStateStyleOverride extends InheritedWidget { + WidgetStateStyleOverride({ + super.key, + required Set states, + required super.child, + }) : states = Set.unmodifiable(states); + + /// Returns the nearest style-state override, if one exists. + static WidgetStateStyleOverride? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// The widget states to use when resolving descendant Mix styles. + final Set states; + + @override + bool updateShouldNotify(WidgetStateStyleOverride oldWidget) { + return !setEquals(oldWidget.states, states); + } +} diff --git a/packages/mix/lib/src/core/style_builder.dart b/packages/mix/lib/src/core/style_builder.dart index ca5fab0ee..9dcd8b5a9 100644 --- a/packages/mix/lib/src/core/style_builder.dart +++ b/packages/mix/lib/src/core/style_builder.dart @@ -6,6 +6,7 @@ import 'internal/mix_interaction_detector.dart'; import 'providers/style_provider.dart'; import 'providers/style_spec_provider.dart'; import 'providers/widget_state_provider.dart'; +import 'providers/widget_state_style_override.dart'; import 'spec.dart'; import 'style.dart'; import 'style_spec.dart'; @@ -124,6 +125,7 @@ class _StyleBuilderState> extends State> @override Widget build(BuildContext context) { final style = _buildStyle(context); + final stateOverride = WidgetStateStyleOverride.maybeOf(context); // Calculate interactivity need early final needsToTrackWidgetState = @@ -142,7 +144,12 @@ class _StyleBuilderState> extends State> }, ); - if (needsToTrackWidgetState && !alreadyHasWidgetStateScope) { + if (stateOverride != null) { + current = WidgetStateProvider( + states: stateOverride.states, + child: current, + ); + } else if (needsToTrackWidgetState && !alreadyHasWidgetStateScope) { // If we need interactivity and no MixWidgetStateModel is present, // wrap in MixInteractionDetector current = MixInteractionDetector(controller: _controller, child: current); diff --git a/packages/mix/test/src/core/widget_state_style_override_test.dart b/packages/mix/test/src/core/widget_state_style_override_test.dart new file mode 100644 index 000000000..28a5d73a2 --- /dev/null +++ b/packages/mix/test/src/core/widget_state_style_override_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +void main() { + final style = BoxStyler() + .color(Colors.red) + .onHovered(.color(Colors.green)) + .onPressed(.color(Colors.blue)); + + Widget styledBox({WidgetStatesController? controller}) { + return StyleBuilder( + style: style, + controller: controller, + builder: (_, spec) => ColoredBox( + key: const Key('styled-box'), + color: (spec.decoration as BoxDecoration).color!, + ), + ); + } + + Color resolvedColor(WidgetTester tester) { + return tester.widget(find.byKey(const Key('styled-box'))).color; + } + + testWidgets('override takes precedence over an explicit controller', ( + tester, + ) async { + final controller = WidgetStatesController({WidgetState.pressed}); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {WidgetState.hovered}, + child: styledBox(controller: controller), + ), + ), + ); + + expect(resolvedColor(tester), Colors.green); + }); + + testWidgets('override survives a nested Pressable state provider', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {WidgetState.pressed}, + child: Pressable(child: styledBox()), + ), + ), + ); + + expect(resolvedColor(tester), Colors.blue); + }); + + testWidgets('changing override states rebuilds style resolution', ( + tester, + ) async { + final states = ValueNotifier>(const {}); + addTearDown(states.dispose); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder>( + valueListenable: states, + builder: (_, value, child) => + WidgetStateStyleOverride(states: value, child: child!), + child: styledBox(), + ), + ), + ); + expect(resolvedColor(tester), Colors.red); + + states.value = const {WidgetState.hovered}; + await tester.pump(); + + expect(resolvedColor(tester), Colors.green); + }); + + testWidgets('controller behavior is unchanged without an override', ( + tester, + ) async { + final controller = WidgetStatesController({WidgetState.pressed}); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp(home: styledBox(controller: controller)), + ); + + expect(resolvedColor(tester), Colors.blue); + }); + + testWidgets('inherited provider behavior is unchanged without an override', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: WidgetStateProvider( + states: const {WidgetState.hovered}, + child: styledBox(), + ), + ), + ); + + expect(resolvedColor(tester), Colors.green); + }); +} diff --git a/packages/mix_protocol/CHANGELOG.md b/packages/mix_protocol/CHANGELOG.md index 6d0e805d7..4da6b261e 100644 --- a/packages/mix_protocol/CHANGELOG.md +++ b/packages/mix_protocol/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased + +### New features + +- Added style and theme document inspection APIs for schema tooling. + +### Fixes + +- Preserve unresolved numeric tokens when box constraints are encoded and + decoded. + ## 1.0.0 - Renamed the unpublished package from `mix_schema` to `mix_protocol` to match diff --git a/packages/mix_protocol/lib/mix_protocol.dart b/packages/mix_protocol/lib/mix_protocol.dart index b9a967316..aeba8e106 100644 --- a/packages/mix_protocol/lib/mix_protocol.dart +++ b/packages/mix_protocol/lib/mix_protocol.dart @@ -12,4 +12,5 @@ export 'src/errors/mix_protocol_error.dart' MixProtocolFailure, MixProtocolResult, MixProtocolSuccess; +export 'src/inspection/document_inspection.dart'; export 'src/tokens/token_reference_walker.dart'; diff --git a/packages/mix_protocol/lib/src/inspection/document_inspection.dart b/packages/mix_protocol/lib/src/inspection/document_inspection.dart new file mode 100644 index 000000000..a4113d273 --- /dev/null +++ b/packages/mix_protocol/lib/src/inspection/document_inspection.dart @@ -0,0 +1,417 @@ +import 'dart:collection'; +import 'dart:convert'; + +import '../contract/json_map.dart'; +import '../contract/mix_protocol_contract.dart'; +import '../errors/mix_protocol_error.dart'; +import '../tokens/token_reference_walker.dart'; + +/// Whether a theme token is declared as a literal or as an alias. +enum MixProtocolTokenDeclaration { direct, alias } + +/// The declared selector surrounding a style term. +final class MixProtocolSelectorContext { + MixProtocolSelectorContext({ + required this.kind, + required this.value, + required this.jsonPointer, + required Map fields, + }) : fields = UnmodifiableMapView(fields); + + final String kind; + final String value; + final String jsonPointer; + final Map fields; + + String get key => '$kind:$value'; +} + +/// One exact token occurrence in declared style JSON. +final class MixProtocolTokenOccurrence { + const MixProtocolTokenOccurrence({ + required this.kind, + required this.name, + required this.jsonPointer, + }); + + final String kind; + final String name; + final String jsonPointer; +} + +/// One declared leaf term in a style document. +final class MixProtocolStyleTerm { + MixProtocolStyleTerm({ + required List propertyPath, + required this.jsonPointer, + required List selectors, + required this.mergeSource, + required this.literalValue, + required this.token, + }) : propertyPath = List.unmodifiable(propertyPath), + selectors = List.unmodifiable(selectors); + + final List propertyPath; + final String jsonPointer; + final List selectors; + final int? mergeSource; + final Object? literalValue; + final MixProtocolTokenOccurrence? token; + + String get property => propertyPath.join('.'); +} + +/// Declared, pointer-addressable evidence from a valid style document. +final class MixProtocolStyleInspection { + MixProtocolStyleInspection({ + required this.styleType, + required List terms, + }) : terms = List.unmodifiable(terms); + + final String styleType; + final List terms; + + List get tokenOccurrences => List.unmodifiable( + terms.map((term) => term.token).whereType(), + ); +} + +/// One declared token entry and its decoded theme value. +final class MixProtocolThemeTokenInspection { + MixProtocolThemeTokenInspection({ + required this.kind, + required this.name, + required this.jsonPointer, + required this.declaration, + required this.declaredValue, + required List aliasChain, + required this.resolvedValue, + }) : aliasChain = List.unmodifiable(aliasChain); + + final String kind; + final String name; + final String jsonPointer; + final MixProtocolTokenDeclaration declaration; + final Object? declaredValue; + final List aliasChain; + final Object resolvedValue; +} + +/// Declared token entries from a valid theme document. +final class MixProtocolThemeInspection { + MixProtocolThemeInspection({ + required List tokens, + }) : tokens = List.unmodifiable(tokens); + + final List tokens; +} + +/// Inspects only declared data after a strict style decode succeeds. +MixProtocolResult inspectStyleDocument( + Object? payload, +) { + final decoded = mixProtocol.decodeStyle(payload); + if (decoded case MixProtocolFailure(:final errors, :final warnings)) { + return MixProtocolFailure(errors, warnings: warnings); + } + final style = (decoded as MixProtocolSuccess).value; + final document = payload as JsonMap; + final referencesByName = >{}; + for (final reference in tokenReferencesOf(style)) { + referencesByName.putIfAbsent(reference.name, () => []).add(reference); + } + final terms = []; + _walkStyle( + document, + pointer: '', + propertyPath: const [], + selectors: const [], + mergeSource: null, + referencesByName: referencesByName, + terms: terms, + ); + terms.sort((left, right) => left.jsonPointer.compareTo(right.jsonPointer)); + + return MixProtocolSuccess( + MixProtocolStyleInspection( + styleType: document['type']! as String, + terms: terms, + ), + warnings: decoded.warnings, + ); +} + +/// Inspects direct and alias token declarations after strict theme decode. +MixProtocolResult inspectThemeDocument( + Object? payload, +) { + final decoded = mixProtocol.decodeTheme(payload); + if (decoded case MixProtocolFailure( + :final errors, + :final warnings, + )) { + return MixProtocolFailure(errors, warnings: warnings); + } + final theme = (decoded as MixProtocolSuccess).value; + final document = payload as JsonMap; + final resolved = {}; + for (final entry in theme.tokens.entries) { + final reference = MixProtocolTokenReference.fromToken(entry.key); + resolved['${reference.kind}/${reference.name}'] = entry.value; + } + final tokens = []; + final kinds = + document.keys.where((key) => key != 'v' && key != 'type').toList() + ..sort(); + for (final kind in kinds) { + final declarations = document[kind]! as JsonMap; + final names = declarations.keys.toList()..sort(); + for (final name in names) { + final value = declarations[name]!; + final alias = _aliasTarget(value); + tokens.add( + MixProtocolThemeTokenInspection( + kind: kind, + name: name, + jsonPointer: '/${_escape(kind)}/${_escape(name)}', + declaration: alias == null + ? MixProtocolTokenDeclaration.direct + : MixProtocolTokenDeclaration.alias, + declaredValue: _immutableJson(value), + aliasChain: alias == null + ? const [] + : _aliasChain(name, declarations), + resolvedValue: resolved['$kind/$name']!, + ), + ); + } + } + + return MixProtocolSuccess( + MixProtocolThemeInspection(tokens: tokens), + warnings: decoded.warnings, + ); +} + +void _walkStyle( + Object? value, { + required String pointer, + required List propertyPath, + required List selectors, + required int? mergeSource, + required Map> referencesByName, + required List terms, +}) { + if (value is JsonMap) { + final tokenName = value[r'$token']; + if (tokenName is String) { + final occurrence = MixProtocolTokenOccurrence( + kind: _tokenKind(value, tokenName, propertyPath, referencesByName), + name: tokenName, + jsonPointer: '$pointer/\$token', + ); + terms.add( + MixProtocolStyleTerm( + propertyPath: propertyPath, + jsonPointer: pointer, + selectors: selectors, + mergeSource: mergeSource, + literalValue: null, + token: occurrence, + ), + ); + for (final entry in value.entries) { + if (entry.key == r'$token' || entry.key == 'kind') continue; + _walkStyle( + entry.value, + pointer: '$pointer/${_escape(entry.key)}', + propertyPath: [...propertyPath, entry.key], + selectors: selectors, + mergeSource: mergeSource, + referencesByName: referencesByName, + terms: terms, + ); + } + + return; + } + final merge = value[r'$merge']; + if (merge is List) { + for (var index = 0; index < merge.length; index += 1) { + _walkStyle( + merge[index], + pointer: '$pointer/\$merge/$index', + propertyPath: propertyPath, + selectors: selectors, + mergeSource: index, + referencesByName: referencesByName, + terms: terms, + ); + } + + return; + } + final variants = value['variants']; + if (variants is List) { + for (var index = 0; index < variants.length; index += 1) { + final variant = variants[index]! as JsonMap; + final selector = _selector(variant, '$pointer/variants/$index'); + _walkStyle( + variant['style'], + pointer: '$pointer/variants/$index/style', + propertyPath: propertyPath, + selectors: [...selectors, selector], + mergeSource: mergeSource, + referencesByName: referencesByName, + terms: terms, + ); + } + } + final keys = + value.keys + .where((key) => key != 'v' && key != 'type' && key != 'variants') + .toList() + ..sort(); + for (final key in keys) { + _walkStyle( + value[key], + pointer: '$pointer/${_escape(key)}', + propertyPath: [...propertyPath, key], + selectors: selectors, + mergeSource: mergeSource, + referencesByName: referencesByName, + terms: terms, + ); + } + + return; + } + if (value is List) { + for (var index = 0; index < value.length; index += 1) { + _walkStyle( + value[index], + pointer: '$pointer/$index', + propertyPath: propertyPath, + selectors: selectors, + mergeSource: mergeSource, + referencesByName: referencesByName, + terms: terms, + ); + } + + return; + } + terms.add( + MixProtocolStyleTerm( + propertyPath: propertyPath, + jsonPointer: pointer, + selectors: selectors, + mergeSource: mergeSource, + literalValue: value, + token: null, + ), + ); +} + +MixProtocolSelectorContext _selector(JsonMap variant, String pointer) { + final fields = { + for (final entry in variant.entries) + if (entry.key != 'style') entry.key: _immutableJson(entry.value), + }; + final kind = fields['kind']! as String; + const valueKeys = [ + 'state', + 'name', + 'brightness', + 'orientation', + 'textDirection', + 'platform', + 'token', + ]; + final selected = valueKeys + .where(fields.containsKey) + .map((key) => fields[key].toString()) + .firstOrNull; + + return MixProtocolSelectorContext( + kind: kind, + value: selected ?? jsonEncode(fields), + jsonPointer: pointer, + fields: fields, + ); +} + +String _tokenKind( + JsonMap token, + String name, + List propertyPath, + Map> referencesByName, +) { + final wireKind = token['kind']; + if (wireKind is String) return _pluralKind(wireKind); + final matches = referencesByName[name] ?? const []; + if (matches.length == 1) return matches.single.kind; + final property = propertyPath.lastOrNull; + final inferred = switch (property) { + 'color' || 'selectionColor' || 'decorationColor' => 'colors', + 'padding' || 'margin' || 'spacing' || 'size' => 'spaces', + 'borderRadius' || 'radius' => 'radii', + 'fontWeight' => 'fontWeights', + 'duration' || 'delay' => 'durations', + _ => null, + }; + if (inferred != null) return inferred; + + return matches.isEmpty ? 'unknown' : matches.first.kind; +} + +String _pluralKind(String kind) => switch (kind) { + 'color' => 'colors', + 'space' => 'spaces', + 'double' => 'doubles', + 'radius' => 'radii', + 'text_style' => 'textStyles', + 'shadow' => 'shadows', + 'box_shadow' => 'boxShadows', + 'border' => 'borders', + 'font_weight' => 'fontWeights', + 'breakpoint' => 'breakpoints', + 'duration' => 'durations', + _ => kind, +}; + +String? _aliasTarget(Object? value) { + if (value is! JsonMap) return null; + final target = value[r'$token']; + + return target is String ? target : null; +} + +List _aliasChain(String name, JsonMap declarations) { + final chain = [name]; + var current = name; + while (true) { + final target = _aliasTarget(declarations[current]); + if (target == null) return chain; + chain.add(target); + current = target; + } +} + +Object? _immutableJson(Object? value) { + if (value is JsonMap) { + final keys = value.keys.toList()..sort(); + + return Map.unmodifiable({ + for (final key in keys) key: _immutableJson(value[key]), + }); + } + if (value is List) { + return List.unmodifiable(value.map(_immutableJson)); + } + + return value; +} + +String _escape(String value) => + value.replaceAll('~', '~0').replaceAll('/', '~1'); diff --git a/packages/mix_protocol/lib/src/schema/common_codecs.dart b/packages/mix_protocol/lib/src/schema/common_codecs.dart index 28db9343d..a9ee45dba 100644 --- a/packages/mix_protocol/lib/src/schema/common_codecs.dart +++ b/packages/mix_protocol/lib/src/schema/common_codecs.dart @@ -498,9 +498,9 @@ CodecSchema> mixPropCodec< CodecSchema boxConstraintsCodec() { return Ack.object({ - 'minWidth': nonNegativeDoubleCodec().optional(), + 'minWidth': nonNegativeDoubleTokenCodec().optional(), 'maxWidth': _maxConstraintBoundWireSchema().optional(), - 'minHeight': nonNegativeDoubleCodec().optional(), + 'minHeight': nonNegativeDoubleTokenCodec().optional(), 'maxHeight': _maxConstraintBoundWireSchema().optional(), }) .constrain(const _BoxConstraintsBoundsConstraint()) @@ -556,7 +556,7 @@ CodecSchema enumNameCodec(List values) { } AckSchema _maxConstraintBoundWireSchema() { - return Ack.anyOf([nonNegativeDoubleCodec(), Ack.literal('infinity')]); + return Ack.anyOf([nonNegativeDoubleTokenCodec(), Ack.literal('infinity')]); } CodecSchema textDirectionCodec() { @@ -1729,10 +1729,10 @@ double? _readOptionalMaxConstraintBound(JsonMap data, String key) { } JsonMap _encodeBoxConstraintsMix(BoxConstraintsMix value) { - final minWidth = singleValueProp(value.$minWidth, 'minWidth'); - final maxWidth = singleValueProp(value.$maxWidth, 'maxWidth'); - final minHeight = singleValueProp(value.$minHeight, 'minHeight'); - final maxHeight = singleValueProp(value.$maxHeight, 'maxHeight'); + final minWidth = singleValuePropWire(value.$minWidth, 'minWidth'); + final maxWidth = singleValuePropWire(value.$maxWidth, 'maxWidth'); + final minHeight = singleValuePropWire(value.$minHeight, 'minHeight'); + final maxHeight = singleValuePropWire(value.$maxHeight, 'maxHeight'); _assertEncodableConstraintBounds(minWidth, maxWidth, 'width'); _assertEncodableConstraintBounds(minHeight, maxHeight, 'height'); @@ -1745,8 +1745,8 @@ JsonMap _encodeBoxConstraintsMix(BoxConstraintsMix value) { }; } -double _encodeMinConstraintBound(double value) { - if (value == double.infinity) { +Object _encodeMinConstraintBound(Object value) { + if (value is double && value == double.infinity) { throw UnsupportedEncodeValueError( value, 'Minimum constraint bounds cannot be unbounded.', @@ -1756,12 +1756,16 @@ double _encodeMinConstraintBound(double value) { return value; } -Object _encodeMaxConstraintBound(double value) { - return value == double.infinity ? 'infinity' : value; +Object _encodeMaxConstraintBound(Object value) { + return value is double && value == double.infinity ? 'infinity' : value; } -void _assertEncodableConstraintBounds(double? min, double? max, String axis) { - if (min == null || max == null || max == double.infinity) return; +void _assertEncodableConstraintBounds(Object? min, Object? max, String axis) { + if (min is! double || max is! double || max == double.infinity) return; + if (tokenFromReferenceValue(min) != null || + tokenFromReferenceValue(max) != null) { + return; + } if (min <= max) return; throw UnsupportedEncodeValueError({ @@ -1845,13 +1849,17 @@ final class _BoxConstraintsBoundsConstraint extends Constraint } static bool _isAxisValid(JsonMap value, String minKey, String maxKey) { - final min = value[minKey] as double?; + final min = value[minKey]; + if (min is! double || tokenFromReferenceValue(min) != null) { + return true; + } final max = switch (value[maxKey]) { 'infinity' => double.infinity, - final double value => value, + final double value when tokenFromReferenceValue(value) == null => + value, _ => null, }; - if (min == null || max == null) return true; + if (max == null) return true; return min <= max; } diff --git a/packages/mix_protocol/test/common_codecs_test.dart b/packages/mix_protocol/test/common_codecs_test.dart index 3dc799540..929d259a7 100644 --- a/packages/mix_protocol/test/common_codecs_test.dart +++ b/packages/mix_protocol/test/common_codecs_test.dart @@ -208,6 +208,32 @@ void main() { expect(singleValueProp(constraints.$maxWidth, 'maxWidth'), 32); }); + test('box constraints preserve unresolved numeric tokens', () { + const minWidth = SpaceToken('constraint.min-width'); + const maxWidth = SpaceToken('constraint.max-width'); + final payload = _encodeBox( + BoxStyler( + constraints: BoxConstraintsMix( + minWidth: minWidth(), + maxWidth: maxWidth(), + ), + ), + ); + + expect(payload['constraints'], { + 'minWidth': {r'$token': minWidth.name, 'kind': 'space'}, + 'maxWidth': {r'$token': maxWidth.name, 'kind': 'space'}, + }); + + final constraints = _decodeBoxConstraints({ + 'type': 'box', + 'constraints': payload['constraints']!, + }); + + expect(tokenFromReferenceValue(constraints.$minWidth), minWidth); + expect(tokenFromReferenceValue(constraints.$maxWidth), maxWidth); + }); + test('unbounded max constraints use infinity sentinel only when present', () { final payload = _encodeBox( BoxStyler(constraints: BoxConstraintsMix.maxWidth(double.infinity)), diff --git a/packages/mix_protocol/test/document_inspection_test.dart b/packages/mix_protocol/test/document_inspection_test.dart new file mode 100644 index 000000000..d506cae16 --- /dev/null +++ b/packages/mix_protocol/test/document_inspection_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_protocol/mix_protocol.dart'; + +void main() { + group('style document inspection', () { + test('preserves merge positions, selectors, pointers, and token uses', () { + final result = inspectStyleDocument({ + 'v': 1, + 'type': 'flex_box', + 'padding': { + r'$merge': [ + {'top': 4}, + { + 'left': {r'$token': 'space.pad', 'kind': 'space'}, + }, + ], + }, + 'variants': [ + { + 'kind': 'widget_state', + 'state': 'hovered', + 'style': { + 'type': 'flex_box', + 'spacing': {r'$token': 'space.gap', 'kind': 'space'}, + }, + }, + ], + }); + final inspection = switch (result) { + MixProtocolSuccess(:final value) => value, + MixProtocolFailure(:final errors) => fail( + '$errors', + ), + }; + + expect( + inspection.terms, + contains( + isA() + .having((term) => term.property, 'property', 'padding.top') + .having( + (term) => term.jsonPointer, + 'pointer', + r'/padding/$merge/0/top', + ) + .having((term) => term.mergeSource, 'merge source', 0) + .having((term) => term.literalValue, 'literal', 4), + ), + ); + final left = inspection.terms.singleWhere( + (term) => term.property == 'padding.left', + ); + expect(left.mergeSource, 1); + expect(left.token?.kind, 'spaces'); + expect(left.token?.name, 'space.pad'); + expect(left.token?.jsonPointer, r'/padding/$merge/1/left/$token'); + + final hovered = inspection.terms.singleWhere( + (term) => term.property == 'spacing', + ); + expect(hovered.selectors.single.kind, 'widget_state'); + expect(hovered.selectors.single.value, 'hovered'); + expect(hovered.token?.kind, 'spaces'); + expect(inspection.tokenOccurrences, hasLength(2)); + }); + + test('rejects invalid protocol data instead of inspecting guesses', () { + final result = inspectStyleDocument({'v': 1, 'type': 'not_a_style'}); + + expect(result, isA>()); + }); + }); + + group('theme document inspection', () { + test('reports direct values, aliases, chains, and resolved values', () { + final result = inspectThemeDocument({ + 'v': 1, + 'type': 'theme', + 'colors': { + 'color.base': '#112233', + 'color.alias': {r'$token': 'color.base'}, + 'color.deep': {r'$token': 'color.alias'}, + }, + }); + final inspection = switch (result) { + MixProtocolSuccess(:final value) => value, + MixProtocolFailure(:final errors) => fail( + '$errors', + ), + }; + + final direct = inspection.tokens.singleWhere( + (token) => token.name == 'color.base', + ); + final alias = inspection.tokens.singleWhere( + (token) => token.name == 'color.deep', + ); + expect(direct.declaration, MixProtocolTokenDeclaration.direct); + expect(direct.jsonPointer, '/colors/color.base'); + expect(direct.resolvedValue, const Color(0xFF112233)); + expect(alias.declaration, MixProtocolTokenDeclaration.alias); + expect(alias.aliasChain, ['color.deep', 'color.alias', 'color.base']); + expect(alias.resolvedValue, const Color(0xFF112233)); + }); + }); +} diff --git a/scripts/exports.dart b/scripts/exports.dart index 5686c1b19..9ae028384 100755 --- a/scripts/exports.dart +++ b/scripts/exports.dart @@ -285,6 +285,9 @@ const _packageExportConfigs = { // from leaking as public Mix API via the barrel. 'src/core/equatable.dart', 'src/core/shape_border_merge.dart', + // Generated stylers import this internal support barrel directly. Its + // individual public symbols are already exported by mix.dart. + 'src/generated_styler_support.dart', ], forcedPaths: ['src/modifiers/internal/reset_modifier.dart'], ), From 48ba8b313a58c303039d7808b94994d89d3f02a3 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 13:03:49 -0400 Subject: [PATCH 2/6] fix(mix): support all widget provider states --- .../core/providers/widget_state_provider.dart | 77 +++++-------------- .../widget_state_style_override_test.dart | 28 +++++++ .../widget_state_controller_test.dart | 31 ++++++++ 3 files changed, 80 insertions(+), 56 deletions(-) diff --git a/packages/mix/lib/src/core/providers/widget_state_provider.dart b/packages/mix/lib/src/core/providers/widget_state_provider.dart index 7d61f5c65..1d49159da 100644 --- a/packages/mix/lib/src/core/providers/widget_state_provider.dart +++ b/packages/mix/lib/src/core/providers/widget_state_provider.dart @@ -1,17 +1,5 @@ import 'package:flutter/widgets.dart' - show WidgetState, InheritedModel, BuildContext, WidgetStatesController; - -/// Extension on [Set] to provide convenient boolean checks -/// for specific widget states. -extension on Set { - bool get hasDisabled => contains(WidgetState.disabled); - bool get hasHovered => contains(WidgetState.hovered); - bool get hasFocused => contains(WidgetState.focused); - bool get hasPressed => contains(WidgetState.pressed); - bool get hasDragged => contains(WidgetState.dragged); - bool get hasSelected => contains(WidgetState.selected); - bool get hasError => contains(WidgetState.error); -} + show BuildContext, InheritedModel, WidgetState, WidgetStatesController; /// Provider for widget state information using Flutter's [InheritedModel]. /// @@ -23,13 +11,7 @@ class WidgetStateProvider extends InheritedModel { super.key, required Set states, required super.child, - }) : disabled = states.hasDisabled, - hovered = states.hasHovered, - focused = states.hasFocused, - pressed = states.hasPressed, - dragged = states.hasDragged, - selected = states.hasSelected, - error = states.hasError; + }) : states = Set.unmodifiable(states); /// Retrieves the [WidgetStateProvider] from the widget tree. /// @@ -46,53 +28,40 @@ class WidgetStateProvider extends InheritedModel { /// /// Returns false if no [WidgetStateProvider] is found in the widget tree. static bool hasStateOf(BuildContext context, WidgetState state) { - final model = of(context, state); - if (model == null) { - return false; - } - - return switch (state) { - .disabled => model.disabled, - .hovered => model.hovered, - .focused => model.focused, - .pressed => model.pressed, - .dragged => model.dragged, - .selected => model.selected, - .error => model.error, - .scrolledUnder => false, - }; + return of(context, state)?.states.contains(state) ?? false; } + /// The active widget states. + final Set states; + /// Whether the widget is disabled. - final bool disabled; + bool get disabled => states.contains(WidgetState.disabled); /// Whether the widget is being hovered. - final bool hovered; + bool get hovered => states.contains(WidgetState.hovered); /// Whether the widget has focus. - final bool focused; + bool get focused => states.contains(WidgetState.focused); /// Whether the widget is being pressed. - final bool pressed; + bool get pressed => states.contains(WidgetState.pressed); /// Whether the widget is being dragged. - final bool dragged; + bool get dragged => states.contains(WidgetState.dragged); /// Whether the widget is selected. - final bool selected; + bool get selected => states.contains(WidgetState.selected); /// Whether the widget has an error. - final bool error; + bool get error => states.contains(WidgetState.error); + + /// Whether the widget is scrolled under another widget. + bool get scrolledUnder => states.contains(WidgetState.scrolledUnder); @override bool updateShouldNotify(WidgetStateProvider oldWidget) { - return oldWidget.disabled != disabled || - oldWidget.hovered != hovered || - oldWidget.focused != focused || - oldWidget.pressed != pressed || - oldWidget.dragged != dragged || - oldWidget.selected != selected || - oldWidget.error != error; + return oldWidget.states.length != states.length || + !oldWidget.states.containsAll(states); } @override @@ -100,13 +69,9 @@ class WidgetStateProvider extends InheritedModel { WidgetStateProvider oldWidget, Set dependencies, ) { - return oldWidget.disabled != disabled && dependencies.hasDisabled || - oldWidget.hovered != hovered && dependencies.hasHovered || - oldWidget.focused != focused && dependencies.hasFocused || - oldWidget.pressed != pressed && dependencies.hasPressed || - oldWidget.dragged != dragged && dependencies.hasDragged || - oldWidget.selected != selected && dependencies.hasSelected || - oldWidget.error != error && dependencies.hasError; + return dependencies.any( + (state) => oldWidget.states.contains(state) != states.contains(state), + ); } } diff --git a/packages/mix/test/src/core/widget_state_style_override_test.dart b/packages/mix/test/src/core/widget_state_style_override_test.dart index 28a5d73a2..b0aa54714 100644 --- a/packages/mix/test/src/core/widget_state_style_override_test.dart +++ b/packages/mix/test/src/core/widget_state_style_override_test.dart @@ -41,6 +41,34 @@ void main() { expect(resolvedColor(tester), Colors.green); }); + testWidgets('override supports the scrolledUnder widget state', ( + tester, + ) async { + final scrolledUnderStyle = BoxStyler() + .color(Colors.red) + .variant( + WidgetStateVariant(WidgetState.scrolledUnder), + BoxStyler().color(Colors.green), + ); + + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {WidgetState.scrolledUnder}, + child: StyleBuilder( + style: scrolledUnderStyle, + builder: (_, spec) => ColoredBox( + key: const Key('styled-box'), + color: (spec.decoration as BoxDecoration).color!, + ), + ), + ), + ), + ); + + expect(resolvedColor(tester), Colors.green); + }); + testWidgets('override survives a nested Pressable state provider', ( tester, ) async { diff --git a/packages/mix/test/src/specs/pressable/widget_state_controller_test.dart b/packages/mix/test/src/specs/pressable/widget_state_controller_test.dart index c7eae7329..c36bc6b88 100644 --- a/packages/mix/test/src/specs/pressable/widget_state_controller_test.dart +++ b/packages/mix/test/src/specs/pressable/widget_state_controller_test.dart @@ -217,6 +217,37 @@ void main() { isFalse, ); }); + + test('notification tracks scrolledUnder state changes', () { + final oldModel = WidgetStateProvider(states: {}, child: Container()); + final newModel = WidgetStateProvider( + states: {WidgetState.scrolledUnder}, + child: Container(), + ); + + expect(newModel.updateShouldNotify(oldModel), isTrue); + expect( + newModel.updateShouldNotifyDependent(oldModel, { + WidgetState.scrolledUnder, + }), + isTrue, + ); + }); + + test('copies the provided widget states', () { + final states = {WidgetState.hovered}; + final model = WidgetStateProvider(states: states, child: Container()); + + states + ..clear() + ..add(WidgetState.pressed); + + expect(model.states, {WidgetState.hovered}); + expect( + () => model.states.add(WidgetState.pressed), + throwsUnsupportedError, + ); + }); }); testWidgets('PressableState updates widgets correctly', ( From f0bff7ad19caeb18714b48f01bd398d1db0dadc9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 13:03:58 -0400 Subject: [PATCH 3/6] fix(mix_protocol): harden document inspection --- .../src/inspection/document_inspection.dart | 311 ++++++++++++++---- .../test/document_inspection_test.dart | 218 +++++++++++- 2 files changed, 462 insertions(+), 67 deletions(-) diff --git a/packages/mix_protocol/lib/src/inspection/document_inspection.dart b/packages/mix_protocol/lib/src/inspection/document_inspection.dart index a4113d273..aca297668 100644 --- a/packages/mix_protocol/lib/src/inspection/document_inspection.dart +++ b/packages/mix_protocol/lib/src/inspection/document_inspection.dart @@ -1,6 +1,6 @@ -import 'dart:collection'; import 'dart:convert'; +import '../contract/identity_resolution.dart'; import '../contract/json_map.dart'; import '../contract/mix_protocol_contract.dart'; import '../errors/mix_protocol_error.dart'; @@ -16,7 +16,7 @@ final class MixProtocolSelectorContext { required this.value, required this.jsonPointer, required Map fields, - }) : fields = UnmodifiableMapView(fields); + }) : fields = _immutableJson(fields) as Map; final String kind; final String value; @@ -61,19 +61,44 @@ final class MixProtocolStyleTerm { String get property => propertyPath.join('.'); } +/// One declared directive attached to a style term. +final class MixProtocolDirectiveInspection { + MixProtocolDirectiveInspection({ + required List propertyPath, + required this.jsonPointer, + required List selectors, + required this.mergeSource, + required this.op, + required Map parameters, + }) : propertyPath = List.unmodifiable(propertyPath), + selectors = List.unmodifiable(selectors), + parameters = _immutableJson(parameters) as Map; + + final List propertyPath; + final String jsonPointer; + final List selectors; + final int? mergeSource; + final String op; + final Map parameters; + + String get property => propertyPath.join('.'); +} + /// Declared, pointer-addressable evidence from a valid style document. final class MixProtocolStyleInspection { MixProtocolStyleInspection({ required this.styleType, required List terms, - }) : terms = List.unmodifiable(terms); + required List tokenOccurrences, + required List directives, + }) : terms = List.unmodifiable(terms), + tokenOccurrences = List.unmodifiable(tokenOccurrences), + directives = List.unmodifiable(directives); final String styleType; final List terms; - - List get tokenOccurrences => List.unmodifiable( - terms.map((term) => term.token).whereType(), - ); + final List tokenOccurrences; + final List directives; } /// One declared token entry and its decoded theme value. @@ -108,35 +133,34 @@ final class MixProtocolThemeInspection { /// Inspects only declared data after a strict style decode succeeds. MixProtocolResult inspectStyleDocument( - Object? payload, -) { - final decoded = mixProtocol.decodeStyle(payload); + Object? payload, { + MixProtocolIconResolver? resolveIcon, + MixProtocolImageResolver? resolveImage, +}) { + final decoded = mixProtocol.decodeStyle( + payload, + options: MixProtocolDecodeOptions( + resolveIcon: resolveIcon, + resolveImage: resolveImage, + ), + ); if (decoded case MixProtocolFailure(:final errors, :final warnings)) { return MixProtocolFailure(errors, warnings: warnings); } final style = (decoded as MixProtocolSuccess).value; final document = payload as JsonMap; - final referencesByName = >{}; - for (final reference in tokenReferencesOf(style)) { - referencesByName.putIfAbsent(reference.name, () => []).add(reference); - } - final terms = []; + final evidence = _StyleInspectionEvidence(tokenReferencesOf(style)); _walkStyle( document, pointer: '', propertyPath: const [], selectors: const [], mergeSource: null, - referencesByName: referencesByName, - terms: terms, + evidence: evidence, ); - terms.sort((left, right) => left.jsonPointer.compareTo(right.jsonPointer)); return MixProtocolSuccess( - MixProtocolStyleInspection( - styleType: document['type']! as String, - terms: terms, - ), + evidence.build(document['type']! as String), warnings: decoded.warnings, ); } @@ -193,24 +217,59 @@ MixProtocolResult inspectThemeDocument( ); } +final class _StyleInspectionEvidence { + _StyleInspectionEvidence(Iterable references) { + for (final reference in references) { + referencesByName.putIfAbsent(reference.name, () => []).add(reference); + } + } + + final referencesByName = >{}; + final terms = []; + final tokenOccurrences = []; + final directives = []; + + MixProtocolStyleInspection build(String styleType) { + terms.sort((left, right) => left.jsonPointer.compareTo(right.jsonPointer)); + tokenOccurrences.sort( + (left, right) => left.jsonPointer.compareTo(right.jsonPointer), + ); + directives.sort( + (left, right) => left.jsonPointer.compareTo(right.jsonPointer), + ); + + return MixProtocolStyleInspection( + styleType: styleType, + terms: terms, + tokenOccurrences: tokenOccurrences, + directives: directives, + ); + } +} + void _walkStyle( Object? value, { required String pointer, required List propertyPath, required List selectors, required int? mergeSource, - required Map> referencesByName, - required List terms, + required _StyleInspectionEvidence evidence, }) { if (value is JsonMap) { final tokenName = value[r'$token']; if (tokenName is String) { final occurrence = MixProtocolTokenOccurrence( - kind: _tokenKind(value, tokenName, propertyPath, referencesByName), + kind: _tokenKind( + value, + tokenName, + propertyPath, + evidence.referencesByName, + ), name: tokenName, jsonPointer: '$pointer/\$token', ); - terms.add( + evidence.tokenOccurrences.add(occurrence); + evidence.terms.add( MixProtocolStyleTerm( propertyPath: propertyPath, jsonPointer: pointer, @@ -220,18 +279,14 @@ void _walkStyle( token: occurrence, ), ); - for (final entry in value.entries) { - if (entry.key == r'$token' || entry.key == 'kind') continue; - _walkStyle( - entry.value, - pointer: '$pointer/${_escape(entry.key)}', - propertyPath: [...propertyPath, entry.key], - selectors: selectors, - mergeSource: mergeSource, - referencesByName: referencesByName, - terms: terms, - ); - } + _collectApplyDirectives( + value, + pointer: pointer, + propertyPath: propertyPath, + selectors: selectors, + mergeSource: mergeSource, + directives: evidence.directives, + ); return; } @@ -244,10 +299,17 @@ void _walkStyle( propertyPath: propertyPath, selectors: selectors, mergeSource: index, - referencesByName: referencesByName, - terms: terms, + evidence: evidence, ); } + _collectApplyDirectives( + value, + pointer: pointer, + propertyPath: propertyPath, + selectors: selectors, + mergeSource: mergeSource, + directives: evidence.directives, + ); return; } @@ -255,15 +317,18 @@ void _walkStyle( if (variants is List) { for (var index = 0; index < variants.length; index += 1) { final variant = variants[index]! as JsonMap; - final selector = _selector(variant, '$pointer/variants/$index'); + final selector = _inspectSelector( + variant, + '$pointer/variants/$index', + evidence.tokenOccurrences, + ); _walkStyle( variant['style'], pointer: '$pointer/variants/$index/style', propertyPath: propertyPath, selectors: [...selectors, selector], mergeSource: mergeSource, - referencesByName: referencesByName, - terms: terms, + evidence: evidence, ); } } @@ -279,8 +344,7 @@ void _walkStyle( propertyPath: [...propertyPath, key], selectors: selectors, mergeSource: mergeSource, - referencesByName: referencesByName, - terms: terms, + evidence: evidence, ); } @@ -294,14 +358,13 @@ void _walkStyle( propertyPath: propertyPath, selectors: selectors, mergeSource: mergeSource, - referencesByName: referencesByName, - terms: terms, + evidence: evidence, ); } return; } - terms.add( + evidence.terms.add( MixProtocolStyleTerm( propertyPath: propertyPath, jsonPointer: pointer, @@ -313,7 +376,41 @@ void _walkStyle( ); } -MixProtocolSelectorContext _selector(JsonMap variant, String pointer) { +void _collectApplyDirectives( + JsonMap term, { + required String pointer, + required List propertyPath, + required List selectors, + required int? mergeSource, + required List directives, +}) { + final apply = term['apply']; + if (apply is! List) return; + + for (var index = 0; index < apply.length; index += 1) { + final wire = apply[index]! as JsonMap; + directives.add( + MixProtocolDirectiveInspection( + propertyPath: propertyPath, + jsonPointer: '$pointer/apply/$index', + selectors: selectors, + mergeSource: mergeSource, + op: wire['op']! as String, + parameters: { + for (final entry in wire.entries) + if (entry.key != 'op') entry.key: entry.value, + }, + ), + ); + } +} + +MixProtocolSelectorContext _inspectSelector( + JsonMap variant, + String pointer, + List tokenOccurrences, +) { + _collectSelectorTokenOccurrences(variant, pointer, tokenOccurrences); final fields = { for (final entry in variant.entries) if (entry.key != 'style') entry.key: _immutableJson(entry.value), @@ -341,6 +438,32 @@ MixProtocolSelectorContext _selector(JsonMap variant, String pointer) { ); } +void _collectSelectorTokenOccurrences( + JsonMap selector, + String pointer, + List tokenOccurrences, +) { + final tokenName = selector['token']; + if (selector['kind'] == 'context_breakpoint' && tokenName is String) { + tokenOccurrences.add( + MixProtocolTokenOccurrence( + kind: 'breakpoints', + name: tokenName, + jsonPointer: '$pointer/token', + ), + ); + } + + final nested = selector['variant']; + if (nested is JsonMap) { + _collectSelectorTokenOccurrences( + nested, + '$pointer/variant', + tokenOccurrences, + ); + } +} + String _tokenKind( JsonMap token, String name, @@ -349,22 +472,90 @@ String _tokenKind( ) { final wireKind = token['kind']; if (wireKind is String) return _pluralKind(wireKind); + final inferred = _schemaTokenKind(propertyPath); + if (inferred != null) return inferred; final matches = referencesByName[name] ?? const []; - if (matches.length == 1) return matches.single.kind; + final runtimeKinds = matches.map((match) => match.kind).toSet(); + if (runtimeKinds.length == 1) return runtimeKinds.single; + + return 'unknown'; +} + +String? _schemaTokenKind(List propertyPath) { final property = propertyPath.lastOrNull; - final inferred = switch (property) { - 'color' || 'selectionColor' || 'decorationColor' => 'colors', - 'padding' || 'margin' || 'spacing' || 'size' => 'spaces', - 'borderRadius' || 'radius' => 'radii', - 'fontWeight' => 'fontWeights', - 'duration' || 'delay' => 'durations', - _ => null, - }; - if (inferred != null) return inferred; + if (property == null) return null; + final parent = propertyPath.length > 1 + ? propertyPath[propertyPath.length - 2] + : null; + + if (_edgeProperties.contains(property)) { + if (parent == 'padding' || parent == 'margin') return 'spaces'; + if (parent == 'border') return 'borders'; + } + if (_cornerProperties.contains(property) && parent == 'borderRadius') { + return 'radii'; + } - return matches.isEmpty ? 'unknown' : matches.first.kind; + return _tokenKindByProperty[property]; } +const _edgeProperties = {'top', 'right', 'bottom', 'left', 'start', 'end'}; + +const _cornerProperties = { + 'topLeft', + 'topRight', + 'bottomLeft', + 'bottomRight', + 'topStart', + 'topEnd', + 'bottomStart', + 'bottomEnd', +}; + +/// Schema-defined token kinds for property names whose runtime references can +/// be ambiguous because multiple token classes share the same declared name. +const _tokenKindByProperty = { + 'color': 'colors', + 'backgroundColor': 'colors', + 'selectionColor': 'colors', + 'decorationColor': 'colors', + 'padding': 'spaces', + 'margin': 'spaces', + 'spacing': 'spaces', + 'size': 'spaces', + 'width': 'spaces', + 'height': 'spaces', + 'minWidth': 'spaces', + 'maxWidth': 'spaces', + 'minHeight': 'spaces', + 'maxHeight': 'spaces', + 'fontSize': 'spaces', + 'letterSpacing': 'spaces', + 'wordSpacing': 'spaces', + 'leading': 'spaces', + 'decorationThickness': 'spaces', + 'blurRadius': 'spaces', + 'spreadRadius': 'spaces', + 'strokeAlign': 'spaces', + 'weight': 'spaces', + 'grade': 'spaces', + 'opticalSize': 'spaces', + 'fill': 'spaces', + 'opacity': 'spaces', + 'radius': 'spaces', + 'focalRadius': 'spaces', + 'startAngle': 'spaces', + 'endAngle': 'spaces', + 'borderRadius': 'radii', + 'style': 'textStyles', + 'shadows': 'shadows', + 'boxShadow': 'boxShadows', + 'border': 'borders', + 'fontWeight': 'fontWeights', + 'duration': 'durations', + 'delay': 'durations', +}; + String _pluralKind(String kind) => switch (kind) { 'color' => 'colors', 'space' => 'spaces', diff --git a/packages/mix_protocol/test/document_inspection_test.dart b/packages/mix_protocol/test/document_inspection_test.dart index d506cae16..b7f942031 100644 --- a/packages/mix_protocol/test/document_inspection_test.dart +++ b/packages/mix_protocol/test/document_inspection_test.dart @@ -5,7 +5,7 @@ import 'package:mix_protocol/mix_protocol.dart'; void main() { group('style document inspection', () { test('preserves merge positions, selectors, pointers, and token uses', () { - final result = inspectStyleDocument({ + final inspection = _inspectStyle({ 'v': 1, 'type': 'flex_box', 'padding': { @@ -27,12 +27,6 @@ void main() { }, ], }); - final inspection = switch (result) { - MixProtocolSuccess(:final value) => value, - MixProtocolFailure(:final errors) => fail( - '$errors', - ), - }; expect( inspection.terms, @@ -65,6 +59,199 @@ void main() { expect(inspection.tokenOccurrences, hasLength(2)); }); + test('accepts strict per-call icon and image resolvers', () { + const icon = IconData(0xe88a, fontFamily: 'MaterialIcons'); + const image = AssetImage('assets/avatar.png'); + + final iconInspection = _inspectStyle({ + 'v': 1, + 'type': 'icon', + 'icon': 'home', + }, resolveIcon: (name) => name == 'home' ? icon : null); + final imageInspection = _inspectStyle({ + 'v': 1, + 'type': 'image', + 'image': 'avatar', + }, resolveImage: (name) => name == 'avatar' ? image : null); + + expect( + iconInspection.terms.singleWhere((term) => term.property == 'icon'), + isA().having( + (term) => term.literalValue, + 'literal value', + 'home', + ), + ); + expect( + imageInspection.terms.singleWhere((term) => term.property == 'image'), + isA().having( + (term) => term.literalValue, + 'literal value', + 'avatar', + ), + ); + }); + + test('reports merge directives without flattening them into terms', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'flex', + 'spacing': { + r'$merge': [4], + 'apply': [ + {'op': 'number_multiply', 'factor': 2}, + ], + }, + }); + + expect(inspection.terms, hasLength(1)); + expect(inspection.terms.single.property, 'spacing'); + expect(inspection.terms.single.jsonPointer, r'/spacing/$merge/0'); + expect(inspection.directives, hasLength(1)); + expect( + inspection.directives.single, + isA() + .having((directive) => directive.property, 'property', 'spacing') + .having( + (directive) => directive.jsonPointer, + 'pointer', + '/spacing/apply/0', + ) + .having((directive) => directive.op, 'operation', 'number_multiply') + .having((directive) => directive.parameters, 'parameters', { + 'factor': 2, + }), + ); + expect( + () => inspection.directives.single.parameters['factor'] = 3, + throwsUnsupportedError, + ); + }); + + test('reports breakpoint selector tokens at their exact pointers', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'variants': [ + { + 'kind': 'context_breakpoint', + 'token': 'breakpoint.sidebar', + 'style': { + 'type': 'box', + 'decoration': {'color': '#112233'}, + }, + }, + ], + }); + + expect(inspection.tokenOccurrences, hasLength(1)); + expect( + inspection.tokenOccurrences.single, + isA() + .having((token) => token.kind, 'kind', 'breakpoints') + .having((token) => token.name, 'name', 'breakpoint.sidebar') + .having( + (token) => token.jsonPointer, + 'pointer', + '/variants/0/token', + ), + ); + }); + + test('uses schema context for same-name cross-kind token references', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'text', + 'style': { + 'color': {r'$token': 'shared'}, + 'fontSize': {r'$token': 'shared'}, + }, + }); + + final occurrences = { + for (final token in inspection.tokenOccurrences) + token.jsonPointer: token.kind, + }; + expect(occurrences, { + r'/style/color/$token': 'colors', + r'/style/fontSize/$token': 'spaces', + }); + }); + + test('uses parent context for colliding property names', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'padding': { + 'top': {r'$token': 'shared'}, + }, + 'decoration': { + 'border': { + 'top': {r'$token': 'shared'}, + }, + }, + }); + + final occurrences = { + for (final token in inspection.tokenOccurrences) + token.jsonPointer: token.kind, + }; + expect(occurrences, { + r'/padding/top/$token': 'spaces', + r'/decoration/border/top/$token': 'borders', + }); + }); + + test('distinguishes numeric radius fields from radius tokens', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'decoration': { + 'color': {r'$token': 'shared'}, + 'gradient': { + 'kind': 'radial', + 'center': 'center', + 'radius': {r'$token': 'shared'}, + 'colors': ['#FF0000', '#0000FF'], + }, + }, + }); + + final radius = inspection.tokenOccurrences.singleWhere( + (token) => token.jsonPointer == r'/decoration/gradient/radius/$token', + ); + expect(radius.kind, 'spaces'); + }); + + test('selector fields are defensively copied', () { + final nested = {'kind': 'context_web'}; + final fields = { + 'kind': 'context_not', + 'variant': nested, + }; + final selector = MixProtocolSelectorContext( + kind: 'context_not', + value: 'context_web', + jsonPointer: '/variants/0', + fields: fields, + ); + + fields['kind'] = 'changed'; + nested['kind'] = 'changed'; + + expect(selector.fields['kind'], 'context_not'); + expect( + (selector.fields['variant'] as Map)['kind'], + 'context_web', + ); + expect(() => selector.fields['kind'] = 'changed', throwsUnsupportedError); + expect( + () => (selector.fields['variant'] as Map)['kind'] = + 'changed', + throwsUnsupportedError, + ); + }); + test('rejects invalid protocol data instead of inspecting guesses', () { final result = inspectStyleDocument({'v': 1, 'type': 'not_a_style'}); @@ -105,3 +292,20 @@ void main() { }); }); } + +MixProtocolStyleInspection _inspectStyle( + Object? payload, { + MixProtocolIconResolver? resolveIcon, + MixProtocolImageResolver? resolveImage, +}) { + return switch (inspectStyleDocument( + payload, + resolveIcon: resolveIcon, + resolveImage: resolveImage, + )) { + MixProtocolSuccess(:final value) => value, + MixProtocolFailure(:final errors) => fail( + '$errors', + ), + }; +} From dbdc4d14a43fca1a6d646cec032fd7cee3083a64 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 14:49:54 -0400 Subject: [PATCH 4/6] fix(mix): isolate widget state style overrides --- .../widget_state_style_override.dart | 2 +- packages/mix/lib/src/core/style_builder.dart | 9 +- packages/mix/lib/src/variants/variant.dart | 4 + .../widget_state_style_override_test.dart | 92 ++++++++++++++++++- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/packages/mix/lib/src/core/providers/widget_state_style_override.dart b/packages/mix/lib/src/core/providers/widget_state_style_override.dart index 0e29e2928..532c6ae63 100644 --- a/packages/mix/lib/src/core/providers/widget_state_style_override.dart +++ b/packages/mix/lib/src/core/providers/widget_state_style_override.dart @@ -6,7 +6,7 @@ import 'package:flutter/widgets.dart'; /// This is intended for tooling and tests that need to render a component in a /// specific visual state without changing the component's interaction state. /// When present, the override takes precedence over explicit controllers and -/// ordinary [WidgetStateProvider] scopes for [StyleBuilder] resolution. +/// ordinary [WidgetStateProvider] scopes when resolving widget-state variants. class WidgetStateStyleOverride extends InheritedWidget { WidgetStateStyleOverride({ super.key, diff --git a/packages/mix/lib/src/core/style_builder.dart b/packages/mix/lib/src/core/style_builder.dart index 9dcd8b5a9..ca5fab0ee 100644 --- a/packages/mix/lib/src/core/style_builder.dart +++ b/packages/mix/lib/src/core/style_builder.dart @@ -6,7 +6,6 @@ import 'internal/mix_interaction_detector.dart'; import 'providers/style_provider.dart'; import 'providers/style_spec_provider.dart'; import 'providers/widget_state_provider.dart'; -import 'providers/widget_state_style_override.dart'; import 'spec.dart'; import 'style.dart'; import 'style_spec.dart'; @@ -125,7 +124,6 @@ class _StyleBuilderState> extends State> @override Widget build(BuildContext context) { final style = _buildStyle(context); - final stateOverride = WidgetStateStyleOverride.maybeOf(context); // Calculate interactivity need early final needsToTrackWidgetState = @@ -144,12 +142,7 @@ class _StyleBuilderState> extends State> }, ); - if (stateOverride != null) { - current = WidgetStateProvider( - states: stateOverride.states, - child: current, - ); - } else if (needsToTrackWidgetState && !alreadyHasWidgetStateScope) { + if (needsToTrackWidgetState && !alreadyHasWidgetStateScope) { // If we need interactivity and no MixWidgetStateModel is present, // wrap in MixInteractionDetector current = MixInteractionDetector(controller: _controller, child: current); diff --git a/packages/mix/lib/src/variants/variant.dart b/packages/mix/lib/src/variants/variant.dart index f30270f79..67150f42b 100644 --- a/packages/mix/lib/src/variants/variant.dart +++ b/packages/mix/lib/src/variants/variant.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../core/breakpoint.dart'; import '../core/providers/widget_state_provider.dart'; +import '../core/providers/widget_state_style_override.dart'; import '../core/spec.dart'; import '../core/style.dart'; import '../theme/tokens/token_refs.dart'; @@ -242,6 +243,9 @@ final class WidgetStateVariant extends ContextVariant { WidgetStateVariant(this.state) : super('widget_state_${state.name}', (context) { + final override = WidgetStateStyleOverride.maybeOf(context); + if (override != null) return override.states.contains(state); + return WidgetStateProvider.hasStateOf(context, state); }); diff --git a/packages/mix/test/src/core/widget_state_style_override_test.dart b/packages/mix/test/src/core/widget_state_style_override_test.dart index b0aa54714..0fb3add03 100644 --- a/packages/mix/test/src/core/widget_state_style_override_test.dart +++ b/packages/mix/test/src/core/widget_state_style_override_test.dart @@ -8,14 +8,21 @@ void main() { .onHovered(.color(Colors.green)) .onPressed(.color(Colors.blue)); - Widget styledBox({WidgetStatesController? controller}) { + Widget styledBox({ + WidgetStatesController? controller, + ValueChanged? inspectContext, + }) { return StyleBuilder( style: style, controller: controller, - builder: (_, spec) => ColoredBox( - key: const Key('styled-box'), - color: (spec.decoration as BoxDecoration).color!, - ), + builder: (context, spec) { + inspectContext?.call(context); + + return ColoredBox( + key: const Key('styled-box'), + color: (spec.decoration as BoxDecoration).color!, + ); + }, ); } @@ -41,6 +48,81 @@ void main() { expect(resolvedColor(tester), Colors.green); }); + testWidgets('override does not replace the explicit controller state', ( + tester, + ) async { + final controller = WidgetStatesController({WidgetState.pressed}); + addTearDown(controller.dispose); + bool? isPressed; + bool? isHovered; + + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {WidgetState.hovered}, + child: styledBox( + controller: controller, + inspectContext: (context) { + isPressed = WidgetStateProvider.hasStateOf( + context, + WidgetState.pressed, + ); + isHovered = WidgetStateProvider.hasStateOf( + context, + WidgetState.hovered, + ); + }, + ), + ), + ), + ); + + expect(resolvedColor(tester), Colors.green); + expect(isPressed, isTrue); + expect(isHovered, isFalse); + }); + + testWidgets('override preserves the automatic interaction context', ( + tester, + ) async { + bool? hasPointerPositionNotifier; + + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {WidgetState.hovered}, + child: styledBox( + inspectContext: (context) { + hasPointerPositionNotifier = + PointerPosition.notifierOf(context) != null; + }, + ), + ), + ), + ); + + expect(resolvedColor(tester), Colors.green); + expect(hasPointerPositionNotifier, isTrue); + }); + + testWidgets('empty override suppresses active controller variants', ( + tester, + ) async { + final controller = WidgetStatesController({WidgetState.pressed}); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {}, + child: styledBox(controller: controller), + ), + ), + ); + + expect(resolvedColor(tester), Colors.red); + }); + testWidgets('override supports the scrolledUnder widget state', ( tester, ) async { From 1fb242791e82713967c2fdeb5adb301ba41501a0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 15:48:24 -0400 Subject: [PATCH 5/6] feat(mix_protocol): unify inspection evidence --- .../src/inspection/document_inspection.dart | 226 ++++++++------ .../test/document_inspection_test.dart | 284 ++++++++++++++++-- 2 files changed, 400 insertions(+), 110 deletions(-) diff --git a/packages/mix_protocol/lib/src/inspection/document_inspection.dart b/packages/mix_protocol/lib/src/inspection/document_inspection.dart index aca297668..893ab5a83 100644 --- a/packages/mix_protocol/lib/src/inspection/document_inspection.dart +++ b/packages/mix_protocol/lib/src/inspection/document_inspection.dart @@ -39,66 +39,95 @@ final class MixProtocolTokenOccurrence { final String jsonPointer; } -/// One declared leaf term in a style document. -final class MixProtocolStyleTerm { - MixProtocolStyleTerm({ +/// Evidence emitted while inspecting a declared style document. +sealed class MixProtocolStyleEvidence { + const MixProtocolStyleEvidence(); + + String get jsonPointer; + List get mergePath; +} + +/// One declared leaf value in a style document. +final class MixProtocolValueEvidence extends MixProtocolStyleEvidence { + MixProtocolValueEvidence({ required List propertyPath, required this.jsonPointer, required List selectors, - required this.mergeSource, + required List mergePath, required this.literalValue, required this.token, }) : propertyPath = List.unmodifiable(propertyPath), - selectors = List.unmodifiable(selectors); + selectors = List.unmodifiable(selectors), + mergePath = List.unmodifiable(mergePath); final List propertyPath; + @override final String jsonPointer; final List selectors; - final int? mergeSource; + @override + final List mergePath; final Object? literalValue; final MixProtocolTokenOccurrence? token; String get property => propertyPath.join('.'); } -/// One declared directive attached to a style term. -final class MixProtocolDirectiveInspection { - MixProtocolDirectiveInspection({ +/// One declared directive attached to a style value. +final class MixProtocolDirectiveEvidence extends MixProtocolStyleEvidence { + MixProtocolDirectiveEvidence({ required List propertyPath, required this.jsonPointer, required List selectors, - required this.mergeSource, + required List mergePath, required this.op, required Map parameters, }) : propertyPath = List.unmodifiable(propertyPath), selectors = List.unmodifiable(selectors), + mergePath = List.unmodifiable(mergePath), parameters = _immutableJson(parameters) as Map; final List propertyPath; + @override final String jsonPointer; final List selectors; - final int? mergeSource; + @override + final List mergePath; final String op; final Map parameters; String get property => propertyPath.join('.'); } +/// One declared selector and any token occurrences within that selector. +final class MixProtocolSelectorEvidence extends MixProtocolStyleEvidence { + MixProtocolSelectorEvidence({ + required this.jsonPointer, + required List selectors, + required List mergePath, + required this.selector, + required List tokenOccurrences, + }) : selectors = List.unmodifiable(selectors), + mergePath = List.unmodifiable(mergePath), + tokenOccurrences = List.unmodifiable(tokenOccurrences); + + @override + final String jsonPointer; + final List selectors; + @override + final List mergePath; + final MixProtocolSelectorContext selector; + final List tokenOccurrences; +} + /// Declared, pointer-addressable evidence from a valid style document. final class MixProtocolStyleInspection { MixProtocolStyleInspection({ required this.styleType, - required List terms, - required List tokenOccurrences, - required List directives, - }) : terms = List.unmodifiable(terms), - tokenOccurrences = List.unmodifiable(tokenOccurrences), - directives = List.unmodifiable(directives); + required List evidence, + }) : evidence = List.unmodifiable(evidence); final String styleType; - final List terms; - final List tokenOccurrences; - final List directives; + final List evidence; } /// One declared token entry and its decoded theme value. @@ -108,18 +137,19 @@ final class MixProtocolThemeTokenInspection { required this.name, required this.jsonPointer, required this.declaration, - required this.declaredValue, + required this.declaredWireValue, required List aliasChain, - required this.resolvedValue, - }) : aliasChain = List.unmodifiable(aliasChain); + required Object? resolvedWireValue, + }) : aliasChain = List.unmodifiable(aliasChain), + resolvedWireValue = _immutableJson(resolvedWireValue); final String kind; final String name; final String jsonPointer; final MixProtocolTokenDeclaration declaration; - final Object? declaredValue; + final Object? declaredWireValue; final List aliasChain; - final Object resolvedValue; + final Object? resolvedWireValue; } /// Declared token entries from a valid theme document. @@ -155,7 +185,7 @@ MixProtocolResult inspectStyleDocument( pointer: '', propertyPath: const [], selectors: const [], - mergeSource: null, + mergePath: const [], evidence: evidence, ); @@ -177,12 +207,12 @@ MixProtocolResult inspectThemeDocument( return MixProtocolFailure(errors, warnings: warnings); } final theme = (decoded as MixProtocolSuccess).value; - final document = payload as JsonMap; - final resolved = {}; - for (final entry in theme.tokens.entries) { - final reference = MixProtocolTokenReference.fromToken(entry.key); - resolved['${reference.kind}/${reference.name}'] = entry.value; + final encoded = mixProtocol.encodeTheme(theme); + if (encoded case MixProtocolFailure(:final errors)) { + return MixProtocolFailure(errors, warnings: decoded.warnings); } + final document = payload as JsonMap; + final resolvedDocument = (encoded as MixProtocolSuccess).value; final tokens = []; final kinds = document.keys.where((key) => key != 'v' && key != 'type').toList() @@ -201,11 +231,11 @@ MixProtocolResult inspectThemeDocument( declaration: alias == null ? MixProtocolTokenDeclaration.direct : MixProtocolTokenDeclaration.alias, - declaredValue: _immutableJson(value), + declaredWireValue: _immutableJson(value), aliasChain: alias == null ? const [] : _aliasChain(name, declarations), - resolvedValue: resolved['$kind/$name']!, + resolvedWireValue: (resolvedDocument[kind]! as JsonMap)[name], ), ); } @@ -225,25 +255,15 @@ final class _StyleInspectionEvidence { } final referencesByName = >{}; - final terms = []; - final tokenOccurrences = []; - final directives = []; + final evidence = []; MixProtocolStyleInspection build(String styleType) { - terms.sort((left, right) => left.jsonPointer.compareTo(right.jsonPointer)); - tokenOccurrences.sort( - (left, right) => left.jsonPointer.compareTo(right.jsonPointer), - ); - directives.sort( - (left, right) => left.jsonPointer.compareTo(right.jsonPointer), + evidence.sort( + (left, right) => + _compareJsonPointers(left.jsonPointer, right.jsonPointer), ); - return MixProtocolStyleInspection( - styleType: styleType, - terms: terms, - tokenOccurrences: tokenOccurrences, - directives: directives, - ); + return MixProtocolStyleInspection(styleType: styleType, evidence: evidence); } } @@ -252,7 +272,7 @@ void _walkStyle( required String pointer, required List propertyPath, required List selectors, - required int? mergeSource, + required List mergePath, required _StyleInspectionEvidence evidence, }) { if (value is JsonMap) { @@ -268,13 +288,12 @@ void _walkStyle( name: tokenName, jsonPointer: '$pointer/\$token', ); - evidence.tokenOccurrences.add(occurrence); - evidence.terms.add( - MixProtocolStyleTerm( + evidence.evidence.add( + MixProtocolValueEvidence( propertyPath: propertyPath, jsonPointer: pointer, selectors: selectors, - mergeSource: mergeSource, + mergePath: mergePath, literalValue: null, token: occurrence, ), @@ -284,8 +303,8 @@ void _walkStyle( pointer: pointer, propertyPath: propertyPath, selectors: selectors, - mergeSource: mergeSource, - directives: evidence.directives, + mergePath: mergePath, + evidence: evidence.evidence, ); return; @@ -298,7 +317,7 @@ void _walkStyle( pointer: '$pointer/\$merge/$index', propertyPath: propertyPath, selectors: selectors, - mergeSource: index, + mergePath: [...mergePath, index], evidence: evidence, ); } @@ -307,8 +326,8 @@ void _walkStyle( pointer: pointer, propertyPath: propertyPath, selectors: selectors, - mergeSource: mergeSource, - directives: evidence.directives, + mergePath: mergePath, + evidence: evidence.evidence, ); return; @@ -317,17 +336,24 @@ void _walkStyle( if (variants is List) { for (var index = 0; index < variants.length; index += 1) { final variant = variants[index]! as JsonMap; - final selector = _inspectSelector( - variant, - '$pointer/variants/$index', - evidence.tokenOccurrences, + final selectorPointer = '$pointer/variants/$index'; + final selectorInspection = _inspectSelector(variant, selectorPointer); + final selector = selectorInspection.selector; + evidence.evidence.add( + MixProtocolSelectorEvidence( + jsonPointer: selectorPointer, + selectors: selectors, + mergePath: mergePath, + selector: selector, + tokenOccurrences: selectorInspection.tokenOccurrences, + ), ); _walkStyle( variant['style'], pointer: '$pointer/variants/$index/style', propertyPath: propertyPath, selectors: [...selectors, selector], - mergeSource: mergeSource, + mergePath: mergePath, evidence: evidence, ); } @@ -343,7 +369,7 @@ void _walkStyle( pointer: '$pointer/${_escape(key)}', propertyPath: [...propertyPath, key], selectors: selectors, - mergeSource: mergeSource, + mergePath: mergePath, evidence: evidence, ); } @@ -357,19 +383,19 @@ void _walkStyle( pointer: '$pointer/$index', propertyPath: propertyPath, selectors: selectors, - mergeSource: mergeSource, + mergePath: mergePath, evidence: evidence, ); } return; } - evidence.terms.add( - MixProtocolStyleTerm( + evidence.evidence.add( + MixProtocolValueEvidence( propertyPath: propertyPath, jsonPointer: pointer, selectors: selectors, - mergeSource: mergeSource, + mergePath: mergePath, literalValue: value, token: null, ), @@ -381,20 +407,20 @@ void _collectApplyDirectives( required String pointer, required List propertyPath, required List selectors, - required int? mergeSource, - required List directives, + required List mergePath, + required List evidence, }) { final apply = term['apply']; if (apply is! List) return; for (var index = 0; index < apply.length; index += 1) { final wire = apply[index]! as JsonMap; - directives.add( - MixProtocolDirectiveInspection( + evidence.add( + MixProtocolDirectiveEvidence( propertyPath: propertyPath, jsonPointer: '$pointer/apply/$index', selectors: selectors, - mergeSource: mergeSource, + mergePath: mergePath, op: wire['op']! as String, parameters: { for (final entry in wire.entries) @@ -405,16 +431,19 @@ void _collectApplyDirectives( } } -MixProtocolSelectorContext _inspectSelector( - JsonMap variant, - String pointer, +({ + MixProtocolSelectorContext selector, List tokenOccurrences, -) { +}) +_inspectSelector(JsonMap variant, String pointer) { + final tokenOccurrences = []; _collectSelectorTokenOccurrences(variant, pointer, tokenOccurrences); - final fields = { - for (final entry in variant.entries) - if (entry.key != 'style') entry.key: _immutableJson(entry.value), - }; + final fields = + _immutableJson({ + for (final entry in variant.entries) + if (entry.key != 'style') entry.key: _immutableJson(entry.value), + })! + as Map; final kind = fields['kind']! as String; const valueKeys = [ 'state', @@ -430,11 +459,14 @@ MixProtocolSelectorContext _inspectSelector( .map((key) => fields[key].toString()) .firstOrNull; - return MixProtocolSelectorContext( - kind: kind, - value: selected ?? jsonEncode(fields), - jsonPointer: pointer, - fields: fields, + return ( + selector: MixProtocolSelectorContext( + kind: kind, + value: selected ?? jsonEncode(fields), + jsonPointer: pointer, + fields: fields, + ), + tokenOccurrences: tokenOccurrences, ); } @@ -512,8 +544,8 @@ const _cornerProperties = { 'bottomEnd', }; -/// Schema-defined token kinds for property names whose runtime references can -/// be ambiguous because multiple token classes share the same declared name. +/// This table intentionally mirrors the supported wire properties. A separate +/// redesign can derive inference directly from the codecs without changing it. const _tokenKindByProperty = { 'color': 'colors', 'backgroundColor': 'colors', @@ -606,3 +638,21 @@ Object? _immutableJson(Object? value) { String _escape(String value) => value.replaceAll('~', '~0').replaceAll('/', '~1'); + +int _compareJsonPointers(String left, String right) { + final leftSegments = left.split('/').skip(1).toList(growable: false); + final rightSegments = right.split('/').skip(1).toList(growable: false); + final commonLength = leftSegments.length < rightSegments.length + ? leftSegments.length + : rightSegments.length; + for (var index = 0; index < commonLength; index += 1) { + final leftIndex = int.tryParse(leftSegments[index]); + final rightIndex = int.tryParse(rightSegments[index]); + final comparison = leftIndex != null && rightIndex != null + ? leftIndex.compareTo(rightIndex) + : leftSegments[index].compareTo(rightSegments[index]); + if (comparison != 0) return comparison; + } + + return leftSegments.length.compareTo(rightSegments.length); +} diff --git a/packages/mix_protocol/test/document_inspection_test.dart b/packages/mix_protocol/test/document_inspection_test.dart index b7f942031..e8ac07896 100644 --- a/packages/mix_protocol/test/document_inspection_test.dart +++ b/packages/mix_protocol/test/document_inspection_test.dart @@ -29,34 +29,34 @@ void main() { }); expect( - inspection.terms, + inspection.values, contains( - isA() + isA() .having((term) => term.property, 'property', 'padding.top') .having( (term) => term.jsonPointer, 'pointer', r'/padding/$merge/0/top', ) - .having((term) => term.mergeSource, 'merge source', 0) + .having((term) => term.mergePath, 'merge path', [0]) .having((term) => term.literalValue, 'literal', 4), ), ); - final left = inspection.terms.singleWhere( + final left = inspection.values.singleWhere( (term) => term.property == 'padding.left', ); - expect(left.mergeSource, 1); + expect(left.mergePath, [1]); expect(left.token?.kind, 'spaces'); expect(left.token?.name, 'space.pad'); expect(left.token?.jsonPointer, r'/padding/$merge/1/left/$token'); - final hovered = inspection.terms.singleWhere( + final hovered = inspection.values.singleWhere( (term) => term.property == 'spacing', ); expect(hovered.selectors.single.kind, 'widget_state'); expect(hovered.selectors.single.value, 'hovered'); expect(hovered.token?.kind, 'spaces'); - expect(inspection.tokenOccurrences, hasLength(2)); + expect(inspection.valueTokens, hasLength(2)); }); test('accepts strict per-call icon and image resolvers', () { @@ -75,16 +75,16 @@ void main() { }, resolveImage: (name) => name == 'avatar' ? image : null); expect( - iconInspection.terms.singleWhere((term) => term.property == 'icon'), - isA().having( + iconInspection.values.singleWhere((term) => term.property == 'icon'), + isA().having( (term) => term.literalValue, 'literal value', 'home', ), ); expect( - imageInspection.terms.singleWhere((term) => term.property == 'image'), - isA().having( + imageInspection.values.singleWhere((term) => term.property == 'image'), + isA().having( (term) => term.literalValue, 'literal value', 'avatar', @@ -104,13 +104,13 @@ void main() { }, }); - expect(inspection.terms, hasLength(1)); - expect(inspection.terms.single.property, 'spacing'); - expect(inspection.terms.single.jsonPointer, r'/spacing/$merge/0'); + expect(inspection.values, hasLength(1)); + expect(inspection.values.single.property, 'spacing'); + expect(inspection.values.single.jsonPointer, r'/spacing/$merge/0'); expect(inspection.directives, hasLength(1)); expect( inspection.directives.single, - isA() + isA() .having((directive) => directive.property, 'property', 'spacing') .having( (directive) => directive.jsonPointer, @@ -144,9 +144,9 @@ void main() { ], }); - expect(inspection.tokenOccurrences, hasLength(1)); + expect(inspection.selectors.single.tokenOccurrences, hasLength(1)); expect( - inspection.tokenOccurrences.single, + inspection.selectors.single.tokenOccurrences.single, isA() .having((token) => token.kind, 'kind', 'breakpoints') .having((token) => token.name, 'name', 'breakpoint.sidebar') @@ -158,6 +158,114 @@ void main() { ); }); + test('emits empty selector evidence with complete selector context', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'variants': [ + { + 'kind': 'widget_state', + 'state': 'hovered', + 'style': { + 'type': 'box', + 'variants': [ + { + 'kind': 'widget_state', + 'state': 'focused', + 'style': {'type': 'box'}, + }, + ], + }, + }, + ], + }); + + expect(inspection.selectors, hasLength(2)); + final focused = inspection.selectors.last; + expect(focused.selectors.single.value, 'hovered'); + expect(focused.selector.value, 'focused'); + expect(focused.mergePath, isEmpty); + expect(inspection.values, isEmpty); + }); + + test( + 'preserves nested merge ancestry and sorts array indices numerically', + () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'flex', + 'spacing': { + r'$merge': [ + for (var index = 0; index < 12; index += 1) + if (index == 1) + { + r'$merge': [0, 1, 2], + 'apply': [ + {'op': 'number_multiply', 'factor': 2}, + ], + } + else + index, + ], + }, + }); + + expect(inspection.values.map((value) => value.mergePath), [ + [0], + [1, 0], + [1, 1], + [1, 2], + [2], + [3], + [4], + [5], + [6], + [7], + [8], + [9], + [10], + [11], + ]); + expect(inspection.directives.single.mergePath, [1]); + }, + ); + + test('canonicalizes reordered compound and nested selector fields', () { + final left = _selectorFrom({ + 'kind': 'context_not', + 'variant': { + 'kind': 'context_not', + 'variant': {'kind': 'context_platform', 'platform': 'android'}, + }, + }); + final right = _selectorFrom({ + 'variant': { + 'variant': {'platform': 'android', 'kind': 'context_platform'}, + 'kind': 'context_not', + }, + 'kind': 'context_not', + }); + + expect(left.fields, right.fields); + expect(left.value, right.value); + expect(left.key, right.key); + }); + + test('canonicalizes reordered breakpoint selector fields', () { + final left = _selectorFrom({ + 'kind': 'context_breakpoint', + 'token': 'breakpoint.sidebar', + }); + final right = _selectorFrom({ + 'token': 'breakpoint.sidebar', + 'kind': 'context_breakpoint', + }); + + expect(left.fields, right.fields); + expect(left.value, right.value); + expect(left.key, right.key); + }); + test('uses schema context for same-name cross-kind token references', () { final inspection = _inspectStyle({ 'v': 1, @@ -169,7 +277,7 @@ void main() { }); final occurrences = { - for (final token in inspection.tokenOccurrences) + for (final token in inspection.valueTokens) token.jsonPointer: token.kind, }; expect(occurrences, { @@ -193,7 +301,7 @@ void main() { }); final occurrences = { - for (final token in inspection.tokenOccurrences) + for (final token in inspection.valueTokens) token.jsonPointer: token.kind, }; expect(occurrences, { @@ -217,7 +325,7 @@ void main() { }, }); - final radius = inspection.tokenOccurrences.singleWhere( + final radius = inspection.valueTokens.singleWhere( (token) => token.jsonPointer == r'/decoration/gradient/radius/$token', ); expect(radius.kind, 'spaces'); @@ -285,14 +393,131 @@ void main() { ); expect(direct.declaration, MixProtocolTokenDeclaration.direct); expect(direct.jsonPointer, '/colors/color.base'); - expect(direct.resolvedValue, const Color(0xFF112233)); + expect(direct.declaredWireValue, '#112233'); + expect(direct.resolvedWireValue, '#112233'); expect(alias.declaration, MixProtocolTokenDeclaration.alias); expect(alias.aliasChain, ['color.deep', 'color.alias', 'color.base']); - expect(alias.resolvedValue, const Color(0xFF112233)); + expect(alias.declaredWireValue, {r'$token': 'color.alias'}); + expect(alias.resolvedWireValue, '#112233'); + }); + + test('reports canonical immutable wire values for every token kind', () { + final payload = { + 'v': 1, + 'type': 'theme', + 'colors': {'base': '#101820'}, + 'spaces': {'base': 8}, + 'doubles': {'base': 0.64}, + 'radii': {'base': 12}, + 'textStyles': { + 'base': {'fontSize': 14, 'height': 1.4}, + }, + 'shadows': { + 'base': [ + { + 'color': '#33000000', + 'offset': {'x': 0, 'y': 1}, + 'blurRadius': 2, + }, + ], + }, + 'boxShadows': { + 'base': [ + { + 'color': '#33000000', + 'offset': {'x': 0, 'y': 2}, + 'blurRadius': 8, + 'spreadRadius': 1, + }, + ], + }, + 'borders': { + 'base': {'color': '#008577', 'width': 2, 'style': 'solid'}, + }, + 'fontWeights': {'base': 'w700'}, + 'breakpoints': { + 'base': {'minWidth': 960}, + }, + 'durations': {'base': 120}, + }; + for (final entry in payload.entries.toList()) { + if (entry.key == 'v' || entry.key == 'type') continue; + final declarations = Map.from(entry.value! as Map); + payload[entry.key] = declarations; + declarations['alias'] = { + r'$token': 'base', + if (entry.key == 'spaces') 'kind': 'space', + if (entry.key == 'doubles') 'kind': 'double', + }; + declarations['deep'] = { + r'$token': 'alias', + if (entry.key == 'spaces') 'kind': 'space', + if (entry.key == 'doubles') 'kind': 'double', + }; + } + + final inspection = switch (inspectThemeDocument(payload)) { + MixProtocolSuccess(:final value) => value, + MixProtocolFailure(:final errors) => fail( + '$errors', + ), + }; + + for (final kind in payload.keys.where( + (key) => key != 'v' && key != 'type', + )) { + final direct = inspection.tokens.singleWhere( + (token) => token.kind == kind && token.name == 'base', + ); + final alias = inspection.tokens.singleWhere( + (token) => token.kind == kind && token.name == 'deep', + ); + expect(alias.aliasChain, ['deep', 'alias', 'base']); + expect(alias.resolvedWireValue, direct.resolvedWireValue); + expect(alias.declaredWireValue, containsPair(r'$token', 'alias')); + } + + final textStyle = inspection.tokens.singleWhere( + (token) => token.kind == 'textStyles' && token.name == 'base', + ); + final shadow = inspection.tokens.singleWhere( + (token) => token.kind == 'shadows' && token.name == 'base', + ); + expect( + () => + (textStyle.resolvedWireValue! as Map)['fontSize'] = + 16, + throwsUnsupportedError, + ); + expect( + () => (shadow.resolvedWireValue! as List).add(null), + throwsUnsupportedError, + ); + expect( + () => + ((shadow.resolvedWireValue! as List).single + as Map)['blurRadius'] = + 4, + throwsUnsupportedError, + ); }); }); } +extension on MixProtocolStyleInspection { + Iterable get values => + evidence.whereType(); + + Iterable get directives => + evidence.whereType(); + + Iterable get selectors => + evidence.whereType(); + + Iterable get valueTokens => + values.map((value) => value.token).nonNulls; +} + MixProtocolStyleInspection _inspectStyle( Object? payload, { MixProtocolIconResolver? resolveIcon, @@ -309,3 +534,18 @@ MixProtocolStyleInspection _inspectStyle( ), }; } + +MixProtocolSelectorContext _selectorFrom(Map selector) { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'variants': [ + { + ...selector, + 'style': {'type': 'box'}, + }, + ], + }); + + return inspection.selectors.single.selector; +} From b40cbde4a39f5a2b9b71c7f9bd6e7c4ee5df73b8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 16:58:20 -0400 Subject: [PATCH 6/6] fix: harden tooling inspection and CI --- .github/workflows/test.yml | 6 +- .../core/providers/widget_state_provider.dart | 4 +- .../src/inspection/document_inspection.dart | 71 ++++++++++------ .../test/document_inspection_test.dart | 81 +++++++++++++++++++ 4 files changed, 133 insertions(+), 29 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d12fa1ad..058f01c61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,8 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} with: flutter-version: 'stable' - run-dcm: true + # DCM requires CI credentials; the reusable workflow still runs Dart analysis. + run-dcm: false melos-commands: | - melos run analyze + melos run schema:inventory + melos run analyze:dart diff --git a/packages/mix/lib/src/core/providers/widget_state_provider.dart b/packages/mix/lib/src/core/providers/widget_state_provider.dart index 1d49159da..900254630 100644 --- a/packages/mix/lib/src/core/providers/widget_state_provider.dart +++ b/packages/mix/lib/src/core/providers/widget_state_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/widgets.dart' show BuildContext, InheritedModel, WidgetState, WidgetStatesController; @@ -60,8 +61,7 @@ class WidgetStateProvider extends InheritedModel { @override bool updateShouldNotify(WidgetStateProvider oldWidget) { - return oldWidget.states.length != states.length || - !oldWidget.states.containsAll(states); + return !setEquals(oldWidget.states, states); } @override diff --git a/packages/mix_protocol/lib/src/inspection/document_inspection.dart b/packages/mix_protocol/lib/src/inspection/document_inspection.dart index 893ab5a83..c0b932ad8 100644 --- a/packages/mix_protocol/lib/src/inspection/document_inspection.dart +++ b/packages/mix_protocol/lib/src/inspection/document_inspection.dart @@ -137,10 +137,11 @@ final class MixProtocolThemeTokenInspection { required this.name, required this.jsonPointer, required this.declaration, - required this.declaredWireValue, + required Object? declaredWireValue, required List aliasChain, required Object? resolvedWireValue, - }) : aliasChain = List.unmodifiable(aliasChain), + }) : declaredWireValue = _immutableJson(declaredWireValue), + aliasChain = List.unmodifiable(aliasChain), resolvedWireValue = _immutableJson(resolvedWireValue); final String kind; @@ -178,19 +179,19 @@ MixProtocolResult inspectStyleDocument( return MixProtocolFailure(errors, warnings: warnings); } final style = (decoded as MixProtocolSuccess).value; - final document = payload as JsonMap; - final evidence = _StyleInspectionEvidence(tokenReferencesOf(style)); + final document = _immutableJson(payload) as JsonMap; + final state = _StyleInspectionState(tokenReferencesOf(style)); _walkStyle( document, pointer: '', propertyPath: const [], selectors: const [], mergePath: const [], - evidence: evidence, + state: state, ); return MixProtocolSuccess( - evidence.build(document['type']! as String), + state.build(document['type']! as String), warnings: decoded.warnings, ); } @@ -211,7 +212,7 @@ MixProtocolResult inspectThemeDocument( if (encoded case MixProtocolFailure(:final errors)) { return MixProtocolFailure(errors, warnings: decoded.warnings); } - final document = payload as JsonMap; + final document = _immutableJson(payload) as JsonMap; final resolvedDocument = (encoded as MixProtocolSuccess).value; final tokens = []; final kinds = @@ -231,7 +232,7 @@ MixProtocolResult inspectThemeDocument( declaration: alias == null ? MixProtocolTokenDeclaration.direct : MixProtocolTokenDeclaration.alias, - declaredWireValue: _immutableJson(value), + declaredWireValue: value, aliasChain: alias == null ? const [] : _aliasChain(name, declarations), @@ -247,8 +248,8 @@ MixProtocolResult inspectThemeDocument( ); } -final class _StyleInspectionEvidence { - _StyleInspectionEvidence(Iterable references) { +final class _StyleInspectionState { + _StyleInspectionState(Iterable references) { for (final reference in references) { referencesByName.putIfAbsent(reference.name, () => []).add(reference); } @@ -273,7 +274,7 @@ void _walkStyle( required List propertyPath, required List selectors, required List mergePath, - required _StyleInspectionEvidence evidence, + required _StyleInspectionState state, }) { if (value is JsonMap) { final tokenName = value[r'$token']; @@ -283,12 +284,12 @@ void _walkStyle( value, tokenName, propertyPath, - evidence.referencesByName, + state.referencesByName, ), name: tokenName, jsonPointer: '$pointer/\$token', ); - evidence.evidence.add( + state.evidence.add( MixProtocolValueEvidence( propertyPath: propertyPath, jsonPointer: pointer, @@ -304,7 +305,7 @@ void _walkStyle( propertyPath: propertyPath, selectors: selectors, mergePath: mergePath, - evidence: evidence.evidence, + evidence: state.evidence, ); return; @@ -318,7 +319,7 @@ void _walkStyle( propertyPath: propertyPath, selectors: selectors, mergePath: [...mergePath, index], - evidence: evidence, + state: state, ); } _collectApplyDirectives( @@ -327,7 +328,7 @@ void _walkStyle( propertyPath: propertyPath, selectors: selectors, mergePath: mergePath, - evidence: evidence.evidence, + evidence: state.evidence, ); return; @@ -339,7 +340,7 @@ void _walkStyle( final selectorPointer = '$pointer/variants/$index'; final selectorInspection = _inspectSelector(variant, selectorPointer); final selector = selectorInspection.selector; - evidence.evidence.add( + state.evidence.add( MixProtocolSelectorEvidence( jsonPointer: selectorPointer, selectors: selectors, @@ -354,7 +355,7 @@ void _walkStyle( propertyPath: propertyPath, selectors: [...selectors, selector], mergePath: mergePath, - evidence: evidence, + state: state, ); } } @@ -370,7 +371,7 @@ void _walkStyle( propertyPath: [...propertyPath, key], selectors: selectors, mergePath: mergePath, - evidence: evidence, + state: state, ); } @@ -384,13 +385,13 @@ void _walkStyle( propertyPath: propertyPath, selectors: selectors, mergePath: mergePath, - evidence: evidence, + state: state, ); } return; } - evidence.evidence.add( + state.evidence.add( MixProtocolValueEvidence( propertyPath: propertyPath, jsonPointer: pointer, @@ -441,7 +442,7 @@ _inspectSelector(JsonMap variant, String pointer) { final fields = _immutableJson({ for (final entry in variant.entries) - if (entry.key != 'style') entry.key: _immutableJson(entry.value), + if (entry.key != 'style') entry.key: entry.value, })! as Map; final kind = fields['kind']! as String; @@ -462,7 +463,7 @@ _inspectSelector(JsonMap variant, String pointer) { return ( selector: MixProtocolSelectorContext( kind: kind, - value: selected ?? jsonEncode(fields), + value: selected ?? jsonEncode(_canonicalSelectorIdentity(fields)), jsonPointer: pointer, fields: fields, ), @@ -622,8 +623,9 @@ List _aliasChain(String name, JsonMap declarations) { } Object? _immutableJson(Object? value) { - if (value is JsonMap) { - final keys = value.keys.toList()..sort(); + if (value is Map) { + if (value.keys.any((key) => key is! String)) return value; + final keys = value.keys.cast().toList()..sort(); return Map.unmodifiable({ for (final key in keys) key: _immutableJson(value[key]), @@ -636,6 +638,25 @@ Object? _immutableJson(Object? value) { return value; } +Object? _canonicalSelectorIdentity(Object? value) { + if (value is num) { + final normalized = value.toDouble(); + + return normalized == 0 ? 0.0 : normalized; + } + if (value is JsonMap) { + return { + for (final entry in value.entries) + entry.key: _canonicalSelectorIdentity(entry.value), + }; + } + if (value is List) { + return value.map(_canonicalSelectorIdentity).toList(growable: false); + } + + return value; +} + String _escape(String value) => value.replaceAll('~', '~0').replaceAll('/', '~1'); diff --git a/packages/mix_protocol/test/document_inspection_test.dart b/packages/mix_protocol/test/document_inspection_test.dart index e8ac07896..930f7b313 100644 --- a/packages/mix_protocol/test/document_inspection_test.dart +++ b/packages/mix_protocol/test/document_inspection_test.dart @@ -266,6 +266,20 @@ void main() { expect(left.key, right.key); }); + test('canonicalizes numerically equivalent breakpoint bounds', () { + final integerBound = _selectorFrom({ + 'kind': 'context_breakpoint', + 'minWidth': 320, + }); + final doubleBound = _selectorFrom({ + 'kind': 'context_breakpoint', + 'minWidth': 320.0, + }); + + expect(integerBound.value, doubleBound.value); + expect(integerBound.key, doubleBound.key); + }); + test('uses schema context for same-name cross-kind token references', () { final inspection = _inspectStyle({ 'v': 1, @@ -360,6 +374,23 @@ void main() { ); }); + test('inspects nested generic string-keyed style maps', () { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'decoration': {'color': '#112233'}, + }); + + expect( + inspection.values.singleWhere( + (value) => value.jsonPointer == '/decoration/color', + ), + isA() + .having((value) => value.property, 'property', 'decoration.color') + .having((value) => value.literalValue, 'literal', '#112233'), + ); + }); + test('rejects invalid protocol data instead of inspecting guesses', () { final result = inspectStyleDocument({'v': 1, 'type': 'not_a_style'}); @@ -368,6 +399,56 @@ void main() { }); group('theme document inspection', () { + test('defensively copies declared wire values', () { + final nested = {'fontSize': 14}; + final declared = {'style': nested}; + final token = MixProtocolThemeTokenInspection( + kind: 'textStyles', + name: 'base', + jsonPointer: '/textStyles/base', + declaration: MixProtocolTokenDeclaration.direct, + declaredWireValue: declared, + aliasChain: const ['base'], + resolvedWireValue: null, + ); + + declared['style'] = null; + nested['fontSize'] = 16; + + expect(token.declaredWireValue, { + 'style': {'fontSize': 14}, + }); + expect( + () => + (token.declaredWireValue! as Map)['style'] = null, + throwsUnsupportedError, + ); + expect( + () => + ((token.declaredWireValue! as Map)['style']! + as Map)['fontSize'] = + 16, + throwsUnsupportedError, + ); + }); + + test('inspects nested generic string-keyed theme maps', () { + final result = inspectThemeDocument({ + 'v': 1, + 'type': 'theme', + 'colors': {'color.base': '#112233'}, + }); + final inspection = switch (result) { + MixProtocolSuccess(:final value) => value, + MixProtocolFailure(:final errors) => fail( + '$errors', + ), + }; + + expect(inspection.tokens.single.name, 'color.base'); + expect(inspection.tokens.single.declaredWireValue, '#112233'); + }); + test('reports direct values, aliases, chains, and resolved values', () { final result = inspectThemeDocument({ 'v': 1,