diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d12fa1adb..058f01c614 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/CHANGELOG.md b/packages/mix/CHANGELOG.md index 8c48c5c217..035c37b549 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 94e2af4af4..7460e2b399 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_provider.dart b/packages/mix/lib/src/core/providers/widget_state_provider.dart index 7d61f5c65c..9002546306 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,6 @@ +import 'package:flutter/foundation.dart' show setEquals; 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 +12,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 +29,39 @@ 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 !setEquals(oldWidget.states, 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/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 0000000000..532c6ae637 --- /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 when resolving widget-state variants. +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/variants/variant.dart b/packages/mix/lib/src/variants/variant.dart index f30270f799..67150f42b9 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 new file mode 100644 index 0000000000..0fb3add035 --- /dev/null +++ b/packages/mix/test/src/core/widget_state_style_override_test.dart @@ -0,0 +1,220 @@ +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, + ValueChanged? inspectContext, + }) { + return StyleBuilder( + style: style, + controller: controller, + builder: (context, spec) { + inspectContext?.call(context); + + return 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 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 { + 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 { + 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/test/src/specs/pressable/widget_state_controller_test.dart b/packages/mix/test/src/specs/pressable/widget_state_controller_test.dart index c7eae73299..c36bc6b881 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', ( diff --git a/packages/mix_protocol/CHANGELOG.md b/packages/mix_protocol/CHANGELOG.md index 6d0e805d76..4da6b261e4 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 b9a9673164..aeba8e1062 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 0000000000..c0b932ad84 --- /dev/null +++ b/packages/mix_protocol/lib/src/inspection/document_inspection.dart @@ -0,0 +1,679 @@ +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'; +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 = _immutableJson(fields) as Map; + + 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; +} + +/// 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 List mergePath, + required this.literalValue, + required this.token, + }) : propertyPath = List.unmodifiable(propertyPath), + selectors = List.unmodifiable(selectors), + mergePath = List.unmodifiable(mergePath); + + final List propertyPath; + @override + final String jsonPointer; + final List selectors; + @override + final List mergePath; + final Object? literalValue; + final MixProtocolTokenOccurrence? token; + + String get property => propertyPath.join('.'); +} + +/// One declared directive attached to a style value. +final class MixProtocolDirectiveEvidence extends MixProtocolStyleEvidence { + MixProtocolDirectiveEvidence({ + required List propertyPath, + required this.jsonPointer, + required List selectors, + 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; + @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 evidence, + }) : evidence = List.unmodifiable(evidence); + + final String styleType; + final List evidence; +} + +/// 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 Object? declaredWireValue, + required List aliasChain, + required Object? resolvedWireValue, + }) : declaredWireValue = _immutableJson(declaredWireValue), + aliasChain = List.unmodifiable(aliasChain), + resolvedWireValue = _immutableJson(resolvedWireValue); + + final String kind; + final String name; + final String jsonPointer; + final MixProtocolTokenDeclaration declaration; + final Object? declaredWireValue; + final List aliasChain; + final Object? resolvedWireValue; +} + +/// 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, { + 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 = _immutableJson(payload) as JsonMap; + final state = _StyleInspectionState(tokenReferencesOf(style)); + _walkStyle( + document, + pointer: '', + propertyPath: const [], + selectors: const [], + mergePath: const [], + state: state, + ); + + return MixProtocolSuccess( + state.build(document['type']! as String), + 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 encoded = mixProtocol.encodeTheme(theme); + if (encoded case MixProtocolFailure(:final errors)) { + return MixProtocolFailure(errors, warnings: decoded.warnings); + } + final document = _immutableJson(payload) as JsonMap; + final resolvedDocument = (encoded as MixProtocolSuccess).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, + declaredWireValue: value, + aliasChain: alias == null + ? const [] + : _aliasChain(name, declarations), + resolvedWireValue: (resolvedDocument[kind]! as JsonMap)[name], + ), + ); + } + } + + return MixProtocolSuccess( + MixProtocolThemeInspection(tokens: tokens), + warnings: decoded.warnings, + ); +} + +final class _StyleInspectionState { + _StyleInspectionState(Iterable references) { + for (final reference in references) { + referencesByName.putIfAbsent(reference.name, () => []).add(reference); + } + } + + final referencesByName = >{}; + final evidence = []; + + MixProtocolStyleInspection build(String styleType) { + evidence.sort( + (left, right) => + _compareJsonPointers(left.jsonPointer, right.jsonPointer), + ); + + return MixProtocolStyleInspection(styleType: styleType, evidence: evidence); + } +} + +void _walkStyle( + Object? value, { + required String pointer, + required List propertyPath, + required List selectors, + required List mergePath, + required _StyleInspectionState state, +}) { + if (value is JsonMap) { + final tokenName = value[r'$token']; + if (tokenName is String) { + final occurrence = MixProtocolTokenOccurrence( + kind: _tokenKind( + value, + tokenName, + propertyPath, + state.referencesByName, + ), + name: tokenName, + jsonPointer: '$pointer/\$token', + ); + state.evidence.add( + MixProtocolValueEvidence( + propertyPath: propertyPath, + jsonPointer: pointer, + selectors: selectors, + mergePath: mergePath, + literalValue: null, + token: occurrence, + ), + ); + _collectApplyDirectives( + value, + pointer: pointer, + propertyPath: propertyPath, + selectors: selectors, + mergePath: mergePath, + evidence: state.evidence, + ); + + 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, + mergePath: [...mergePath, index], + state: state, + ); + } + _collectApplyDirectives( + value, + pointer: pointer, + propertyPath: propertyPath, + selectors: selectors, + mergePath: mergePath, + evidence: state.evidence, + ); + + 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 selectorPointer = '$pointer/variants/$index'; + final selectorInspection = _inspectSelector(variant, selectorPointer); + final selector = selectorInspection.selector; + state.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], + mergePath: mergePath, + state: state, + ); + } + } + 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, + mergePath: mergePath, + state: state, + ); + } + + return; + } + if (value is List) { + for (var index = 0; index < value.length; index += 1) { + _walkStyle( + value[index], + pointer: '$pointer/$index', + propertyPath: propertyPath, + selectors: selectors, + mergePath: mergePath, + state: state, + ); + } + + return; + } + state.evidence.add( + MixProtocolValueEvidence( + propertyPath: propertyPath, + jsonPointer: pointer, + selectors: selectors, + mergePath: mergePath, + literalValue: value, + token: null, + ), + ); +} + +void _collectApplyDirectives( + JsonMap term, { + required String pointer, + required List propertyPath, + required List selectors, + 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; + evidence.add( + MixProtocolDirectiveEvidence( + propertyPath: propertyPath, + jsonPointer: '$pointer/apply/$index', + selectors: selectors, + mergePath: mergePath, + op: wire['op']! as String, + parameters: { + for (final entry in wire.entries) + if (entry.key != 'op') entry.key: entry.value, + }, + ), + ); + } +} + +({ + MixProtocolSelectorContext selector, + List tokenOccurrences, +}) +_inspectSelector(JsonMap variant, String pointer) { + final tokenOccurrences = []; + _collectSelectorTokenOccurrences(variant, pointer, tokenOccurrences); + final fields = + _immutableJson({ + for (final entry in variant.entries) + if (entry.key != 'style') entry.key: entry.value, + })! + as Map; + 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 ( + selector: MixProtocolSelectorContext( + kind: kind, + value: selected ?? jsonEncode(_canonicalSelectorIdentity(fields)), + jsonPointer: pointer, + fields: fields, + ), + tokenOccurrences: tokenOccurrences, + ); +} + +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, + List propertyPath, + Map> referencesByName, +) { + 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 []; + 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; + 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 _tokenKindByProperty[property]; +} + +const _edgeProperties = {'top', 'right', 'bottom', 'left', 'start', 'end'}; + +const _cornerProperties = { + 'topLeft', + 'topRight', + 'bottomLeft', + 'bottomRight', + 'topStart', + 'topEnd', + 'bottomStart', + 'bottomEnd', +}; + +/// 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', + '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', + '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 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]), + }); + } + if (value is List) { + return List.unmodifiable(value.map(_immutableJson)); + } + + 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'); + +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/lib/src/schema/common_codecs.dart b/packages/mix_protocol/lib/src/schema/common_codecs.dart index 28db9343dc..a9ee45dba3 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 3dc7995401..929d259a70 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 0000000000..930f7b3138 --- /dev/null +++ b/packages/mix_protocol/test/document_inspection_test.dart @@ -0,0 +1,632 @@ +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 inspection = _inspectStyle({ + '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'}, + }, + }, + ], + }); + + expect( + inspection.values, + contains( + isA() + .having((term) => term.property, 'property', 'padding.top') + .having( + (term) => term.jsonPointer, + 'pointer', + r'/padding/$merge/0/top', + ) + .having((term) => term.mergePath, 'merge path', [0]) + .having((term) => term.literalValue, 'literal', 4), + ), + ); + final left = inspection.values.singleWhere( + (term) => term.property == 'padding.left', + ); + 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.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.valueTokens, 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.values.singleWhere((term) => term.property == 'icon'), + isA().having( + (term) => term.literalValue, + 'literal value', + 'home', + ), + ); + expect( + imageInspection.values.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.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() + .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.selectors.single.tokenOccurrences, hasLength(1)); + expect( + inspection.selectors.single.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('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('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, + 'type': 'text', + 'style': { + 'color': {r'$token': 'shared'}, + 'fontSize': {r'$token': 'shared'}, + }, + }); + + final occurrences = { + for (final token in inspection.valueTokens) + 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.valueTokens) + 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.valueTokens.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('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'}); + + expect(result, isA>()); + }); + }); + + 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, + '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.declaredWireValue, '#112233'); + expect(direct.resolvedWireValue, '#112233'); + expect(alias.declaration, MixProtocolTokenDeclaration.alias); + expect(alias.aliasChain, ['color.deep', 'color.alias', 'color.base']); + 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, + MixProtocolImageResolver? resolveImage, +}) { + return switch (inspectStyleDocument( + payload, + resolveIcon: resolveIcon, + resolveImage: resolveImage, + )) { + MixProtocolSuccess(:final value) => value, + MixProtocolFailure(:final errors) => fail( + '$errors', + ), + }; +} + +MixProtocolSelectorContext _selectorFrom(Map selector) { + final inspection = _inspectStyle({ + 'v': 1, + 'type': 'box', + 'variants': [ + { + ...selector, + 'style': {'type': 'box'}, + }, + ], + }); + + return inspection.selectors.single.selector; +} diff --git a/scripts/exports.dart b/scripts/exports.dart index 5686c1b196..9ae028384c 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'], ),