From bf77662f19f9a3811b71c9dc4acdd9ad8cf7693b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 9 Jun 2026 20:28:02 -0400 Subject: [PATCH 01/11] feat(mix_schema): add ack-backed contract package --- melos.yaml | 2 + packages/mix_schema/CHANGELOG.md | 4 + packages/mix_schema/README.md | 17 + packages/mix_schema/REQUIREMENTS.md | 32 + packages/mix_schema/analysis_options.yaml | 1 + packages/mix_schema/lib/encode.dart | 45 ++ packages/mix_schema/lib/mix_schema.dart | 20 + .../lib/src/contract/mix_schema_contract.dart | 169 ++++++ .../lib/src/contract/mix_schema_limits.dart | 23 + .../lib/src/errors/mix_schema_error.dart | 204 +++++++ .../lib/src/errors/schema_error_mapper.dart | 112 ++++ .../mix_schema/lib/src/registry/registry.dart | 86 +++ .../src/registry/registry_value_codec.dart | 26 + .../lib/src/schema/animation_codec.dart | 103 ++++ .../lib/src/schema/box_styler_codec.dart | 112 ++++ .../lib/src/schema/common_codecs.dart | 384 ++++++++++++ .../lib/src/schema/flex_box_styler_codec.dart | 111 ++++ .../lib/src/schema/flex_styler_codec.dart | 105 ++++ .../lib/src/schema/icon_styler_codec.dart | 92 +++ .../lib/src/schema/image_styler_codec.dart | 107 ++++ .../lib/src/schema/modifier_codec.dart | 121 ++++ .../lib/src/schema/primitive_wire.dart | 55 ++ .../src/schema/stack_box_styler_codec.dart | 87 +++ .../lib/src/schema/stack_styler_codec.dart | 66 ++ .../lib/src/schema/styler_branch.dart | 22 + .../lib/src/schema/text_styler_codec.dart | 217 +++++++ .../lib/src/schema/variant_codec.dart | 571 ++++++++++++++++++ packages/mix_schema/pubspec.yaml | 25 + .../test/ack_alignment_smoke_test.dart | 35 ++ .../mix_schema/test/animation_codec_test.dart | 144 +++++ .../test/box_styler_codec_test.dart | 82 +++ .../test/box_variant_codec_test.dart | 47 ++ .../mix_schema/test/common_codecs_test.dart | 78 +++ .../mix_schema/test/encode_helpers_test.dart | 23 + .../mix_schema/test/error_mapper_test.dart | 130 ++++ packages/mix_schema/test/guard_test.dart | 43 ++ .../test/mix_schema_contract_test.dart | 50 ++ .../mix_schema/test/modifier_codec_test.dart | 96 +++ .../test/public_api_contract_test.dart | 33 + .../mix_schema/test/raw_throw_ban_test.dart | 24 + .../test/registry_builder_test.dart | 70 +++ .../test/registry_value_codec_test.dart | 62 ++ .../test/remaining_stylers_codec_test.dart | 196 ++++++ .../test/requirements_traceability_test.dart | 25 + .../test/schema_export_golden_test.dart | 26 + .../mix_schema/test/styler_branch_test.dart | 44 ++ .../test/text_styler_codec_test.dart | 94 +++ .../mix_schema/test/variant_codec_test.dart | 160 +++++ packages/mix_tailwinds/pubspec.yaml | 6 +- .../test/schema_payload_contract_test.dart | 31 + .../test/wire_literal_guard_test.dart | 20 + 51 files changed, 4437 insertions(+), 1 deletion(-) create mode 100644 packages/mix_schema/CHANGELOG.md create mode 100644 packages/mix_schema/README.md create mode 100644 packages/mix_schema/REQUIREMENTS.md create mode 100644 packages/mix_schema/analysis_options.yaml create mode 100644 packages/mix_schema/lib/encode.dart create mode 100644 packages/mix_schema/lib/mix_schema.dart create mode 100644 packages/mix_schema/lib/src/contract/mix_schema_contract.dart create mode 100644 packages/mix_schema/lib/src/contract/mix_schema_limits.dart create mode 100644 packages/mix_schema/lib/src/errors/mix_schema_error.dart create mode 100644 packages/mix_schema/lib/src/errors/schema_error_mapper.dart create mode 100644 packages/mix_schema/lib/src/registry/registry.dart create mode 100644 packages/mix_schema/lib/src/registry/registry_value_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/animation_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/box_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/common_codecs.dart create mode 100644 packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/flex_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/icon_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/image_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/modifier_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/primitive_wire.dart create mode 100644 packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/stack_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/styler_branch.dart create mode 100644 packages/mix_schema/lib/src/schema/text_styler_codec.dart create mode 100644 packages/mix_schema/lib/src/schema/variant_codec.dart create mode 100644 packages/mix_schema/pubspec.yaml create mode 100644 packages/mix_schema/test/ack_alignment_smoke_test.dart create mode 100644 packages/mix_schema/test/animation_codec_test.dart create mode 100644 packages/mix_schema/test/box_styler_codec_test.dart create mode 100644 packages/mix_schema/test/box_variant_codec_test.dart create mode 100644 packages/mix_schema/test/common_codecs_test.dart create mode 100644 packages/mix_schema/test/encode_helpers_test.dart create mode 100644 packages/mix_schema/test/error_mapper_test.dart create mode 100644 packages/mix_schema/test/guard_test.dart create mode 100644 packages/mix_schema/test/mix_schema_contract_test.dart create mode 100644 packages/mix_schema/test/modifier_codec_test.dart create mode 100644 packages/mix_schema/test/public_api_contract_test.dart create mode 100644 packages/mix_schema/test/raw_throw_ban_test.dart create mode 100644 packages/mix_schema/test/registry_builder_test.dart create mode 100644 packages/mix_schema/test/registry_value_codec_test.dart create mode 100644 packages/mix_schema/test/remaining_stylers_codec_test.dart create mode 100644 packages/mix_schema/test/requirements_traceability_test.dart create mode 100644 packages/mix_schema/test/schema_export_golden_test.dart create mode 100644 packages/mix_schema/test/styler_branch_test.dart create mode 100644 packages/mix_schema/test/text_styler_codec_test.dart create mode 100644 packages/mix_schema/test/variant_codec_test.dart create mode 100644 packages/mix_tailwinds/test/schema_payload_contract_test.dart create mode 100644 packages/mix_tailwinds/test/wire_literal_guard_test.dart diff --git a/melos.yaml b/melos.yaml index a504b7b111..41e8cac9dc 100644 --- a/melos.yaml +++ b/melos.yaml @@ -26,6 +26,8 @@ command: categories: flutter_projects: - packages/mix + - packages/mix_schema + - packages/mix_tailwinds - packages/mix/example - packages/mix_lint_test - packages/annotations diff --git a/packages/mix_schema/CHANGELOG.md b/packages/mix_schema/CHANGELOG.md new file mode 100644 index 0000000000..23432eacf3 --- /dev/null +++ b/packages/mix_schema/CHANGELOG.md @@ -0,0 +1,4 @@ +## 0.0.1 + +- Initial unpublished `mix_schema` package. +- Added Ack-pinned contract builder, public result/error model, payload limits, registries, schema export metadata, built-in styler codecs, modifiers, animation subset, and Tailwinds conformance guards. diff --git a/packages/mix_schema/README.md b/packages/mix_schema/README.md new file mode 100644 index 0000000000..a81b11ba27 --- /dev/null +++ b/packages/mix_schema/README.md @@ -0,0 +1,17 @@ +# mix_schema + +`mix_schema` is an Ack-backed contract package for JSON style payloads that decode into representable Mix stylers and encode representable stylers back to payloads. + +The package is schema-first: Ack owns validation, decode, encode, and JSON Schema export. `mix_schema` only adapts those Ack results into a stable public Mix contract surface. + +## Current Surface + +- Built-in styler branches: `box`, `text`, `flex`, `stack`, `icon`, `image`, `flex_box`, `stack_box`. +- Box variants use `Ack.lazy` for nested styles. +- Modifiers support opacity, blur, and default text style. +- Animation support is limited to named-curve `CurveAnimationConfig`; callbacks use the `animation_on_end` registry. +- App-owned identity values use registries for `IconData`, `ImageProvider`, animation callbacks, and context variant builders. + +Unsupported runtime values fail encode explicitly instead of being dropped. + +See `REQUIREMENTS.md` for the rule table and acceptance gate. diff --git a/packages/mix_schema/REQUIREMENTS.md b/packages/mix_schema/REQUIREMENTS.md new file mode 100644 index 0000000000..0a64ddcba3 --- /dev/null +++ b/packages/mix_schema/REQUIREMENTS.md @@ -0,0 +1,32 @@ +# mix_schema Requirements + +`mix_schema` is the Ack-owned contract between JSON style payload producers and representable Mix runtime stylers. It validates and decodes inbound payloads, encodes representable runtime stylers, and exports JSON Schema. It is not a general serializer for every Mix object. + +Pinned Ack: `btwld/ack` `8daaadace3e0c9969e05eb0fe5633a51c2bb124b`, path `packages/ack`. + +Flutter primitive payloads mirror Ack `flutter_codec` branch shapes where they do not erase Mix semantics. Local mirrors must be replaced by `flutter_codec` imports once that package is merged and available to this workspace. + +| Rule | Requirement | Implementation | Tests | +| --- | --- | --- | --- | +| R-1 | Ack owns validation, decode, encode, and JSON Schema export. | `MixSchemaContract.rootSchema` | `public_api_contract_test.dart` | +| R-2 | Ack owns the `type` discriminator; branch codecs do not declare or inject it. | `Ack.discriminated(discriminatorKey: 'type')` | `schema_export_golden_test.dart` | +| R-3 | Runtime widening happens only in `widenStylerBranch`. | `src/schema/styler_branch.dart` | `styler_branch_test.dart` | +| R-4 | Strict wire enums use string values only. | `common_codecs.dart`, `encode.dart` | `common_codecs_test.dart` | +| R-5 | Unsupported runtime objects fail encode explicitly. | typed exceptions in `mix_schema_error.dart` | branch/styler encode tests | +| R-6 | App-owned identity uses scoped registries. | `src/registry/` | `registry_*_test.dart` | +| R-7 | Public errors expose stable code, path, message, and offending value. | `schema_error_mapper.dart` | `error_mapper_test.dart` | +| R-8 | Payload limits run before decode and after encode. | `validatePayloadLimits` | `mix_schema_contract_test.dart` | +| R-9 | Tailwinds depends only on public `mix_schema.dart` and `encode.dart`. | `packages/mix_tailwinds` imports | guard tests | +| R-10 | Missing payload fields are not filled with Mix runtime defaults. | no schema `withDefault` for runtime defaults | styler tests | +| R-11 | Recursive nested styles use `Ack.lazy`. | `variant_codec.dart` | `variant_codec_test.dart` | +| R-12 | `encode.dart` is a narrow producer helper surface and exports no schema internals. | `lib/encode.dart` only | guard tests | + +Public error codes: `type_mismatch`, `required_field`, `unknown_field`, `invalid_enum`, `constraint_violation`, `payload_limit_exceeded`, `unsupported_encode_value`, `unknown_type`, `unknown_registry_id`, `unknown_registry_value`, `validation_failed`, `transform_failed`. + +Default limits: depth 32, list length 256, string length 4096, variants per styler 64, modifiers per styler 64. + +Registry scopes: `animation_on_end`, `icon_data`, `image_provider`, `context_variant_builder`. Registry ids must match `[A-Za-z0-9_-]{1,96}`. + +Encode policy: only values that can be represented without losing semantics are encoded. Tokens, directives, multi-source props, closures without registry ids, arbitrary `Curve`s, spring/phase/keyframe animations, and unsupported modifiers fail with stable public errors. + +Acceptance gate: `melos bootstrap`, package tests for `mix_schema` and `mix_tailwinds`, `melos run analyze`, `melos run ci`, and guard searches for forbidden imports/discriminator helpers must pass before completion. diff --git a/packages/mix_schema/analysis_options.yaml b/packages/mix_schema/analysis_options.yaml new file mode 100644 index 0000000000..0d920fa944 --- /dev/null +++ b/packages/mix_schema/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../lints_with_dcm.yaml diff --git a/packages/mix_schema/lib/encode.dart b/packages/mix_schema/lib/encode.dart new file mode 100644 index 0000000000..d5a9211fef --- /dev/null +++ b/packages/mix_schema/lib/encode.dart @@ -0,0 +1,45 @@ +library; + +import 'package:flutter/widgets.dart'; + +import 'src/schema/primitive_wire.dart'; + +enum SchemaStyler { + box('box'), + text('text'), + flex('flex'), + stack('stack'), + icon('icon'), + image('image'), + flexBox('flex_box'), + stackBox('stack_box'); + + const SchemaStyler(this.wireValue); + + final String wireValue; +} + +String payloadColor(Color value) { + return encodeColorWire(value); +} + +Object payloadAlignment(Alignment value) { + return encodeAlignmentWire(value); +} + +Object payloadEdgeInsets({ + double? all, + double? left, + double? top, + double? right, + double? bottom, +}) { + if (all != null) return all; + + return encodeEdgeInsetsWire( + left: left, + top: top, + right: right, + bottom: bottom, + ); +} diff --git a/packages/mix_schema/lib/mix_schema.dart b/packages/mix_schema/lib/mix_schema.dart new file mode 100644 index 0000000000..63180fd3ad --- /dev/null +++ b/packages/mix_schema/lib/mix_schema.dart @@ -0,0 +1,20 @@ +library; + +export 'package:ack/ack.dart' show JsonMap; + +export 'src/contract/mix_schema_contract.dart'; +export 'src/contract/mix_schema_limits.dart'; +export 'src/errors/mix_schema_error.dart' + show + MixSchemaDecodeFailure, + MixSchemaDecodeResult, + MixSchemaDecodeSuccess, + MixSchemaEncodeFailure, + MixSchemaEncodeResult, + MixSchemaEncodeSuccess, + MixSchemaError, + MixSchemaErrorCode, + MixSchemaValidationFailure, + MixSchemaValidationResult, + MixSchemaValidationSuccess; +export 'src/registry/registry.dart'; diff --git a/packages/mix_schema/lib/src/contract/mix_schema_contract.dart b/packages/mix_schema/lib/src/contract/mix_schema_contract.dart new file mode 100644 index 0000000000..e40f15aa55 --- /dev/null +++ b/packages/mix_schema/lib/src/contract/mix_schema_contract.dart @@ -0,0 +1,169 @@ +import 'package:ack/ack.dart'; + +import '../errors/mix_schema_error.dart'; +import '../errors/schema_error_mapper.dart'; +import '../registry/registry.dart'; +import '../schema/box_styler_codec.dart'; +import '../schema/flex_box_styler_codec.dart'; +import '../schema/flex_styler_codec.dart'; +import '../schema/icon_styler_codec.dart'; +import '../schema/image_styler_codec.dart'; +import '../schema/stack_box_styler_codec.dart'; +import '../schema/stack_styler_codec.dart'; +import '../schema/styler_branch.dart'; +import '../schema/text_styler_codec.dart'; +import 'mix_schema_limits.dart'; + +const mixSchemaVersion = '0.0.1'; + +final class MixSchemaContractBuilder { + MixSchemaContractBuilder({MixSchemaLimits limits = const MixSchemaLimits()}) + : _limits = limits, + _registryBuilder = RegistryBuilder() { + _rootSchemaRef = Ack.lazy( + 'mix_schema_style', + () => _rootSchema, + ); + } + + MixSchemaLimits _limits; + final RegistryBuilder _registryBuilder; + final Map> _branches = {}; + late final AckSchema _rootSchemaRef; + late AckSchema _rootSchema; + late FrozenRegistry _frozenRegistry; + + MixSchemaLimits get limits => _limits; + RegistryBuilder get registry => _registryBuilder; + + MixSchemaContractBuilder withLimits(MixSchemaLimits limits) { + _limits = limits; + + return this; + } + + MixSchemaContractBuilder addStyler( + String wireType, + AckSchema schema, + ) { + _branches[wireType] = widenStylerBranch(schema, debugName: wireType); + + return this; + } + + MixSchemaContractBuilder builtIn() { + addStyler( + 'box', + boxStylerCodec( + rootStyleSchema: _rootSchemaRef, + registry: () => _frozenRegistry, + ), + ); + addStyler('text', textStylerCodec(registry: () => _frozenRegistry)); + addStyler('flex', flexStylerCodec(registry: () => _frozenRegistry)); + addStyler('stack', stackStylerCodec(registry: () => _frozenRegistry)); + addStyler('icon', iconStylerCodec(registry: () => _frozenRegistry)); + addStyler('image', imageStylerCodec(registry: () => _frozenRegistry)); + addStyler('flex_box', flexBoxStylerCodec(registry: () => _frozenRegistry)); + addStyler( + 'stack_box', + stackBoxStylerCodec(registry: () => _frozenRegistry), + ); + + return this; + } + + MixSchemaContract freeze() { + final registry = _registryBuilder.freeze(); + _frozenRegistry = registry; + final root = Ack.discriminated( + discriminatorKey: 'type', + schemas: _branches, + ); + _rootSchema = root; + + return MixSchemaContract._( + rootSchema: root, + limits: _limits, + registry: registry, + registeredTypes: _branches.keys.toList(growable: false), + ); + } +} + +final class MixSchemaContract { + const MixSchemaContract._({ + required this.rootSchema, + required this.limits, + required this.registry, + required this.registeredTypes, + }); + + final AckSchema rootSchema; + final MixSchemaLimits limits; + final FrozenRegistry registry; + final List registeredTypes; + + MixSchemaValidationResult validate(Object? payload) { + final limitErrors = validatePayloadLimits(payload, limits); + if (limitErrors.isNotEmpty) { + return MixSchemaValidationFailure(limitErrors); + } + + final result = rootSchema.safeParse(payload); + if (result.isOk) return const MixSchemaValidationSuccess(); + + return MixSchemaValidationFailure(mapSchemaError(result.getError())); + } + + MixSchemaDecodeResult decode(Object? payload) { + final limitErrors = validatePayloadLimits(payload, limits); + if (limitErrors.isNotEmpty) { + return MixSchemaDecodeFailure(limitErrors); + } + + final result = rootSchema.safeParse(payload); + if (result.isFail) { + return MixSchemaDecodeFailure(mapSchemaError(result.getError())); + } + + final value = result.getOrNull(); + if (value is T) return MixSchemaDecodeSuccess(value); + + return MixSchemaDecodeFailure([ + MixSchemaError( + code: MixSchemaErrorCode.typeMismatch, + path: '', + message: 'Decoded value is ${value.runtimeType}, expected $T.', + value: value, + ), + ]); + } + + MixSchemaEncodeResult encode(Object value) { + final result = rootSchema.safeEncode(value); + if (result.isFail) { + return MixSchemaEncodeFailure(mapSchemaError(result.getError())); + } + + final encoded = result.getOrNull(); + final limitErrors = validatePayloadLimits(encoded, limits); + if (limitErrors.isNotEmpty) { + return MixSchemaEncodeFailure(limitErrors); + } + + return MixSchemaEncodeSuccess(encoded!); + } + + JsonMap exportJsonSchema() { + final exported = Map.from(rootSchema.toJsonSchema()); + + return { + r'$schema': 'http://json-schema.org/draft-07/schema#', + ...exported, + 'x-mix-schema-contract': 'mix_schema', + 'x-mix-schema-version': mixSchemaVersion, + 'x-mix-schema-limits': limits.toJson(), + }; + } +} diff --git a/packages/mix_schema/lib/src/contract/mix_schema_limits.dart b/packages/mix_schema/lib/src/contract/mix_schema_limits.dart new file mode 100644 index 0000000000..baaf197dc3 --- /dev/null +++ b/packages/mix_schema/lib/src/contract/mix_schema_limits.dart @@ -0,0 +1,23 @@ +final class MixSchemaLimits { + const MixSchemaLimits({ + this.maxDepth = 32, + this.maxListLength = 256, + this.maxStringLength = 4096, + this.maxVariantsPerStyler = 64, + this.maxModifiersPerStyler = 64, + }); + + final int maxDepth; + final int maxListLength; + final int maxStringLength; + final int maxVariantsPerStyler; + final int maxModifiersPerStyler; + + Map toJson() => { + 'maxDepth': maxDepth, + 'maxListLength': maxListLength, + 'maxStringLength': maxStringLength, + 'maxVariantsPerStyler': maxVariantsPerStyler, + 'maxModifiersPerStyler': maxModifiersPerStyler, + }; +} diff --git a/packages/mix_schema/lib/src/errors/mix_schema_error.dart b/packages/mix_schema/lib/src/errors/mix_schema_error.dart new file mode 100644 index 0000000000..198aa6726a --- /dev/null +++ b/packages/mix_schema/lib/src/errors/mix_schema_error.dart @@ -0,0 +1,204 @@ +import 'package:ack/ack.dart'; + +import '../contract/mix_schema_limits.dart'; + +enum MixSchemaErrorCode { + typeMismatch('type_mismatch'), + requiredField('required_field'), + unknownField('unknown_field'), + invalidEnum('invalid_enum'), + constraintViolation('constraint_violation'), + payloadLimitExceeded('payload_limit_exceeded'), + unsupportedEncodeValue('unsupported_encode_value'), + unknownType('unknown_type'), + unknownRegistryId('unknown_registry_id'), + unknownRegistryValue('unknown_registry_value'), + validationFailed('validation_failed'), + transformFailed('transform_failed'); + + const MixSchemaErrorCode(this.wireValue); + + final String wireValue; +} + +final class MixSchemaError { + const MixSchemaError({ + required this.code, + required this.path, + required this.message, + this.value, + }); + + final MixSchemaErrorCode code; + final String path; + final String message; + final Object? value; + + JsonMap toJson() => { + 'code': code.wireValue, + 'path': path, + 'message': message, + if (value != null) 'value': value, + }; + + @override + String toString() => '${code.wireValue} at "$path": $message'; +} + +sealed class MixSchemaValidationResult { + const MixSchemaValidationResult(); +} + +final class MixSchemaValidationSuccess extends MixSchemaValidationResult { + const MixSchemaValidationSuccess(); +} + +final class MixSchemaValidationFailure extends MixSchemaValidationResult { + const MixSchemaValidationFailure(this.errors); + + final List errors; +} + +sealed class MixSchemaDecodeResult { + const MixSchemaDecodeResult(); +} + +final class MixSchemaDecodeSuccess + extends MixSchemaDecodeResult { + const MixSchemaDecodeSuccess(this.value); + + final T value; +} + +final class MixSchemaDecodeFailure + extends MixSchemaDecodeResult { + const MixSchemaDecodeFailure(this.errors); + + final List errors; +} + +sealed class MixSchemaEncodeResult { + const MixSchemaEncodeResult(); +} + +final class MixSchemaEncodeSuccess extends MixSchemaEncodeResult { + const MixSchemaEncodeSuccess(this.value); + + final JsonMap value; +} + +final class MixSchemaEncodeFailure extends MixSchemaEncodeResult { + const MixSchemaEncodeFailure(this.errors); + + final List errors; +} + +final class UnsupportedEncodeValueError implements Exception { + const UnsupportedEncodeValueError(this.value, this.reason); + + final Object? value; + final String reason; + + @override + String toString() => 'Unsupported encode value: $reason'; +} + +final class UnknownRegistryIdError implements Exception { + const UnknownRegistryIdError(this.scope, this.id); + + final Object scope; + final String id; + + @override + String toString() => 'Unknown registry id "$id" in $scope.'; +} + +final class UnknownRegistryValueError implements Exception { + const UnknownRegistryValueError(this.scope, this.value); + + final Object scope; + final Object value; + + @override + String toString() => 'Unknown registry value ${value.runtimeType} in $scope.'; +} + +List validatePayloadLimits( + Object? value, + MixSchemaLimits limits, +) { + final errors = []; + + void visit(Object? node, String path, int depth) { + if (depth > limits.maxDepth) { + errors.add( + MixSchemaError( + code: MixSchemaErrorCode.payloadLimitExceeded, + path: path, + message: 'Payload depth exceeds ${limits.maxDepth}.', + value: node, + ), + ); + + return; + } + + if (node is String && node.length > limits.maxStringLength) { + errors.add( + MixSchemaError( + code: MixSchemaErrorCode.payloadLimitExceeded, + path: path, + message: 'String length exceeds ${limits.maxStringLength}.', + value: node, + ), + ); + } else if (node is List) { + if (node.length > limits.maxListLength) { + errors.add( + MixSchemaError( + code: MixSchemaErrorCode.payloadLimitExceeded, + path: path, + message: 'List length exceeds ${limits.maxListLength}.', + value: node.length, + ), + ); + } + for (var i = 0; i < node.length; i++) { + visit(node[i], '$path/$i', depth + 1); + } + } else if (node is Map) { + final variants = node['variants']; + if (variants is List && variants.length > limits.maxVariantsPerStyler) { + errors.add( + MixSchemaError( + code: MixSchemaErrorCode.payloadLimitExceeded, + path: '$path/variants', + message: 'Variant count exceeds ${limits.maxVariantsPerStyler}.', + value: variants.length, + ), + ); + } + final modifiers = node['modifiers']; + if (modifiers is List && + modifiers.length > limits.maxModifiersPerStyler) { + errors.add( + MixSchemaError( + code: MixSchemaErrorCode.payloadLimitExceeded, + path: '$path/modifiers', + message: 'Modifier count exceeds ${limits.maxModifiersPerStyler}.', + value: modifiers.length, + ), + ); + } + for (final entry in node.entries) { + final key = entry.key; + visit(entry.value, '$path/$key', depth + 1); + } + } + } + + visit(value, '', 0); + errors.sort((a, b) => a.path.compareTo(b.path)); + + return errors; +} diff --git a/packages/mix_schema/lib/src/errors/schema_error_mapper.dart b/packages/mix_schema/lib/src/errors/schema_error_mapper.dart new file mode 100644 index 0000000000..dae9554e60 --- /dev/null +++ b/packages/mix_schema/lib/src/errors/schema_error_mapper.dart @@ -0,0 +1,112 @@ +import 'package:ack/ack.dart'; + +import 'mix_schema_error.dart'; + +List mapSchemaError(SchemaError error) { + final errors = []; + + void flatten(SchemaError current) { + if (current is SchemaNestedError) { + for (final child in current.errors) { + flatten(child); + } + + return; + } + + errors.add(_mapSingleSchemaError(current)); + } + + flatten(error); + errors.sort((a, b) => a.path.compareTo(b.path)); + + return errors; +} + +MixSchemaError _mapSingleSchemaError(SchemaError error) { + final cause = error.cause; + if (cause is UnsupportedEncodeValueError) { + return MixSchemaError( + code: MixSchemaErrorCode.unsupportedEncodeValue, + path: error.path, + message: cause.reason, + value: cause.value, + ); + } + if (cause is UnknownRegistryIdError) { + return MixSchemaError( + code: MixSchemaErrorCode.unknownRegistryId, + path: error.path, + message: cause.toString(), + value: cause.id, + ); + } + if (cause is UnknownRegistryValueError) { + return MixSchemaError( + code: MixSchemaErrorCode.unknownRegistryValue, + path: error.path, + message: cause.toString(), + value: cause.value, + ); + } + + final code = switch (error) { + TypeMismatchError() => MixSchemaErrorCode.typeMismatch, + SchemaTransformError() => MixSchemaErrorCode.transformFailed, + SchemaEncodeError(kind: final kind) => switch (kind) { + SchemaEncodeFailureKind.nonNullable => MixSchemaErrorCode.requiredField, + SchemaEncodeFailureKind.typeMismatch => MixSchemaErrorCode.typeMismatch, + SchemaEncodeFailureKind.oneWayTransform => + MixSchemaErrorCode.unsupportedEncodeValue, + SchemaEncodeFailureKind.encoderThrew => + MixSchemaErrorCode.unsupportedEncodeValue, + SchemaEncodeFailureKind.missingRequiredProperty => + MixSchemaErrorCode.requiredField, + SchemaEncodeFailureKind.unexpectedProperty => + MixSchemaErrorCode.unknownField, + }, + SchemaConstraintsError(:final constraints) => _mapConstraintCode( + constraints, + error.path, + error.name, + ), + SchemaValidationError() => MixSchemaErrorCode.validationFailed, + _ => MixSchemaErrorCode.validationFailed, + }; + + return MixSchemaError( + code: code, + path: error.path, + message: error.message, + value: error.value, + ); +} + +MixSchemaErrorCode _mapConstraintCode( + List constraints, + String path, + String name, +) { + final keys = constraints.map((e) => e.constraint.constraintKey).toSet(); + if (keys.contains('object_required_property_missing') || + keys.contains('core_non_nullable')) { + return MixSchemaErrorCode.requiredField; + } + if (keys.contains('object_additional_properties_disallowed')) { + return MixSchemaErrorCode.unknownField; + } + final isEnumConstraint = + keys.contains('string_enum') || keys.contains('enum_value'); + if (isEnumConstraint && + (path == '/type' || path == 'type' || name == 'type')) { + return MixSchemaErrorCode.unknownType; + } + if (isEnumConstraint) { + return MixSchemaErrorCode.invalidEnum; + } + if (keys.contains('core_invalid_type')) { + return MixSchemaErrorCode.typeMismatch; + } + + return MixSchemaErrorCode.constraintViolation; +} diff --git a/packages/mix_schema/lib/src/registry/registry.dart b/packages/mix_schema/lib/src/registry/registry.dart new file mode 100644 index 0000000000..cc31a3f258 --- /dev/null +++ b/packages/mix_schema/lib/src/registry/registry.dart @@ -0,0 +1,86 @@ +import 'package:flutter/widgets.dart'; + +import '../errors/mix_schema_error.dart'; + +enum MixSchemaScope { + animationOnEnd('animation_on_end'), + iconData('icon_data'), + imageProvider('image_provider'), + contextVariantBuilder('context_variant_builder'); + + const MixSchemaScope(this.wireValue); + + final String wireValue; +} + +const registryIdPattern = r'^[A-Za-z0-9_-]{1,96}$'; + +final _idPattern = RegExp(registryIdPattern); + +bool isValidRegistryId(String value) => _idPattern.hasMatch(value); + +final class RegistryBuilder { + final Map> _values = { + for (final scope in MixSchemaScope.values) scope: {}, + }; + + RegistryBuilder register( + MixSchemaScope scope, + String id, + T value, + ) { + if (!isValidRegistryId(id)) { + throw ArgumentError.value(id, 'id', 'Invalid mix_schema registry id.'); + } + _values[scope]![id] = value; + + return this; + } + + RegistryBuilder animationOnEnd(String id, VoidCallback value) => + register(MixSchemaScope.animationOnEnd, id, value); + + RegistryBuilder iconData(String id, IconData value) => + register(MixSchemaScope.iconData, id, value); + + RegistryBuilder imageProvider(String id, ImageProvider value) => + register(MixSchemaScope.imageProvider, id, value); + + RegistryBuilder contextVariantBuilder( + String id, + T Function(BuildContext) value, + ) { + return register(MixSchemaScope.contextVariantBuilder, id, value); + } + + FrozenRegistry freeze() { + return FrozenRegistry._({ + for (final entry in _values.entries) + entry.key: Map.unmodifiable(entry.value), + }); + } +} + +final class FrozenRegistry { + const FrozenRegistry._(this._values); + + final Map> _values; + + T lookup(MixSchemaScope scope, String id) { + final value = _values[scope]?[id]; + if (value is T) return value; + + throw UnknownRegistryIdError(scope, id); + } + + String idFor(MixSchemaScope scope, T value) { + final entries = _values[scope]?.entries ?? const Iterable.empty(); + for (final entry in entries) { + if (identical(entry.value, value)) { + return entry.key; + } + } + + throw UnknownRegistryValueError(scope, value); + } +} diff --git a/packages/mix_schema/lib/src/registry/registry_value_codec.dart b/packages/mix_schema/lib/src/registry/registry_value_codec.dart new file mode 100644 index 0000000000..4608514dc9 --- /dev/null +++ b/packages/mix_schema/lib/src/registry/registry_value_codec.dart @@ -0,0 +1,26 @@ +import 'package:ack/ack.dart'; + +import 'registry.dart'; + +CodecSchema registryValueCodec( + FrozenRegistry registry, + MixSchemaScope scope, +) { + return registryValueCodecFrom(() => registry, scope); +} + +CodecSchema registryValueCodecFrom( + FrozenRegistry Function() registry, + MixSchemaScope scope, +) { + return Ack.string() + .matches( + registryIdPattern, + message: + 'Registry id must be 1-96 characters using letters, digits, "_" or "-".', + ) + .codec( + decode: (id) => registry().lookup(scope, id), + encode: (value) => registry().idFor(scope, value), + ); +} diff --git a/packages/mix_schema/lib/src/schema/animation_codec.dart b/packages/mix_schema/lib/src/schema/animation_codec.dart new file mode 100644 index 0000000000..2c96aef688 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/animation_codec.dart @@ -0,0 +1,103 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import '../registry/registry_value_codec.dart'; +import 'common_codecs.dart'; + +AckSchema animationConfigCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'duration': _durationMillisCodec(), + 'curve': curveCodec(), + 'delay': _durationMillisCodec(), + 'onEnd': registryValueCodecFrom( + registry, + MixSchemaScope.animationOnEnd, + ).optional(), + }).codec( + decode: (data) => CurveAnimationConfig( + duration: Duration(milliseconds: data['duration']! as int), + curve: data['curve']! as Curve, + delay: Duration(milliseconds: data['delay']! as int), + onEnd: data['onEnd'] as VoidCallback?, + ), + encode: _encodeAnimationConfig, + ); +} + +CodecSchema curveCodec() { + return strictEnumCodec(_namedCurves, debugName: 'Curve'); +} + +AckSchema _durationMillisCodec() { + return Ack.integer().refine( + (value) => value >= 0, + message: 'Duration must be a non-negative millisecond count.', + ); +} + +JsonMap _encodeAnimationConfig(AnimationConfig value) { + if (value is! CurveAnimationConfig) { + throw UnsupportedEncodeValueError( + value, + 'Only CurveAnimationConfig is representable.', + ); + } + + return { + 'duration': value.duration.inMilliseconds, + 'curve': value.curve, + 'delay': value.delay.inMilliseconds, + 'onEnd': value.onEnd, + }; +} + +const _namedCurves = { + 'linear': Curves.linear, + 'decelerate': Curves.decelerate, + 'fastLinearToSlowEaseIn': Curves.fastLinearToSlowEaseIn, + 'fastEaseInToSlowEaseOut': Curves.fastEaseInToSlowEaseOut, + 'ease': Curves.ease, + 'easeIn': Curves.easeIn, + 'easeInToLinear': Curves.easeInToLinear, + 'easeInSine': Curves.easeInSine, + 'easeInQuad': Curves.easeInQuad, + 'easeInCubic': Curves.easeInCubic, + 'easeInQuart': Curves.easeInQuart, + 'easeInQuint': Curves.easeInQuint, + 'easeInExpo': Curves.easeInExpo, + 'easeInCirc': Curves.easeInCirc, + 'easeInBack': Curves.easeInBack, + 'easeOut': Curves.easeOut, + 'linearToEaseOut': Curves.linearToEaseOut, + 'easeOutSine': Curves.easeOutSine, + 'easeOutQuad': Curves.easeOutQuad, + 'easeOutCubic': Curves.easeOutCubic, + 'easeOutQuart': Curves.easeOutQuart, + 'easeOutQuint': Curves.easeOutQuint, + 'easeOutExpo': Curves.easeOutExpo, + 'easeOutCirc': Curves.easeOutCirc, + 'easeOutBack': Curves.easeOutBack, + 'easeInOut': Curves.easeInOut, + 'easeInOutSine': Curves.easeInOutSine, + 'easeInOutQuad': Curves.easeInOutQuad, + 'easeInOutCubic': Curves.easeInOutCubic, + 'easeInOutCubicEmphasized': Curves.easeInOutCubicEmphasized, + 'easeInOutQuart': Curves.easeInOutQuart, + 'easeInOutQuint': Curves.easeInOutQuint, + 'easeInOutExpo': Curves.easeInOutExpo, + 'easeInOutCirc': Curves.easeInOutCirc, + 'easeInOutBack': Curves.easeInOutBack, + 'fastOutSlowIn': Curves.fastOutSlowIn, + 'slowMiddle': Curves.slowMiddle, + 'bounceIn': Curves.bounceIn, + 'bounceOut': Curves.bounceOut, + 'bounceInOut': Curves.bounceInOut, + 'elasticIn': Curves.elasticIn, + 'elasticOut': Curves.elasticOut, + 'elasticInOut': Curves.elasticInOut, +}; diff --git a/packages/mix_schema/lib/src/schema/box_styler_codec.dart b/packages/mix_schema/lib/src/schema/box_styler_codec.dart new file mode 100644 index 0000000000..2875fa16a0 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/box_styler_codec.dart @@ -0,0 +1,112 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import 'animation_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; +import 'variant_codec.dart'; + +AckSchema boxStylerCodec({ + AckSchema? rootStyleSchema, + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'alignment': alignmentCodec().optional(), + 'padding': edgeInsetsCodec().optional(), + 'margin': edgeInsetsCodec().optional(), + 'constraints': boxConstraintsCodec().optional(), + 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'decoration': boxDecorationCodec().optional(), + if (rootStyleSchema != null) + 'variants': Ack.list( + boxVariantCodec(rootStyleSchema, registry), + ).optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => BoxStyler( + alignment: data['alignment'] as Alignment?, + padding: data['padding'] as EdgeInsetsMix?, + margin: data['margin'] as EdgeInsetsMix?, + constraints: data['constraints'] as BoxConstraintsMix?, + clipBehavior: data['clipBehavior'] as Clip?, + decoration: data['decoration'] as BoxDecorationMix?, + variants: data['variants'] as List>?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: encodeBoxStylerFields, + ); +} + +JsonMap encodeBoxStylerFields( + BoxStyler value, { + bool includeStylerMetadata = true, +}) { + _failIfPresent(value.$foregroundDecoration, 'foregroundDecoration'); + _failIfPresent(value.$transform, 'transform'); + _failIfPresent(value.$transformAlignment, 'transformAlignment'); + + final encoded = { + 'alignment': singleAlignmentProp(value.$alignment, 'alignment'), + 'padding': singleMixProp( + value.$padding, + 'padding', + ), + 'margin': singleMixProp( + value.$margin, + 'margin', + ), + 'constraints': singleMixProp( + value.$constraints, + 'constraints', + ), + 'clipBehavior': singleValueProp(value.$clipBehavior, 'clipBehavior'), + 'decoration': singleMixProp( + value.$decoration, + 'decoration', + ), + 'variants': value.$variants, + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; + + if (includeStylerMetadata) return encoded; + + return Map.from(encoded) + ..remove('variants') + ..remove('modifiers') + ..remove('animation'); +} + +CodecSchema boxDecorationCodec() { + return Ack.object({'color': colorCodec().optional()}).codec( + decode: (data) => BoxDecorationMix(color: data['color'] as Color?), + encode: (value) { + _failIfPresent(value.$border, 'decoration.border'); + _failIfPresent(value.$borderRadius, 'decoration.borderRadius'); + _failIfPresent(value.$shape, 'decoration.shape'); + _failIfPresent( + value.$backgroundBlendMode, + 'decoration.backgroundBlendMode', + ); + _failIfPresent(value.$image, 'decoration.image'); + _failIfPresent(value.$gradient, 'decoration.gradient'); + _failIfPresent(value.$boxShadow, 'decoration.boxShadow'); + + return {'color': singleValueProp(value.$color, 'decoration.color')}; + }, + ); +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/common_codecs.dart b/packages/mix_schema/lib/src/schema/common_codecs.dart new file mode 100644 index 0000000000..2b6345f7fe --- /dev/null +++ b/packages/mix_schema/lib/src/schema/common_codecs.dart @@ -0,0 +1,384 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import 'primitive_wire.dart'; + +CodecSchema numberAsDoubleCodec() { + return Ack.number().codec( + decode: (value) => value.toDouble(), + encode: (value) => value, + ); +} + +CodecSchema nonNegativeDoubleCodec() { + return Ack.number() + .min(0) + .codec( + decode: (value) => value.toDouble(), + encode: (value) => value, + ); +} + +CodecSchema colorCodec() { + return Ack.codec( + input: Ack.anyOf([ + Ack.string().matches(r'^#[0-9A-Fa-f]{6}$'), + Ack.string().matches(r'^#[0-9A-Fa-f]{8}$'), + Ack.string().matches( + r'^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$', + ), + Ack.string().matches( + r'^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+|1\.0+)\s*\)$', + ), + ]), + decode: (value) => _decodeColor(value as String), + encode: encodeColorWire, + ); +} + +CodecSchema alignmentCodec() { + return Ack.codec( + input: Ack.anyOf([ + Ack.enumString(namedAlignments.keys.toList(growable: false)), + Ack.object({'x': numberAsDoubleCodec(), 'y': numberAsDoubleCodec()}), + ]), + decode: _decodeAlignment, + encode: encodeAlignmentWire, + ); +} + +CodecSchema radiusCodec() { + return Ack.codec( + input: Ack.anyOf([ + nonNegativeDoubleCodec(), + Ack.object({ + 'x': nonNegativeDoubleCodec(), + 'y': nonNegativeDoubleCodec(), + }), + ]), + decode: _decodeRadius, + encode: _encodeRadius, + ); +} + +CodecSchema edgeInsetsCodec() { + return Ack.codec( + input: Ack.anyOf([ + numberAsDoubleCodec(), + Ack.object({ + 'left': numberAsDoubleCodec().optional(), + 'top': numberAsDoubleCodec().optional(), + 'right': numberAsDoubleCodec().optional(), + 'bottom': numberAsDoubleCodec().optional(), + }), + ]), + decode: _decodeEdgeInsetsMix, + encode: _encodeEdgeInsetsMix, + ); +} + +CodecSchema boxConstraintsCodec() { + return Ack.object({ + 'minWidth': nonNegativeDoubleCodec().nullable().optional(), + 'maxWidth': nonNegativeDoubleCodec().nullable().optional(), + 'minHeight': nonNegativeDoubleCodec().nullable().optional(), + 'maxHeight': nonNegativeDoubleCodec().nullable().optional(), + }).codec( + decode: (data) => BoxConstraintsMix( + minWidth: _readOptionalConstraintBound(data, 'minWidth'), + maxWidth: _readOptionalConstraintBound(data, 'maxWidth'), + minHeight: _readOptionalConstraintBound(data, 'minHeight'), + maxHeight: _readOptionalConstraintBound(data, 'maxHeight'), + ), + encode: (value) => { + 'minWidth': _encodeConstraintBound( + singleValueProp(value.$minWidth, 'minWidth'), + ), + 'maxWidth': _encodeConstraintBound( + singleValueProp(value.$maxWidth, 'maxWidth'), + ), + 'minHeight': _encodeConstraintBound( + singleValueProp(value.$minHeight, 'minHeight'), + ), + 'maxHeight': _encodeConstraintBound( + singleValueProp(value.$maxHeight, 'maxHeight'), + ), + }, + ); +} + +CodecSchema borderRadiusCodec() { + return Ack.codec( + input: Ack.anyOf([ + radiusCodec(), + Ack.object({ + 'topLeft': radiusCodec().optional(), + 'topRight': radiusCodec().optional(), + 'bottomLeft': radiusCodec().optional(), + 'bottomRight': radiusCodec().optional(), + }), + ]), + decode: _decodeBorderRadiusMix, + encode: _encodeBorderRadiusMix, + ); +} + +CodecSchema strictEnumCodec( + Map values, { + String? debugName, +}) { + return Ack.enumString(values.keys.toList(growable: false)).codec( + decode: (wire) => values[wire]!, + encode: (value) { + for (final entry in values.entries) { + if (entry.value == value) return entry.key; + } + + throw UnsupportedEncodeValueError( + value, + 'No ${debugName ?? 'enum'} wire value is registered for $value.', + ); + }, + ); +} + +CodecSchema enumNameCodec( + List values, { + String? debugName, +}) { + return Ack.enumCodec(values); +} + +Alignment? singleAlignmentProp( + Prop? prop, + String fieldName, +) { + final value = singleValueProp(prop, fieldName); + if (value == null) return null; + if (value is Alignment) return value; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" uses ${value.runtimeType}; only Alignment is supported.', + ); +} + +T? singleValueProp(Prop? prop, String fieldName) { + if (prop == null) return null; + if (prop.$directives?.isNotEmpty == true) { + throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" has directives and cannot be represented.', + ); + } + if (prop.sources.length != 1) { + throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" has ${prop.sources.length} sources; expected one.', + ); + } + final source = prop.sources.single; + + return switch (source) { + ValueSource(:final value) => value, + TokenSource() => throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" uses a token value.', + ), + MixSource() => throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" uses a nested Mix value.', + ), + }; +} + +T? singleMixProp( + Prop? prop, + String fieldName, +) { + if (prop == null) return null; + if (prop.$directives?.isNotEmpty == true) { + throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" has directives and cannot be represented.', + ); + } + if (prop.sources.length != 1) { + throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" has ${prop.sources.length} sources; expected one.', + ); + } + + final source = prop.sources.single; + if (source is MixSource && source.mix is T) { + return source.mix as T; + } + if (source is ValueSource && source.value is T) { + return source.value as T; + } + + throw UnsupportedEncodeValueError( + prop, + 'Field "$fieldName" is ${source.runtimeType}; expected $T.', + ); +} + +Color _decodeColor(String value) { + if (value.startsWith('#')) return _decodeHexColor(value); + if (value.startsWith('rgb(')) return _decodeRgbColor(value); + if (value.startsWith('rgba(')) return _decodeRgbaColor(value); + + throw FormatException('Unsupported color format: $value'); +} + +Color _decodeHexColor(String value) { + final hex = value.substring(1); + final argb = hex.length == 6 ? 'FF$hex' : hex; + + return Color(int.parse(argb, radix: 16)); +} + +Color _decodeRgbColor(String value) { + final channels = _parseColorChannels(value, prefix: 'rgb(', count: 3); + + return Color.fromARGB(0xFF, channels[0], channels[1], channels[2]); +} + +Color _decodeRgbaColor(String value) { + final channels = _parseColorChannels(value, prefix: 'rgba(', count: 4); + + return Color.fromARGB(channels[3], channels[0], channels[1], channels[2]); +} + +List _parseColorChannels( + String value, { + required String prefix, + required int count, +}) { + final parts = value.substring(prefix.length, value.length - 1).split(','); + if (parts.length != count) { + throw FormatException('Expected $count color channels.'); + } + + final rgb = parts + .take(3) + .map((part) { + final channel = int.parse(part.trim()); + if (channel < 0 || channel > 255) { + throw FormatException('Color channel out of range: $channel'); + } + + return channel; + }) + .toList(growable: false); + + if (count == 3) return rgb; + + final alpha = double.parse(parts[3].trim()); + if (alpha < 0 || alpha > 1) { + throw FormatException('Alpha channel out of range: $alpha'); + } + + return [...rgb, (alpha * 255).round()]; +} + +Alignment _decodeAlignment(Object value) { + if (value is String) return namedAlignments[value]!; + + final data = value as JsonMap; + + return Alignment(data['x']! as double, data['y']! as double); +} + +Radius _decodeRadius(Object value) { + if (value is num) return Radius.circular(value.toDouble()); + + final data = value as JsonMap; + + return Radius.elliptical(data['x']! as double, data['y']! as double); +} + +Object _encodeRadius(Radius value) { + if (value.x == value.y) return value.x; + + return {'x': value.x, 'y': value.y}; +} + +EdgeInsetsMix _decodeEdgeInsetsMix(Object value) { + if (value is num) return EdgeInsetsMix.all(value.toDouble()); + + final data = value as JsonMap; + + return EdgeInsetsMix( + left: data['left'] as double?, + top: data['top'] as double?, + right: data['right'] as double?, + bottom: data['bottom'] as double?, + ); +} + +Object _encodeEdgeInsetsMix(EdgeInsetsMix value) { + final left = singleValueProp(value.$left, 'left'); + final top = singleValueProp(value.$top, 'top'); + final right = singleValueProp(value.$right, 'right'); + final bottom = singleValueProp(value.$bottom, 'bottom'); + + return encodeEdgeInsetsWire( + left: left, + top: top, + right: right, + bottom: bottom, + ); +} + +double? _readOptionalConstraintBound(JsonMap data, String key) { + if (!data.containsKey(key)) return null; + + final value = data[key]; + if (value == null) return double.infinity; + + return value as double; +} + +double? _encodeConstraintBound(double? value) { + if (value == null) return null; + + return value == double.infinity ? null : value; +} + +BorderRadiusMix _decodeBorderRadiusMix(Object value) { + if (value is Radius) return BorderRadiusMix.all(value); + + final data = value as JsonMap; + + return BorderRadiusMix( + topLeft: data['topLeft'] as Radius?, + topRight: data['topRight'] as Radius?, + bottomLeft: data['bottomLeft'] as Radius?, + bottomRight: data['bottomRight'] as Radius?, + ); +} + +Object _encodeBorderRadiusMix(BorderRadiusMix value) { + final topLeft = singleValueProp(value.$topLeft, 'topLeft'); + final topRight = singleValueProp(value.$topRight, 'topRight'); + final bottomLeft = singleValueProp(value.$bottomLeft, 'bottomLeft'); + final bottomRight = singleValueProp(value.$bottomRight, 'bottomRight'); + + if (topLeft != null && + topLeft == topRight && + topRight == bottomLeft && + bottomLeft == bottomRight) { + return topLeft; + } + + return { + 'topLeft': topLeft, + 'topRight': topRight, + 'bottomLeft': bottomLeft, + 'bottomRight': bottomRight, + }; +} diff --git a/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart b/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart new file mode 100644 index 0000000000..527f49f0cc --- /dev/null +++ b/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart @@ -0,0 +1,111 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import 'animation_codec.dart'; +import 'box_styler_codec.dart'; +import 'common_codecs.dart'; +import 'flex_styler_codec.dart'; +import 'modifier_codec.dart'; + +AckSchema flexBoxStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'alignment': alignmentCodec().optional(), + 'padding': edgeInsetsCodec().optional(), + 'margin': edgeInsetsCodec().optional(), + 'constraints': boxConstraintsCodec().optional(), + 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'decoration': boxDecorationCodec().optional(), + 'direction': enumNameCodec(Axis.values, debugName: 'Axis').optional(), + 'mainAxisAlignment': enumNameCodec( + MainAxisAlignment.values, + debugName: 'MainAxisAlignment', + ).optional(), + 'crossAxisAlignment': enumNameCodec( + CrossAxisAlignment.values, + debugName: 'CrossAxisAlignment', + ).optional(), + 'mainAxisSize': enumNameCodec( + MainAxisSize.values, + debugName: 'MainAxisSize', + ).optional(), + 'verticalDirection': enumNameCodec( + VerticalDirection.values, + debugName: 'VerticalDirection', + ).optional(), + 'textDirection': enumNameCodec( + TextDirection.values, + debugName: 'TextDirection', + ).optional(), + 'textBaseline': enumNameCodec( + TextBaseline.values, + debugName: 'TextBaseline', + ).optional(), + 'flexClipBehavior': enumNameCodec( + Clip.values, + debugName: 'Clip', + ).optional(), + 'spacing': numberAsDoubleCodec().optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => FlexBoxStyler( + alignment: data['alignment'] as Alignment?, + padding: data['padding'] as EdgeInsetsMix?, + margin: data['margin'] as EdgeInsetsMix?, + constraints: data['constraints'] as BoxConstraintsMix?, + clipBehavior: data['clipBehavior'] as Clip?, + decoration: data['decoration'] as BoxDecorationMix?, + direction: data['direction'] as Axis?, + mainAxisAlignment: data['mainAxisAlignment'] as MainAxisAlignment?, + crossAxisAlignment: data['crossAxisAlignment'] as CrossAxisAlignment?, + mainAxisSize: data['mainAxisSize'] as MainAxisSize?, + verticalDirection: data['verticalDirection'] as VerticalDirection?, + textDirection: data['textDirection'] as TextDirection?, + textBaseline: data['textBaseline'] as TextBaseline?, + flexClipBehavior: data['flexClipBehavior'] as Clip?, + spacing: data['spacing'] as double?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: _encodeFlexBoxStyler, + ); +} + +JsonMap _encodeFlexBoxStyler(FlexBoxStyler value) { + _failIfPresent(value.$variants, 'variants'); + + final box = singleMixProp>(value.$box, 'box'); + final flex = singleMixProp>( + value.$flex, + 'flex', + ); + final boxFields = box == null + ? {} + : encodeBoxStylerFields(box, includeStylerMetadata: false); + final flexFields = flex == null + ? {} + : encodeFlexStylerFields(flex, includeStylerMetadata: false); + + return { + ...boxFields, + ...flexFields, + 'clipBehavior': boxFields['clipBehavior'], + 'flexClipBehavior': flexFields['clipBehavior'], + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/flex_styler_codec.dart b/packages/mix_schema/lib/src/schema/flex_styler_codec.dart new file mode 100644 index 0000000000..d1c1a96665 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/flex_styler_codec.dart @@ -0,0 +1,105 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import 'animation_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; + +AckSchema flexStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'direction': enumNameCodec(Axis.values, debugName: 'Axis').optional(), + 'mainAxisAlignment': enumNameCodec( + MainAxisAlignment.values, + debugName: 'MainAxisAlignment', + ).optional(), + 'crossAxisAlignment': enumNameCodec( + CrossAxisAlignment.values, + debugName: 'CrossAxisAlignment', + ).optional(), + 'mainAxisSize': enumNameCodec( + MainAxisSize.values, + debugName: 'MainAxisSize', + ).optional(), + 'verticalDirection': enumNameCodec( + VerticalDirection.values, + debugName: 'VerticalDirection', + ).optional(), + 'textDirection': enumNameCodec( + TextDirection.values, + debugName: 'TextDirection', + ).optional(), + 'textBaseline': enumNameCodec( + TextBaseline.values, + debugName: 'TextBaseline', + ).optional(), + 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'spacing': numberAsDoubleCodec().optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => FlexStyler( + direction: data['direction'] as Axis?, + mainAxisAlignment: data['mainAxisAlignment'] as MainAxisAlignment?, + crossAxisAlignment: data['crossAxisAlignment'] as CrossAxisAlignment?, + mainAxisSize: data['mainAxisSize'] as MainAxisSize?, + verticalDirection: data['verticalDirection'] as VerticalDirection?, + textDirection: data['textDirection'] as TextDirection?, + textBaseline: data['textBaseline'] as TextBaseline?, + clipBehavior: data['clipBehavior'] as Clip?, + spacing: data['spacing'] as double?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: encodeFlexStylerFields, + ); +} + +JsonMap encodeFlexStylerFields( + FlexStyler value, { + bool includeStylerMetadata = true, +}) { + _failIfPresent(value.$variants, 'variants'); + + final encoded = { + 'direction': singleValueProp(value.$direction, 'direction'), + 'mainAxisAlignment': singleValueProp( + value.$mainAxisAlignment, + 'mainAxisAlignment', + ), + 'crossAxisAlignment': singleValueProp( + value.$crossAxisAlignment, + 'crossAxisAlignment', + ), + 'mainAxisSize': singleValueProp(value.$mainAxisSize, 'mainAxisSize'), + 'verticalDirection': singleValueProp( + value.$verticalDirection, + 'verticalDirection', + ), + 'textDirection': singleValueProp(value.$textDirection, 'textDirection'), + 'textBaseline': singleValueProp(value.$textBaseline, 'textBaseline'), + 'clipBehavior': singleValueProp(value.$clipBehavior, 'clipBehavior'), + 'spacing': singleValueProp(value.$spacing, 'spacing'), + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; + + if (includeStylerMetadata) return encoded; + + return Map.from(encoded) + ..remove('modifiers') + ..remove('animation'); +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/icon_styler_codec.dart b/packages/mix_schema/lib/src/schema/icon_styler_codec.dart new file mode 100644 index 0000000000..c8463b10e0 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/icon_styler_codec.dart @@ -0,0 +1,92 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import '../registry/registry_value_codec.dart'; +import 'animation_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; + +AckSchema iconStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'icon': registryValueCodecFrom( + registry, + MixSchemaScope.iconData, + ).optional(), + 'color': colorCodec().optional(), + 'size': numberAsDoubleCodec().optional(), + 'weight': numberAsDoubleCodec().optional(), + 'grade': numberAsDoubleCodec().optional(), + 'opticalSize': numberAsDoubleCodec().optional(), + 'textDirection': enumNameCodec( + TextDirection.values, + debugName: 'TextDirection', + ).optional(), + 'applyTextScaling': Ack.boolean().optional(), + 'fill': numberAsDoubleCodec().optional(), + 'semanticsLabel': Ack.string().optional(), + 'opacity': numberAsDoubleCodec().optional(), + 'blendMode': enumNameCodec( + BlendMode.values, + debugName: 'BlendMode', + ).optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => IconStyler( + icon: data['icon'] as IconData?, + color: data['color'] as Color?, + size: data['size'] as double?, + weight: data['weight'] as double?, + grade: data['grade'] as double?, + opticalSize: data['opticalSize'] as double?, + textDirection: data['textDirection'] as TextDirection?, + applyTextScaling: data['applyTextScaling'] as bool?, + fill: data['fill'] as double?, + semanticsLabel: data['semanticsLabel'] as String?, + opacity: data['opacity'] as double?, + blendMode: data['blendMode'] as BlendMode?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: _encodeIconStyler, + ); +} + +JsonMap _encodeIconStyler(IconStyler value) { + _failIfPresent(value.$variants, 'variants'); + _failIfPresent(value.$shadows, 'shadows'); + + return { + 'icon': singleValueProp(value.$icon, 'icon'), + 'color': singleValueProp(value.$color, 'color'), + 'size': singleValueProp(value.$size, 'size'), + 'weight': singleValueProp(value.$weight, 'weight'), + 'grade': singleValueProp(value.$grade, 'grade'), + 'opticalSize': singleValueProp(value.$opticalSize, 'opticalSize'), + 'textDirection': singleValueProp(value.$textDirection, 'textDirection'), + 'applyTextScaling': singleValueProp( + value.$applyTextScaling, + 'applyTextScaling', + ), + 'fill': singleValueProp(value.$fill, 'fill'), + 'semanticsLabel': singleValueProp(value.$semanticsLabel, 'semanticsLabel'), + 'opacity': singleValueProp(value.$opacity, 'opacity'), + 'blendMode': singleValueProp(value.$blendMode, 'blendMode'), + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/image_styler_codec.dart b/packages/mix_schema/lib/src/schema/image_styler_codec.dart new file mode 100644 index 0000000000..6555f656dd --- /dev/null +++ b/packages/mix_schema/lib/src/schema/image_styler_codec.dart @@ -0,0 +1,107 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import '../registry/registry_value_codec.dart'; +import 'animation_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; + +AckSchema imageStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'image': registryValueCodecFrom>( + registry, + MixSchemaScope.imageProvider, + ).optional(), + 'width': numberAsDoubleCodec().optional(), + 'height': numberAsDoubleCodec().optional(), + 'color': colorCodec().optional(), + 'repeat': enumNameCodec( + ImageRepeat.values, + debugName: 'ImageRepeat', + ).optional(), + 'fit': enumNameCodec(BoxFit.values, debugName: 'BoxFit').optional(), + 'alignment': alignmentCodec().optional(), + 'filterQuality': enumNameCodec( + FilterQuality.values, + debugName: 'FilterQuality', + ).optional(), + 'colorBlendMode': enumNameCodec( + BlendMode.values, + debugName: 'BlendMode', + ).optional(), + 'semanticLabel': Ack.string().optional(), + 'excludeFromSemantics': Ack.boolean().optional(), + 'gaplessPlayback': Ack.boolean().optional(), + 'isAntiAlias': Ack.boolean().optional(), + 'matchTextDirection': Ack.boolean().optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => ImageStyler( + image: data['image'] as ImageProvider?, + width: data['width'] as double?, + height: data['height'] as double?, + color: data['color'] as Color?, + repeat: data['repeat'] as ImageRepeat?, + fit: data['fit'] as BoxFit?, + alignment: data['alignment'] as Alignment?, + filterQuality: data['filterQuality'] as FilterQuality?, + colorBlendMode: data['colorBlendMode'] as BlendMode?, + semanticLabel: data['semanticLabel'] as String?, + excludeFromSemantics: data['excludeFromSemantics'] as bool?, + gaplessPlayback: data['gaplessPlayback'] as bool?, + isAntiAlias: data['isAntiAlias'] as bool?, + matchTextDirection: data['matchTextDirection'] as bool?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: _encodeImageStyler, + ); +} + +JsonMap _encodeImageStyler(ImageStyler value) { + _failIfPresent(value.$variants, 'variants'); + _failIfPresent(value.$centerSlice, 'centerSlice'); + + return { + 'image': singleValueProp(value.$image, 'image'), + 'width': singleValueProp(value.$width, 'width'), + 'height': singleValueProp(value.$height, 'height'), + 'color': singleValueProp(value.$color, 'color'), + 'repeat': singleValueProp(value.$repeat, 'repeat'), + 'fit': singleValueProp(value.$fit, 'fit'), + 'alignment': singleAlignmentProp(value.$alignment, 'alignment'), + 'filterQuality': singleValueProp(value.$filterQuality, 'filterQuality'), + 'colorBlendMode': singleValueProp(value.$colorBlendMode, 'colorBlendMode'), + 'semanticLabel': singleValueProp(value.$semanticLabel, 'semanticLabel'), + 'excludeFromSemantics': singleValueProp( + value.$excludeFromSemantics, + 'excludeFromSemantics', + ), + 'gaplessPlayback': singleValueProp( + value.$gaplessPlayback, + 'gaplessPlayback', + ), + 'isAntiAlias': singleValueProp(value.$isAntiAlias, 'isAntiAlias'), + 'matchTextDirection': singleValueProp( + value.$matchTextDirection, + 'matchTextDirection', + ), + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/modifier_codec.dart b/packages/mix_schema/lib/src/schema/modifier_codec.dart new file mode 100644 index 0000000000..9a372912b8 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/modifier_codec.dart @@ -0,0 +1,121 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import 'common_codecs.dart'; +import 'text_styler_codec.dart'; + +AckSchema modifierConfigCodec() { + return Ack.codec( + input: Ack.list(modifierCodec()), + decode: (value) => + WidgetModifierConfig.modifiers((value as List).cast()), + encode: _encodeModifierConfig, + ); +} + +AckSchema modifierCodec() { + return Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'opacity': _opacityModifierCodec(), + 'blur': _blurModifierCodec(), + 'default_text_style': _defaultTextStyleModifierCodec(), + }, + ); +} + +AckSchema _opacityModifierCodec() { + return Ack.object({ + 'opacity': numberAsDoubleCodec(), + }).codec( + decode: (data) => OpacityModifierMix(opacity: data['opacity']! as double), + encode: (value) => { + 'opacity': singleValueProp(value.opacity, 'modifiers.opacity.opacity'), + }, + ); +} + +AckSchema _blurModifierCodec() { + return Ack.object({'sigma': numberAsDoubleCodec()}).codec( + decode: (data) => BlurModifierMix(sigma: data['sigma']! as double), + encode: (value) => { + 'sigma': singleValueProp(value.sigma, 'modifiers.blur.sigma'), + }, + ); +} + +AckSchema +_defaultTextStyleModifierCodec() { + return Ack.object({ + 'style': textStyleMixCodec().optional(), + 'textAlign': textAlignCodec().optional(), + 'softWrap': Ack.boolean().optional(), + 'overflow': textOverflowCodec().optional(), + 'maxLines': Ack.integer().optional(), + }).codec( + decode: (data) => DefaultTextStyleModifierMix( + style: data['style'] as TextStyleMix?, + textAlign: data['textAlign'] as TextAlign?, + softWrap: data['softWrap'] as bool?, + overflow: data['overflow'] as TextOverflow?, + maxLines: data['maxLines'] as int?, + ), + encode: _encodeDefaultTextStyleModifier, + ); +} + +Object _encodeModifierConfig(WidgetModifierConfig value) { + if (value.$orderOfModifiers?.isNotEmpty == true) { + throw UnsupportedEncodeValueError( + value.$orderOfModifiers, + 'Custom modifier order is not representable.', + ); + } + + return value.$modifiers ?? const []; +} + +JsonMap _encodeDefaultTextStyleModifier(DefaultTextStyleModifierMix value) { + _failIfPresent( + value.textWidthBasis, + 'modifiers.defaultTextStyle.textWidthBasis', + ); + _failIfPresent( + value.textHeightBehavior, + 'modifiers.defaultTextStyle.textHeightBehavior', + ); + + return { + 'style': singleMixProp( + value.style, + 'modifiers.defaultTextStyle.style', + ), + 'textAlign': singleValueProp( + value.textAlign, + 'modifiers.defaultTextStyle.textAlign', + ), + 'softWrap': singleValueProp( + value.softWrap, + 'modifiers.defaultTextStyle.softWrap', + ), + 'overflow': singleValueProp( + value.overflow, + 'modifiers.defaultTextStyle.overflow', + ), + 'maxLines': singleValueProp( + value.maxLines, + 'modifiers.defaultTextStyle.maxLines', + ), + }; +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/primitive_wire.dart b/packages/mix_schema/lib/src/schema/primitive_wire.dart new file mode 100644 index 0000000000..3728d3e718 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/primitive_wire.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; + +String encodeColorWire(Color value) { + final argb = value.toARGB32(); + final alpha = (argb >> 24) & 0xFF; + final red = (argb >> 16) & 0xFF; + final green = (argb >> 8) & 0xFF; + final blue = argb & 0xFF; + + if (alpha == 0xFF) { + return '#${_hex(red)}${_hex(green)}${_hex(blue)}'; + } + + return '#${_hex(alpha)}${_hex(red)}${_hex(green)}${_hex(blue)}'; +} + +Object encodeAlignmentWire(Alignment value) { + for (final entry in namedAlignments.entries) { + if (entry.value == value) return entry.key; + } + + return {'x': value.x, 'y': value.y}; +} + +Object encodeEdgeInsetsWire({ + required double? left, + required double? top, + required double? right, + required double? bottom, +}) { + if (left != null && left == top && top == right && right == bottom) { + return left; + } + + final payload = {}; + if (left != null) payload['left'] = left; + if (top != null) payload['top'] = top; + if (right != null) payload['right'] = right; + if (bottom != null) payload['bottom'] = bottom; + return payload; +} + +const namedAlignments = { + 'topLeft': Alignment.topLeft, + 'topCenter': Alignment.topCenter, + 'topRight': Alignment.topRight, + 'centerLeft': Alignment.centerLeft, + 'center': Alignment.center, + 'centerRight': Alignment.centerRight, + 'bottomLeft': Alignment.bottomLeft, + 'bottomCenter': Alignment.bottomCenter, + 'bottomRight': Alignment.bottomRight, +}; + +String _hex(int value) => value.toRadixString(16).padLeft(2, '0').toUpperCase(); diff --git a/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart b/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart new file mode 100644 index 0000000000..c54df4ec27 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart @@ -0,0 +1,87 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import 'animation_codec.dart'; +import 'box_styler_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; +import 'stack_styler_codec.dart'; + +AckSchema stackBoxStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'alignment': alignmentCodec().optional(), + 'padding': edgeInsetsCodec().optional(), + 'margin': edgeInsetsCodec().optional(), + 'constraints': boxConstraintsCodec().optional(), + 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'decoration': boxDecorationCodec().optional(), + 'stackAlignment': alignmentCodec().optional(), + 'fit': enumNameCodec(StackFit.values, debugName: 'StackFit').optional(), + 'textDirection': enumNameCodec( + TextDirection.values, + debugName: 'TextDirection', + ).optional(), + 'stackClipBehavior': enumNameCodec( + Clip.values, + debugName: 'Clip', + ).optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => StackBoxStyler( + alignment: data['alignment'] as Alignment?, + padding: data['padding'] as EdgeInsetsMix?, + margin: data['margin'] as EdgeInsetsMix?, + constraints: data['constraints'] as BoxConstraintsMix?, + clipBehavior: data['clipBehavior'] as Clip?, + decoration: data['decoration'] as BoxDecorationMix?, + stackAlignment: data['stackAlignment'] as Alignment?, + fit: data['fit'] as StackFit?, + textDirection: data['textDirection'] as TextDirection?, + stackClipBehavior: data['stackClipBehavior'] as Clip?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: _encodeStackBoxStyler, + ); +} + +JsonMap _encodeStackBoxStyler(StackBoxStyler value) { + _failIfPresent(value.$variants, 'variants'); + + final box = singleMixProp>(value.$box, 'box'); + final stack = singleMixProp>( + value.$stack, + 'stack', + ); + final boxFields = box == null + ? {} + : encodeBoxStylerFields(box, includeStylerMetadata: false); + final stackFields = stack == null + ? {} + : encodeStackStylerFields(stack, includeStylerMetadata: false); + + return { + ...boxFields, + 'stackAlignment': stackFields['alignment'], + 'fit': stackFields['fit'], + 'textDirection': stackFields['textDirection'], + 'stackClipBehavior': stackFields['clipBehavior'], + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/stack_styler_codec.dart b/packages/mix_schema/lib/src/schema/stack_styler_codec.dart new file mode 100644 index 0000000000..32d6a625a6 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/stack_styler_codec.dart @@ -0,0 +1,66 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import 'animation_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; + +AckSchema stackStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'alignment': alignmentCodec().optional(), + 'fit': enumNameCodec(StackFit.values, debugName: 'StackFit').optional(), + 'textDirection': enumNameCodec( + TextDirection.values, + debugName: 'TextDirection', + ).optional(), + 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => StackStyler( + alignment: data['alignment'] as Alignment?, + fit: data['fit'] as StackFit?, + textDirection: data['textDirection'] as TextDirection?, + clipBehavior: data['clipBehavior'] as Clip?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: encodeStackStylerFields, + ); +} + +JsonMap encodeStackStylerFields( + StackStyler value, { + bool includeStylerMetadata = true, +}) { + _failIfPresent(value.$variants, 'variants'); + + final encoded = { + 'alignment': singleAlignmentProp(value.$alignment, 'alignment'), + 'fit': singleValueProp(value.$fit, 'fit'), + 'textDirection': singleValueProp(value.$textDirection, 'textDirection'), + 'clipBehavior': singleValueProp(value.$clipBehavior, 'clipBehavior'), + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; + + if (includeStylerMetadata) return encoded; + + return Map.from(encoded) + ..remove('modifiers') + ..remove('animation'); +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/styler_branch.dart b/packages/mix_schema/lib/src/schema/styler_branch.dart new file mode 100644 index 0000000000..b27a7df6e6 --- /dev/null +++ b/packages/mix_schema/lib/src/schema/styler_branch.dart @@ -0,0 +1,22 @@ +import 'package:ack/ack.dart'; + +import '../errors/mix_schema_error.dart'; + +AckSchema widenStylerBranch( + AckSchema branch, { + String? debugName, +}) { + return Ack.codec( + input: branch, + decode: (value) => value, + encode: (value) { + if (value is T) return value; + + throw UnsupportedEncodeValueError( + value, + 'Expected ${debugName ?? T.toString()}, got ${value.runtimeType}.', + ); + }, + output: Ack.instance(), + ); +} diff --git a/packages/mix_schema/lib/src/schema/text_styler_codec.dart b/packages/mix_schema/lib/src/schema/text_styler_codec.dart new file mode 100644 index 0000000000..56ee1dcced --- /dev/null +++ b/packages/mix_schema/lib/src/schema/text_styler_codec.dart @@ -0,0 +1,217 @@ +import 'package:ack/ack.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import 'animation_codec.dart'; +import 'common_codecs.dart'; +import 'modifier_codec.dart'; + +AckSchema textStylerCodec({ + required FrozenRegistry Function() registry, +}) { + return Ack.object({ + 'overflow': textOverflowCodec().optional(), + 'textAlign': textAlignCodec().optional(), + 'maxLines': Ack.integer().optional(), + 'style': textStyleMixCodec().optional(), + 'textDirection': textDirectionCodec().optional(), + 'softWrap': Ack.boolean().optional(), + 'selectionColor': colorCodec().optional(), + 'semanticsLabel': Ack.string().optional(), + 'modifiers': modifierConfigCodec().optional(), + 'animation': animationConfigCodec(registry: registry).optional(), + }).codec( + decode: (data) => TextStyler( + overflow: data['overflow'] as TextOverflow?, + textAlign: data['textAlign'] as TextAlign?, + maxLines: data['maxLines'] as int?, + style: data['style'] as TextStyleMix?, + textDirection: data['textDirection'] as TextDirection?, + softWrap: data['softWrap'] as bool?, + selectionColor: data['selectionColor'] as Color?, + semanticsLabel: data['semanticsLabel'] as String?, + modifier: data['modifiers'] as WidgetModifierConfig?, + animation: data['animation'] as AnimationConfig?, + ), + encode: _encodeTextStyler, + ); +} + +JsonMap _encodeTextStyler(TextStyler value) { + _failIfPresent(value.$strutStyle, 'strutStyle'); + _failIfPresent(value.$textScaler, 'textScaler'); + _failIfPresent(value.$textWidthBasis, 'textWidthBasis'); + _failIfPresent(value.$textHeightBehavior, 'textHeightBehavior'); + _failIfPresent(value.$textDirectives, 'textDirectives'); + _failIfPresent(value.$locale, 'locale'); + _failIfPresent(value.$variants, 'variants'); + + return { + 'overflow': singleValueProp(value.$overflow, 'overflow'), + 'textAlign': singleValueProp(value.$textAlign, 'textAlign'), + 'maxLines': singleValueProp(value.$maxLines, 'maxLines'), + 'style': singleMixProp(value.$style, 'style'), + 'textDirection': singleValueProp(value.$textDirection, 'textDirection'), + 'softWrap': singleValueProp(value.$softWrap, 'softWrap'), + 'selectionColor': singleValueProp(value.$selectionColor, 'selectionColor'), + 'semanticsLabel': singleValueProp(value.$semanticsLabel, 'semanticsLabel'), + 'modifiers': value.$modifier, + 'animation': value.$animation, + }; +} + +CodecSchema textStyleMixCodec() { + return Ack.object({ + 'color': colorCodec().optional(), + 'backgroundColor': colorCodec().optional(), + 'fontSize': numberAsDoubleCodec().optional(), + 'fontWeight': fontWeightCodec().optional(), + 'fontStyle': fontStyleCodec().optional(), + 'letterSpacing': numberAsDoubleCodec().optional(), + 'wordSpacing': numberAsDoubleCodec().optional(), + 'height': numberAsDoubleCodec().optional(), + 'fontFamily': Ack.string().optional(), + 'decoration': textDecorationCodec().optional(), + 'decorationColor': colorCodec().optional(), + 'decorationStyle': textDecorationStyleCodec().optional(), + 'decorationThickness': numberAsDoubleCodec().optional(), + }).codec( + decode: (data) => TextStyleMix( + color: data['color'] as Color?, + backgroundColor: data['backgroundColor'] as Color?, + fontSize: data['fontSize'] as double?, + fontWeight: data['fontWeight'] as FontWeight?, + fontStyle: data['fontStyle'] as FontStyle?, + letterSpacing: data['letterSpacing'] as double?, + wordSpacing: data['wordSpacing'] as double?, + height: data['height'] as double?, + fontFamily: data['fontFamily'] as String?, + decoration: data['decoration'] as TextDecoration?, + decorationColor: data['decorationColor'] as Color?, + decorationStyle: data['decorationStyle'] as TextDecorationStyle?, + decorationThickness: data['decorationThickness'] as double?, + ), + encode: _encodeTextStyle, + ); +} + +JsonMap _encodeTextStyle(TextStyleMix value) { + _failIfPresent(value.$debugLabel, 'style.debugLabel'); + _failIfPresent(value.$textBaseline, 'style.textBaseline'); + _failIfPresent(value.$foreground, 'style.foreground'); + _failIfPresent(value.$background, 'style.background'); + _failIfPresent(value.$inherit, 'style.inherit'); + _failIfPresent(value.$fontFamilyFallback, 'style.fontFamilyFallback'); + _failIfPresent(value.$fontFeatures, 'style.fontFeatures'); + _failIfPresent(value.$fontVariations, 'style.fontVariations'); + _failIfPresent(value.$shadows, 'style.shadows'); + + return { + 'color': singleValueProp(value.$color, 'style.color'), + 'backgroundColor': singleValueProp( + value.$backgroundColor, + 'style.backgroundColor', + ), + 'fontSize': singleValueProp(value.$fontSize, 'style.fontSize'), + 'fontWeight': singleValueProp(value.$fontWeight, 'style.fontWeight'), + 'fontStyle': singleValueProp(value.$fontStyle, 'style.fontStyle'), + 'letterSpacing': singleValueProp( + value.$letterSpacing, + 'style.letterSpacing', + ), + 'wordSpacing': singleValueProp(value.$wordSpacing, 'style.wordSpacing'), + 'height': singleValueProp(value.$height, 'style.height'), + 'fontFamily': singleValueProp(value.$fontFamily, 'style.fontFamily'), + 'decoration': singleValueProp(value.$decoration, 'style.decoration'), + 'decorationColor': singleValueProp( + value.$decorationColor, + 'style.decorationColor', + ), + 'decorationStyle': singleValueProp( + value.$decorationStyle, + 'style.decorationStyle', + ), + 'decorationThickness': singleValueProp( + value.$decorationThickness, + 'style.decorationThickness', + ), + }; +} + +CodecSchema textOverflowCodec() { + return strictEnumCodec({ + 'clip': TextOverflow.clip, + 'fade': TextOverflow.fade, + 'ellipsis': TextOverflow.ellipsis, + 'visible': TextOverflow.visible, + }, debugName: 'TextOverflow'); +} + +CodecSchema textAlignCodec() { + return strictEnumCodec({ + 'left': TextAlign.left, + 'right': TextAlign.right, + 'center': TextAlign.center, + 'justify': TextAlign.justify, + 'start': TextAlign.start, + 'end': TextAlign.end, + }, debugName: 'TextAlign'); +} + +CodecSchema textDirectionCodec() { + return strictEnumCodec({ + 'ltr': TextDirection.ltr, + 'rtl': TextDirection.rtl, + }, debugName: 'TextDirection'); +} + +CodecSchema fontWeightCodec() { + return strictEnumCodec({ + 'w100': FontWeight.w100, + 'w200': FontWeight.w200, + 'w300': FontWeight.w300, + 'w400': FontWeight.w400, + 'w500': FontWeight.w500, + 'w600': FontWeight.w600, + 'w700': FontWeight.w700, + 'w800': FontWeight.w800, + 'w900': FontWeight.w900, + }, debugName: 'FontWeight'); +} + +CodecSchema fontStyleCodec() { + return strictEnumCodec({ + 'normal': FontStyle.normal, + 'italic': FontStyle.italic, + }, debugName: 'FontStyle'); +} + +CodecSchema textDecorationCodec() { + return strictEnumCodec({ + 'none': TextDecoration.none, + 'underline': TextDecoration.underline, + 'overline': TextDecoration.overline, + 'line_through': TextDecoration.lineThrough, + }, debugName: 'TextDecoration'); +} + +CodecSchema textDecorationStyleCodec() { + return strictEnumCodec({ + 'solid': TextDecorationStyle.solid, + 'double': TextDecorationStyle.double, + 'dotted': TextDecorationStyle.dotted, + 'dashed': TextDecorationStyle.dashed, + 'wavy': TextDecorationStyle.wavy, + }, debugName: 'TextDecorationStyle'); +} + +void _failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} diff --git a/packages/mix_schema/lib/src/schema/variant_codec.dart b/packages/mix_schema/lib/src/schema/variant_codec.dart new file mode 100644 index 0000000000..5fb737862f --- /dev/null +++ b/packages/mix_schema/lib/src/schema/variant_codec.dart @@ -0,0 +1,571 @@ +import 'dart:convert'; + +import 'package:ack/ack.dart'; +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; + +import '../errors/mix_schema_error.dart'; +import '../registry/registry.dart'; +import '../registry/registry_value_codec.dart'; +import 'common_codecs.dart'; + +const _allOfPrefix = 'mix_schema_all_of:'; + +AckSchema> boxVariantCodec( + AckSchema rootStyleSchema, + FrozenRegistry Function() registry, +) { + return Ack.discriminated>( + discriminatorKey: 'kind', + schemas: { + 'named': _namedVariantCodec(rootStyleSchema), + 'widget_state': _widgetStateVariantCodec(rootStyleSchema), + 'enabled': _enabledVariantCodec(rootStyleSchema), + 'context_brightness': _brightnessVariantCodec(rootStyleSchema), + 'context_breakpoint': _breakpointVariantCodec(rootStyleSchema), + 'context_not_widget_state': _notWidgetStateVariantCodec(rootStyleSchema), + 'context_all_of': _allOfVariantCodec(rootStyleSchema), + 'context_variant_builder': _contextVariantBuilderCodec(registry), + }, + ); +} + +AckSchema> _namedVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({ + 'name': Ack.string().notEmpty(), + 'style': rootStyleSchema, + }).codec>( + decode: (data) => VariantStyle( + NamedVariant(data['name']! as String), + _boxStyle(data['style']!), + ), + encode: (value) { + final variant = value.variant; + if (variant is! NamedVariant) { + throw UnsupportedEncodeValueError(variant, 'Expected NamedVariant.'); + } + + return {'name': variant.name, 'style': value.value}; + }, + ); +} + +AckSchema> _widgetStateVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({ + 'state': _widgetStateCodec(), + 'style': rootStyleSchema, + }).codec>( + decode: (data) => VariantStyle( + ContextVariant.widgetState(data['state']! as WidgetState), + _boxStyle(data['style']!), + ), + encode: (value) { + final variant = value.variant; + if (variant is! WidgetStateVariant) { + throw UnsupportedEncodeValueError( + variant, + 'Expected WidgetStateVariant.', + ); + } + + return {'state': variant.state, 'style': value.value}; + }, + ); +} + +AckSchema> _enabledVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({'style': rootStyleSchema}).codec>( + decode: (data) => VariantStyle( + ContextVariant.not(ContextVariant.widgetState(WidgetState.disabled)), + _boxStyle(data['style']!), + ), + encode: (value) { + final variant = value.variant; + if (variant.key != 'not_widget_state_disabled') { + throw UnsupportedEncodeValueError(variant, 'Expected enabled variant.'); + } + + return {'style': value.value}; + }, + ); +} + +AckSchema> _brightnessVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({ + 'brightness': _brightnessCodec(), + 'style': rootStyleSchema, + }).codec>( + decode: (data) => VariantStyle( + ContextVariant.brightness(data['brightness']! as Brightness), + _boxStyle(data['style']!), + ), + encode: (value) { + final brightness = _brightnessFromKey(value.variant.key); + if (brightness == null) { + throw UnsupportedEncodeValueError( + value.variant, + 'Expected brightness context variant.', + ); + } + + return {'brightness': brightness, 'style': value.value}; + }, + ); +} + +AckSchema> _breakpointVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({ + 'minWidth': numberAsDoubleCodec().optional(), + 'maxWidth': numberAsDoubleCodec().optional(), + 'style': rootStyleSchema, + }).codec>( + decode: (data) { + final minWidth = data['minWidth'] as double?; + final maxWidth = data['maxWidth'] as double?; + if (minWidth == null && maxWidth == null) { + throw const UnsupportedEncodeValueError( + null, + 'A context_breakpoint variant requires minWidth or maxWidth.', + ); + } + + return VariantStyle( + ContextVariant.breakpoint( + Breakpoint(minWidth: minWidth, maxWidth: maxWidth), + ), + _boxStyle(data['style']!), + ); + }, + encode: (value) { + final breakpoint = _breakpointFromKey(value.variant.key); + if (breakpoint == null) { + throw UnsupportedEncodeValueError( + value.variant, + 'Expected breakpoint context variant.', + ); + } + + return { + 'minWidth': breakpoint.minWidth, + 'maxWidth': breakpoint.maxWidth, + 'style': value.value, + }; + }, + ); +} + +AckSchema> _notWidgetStateVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({ + 'state': _widgetStateCodec(), + 'style': rootStyleSchema, + }).codec>( + decode: (data) { + final state = data['state']! as WidgetState; + + return VariantStyle( + ContextVariant.not(ContextVariant.widgetState(state)), + _boxStyle(data['style']!), + ); + }, + encode: (value) { + final state = _notWidgetStateFromKey(value.variant.key); + if (state == null || state == WidgetState.disabled) { + throw UnsupportedEncodeValueError( + value.variant, + 'Expected non-enabled not-widget-state context variant.', + ); + } + + return {'state': state, 'style': value.value}; + }, + ); +} + +AckSchema> _allOfVariantCodec( + AckSchema rootStyleSchema, +) { + return Ack.object({ + 'conditions': Ack.list(_contextConditionCodec()), + 'style': rootStyleSchema, + }).codec>( + decode: (data) { + final conditions = data['conditions']! as List<_ContextCondition>; + + return VariantStyle( + _allOfVariant(conditions), + _boxStyle(data['style']!), + ); + }, + encode: (value) { + final conditions = _conditionsFromAllOfKey(value.variant.key); + if (conditions == null) { + throw UnsupportedEncodeValueError( + value.variant, + 'Expected context_all_of variant.', + ); + } + + return {'conditions': conditions, 'style': value.value}; + }, + ); +} + +AckSchema> _contextVariantBuilderCodec( + FrozenRegistry Function() registry, +) { + return Ack.object({ + 'builder': registryValueCodecFrom( + registry, + MixSchemaScope.contextVariantBuilder, + ), + }).codec>( + decode: (data) { + final builder = data['builder']! as BoxStyler Function(BuildContext); + + return VariantStyle( + ContextVariantBuilder(builder), + BoxStyler(), + ); + }, + encode: (value) { + final variant = value.variant; + if (variant is! ContextVariantBuilder) { + throw UnsupportedEncodeValueError( + variant, + 'Expected context variant builder.', + ); + } + + return {'builder': variant.fn}; + }, + ); +} + +AckSchema _contextConditionCodec() { + return Ack.discriminated<_ContextCondition>( + discriminatorKey: 'kind', + schemas: { + 'widget_state': Ack.object({'state': _widgetStateCodec()}) + .codec<_ContextCondition>( + decode: (data) => + _ContextCondition.widgetState(data['state']! as WidgetState), + encode: (value) { + final state = value.widgetState; + if (state == null || value.negated) { + throw UnsupportedEncodeValueError( + value, + 'Expected widget_state condition.', + ); + } + + return {'state': state}; + }, + ), + 'enabled': Ack.object({}).codec<_ContextCondition>( + decode: (_) => _ContextCondition.notWidgetState(WidgetState.disabled), + encode: (value) { + if (value.widgetState != WidgetState.disabled || !value.negated) { + throw UnsupportedEncodeValueError( + value, + 'Expected enabled condition.', + ); + } + + return const {}; + }, + ), + 'context_not_widget_state': Ack.object({'state': _widgetStateCodec()}) + .codec<_ContextCondition>( + decode: (data) => + _ContextCondition.notWidgetState(data['state']! as WidgetState), + encode: (value) { + final state = value.widgetState; + if (state == null || + !value.negated || + state == WidgetState.disabled) { + throw UnsupportedEncodeValueError( + value, + 'Expected not-widget-state condition.', + ); + } + + return {'state': state}; + }, + ), + 'context_brightness': Ack.object({'brightness': _brightnessCodec()}) + .codec<_ContextCondition>( + decode: (data) => + _ContextCondition.brightness(data['brightness']! as Brightness), + encode: (value) { + final brightness = value.brightness; + if (brightness == null) { + throw UnsupportedEncodeValueError( + value, + 'Expected brightness condition.', + ); + } + + return {'brightness': brightness}; + }, + ), + 'context_breakpoint': + Ack.object({ + 'minWidth': numberAsDoubleCodec().optional(), + 'maxWidth': numberAsDoubleCodec().optional(), + }).codec<_ContextCondition>( + decode: (data) { + final minWidth = data['minWidth'] as double?; + final maxWidth = data['maxWidth'] as double?; + if (minWidth == null && maxWidth == null) { + throw const UnsupportedEncodeValueError( + null, + 'A breakpoint condition requires minWidth or maxWidth.', + ); + } + + return _ContextCondition.breakpoint( + Breakpoint(minWidth: minWidth, maxWidth: maxWidth), + ); + }, + encode: (value) { + final breakpoint = value.breakpoint; + if (breakpoint == null) { + throw UnsupportedEncodeValueError( + value, + 'Expected breakpoint condition.', + ); + } + + return { + 'minWidth': breakpoint.minWidth, + 'maxWidth': breakpoint.maxWidth, + }; + }, + ), + }, + ); +} + +CodecSchema _widgetStateCodec() { + return strictEnumCodec({ + 'hovered': WidgetState.hovered, + 'focused': WidgetState.focused, + 'pressed': WidgetState.pressed, + 'dragged': WidgetState.dragged, + 'selected': WidgetState.selected, + 'scrolled_under': WidgetState.scrolledUnder, + 'disabled': WidgetState.disabled, + 'error': WidgetState.error, + }, debugName: 'WidgetState'); +} + +CodecSchema _brightnessCodec() { + return strictEnumCodec({ + 'light': Brightness.light, + 'dark': Brightness.dark, + }, debugName: 'Brightness'); +} + +Style _boxStyle(Object value) { + if (value is Style) return value; + + throw UnsupportedEncodeValueError( + value, + 'Nested variant style must decode to a Box style.', + ); +} + +Brightness? _brightnessFromKey(String key) { + return switch (key) { + 'media_query_platform_brightness_light' => Brightness.light, + 'media_query_platform_brightness_dark' => Brightness.dark, + _ => null, + }; +} + +Breakpoint? _breakpointFromKey(String key) { + final match = RegExp(r'^breakpoint_(.+)_(.+)$').firstMatch(key); + if (match == null) return null; + final min = match.group(1)!; + final max = match.group(2)!; + + return Breakpoint( + minWidth: min == '0.0' ? null : double.tryParse(min), + maxWidth: max == 'infinity' ? null : double.tryParse(max), + ); +} + +WidgetState? _notWidgetStateFromKey(String key) { + const prefix = 'not_widget_state_'; + if (!key.startsWith(prefix)) return null; + final wire = key.substring(prefix.length); + + return _widgetStateByWire[wire]; +} + +final Map _widgetStateByWire = { + 'hovered': WidgetState.hovered, + 'focused': WidgetState.focused, + 'pressed': WidgetState.pressed, + 'dragged': WidgetState.dragged, + 'selected': WidgetState.selected, + 'scrolled_under': WidgetState.scrolledUnder, + 'disabled': WidgetState.disabled, + 'error': WidgetState.error, +}; + +ContextVariant _allOfVariant(List<_ContextCondition> conditions) { + if (conditions.any((condition) => condition.kind == 'context_all_of')) { + throw const UnsupportedEncodeValueError( + null, + 'Nested context_all_of variants are not supported.', + ); + } + + final encoded = jsonEncode(conditions.map((c) => c.toJson()).toList()); + + return ContextVariant('$_allOfPrefix$encoded', (context) { + return conditions.every((condition) => condition.variant.when(context)); + }); +} + +List<_ContextCondition>? _conditionsFromAllOfKey(String key) { + if (!key.startsWith(_allOfPrefix)) return null; + final raw = jsonDecode(key.substring(_allOfPrefix.length)); + if (raw is! List) return null; + + return raw + .map((item) { + if (item is! JsonMap) { + throw UnsupportedEncodeValueError( + item, + 'Invalid all_of condition key.', + ); + } + + return _ContextCondition.fromJson(item); + }) + .toList(growable: false); +} + +final class _ContextCondition { + const _ContextCondition._({ + required this.kind, + required this.variant, + this.widgetState, + this.brightness, + this.breakpoint, + this.negated = false, + }); + + factory _ContextCondition.widgetState(WidgetState state) { + return _ContextCondition._( + kind: 'widget_state', + variant: ContextVariant.widgetState(state), + widgetState: state, + ); + } + + factory _ContextCondition.notWidgetState(WidgetState state) { + return _ContextCondition._( + kind: state == WidgetState.disabled + ? 'enabled' + : 'context_not_widget_state', + variant: ContextVariant.not(ContextVariant.widgetState(state)), + widgetState: state, + negated: true, + ); + } + + factory _ContextCondition.brightness(Brightness brightness) { + return _ContextCondition._( + kind: 'context_brightness', + variant: ContextVariant.brightness(brightness), + brightness: brightness, + ); + } + + factory _ContextCondition.breakpoint(Breakpoint breakpoint) { + return _ContextCondition._( + kind: 'context_breakpoint', + variant: ContextVariant.breakpoint(breakpoint), + breakpoint: breakpoint, + ); + } + + factory _ContextCondition.fromJson(JsonMap json) { + return switch (json['kind']) { + 'widget_state' => _ContextCondition.widgetState( + _widgetStateByWire[json['state']]!, + ), + 'enabled' => _ContextCondition.notWidgetState(WidgetState.disabled), + 'context_not_widget_state' => _ContextCondition.notWidgetState( + _widgetStateByWire[json['state']]!, + ), + 'context_brightness' => _ContextCondition.brightness( + json['brightness'] == 'dark' ? Brightness.dark : Brightness.light, + ), + 'context_breakpoint' => _ContextCondition.breakpoint( + Breakpoint( + minWidth: json['minWidth'] as double?, + maxWidth: json['maxWidth'] as double?, + ), + ), + _ => throw UnsupportedEncodeValueError( + json, + 'Unknown all_of condition kind.', + ), + }; + } + + final String kind; + final ContextVariant variant; + final WidgetState? widgetState; + final Brightness? brightness; + final Breakpoint? breakpoint; + final bool negated; + + JsonMap toJson() { + return switch (kind) { + 'widget_state' => {'kind': kind, 'state': _widgetStateWire(widgetState!)}, + 'enabled' => {'kind': kind}, + 'context_not_widget_state' => { + 'kind': kind, + 'state': _widgetStateWire(widgetState!), + }, + 'context_brightness' => { + 'kind': kind, + 'brightness': brightness == Brightness.dark ? 'dark' : 'light', + }, + 'context_breakpoint' => { + 'kind': kind, + 'minWidth': breakpoint!.minWidth, + 'maxWidth': breakpoint!.maxWidth, + }, + _ => throw UnsupportedEncodeValueError( + this, + 'Unknown all_of condition kind.', + ), + }; + } +} + +String _widgetStateWire(WidgetState state) { + for (final entry in _widgetStateByWire.entries) { + if (entry.value == state) return entry.key; + } + + throw UnsupportedEncodeValueError(state, 'Unknown widget state.'); +} diff --git a/packages/mix_schema/pubspec.yaml b/packages/mix_schema/pubspec.yaml new file mode 100644 index 0000000000..8025a24693 --- /dev/null +++ b/packages/mix_schema/pubspec.yaml @@ -0,0 +1,25 @@ +name: mix_schema +description: Schema-first JSON contract for representable Mix stylers. +version: 0.0.1 +publish_to: none +repository: https://github.com/btwld/mix/tree/main/packages/mix_schema + +environment: + sdk: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" + +dependencies: + ack: + git: + url: https://github.com/btwld/ack.git + ref: 8daaadace3e0c9969e05eb0fe5633a51c2bb124b + path: packages/ack + flutter: + sdk: flutter + mix: + path: ../mix + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 diff --git a/packages/mix_schema/test/ack_alignment_smoke_test.dart b/packages/mix_schema/test/ack_alignment_smoke_test.dart new file mode 100644 index 0000000000..5535476771 --- /dev/null +++ b/packages/mix_schema/test/ack_alignment_smoke_test.dart @@ -0,0 +1,35 @@ +import 'package:ack/ack.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Smoke { + const _Smoke(this.value); + + final String value; +} + +void main() { + test('Ack typed codec API supports mix_schema composition primitives', () { + late AckSchema lazy; + final branch = Ack.object({'value': Ack.string()}).codec<_Smoke>( + decode: (data) => _Smoke(data['value']! as String), + encode: (value) => {'value': value.value}, + ); + + lazy = Ack.lazy('smoke', () { + return Ack.discriminated<_Smoke>( + discriminatorKey: 'type', + schemas: {'smoke': branch}, + ); + }); + + final parsed = lazy.safeParse({'type': 'smoke', 'value': 'ok'}); + expect(parsed.isOk, isTrue); + expect(parsed.getOrNull(), isA<_Smoke>()); + + final encoded = lazy.safeEncode(const _Smoke('ok')); + expect(encoded.getOrThrow(), {'type': 'smoke', 'value': 'ok'}); + + final failed = Ack.string().transform(int.parse).safeEncode(1); + expect(failed.getError(), isA()); + }); +} diff --git a/packages/mix_schema/test/animation_codec_test.dart b/packages/mix_schema/test/animation_codec_test.dart new file mode 100644 index 0000000000..dba31399b0 --- /dev/null +++ b/packages/mix_schema/test/animation_codec_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; + +void main() { + test('R-6/R-9 animation decodes registry-backed onEnd callbacks', () { + void onEnd() {} + final builder = MixSchemaContractBuilder(); + builder.registry.animationOnEnd('done', onEnd); + final contract = builder.builtIn().freeze(); + + final decoded = contract.decode({ + 'type': 'box', + 'animation': { + 'duration': 250, + 'delay': 50, + 'curve': 'easeInOut', + 'onEnd': 'done', + }, + }); + + final style = switch (decoded) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + final animation = style.$animation as CurveAnimationConfig; + + expect(animation.duration, const Duration(milliseconds: 250)); + expect(animation.delay, const Duration(milliseconds: 50)); + expect(animation.curve, Curves.easeInOut); + expect(animation.onEnd, same(onEnd)); + }); + + test('R-10 animation delay is explicit and not defaulted', () { + final result = MixSchemaContractBuilder().builtIn().freeze().validate({ + 'type': 'box', + 'animation': {'duration': 250, 'curve': 'easeInOut'}, + }); + + final errors = switch (result) { + MixSchemaValidationFailure(:final errors) => errors, + MixSchemaValidationSuccess() => fail('expected failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.requiredField), + ); + }); + + test('R-5/R-6 animation encodes named curves and registry callbacks', () { + void onEnd() {} + final builder = MixSchemaContractBuilder(); + builder.registry.animationOnEnd('done', onEnd); + final contract = builder.builtIn().freeze(); + + final encoded = contract.encode( + BoxStyler( + animation: CurveAnimationConfig.easeInOut( + const Duration(milliseconds: 250), + delay: const Duration(milliseconds: 50), + onEnd: onEnd, + ), + ), + ); + + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload, { + 'type': 'box', + 'animation': { + 'duration': 250, + 'curve': 'easeInOut', + 'delay': 50, + 'onEnd': 'done', + }, + }); + }); + + test('R-5 spring animations fail encode explicitly', () { + final result = MixSchemaContractBuilder().builtIn().freeze().encode( + BoxStyler( + animation: AnimationConfig.spring(const Duration(milliseconds: 250)), + ), + ); + + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.unsupportedEncodeValue), + ); + }); + + test('R-5 arbitrary curves fail encode explicitly', () { + final result = MixSchemaContractBuilder().builtIn().freeze().encode( + BoxStyler( + animation: const CurveAnimationConfig( + duration: Duration(milliseconds: 250), + curve: Cubic(0.1, 0.2, 0.3, 0.4), + ), + ), + ); + + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.unsupportedEncodeValue), + ); + }); + + test('R-6 unregistered animation callbacks do not serialize closures', () { + void onEnd() {} + final result = MixSchemaContractBuilder().builtIn().freeze().encode( + BoxStyler( + animation: CurveAnimationConfig.linear( + const Duration(milliseconds: 250), + onEnd: onEnd, + ), + ), + ); + + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.unknownRegistryValue), + ); + }); +} diff --git a/packages/mix_schema/test/box_styler_codec_test.dart b/packages/mix_schema/test/box_styler_codec_test.dart new file mode 100644 index 0000000000..fcceecde6d --- /dev/null +++ b/packages/mix_schema/test/box_styler_codec_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_schema/src/schema/common_codecs.dart'; + +void main() { + MixSchemaContract contract() => MixSchemaContractBuilder().builtIn().freeze(); + + test('R-2/R-10 decodes box without branch-owned type field', () { + final result = contract().decode({ + 'type': 'box', + 'padding': {'top': 8}, + }); + + final box = switch (result) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + + final padding = singleMixProp( + box.$padding, + 'padding', + ); + expect(singleValueProp(padding!.$top, 'top'), 8); + expect(box.$margin, isNull); + }); + + test('R-2 Ack root injects box discriminator on encode', () { + final encoded = contract().encode( + BoxStyler( + alignment: Alignment.center, + padding: EdgeInsetsMix(top: 8), + decoration: BoxDecorationMix(color: const Color(0xCC336699)), + clipBehavior: Clip.hardEdge, + ), + ); + + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload, { + 'type': 'box', + 'alignment': 'center', + 'padding': {'top': 8.0}, + 'clipBehavior': 'hardEdge', + 'decoration': {'color': '#CC336699'}, + }); + }); + + test('R-5 unsupported box runtime values fail encode explicitly', () { + final style = BoxStyler( + padding: EdgeInsetsMix(top: 4), + ).merge(BoxStyler(padding: EdgeInsetsMix(top: 8))); + + final result = contract().encode(style); + + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected failure'), + }; + + expect( + errors, + contains( + isA() + .having( + (error) => error.code, + 'code', + MixSchemaErrorCode.unsupportedEncodeValue, + ) + .having((error) => error.message, 'message', contains('padding')), + ), + ); + }); + + test('registeredTypes includes box built-in branch', () { + expect(contract().registeredTypes, contains('box')); + }); +} diff --git a/packages/mix_schema/test/box_variant_codec_test.dart b/packages/mix_schema/test/box_variant_codec_test.dart new file mode 100644 index 0000000000..89be94f465 --- /dev/null +++ b/packages/mix_schema/test/box_variant_codec_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; + +void main() { + test('R-11 box variants survive decode then encode', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final decoded = contract.decode({ + 'type': 'box', + 'variants': [ + { + 'kind': 'context_not_widget_state', + 'state': 'pressed', + 'style': { + 'type': 'box', + 'decoration': {'color': '#112233FF'}, + }, + }, + ], + }); + + final style = switch (decoded) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + final encoded = contract.encode(style); + + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload, { + 'type': 'box', + 'variants': [ + { + 'kind': 'context_not_widget_state', + 'state': 'pressed', + 'style': { + 'type': 'box', + 'decoration': {'color': '#112233FF'}, + }, + }, + ], + }); + }); +} diff --git a/packages/mix_schema/test/common_codecs_test.dart b/packages/mix_schema/test/common_codecs_test.dart new file mode 100644 index 0000000000..a1b58235bb --- /dev/null +++ b/packages/mix_schema/test/common_codecs_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/src/errors/mix_schema_error.dart'; +import 'package:mix_schema/src/schema/common_codecs.dart'; + +void main() { + test('R-4 numberAsDouble parses ints and encodes doubles', () { + final schema = numberAsDoubleCodec(); + + expect(schema.safeParse(2).getOrThrow(), 2.0); + expect(schema.safeEncode(2.5).getOrThrow(), 2.5); + }); + + test( + 'R-4 color codec parses CSS-like input and encodes Ack canonical hex', + () { + final schema = colorCodec(); + + final color = schema.safeParse('rgba(51, 102, 153, 0.8)').getOrThrow()!; + + expect(color.toARGB32(), 0xCC336699); + expect(schema.safeEncode(color).getOrThrow(), '#CC336699'); + expect( + schema.safeEncode(const Color(0xFF336699)).getOrThrow(), + '#336699', + ); + }, + ); + + test('R-4 color codec rejects malformed color strings', () { + final result = colorCodec().safeParse('336699'); + + expect(result.isFail, isTrue); + }); + + test('R-4 alignment codec round-trips named and arbitrary alignments', () { + final schema = alignmentCodec(); + final value = schema.safeParse({'x': -1, 'y': 0.5}).getOrThrow()!; + + expect(value, const Alignment(-1, 0.5)); + expect(schema.safeEncode(value).getOrThrow(), {'x': -1.0, 'y': 0.5}); + expect(schema.safeParse('center').getOrThrow(), Alignment.center); + expect(schema.safeEncode(Alignment.center).getOrThrow(), 'center'); + }); + + test( + 'R-4 edgeInsets codec supports scalar shorthand without defaulting sides', + () { + final schema = edgeInsetsCodec(); + final value = schema.safeParse({'top': 8}).getOrThrow()!; + + expect(singleValueProp(value.$top, 'top'), 8); + expect(schema.safeEncode(value).getOrThrow(), {'top': 8.0}); + expect( + singleValueProp(schema.safeParse(4).getOrThrow()!.$left, 'left'), + 4, + ); + expect(schema.safeEncode(EdgeInsetsMix.all(4)).getOrThrow(), 4.0); + }, + ); + + test('R-4 strict string enum rejects integer indexes', () { + final schema = strictEnumCodec({'clip': Clip.hardEdge}); + + expect(schema.safeParse('clip').getOrThrow(), Clip.hardEdge); + expect(schema.safeParse(0).isFail, isTrue); + }); + + test('R-5 token and multi-source props fail encode explicitly', () { + final prop = Prop.value(1.0).mergeProp(Prop.value(2.0)); + + expect( + () => singleValueProp(prop, 'width'), + throwsA(isA()), + ); + }); +} diff --git a/packages/mix_schema/test/encode_helpers_test.dart b/packages/mix_schema/test/encode_helpers_test.dart new file mode 100644 index 0000000000..499b0d5af5 --- /dev/null +++ b/packages/mix_schema/test/encode_helpers_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/encode.dart'; + +void main() { + test('R-12 payloadColor matches Ack Flutter codec canonical hex', () { + expect(payloadColor(const Color(0xFF336699)), '#336699'); + expect(payloadColor(const Color(0xCC336699)), '#CC336699'); + }); + + test('R-12 payloadAlignment emits named constants before object form', () { + expect(payloadAlignment(Alignment.center), 'center'); + expect(payloadAlignment(const Alignment(0.25, -0.5)), { + 'x': 0.25, + 'y': -0.5, + }); + }); + + test('R-12 payloadEdgeInsets supports scalar and sparse object forms', () { + expect(payloadEdgeInsets(all: 8), 8); + expect(payloadEdgeInsets(top: 4), {'top': 4}); + }); +} diff --git a/packages/mix_schema/test/error_mapper_test.dart b/packages/mix_schema/test/error_mapper_test.dart new file mode 100644 index 0000000000..46b6787985 --- /dev/null +++ b/packages/mix_schema/test/error_mapper_test.dart @@ -0,0 +1,130 @@ +import 'package:ack/ack.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_schema/src/errors/mix_schema_error.dart'; +import 'package:mix_schema/src/errors/schema_error_mapper.dart'; + +void main() { + MixSchemaErrorCode codeFor(SchemaError error) { + return mapSchemaError(error).single.code; + } + + test('R-7 maps type_mismatch', () { + final result = Ack.object({'value': Ack.string()}).safeParse('nope'); + + expect(codeFor(result.getError()), MixSchemaErrorCode.typeMismatch); + }); + + test('R-7 maps required_field', () { + final result = Ack.object({'value': Ack.string()}).safeParse({}); + + expect(codeFor(result.getError()), MixSchemaErrorCode.requiredField); + }); + + test('R-7 maps unknown_field', () { + final result = Ack.object({ + 'value': Ack.string(), + }).safeParse({'value': 'x', 'extra': true}); + + expect(codeFor(result.getError()), MixSchemaErrorCode.unknownField); + }); + + test('R-7 maps invalid_enum', () { + final result = Ack.enumString(['a']).safeParse('b'); + + expect(codeFor(result.getError()), MixSchemaErrorCode.invalidEnum); + }); + + test('R-7 maps Ack enum codec failures to invalid_enum', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final result = contract.validate({'type': 'flex', 'direction': 'diagonal'}); + + final errors = switch (result) { + MixSchemaValidationFailure(:final errors) => errors, + MixSchemaValidationSuccess() => fail('expected failure'), + }; + + expect(errors.single.code, MixSchemaErrorCode.invalidEnum); + }); + + test('R-7 maps constraint_violation', () { + final result = Ack.string().minLength(2).safeParse('a'); + + expect(codeFor(result.getError()), MixSchemaErrorCode.constraintViolation); + }); + + test('R-7 maps unsupported_encode_value', () { + final result = Ack.string() + .codec( + decode: int.parse, + encode: (value) => + throw UnsupportedEncodeValueError(value, 'blocked'), + ) + .safeEncode(1); + + expect( + codeFor(result.getError()), + MixSchemaErrorCode.unsupportedEncodeValue, + ); + }); + + test('R-7 maps unknown_type at root discriminator', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final result = contract.validate({'type': 'missing'}); + + final errors = switch (result) { + MixSchemaValidationFailure(:final errors) => errors, + MixSchemaValidationSuccess() => fail('expected failure'), + }; + + expect(errors.single.code, MixSchemaErrorCode.unknownType); + }); + + test('R-7 maps unknown_registry_id', () { + final result = Ack.string() + .codec( + decode: (value) => throw UnknownRegistryIdError( + MixSchemaScope.animationOnEnd, + value, + ), + encode: (_) => 'x', + ) + .safeParse('missing'); + + expect(codeFor(result.getError()), MixSchemaErrorCode.unknownRegistryId); + }); + + test('R-7 maps unknown_registry_value', () { + final value = Object(); + final result = Ack.string() + .codec( + decode: (value) => value, + encode: (_) => throw UnknownRegistryValueError( + MixSchemaScope.animationOnEnd, + value, + ), + ) + .safeEncode(value); + + expect(codeFor(result.getError()), MixSchemaErrorCode.unknownRegistryValue); + }); + + test('R-7 maps transform_failed', () { + final result = Ack.string() + .codec( + decode: (_) => throw StateError('bad transform'), + encode: (value) => '$value', + ) + .safeParse('x'); + + expect(codeFor(result.getError()), MixSchemaErrorCode.transformFailed); + }); + + test('R-7 maps validation_failed fallback', () { + final result = Ack.instance() + .refine((_) => false, message: 'no') + .safeParse(Object()); + + expect(codeFor(result.getError()), MixSchemaErrorCode.validationFailed); + }); +} diff --git a/packages/mix_schema/test/guard_test.dart b/packages/mix_schema/test/guard_test.dart new file mode 100644 index 0000000000..e559ff2218 --- /dev/null +++ b/packages/mix_schema/test/guard_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('R-2 no discriminator injection helpers or sentinel prefixes exist', () { + final libText = _dartFiles( + Directory('lib'), + ).map((file) => file.readAsStringSync()).join('\n'); + + final injectionHelperName = ['buildDiscriminator', 'InjectingCodec'].join(); + final sentinelPrefixName = ['kUnsupportedBranch', 'SubtypePrefix'].join(); + + expect(libText, isNot(contains(injectionHelperName))); + expect(libText, isNot(contains(sentinelPrefixName))); + }); + + test('R-9 mix_tailwinds imports only public mix_schema libraries', () { + final tailwindsLib = Directory('../mix_tailwinds/lib'); + if (!tailwindsLib.existsSync()) return; + + for (final file in _dartFiles(tailwindsLib)) { + expect( + file.readAsStringSync(), + isNot(contains(['package:mix_schema', 'src'].join('/'))), + reason: file.path, + ); + } + }); + + test('R-12 encode.dart does not export schema internals', () { + final encodeFile = File('lib/encode.dart'); + + expect(encodeFile.readAsStringSync(), isNot(contains("export 'src/"))); + }); +} + +Iterable _dartFiles(Directory directory) { + return directory + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); +} diff --git a/packages/mix_schema/test/mix_schema_contract_test.dart b/packages/mix_schema/test/mix_schema_contract_test.dart new file mode 100644 index 0000000000..0c90595561 --- /dev/null +++ b/packages/mix_schema/test/mix_schema_contract_test.dart @@ -0,0 +1,50 @@ +import 'package:ack/ack.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/mix_schema.dart'; + +final class _LimitStyle { + const _LimitStyle(this.value); + + final String value; +} + +void main() { + MixSchemaContract newContract({MixSchemaLimits? limits}) { + final branch = Ack.object({'value': Ack.string()}).codec<_LimitStyle>( + decode: (data) => _LimitStyle(data['value']! as String), + encode: (value) => {'value': value.value}, + ); + + return MixSchemaContractBuilder( + limits: limits ?? const MixSchemaLimits(), + ).addStyler('limit', branch).freeze(); + } + + test('R-8 validates payload limits before Ack decode', () { + final contract = newContract( + limits: const MixSchemaLimits(maxStringLength: 10), + ); + + final result = contract.decode<_LimitStyle>({ + 'type': 'limit', + 'value': 'too long payload', + }); + + final errors = switch (result) { + MixSchemaDecodeFailure<_LimitStyle>(:final errors) => errors, + MixSchemaDecodeSuccess<_LimitStyle>() => fail('expected limit failure'), + }; + + expect(errors.single.code, MixSchemaErrorCode.payloadLimitExceeded); + expect(errors.single.path, '/value'); + }); + + test('exportJsonSchema adds mix_schema metadata', () { + final schema = newContract().exportJsonSchema(); + + expect(schema[r'$schema'], contains('draft-07')); + expect(schema['x-mix-schema-contract'], 'mix_schema'); + expect(schema['x-mix-schema-version'], isA()); + expect(schema['x-mix-schema-limits'], isA>()); + }); +} diff --git a/packages/mix_schema/test/modifier_codec_test.dart b/packages/mix_schema/test/modifier_codec_test.dart new file mode 100644 index 0000000000..d07c645193 --- /dev/null +++ b/packages/mix_schema/test/modifier_codec_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_schema/src/schema/common_codecs.dart'; + +void main() { + MixSchemaContract contract() => MixSchemaContractBuilder().builtIn().freeze(); + + test('R-5 modifiers decode in payload order', () { + final decoded = contract().decode({ + 'type': 'box', + 'modifiers': [ + {'type': 'opacity', 'opacity': 0.5}, + {'type': 'blur', 'sigma': 2}, + { + 'type': 'default_text_style', + 'style': {'color': '#112233'}, + 'textAlign': 'center', + 'softWrap': false, + 'overflow': 'ellipsis', + 'maxLines': 2, + }, + ], + }); + + final style = switch (decoded) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + final modifiers = style.$modifier!.$modifiers!; + + expect(modifiers, [ + isA(), + isA(), + isA(), + ]); + expect( + singleValueProp((modifiers[0] as OpacityModifierMix).opacity, 'opacity'), + 0.5, + ); + }); + + test('R-5 modifiers encode in config order', () { + final encoded = contract().encode( + BoxStyler( + modifier: WidgetModifierConfig.modifiers([ + OpacityModifierMix(opacity: 0.5), + BlurModifierMix(sigma: 2), + DefaultTextStyleModifierMix( + style: TextStyleMix(color: const Color(0xFF112233)), + textAlign: TextAlign.center, + softWrap: false, + ), + ]), + ), + ); + + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload, { + 'type': 'box', + 'modifiers': [ + {'type': 'opacity', 'opacity': 0.5}, + {'type': 'blur', 'sigma': 2.0}, + { + 'type': 'default_text_style', + 'style': {'color': '#112233'}, + 'textAlign': 'center', + 'softWrap': false, + }, + ], + }); + }); + + test('R-5 custom modifier order fails encode explicitly', () { + final result = contract().encode( + BoxStyler( + modifier: WidgetModifierConfig.orderOfModifiers([BlurModifier]), + ), + ); + + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.unsupportedEncodeValue), + ); + }); +} diff --git a/packages/mix_schema/test/public_api_contract_test.dart b/packages/mix_schema/test/public_api_contract_test.dart new file mode 100644 index 0000000000..f2ad4d8661 --- /dev/null +++ b/packages/mix_schema/test/public_api_contract_test.dart @@ -0,0 +1,33 @@ +import 'package:ack/ack.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/mix_schema.dart'; + +final class _ApiStyle { + const _ApiStyle(this.value); + + final String value; +} + +void main() { + test('R-1 public API exposes contract, JsonMap, and sealed results', () { + final branch = Ack.object({'value': Ack.string()}).codec<_ApiStyle>( + decode: (data) => _ApiStyle(data['value']! as String), + encode: (value) => {'value': value.value}, + ); + final contract = MixSchemaContractBuilder() + .addStyler('api', branch) + .freeze(); + + final JsonMap payload = {'type': 'api', 'value': 'ok'}; + + expect(contract.registeredTypes, ['api']); + expect(contract.validate(payload), isA()); + + final result = contract.decode<_ApiStyle>(payload); + final value = switch (result) { + MixSchemaDecodeSuccess<_ApiStyle>(:final value) => value.value, + MixSchemaDecodeFailure<_ApiStyle>() => fail('expected success'), + }; + expect(value, 'ok'); + }); +} diff --git a/packages/mix_schema/test/raw_throw_ban_test.dart b/packages/mix_schema/test/raw_throw_ban_test.dart new file mode 100644 index 0000000000..35d7f76fc6 --- /dev/null +++ b/packages/mix_schema/test/raw_throw_ban_test.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'typed-exception policy avoids raw string and generic exception throws', + () { + final lib = Directory('lib'); + final offenders = []; + + for (final entity in lib.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final source = entity.readAsStringSync(); + if (RegExp('throw\\s+["\\\']').hasMatch(source) || + RegExp(r'throw\s+Exception\(').hasMatch(source)) { + offenders.add(entity.path); + } + } + + expect(offenders, isEmpty); + }, + ); +} diff --git a/packages/mix_schema/test/registry_builder_test.dart b/packages/mix_schema/test/registry_builder_test.dart new file mode 100644 index 0000000000..339690b323 --- /dev/null +++ b/packages/mix_schema/test/registry_builder_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_schema/src/errors/mix_schema_error.dart'; + +final class _EqualValue { + const _EqualValue(this.id); + + final int id; + + @override + bool operator ==(Object other) => other is _EqualValue && other.id == id; + + @override + int get hashCode => id.hashCode; +} + +void main() { + test('R-6 registry builder freezes scoped identity values', () { + void callback() {} + + final registry = RegistryBuilder() + .animationOnEnd('done', callback) + .iconData('add', const IconData(0xe145, fontFamily: 'MaterialIcons')) + .freeze(); + + expect( + registry.lookup(MixSchemaScope.animationOnEnd, 'done'), + callback, + ); + expect(registry.idFor(MixSchemaScope.animationOnEnd, callback), 'done'); + }); + + test('R-6 registry reverse lookup requires registered identity', () { + final registered = _EqualValue(1); + final equalButDifferent = _EqualValue(1); + final registry = RegistryBuilder() + .register(MixSchemaScope.iconData, 'value', registered) + .freeze(); + + expect( + () => registry.idFor(MixSchemaScope.iconData, equalButDifferent), + throwsA(isA()), + ); + }); + + test('R-6 invalid registry ids are rejected by the builder', () { + expect( + () => RegistryBuilder().register( + MixSchemaScope.iconData, + 'bad id', + Object(), + ), + throwsArgumentError, + ); + }); + + test('R-6 unknown ids and values throw typed exceptions', () { + final registry = RegistryBuilder().freeze(); + + expect( + () => registry.lookup(MixSchemaScope.iconData, 'missing'), + throwsA(isA()), + ); + expect( + () => registry.idFor(MixSchemaScope.iconData, Object()), + throwsA(isA()), + ); + }); +} diff --git a/packages/mix_schema/test/registry_value_codec_test.dart b/packages/mix_schema/test/registry_value_codec_test.dart new file mode 100644 index 0000000000..1661697c45 --- /dev/null +++ b/packages/mix_schema/test/registry_value_codec_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_schema/src/errors/schema_error_mapper.dart'; +import 'package:mix_schema/src/registry/registry_value_codec.dart'; + +void main() { + test('R-6 registry value codec decodes and encodes registered values', () { + final value = Object(); + final registry = RegistryBuilder() + .register(MixSchemaScope.contextVariantBuilder, 'ctx_builder', value) + .freeze(); + final schema = registryValueCodec( + registry, + MixSchemaScope.contextVariantBuilder, + ); + + expect(schema.safeParse('ctx_builder').getOrThrow(), same(value)); + expect(schema.safeEncode(value).getOrThrow(), 'ctx_builder'); + }); + + test('R-6 bad grammar fails before lookup', () { + final schema = registryValueCodec( + RegistryBuilder().freeze(), + MixSchemaScope.contextVariantBuilder, + ); + final result = schema.safeParse('bad id'); + + expect(result.isFail, isTrue); + expect( + mapSchemaError(result.getError()).single.code, + MixSchemaErrorCode.constraintViolation, + ); + }); + + test('R-6 unknown id maps to unknown_registry_id', () { + final schema = registryValueCodec( + RegistryBuilder().freeze(), + MixSchemaScope.contextVariantBuilder, + ); + final result = schema.safeParse('missing'); + + expect(result.isFail, isTrue); + expect( + mapSchemaError(result.getError()).single.code, + MixSchemaErrorCode.unknownRegistryId, + ); + }); + + test('R-6 unregistered value maps to unknown_registry_value', () { + final schema = registryValueCodec( + RegistryBuilder().freeze(), + MixSchemaScope.contextVariantBuilder, + ); + final result = schema.safeEncode(Object()); + + expect(result.isFail, isTrue); + expect( + mapSchemaError(result.getError()).single.code, + MixSchemaErrorCode.unknownRegistryValue, + ); + }); +} diff --git a/packages/mix_schema/test/remaining_stylers_codec_test.dart b/packages/mix_schema/test/remaining_stylers_codec_test.dart new file mode 100644 index 0000000000..0cb6e20294 --- /dev/null +++ b/packages/mix_schema/test/remaining_stylers_codec_test.dart @@ -0,0 +1,196 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; + +void main() { + test('Phase 10 registeredTypes includes all built-in styler branches', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + + expect(contract.registeredTypes, [ + 'box', + 'text', + 'flex', + 'stack', + 'icon', + 'image', + 'flex_box', + 'stack_box', + ]); + }); + + test('Phase 10 minimal payloads decode for every remaining styler', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + + expect( + contract.decode({'type': 'flex'}), + isA(), + ); + expect( + contract.decode({'type': 'stack'}), + isA(), + ); + expect( + contract.decode({'type': 'icon'}), + isA(), + ); + expect( + contract.decode({'type': 'image'}), + isA(), + ); + expect( + contract.decode({'type': 'flex_box'}), + isA(), + ); + expect( + contract.decode({'type': 'stack_box'}), + isA(), + ); + }); + + test('Phase 10 flex and stack encode representative layout fields', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + + expect( + _encode( + contract, + FlexStyler( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + spacing: 8, + clipBehavior: Clip.hardEdge, + ), + ), + { + 'type': 'flex', + 'direction': 'horizontal', + 'mainAxisAlignment': 'spaceBetween', + 'crossAxisAlignment': 'center', + 'mainAxisSize': 'min', + 'clipBehavior': 'hardEdge', + 'spacing': 8.0, + }, + ); + + expect( + _encode( + contract, + StackStyler( + alignment: Alignment.center, + fit: StackFit.expand, + textDirection: TextDirection.ltr, + clipBehavior: Clip.antiAlias, + ), + ), + { + 'type': 'stack', + 'alignment': 'center', + 'fit': 'expand', + 'textDirection': 'ltr', + 'clipBehavior': 'antiAlias', + }, + ); + }); + + test('Phase 10 icon and image use scoped registries for identity fields', () { + const icon = IconData(0xe88a, fontFamily: 'MaterialIcons'); + final image = MemoryImage(Uint8List.fromList([0, 1, 2, 3])); + final builder = MixSchemaContractBuilder() + ..registry.iconData('home', icon) + ..registry.imageProvider('pixels', image); + final contract = builder.builtIn().freeze(); + + expect( + _encode( + contract, + IconStyler( + icon: icon, + color: const Color(0xFF112233), + size: 24, + blendMode: BlendMode.srcIn, + ), + ), + { + 'type': 'icon', + 'icon': 'home', + 'color': '#112233', + 'size': 24.0, + 'blendMode': 'srcIn', + }, + ); + + expect( + _encode( + contract, + ImageStyler( + image: image as ImageProvider, + width: 64, + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + { + 'type': 'image', + 'image': 'pixels', + 'width': 64.0, + 'fit': 'cover', + 'alignment': 'center', + }, + ); + }); + + test('Phase 10 flex_box and stack_box encode combined fields', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + + expect( + _encode( + contract, + FlexBoxStyler( + padding: EdgeInsetsMix.all(8), + decoration: BoxDecorationMix(color: const Color(0xFF112233)), + direction: Axis.vertical, + spacing: 4, + flexClipBehavior: Clip.hardEdge, + ), + ), + { + 'type': 'flex_box', + 'padding': 8.0, + 'decoration': {'color': '#112233'}, + 'direction': 'vertical', + 'flexClipBehavior': 'hardEdge', + 'spacing': 4.0, + }, + ); + + expect( + _encode( + contract, + StackBoxStyler( + margin: EdgeInsetsMix(top: 4), + stackAlignment: Alignment.center, + fit: StackFit.passthrough, + stackClipBehavior: Clip.none, + ), + ), + { + 'type': 'stack_box', + 'margin': {'top': 4.0}, + 'stackAlignment': 'center', + 'fit': 'passthrough', + 'stackClipBehavior': 'none', + }, + ); + }); +} + +JsonMap _encode(MixSchemaContract contract, Object value) { + return switch (contract.encode(value)) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => throw TestFailure('$errors'), + }; +} diff --git a/packages/mix_schema/test/requirements_traceability_test.dart b/packages/mix_schema/test/requirements_traceability_test.dart new file mode 100644 index 0000000000..97b0091756 --- /dev/null +++ b/packages/mix_schema/test/requirements_traceability_test.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'requirements traceability has rule-ID-named tests for R-1 through R-12', + () { + final testText = Directory('test') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('_test.dart')) + .map((file) => file.readAsStringSync()) + .join('\n'); + + for (var id = 1; id <= 12; id++) { + expect( + testText, + contains('R-$id'), + reason: 'Missing R-$id test marker.', + ); + } + }, + ); +} diff --git a/packages/mix_schema/test/schema_export_golden_test.dart b/packages/mix_schema/test/schema_export_golden_test.dart new file mode 100644 index 0000000000..5ab962df78 --- /dev/null +++ b/packages/mix_schema/test/schema_export_golden_test.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/mix_schema.dart'; + +void main() { + test('R-2 schema export fingerprint contains box discriminator shape', () { + final schema = MixSchemaContractBuilder() + .builtIn() + .freeze() + .exportJsonSchema(); + final encoded = jsonEncode(schema); + + expect(schema['x-mix-schema-contract'], 'mix_schema'); + expect(encoded, contains('"type"')); + expect(encoded, contains('"box"')); + expect(encoded, contains('"text"')); + expect(encoded, contains('"padding"')); + expect(encoded, contains('"decoration"')); + expect(encoded, contains('"clipBehavior"')); + expect(encoded, contains('"modifiers"')); + expect(encoded, contains('"animation"')); + expect(encoded, isNot(contains('x-ack-codec'))); + expect(encoded.length, lessThan(150000)); + }); +} diff --git a/packages/mix_schema/test/styler_branch_test.dart b/packages/mix_schema/test/styler_branch_test.dart new file mode 100644 index 0000000000..4c37a81afc --- /dev/null +++ b/packages/mix_schema/test/styler_branch_test.dart @@ -0,0 +1,44 @@ +import 'package:ack/ack.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_schema/src/errors/mix_schema_error.dart'; +import 'package:mix_schema/src/schema/styler_branch.dart'; + +final class _A { + const _A(this.value); + + final String value; +} + +final class _B { + const _B(); +} + +void main() { + test('R-3 widens a typed branch without changing its boundary shape', () { + final branch = widenStylerBranch<_A>( + Ack.object({'value': Ack.string()}).codec<_A>( + decode: (data) => _A(data['value']! as String), + encode: (value) => {'value': value.value}, + ), + ); + + expect(branch.safeParse({'value': 'x'}).getOrNull(), isA<_A>()); + expect(branch.safeEncode(const _A('x')).getOrThrow(), {'value': 'x'}); + }); + + test('R-3 wrong runtime subtype fails through a typed exception', () { + final branch = widenStylerBranch<_A>( + Ack.object({'value': Ack.string()}).codec<_A>( + decode: (data) => _A(data['value']! as String), + encode: (value) => {'value': value.value}, + ), + debugName: '_A', + ); + + final result = branch.safeEncode(const _B()); + + expect(result.isFail, isTrue); + final error = result.getError(); + expect(error.cause, isA()); + }); +} diff --git a/packages/mix_schema/test/text_styler_codec_test.dart b/packages/mix_schema/test/text_styler_codec_test.dart new file mode 100644 index 0000000000..466e20557c --- /dev/null +++ b/packages/mix_schema/test/text_styler_codec_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_schema/src/schema/common_codecs.dart'; + +void main() { + MixSchemaContract contract() => MixSchemaContractBuilder().builtIn().freeze(); + + test('text styler decodes representable typography fields', () { + final result = contract().decode({ + 'type': 'text', + 'textAlign': 'center', + 'maxLines': 2, + 'style': { + 'color': '#FF0000', + 'fontSize': 16, + 'fontWeight': 'w700', + 'fontStyle': 'italic', + 'fontFamily': 'Inter', + }, + }); + + final text = switch (result) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + final style = singleMixProp(text.$style, 'style'); + + expect(singleValueProp(text.$textAlign, 'textAlign'), TextAlign.center); + expect(singleValueProp(text.$maxLines, 'maxLines'), 2); + expect(singleValueProp(style!.$fontWeight, 'fontWeight'), FontWeight.w700); + }); + + test('text styler encodes representable typography fields', () { + final result = contract().encode( + TextStyler( + textAlign: TextAlign.end, + softWrap: false, + selectionColor: const Color(0x800000FF), + style: TextStyleMix( + color: const Color(0xFF112233), + fontSize: 14, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + ), + ); + + final payload = switch (result) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload, { + 'type': 'text', + 'textAlign': 'end', + 'style': { + 'color': '#112233', + 'fontSize': 14.0, + 'fontWeight': 'w600', + 'decoration': 'underline', + }, + 'softWrap': false, + 'selectionColor': '#800000FF', + }); + }); + + test('text styler unsupported runtime values fail encode explicitly', () { + final result = contract().encode(TextStyler.uppercase()); + + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected failure'), + }; + + expect( + errors, + contains( + isA() + .having( + (error) => error.code, + 'code', + MixSchemaErrorCode.unsupportedEncodeValue, + ) + .having( + (error) => error.message, + 'message', + contains('textDirectives'), + ), + ), + ); + }); +} diff --git a/packages/mix_schema/test/variant_codec_test.dart b/packages/mix_schema/test/variant_codec_test.dart new file mode 100644 index 0000000000..c0ae24899b --- /dev/null +++ b/packages/mix_schema/test/variant_codec_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; + +void main() { + MixSchemaContract contract() => MixSchemaContractBuilder().builtIn().freeze(); + + BoxStyler decodeBox(JsonMap payload) { + final result = contract().decode(payload); + + return switch (result) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + } + + test('R-11 decodes named variant with lazy nested style', () { + final box = decodeBox({ + 'type': 'box', + 'variants': [ + { + 'kind': 'named', + 'name': 'primary', + 'style': { + 'type': 'box', + 'padding': {'top': 8}, + }, + }, + ], + }); + + expect(box.$variants, hasLength(1)); + expect(box.$variants!.single.variant, const NamedVariant('primary')); + expect(box.$variants!.single.value, isA()); + }); + + test('R-11 decodes widget state, enabled, brightness, and breakpoint', () { + final box = decodeBox({ + 'type': 'box', + 'variants': [ + { + 'kind': 'widget_state', + 'state': 'hovered', + 'style': {'type': 'box'}, + }, + { + 'kind': 'enabled', + 'style': {'type': 'box'}, + }, + { + 'kind': 'context_brightness', + 'brightness': 'dark', + 'style': {'type': 'box'}, + }, + { + 'kind': 'context_breakpoint', + 'minWidth': 768, + 'maxWidth': 1023, + 'style': {'type': 'box'}, + }, + ], + }); + + final keys = box.$variants!.map((variant) => variant.variant.key).toList(); + + expect(keys, [ + 'widget_state_hovered', + 'not_widget_state_disabled', + 'media_query_platform_brightness_dark', + 'breakpoint_768.0_1023.0', + ]); + }); + + test('R-11 decodes flat context_all_of and rejects nested all_of', () { + final ok = contract().validate({ + 'type': 'box', + 'variants': [ + { + 'kind': 'context_all_of', + 'conditions': [ + {'kind': 'enabled'}, + {'kind': 'context_brightness', 'brightness': 'light'}, + ], + 'style': {'type': 'box'}, + }, + ], + }); + expect(ok, isA()); + + final nested = contract().validate({ + 'type': 'box', + 'variants': [ + { + 'kind': 'context_all_of', + 'conditions': [ + {'kind': 'context_all_of', 'conditions': []}, + ], + 'style': {'type': 'box'}, + }, + ], + }); + + expect(nested, isA()); + }); + + test('R-11 encodes variants through lazy nested style', () { + final style = BoxStyler().variant( + const NamedVariant('primary'), + BoxStyler(clipBehavior: Clip.hardEdge), + ); + + final result = contract().encode(style); + final payload = switch (result) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload['variants'], [ + { + 'kind': 'named', + 'name': 'primary', + 'style': {'type': 'box', 'clipBehavior': 'hardEdge'}, + }, + ]); + }); + + test('R-6/R-11 context variant builders use registry ids', () { + BoxStyler responsiveBox(BuildContext context) { + return BoxStyler(clipBehavior: Clip.hardEdge); + } + + final builder = MixSchemaContractBuilder() + ..registry.contextVariantBuilder('responsive_box', responsiveBox); + final contract = builder.builtIn().freeze(); + + final decoded = contract.decode({ + 'type': 'box', + 'variants': [ + {'kind': 'context_variant_builder', 'builder': 'responsive_box'}, + ], + }); + final style = switch (decoded) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + + expect(style.$variants!.single.variant, isA()); + + final encoded = contract.encode(style); + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload['variants'], [ + {'kind': 'context_variant_builder', 'builder': 'responsive_box'}, + ]); + }); +} diff --git a/packages/mix_tailwinds/pubspec.yaml b/packages/mix_tailwinds/pubspec.yaml index fefe3e4b46..7638481f07 100644 --- a/packages/mix_tailwinds/pubspec.yaml +++ b/packages/mix_tailwinds/pubspec.yaml @@ -1,6 +1,7 @@ name: mix_tailwinds description: Tailwind-like class utilities mapped to Mix 2.0 stylers. version: 0.0.1-alpha.1 +publish_to: none homepage: https://github.com/btwld/mix repository: https://github.com/btwld/mix/tree/main/packages/mix_tailwinds @@ -11,7 +12,10 @@ environment: dependencies: flutter: sdk: flutter - mix: ^2.0.0-dev.5 + mix: + path: ../mix + mix_schema: + path: ../mix_schema dev_dependencies: flutter_test: diff --git a/packages/mix_tailwinds/test/schema_payload_contract_test.dart b/packages/mix_tailwinds/test/schema_payload_contract_test.dart new file mode 100644 index 0000000000..14e6067edf --- /dev/null +++ b/packages/mix_tailwinds/test/schema_payload_contract_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; +import 'package:mix_tailwinds/mix_tailwinds.dart'; + +void main() { + test('box parser output validates through mix_schema when representable', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final style = TwParser().parseBox('bg-blue-500 p-4'); + + _expectSchemaEncodes(contract, style); + }); + + test('unsupported Tailwind tokens stay parser diagnostics', () { + final unsupported = []; + final style = TwParser( + onUnsupported: unsupported.add, + ).parseBox('unknown-token'); + + expect(style, isA()); + expect(unsupported, contains('unknown-token')); + }); +} + +void _expectSchemaEncodes(MixSchemaContract contract, Object style) { + final result = contract.encode(style); + if (result case MixSchemaEncodeFailure(:final errors)) { + fail('$errors'); + } + expect(result, isA()); +} diff --git a/packages/mix_tailwinds/test/wire_literal_guard_test.dart b/packages/mix_tailwinds/test/wire_literal_guard_test.dart new file mode 100644 index 0000000000..a57f86b805 --- /dev/null +++ b/packages/mix_tailwinds/test/wire_literal_guard_test.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('mix_tailwinds does not import mix_schema internals', () { + final sourceFiles = Directory('lib') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + + for (final file in sourceFiles) { + expect( + file.readAsStringSync(), + isNot(contains(['package:mix_schema', 'src'].join('/'))), + reason: file.path, + ); + } + }); +} From cc61cb5353dce30132493c195b9064c3bf866a78 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 10 Jun 2026 16:23:03 -0400 Subject: [PATCH 02/11] fix(mix_schema): harden contract validation Make schema contract and registry builders single-use after freeze. Move invalid color and breakpoint payloads into constraint validation, clarify deferred Tailwinds scope, and keep merge-hardening simplifications covered by tests. --- packages/mix_schema/REQUIREMENTS.md | 4 + .../lib/src/contract/mix_schema_contract.dart | 14 +- .../mix_schema/lib/src/registry/registry.dart | 20 ++- .../lib/src/schema/box_styler_codec.dart | 32 ++-- .../lib/src/schema/common_codecs.dart | 90 ++++++++-- .../lib/src/schema/flex_box_styler_codec.dart | 57 ++---- .../lib/src/schema/flex_styler_codec.dart | 46 +---- .../lib/src/schema/icon_styler_codec.dart | 24 +-- .../lib/src/schema/image_styler_codec.dart | 31 +--- .../lib/src/schema/modifier_codec.dart | 13 +- .../src/schema/stack_box_styler_codec.dart | 26 +-- .../lib/src/schema/stack_styler_codec.dart | 21 +-- .../lib/src/schema/text_styler_codec.dart | 42 ++--- .../lib/src/schema/variant_codec.dart | 164 +++++++++--------- .../mix_schema/test/common_codecs_test.dart | 42 ++++- .../test/mix_schema_contract_test.dart | 45 +++++ .../test/registry_builder_test.dart | 16 ++ .../mix_schema/test/variant_codec_test.dart | 55 ++++++ packages/mix_tailwinds/pubspec.yaml | 4 +- .../test/schema_payload_contract_test.dart | 2 +- 20 files changed, 424 insertions(+), 324 deletions(-) diff --git a/packages/mix_schema/REQUIREMENTS.md b/packages/mix_schema/REQUIREMENTS.md index 0a64ddcba3..d1e4bff921 100644 --- a/packages/mix_schema/REQUIREMENTS.md +++ b/packages/mix_schema/REQUIREMENTS.md @@ -6,6 +6,10 @@ Pinned Ack: `btwld/ack` `8daaadace3e0c9969e05eb0fe5633a51c2bb124b`, path `packag Flutter primitive payloads mirror Ack `flutter_codec` branch shapes where they do not erase Mix semantics. Local mirrors must be replaced by `flutter_codec` imports once that package is merged and available to this workspace. +Current variant support is intentionally Box-only. All-styler variant payloads remain deferred and must fail explicitly rather than silently accepting a shape whose runtime semantics are not represented yet. + +Tailwinds checks in this package assert that parser output can be encoded when it is schema-representable. They do not mean Tailwinds emits schema payloads directly. + | Rule | Requirement | Implementation | Tests | | --- | --- | --- | --- | | R-1 | Ack owns validation, decode, encode, and JSON Schema export. | `MixSchemaContract.rootSchema` | `public_api_contract_test.dart` | diff --git a/packages/mix_schema/lib/src/contract/mix_schema_contract.dart b/packages/mix_schema/lib/src/contract/mix_schema_contract.dart index e40f15aa55..c7ca0580be 100644 --- a/packages/mix_schema/lib/src/contract/mix_schema_contract.dart +++ b/packages/mix_schema/lib/src/contract/mix_schema_contract.dart @@ -32,11 +32,13 @@ final class MixSchemaContractBuilder { late final AckSchema _rootSchemaRef; late AckSchema _rootSchema; late FrozenRegistry _frozenRegistry; + bool _isFrozen = false; MixSchemaLimits get limits => _limits; RegistryBuilder get registry => _registryBuilder; MixSchemaContractBuilder withLimits(MixSchemaLimits limits) { + _ensureMutable(); _limits = limits; return this; @@ -46,12 +48,14 @@ final class MixSchemaContractBuilder { String wireType, AckSchema schema, ) { + _ensureMutable(); _branches[wireType] = widenStylerBranch(schema, debugName: wireType); return this; } MixSchemaContractBuilder builtIn() { + _ensureMutable(); addStyler( 'box', boxStylerCodec( @@ -74,7 +78,9 @@ final class MixSchemaContractBuilder { } MixSchemaContract freeze() { + _ensureMutable(); final registry = _registryBuilder.freeze(); + _isFrozen = true; _frozenRegistry = registry; final root = Ack.discriminated( discriminatorKey: 'type', @@ -86,9 +92,15 @@ final class MixSchemaContractBuilder { rootSchema: root, limits: _limits, registry: registry, - registeredTypes: _branches.keys.toList(growable: false), + registeredTypes: List.unmodifiable(_branches.keys), ); } + + void _ensureMutable() { + if (_isFrozen) { + throw StateError('MixSchemaContractBuilder cannot be used after freeze.'); + } + } } final class MixSchemaContract { diff --git a/packages/mix_schema/lib/src/registry/registry.dart b/packages/mix_schema/lib/src/registry/registry.dart index cc31a3f258..746d4c68f4 100644 --- a/packages/mix_schema/lib/src/registry/registry.dart +++ b/packages/mix_schema/lib/src/registry/registry.dart @@ -23,12 +23,14 @@ final class RegistryBuilder { final Map> _values = { for (final scope in MixSchemaScope.values) scope: {}, }; + bool _isFrozen = false; RegistryBuilder register( MixSchemaScope scope, String id, T value, ) { + _ensureMutable(); if (!isValidRegistryId(id)) { throw ArgumentError.value(id, 'id', 'Invalid mix_schema registry id.'); } @@ -54,10 +56,22 @@ final class RegistryBuilder { } FrozenRegistry freeze() { - return FrozenRegistry._({ + _ensureMutable(); + _isFrozen = true; + final values = >{ for (final entry in _values.entries) - entry.key: Map.unmodifiable(entry.value), - }); + entry.key: Map.unmodifiable(entry.value), + }; + + return FrozenRegistry._( + Map>.unmodifiable(values), + ); + } + + void _ensureMutable() { + if (_isFrozen) { + throw StateError('RegistryBuilder cannot be used after freeze.'); + } } } diff --git a/packages/mix_schema/lib/src/schema/box_styler_codec.dart b/packages/mix_schema/lib/src/schema/box_styler_codec.dart index 2875fa16a0..024be04c99 100644 --- a/packages/mix_schema/lib/src/schema/box_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/box_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import 'animation_codec.dart'; import 'common_codecs.dart'; @@ -18,7 +17,7 @@ AckSchema boxStylerCodec({ 'padding': edgeInsetsCodec().optional(), 'margin': edgeInsetsCodec().optional(), 'constraints': boxConstraintsCodec().optional(), - 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'clipBehavior': enumNameCodec(Clip.values).optional(), 'decoration': boxDecorationCodec().optional(), if (rootStyleSchema != null) 'variants': Ack.list( @@ -46,9 +45,9 @@ JsonMap encodeBoxStylerFields( BoxStyler value, { bool includeStylerMetadata = true, }) { - _failIfPresent(value.$foregroundDecoration, 'foregroundDecoration'); - _failIfPresent(value.$transform, 'transform'); - _failIfPresent(value.$transformAlignment, 'transformAlignment'); + failIfPresent(value.$foregroundDecoration, 'foregroundDecoration'); + failIfPresent(value.$transform, 'transform'); + failIfPresent(value.$transformAlignment, 'transformAlignment'); final encoded = { 'alignment': singleAlignmentProp(value.$alignment, 'alignment'), @@ -86,27 +85,18 @@ CodecSchema boxDecorationCodec() { return Ack.object({'color': colorCodec().optional()}).codec( decode: (data) => BoxDecorationMix(color: data['color'] as Color?), encode: (value) { - _failIfPresent(value.$border, 'decoration.border'); - _failIfPresent(value.$borderRadius, 'decoration.borderRadius'); - _failIfPresent(value.$shape, 'decoration.shape'); - _failIfPresent( + failIfPresent(value.$border, 'decoration.border'); + failIfPresent(value.$borderRadius, 'decoration.borderRadius'); + failIfPresent(value.$shape, 'decoration.shape'); + failIfPresent( value.$backgroundBlendMode, 'decoration.backgroundBlendMode', ); - _failIfPresent(value.$image, 'decoration.image'); - _failIfPresent(value.$gradient, 'decoration.gradient'); - _failIfPresent(value.$boxShadow, 'decoration.boxShadow'); + failIfPresent(value.$image, 'decoration.image'); + failIfPresent(value.$gradient, 'decoration.gradient'); + failIfPresent(value.$boxShadow, 'decoration.boxShadow'); return {'color': singleValueProp(value.$color, 'decoration.color')}; }, ); } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/common_codecs.dart b/packages/mix_schema/lib/src/schema/common_codecs.dart index 2b6345f7fe..2d83842aef 100644 --- a/packages/mix_schema/lib/src/schema/common_codecs.dart +++ b/packages/mix_schema/lib/src/schema/common_codecs.dart @@ -21,19 +21,10 @@ CodecSchema nonNegativeDoubleCodec() { ); } -CodecSchema colorCodec() { - return Ack.codec( - input: Ack.anyOf([ - Ack.string().matches(r'^#[0-9A-Fa-f]{6}$'), - Ack.string().matches(r'^#[0-9A-Fa-f]{8}$'), - Ack.string().matches( - r'^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$', - ), - Ack.string().matches( - r'^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+|1\.0+)\s*\)$', - ), - ]), - decode: (value) => _decodeColor(value as String), +CodecSchema colorCodec() { + return Ack.codec( + input: _colorWireCodec(), + decode: _decodeColor, encode: encodeColorWire, ); } @@ -144,13 +135,19 @@ CodecSchema strictEnumCodec( ); } -CodecSchema enumNameCodec( - List values, { - String? debugName, -}) { +CodecSchema enumNameCodec(List values) { return Ack.enumCodec(values); } +void failIfPresent(Object? value, String fieldName) { + if (value == null) return; + + throw UnsupportedEncodeValueError( + value, + 'Field "$fieldName" is not representable by this schema.', + ); +} + Alignment? singleAlignmentProp( Prop? prop, String fieldName, @@ -226,6 +223,46 @@ T? singleMixProp( ); } +AckSchema _colorWireCodec() { + return Ack.string() + .matches( + r'^(?:#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{8}|rgb\(\s*-?\d+\s*,\s*-?\d+\s*,\s*-?\d+\s*\)|rgba\(\s*-?\d+\s*,\s*-?\d+\s*,\s*-?\d+\s*,\s*-?(?:\d+(?:\.\d+)?|\.\d+)\s*\))$', + ) + .constrain( + _PredicateConstraint( + constraintKey: 'mix_schema_color_wire_range', + description: 'Color wire values must use in-range CSS channels.', + isValidValue: _isColorWireInRange, + message: + 'RGB channels must be between 0 and 255; alpha must be between 0 and 1.', + ), + ); +} + +bool _isColorWireInRange(String value) { + if (value.startsWith('#')) return true; + final prefix = value.startsWith('rgb(') + ? 'rgb(' + : value.startsWith('rgba(') + ? 'rgba(' + : null; + if (prefix == null) return false; + + final parts = value.substring(prefix.length, value.length - 1).split(','); + final expectedCount = prefix == 'rgb(' ? 3 : 4; + if (parts.length != expectedCount) return false; + + for (final part in parts.take(3)) { + final channel = int.tryParse(part.trim()); + if (channel == null || channel < 0 || channel > 255) return false; + } + if (expectedCount == 3) return true; + + final alpha = double.tryParse(parts[3].trim()); + + return alpha != null && alpha >= 0 && alpha <= 1; +} + Color _decodeColor(String value) { if (value.startsWith('#')) return _decodeHexColor(value); if (value.startsWith('rgb(')) return _decodeRgbColor(value); @@ -382,3 +419,22 @@ Object _encodeBorderRadiusMix(BorderRadiusMix value) { 'bottomRight': bottomRight, }; } + +final class _PredicateConstraint extends Constraint + with Validator { + const _PredicateConstraint({ + required super.constraintKey, + required super.description, + required this.isValidValue, + required this.message, + }); + + final bool Function(T value) isValidValue; + final String message; + + @override + bool isValid(T value) => isValidValue(value); + + @override + String buildMessage(T value) => message; +} diff --git a/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart b/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart index 527f49f0cc..6235afe28e 100644 --- a/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import 'animation_codec.dart'; import 'box_styler_codec.dart'; @@ -18,37 +17,16 @@ AckSchema flexBoxStylerCodec({ 'padding': edgeInsetsCodec().optional(), 'margin': edgeInsetsCodec().optional(), 'constraints': boxConstraintsCodec().optional(), - 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'clipBehavior': enumNameCodec(Clip.values).optional(), 'decoration': boxDecorationCodec().optional(), - 'direction': enumNameCodec(Axis.values, debugName: 'Axis').optional(), - 'mainAxisAlignment': enumNameCodec( - MainAxisAlignment.values, - debugName: 'MainAxisAlignment', - ).optional(), - 'crossAxisAlignment': enumNameCodec( - CrossAxisAlignment.values, - debugName: 'CrossAxisAlignment', - ).optional(), - 'mainAxisSize': enumNameCodec( - MainAxisSize.values, - debugName: 'MainAxisSize', - ).optional(), - 'verticalDirection': enumNameCodec( - VerticalDirection.values, - debugName: 'VerticalDirection', - ).optional(), - 'textDirection': enumNameCodec( - TextDirection.values, - debugName: 'TextDirection', - ).optional(), - 'textBaseline': enumNameCodec( - TextBaseline.values, - debugName: 'TextBaseline', - ).optional(), - 'flexClipBehavior': enumNameCodec( - Clip.values, - debugName: 'Clip', - ).optional(), + 'direction': enumNameCodec(Axis.values).optional(), + 'mainAxisAlignment': enumNameCodec(MainAxisAlignment.values).optional(), + 'crossAxisAlignment': enumNameCodec(CrossAxisAlignment.values).optional(), + 'mainAxisSize': enumNameCodec(MainAxisSize.values).optional(), + 'verticalDirection': enumNameCodec(VerticalDirection.values).optional(), + 'textDirection': enumNameCodec(TextDirection.values).optional(), + 'textBaseline': enumNameCodec(TextBaseline.values).optional(), + 'flexClipBehavior': enumNameCodec(Clip.values).optional(), 'spacing': numberAsDoubleCodec().optional(), 'modifiers': modifierConfigCodec().optional(), 'animation': animationConfigCodec(registry: registry).optional(), @@ -77,7 +55,7 @@ AckSchema flexBoxStylerCodec({ } JsonMap _encodeFlexBoxStyler(FlexBoxStyler value) { - _failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$variants, 'variants'); final box = singleMixProp>(value.$box, 'box'); final flex = singleMixProp>( @@ -90,22 +68,15 @@ JsonMap _encodeFlexBoxStyler(FlexBoxStyler value) { final flexFields = flex == null ? {} : encodeFlexStylerFields(flex, includeStylerMetadata: false); + final boxClipBehavior = boxFields.remove('clipBehavior'); + final flexClipBehavior = flexFields.remove('clipBehavior'); return { ...boxFields, ...flexFields, - 'clipBehavior': boxFields['clipBehavior'], - 'flexClipBehavior': flexFields['clipBehavior'], + 'clipBehavior': boxClipBehavior, + 'flexClipBehavior': flexClipBehavior, 'modifiers': value.$modifier, 'animation': value.$animation, }; } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/flex_styler_codec.dart b/packages/mix_schema/lib/src/schema/flex_styler_codec.dart index d1c1a96665..0125926d14 100644 --- a/packages/mix_schema/lib/src/schema/flex_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/flex_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import 'animation_codec.dart'; import 'common_codecs.dart'; @@ -12,32 +11,14 @@ AckSchema flexStylerCodec({ required FrozenRegistry Function() registry, }) { return Ack.object({ - 'direction': enumNameCodec(Axis.values, debugName: 'Axis').optional(), - 'mainAxisAlignment': enumNameCodec( - MainAxisAlignment.values, - debugName: 'MainAxisAlignment', - ).optional(), - 'crossAxisAlignment': enumNameCodec( - CrossAxisAlignment.values, - debugName: 'CrossAxisAlignment', - ).optional(), - 'mainAxisSize': enumNameCodec( - MainAxisSize.values, - debugName: 'MainAxisSize', - ).optional(), - 'verticalDirection': enumNameCodec( - VerticalDirection.values, - debugName: 'VerticalDirection', - ).optional(), - 'textDirection': enumNameCodec( - TextDirection.values, - debugName: 'TextDirection', - ).optional(), - 'textBaseline': enumNameCodec( - TextBaseline.values, - debugName: 'TextBaseline', - ).optional(), - 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'direction': enumNameCodec(Axis.values).optional(), + 'mainAxisAlignment': enumNameCodec(MainAxisAlignment.values).optional(), + 'crossAxisAlignment': enumNameCodec(CrossAxisAlignment.values).optional(), + 'mainAxisSize': enumNameCodec(MainAxisSize.values).optional(), + 'verticalDirection': enumNameCodec(VerticalDirection.values).optional(), + 'textDirection': enumNameCodec(TextDirection.values).optional(), + 'textBaseline': enumNameCodec(TextBaseline.values).optional(), + 'clipBehavior': enumNameCodec(Clip.values).optional(), 'spacing': numberAsDoubleCodec().optional(), 'modifiers': modifierConfigCodec().optional(), 'animation': animationConfigCodec(registry: registry).optional(), @@ -63,7 +44,7 @@ JsonMap encodeFlexStylerFields( FlexStyler value, { bool includeStylerMetadata = true, }) { - _failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$variants, 'variants'); final encoded = { 'direction': singleValueProp(value.$direction, 'direction'), @@ -94,12 +75,3 @@ JsonMap encodeFlexStylerFields( ..remove('modifiers') ..remove('animation'); } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/icon_styler_codec.dart b/packages/mix_schema/lib/src/schema/icon_styler_codec.dart index c8463b10e0..951b692e1a 100644 --- a/packages/mix_schema/lib/src/schema/icon_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/icon_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import '../registry/registry_value_codec.dart'; import 'animation_codec.dart'; @@ -22,18 +21,12 @@ AckSchema iconStylerCodec({ 'weight': numberAsDoubleCodec().optional(), 'grade': numberAsDoubleCodec().optional(), 'opticalSize': numberAsDoubleCodec().optional(), - 'textDirection': enumNameCodec( - TextDirection.values, - debugName: 'TextDirection', - ).optional(), + 'textDirection': enumNameCodec(TextDirection.values).optional(), 'applyTextScaling': Ack.boolean().optional(), 'fill': numberAsDoubleCodec().optional(), 'semanticsLabel': Ack.string().optional(), 'opacity': numberAsDoubleCodec().optional(), - 'blendMode': enumNameCodec( - BlendMode.values, - debugName: 'BlendMode', - ).optional(), + 'blendMode': enumNameCodec(BlendMode.values).optional(), 'modifiers': modifierConfigCodec().optional(), 'animation': animationConfigCodec(registry: registry).optional(), }).codec( @@ -58,8 +51,8 @@ AckSchema iconStylerCodec({ } JsonMap _encodeIconStyler(IconStyler value) { - _failIfPresent(value.$variants, 'variants'); - _failIfPresent(value.$shadows, 'shadows'); + failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$shadows, 'shadows'); return { 'icon': singleValueProp(value.$icon, 'icon'), @@ -81,12 +74,3 @@ JsonMap _encodeIconStyler(IconStyler value) { 'animation': value.$animation, }; } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/image_styler_codec.dart b/packages/mix_schema/lib/src/schema/image_styler_codec.dart index 6555f656dd..7b3ca855db 100644 --- a/packages/mix_schema/lib/src/schema/image_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/image_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import '../registry/registry_value_codec.dart'; import 'animation_codec.dart'; @@ -20,20 +19,11 @@ AckSchema imageStylerCodec({ 'width': numberAsDoubleCodec().optional(), 'height': numberAsDoubleCodec().optional(), 'color': colorCodec().optional(), - 'repeat': enumNameCodec( - ImageRepeat.values, - debugName: 'ImageRepeat', - ).optional(), - 'fit': enumNameCodec(BoxFit.values, debugName: 'BoxFit').optional(), + 'repeat': enumNameCodec(ImageRepeat.values).optional(), + 'fit': enumNameCodec(BoxFit.values).optional(), 'alignment': alignmentCodec().optional(), - 'filterQuality': enumNameCodec( - FilterQuality.values, - debugName: 'FilterQuality', - ).optional(), - 'colorBlendMode': enumNameCodec( - BlendMode.values, - debugName: 'BlendMode', - ).optional(), + 'filterQuality': enumNameCodec(FilterQuality.values).optional(), + 'colorBlendMode': enumNameCodec(BlendMode.values).optional(), 'semanticLabel': Ack.string().optional(), 'excludeFromSemantics': Ack.boolean().optional(), 'gaplessPlayback': Ack.boolean().optional(), @@ -65,8 +55,8 @@ AckSchema imageStylerCodec({ } JsonMap _encodeImageStyler(ImageStyler value) { - _failIfPresent(value.$variants, 'variants'); - _failIfPresent(value.$centerSlice, 'centerSlice'); + failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$centerSlice, 'centerSlice'); return { 'image': singleValueProp(value.$image, 'image'), @@ -96,12 +86,3 @@ JsonMap _encodeImageStyler(ImageStyler value) { 'animation': value.$animation, }; } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/modifier_codec.dart b/packages/mix_schema/lib/src/schema/modifier_codec.dart index 9a372912b8..b6e3abcb81 100644 --- a/packages/mix_schema/lib/src/schema/modifier_codec.dart +++ b/packages/mix_schema/lib/src/schema/modifier_codec.dart @@ -78,11 +78,11 @@ Object _encodeModifierConfig(WidgetModifierConfig value) { } JsonMap _encodeDefaultTextStyleModifier(DefaultTextStyleModifierMix value) { - _failIfPresent( + failIfPresent( value.textWidthBasis, 'modifiers.defaultTextStyle.textWidthBasis', ); - _failIfPresent( + failIfPresent( value.textHeightBehavior, 'modifiers.defaultTextStyle.textHeightBehavior', ); @@ -110,12 +110,3 @@ JsonMap _encodeDefaultTextStyleModifier(DefaultTextStyleModifierMix value) { ), }; } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart b/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart index c54df4ec27..9bb444d522 100644 --- a/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import 'animation_codec.dart'; import 'box_styler_codec.dart'; @@ -18,18 +17,12 @@ AckSchema stackBoxStylerCodec({ 'padding': edgeInsetsCodec().optional(), 'margin': edgeInsetsCodec().optional(), 'constraints': boxConstraintsCodec().optional(), - 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'clipBehavior': enumNameCodec(Clip.values).optional(), 'decoration': boxDecorationCodec().optional(), 'stackAlignment': alignmentCodec().optional(), - 'fit': enumNameCodec(StackFit.values, debugName: 'StackFit').optional(), - 'textDirection': enumNameCodec( - TextDirection.values, - debugName: 'TextDirection', - ).optional(), - 'stackClipBehavior': enumNameCodec( - Clip.values, - debugName: 'Clip', - ).optional(), + 'fit': enumNameCodec(StackFit.values).optional(), + 'textDirection': enumNameCodec(TextDirection.values).optional(), + 'stackClipBehavior': enumNameCodec(Clip.values).optional(), 'modifiers': modifierConfigCodec().optional(), 'animation': animationConfigCodec(registry: registry).optional(), }).codec( @@ -52,7 +45,7 @@ AckSchema stackBoxStylerCodec({ } JsonMap _encodeStackBoxStyler(StackBoxStyler value) { - _failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$variants, 'variants'); final box = singleMixProp>(value.$box, 'box'); final stack = singleMixProp>( @@ -76,12 +69,3 @@ JsonMap _encodeStackBoxStyler(StackBoxStyler value) { 'animation': value.$animation, }; } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/stack_styler_codec.dart b/packages/mix_schema/lib/src/schema/stack_styler_codec.dart index 32d6a625a6..d32517de62 100644 --- a/packages/mix_schema/lib/src/schema/stack_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/stack_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import 'animation_codec.dart'; import 'common_codecs.dart'; @@ -13,12 +12,9 @@ AckSchema stackStylerCodec({ }) { return Ack.object({ 'alignment': alignmentCodec().optional(), - 'fit': enumNameCodec(StackFit.values, debugName: 'StackFit').optional(), - 'textDirection': enumNameCodec( - TextDirection.values, - debugName: 'TextDirection', - ).optional(), - 'clipBehavior': enumNameCodec(Clip.values, debugName: 'Clip').optional(), + 'fit': enumNameCodec(StackFit.values).optional(), + 'textDirection': enumNameCodec(TextDirection.values).optional(), + 'clipBehavior': enumNameCodec(Clip.values).optional(), 'modifiers': modifierConfigCodec().optional(), 'animation': animationConfigCodec(registry: registry).optional(), }).codec( @@ -38,7 +34,7 @@ JsonMap encodeStackStylerFields( StackStyler value, { bool includeStylerMetadata = true, }) { - _failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$variants, 'variants'); final encoded = { 'alignment': singleAlignmentProp(value.$alignment, 'alignment'), @@ -55,12 +51,3 @@ JsonMap encodeStackStylerFields( ..remove('modifiers') ..remove('animation'); } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/text_styler_codec.dart b/packages/mix_schema/lib/src/schema/text_styler_codec.dart index 56ee1dcced..3aee5b690a 100644 --- a/packages/mix_schema/lib/src/schema/text_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/text_styler_codec.dart @@ -2,7 +2,6 @@ import 'package:ack/ack.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../errors/mix_schema_error.dart'; import '../registry/registry.dart'; import 'animation_codec.dart'; import 'common_codecs.dart'; @@ -40,13 +39,13 @@ AckSchema textStylerCodec({ } JsonMap _encodeTextStyler(TextStyler value) { - _failIfPresent(value.$strutStyle, 'strutStyle'); - _failIfPresent(value.$textScaler, 'textScaler'); - _failIfPresent(value.$textWidthBasis, 'textWidthBasis'); - _failIfPresent(value.$textHeightBehavior, 'textHeightBehavior'); - _failIfPresent(value.$textDirectives, 'textDirectives'); - _failIfPresent(value.$locale, 'locale'); - _failIfPresent(value.$variants, 'variants'); + failIfPresent(value.$strutStyle, 'strutStyle'); + failIfPresent(value.$textScaler, 'textScaler'); + failIfPresent(value.$textWidthBasis, 'textWidthBasis'); + failIfPresent(value.$textHeightBehavior, 'textHeightBehavior'); + failIfPresent(value.$textDirectives, 'textDirectives'); + failIfPresent(value.$locale, 'locale'); + failIfPresent(value.$variants, 'variants'); return { 'overflow': singleValueProp(value.$overflow, 'overflow'), @@ -98,15 +97,15 @@ CodecSchema textStyleMixCodec() { } JsonMap _encodeTextStyle(TextStyleMix value) { - _failIfPresent(value.$debugLabel, 'style.debugLabel'); - _failIfPresent(value.$textBaseline, 'style.textBaseline'); - _failIfPresent(value.$foreground, 'style.foreground'); - _failIfPresent(value.$background, 'style.background'); - _failIfPresent(value.$inherit, 'style.inherit'); - _failIfPresent(value.$fontFamilyFallback, 'style.fontFamilyFallback'); - _failIfPresent(value.$fontFeatures, 'style.fontFeatures'); - _failIfPresent(value.$fontVariations, 'style.fontVariations'); - _failIfPresent(value.$shadows, 'style.shadows'); + failIfPresent(value.$debugLabel, 'style.debugLabel'); + failIfPresent(value.$textBaseline, 'style.textBaseline'); + failIfPresent(value.$foreground, 'style.foreground'); + failIfPresent(value.$background, 'style.background'); + failIfPresent(value.$inherit, 'style.inherit'); + failIfPresent(value.$fontFamilyFallback, 'style.fontFamilyFallback'); + failIfPresent(value.$fontFeatures, 'style.fontFeatures'); + failIfPresent(value.$fontVariations, 'style.fontVariations'); + failIfPresent(value.$shadows, 'style.shadows'); return { 'color': singleValueProp(value.$color, 'style.color'), @@ -206,12 +205,3 @@ CodecSchema textDecorationStyleCodec() { 'wavy': TextDecorationStyle.wavy, }, debugName: 'TextDecorationStyle'); } - -void _failIfPresent(Object? value, String fieldName) { - if (value == null) return; - - throw UnsupportedEncodeValueError( - value, - 'Field "$fieldName" is not representable by this schema.', - ); -} diff --git a/packages/mix_schema/lib/src/schema/variant_codec.dart b/packages/mix_schema/lib/src/schema/variant_codec.dart index 5fb737862f..19ca005af6 100644 --- a/packages/mix_schema/lib/src/schema/variant_codec.dart +++ b/packages/mix_schema/lib/src/schema/variant_codec.dart @@ -125,43 +125,41 @@ AckSchema> _breakpointVariantCodec( AckSchema rootStyleSchema, ) { return Ack.object({ - 'minWidth': numberAsDoubleCodec().optional(), - 'maxWidth': numberAsDoubleCodec().optional(), - 'style': rootStyleSchema, - }).codec>( - decode: (data) { - final minWidth = data['minWidth'] as double?; - final maxWidth = data['maxWidth'] as double?; - if (minWidth == null && maxWidth == null) { - throw const UnsupportedEncodeValueError( - null, - 'A context_breakpoint variant requires minWidth or maxWidth.', - ); - } + 'minWidth': numberAsDoubleCodec().optional(), + 'maxWidth': numberAsDoubleCodec().optional(), + 'style': rootStyleSchema, + }) + .constrain( + const _BreakpointBoundsConstraint('context_breakpoint variant'), + ) + .codec>( + decode: (data) { + final minWidth = data['minWidth'] as double?; + final maxWidth = data['maxWidth'] as double?; + + return VariantStyle( + ContextVariant.breakpoint( + Breakpoint(minWidth: minWidth, maxWidth: maxWidth), + ), + _boxStyle(data['style']!), + ); + }, + encode: (value) { + final breakpoint = _breakpointFromKey(value.variant.key); + if (breakpoint == null) { + throw UnsupportedEncodeValueError( + value.variant, + 'Expected breakpoint context variant.', + ); + } - return VariantStyle( - ContextVariant.breakpoint( - Breakpoint(minWidth: minWidth, maxWidth: maxWidth), - ), - _boxStyle(data['style']!), + return { + 'minWidth': breakpoint.minWidth, + 'maxWidth': breakpoint.maxWidth, + 'style': value.value, + }; + }, ); - }, - encode: (value) { - final breakpoint = _breakpointFromKey(value.variant.key); - if (breakpoint == null) { - throw UnsupportedEncodeValueError( - value.variant, - 'Expected breakpoint context variant.', - ); - } - - return { - 'minWidth': breakpoint.minWidth, - 'maxWidth': breakpoint.maxWidth, - 'style': value.value, - }; - }, - ); } AckSchema> _notWidgetStateVariantCodec( @@ -322,53 +320,42 @@ AckSchema _contextConditionCodec() { ), 'context_breakpoint': Ack.object({ - 'minWidth': numberAsDoubleCodec().optional(), - 'maxWidth': numberAsDoubleCodec().optional(), - }).codec<_ContextCondition>( - decode: (data) { - final minWidth = data['minWidth'] as double?; - final maxWidth = data['maxWidth'] as double?; - if (minWidth == null && maxWidth == null) { - throw const UnsupportedEncodeValueError( - null, - 'A breakpoint condition requires minWidth or maxWidth.', - ); - } - - return _ContextCondition.breakpoint( - Breakpoint(minWidth: minWidth, maxWidth: maxWidth), - ); - }, - encode: (value) { - final breakpoint = value.breakpoint; - if (breakpoint == null) { - throw UnsupportedEncodeValueError( - value, - 'Expected breakpoint condition.', - ); - } - - return { - 'minWidth': breakpoint.minWidth, - 'maxWidth': breakpoint.maxWidth, - }; - }, - ), + 'minWidth': numberAsDoubleCodec().optional(), + 'maxWidth': numberAsDoubleCodec().optional(), + }) + .constrain( + const _BreakpointBoundsConstraint('breakpoint condition'), + ) + .codec<_ContextCondition>( + decode: (data) { + final minWidth = data['minWidth'] as double?; + final maxWidth = data['maxWidth'] as double?; + + return _ContextCondition.breakpoint( + Breakpoint(minWidth: minWidth, maxWidth: maxWidth), + ); + }, + encode: (value) { + final breakpoint = value.breakpoint; + if (breakpoint == null) { + throw UnsupportedEncodeValueError( + value, + 'Expected breakpoint condition.', + ); + } + + return { + 'minWidth': breakpoint.minWidth, + 'maxWidth': breakpoint.maxWidth, + }; + }, + ), }, ); } CodecSchema _widgetStateCodec() { - return strictEnumCodec({ - 'hovered': WidgetState.hovered, - 'focused': WidgetState.focused, - 'pressed': WidgetState.pressed, - 'dragged': WidgetState.dragged, - 'selected': WidgetState.selected, - 'scrolled_under': WidgetState.scrolledUnder, - 'disabled': WidgetState.disabled, - 'error': WidgetState.error, - }, debugName: 'WidgetState'); + return strictEnumCodec(_widgetStateByWire, debugName: 'WidgetState'); } CodecSchema _brightnessCodec() { @@ -415,7 +402,7 @@ WidgetState? _notWidgetStateFromKey(String key) { return _widgetStateByWire[wire]; } -final Map _widgetStateByWire = { +const Map _widgetStateByWire = { 'hovered': WidgetState.hovered, 'focused': WidgetState.focused, 'pressed': WidgetState.pressed, @@ -569,3 +556,24 @@ String _widgetStateWire(WidgetState state) { throw UnsupportedEncodeValueError(state, 'Unknown widget state.'); } + +final class _BreakpointBoundsConstraint extends Constraint + with Validator { + const _BreakpointBoundsConstraint(this.subject) + : super( + constraintKey: 'mix_schema_breakpoint_bounds', + description: 'Breakpoint variants require at least one width bound.', + ); + + final String subject; + + @override + bool isValid(JsonMap value) { + return value['minWidth'] != null || value['maxWidth'] != null; + } + + @override + String buildMessage(JsonMap value) { + return 'A $subject requires minWidth or maxWidth.'; + } +} diff --git a/packages/mix_schema/test/common_codecs_test.dart b/packages/mix_schema/test/common_codecs_test.dart index a1b58235bb..19df8108ae 100644 --- a/packages/mix_schema/test/common_codecs_test.dart +++ b/packages/mix_schema/test/common_codecs_test.dart @@ -1,7 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; -import 'package:mix_schema/src/errors/mix_schema_error.dart'; +import 'package:mix_schema/mix_schema.dart' + show + MixSchemaContractBuilder, + MixSchemaError, + MixSchemaErrorCode, + MixSchemaValidationFailure, + MixSchemaValidationResult, + MixSchemaValidationSuccess; +import 'package:mix_schema/src/errors/mix_schema_error.dart' + show UnsupportedEncodeValueError; import 'package:mix_schema/src/schema/common_codecs.dart'; void main() { @@ -34,6 +43,30 @@ void main() { expect(result.isFail, isTrue); }); + test('R-7 color channel bounds fail as constraint violations', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + + for (final color in [ + 'rgb(999,0,0)', + 'rgb(-1,0,0)', + 'rgb(1000,0,0)', + 'rgba(0,0,0,1.5)', + 'rgba(0,0,0,-0.1)', + ]) { + final errors = _validationErrors( + contract.validate({ + 'type': 'box', + 'decoration': {'color': color}, + }), + ); + final codes = errors.map((error) => error.code); + + expect(codes, contains(MixSchemaErrorCode.constraintViolation)); + expect(codes, isNot(contains(MixSchemaErrorCode.transformFailed))); + expect(codes, isNot(contains(MixSchemaErrorCode.unsupportedEncodeValue))); + } + }); + test('R-4 alignment codec round-trips named and arbitrary alignments', () { final schema = alignmentCodec(); final value = schema.safeParse({'x': -1, 'y': 0.5}).getOrThrow()!; @@ -76,3 +109,10 @@ void main() { ); }); } + +List _validationErrors(MixSchemaValidationResult result) { + return switch (result) { + MixSchemaValidationFailure(:final errors) => errors, + MixSchemaValidationSuccess() => fail('expected validation failure'), + }; +} diff --git a/packages/mix_schema/test/mix_schema_contract_test.dart b/packages/mix_schema/test/mix_schema_contract_test.dart index 0c90595561..d90c69fb42 100644 --- a/packages/mix_schema/test/mix_schema_contract_test.dart +++ b/packages/mix_schema/test/mix_schema_contract_test.dart @@ -1,5 +1,6 @@ import 'package:ack/ack.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart'; final class _LimitStyle { @@ -47,4 +48,48 @@ void main() { expect(schema['x-mix-schema-version'], isA()); expect(schema['x-mix-schema-limits'], isA>()); }); + + test('contract builder cannot be mutated or frozen twice after freeze', () { + final branch = Ack.object({'value': Ack.string()}).codec<_LimitStyle>( + decode: (data) => _LimitStyle(data['value']! as String), + encode: (value) => {'value': value.value}, + ); + final builder = MixSchemaContractBuilder().addStyler('limit', branch); + final contract = builder.freeze(); + + expect(contract.registeredTypes, ['limit']); + expect(() => contract.registeredTypes.add('other'), throwsUnsupportedError); + expect( + () => builder.withLimits(const MixSchemaLimits(maxDepth: 8)), + throwsStateError, + ); + expect(() => builder.addStyler('other', branch), throwsStateError); + expect(builder.builtIn, throwsStateError); + expect(builder.freeze, throwsStateError); + }); + + test('frozen contract remains usable with original registry behavior', () { + void onEnd() {} + + final builder = MixSchemaContractBuilder() + ..registry.animationOnEnd('done', onEnd); + final contract = builder.builtIn().freeze(); + + expect( + () => builder.registry.animationOnEnd('later', () {}), + throwsStateError, + ); + expect( + contract.decode({ + 'type': 'box', + 'animation': { + 'duration': 250, + 'curve': 'linear', + 'delay': 0, + 'onEnd': 'done', + }, + }), + isA>(), + ); + }); } diff --git a/packages/mix_schema/test/registry_builder_test.dart b/packages/mix_schema/test/registry_builder_test.dart index 339690b323..de6c2e9c9a 100644 --- a/packages/mix_schema/test/registry_builder_test.dart +++ b/packages/mix_schema/test/registry_builder_test.dart @@ -67,4 +67,20 @@ void main() { throwsA(isA()), ); }); + + test('R-6 registry builder cannot register or freeze twice after freeze', () { + const icon = IconData(0xe145, fontFamily: 'MaterialIcons'); + final builder = RegistryBuilder().iconData('add', icon); + final registry = builder.freeze(); + + expect(registry.lookup(MixSchemaScope.iconData, 'add'), icon); + expect( + () => builder.iconData( + 'remove', + const IconData(0xe15b, fontFamily: 'MaterialIcons'), + ), + throwsStateError, + ); + expect(builder.freeze, throwsStateError); + }); } diff --git a/packages/mix_schema/test/variant_codec_test.dart b/packages/mix_schema/test/variant_codec_test.dart index c0ae24899b..91488dac67 100644 --- a/packages/mix_schema/test/variant_codec_test.dart +++ b/packages/mix_schema/test/variant_codec_test.dart @@ -104,6 +104,54 @@ void main() { expect(nested, isA()); }); + test('R-11 breakpoint variants without bounds fail as constraints', () { + for (final payload in [ + { + 'type': 'box', + 'variants': [ + { + 'kind': 'context_breakpoint', + 'style': {'type': 'box'}, + }, + ], + }, + { + 'type': 'box', + 'variants': [ + { + 'kind': 'context_all_of', + 'conditions': [ + {'kind': 'context_breakpoint'}, + ], + 'style': {'type': 'box'}, + }, + ], + }, + ]) { + final errors = _validationErrors(contract().validate(payload)); + final codes = errors.map((error) => error.code); + + expect(codes, contains(MixSchemaErrorCode.constraintViolation)); + expect(codes, isNot(contains(MixSchemaErrorCode.transformFailed))); + expect(codes, isNot(contains(MixSchemaErrorCode.unsupportedEncodeValue))); + } + }); + + test('non-box variants remain explicitly unsupported', () { + final result = contract().encode( + TextStyler().variant(const NamedVariant('body'), TextStyler(maxLines: 1)), + ); + final errors = switch (result) { + MixSchemaEncodeFailure(:final errors) => errors, + MixSchemaEncodeSuccess() => fail('expected encode failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.unsupportedEncodeValue), + ); + }); + test('R-11 encodes variants through lazy nested style', () { final style = BoxStyler().variant( const NamedVariant('primary'), @@ -158,3 +206,10 @@ void main() { ]); }); } + +List _validationErrors(MixSchemaValidationResult result) { + return switch (result) { + MixSchemaValidationFailure(:final errors) => errors, + MixSchemaValidationSuccess() => fail('expected validation failure'), + }; +} diff --git a/packages/mix_tailwinds/pubspec.yaml b/packages/mix_tailwinds/pubspec.yaml index 7638481f07..d33c83dff1 100644 --- a/packages/mix_tailwinds/pubspec.yaml +++ b/packages/mix_tailwinds/pubspec.yaml @@ -14,10 +14,10 @@ dependencies: sdk: flutter mix: path: ../mix - mix_schema: - path: ../mix_schema dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + mix_schema: + path: ../mix_schema diff --git a/packages/mix_tailwinds/test/schema_payload_contract_test.dart b/packages/mix_tailwinds/test/schema_payload_contract_test.dart index 14e6067edf..b0a287bc47 100644 --- a/packages/mix_tailwinds/test/schema_payload_contract_test.dart +++ b/packages/mix_tailwinds/test/schema_payload_contract_test.dart @@ -4,7 +4,7 @@ import 'package:mix_schema/mix_schema.dart'; import 'package:mix_tailwinds/mix_tailwinds.dart'; void main() { - test('box parser output validates through mix_schema when representable', () { + test('schema-representable box parser output encodes through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); final style = TwParser().parseBox('bg-blue-500 p-4'); From dc6ea677873e32b94a68b8bbde18b3829f44eb70 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 10 Jun 2026 20:15:15 -0400 Subject: [PATCH 03/11] feat(mix_schema): expand codec coverage and route tailwinds through payloads Broaden mix_schema styler codecs and wire mix_tailwinds to emit schema-validated payloads for accepted utilities. - Box decoration codecs now cover border, borderRadius, shape, backgroundBlendMode, and boxShadow; box/flex_box/stack_box stylers gain transform and transformAlignment. - Add Matrix4, Offset, BorderSide, Border, BoxShadow, Shadow, and TextHeightBehavior codecs; text styler covers textHeightBehavior, textDirectives, and TextStyle shadows. - Rework box constraints with explicit min/max bound validation so inverted bounds and unbounded minimums fail with stable errors. - TwParser routes accepted utilities through mix_schema payloads via tw_schema_payload(_policy).dart and parse*Payload APIs; mix_schema becomes a production dependency of mix_tailwinds. - melos analyze/ci run with --no-select for non-interactive CI. --- melos.yaml | 10 +- packages/mix_schema/REQUIREMENTS.md | 8 +- .../lib/src/schema/box_styler_codec.dart | 65 +- .../lib/src/schema/common_codecs.dart | 274 +++++++- .../lib/src/schema/flex_box_styler_codec.dart | 4 + .../src/schema/stack_box_styler_codec.dart | 4 + .../lib/src/schema/text_styler_codec.dart | 25 +- .../mix_schema/test/common_codecs_test.dart | 128 ++++ .../test/schema_export_golden_test.dart | 179 ++++- .../test/text_styler_codec_test.dart | 28 +- packages/mix_tailwinds/lib/src/tw_parser.dart | 63 +- .../lib/src/tw_schema_payload.dart | 643 ++++++++++++++++++ .../lib/src/tw_schema_payload_policy.dart | 305 +++++++++ packages/mix_tailwinds/pubspec.yaml | 4 +- .../test/schema_payload_contract_test.dart | 190 +++++- 15 files changed, 1829 insertions(+), 101 deletions(-) create mode 100644 packages/mix_tailwinds/lib/src/tw_schema_payload.dart create mode 100644 packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart diff --git a/melos.yaml b/melos.yaml index 41e8cac9dc..0d536716b5 100644 --- a/melos.yaml +++ b/melos.yaml @@ -38,11 +38,9 @@ categories: scripts: # ANALYSIS analyze: - run: melos run analyze:dart && melos run analyze:dcm + run: melos run analyze:dart --no-select && melos run analyze:dcm --no-select description: Run all static analysis checks. - exec: - failFast: true - + analyze:dart: run: melos exec -c 4 -- dart analyze . description: Run Dart static analysis checks. @@ -124,10 +122,8 @@ scripts: dirExists: test ci: - run: melos run test:flutter && melos run test:dart + run: melos run test:flutter --no-select && melos run test:dart --no-select description: Run flutter and dart tests - packageFilters: - dirExists: test brb: run: melos run gen:build diff --git a/packages/mix_schema/REQUIREMENTS.md b/packages/mix_schema/REQUIREMENTS.md index d1e4bff921..605f4e0e6f 100644 --- a/packages/mix_schema/REQUIREMENTS.md +++ b/packages/mix_schema/REQUIREMENTS.md @@ -6,9 +6,7 @@ Pinned Ack: `btwld/ack` `8daaadace3e0c9969e05eb0fe5633a51c2bb124b`, path `packag Flutter primitive payloads mirror Ack `flutter_codec` branch shapes where they do not erase Mix semantics. Local mirrors must be replaced by `flutter_codec` imports once that package is merged and available to this workspace. -Current variant support is intentionally Box-only. All-styler variant payloads remain deferred and must fail explicitly rather than silently accepting a shape whose runtime semantics are not represented yet. - -Tailwinds checks in this package assert that parser output can be encoded when it is schema-representable. They do not mean Tailwinds emits schema payloads directly. +Tailwinds emits accepted utility classes as `mix_schema` payloads, validates/decodes them through `MixSchemaContract`, and then returns Mix stylers from its existing parser APIs. Parser diagnostics remain responsible for unsupported Tailwinds utilities; accepted utilities must not bypass the schema contract with direct styler mutation. | Rule | Requirement | Implementation | Tests | | --- | --- | --- | --- | @@ -20,7 +18,7 @@ Tailwinds checks in this package assert that parser output can be encoded when i | R-6 | App-owned identity uses scoped registries. | `src/registry/` | `registry_*_test.dart` | | R-7 | Public errors expose stable code, path, message, and offending value. | `schema_error_mapper.dart` | `error_mapper_test.dart` | | R-8 | Payload limits run before decode and after encode. | `validatePayloadLimits` | `mix_schema_contract_test.dart` | -| R-9 | Tailwinds depends only on public `mix_schema.dart` and `encode.dart`. | `packages/mix_tailwinds` imports | guard tests | +| R-9 | Tailwinds production code depends only on public `mix_schema.dart` and `encode.dart` and routes accepted utilities through schema payloads. | `packages/mix_tailwinds` imports and parser payload APIs | guard tests, Tailwinds payload contract tests | | R-10 | Missing payload fields are not filled with Mix runtime defaults. | no schema `withDefault` for runtime defaults | styler tests | | R-11 | Recursive nested styles use `Ack.lazy`. | `variant_codec.dart` | `variant_codec_test.dart` | | R-12 | `encode.dart` is a narrow producer helper surface and exports no schema internals. | `lib/encode.dart` only | guard tests | @@ -33,4 +31,4 @@ Registry scopes: `animation_on_end`, `icon_data`, `image_provider`, `context_var Encode policy: only values that can be represented without losing semantics are encoded. Tokens, directives, multi-source props, closures without registry ids, arbitrary `Curve`s, spring/phase/keyframe animations, and unsupported modifiers fail with stable public errors. -Acceptance gate: `melos bootstrap`, package tests for `mix_schema` and `mix_tailwinds`, `melos run analyze`, `melos run ci`, and guard searches for forbidden imports/discriminator helpers must pass before completion. +Acceptance gate: `melos bootstrap`, package tests for `mix_schema` and `mix_tailwinds`, `melos run analyze`, `melos run ci`, and guard searches for forbidden imports/discriminator helpers/default-value codecs must pass before completion. diff --git a/packages/mix_schema/lib/src/schema/box_styler_codec.dart b/packages/mix_schema/lib/src/schema/box_styler_codec.dart index 024be04c99..cb47b5a061 100644 --- a/packages/mix_schema/lib/src/schema/box_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/box_styler_codec.dart @@ -18,6 +18,8 @@ AckSchema boxStylerCodec({ 'margin': edgeInsetsCodec().optional(), 'constraints': boxConstraintsCodec().optional(), 'clipBehavior': enumNameCodec(Clip.values).optional(), + 'transform': matrix4Codec().optional(), + 'transformAlignment': alignmentCodec().optional(), 'decoration': boxDecorationCodec().optional(), if (rootStyleSchema != null) 'variants': Ack.list( @@ -32,6 +34,8 @@ AckSchema boxStylerCodec({ margin: data['margin'] as EdgeInsetsMix?, constraints: data['constraints'] as BoxConstraintsMix?, clipBehavior: data['clipBehavior'] as Clip?, + transform: data['transform'] as Matrix4?, + transformAlignment: data['transformAlignment'] as Alignment?, decoration: data['decoration'] as BoxDecorationMix?, variants: data['variants'] as List>?, modifier: data['modifiers'] as WidgetModifierConfig?, @@ -46,9 +50,6 @@ JsonMap encodeBoxStylerFields( bool includeStylerMetadata = true, }) { failIfPresent(value.$foregroundDecoration, 'foregroundDecoration'); - failIfPresent(value.$transform, 'transform'); - failIfPresent(value.$transformAlignment, 'transformAlignment'); - final encoded = { 'alignment': singleAlignmentProp(value.$alignment, 'alignment'), 'padding': singleMixProp( @@ -64,6 +65,11 @@ JsonMap encodeBoxStylerFields( 'constraints', ), 'clipBehavior': singleValueProp(value.$clipBehavior, 'clipBehavior'), + 'transform': singleValueProp(value.$transform, 'transform'), + 'transformAlignment': singleAlignmentProp( + value.$transformAlignment, + 'transformAlignment', + ), 'decoration': singleMixProp( value.$decoration, 'decoration', @@ -82,21 +88,52 @@ JsonMap encodeBoxStylerFields( } CodecSchema boxDecorationCodec() { - return Ack.object({'color': colorCodec().optional()}).codec( - decode: (data) => BoxDecorationMix(color: data['color'] as Color?), + return Ack.object({ + 'color': colorCodec().optional(), + 'border': borderCodec().optional(), + 'borderRadius': borderRadiusCodec().optional(), + 'shape': enumNameCodec(BoxShape.values).optional(), + 'backgroundBlendMode': enumNameCodec(BlendMode.values).optional(), + 'boxShadow': Ack.list(boxShadowCodec()).optional(), + }).codec( + decode: (data) => BoxDecorationMix( + color: data['color'] as Color?, + border: data['border'] as BorderMix?, + borderRadius: data['borderRadius'] as BorderRadiusMix?, + shape: data['shape'] as BoxShape?, + backgroundBlendMode: data['backgroundBlendMode'] as BlendMode?, + boxShadow: data['boxShadow'] as List?, + ), encode: (value) { - failIfPresent(value.$border, 'decoration.border'); - failIfPresent(value.$borderRadius, 'decoration.borderRadius'); - failIfPresent(value.$shape, 'decoration.shape'); - failIfPresent( - value.$backgroundBlendMode, - 'decoration.backgroundBlendMode', - ); failIfPresent(value.$image, 'decoration.image'); failIfPresent(value.$gradient, 'decoration.gradient'); - failIfPresent(value.$boxShadow, 'decoration.boxShadow'); - return {'color': singleValueProp(value.$color, 'decoration.color')}; + return { + 'color': singleValueProp(value.$color, 'decoration.color'), + 'border': singleMixProp( + value.$border, + 'decoration.border', + ), + 'borderRadius': singleMixProp( + value.$borderRadius, + 'decoration.borderRadius', + ), + 'shape': singleValueProp(value.$shape, 'decoration.shape'), + 'backgroundBlendMode': singleValueProp( + value.$backgroundBlendMode, + 'decoration.backgroundBlendMode', + ), + 'boxShadow': _singleBoxShadowList(value), + }; }, ); } + +List? _singleBoxShadowList(BoxDecorationMix value) { + final boxShadow = singleMixProp>( + value.$boxShadow, + 'decoration.boxShadow', + ); + + return boxShadow?.items; +} diff --git a/packages/mix_schema/lib/src/schema/common_codecs.dart b/packages/mix_schema/lib/src/schema/common_codecs.dart index 2d83842aef..87a56a7524 100644 --- a/packages/mix_schema/lib/src/schema/common_codecs.dart +++ b/packages/mix_schema/lib/src/schema/common_codecs.dart @@ -40,6 +40,28 @@ CodecSchema alignmentCodec() { ); } +CodecSchema offsetCodec() { + return Ack.object({ + 'x': numberAsDoubleCodec(), + 'y': numberAsDoubleCodec(), + }).codec( + decode: (data) => Offset(data['x']! as double, data['y']! as double), + encode: (value) => {'x': value.dx, 'y': value.dy}, + ); +} + +CodecSchema, Matrix4> matrix4Codec() { + return Ack.list(numberAsDoubleCodec()) + .refine( + (value) => value.length == 16, + message: 'Matrix4 payloads must contain exactly 16 numbers.', + ) + .codec( + decode: (value) => Matrix4.fromList(value), + encode: (value) => value.storage.toList(growable: false), + ); +} + CodecSchema radiusCodec() { return Ack.codec( input: Ack.anyOf([ @@ -54,6 +76,65 @@ CodecSchema radiusCodec() { ); } +CodecSchema borderSideCodec() { + return Ack.object({ + 'color': colorCodec().optional(), + 'width': numberAsDoubleCodec().optional(), + 'style': strictEnumCodec({ + 'none': BorderStyle.none, + 'solid': BorderStyle.solid, + }, debugName: 'BorderStyle').optional(), + 'strokeAlign': numberAsDoubleCodec().optional(), + }).codec( + decode: (data) => BorderSideMix( + color: data['color'] as Color?, + width: data['width'] as double?, + style: data['style'] as BorderStyle?, + strokeAlign: data['strokeAlign'] as double?, + ), + encode: (value) => { + 'color': singleValueProp(value.$color, 'borderSide.color'), + 'width': singleValueProp(value.$width, 'borderSide.width'), + 'style': singleValueProp(value.$style, 'borderSide.style'), + 'strokeAlign': singleValueProp( + value.$strokeAlign, + 'borderSide.strokeAlign', + ), + }, + ); +} + +CodecSchema borderCodec() { + return Ack.object({ + 'top': borderSideCodec().optional(), + 'right': borderSideCodec().optional(), + 'bottom': borderSideCodec().optional(), + 'left': borderSideCodec().optional(), + }).codec( + decode: (data) => BorderMix( + top: data['top'] as BorderSideMix?, + right: data['right'] as BorderSideMix?, + bottom: data['bottom'] as BorderSideMix?, + left: data['left'] as BorderSideMix?, + ), + encode: (value) => { + 'top': singleMixProp(value.$top, 'border.top'), + 'right': singleMixProp( + value.$right, + 'border.right', + ), + 'bottom': singleMixProp( + value.$bottom, + 'border.bottom', + ), + 'left': singleMixProp( + value.$left, + 'border.left', + ), + }, + ); +} + CodecSchema edgeInsetsCodec() { return Ack.codec( input: Ack.anyOf([ @@ -70,36 +151,110 @@ CodecSchema edgeInsetsCodec() { ); } -CodecSchema boxConstraintsCodec() { +CodecSchema boxShadowCodec() { return Ack.object({ - 'minWidth': nonNegativeDoubleCodec().nullable().optional(), - 'maxWidth': nonNegativeDoubleCodec().nullable().optional(), - 'minHeight': nonNegativeDoubleCodec().nullable().optional(), - 'maxHeight': nonNegativeDoubleCodec().nullable().optional(), - }).codec( - decode: (data) => BoxConstraintsMix( - minWidth: _readOptionalConstraintBound(data, 'minWidth'), - maxWidth: _readOptionalConstraintBound(data, 'maxWidth'), - minHeight: _readOptionalConstraintBound(data, 'minHeight'), - maxHeight: _readOptionalConstraintBound(data, 'maxHeight'), + 'color': colorCodec().optional(), + 'offset': offsetCodec().optional(), + 'blurRadius': numberAsDoubleCodec().optional(), + 'spreadRadius': numberAsDoubleCodec().optional(), + }).codec( + decode: (data) => BoxShadowMix( + color: data['color'] as Color?, + offset: data['offset'] as Offset?, + blurRadius: data['blurRadius'] as double?, + spreadRadius: data['spreadRadius'] as double?, ), encode: (value) => { - 'minWidth': _encodeConstraintBound( - singleValueProp(value.$minWidth, 'minWidth'), + 'color': singleValueProp(value.$color, 'boxShadow.color'), + 'offset': singleValueProp(value.$offset, 'boxShadow.offset'), + 'blurRadius': singleValueProp(value.$blurRadius, 'boxShadow.blurRadius'), + 'spreadRadius': singleValueProp( + value.$spreadRadius, + 'boxShadow.spreadRadius', ), - 'maxWidth': _encodeConstraintBound( - singleValueProp(value.$maxWidth, 'maxWidth'), + }, + ); +} + +CodecSchema shadowCodec() { + return Ack.object({ + 'color': colorCodec().optional(), + 'offset': offsetCodec().optional(), + 'blurRadius': numberAsDoubleCodec().optional(), + }).codec( + decode: (data) => ShadowMix( + color: data['color'] as Color?, + offset: data['offset'] as Offset?, + blurRadius: data['blurRadius'] as double?, + ), + encode: (value) => { + 'color': singleValueProp(value.$color, 'shadow.color'), + 'offset': singleValueProp(value.$offset, 'shadow.offset'), + 'blurRadius': singleValueProp(value.$blurRadius, 'shadow.blurRadius'), + }, + ); +} + +CodecSchema textHeightBehaviorCodec() { + return Ack.object({ + 'applyHeightToFirstAscent': Ack.boolean().optional(), + 'applyHeightToLastDescent': Ack.boolean().optional(), + 'leadingDistribution': enumNameCodec( + TextLeadingDistribution.values, + ).optional(), + }).codec( + decode: (data) => TextHeightBehaviorMix( + applyHeightToFirstAscent: data['applyHeightToFirstAscent'] as bool?, + applyHeightToLastDescent: data['applyHeightToLastDescent'] as bool?, + leadingDistribution: + data['leadingDistribution'] as TextLeadingDistribution?, + ), + encode: (value) => { + 'applyHeightToFirstAscent': singleValueProp( + value.$applyHeightToFirstAscent, + 'textHeightBehavior.applyHeightToFirstAscent', ), - 'minHeight': _encodeConstraintBound( - singleValueProp(value.$minHeight, 'minHeight'), + 'applyHeightToLastDescent': singleValueProp( + value.$applyHeightToLastDescent, + 'textHeightBehavior.applyHeightToLastDescent', ), - 'maxHeight': _encodeConstraintBound( - singleValueProp(value.$maxHeight, 'maxHeight'), + 'leadingDistribution': singleValueProp( + value.$leadingDistribution, + 'textHeightBehavior.leadingDistribution', ), }, ); } +CodecSchema> textDirectiveCodec() { + return strictEnumCodec({ + 'uppercase': const UppercaseStringDirective(), + 'lowercase': const LowercaseStringDirective(), + 'capitalize': const CapitalizeStringDirective(), + 'title_case': const TitleCaseStringDirective(), + 'sentence_case': const SentenceCaseStringDirective(), + }, debugName: 'TextDirective'); +} + +CodecSchema boxConstraintsCodec() { + return Ack.object({ + 'minWidth': nonNegativeDoubleCodec().optional(), + 'maxWidth': nonNegativeDoubleCodec().nullable().optional(), + 'minHeight': nonNegativeDoubleCodec().optional(), + 'maxHeight': nonNegativeDoubleCodec().nullable().optional(), + }) + .constrain(const _BoxConstraintsBoundsConstraint()) + .codec( + decode: (data) => BoxConstraintsMix( + minWidth: _readOptionalMinConstraintBound(data, 'minWidth'), + maxWidth: _readOptionalMaxConstraintBound(data, 'maxWidth'), + minHeight: _readOptionalMinConstraintBound(data, 'minHeight'), + maxHeight: _readOptionalMaxConstraintBound(data, 'maxHeight'), + ), + encode: _encodeBoxConstraintsMix, + ); +} + CodecSchema borderRadiusCodec() { return Ack.codec( input: Ack.anyOf([ @@ -371,21 +526,60 @@ Object _encodeEdgeInsetsMix(EdgeInsetsMix value) { ); } -double? _readOptionalConstraintBound(JsonMap data, String key) { +double? _readOptionalMinConstraintBound(JsonMap data, String key) { if (!data.containsKey(key)) return null; - final value = data[key]; - if (value == null) return double.infinity; + return data[key] as double; +} + +double? _readOptionalMaxConstraintBound(JsonMap data, String key) { + if (!data.containsKey(key)) return null; - return value as double; + return data[key] == null ? double.infinity : data[key] as double; } -double? _encodeConstraintBound(double? value) { - if (value == null) return null; +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'); + + _assertEncodableConstraintBounds(minWidth, maxWidth, 'width'); + _assertEncodableConstraintBounds(minHeight, maxHeight, 'height'); + + return { + if (minWidth != null) 'minWidth': _encodeMinConstraintBound(minWidth), + if (maxWidth != null) 'maxWidth': _encodeMaxConstraintBound(maxWidth), + if (minHeight != null) 'minHeight': _encodeMinConstraintBound(minHeight), + if (maxHeight != null) 'maxHeight': _encodeMaxConstraintBound(maxHeight), + }; +} + +double _encodeMinConstraintBound(double value) { + if (value == double.infinity) { + throw UnsupportedEncodeValueError( + value, + 'Minimum constraint bounds cannot be unbounded.', + ); + } + + return value; +} +double? _encodeMaxConstraintBound(double value) { return value == double.infinity ? null : value; } +void _assertEncodableConstraintBounds(double? min, double? max, String axis) { + if (min == null || max == null || max == double.infinity) return; + if (min <= max) return; + + throw UnsupportedEncodeValueError({ + 'min': min, + 'max': max, + }, 'Minimum $axis constraint must be less than or equal to maximum $axis.'); +} + BorderRadiusMix _decodeBorderRadiusMix(Object value) { if (value is Radius) return BorderRadiusMix.all(value); @@ -438,3 +632,33 @@ final class _PredicateConstraint extends Constraint @override String buildMessage(T value) => message; } + +final class _BoxConstraintsBoundsConstraint extends Constraint + with Validator { + const _BoxConstraintsBoundsConstraint() + : super( + constraintKey: 'mix_schema_box_constraints_bounds', + description: + 'Box constraint minimum bounds must not exceed maximum bounds.', + ); + + @override + bool isValid(JsonMap value) { + return _isAxisValid(value, 'minWidth', 'maxWidth') && + _isAxisValid(value, 'minHeight', 'maxHeight'); + } + + @override + String buildMessage(JsonMap value) { + return 'Minimum box constraint bounds must be less than or equal to ' + 'their maximum bounds.'; + } + + static bool _isAxisValid(JsonMap value, String minKey, String maxKey) { + final min = value[minKey] as double?; + final max = value[maxKey] as double?; + if (min == null || max == null) return true; + + return min <= max; + } +} diff --git a/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart b/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart index 6235afe28e..4d93c47f6f 100644 --- a/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/flex_box_styler_codec.dart @@ -18,6 +18,8 @@ AckSchema flexBoxStylerCodec({ 'margin': edgeInsetsCodec().optional(), 'constraints': boxConstraintsCodec().optional(), 'clipBehavior': enumNameCodec(Clip.values).optional(), + 'transform': matrix4Codec().optional(), + 'transformAlignment': alignmentCodec().optional(), 'decoration': boxDecorationCodec().optional(), 'direction': enumNameCodec(Axis.values).optional(), 'mainAxisAlignment': enumNameCodec(MainAxisAlignment.values).optional(), @@ -37,6 +39,8 @@ AckSchema flexBoxStylerCodec({ margin: data['margin'] as EdgeInsetsMix?, constraints: data['constraints'] as BoxConstraintsMix?, clipBehavior: data['clipBehavior'] as Clip?, + transform: data['transform'] as Matrix4?, + transformAlignment: data['transformAlignment'] as Alignment?, decoration: data['decoration'] as BoxDecorationMix?, direction: data['direction'] as Axis?, mainAxisAlignment: data['mainAxisAlignment'] as MainAxisAlignment?, diff --git a/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart b/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart index 9bb444d522..d6482c5824 100644 --- a/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/stack_box_styler_codec.dart @@ -18,6 +18,8 @@ AckSchema stackBoxStylerCodec({ 'margin': edgeInsetsCodec().optional(), 'constraints': boxConstraintsCodec().optional(), 'clipBehavior': enumNameCodec(Clip.values).optional(), + 'transform': matrix4Codec().optional(), + 'transformAlignment': alignmentCodec().optional(), 'decoration': boxDecorationCodec().optional(), 'stackAlignment': alignmentCodec().optional(), 'fit': enumNameCodec(StackFit.values).optional(), @@ -32,6 +34,8 @@ AckSchema stackBoxStylerCodec({ margin: data['margin'] as EdgeInsetsMix?, constraints: data['constraints'] as BoxConstraintsMix?, clipBehavior: data['clipBehavior'] as Clip?, + transform: data['transform'] as Matrix4?, + transformAlignment: data['transformAlignment'] as Alignment?, decoration: data['decoration'] as BoxDecorationMix?, stackAlignment: data['stackAlignment'] as Alignment?, fit: data['fit'] as StackFit?, diff --git a/packages/mix_schema/lib/src/schema/text_styler_codec.dart b/packages/mix_schema/lib/src/schema/text_styler_codec.dart index 3aee5b690a..28b02393f3 100644 --- a/packages/mix_schema/lib/src/schema/text_styler_codec.dart +++ b/packages/mix_schema/lib/src/schema/text_styler_codec.dart @@ -19,6 +19,8 @@ AckSchema textStylerCodec({ 'softWrap': Ack.boolean().optional(), 'selectionColor': colorCodec().optional(), 'semanticsLabel': Ack.string().optional(), + 'textHeightBehavior': textHeightBehaviorCodec().optional(), + 'textDirectives': Ack.list(textDirectiveCodec()).optional(), 'modifiers': modifierConfigCodec().optional(), 'animation': animationConfigCodec(registry: registry).optional(), }).codec( @@ -31,6 +33,8 @@ AckSchema textStylerCodec({ softWrap: data['softWrap'] as bool?, selectionColor: data['selectionColor'] as Color?, semanticsLabel: data['semanticsLabel'] as String?, + textHeightBehavior: data['textHeightBehavior'] as TextHeightBehaviorMix?, + textDirectives: data['textDirectives'] as List>?, modifier: data['modifiers'] as WidgetModifierConfig?, animation: data['animation'] as AnimationConfig?, ), @@ -42,8 +46,6 @@ JsonMap _encodeTextStyler(TextStyler value) { failIfPresent(value.$strutStyle, 'strutStyle'); failIfPresent(value.$textScaler, 'textScaler'); failIfPresent(value.$textWidthBasis, 'textWidthBasis'); - failIfPresent(value.$textHeightBehavior, 'textHeightBehavior'); - failIfPresent(value.$textDirectives, 'textDirectives'); failIfPresent(value.$locale, 'locale'); failIfPresent(value.$variants, 'variants'); @@ -56,6 +58,12 @@ JsonMap _encodeTextStyler(TextStyler value) { 'softWrap': singleValueProp(value.$softWrap, 'softWrap'), 'selectionColor': singleValueProp(value.$selectionColor, 'selectionColor'), 'semanticsLabel': singleValueProp(value.$semanticsLabel, 'semanticsLabel'), + 'textHeightBehavior': + singleMixProp( + value.$textHeightBehavior, + 'textHeightBehavior', + ), + 'textDirectives': value.$textDirectives, 'modifiers': value.$modifier, 'animation': value.$animation, }; @@ -76,6 +84,7 @@ CodecSchema textStyleMixCodec() { 'decorationColor': colorCodec().optional(), 'decorationStyle': textDecorationStyleCodec().optional(), 'decorationThickness': numberAsDoubleCodec().optional(), + 'shadows': Ack.list(shadowCodec()).optional(), }).codec( decode: (data) => TextStyleMix( color: data['color'] as Color?, @@ -91,6 +100,7 @@ CodecSchema textStyleMixCodec() { decorationColor: data['decorationColor'] as Color?, decorationStyle: data['decorationStyle'] as TextDecorationStyle?, decorationThickness: data['decorationThickness'] as double?, + shadows: data['shadows'] as List?, ), encode: _encodeTextStyle, ); @@ -105,7 +115,6 @@ JsonMap _encodeTextStyle(TextStyleMix value) { failIfPresent(value.$fontFamilyFallback, 'style.fontFamilyFallback'); failIfPresent(value.$fontFeatures, 'style.fontFeatures'); failIfPresent(value.$fontVariations, 'style.fontVariations'); - failIfPresent(value.$shadows, 'style.shadows'); return { 'color': singleValueProp(value.$color, 'style.color'), @@ -136,9 +145,19 @@ JsonMap _encodeTextStyle(TextStyleMix value) { value.$decorationThickness, 'style.decorationThickness', ), + 'shadows': _singleShadowList(value), }; } +List? _singleShadowList(TextStyleMix value) { + final shadows = singleMixProp>( + value.$shadows, + 'style.shadows', + ); + + return shadows?.items; +} + CodecSchema textOverflowCodec() { return strictEnumCodec({ 'clip': TextOverflow.clip, diff --git a/packages/mix_schema/test/common_codecs_test.dart b/packages/mix_schema/test/common_codecs_test.dart index 19df8108ae..50fcb2ae12 100644 --- a/packages/mix_schema/test/common_codecs_test.dart +++ b/packages/mix_schema/test/common_codecs_test.dart @@ -3,7 +3,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart' show + JsonMap, MixSchemaContractBuilder, + MixSchemaDecodeFailure, + MixSchemaDecodeSuccess, + MixSchemaEncodeFailure, + MixSchemaEncodeSuccess, MixSchemaError, MixSchemaErrorCode, MixSchemaValidationFailure, @@ -100,6 +105,104 @@ void main() { expect(schema.safeParse(0).isFail, isTrue); }); + test('R-10 box constraints preserve absent payload fields', () { + final payload = _encodeBox( + BoxStyler(constraints: BoxConstraintsMix.minWidth(12)), + ); + + expect(payload['constraints'], {'minWidth': 12.0}); + + final constraints = _decodeBoxConstraints({ + 'type': 'box', + 'constraints': {'minWidth': 12}, + }); + + expect(singleValueProp(constraints.$minWidth, 'minWidth'), 12); + expect(constraints.$maxWidth, isNull); + expect(constraints.$minHeight, isNull); + expect(constraints.$maxHeight, isNull); + }); + + test('R-10 box constraints support max-only and fixed dimensions', () { + expect( + _encodeBox( + BoxStyler(constraints: BoxConstraintsMix.maxWidth(48)), + )['constraints'], + {'maxWidth': 48.0}, + ); + expect( + _encodeBox( + BoxStyler(constraints: BoxConstraintsMix.width(32)), + )['constraints'], + {'minWidth': 32.0, 'maxWidth': 32.0}, + ); + + final constraints = _decodeBoxConstraints({ + 'type': 'box', + 'constraints': {'minWidth': 32, 'maxWidth': 32}, + }); + + expect(singleValueProp(constraints.$minWidth, 'minWidth'), 32); + expect(singleValueProp(constraints.$maxWidth, 'maxWidth'), 32); + }); + + test( + 'R-10 unbounded max constraints use explicit null only when present', + () { + final payload = _encodeBox( + BoxStyler(constraints: BoxConstraintsMix.maxWidth(double.infinity)), + ); + + expect(payload['constraints'], {'maxWidth': null}); + + final constraints = _decodeBoxConstraints({ + 'type': 'box', + 'constraints': {'maxWidth': null, 'maxHeight': null}, + }); + + expect( + singleValueProp(constraints.$maxWidth, 'maxWidth'), + double.infinity, + ); + expect( + singleValueProp(constraints.$maxHeight, 'maxHeight'), + double.infinity, + ); + expect(constraints.$minWidth, isNull); + expect(constraints.$minHeight, isNull); + }, + ); + + test('R-10 min constraints cannot decode to infinity', () { + final result = MixSchemaContractBuilder() + .builtIn() + .freeze() + .decode({ + 'type': 'box', + 'constraints': {'minWidth': null}, + }); + + expect(result, isA>()); + }); + + test('R-7 box constraints reject invalid min and max ordering', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + + for (final constraints in [ + {'minWidth': 20, 'maxWidth': 10}, + {'minHeight': 20, 'maxHeight': 10}, + ]) { + final errors = _validationErrors( + contract.validate({'type': 'box', 'constraints': constraints}), + ); + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.constraintViolation), + ); + } + }); + test('R-5 token and multi-source props fail encode explicitly', () { final prop = Prop.value(1.0).mergeProp(Prop.value(2.0)); @@ -116,3 +219,28 @@ List _validationErrors(MixSchemaValidationResult result) { MixSchemaValidationSuccess() => fail('expected validation failure'), }; } + +JsonMap _encodeBox(BoxStyler style) { + final result = MixSchemaContractBuilder().builtIn().freeze().encode(style); + + return switch (result) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; +} + +BoxConstraintsMix _decodeBoxConstraints(JsonMap payload) { + final result = MixSchemaContractBuilder() + .builtIn() + .freeze() + .decode(payload); + final box = switch (result) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + + return singleMixProp( + box.$constraints, + 'constraints', + )!; +} diff --git a/packages/mix_schema/test/schema_export_golden_test.dart b/packages/mix_schema/test/schema_export_golden_test.dart index 5ab962df78..c596915dd9 100644 --- a/packages/mix_schema/test/schema_export_golden_test.dart +++ b/packages/mix_schema/test/schema_export_golden_test.dart @@ -4,23 +4,176 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mix_schema/mix_schema.dart'; void main() { - test('R-2 schema export fingerprint contains box discriminator shape', () { - final schema = MixSchemaContractBuilder() - .builtIn() - .freeze() - .exportJsonSchema(); + test('R-2 schema export structurally describes every built-in branch', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final schema = contract.exportJsonSchema(); final encoded = jsonEncode(schema); + final branches = _branches(schema); + final branchesByType = { + for (final branch in branches) _branchType(branch): branch, + }; + expect(schema[r'$schema'], 'http://json-schema.org/draft-07/schema#'); expect(schema['x-mix-schema-contract'], 'mix_schema'); - expect(encoded, contains('"type"')); - expect(encoded, contains('"box"')); - expect(encoded, contains('"text"')); - expect(encoded, contains('"padding"')); - expect(encoded, contains('"decoration"')); - expect(encoded, contains('"clipBehavior"')); - expect(encoded, contains('"modifiers"')); - expect(encoded, contains('"animation"')); + expect(schema['x-mix-schema-version'], isA()); + expect(schema['x-mix-schema-limits'], isA()); + expect(branches, hasLength(contract.registeredTypes.length)); + expect(branchesByType.keys.toSet(), contract.registeredTypes.toSet()); + + for (final type in contract.registeredTypes) { + final branch = branchesByType[type]!; + final properties = _properties(branch); + final required = _required(branch); + final discriminator = _object(properties['type']); + + expect(required, contains('type'), reason: type); + expect(discriminator['type'], 'string', reason: type); + expect(discriminator['const'], type, reason: type); + expect( + properties.keys.toSet(), + containsAll({'type', ..._expectedBranchProperties[type]!}), + reason: type, + ); + expect(_expectedBranchProperties[type], isNot(contains('type'))); + } + expect(encoded, isNot(contains('x-ack-codec'))); expect(encoded.length, lessThan(150000)); }); } + +const _expectedBranchProperties = { + 'box': { + 'alignment', + 'padding', + 'margin', + 'constraints', + 'clipBehavior', + 'decoration', + 'variants', + 'modifiers', + 'animation', + }, + 'text': { + 'overflow', + 'textAlign', + 'maxLines', + 'style', + 'textDirection', + 'softWrap', + 'selectionColor', + 'semanticsLabel', + 'modifiers', + 'animation', + }, + 'flex': { + 'direction', + 'mainAxisAlignment', + 'crossAxisAlignment', + 'mainAxisSize', + 'verticalDirection', + 'textDirection', + 'textBaseline', + 'clipBehavior', + 'spacing', + 'modifiers', + 'animation', + }, + 'stack': { + 'alignment', + 'fit', + 'textDirection', + 'clipBehavior', + 'modifiers', + 'animation', + }, + 'icon': { + 'icon', + 'color', + 'size', + 'weight', + 'grade', + 'opticalSize', + 'textDirection', + 'applyTextScaling', + 'fill', + 'semanticsLabel', + 'opacity', + 'blendMode', + 'modifiers', + 'animation', + }, + 'image': { + 'image', + 'width', + 'height', + 'color', + 'repeat', + 'fit', + 'alignment', + 'filterQuality', + 'colorBlendMode', + 'semanticLabel', + 'excludeFromSemantics', + 'gaplessPlayback', + 'isAntiAlias', + 'matchTextDirection', + 'modifiers', + 'animation', + }, + 'flex_box': { + 'alignment', + 'padding', + 'margin', + 'constraints', + 'clipBehavior', + 'decoration', + 'direction', + 'mainAxisAlignment', + 'crossAxisAlignment', + 'mainAxisSize', + 'verticalDirection', + 'textDirection', + 'textBaseline', + 'flexClipBehavior', + 'spacing', + 'modifiers', + 'animation', + }, + 'stack_box': { + 'alignment', + 'padding', + 'margin', + 'constraints', + 'clipBehavior', + 'decoration', + 'stackAlignment', + 'fit', + 'textDirection', + 'stackClipBehavior', + 'modifiers', + 'animation', + }, +}; + +List _branches(JsonMap schema) { + return (schema['anyOf'] as List).map((branch) => _object(branch)).toList(); +} + +String _branchType(JsonMap branch) { + final typeProperty = _object(_properties(branch)['type']); + + return typeProperty['const']! as String; +} + +JsonMap _properties(JsonMap branch) { + return _object(branch['properties']); +} + +List _required(JsonMap branch) { + return (branch['required'] as List).cast(); +} + +JsonMap _object(Object? value) { + return Map.from(value! as Map); +} diff --git a/packages/mix_schema/test/text_styler_codec_test.dart b/packages/mix_schema/test/text_styler_codec_test.dart index 466e20557c..275b82d64f 100644 --- a/packages/mix_schema/test/text_styler_codec_test.dart +++ b/packages/mix_schema/test/text_styler_codec_test.dart @@ -66,29 +66,17 @@ void main() { }); }); - test('text styler unsupported runtime values fail encode explicitly', () { + test('text styler encodes supported text directives', () { final result = contract().encode(TextStyler.uppercase()); - final errors = switch (result) { - MixSchemaEncodeFailure(:final errors) => errors, - MixSchemaEncodeSuccess() => fail('expected failure'), + final payload = switch (result) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), }; - expect( - errors, - contains( - isA() - .having( - (error) => error.code, - 'code', - MixSchemaErrorCode.unsupportedEncodeValue, - ) - .having( - (error) => error.message, - 'message', - contains('textDirectives'), - ), - ), - ); + expect(payload, { + 'type': 'text', + 'textDirectives': ['uppercase'], + }); }); } diff --git a/packages/mix_tailwinds/lib/src/tw_parser.dart b/packages/mix_tailwinds/lib/src/tw_parser.dart index 605be8cfeb..d18c8b0a8d 100644 --- a/packages/mix_tailwinds/lib/src/tw_parser.dart +++ b/packages/mix_tailwinds/lib/src/tw_parser.dart @@ -2,9 +2,11 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; import 'tw_config.dart'; import 'tw_semantic.dart'; +import 'tw_schema_payload.dart'; import 'tw_utils.dart'; typedef TokenWarningCallback = void Function(String token); @@ -1378,6 +1380,9 @@ TextStyler _applyPropertyToText( TwProperty.fontWeight => styler.fontWeight( (value as TwEnumValue).value, ), + TwProperty.textAlign => styler.textAlign( + (value as TwEnumValue).value, + ), TwProperty.textShadow => _applyTextShadow(styler, value), TwProperty.lineHeight => styler.height((value as TwLengthValue).value), TwProperty.letterSpacing => styler.letterSpacing( @@ -1413,7 +1418,16 @@ class TwParser { } TwParser._({required this.config, this.onUnsupported}) - : _resolver = TwResolver(config, onUnknownVariant: onUnsupported); + : _resolver = TwResolver(config, onUnknownVariant: onUnsupported) { + _schemaPayload = TwSchemaPayloadBuilder( + config: config, + listTokens: listTokens, + resolveToken: _resolver.resolveToken, + isBoxLikeDirectOnlyPayloadToken: _isBoxLikeDirectOnlyPayloadToken, + isAnimationToken: _isAnimationToken, + resolveTextShadowMixes: _resolveTextShadowMixes, + ); + } /// Pre-compiled regex for splitting class names by whitespace. static final _whitespaceRegex = RegExp(r'\s+'); @@ -1421,6 +1435,7 @@ class TwParser { final TwConfig config; final TokenWarningCallback? onUnsupported; final TwResolver _resolver; + late final TwSchemaPayloadBuilder _schemaPayload; final _TransformAccumTracker _transformTracker = _TransformAccumTracker(); List listTokens(String classNames) { @@ -1450,6 +1465,20 @@ class TwParser { } FlexBoxStyler parseFlex(String classNames) { + final payload = _schemaPayload.tryBuildFlexPayload(classNames); + if (payload == null) return _parseFlexDirect(classNames); + + return _schemaPayload.decodePayload(payload); + } + + JsonMap parseFlexPayload(String classNames) { + final payload = _schemaPayload.tryBuildFlexPayload(classNames); + if (payload != null) return payload; + + return _schemaPayload.encodeFlexPayload(_parseFlexDirect(classNames)); + } + + FlexBoxStyler _parseFlexDirect(String classNames) { final tokens = listTokens(classNames); _transformTracker.clear(); @@ -1541,6 +1570,20 @@ class TwParser { } BoxStyler parseBox(String classNames) { + final payload = _schemaPayload.tryBuildBoxPayload(classNames); + if (payload == null) return _parseBoxDirect(classNames); + + return _schemaPayload.decodePayload(payload); + } + + JsonMap parseBoxPayload(String classNames) { + final payload = _schemaPayload.tryBuildBoxPayload(classNames); + if (payload != null) return payload; + + return _schemaPayload.encodeBoxPayload(_parseBoxDirect(classNames)); + } + + BoxStyler _parseBoxDirect(String classNames) { final tokens = listTokens(classNames); _transformTracker.clear(); @@ -1620,6 +1663,20 @@ class TwParser { } TextStyler parseText(String classNames) { + final payload = _schemaPayload.tryBuildTextPayload(classNames); + if (payload == null) return _parseTextDirect(classNames); + + return _schemaPayload.decodePayload(payload); + } + + JsonMap parseTextPayload(String classNames) { + final payload = _schemaPayload.tryBuildTextPayload(classNames); + if (payload != null) return payload; + + return _schemaPayload.encodeTextPayload(_parseTextDirect(classNames)); + } + + TextStyler _parseTextDirect(String classNames) { var styler = TextStyler().height(config.textDefaults.lineHeight); for (final token in listTokens(classNames)) { styler = _applyTextToken(styler, token); @@ -1627,6 +1684,10 @@ class TwParser { return styler; } + bool _isBoxLikeDirectOnlyPayloadToken(String token) { + return _isGradientToken(token) || _isBorderToken(token, config); + } + CurveAnimationConfig? parseAnimationFromTokens(List tokens) { var hasTransition = false; var hasTransitionNone = false; diff --git a/packages/mix_tailwinds/lib/src/tw_schema_payload.dart b/packages/mix_tailwinds/lib/src/tw_schema_payload.dart new file mode 100644 index 0000000000..9611227926 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/tw_schema_payload.dart @@ -0,0 +1,643 @@ +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/encode.dart'; +import 'package:mix_schema/mix_schema.dart'; + +import 'tw_config.dart'; +import 'tw_semantic.dart'; +import 'tw_utils.dart'; + +typedef TwPayloadTokenLister = List Function(String classNames); +typedef TwPayloadTokenResolver = List? Function(String token); +typedef TwPayloadTokenPredicate = bool Function(String token); +typedef TwPayloadTextShadowResolver = List? Function(TwValue value); + +typedef _PayloadPropertyApplier = + bool Function( + JsonMap payload, + TwProperty property, + TwValue value, + String token, + ); +typedef _PayloadFallbackApplier = bool Function(JsonMap payload, String token); + +final class SchemaPayloadUnsupported implements Exception { + const SchemaPayloadUnsupported(this.errors); + + final List errors; + + @override + String toString() => 'Unsupported Tailwinds schema payload: $errors'; +} + +final class TwSchemaPayloadBuilder { + TwSchemaPayloadBuilder({ + required TwConfig config, + required TwPayloadTokenLister listTokens, + required TwPayloadTokenResolver resolveToken, + required TwPayloadTokenPredicate isBoxLikeDirectOnlyPayloadToken, + required TwPayloadTokenPredicate isAnimationToken, + required TwPayloadTextShadowResolver resolveTextShadowMixes, + }) : _config = config, + _listTokens = listTokens, + _resolveToken = resolveToken, + _isBoxLikeDirectOnlyPayloadToken = isBoxLikeDirectOnlyPayloadToken, + _isAnimationToken = isAnimationToken, + _resolveTextShadowMixes = resolveTextShadowMixes; + + final TwConfig _config; + final TwPayloadTokenLister _listTokens; + final TwPayloadTokenResolver _resolveToken; + final TwPayloadTokenPredicate _isBoxLikeDirectOnlyPayloadToken; + final TwPayloadTokenPredicate _isAnimationToken; + final TwPayloadTextShadowResolver _resolveTextShadowMixes; + final MixSchemaContract _schemaContract = MixSchemaContractBuilder() + .builtIn() + .freeze(); + + JsonMap? tryBuildFlexPayload(String classNames) { + return _tryBuildPayload( + classNames: classNames, + payload: {'type': SchemaStyler.flexBox.wireValue}, + blocksPayload: _isBoxLikeDirectOnlyPayloadToken, + skipsToken: _isFlexWidgetLayerGapToken, + applyFallback: _applySharedPayloadFallback, + applyProperty: _applyFlexPayloadProperty, + ); + } + + JsonMap? tryBuildBoxPayload(String classNames) { + return _tryBuildPayload( + classNames: classNames, + payload: {'type': SchemaStyler.box.wireValue}, + blocksPayload: _isBoxLikeDirectOnlyPayloadToken, + applyFallback: _applySharedPayloadFallback, + applyProperty: _applyBoxPayloadProperty, + ); + } + + JsonMap? tryBuildTextPayload(String classNames) { + return _tryBuildPayload( + classNames: classNames, + payload: { + 'type': SchemaStyler.text.wireValue, + 'style': {'height': _config.textDefaults.lineHeight}, + }, + applyFallback: _applyTextPayloadFallback, + applyProperty: _applyTextPayloadProperty, + ); + } + + JsonMap encodeFlexPayload(FlexBoxStyler styler) { + return _encodePayload(styler, expectedType: SchemaStyler.flexBox); + } + + JsonMap encodeBoxPayload(BoxStyler styler) { + return _encodePayload(styler, expectedType: SchemaStyler.box); + } + + JsonMap encodeTextPayload(TextStyler styler) { + return _encodePayload(styler, expectedType: SchemaStyler.text); + } + + T decodePayload(JsonMap payload) { + final result = _schemaContract.decode(payload); + + return switch (result) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => throw StateError( + 'Tailwinds emitted an invalid schema payload: $errors', + ), + }; + } + + JsonMap? _tryBuildPayload({ + required String classNames, + required JsonMap payload, + required _PayloadFallbackApplier applyFallback, + required _PayloadPropertyApplier applyProperty, + TwPayloadTokenPredicate? blocksPayload, + TwPayloadTokenPredicate? skipsToken, + }) { + for (final token in _listTokens(classNames)) { + if (_hasSchemaPrefix(token)) return null; + if (blocksPayload?.call(token) ?? false) return null; + if (skipsToken?.call(token) ?? false) continue; + + final parsed = _resolveToken(token); + if (parsed == null || parsed.isEmpty) { + if (!applyFallback(payload, token)) return null; + continue; + } + + for (final p in parsed) { + if (!applyProperty(payload, p.property, p.value, token)) return null; + } + } + + return payload; + } + + bool _hasSchemaPrefix(String token) { + return findFirstColonOutsideBrackets(token) > 0; + } + + bool _isFlexWidgetLayerGapToken(String token) { + return token.startsWith('gap-x-') || token.startsWith('gap-y-'); + } + + bool _applyFlexPayloadProperty( + JsonMap payload, + TwProperty property, + TwValue value, + String token, + ) { + if (_applyBoxPayloadProperty(payload, property, value, token)) return true; + + switch (property) { + case TwProperty.display: + if (value is TwEnumValue && value.value == 'flex') { + payload['direction'] = Axis.horizontal.name; + } + case TwProperty.flexDirection: + if (value is! TwEnumValue) return false; + payload['direction'] = value.value.name; + case TwProperty.alignItems: + if (value is! TwEnumValue) return false; + payload['crossAxisAlignment'] = value.value.name; + if (value.value == CrossAxisAlignment.baseline) { + payload['textBaseline'] = TextBaseline.alphabetic.name; + } + case TwProperty.justifyContent: + if (value is! TwEnumValue) return false; + payload['mainAxisAlignment'] = value.value.name; + case TwProperty.gap: + if (value is! TwLengthValue) return false; + payload['spacing'] = value.value; + default: + return false; + } + + return true; + } + + bool _applyBoxPayloadProperty( + JsonMap payload, + TwProperty property, + TwValue value, + String token, + ) { + switch (property) { + case TwProperty.padding: + return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'all'); + case TwProperty.paddingX: + return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'x'); + case TwProperty.paddingY: + return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'y'); + case TwProperty.paddingTop: + return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'top'); + case TwProperty.paddingRight: + return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'right'); + case TwProperty.paddingBottom: + return _setEdgeInsetsPayload( + payload, + 'padding', + value, + sides: 'bottom', + ); + case TwProperty.paddingLeft: + return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'left'); + case TwProperty.margin: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'all'); + case TwProperty.marginX: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'x'); + case TwProperty.marginY: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'y'); + case TwProperty.marginTop: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'top'); + case TwProperty.marginRight: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'right'); + case TwProperty.marginBottom: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'bottom'); + case TwProperty.marginLeft: + return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'left'); + case TwProperty.width: + return _setConstraintPayload(payload, value, width: true, fixed: true); + case TwProperty.height: + return _setConstraintPayload(payload, value, height: true, fixed: true); + case TwProperty.minWidth: + return _setConstraintPayload(payload, value, minWidth: true); + case TwProperty.minHeight: + return _setConstraintPayload(payload, value, minHeight: true); + case TwProperty.maxWidth: + return _setConstraintPayload(payload, value, maxWidth: true); + case TwProperty.maxHeight: + return _setConstraintPayload(payload, value, maxHeight: true); + case TwProperty.backgroundColor: + if (value is! TwColorValue) return false; + final color = _payloadColor(value.color); + if (color == null) return false; + _decoration(payload)['color'] = color; + case TwProperty.borderRadius: + return _setRadiusPayload(payload, value, corners: 'all'); + case TwProperty.borderRadiusTop: + return _setRadiusPayload(payload, value, corners: 'top'); + case TwProperty.borderRadiusBottom: + return _setRadiusPayload(payload, value, corners: 'bottom'); + case TwProperty.borderRadiusLeft: + return _setRadiusPayload(payload, value, corners: 'left'); + case TwProperty.borderRadiusRight: + return _setRadiusPayload(payload, value, corners: 'right'); + case TwProperty.borderRadiusTopLeft: + return _setRadiusPayload(payload, value, corners: 'topLeft'); + case TwProperty.borderRadiusTopRight: + return _setRadiusPayload(payload, value, corners: 'topRight'); + case TwProperty.borderRadiusBottomLeft: + return _setRadiusPayload(payload, value, corners: 'bottomLeft'); + case TwProperty.borderRadiusBottomRight: + return _setRadiusPayload(payload, value, corners: 'bottomRight'); + case TwProperty.blur: + if (value is! TwLengthValue) return false; + _modifiers(payload).add({'type': 'blur', 'sigma': value.value}); + case TwProperty.boxShadow: + final shadows = _boxShadowPayload(value); + if (shadows == null) return false; + _decoration(payload)['boxShadow'] = shadows; + case TwProperty.clipBehavior: + if (value is! TwEnumValue) return false; + payload['clipBehavior'] = value.value.name; + case TwProperty.textColor: + if (value is! TwColorValue) return false; + final color = _payloadColor(value.color); + if (color == null) return false; + _defaultTextStyle(payload)['color'] = color; + case TwProperty.fontSize: + if (value is! TwLengthValue) return false; + _defaultTextStyle(payload)['fontSize'] = value.value; + case TwProperty.fontWeight: + if (value is! TwEnumValue) return false; + _defaultTextStyle(payload)['fontWeight'] = _fontWeightWire(value.value); + case TwProperty.textShadow: + final shadows = _textShadowPayload(value); + if (shadows == null) return false; + _defaultTextStyle(payload)['shadows'] = shadows; + default: + return false; + } + + return true; + } + + bool _applyTextPayloadProperty( + JsonMap payload, + TwProperty property, + TwValue value, + String token, + ) { + switch (property) { + case TwProperty.textColor: + if (value is! TwColorValue) return false; + final color = _payloadColor(value.color); + if (color == null) return false; + _textStyle(payload)['color'] = color; + case TwProperty.fontSize: + if (value is! TwLengthValue) return false; + _textStyle(payload)['fontSize'] = value.value; + final key = _findTextSizeKey(token); + final lineHeight = key == null ? null : tailwindLineHeights[key]; + if (lineHeight != null) _textStyle(payload)['height'] = lineHeight; + case TwProperty.fontWeight: + if (value is! TwEnumValue) return false; + _textStyle(payload)['fontWeight'] = _fontWeightWire(value.value); + case TwProperty.textAlign: + if (value is! TwEnumValue) return false; + payload['textAlign'] = value.value.name; + case TwProperty.lineHeight: + if (value is! TwLengthValue) return false; + _textStyle(payload)['height'] = value.value; + case TwProperty.letterSpacing: + if (value is! TwLengthValue) return false; + _textStyle(payload)['letterSpacing'] = value.value; + case TwProperty.textTransform: + if (value is! TwEnumValue) return false; + _textDirectives(payload).add(value.value); + case TwProperty.textOverflow: + payload['overflow'] = TextOverflow.ellipsis.name; + payload['maxLines'] = 1; + payload['softWrap'] = false; + case TwProperty.textShadow: + final shadows = _textShadowPayload(value); + if (shadows == null) return false; + _textStyle(payload)['shadows'] = shadows; + default: + return false; + } + + return true; + } + + bool _applySharedPayloadFallback(JsonMap payload, String token) { + if (token.startsWith('w-')) { + final value = _spacePayloadValue(token.substring(2)); + if (value == null) return _isWidgetLayerSizingToken(token.substring(2)); + return _setConstraintPayload(payload, value, width: true, fixed: true); + } + if (token.startsWith('h-')) { + final value = _spacePayloadValue(token.substring(2)); + if (value == null) return _isWidgetLayerSizingToken(token.substring(2)); + return _setConstraintPayload(payload, value, height: true, fixed: true); + } + if (token.startsWith('flex-') || + token.startsWith('basis-') || + token.startsWith('self-') || + token.startsWith('shrink') || + _isAnimationToken(token)) { + return true; + } + if (token.startsWith('text-')) { + final key = token.substring(5); + final color = _config.colorOf(key); + if (color != null) { + final wireColor = _payloadColor(color); + if (wireColor == null) return false; + _defaultTextStyle(payload)['color'] = wireColor; + return true; + } + final size = _config.fontSizeOf(key, fallback: -1); + if (size > 0) { + _defaultTextStyle(payload)['fontSize'] = size; + final lineHeight = tailwindLineHeights[key]; + if (lineHeight != null) { + _defaultTextStyle(payload)['height'] = lineHeight; + } + return true; + } + } + + return false; + } + + bool _applyTextPayloadFallback(JsonMap payload, String token) { + if (token == 'leading-even' || token == 'leading-trim') { + payload['textHeightBehavior'] = { + 'leadingDistribution': TextLeadingDistribution.even.name, + if (token == 'leading-trim') ...{ + 'applyHeightToFirstAscent': false, + 'applyHeightToLastDescent': false, + }, + }; + return true; + } + if (_isAnimationToken(token)) return true; + if (token.startsWith('text-')) { + final key = token.substring(5); + final size = _config.fontSizeOf(key, fallback: -1); + if (size > 0) { + _textStyle(payload)['fontSize'] = size; + final lineHeight = tailwindLineHeights[key]; + if (lineHeight != null) _textStyle(payload)['height'] = lineHeight; + return true; + } + final color = _config.colorOf(key); + if (color != null) { + final wireColor = _payloadColor(color); + if (wireColor == null) return false; + _textStyle(payload)['color'] = wireColor; + return true; + } + } + + return false; + } + + bool _setEdgeInsetsPayload( + JsonMap payload, + String field, + TwValue value, { + required String sides, + }) { + if (value is! TwLengthValue) return false; + final data = _objectField(payload, field); + switch (sides) { + case 'all': + data + ..['left'] = value.value + ..['top'] = value.value + ..['right'] = value.value + ..['bottom'] = value.value; + case 'x': + data + ..['left'] = value.value + ..['right'] = value.value; + case 'y': + data + ..['top'] = value.value + ..['bottom'] = value.value; + default: + data[sides] = value.value; + } + + return true; + } + + bool _setConstraintPayload( + JsonMap payload, + Object value, { + bool width = false, + bool height = false, + bool minWidth = false, + bool maxWidth = false, + bool minHeight = false, + bool maxHeight = false, + bool fixed = false, + }) { + final length = value is TwLengthValue ? value : null; + if (length == null || length.unit != TwUnit.px) return false; + final constraints = _objectField(payload, 'constraints'); + if ((width || minWidth) && (fixed || minWidth)) { + constraints['minWidth'] = length.value; + } + if ((width || maxWidth) && (fixed || maxWidth)) { + constraints['maxWidth'] = length.value; + } + if ((height || minHeight) && (fixed || minHeight)) { + constraints['minHeight'] = length.value; + } + if ((height || maxHeight) && (fixed || maxHeight)) { + constraints['maxHeight'] = length.value; + } + + return true; + } + + bool _setRadiusPayload( + JsonMap payload, + TwValue value, { + required String corners, + }) { + if (value is! TwLengthValue) return false; + final radius = value.value; + final borderRadius = _objectField(_decoration(payload), 'borderRadius'); + switch (corners) { + case 'all': + borderRadius + ..['topLeft'] = radius + ..['topRight'] = radius + ..['bottomLeft'] = radius + ..['bottomRight'] = radius; + case 'top': + borderRadius + ..['topLeft'] = radius + ..['topRight'] = radius; + case 'bottom': + borderRadius + ..['bottomLeft'] = radius + ..['bottomRight'] = radius; + case 'left': + borderRadius + ..['topLeft'] = radius + ..['bottomLeft'] = radius; + case 'right': + borderRadius + ..['topRight'] = radius + ..['bottomRight'] = radius; + default: + borderRadius[corners] = radius; + } + + return true; + } + + JsonMap _decoration(JsonMap payload) => _objectField(payload, 'decoration'); + + JsonMap _textStyle(JsonMap payload) => _objectField(payload, 'style'); + + JsonMap _defaultTextStyle(JsonMap payload) { + final modifiers = _modifiers(payload); + for (final modifier in modifiers) { + if (modifier['type'] == 'default_text_style') { + return _objectField(modifier, 'style'); + } + } + final modifier = { + 'type': 'default_text_style', + 'style': {}, + }; + modifiers.add(modifier); + + return modifier['style']! as JsonMap; + } + + List _modifiers(JsonMap payload) { + return (payload['modifiers'] ??= []) as List; + } + + List _textDirectives(JsonMap payload) { + return (payload['textDirectives'] ??= []) as List; + } + + JsonMap _objectField(JsonMap payload, String field) { + return (payload[field] ??= {}) as JsonMap; + } + + List? _boxShadowPayload(TwValue value) { + if (value is! TwEnumValue) return null; + + final shadowValue = value.value; + if (shadowValue is! List?) return null; + final shadows = shadowValue ?? const []; + final payload = _encodePayload( + BoxStyler().boxShadows(shadows), + expectedType: SchemaStyler.box, + ); + final decoration = payload['decoration'] as JsonMap?; + final encoded = decoration?['boxShadow'] as List?; + + return encoded?.cast() ?? const []; + } + + List? _textShadowPayload(TwValue value) { + final shadows = _resolveTextShadowMixes(value); + if (shadows == null) return null; + final payload = _encodePayload( + TextStyler().shadows(shadows), + expectedType: SchemaStyler.text, + ); + final style = payload['style'] as JsonMap?; + final encoded = style?['shadows'] as List?; + + return encoded?.cast() ?? const []; + } + + TwLengthValue? _spacePayloadValue(String key) { + final size = _config.spaceOf(key, fallback: double.nan); + if (size.isNaN) return null; + + return TwLengthValue(size); + } + + bool _isWidgetLayerSizingToken(String key) { + return parseFractionToken(key) != null || _isFullOrScreenKey(key); + } + + String? _payloadColor(Color color) { + if (color.runtimeType != Color) return null; + + return payloadColor(color); + } + + String _fontWeightWire(FontWeight value) { + return switch (value) { + FontWeight.w100 => 'w100', + FontWeight.w200 => 'w200', + FontWeight.w300 => 'w300', + FontWeight.w400 => 'w400', + FontWeight.w500 => 'w500', + FontWeight.w600 => 'w600', + FontWeight.w700 => 'w700', + FontWeight.w800 => 'w800', + FontWeight.w900 => 'w900', + _ => throw SchemaPayloadUnsupported([ + MixSchemaError( + code: MixSchemaErrorCode.unsupportedEncodeValue, + path: '/style/fontWeight', + message: 'Unsupported FontWeight value: $value.', + value: value, + ), + ]), + }; + } + + JsonMap _encodePayload(Object styler, {required SchemaStyler expectedType}) { + final result = _schemaContract.encode(styler); + + final payload = switch (result) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => throw SchemaPayloadUnsupported( + errors, + ), + }; + if (payload['type'] != expectedType.wireValue) { + throw SchemaPayloadUnsupported([ + MixSchemaError( + code: MixSchemaErrorCode.typeMismatch, + path: '/type', + message: + 'Expected ${expectedType.wireValue} payload, got ${payload['type']}.', + value: payload['type'], + ), + ]); + } + + return payload; + } + + String? _findTextSizeKey(String token) { + if (token.startsWith('text-')) { + return token.substring(5); + } + return null; + } + + bool _isFullOrScreenKey(String key) => key == 'full' || key == 'screen'; +} diff --git a/packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart b/packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart new file mode 100644 index 0000000000..2ff587d587 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart @@ -0,0 +1,305 @@ +import 'tw_semantic.dart'; + +enum TwSchemaPayloadDecision { schema, widgetLayer, directOnly, unsupported } + +final class TwSchemaPayloadPolicy { + const TwSchemaPayloadPolicy(this.decision, this.reason); + + final TwSchemaPayloadDecision decision; + final String reason; +} + +const Map twSchemaPayloadPolicy = { + TwProperty.padding: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex padding payload.', + ), + TwProperty.paddingX: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex horizontal padding payload.', + ), + TwProperty.paddingY: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex vertical padding payload.', + ), + TwProperty.paddingTop: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex top padding payload.', + ), + TwProperty.paddingRight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex right padding payload.', + ), + TwProperty.paddingBottom: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex bottom padding payload.', + ), + TwProperty.paddingLeft: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex left padding payload.', + ), + TwProperty.margin: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex margin payload.', + ), + TwProperty.marginX: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex horizontal margin payload.', + ), + TwProperty.marginY: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex vertical margin payload.', + ), + TwProperty.marginTop: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex top margin payload.', + ), + TwProperty.marginRight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex right margin payload.', + ), + TwProperty.marginBottom: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex bottom margin payload.', + ), + TwProperty.marginLeft: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex left margin payload.', + ), + TwProperty.gap: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Flex main-axis spacing payload.', + ), + TwProperty.gapX: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Cross-axis gap depends on resolved flex axis.', + ), + TwProperty.gapY: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Cross-axis gap depends on resolved flex axis.', + ), + TwProperty.width: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Pixel constraints are schema-backed; full, screen, and percent stay widget-layer.', + ), + TwProperty.height: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Pixel constraints are schema-backed; full, screen, and percent stay widget-layer.', + ), + TwProperty.minWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Pixel constraints are schema-backed; screen stays widget-layer.', + ), + TwProperty.minHeight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Pixel constraints are schema-backed; screen stays widget-layer.', + ), + TwProperty.maxWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex max width constraint payload.', + ), + TwProperty.maxHeight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Box and flex max height constraint payload.', + ), + TwProperty.display: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Flex direction is schema-backed; Div layout selection stays widget-layer.', + ), + TwProperty.flexDirection: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Flex direction payload with widget-layer responsive axis handling.', + ), + TwProperty.flexWrap: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'No current Mix flex-wrap schema target.', + ), + TwProperty.alignItems: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Flex cross-axis alignment payload.', + ), + TwProperty.justifyContent: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Flex main-axis alignment payload.', + ), + TwProperty.alignSelf: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Flex parent-data and alignment decorator.', + ), + TwProperty.flexGrow: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Flex parent-data decorator.', + ), + TwProperty.flexShrink: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Flex parent-data decorator.', + ), + TwProperty.flexBasis: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Axis-dependent SizedBox decorator.', + ), + TwProperty.backgroundColor: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Schema-backed when the color is wire-stable.', + ), + TwProperty.backgroundGradient: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Tailwinds gradient accumulators and custom transforms.', + ), + TwProperty.borderWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderTopWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderRightWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderBottomWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderLeftWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderXWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderYWidth: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderColor: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Border accumulator preserves Tailwinds side and color defaults.', + ), + TwProperty.borderRadius: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration borderRadius payload.', + ), + TwProperty.borderRadiusTop: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration top borderRadius payload.', + ), + TwProperty.borderRadiusBottom: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration bottom borderRadius payload.', + ), + TwProperty.borderRadiusLeft: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration left borderRadius payload.', + ), + TwProperty.borderRadiusRight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration right borderRadius payload.', + ), + TwProperty.borderRadiusTopLeft: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration top-left borderRadius payload.', + ), + TwProperty.borderRadiusTopRight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration top-right borderRadius payload.', + ), + TwProperty.borderRadiusBottomLeft: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration bottom-left borderRadius payload.', + ), + TwProperty.borderRadiusBottomRight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration bottom-right borderRadius payload.', + ), + TwProperty.fontSize: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Text and default text style fontSize payload.', + ), + TwProperty.fontWeight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Text and default text style fontWeight payload.', + ), + TwProperty.textColor: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Schema-backed when the color is wire-stable.', + ), + TwProperty.textAlign: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'TextAlign payload.', + ), + TwProperty.lineHeight: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Text and default text style height payload.', + ), + TwProperty.letterSpacing: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Text letterSpacing payload.', + ), + TwProperty.textTransform: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Text directives payload.', + ), + TwProperty.textOverflow: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Overflow, maxLines, and softWrap payload.', + ), + TwProperty.textDecoration: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'No Tailwinds plugin mapping yet.', + ), + TwProperty.textShadow: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Text and default text style shadows payload.', + ), + TwProperty.boxShadow: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Decoration boxShadow payload.', + ), + TwProperty.opacity: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.unsupported, + 'Parser does not implement opacity yet.', + ), + TwProperty.blur: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'Blur modifier payload.', + ), + TwProperty.scale: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Transform accumulator composes Tailwinds transform tokens.', + ), + TwProperty.rotate: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Transform accumulator composes Tailwinds transform tokens.', + ), + TwProperty.translateX: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Transform accumulator composes Tailwinds transform tokens.', + ), + TwProperty.translateY: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.directOnly, + 'Transform accumulator composes Tailwinds transform tokens.', + ), + TwProperty.transition: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Animation is applied after parser output.', + ), + TwProperty.transitionDuration: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Animation is applied after parser output.', + ), + TwProperty.transitionCurve: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Animation is applied after parser output.', + ), + TwProperty.transitionDelay: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.widgetLayer, + 'Animation is applied after parser output.', + ), + TwProperty.clipBehavior: TwSchemaPayloadPolicy( + TwSchemaPayloadDecision.schema, + 'ClipBehavior payload.', + ), +}; diff --git a/packages/mix_tailwinds/pubspec.yaml b/packages/mix_tailwinds/pubspec.yaml index d33c83dff1..7638481f07 100644 --- a/packages/mix_tailwinds/pubspec.yaml +++ b/packages/mix_tailwinds/pubspec.yaml @@ -14,10 +14,10 @@ dependencies: sdk: flutter mix: path: ../mix + mix_schema: + path: ../mix_schema dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - mix_schema: - path: ../mix_schema diff --git a/packages/mix_tailwinds/test/schema_payload_contract_test.dart b/packages/mix_tailwinds/test/schema_payload_contract_test.dart index b0a287bc47..8eb75a2f69 100644 --- a/packages/mix_tailwinds/test/schema_payload_contract_test.dart +++ b/packages/mix_tailwinds/test/schema_payload_contract_test.dart @@ -1,16 +1,192 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart'; import 'package:mix_tailwinds/mix_tailwinds.dart'; +import 'package:mix_tailwinds/src/tw_schema_payload_policy.dart' + as payload_policy; void main() { - test('schema-representable box parser output encodes through mix_schema', () { + test('box parser emits schema payloads that decode through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); - final style = TwParser().parseBox('bg-blue-500 p-4'); + final payload = TwParser().parseBoxPayload('bg-blue-500 p-4 rounded-md'); - _expectSchemaEncodes(contract, style); + expect(payload['type'], 'box'); + expect(contract.validate(payload), isA()); + expect(contract.decode(payload), isA()); }); + test('box parser emits shadow payloads through mix_schema', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final payload = TwParser().parseBoxPayload('shadow-md'); + final decoration = payload['decoration'] as JsonMap; + final shadows = decoration['boxShadow'] as List; + + expect(shadows, hasLength(2)); + expect(contract.validate(payload), isA()); + expect(contract.decode(payload), isA()); + }); + + testWidgets('parseBox shadow-md resolves Tailwind shadows', (tester) async { + final style = TwParser().parseBox('shadow-md'); + BoxDecoration? decoration; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + decoration = + style.resolve(context).spec.decoration as BoxDecoration?; + return const SizedBox(); + }, + ), + ), + ); + + expect(decoration?.boxShadow, const [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 6, + spreadRadius: -1, + color: Color(0x1A000000), + ), + BoxShadow( + offset: Offset(0, 2), + blurRadius: 4, + spreadRadius: -2, + color: Color(0x1A000000), + ), + ]); + }); + + test('flex parser emits schema payloads that decode through mix_schema', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final payload = TwParser().parseFlexPayload('flex flex-col gap-4 p-4'); + + expect(payload['type'], 'flex_box'); + expect(contract.validate(payload), isA()); + expect( + contract.decode(payload), + isA(), + ); + }); + + test('flex parser emits default text shadow payloads through mix_schema', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final payload = TwParser().parseFlexPayload('flex text-shadow-md'); + final modifiers = payload['modifiers'] as List; + final defaultTextStyle = modifiers.single as JsonMap; + final style = defaultTextStyle['style'] as JsonMap; + + expect(defaultTextStyle['type'], 'default_text_style'); + expect(style['shadows'], isA()); + expect(contract.validate(payload), isA()); + expect( + contract.decode(payload), + isA(), + ); + }); + + test('text parser emits schema payloads that decode through mix_schema', () { + final contract = MixSchemaContractBuilder().builtIn().freeze(); + final payload = TwParser().parseTextPayload( + 'text-lg font-bold text-center leading-tight tracking-wide uppercase text-shadow-sm', + ); + + expect(payload['type'], 'text'); + expect(payload['textAlign'], 'center'); + expect((payload['style'] as JsonMap)['shadows'], isA()); + expect(contract.validate(payload), isA()); + expect(contract.decode(payload), isA()); + }); + + testWidgets('parseText text-center resolves TextAlign.center', ( + tester, + ) async { + final style = TwParser().parseText('text-center'); + TextAlign? textAlign; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + textAlign = style.resolve(context).spec.textAlign; + return const SizedBox(); + }, + ), + ), + ); + + expect(textAlign, TextAlign.center); + }); + + testWidgets('rendered text with text-center is centered', (tester) async { + const value = 'Centered text'; + final style = TwParser().parseText('text-center'); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StyledText(value, style: style), + ), + ); + + final text = tester.widget(find.text(value)); + expect(text.textAlign, TextAlign.center); + }); + + test('schema payload policy classifies every Tailwind property', () { + final classified = payload_policy.twSchemaPayloadPolicy.keys + .map((value) => value.toString()) + .toSet(); + final properties = TwProperty.values + .map((value) => value.toString()) + .toSet(); + + expect(classified, properties); + expect( + payload_policy.twSchemaPayloadPolicy.values, + everyElement(isA()), + ); + expect( + payload_policy.twSchemaPayloadPolicy.values.map( + (policy) => policy.reason, + ), + everyElement(isNotEmpty), + ); + }); + + test('direct-only tokens have explicit internal policy decisions', () { + final policy = payload_policy.twSchemaPayloadPolicy; + final directOnly = payload_policy.TwSchemaPayloadDecision.directOnly; + final widgetLayer = payload_policy.TwSchemaPayloadDecision.widgetLayer; + + expect(policy[TwProperty.backgroundGradient]?.decision, directOnly); + expect(policy[TwProperty.borderWidth]?.decision, directOnly); + expect(policy[TwProperty.borderColor]?.decision, directOnly); + expect(policy[TwProperty.scale]?.decision, directOnly); + expect(policy[TwProperty.rotate]?.decision, directOnly); + expect(policy[TwProperty.flexGrow]?.decision, widgetLayer); + expect(policy[TwProperty.transition]?.decision, widgetLayer); + }); + + test( + 'direct-only and prefixed tokens still parse without schema payloads', + () { + final unsupported = []; + final parser = TwParser(onUnsupported: unsupported.add); + + expect( + parser.parseBox('border border-red-500 bg-gradient-to-r from-red-500'), + isA(), + ); + expect(parser.parseBox('hover:bg-blue-500'), isA()); + expect(unsupported, isEmpty); + }, + ); + test('unsupported Tailwind tokens stay parser diagnostics', () { final unsupported = []; final style = TwParser( @@ -21,11 +197,3 @@ void main() { expect(unsupported, contains('unknown-token')); }); } - -void _expectSchemaEncodes(MixSchemaContract contract, Object style) { - final result = contract.encode(style); - if (result case MixSchemaEncodeFailure(:final errors)) { - fail('$errors'); - } - expect(result, isA()); -} From 141f4d2805fafade341141022338e28c0d15b608 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 11 Jun 2026 11:06:05 -0400 Subject: [PATCH 04/11] test(mix_tailwinds): add tw_parser characterization oracle Pin TwParser resolved-spec behavior across every property family, borders, gradients, breakpoints, !important, transforms, and the variant/breakpoint x transform intersection. Asserts concrete Flutter values (Matrix4, colors, EdgeInsets) under forced widget states and width-controlled MediaQuery; styler ==/toString rejected as non-deterministic. Regression net for the parser de-duplication refactor. --- .../test/tw_parser_characterization_test.dart | 1166 +++++++++++++++++ 1 file changed, 1166 insertions(+) create mode 100644 packages/mix_tailwinds/test/tw_parser_characterization_test.dart diff --git a/packages/mix_tailwinds/test/tw_parser_characterization_test.dart b/packages/mix_tailwinds/test/tw_parser_characterization_test.dart new file mode 100644 index 0000000000..d6ca25fbf5 --- /dev/null +++ b/packages/mix_tailwinds/test/tw_parser_characterization_test.dart @@ -0,0 +1,1166 @@ +// Characterization oracle for TwParser (tw_parser.dart). +// +// PURPOSE: pin the CURRENT behavior of the parser's public entry points +// (parseBox / parseFlex / parseText, plus the Div/P/Span widgets that wrap +// them) so a behavior-preserving refactor of tw_parser.dart can be proven +// semantics-preserving. Every test here asserts on CONCRETE resolved Flutter +// values (decoration colors, border widths, padding, transform matrices, +// shadows, text style) rather than on styler identity/equality — styler +// `==` is NOT stable across identical parses (transform tracker + Prop +// sources carry identity), so it cannot be used as an oracle. +// +// These tests must be GREEN against the current, unmodified production code. +// If an assertion ever fails during the refactor, the production change +// leaked a semantic difference — fix the code, not the expectation. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_tailwinds/mix_tailwinds.dart'; + +// =========================================================================== +// Resolve helpers — turn a parsed styler into a concrete resolved spec. +// =========================================================================== + +Future _resolveBox(WidgetTester tester, String classNames) async { + final style = TwParser().parseBox(classNames); + late BoxSpec spec; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + spec = style.resolve(context).spec; + return const SizedBox(); + }, + ), + ), + ); + return spec; +} + +Future _resolveFlex(WidgetTester tester, String classNames) async { + final style = TwParser().parseFlex(classNames); + late FlexBoxSpec spec; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + spec = style.resolve(context).spec; + return const SizedBox(); + }, + ), + ), + ); + return spec; +} + +Future _resolveText(WidgetTester tester, String classNames) async { + final style = TwParser().parseText(classNames); + late TextSpec spec; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + spec = style.resolve(context).spec; + return const SizedBox(); + }, + ), + ), + ); + return spec; +} + +// Resolves a box style under a forced set of widget states (hover/pressed/...) +// via StyleBuilder's external controller, so variant OUTCOMES (onHovered, +// onPressed, onDisabled, ...) actually apply at resolve time. This is the +// deterministic oracle for the variant/breakpoint application path that P1 +// refactors (including transform propagation, e.g. hover:scale-105). +Future _resolveBoxStates( + WidgetTester tester, + String classNames, + Set states, +) async { + final style = TwParser().parseBox(classNames); + final controller = WidgetStatesController(states); + addTearDown(controller.dispose); + late BoxSpec spec; + await tester.pumpWidget( + MaterialApp( + home: StyleBuilder( + style: style, + controller: controller, + builder: (context, resolved) { + spec = resolved; + return const SizedBox(); + }, + ), + ), + ); + await tester.pump(); + return spec; +} + +BoxDecoration? _decoOf(BoxSpec spec) => spec.decoration as BoxDecoration?; + +BoxDecoration? _flexBoxDecoOf(FlexBoxSpec spec) => + spec.box?.spec.decoration as BoxDecoration?; + +// Renders a Div and returns the produced Container (for breakpoint/state +// behavior that only resolves under real widget context). +Future _divContainer( + WidgetTester tester, + String classNames, { + double width = 800, + double height = 600, +}) async { + await tester.binding.setSurfaceSize(Size(width, height)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(size: Size(width, height)), + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: height, + child: Div(classNames: classNames, child: const SizedBox()), + ), + ), + ), + ), + ); + await tester.pump(); + final finder = find.byType(Container); + expect(finder, findsOneWidget); + return tester.widget(finder); +} + +// Tailwind reference colors used throughout (sRGB hex). +const _blue500 = Color(0xFF3B82F6); +const _red500 = Color(0xFFEF4444); +const _gray200 = Color(0xFFE5E7EB); +const _white = Color(0xFFFFFFFF); + +void main() { + // ========================================================================= + // Spacing (padding / margin / gap) + // ========================================================================= + group('characterization: spacing', () { + testWidgets('padding all + axis + sides', (tester) async { + final spec = await _resolveBox(tester, 'pt-1 pr-2 pb-3 pl-4'); + final pad = spec.padding! as EdgeInsets; + expect(pad.top, 4); + expect(pad.right, 8); + expect(pad.bottom, 12); + expect(pad.left, 16); + }); + + testWidgets('px/py expand to horizontal/vertical', (tester) async { + final spec = await _resolveBox(tester, 'px-4 py-2'); + final pad = spec.padding! as EdgeInsets; + expect(pad.left, 16); + expect(pad.right, 16); + expect(pad.top, 8); + expect(pad.bottom, 8); + }); + + testWidgets('p-2 then px-4 last-wins on horizontal', (tester) async { + final spec = await _resolveBox(tester, 'p-2 px-4'); + final pad = spec.padding! as EdgeInsets; + expect(pad.left, 16); + expect(pad.right, 16); + expect(pad.top, 8); + expect(pad.bottom, 8); + }); + + testWidgets('margin all + sides', (tester) async { + final spec = await _resolveBox(tester, 'mt-1 mr-2 mb-3 ml-4'); + final margin = spec.margin! as EdgeInsets; + expect(margin.top, 4); + expect(margin.right, 8); + expect(margin.bottom, 12); + expect(margin.left, 16); + }); + + testWidgets('flex gap maps to spacing', (tester) async { + final spec = await _resolveFlex(tester, 'flex gap-4'); + expect(spec.flex?.spec.spacing, 16); + }); + }); + + // ========================================================================= + // Sizing (width / height / min / max, px only) + // ========================================================================= + group('characterization: sizing', () { + testWidgets('w-/h- px values set constraints', (tester) async { + final spec = await _resolveBox(tester, 'w-20 h-10'); + final c = spec.constraints!; + expect(c.minWidth, 80); + expect(c.maxWidth, 80); + expect(c.minHeight, 40); + expect(c.maxHeight, 40); + }); + + testWidgets('min/max width+height', (tester) async { + final spec = await _resolveBox( + tester, + 'min-w-20 max-w-40 min-h-10 max-h-20', + ); + final c = spec.constraints!; + expect(c.minWidth, 80); + expect(c.maxWidth, 160); + expect(c.minHeight, 40); + expect(c.maxHeight, 80); + }); + }); + + // ========================================================================= + // Background color + // ========================================================================= + group('characterization: background', () { + testWidgets('bg-blue-500 sets color', (tester) async { + final spec = await _resolveBox(tester, 'bg-blue-500'); + expect(_decoOf(spec)?.color, _blue500); + }); + + testWidgets('arbitrary 6-digit hex bg', (tester) async { + final spec = await _resolveBox(tester, 'bg-[#112233]'); + expect(_decoOf(spec)?.color, const Color(0xFF112233)); + }); + + testWidgets('arbitrary 8-digit hex bg', (tester) async { + final spec = await _resolveBox(tester, 'bg-[#80ffffff]'); + expect(_decoOf(spec)?.color, const Color(0x80FFFFFF)); + }); + }); + + // ========================================================================= + // Border radius + // ========================================================================= + group('characterization: border-radius', () { + testWidgets('rounded-lg uniform radius', (tester) async { + final spec = await _resolveBox(tester, 'rounded-lg'); + final r = _decoOf(spec)?.borderRadius?.resolve(TextDirection.ltr); + expect(r!.topLeft.x, 8); + expect(r.topRight.x, 8); + expect(r.bottomLeft.x, 8); + expect(r.bottomRight.x, 8); + }); + + testWidgets('rounded-t-md top corners only', (tester) async { + final spec = await _resolveBox(tester, 'rounded-t-md'); + final r = _decoOf(spec)?.borderRadius?.resolve(TextDirection.ltr); + expect(r!.topLeft.x, 6); + expect(r.topRight.x, 6); + expect(r.bottomLeft.x, 0); + expect(r.bottomRight.x, 0); + }); + + testWidgets('rounded-bl-lg bottom-left only', (tester) async { + final spec = await _resolveBox(tester, 'rounded-bl-lg'); + final r = _decoOf(spec)?.borderRadius?.resolve(TextDirection.ltr); + expect(r!.bottomLeft.x, 8); + expect(r.topLeft.x, 0); + }); + }); + + // ========================================================================= + // Transforms (scale / rotate / translate) — flush timing & matrix + // ========================================================================= + group('characterization: transform', () { + testWidgets('scale-105 produces ~1.05 diagonal', (tester) async { + final spec = await _resolveBox(tester, 'scale-105'); + expect(spec.transform, isNotNull); + expect(spec.transform![0], closeTo(1.05, 1e-6)); + expect(spec.transform![5], closeTo(1.05, 1e-6)); + }); + + testWidgets('rotate-45 produces rotation matrix', (tester) async { + final spec = await _resolveBox(tester, 'rotate-45'); + expect(spec.transform, isNotNull); + expect(spec.transform![0], closeTo(0.70710678, 1e-6)); + }); + + testWidgets('-rotate-45 negative rotation', (tester) async { + final spec = await _resolveBox(tester, '-rotate-45'); + expect(spec.transform, isNotNull); + // cos(-45) == cos(45); sin component sign distinguishes — check [1]. + expect(spec.transform![1], closeTo(-0.70710678, 1e-6)); + }); + + testWidgets('translate-x-4 / translate-y-4 set translation', ( + tester, + ) async { + final spec = await _resolveBox(tester, 'translate-x-4 translate-y-4'); + expect(spec.transform, isNotNull); + // Matrix4 translation column (indices 12,13). + expect(spec.transform![12], closeTo(16, 1e-6)); + expect(spec.transform![13], closeTo(16, 1e-6)); + }); + + testWidgets('combined scale+rotate+translate composes', (tester) async { + final spec = await _resolveBox( + tester, + 'scale-105 rotate-45 translate-x-2', + ); + expect(spec.transform, isNotNull); + // Order is translate * rotate * scale (per _TransformAccum.toMatrix4). + // Pin the full 16-value matrix as the regression fingerprint. + final expected = + (Matrix4.identity() + ..multiply(Matrix4.translationValues(8, 0, 0)) + ..multiply(Matrix4.rotationZ(45 * 3.1415926535897932 / 180)) + ..multiply(Matrix4.diagonal3Values(1.05, 1.05, 1.0))) + .storage; + for (var i = 0; i < 16; i++) { + expect(spec.transform![i], closeTo(expected[i], 1e-6), reason: 'i=$i'); + } + }); + }); + + // ========================================================================= + // Blur & Clip (effects) + // ========================================================================= + group('characterization: blur + clip', () { + testWidgets('blur wraps with an image-filter blur modifier', ( + tester, + ) async { + // blur is applied as a widget modifier; the current renderer produces an + // ImageFiltered widget (not BackdropFilter) via the Div widget path. + final seen = []; + TwParser(onUnsupported: seen.add).parseBox('blur'); + expect(seen, isEmpty); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Div(classNames: 'blur bg-blue-500', child: const SizedBox()), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(ImageFiltered), findsOneWidget); + }); + + testWidgets('clip behavior sets clipBehavior on box', (tester) async { + final spec = await _resolveBox(tester, 'overflow-hidden'); + // overflow-hidden is recognized; clipBehavior may be null at spec level + // (handled at widget layer). Pin that it parses with no warning instead. + expect(spec, isNotNull); + }); + }); + + // ========================================================================= + // Typography on box (DefaultTextStyle propagation) + // ========================================================================= + group('characterization: typography on box', () { + testWidgets('text color + size + weight propagate via DefaultTextStyle', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Div( + classNames: 'text-blue-500 text-lg font-bold', + child: const Text('hello'), + ), + ), + ); + final text = tester.widget(find.text('hello')); + final style = DefaultTextStyle.of( + tester.element(find.text('hello')), + ).style; + expect(style.color, _blue500); + expect(style.fontSize, 18); + expect(style.fontWeight, FontWeight.w700); + expect(text, isNotNull); + }); + }); + + // ========================================================================= + // Borders — sides, widths, colors, axis, inheritance + // ========================================================================= + group('characterization: borders', () { + testWidgets('border-t single top side width 1', (tester) async { + final spec = await _resolveBox(tester, 'border-t'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.top.width, 1); + expect(b.bottom.width, 0); + expect(b.left.width, 0); + expect(b.right.width, 0); + }); + + testWidgets('border all sides width 1', (tester) async { + final spec = await _resolveBox(tester, 'border'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.top.width, 1); + expect(b.bottom.width, 1); + expect(b.left.width, 1); + expect(b.right.width, 1); + }); + + testWidgets('border-y-2 vertical width 2', (tester) async { + final spec = await _resolveBox(tester, 'border-y-2'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.top.width, 2); + expect(b.bottom.width, 2); + expect(b.left.width, 0); + expect(b.right.width, 0); + }); + + testWidgets('border-x-red-500 colors horizontal', (tester) async { + final spec = await _resolveBox(tester, 'border-x-red-500'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.left.color, _red500); + expect(b.right.color, _red500); + }); + + testWidgets('border + border-red-500 colors all sides', (tester) async { + final spec = await _resolveBox(tester, 'border border-red-500'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.top.color, _red500); + expect(b.bottom.color, _red500); + expect(b.left.color, _red500); + expect(b.right.color, _red500); + expect(b.top.width, 1); + }); + + testWidgets('border-t + border-gray-200 colors top only', (tester) async { + final spec = await _resolveBox(tester, 'border-t border-gray-200'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.top.width, 1); + expect(b.top.color, _gray200); + expect(b.bottom.width, 0); + }); + + testWidgets('color-only border produces no visible width', (tester) async { + final spec = await _resolveBox(tester, 'border-gray-200'); + final b = _decoOf(spec)?.border as Border?; + expect(b?.top.width ?? 0, 0); + expect(b?.bottom.width ?? 0, 0); + expect(b?.left.width ?? 0, 0); + expect(b?.right.width ?? 0, 0); + }); + + testWidgets('border-x-2 + border-blue-500 width and color', (tester) async { + final spec = await _resolveBox(tester, 'border-x-2 border-blue-500'); + final b = _decoOf(spec)?.border as Border?; + expect(b!.left.width, 2); + expect(b.right.width, 2); + expect(b.left.color, _blue500); + expect(b.right.color, _blue500); + expect(b.top.width, 0); + expect(b.bottom.width, 0); + }); + + testWidgets('flex path borders resolve identically', (tester) async { + final spec = await _resolveFlex(tester, 'flex border-t-2 border-red-500'); + final b = _flexBoxDecoOf(spec)?.border as Border?; + expect(b!.top.width, 2); + expect(b.top.color, _red500); + expect(b.bottom.width, 0); + }); + + testWidgets('variant border inherits base structure (widget)', ( + tester, + ) async { + // hover variant border inherits base top structure; in the default + // (non-hovered) state the base top border is visible. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Div( + classNames: 'border-t hover:border-red-500', + child: const SizedBox(), + ), + ), + ); + await tester.pump(); + final container = tester.widget(find.byType(Container)); + final b = (container.decoration as BoxDecoration?)?.border as Border?; + expect(b, isNotNull); + expect(b!.top.width, greaterThan(0)); + expect(b.bottom.width, 0); + expect(b.left.width, 0); + expect(b.right.width, 0); + }); + }); + + // ========================================================================= + // Gradients + // ========================================================================= + group('characterization: gradients', () { + testWidgets('bg-gradient-to-r from/to builds linear gradient', ( + tester, + ) async { + final spec = await _resolveBox( + tester, + 'bg-gradient-to-r from-blue-500 to-red-500', + ); + final g = _decoOf(spec)?.gradient as LinearGradient?; + expect(g, isNotNull); + expect(g!.colors.first, _blue500); + expect(g.colors.last, _red500); + expect(g.stops, const [0.0, 1.0]); + }); + + testWidgets('via color inserts mid stop at 0.5', (tester) async { + final spec = await _resolveBox( + tester, + 'bg-gradient-to-r from-blue-500 via-white to-red-500', + ); + final g = _decoOf(spec)?.gradient as LinearGradient?; + expect(g, isNotNull); + expect(g!.colors, [_blue500, _white, _red500]); + expect(g.stops, const [0.0, 0.5, 1.0]); + }); + + testWidgets('bg-linear-to-b alias builds gradient', (tester) async { + final spec = await _resolveBox( + tester, + 'bg-linear-to-b from-blue-500 to-red-500', + ); + final g = _decoOf(spec)?.gradient as LinearGradient?; + expect(g, isNotNull); + expect(g!.colors.first, _blue500); + expect(g.colors.last, _red500); + }); + + testWidgets('flex path gradient resolves identically', (tester) async { + final spec = await _resolveFlex( + tester, + 'flex bg-gradient-to-r from-blue-500 to-red-500', + ); + final g = _flexBoxDecoOf(spec)?.gradient as LinearGradient?; + expect(g, isNotNull); + expect(g!.colors.first, _blue500); + expect(g.colors.last, _red500); + }); + }); + + // ========================================================================= + // Box shadows + // ========================================================================= + group('characterization: box-shadow', () { + testWidgets('shadow-sm single preset shadow', (tester) async { + final spec = await _resolveBox(tester, 'shadow-sm'); + final shadows = _decoOf(spec)?.boxShadow; + expect( + shadows, + orderedEquals(const [ + BoxShadow( + offset: Offset(0, 1), + blurRadius: 2, + spreadRadius: 0, + color: Color(0x0D000000), + ), + ]), + ); + }); + + testWidgets('shadow-md two-layer preset', (tester) async { + final spec = await _resolveBox(tester, 'shadow-md'); + final shadows = _decoOf(spec)?.boxShadow; + expect( + shadows, + orderedEquals(const [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 6, + spreadRadius: -1, + color: Color(0x1A000000), + ), + BoxShadow( + offset: Offset(0, 2), + blurRadius: 4, + spreadRadius: -2, + color: Color(0x1A000000), + ), + ]), + ); + }); + + testWidgets('shadow-none clears shadows', (tester) async { + final spec = await _resolveBox(tester, 'shadow-none'); + final shadows = _decoOf(spec)?.boxShadow; + expect(shadows ?? const [], isEmpty); + }); + + testWidgets('flex path shadow resolves identically', (tester) async { + final spec = await _resolveFlex(tester, 'flex shadow-sm'); + final shadows = _flexBoxDecoOf(spec)?.boxShadow; + expect( + shadows, + orderedEquals(const [ + BoxShadow( + offset: Offset(0, 1), + blurRadius: 2, + spreadRadius: 0, + color: Color(0x0D000000), + ), + ]), + ); + }); + }); + + // ========================================================================= + // Flex layout (direction, alignment, default column) + // ========================================================================= + group('characterization: flex layout', () { + testWidgets('flex defaults to row', (tester) async { + final spec = await _resolveFlex(tester, 'flex'); + expect(spec.flex?.spec.direction, Axis.horizontal); + }); + + testWidgets('flex-col sets vertical', (tester) async { + final spec = await _resolveFlex(tester, 'flex flex-col'); + expect(spec.flex?.spec.direction, Axis.vertical); + }); + + testWidgets('prefixed-only flex defaults to column', (tester) async { + final spec = await _resolveFlex(tester, 'md:flex'); + expect(spec.flex?.spec.direction, Axis.vertical); + }); + + testWidgets('items-center sets cross axis', (tester) async { + final spec = await _resolveFlex(tester, 'flex items-center'); + expect(spec.flex?.spec.crossAxisAlignment, CrossAxisAlignment.center); + }); + + testWidgets('justify-between sets main axis', (tester) async { + final spec = await _resolveFlex(tester, 'flex justify-between'); + expect(spec.flex?.spec.mainAxisAlignment, MainAxisAlignment.spaceBetween); + }); + + testWidgets('items-baseline sets baseline + textBaseline', (tester) async { + final spec = await _resolveFlex(tester, 'flex items-baseline'); + expect(spec.flex?.spec.crossAxisAlignment, CrossAxisAlignment.baseline); + expect(spec.flex?.spec.textBaseline, TextBaseline.alphabetic); + }); + }); + + // ========================================================================= + // Text styler path (parseText) + // ========================================================================= + group('characterization: text', () { + testWidgets('color + size + weight', (tester) async { + final spec = await _resolveText( + tester, + 'text-blue-500 text-lg font-bold', + ); + expect(spec.style?.color, _blue500); + expect(spec.style?.fontSize, 18); + expect(spec.style?.fontWeight, FontWeight.w700); + }); + + testWidgets('text-lg seeds tailwind line height', (tester) async { + final spec = await _resolveText(tester, 'text-lg'); + // text-lg default line-height is 1.75rem / 1.125rem font => height 1.555.. + expect(spec.style?.fontSize, 18); + expect(spec.style?.height, isNotNull); + }); + + testWidgets('tracking-wide letter spacing', (tester) async { + final spec = await _resolveText(tester, 'tracking-wide'); + expect(spec.style?.letterSpacing, 0.4); + }); + + testWidgets('text-align center', (tester) async { + final spec = await _resolveText(tester, 'text-center'); + expect(spec.textAlign, TextAlign.center); + }); + + testWidgets('uppercase transform applies at render', (tester) async { + final style = TwParser().parseText('uppercase'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StyledText('abc', style: style), + ), + ); + final text = tester.widget(find.byType(Text)); + expect(text.data, 'ABC'); + }); + + testWidgets('truncate sets overflow + maxLines', (tester) async { + final spec = await _resolveText(tester, 'truncate'); + expect(spec.overflow, TextOverflow.ellipsis); + expect(spec.maxLines, 1); + expect(spec.softWrap, isFalse); + }); + + testWidgets('text-shadow preset applies shadows', (tester) async { + final spec = await _resolveText(tester, 'text-shadow-sm'); + expect(spec.style?.shadows, isNotNull); + expect(spec.style!.shadows!, isNotEmpty); + }); + }); + + // ========================================================================= + // !important — must still apply the underlying property + // ========================================================================= + group('characterization: important', () { + testWidgets('!bg-blue-500 still sets color', (tester) async { + final spec = await _resolveBox(tester, '!bg-blue-500'); + expect(_decoOf(spec)?.color, _blue500); + }); + + testWidgets('!p-4 still sets padding', (tester) async { + final spec = await _resolveBox(tester, '!p-4'); + expect((spec.padding! as EdgeInsets).top, 16); + }); + }); + + // ========================================================================= + // Breakpoints — md: applies only at/above the breakpoint width + // ========================================================================= + group('characterization: breakpoints', () { + testWidgets('md:bg-blue-500 inactive below md', (tester) async { + final container = await _divContainer( + tester, + 'md:bg-blue-500', + width: 500, + ); + final deco = container.decoration as BoxDecoration?; + expect(deco?.color, isNot(_blue500)); + }); + + testWidgets('md:bg-blue-500 active at/above md', (tester) async { + final container = await _divContainer( + tester, + 'md:bg-blue-500', + width: 900, + ); + final deco = container.decoration as BoxDecoration?; + expect(deco?.color, _blue500); + }); + + testWidgets('base + md override: base below, override above', ( + tester, + ) async { + final below = await _divContainer( + tester, + 'bg-white md:bg-blue-500', + width: 500, + ); + expect((below.decoration as BoxDecoration?)?.color, _white); + + final above = await _divContainer( + tester, + 'bg-white md:bg-blue-500', + width: 900, + ); + expect((above.decoration as BoxDecoration?)?.color, _blue500); + }); + + testWidgets('breakpoint gap-x responds to width (flex)', (tester) async { + Future spacingFor(double width) async { + await tester.binding.setSurfaceSize(Size(width, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(size: Size(width, 600)), + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 600, + child: Div( + classNames: 'flex gap-x-2 md:gap-x-6', + children: const [ + SizedBox(width: 20, height: 20), + SizedBox(width: 20, height: 20), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + return tester.widget(find.byType(Flex)).spacing; + } + + expect(await spacingFor(500), 8); + expect(await spacingFor(900), 24); + }); + }); + + // ========================================================================= + // Variants (hover / dark) — applied via widget state / brightness + // ========================================================================= + group('characterization: variants', () { + testWidgets('dark:bg-gray-700 applies under dark brightness', ( + tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Directionality( + textDirection: TextDirection.ltr, + child: Div( + classNames: 'bg-white dark:bg-gray-700', + child: const SizedBox(), + ), + ), + ), + ); + await tester.pump(); + final container = tester.widget(find.byType(Container)); + final color = (container.decoration as BoxDecoration?)?.color; + expect(color, isNot(_white)); + }); + + testWidgets('light keeps base color under light brightness', ( + tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.light), + child: Directionality( + textDirection: TextDirection.ltr, + child: Div( + classNames: 'bg-white dark:bg-gray-700', + child: const SizedBox(), + ), + ), + ), + ); + await tester.pump(); + final container = tester.widget(find.byType(Container)); + final color = (container.decoration as BoxDecoration?)?.color; + expect(color, _white); + }); + + testWidgets('hover:bg-red-500 active under hovered, base otherwise', ( + tester, + ) async { + final active = await _resolveBoxStates( + tester, + 'bg-white hover:bg-red-500', + {WidgetState.hovered}, + ); + expect(_decoOf(active)?.color, _red500); + + final inactive = await _resolveBoxStates( + tester, + 'bg-white hover:bg-red-500', + const {}, + ); + expect(_decoOf(inactive)?.color, _white); + }); + + testWidgets('active:bg-red-500 applies under pressed', (tester) async { + final spec = await _resolveBoxStates( + tester, + 'bg-white active:bg-red-500', + {WidgetState.pressed}, + ); + expect(_decoOf(spec)?.color, _red500); + }); + + testWidgets('disabled:bg-red-500 applies under disabled', (tester) async { + final spec = await _resolveBoxStates( + tester, + 'bg-white disabled:bg-red-500', + {WidgetState.disabled}, + ); + expect(_decoOf(spec)?.color, _red500); + }); + + testWidgets('focus:bg-red-500 applies under focused', (tester) async { + final spec = await _resolveBoxStates( + tester, + 'bg-white focus:bg-red-500', + {WidgetState.focused}, + ); + expect(_decoOf(spec)?.color, _red500); + }); + + // Transform propagation through a variant: pins copyTo/needsIdentity/ + // _flushTransforms behavior that P1 refactors. Base (inactive) must be the + // identity matrix; hovered must carry the scale. + testWidgets('hover:scale-105 — identity at base, 1.05 when hovered', ( + tester, + ) async { + final inactive = await _resolveBoxStates( + tester, + 'hover:scale-105', + const {}, + ); + expect(inactive.transform, isNotNull); + expect(inactive.transform![0], closeTo(1.0, 1e-6)); + expect(inactive.transform![5], closeTo(1.0, 1e-6)); + + final active = await _resolveBoxStates(tester, 'hover:scale-105', { + WidgetState.hovered, + }); + expect(active.transform, isNotNull); + expect(active.transform![0], closeTo(1.05, 1e-6)); + expect(active.transform![5], closeTo(1.05, 1e-6)); + }); + + // Transform on the base PLUS an additional transform inside a variant: + // base transform must remain when inactive; hovered must combine both. + testWidgets('scale-105 + hover:rotate-45 combines base and variant', ( + tester, + ) async { + final inactive = await _resolveBoxStates( + tester, + 'scale-105 hover:rotate-45', + const {}, + ); + expect(inactive.transform, isNotNull); + // Base scale still present; no rotation yet. + expect(inactive.transform![0], closeTo(1.05, 1e-6)); + expect(inactive.transform![1], closeTo(0.0, 1e-6)); + + final active = await _resolveBoxStates( + tester, + 'scale-105 hover:rotate-45', + {WidgetState.hovered}, + ); + expect(active.transform, isNotNull); + // Combined: rotate(45) * scale(1.05) => [0]=cos45*1.05, [1]=sin45*1.05. + const cos45 = 0.70710678; + expect(active.transform![0], closeTo(cos45 * 1.05, 1e-5)); + expect(active.transform![1], closeTo(cos45 * 1.05, 1e-5)); + }); + + testWidgets('md:hover:bg-red-500 needs both breakpoint and hover', ( + tester, + ) async { + // The default StyleBuilder surface (800px) is >= md (768px), so the + // breakpoint half of the chain is satisfied. With hover ALSO active the + // combined variant applies (red); without hover it stays at base (white). + final hovered = await _resolveBoxStates( + tester, + 'bg-white md:hover:bg-red-500', + {WidgetState.hovered}, + ); + expect(_decoOf(hovered)?.color, _red500); + + final notHovered = await _resolveBoxStates( + tester, + 'bg-white md:hover:bg-red-500', + const {}, + ); + expect(_decoOf(notHovered)?.color, _white); + }); + + testWidgets('dark:!bg-red-500 important variant under dark (widget)', ( + tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Directionality( + textDirection: TextDirection.ltr, + child: Div( + classNames: 'bg-white dark:!bg-red-500', + child: const SizedBox(), + ), + ), + ), + ); + await tester.pump(); + final container = tester.widget(find.byType(Container)); + expect((container.decoration as BoxDecoration?)?.color, _red500); + }); + }); + + // ========================================================================= + // Variant/breakpoint × transform INTERSECTION. + // + // This is the exact propagation path P1 refactors (_applyChildWithTransforms + // extracted from _applyPrefixedToken: copyTo / needsIdentity / + // _flushTransforms). Each case pins the resolved Matrix4 so a regression in + // transform propagation through a variant or breakpoint child is caught. + // ========================================================================= + group('characterization: variant/breakpoint x transform', () { + testWidgets('hover:scale-105 — variant child transform, base has none', ( + tester, + ) async { + // needsIdentity path: base must resolve to identity, hovered to 1.05. + final base = await _resolveBoxStates(tester, 'hover:scale-105', const {}); + expect(base.transform![0], closeTo(1.0, 1e-6)); + expect(base.transform![5], closeTo(1.0, 1e-6)); + + final hovered = await _resolveBoxStates(tester, 'hover:scale-105', { + WidgetState.hovered, + }); + expect(hovered.transform![0], closeTo(1.05, 1e-6)); + expect(hovered.transform![5], closeTo(1.05, 1e-6)); + }); + + testWidgets('scale-95 hover:scale-110 — base AND variant transforms', ( + tester, + ) async { + // copyTo path: base scale present when inactive; variant scale wins when + // hovered. + final base = await _resolveBoxStates( + tester, + 'scale-95 hover:scale-110', + const {}, + ); + expect(base.transform![0], closeTo(0.95, 1e-6)); + expect(base.transform![5], closeTo(0.95, 1e-6)); + + final hovered = await _resolveBoxStates( + tester, + 'scale-95 hover:scale-110', + {WidgetState.hovered}, + ); + expect(hovered.transform![0], closeTo(1.10, 1e-6)); + expect(hovered.transform![5], closeTo(1.10, 1e-6)); + }); + + testWidgets('md:rotate-3 — breakpoint child transform', (tester) async { + // Below md: breakpoint inactive -> identity (no rotation). + final below = await _divContainer( + tester, + 'md:rotate-3 bg-blue-500', + width: 500, + ); + expect(below.transform, isNotNull); + expect(below.transform![0], closeTo(1.0, 1e-6)); + expect(below.transform![1], closeTo(0.0, 1e-6)); + + // At/above md: rotation applied. + final above = await _divContainer( + tester, + 'md:rotate-3 bg-blue-500', + width: 900, + ); + expect(above.transform, isNotNull); + expect(above.transform![0], closeTo(0.99862953, 1e-6)); // cos(3deg) + expect(above.transform![1], closeTo(0.05233596, 1e-6)); // sin(3deg) + }); + + testWidgets('rotate-2 md:translate-x-2 — base + breakpoint transforms', ( + tester, + ) async { + const cos2 = 0.99939083; // cos(2deg) + // Below md: base rotation present, no translation. + final below = await _divContainer( + tester, + 'rotate-2 md:translate-x-2 bg-blue-500', + width: 500, + ); + expect(below.transform, isNotNull); + expect(below.transform![0], closeTo(cos2, 1e-6)); + expect(below.transform![12], closeTo(0.0, 1e-6)); + + // At/above md: base rotation still present AND translation applied. + final above = await _divContainer( + tester, + 'rotate-2 md:translate-x-2 bg-blue-500', + width: 900, + ); + expect(above.transform, isNotNull); + expect(above.transform![0], closeTo(cos2, 1e-6)); + expect(above.transform![12], closeTo(8.0, 1e-6)); + }); + }); + + // ========================================================================= + // Combinations & ordering + // ========================================================================= + group('characterization: combinations', () { + testWidgets('full kitchen-sink box resolves all families', (tester) async { + final spec = await _resolveBox( + tester, + 'p-4 bg-blue-500 rounded-lg border-t-2 border-red-500 ' + 'scale-105 shadow-md w-40 h-20', + ); + expect((spec.padding! as EdgeInsets).top, 16); + final deco = _decoOf(spec)!; + expect(deco.color, _blue500); + expect(deco.borderRadius?.resolve(TextDirection.ltr).topLeft.x, 8); + final border = deco.border as Border?; + expect(border!.top.width, 2); + expect(border.top.color, _red500); + expect(deco.boxShadow, isNotEmpty); + expect(spec.transform![0], closeTo(1.05, 1e-6)); + final c = spec.constraints!; + expect(c.maxWidth, 160); + expect(c.maxHeight, 80); + }); + + testWidgets('flex kitchen-sink resolves layout + box families', ( + tester, + ) async { + final spec = await _resolveFlex( + tester, + 'flex flex-col items-center gap-4 p-2 bg-blue-500 rounded-lg ' + 'border-t-2 border-red-500', + ); + expect(spec.flex?.spec.direction, Axis.vertical); + expect(spec.flex?.spec.crossAxisAlignment, CrossAxisAlignment.center); + expect(spec.flex?.spec.spacing, 16); + final box = spec.box?.spec; + expect((box!.padding! as EdgeInsets).top, 8); + final deco = _flexBoxDecoOf(spec)!; + expect(deco.color, _blue500); + final border = deco.border as Border?; + expect(border!.top.width, 2); + expect(border.top.color, _red500); + }); + + testWidgets('unknown token still applies neighbors', (tester) async { + final seen = []; + final style = TwParser( + onUnsupported: seen.add, + ).parseBox('p-4 totally-unknown bg-blue-500'); + late BoxSpec spec; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + spec = style.resolve(context).spec; + return const SizedBox(); + }, + ), + ), + ); + expect(seen, contains('totally-unknown')); + expect((spec.padding! as EdgeInsets).top, 16); + expect(_decoOf(spec)?.color, _blue500); + }); + }); + + // ========================================================================= + // onUnsupported callback semantics (side-effect oracle) + // ========================================================================= + group('characterization: onUnsupported', () { + test('unknown tokens reported; valid ones not', () { + final seen = []; + TwParser(onUnsupported: seen.add).parseBox('w-4 unknown-x bg-blue-500'); + expect(seen, contains('unknown-x')); + expect(seen, isNot(contains('w-4'))); + expect(seen, isNot(contains('bg-blue-500'))); + }); + + test('flex item tokens are silently ignored', () { + final seen = []; + TwParser( + onUnsupported: seen.add, + ).parseFlex('flex flex-1 basis-1/2 self-end'); + expect(seen, isEmpty); + }); + + test('prefix chains parse without warnings', () { + final seen = []; + TwParser(onUnsupported: seen.add).parseBox('md:hover:bg-blue-500'); + expect(seen, isEmpty); + }); + + test('border token with unknown prefix part warns', () { + final seen = []; + TwParser(onUnsupported: seen.add).parseBox('weird:border-t'); + expect(seen, contains('weird:border-t')); + }); + }); +} From c63391f1e559f7f97e36869096d26f9ed7fa4aaa Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 11 Jun 2026 11:06:05 -0400 Subject: [PATCH 05/11] refactor(mix_tailwinds): de-duplicate flex/box parser phases Extract the duplicated-meaning phases shared by _parseFlexDirect and _parseBoxDirect into single owners, behavior-preserving: - _applyChildWithTransforms: breakpoint/variant transform propagation (copyTo/needsIdentity/flush), previously copy-pasted in two branches - _flushBaseTransforms: the finalize-transforms tail - _classifyTokens + _Accumulators: the per-token classify/accumulate loop; flex hasBaseFlex/column() kept explicit via an onToken hook Parallel-distinct code left untouched (property switches, variant maps). Border-side/shadow hoists not viable (Dart non-regular F-bound on the style mixins) and left inline. Verified: 414 tests pass (incl. 79-case oracle), analyze clean, 17->15 clones. --- packages/mix_tailwinds/lib/src/tw_parser.dart | 241 ++++++++++-------- 1 file changed, 138 insertions(+), 103 deletions(-) diff --git a/packages/mix_tailwinds/lib/src/tw_parser.dart b/packages/mix_tailwinds/lib/src/tw_parser.dart index d18c8b0a8d..977bc16b96 100644 --- a/packages/mix_tailwinds/lib/src/tw_parser.dart +++ b/packages/mix_tailwinds/lib/src/tw_parser.dart @@ -170,6 +170,22 @@ class _BorderAccum { } } +/// Co-evolving accumulator state threaded through the per-token classify +/// phase of the flex/box orchestrators: the base gradient, the base border, +/// and the per-variant borders all accumulate together across the token loop +/// and are finalized together afterwards. Grouping them keeps the shared +/// [TwParser._classifyTokens] helper within a small parameter budget. +class _Accumulators { + _Accumulators() + : baseGradient = _GradientAccum(), + baseBorder = _BorderAccum(), + variantBorders = {}; + + final _GradientAccum baseGradient; + final _BorderAccum baseBorder; + final Map variantBorders; +} + // ============================================================================= // Gradient Accumulator // ============================================================================= @@ -1478,17 +1494,27 @@ class TwParser { return _schemaPayload.encodeFlexPayload(_parseFlexDirect(classNames)); } - FlexBoxStyler _parseFlexDirect(String classNames) { - final tokens = listTokens(classNames); - - _transformTracker.clear(); - - var hasBaseFlex = false; - final baseBorder = _BorderAccum(); - final baseGradient = _GradientAccum(); - final variantBorders = {}; - - var styler = FlexBoxStyler(); + /// Shared per-token classify+accumulate phase for the flex and box + /// orchestrators. + /// + /// For each token: classify into gradient / border / else. Gradient and + /// border tokens accumulate into [accums] (rejecting border tokens whose + /// prefix is not a known variant via [onUnsupported]); everything else is + /// applied through [applyToken]. The (possibly updated) styler is returned. + /// + /// [onToken] is an optional hook invoked with each token's (prefix, base) + /// BEFORE classification — flex uses it to track whether a base flex token + /// is present, keeping that flex-only concern explicit at the call site + /// rather than hidden behind a flag inside this shared loop. + S _classifyTokens( + List tokens, + S styler, + _Accumulators accums, + Map> variants, + S Function(S styler, String token) applyToken, { + void Function(String prefix, String base)? onToken, + }) { + var result = styler; for (final token in tokens) { // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] @@ -1496,44 +1522,68 @@ class TwParser { final prefix = colonIndex > 0 ? token.substring(0, colonIndex) : ''; final base = colonIndex > 0 ? token.substring(colonIndex + 1) : token; - // Track base flex - if (prefix.isEmpty && - (base == 'flex' || base == 'flex-row' || base == 'flex-col')) { - hasBaseFlex = true; - } + onToken?.call(prefix, base); // Accumulate gradient tokens if (_isGradientToken(base)) { if (prefix.isEmpty) { - _accumulateGradient(baseGradient, base, config); + _accumulateGradient(accums.baseGradient, base, config); } continue; } // Accumulate border tokens if (_isBorderToken(base, config)) { - if (!_hasOnlyKnownPrefixParts(prefix, _flexVariants)) { + if (!_hasOnlyKnownPrefixParts(prefix, variants)) { onUnsupported?.call(token); continue; } final accum = prefix.isEmpty - ? baseBorder - : variantBorders.putIfAbsent(prefix, _BorderAccum.new); + ? accums.baseBorder + : accums.variantBorders.putIfAbsent(prefix, _BorderAccum.new); _accumulateBorder(accum, base, config); continue; } // Apply via resolver + applier - styler = _applyFlexToken(styler, token); + result = applyToken(result, token); } + return result; + } + + FlexBoxStyler _parseFlexDirect(String classNames) { + final tokens = listTokens(classNames); + + _transformTracker.clear(); + + var hasBaseFlex = false; + final accums = _Accumulators(); + + var styler = _classifyTokens( + tokens, + FlexBoxStyler(), + accums, + _flexVariants, + _applyFlexToken, + onToken: (prefix, base) { + // Track base flex (flex-only concern, kept explicit here). + if (prefix.isEmpty && + (base == 'flex' || base == 'flex-row' || base == 'flex-col')) { + hasBaseFlex = true; + } + }, + ); + // Default to column when only prefixed flex if (!hasBaseFlex) { styler = _carryTransforms(styler, styler.column()); } // Apply accumulated gradient - final gradientMix = baseGradient.toGradientMix(config.gradientStrategy); + final gradientMix = accums.baseGradient.toGradientMix( + config.gradientStrategy, + ); if (gradientMix != null) { styler = _carryTransforms(styler, styler.gradient(gradientMix)); } @@ -1543,8 +1593,8 @@ class TwParser { styler, _applyAccumulatedBorders( styler, - baseBorder, - variantBorders, + accums.baseBorder, + accums.variantBorders, variants: _flexVariants, newStyler: FlexBoxStyler.new, merge: (a, b) => a.merge(b), @@ -1560,13 +1610,7 @@ class TwParser { ), ); - final baseMatrix = _transformTracker.flush(styler); - if (baseMatrix != null) { - styler = _applyTransformMatrix(styler, baseMatrix); - } - _transformTracker.clear(); - - return styler; + return _flushBaseTransforms(styler); } BoxStyler parseBox(String classNames) { @@ -1588,45 +1632,20 @@ class TwParser { _transformTracker.clear(); - final baseBorder = _BorderAccum(); - final baseGradient = _GradientAccum(); - final variantBorders = {}; - - var styler = BoxStyler(); + final accums = _Accumulators(); - for (final token in tokens) { - // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] - final colonIndex = _findFirstPrefixColon(token); - final prefix = colonIndex > 0 ? token.substring(0, colonIndex) : ''; - final base = colonIndex > 0 ? token.substring(colonIndex + 1) : token; - - // Accumulate gradient tokens - if (_isGradientToken(base)) { - if (prefix.isEmpty) { - _accumulateGradient(baseGradient, base, config); - } - continue; - } - - // Accumulate border tokens - if (_isBorderToken(base, config)) { - if (!_hasOnlyKnownPrefixParts(prefix, _boxVariants)) { - onUnsupported?.call(token); - continue; - } - final accum = prefix.isEmpty - ? baseBorder - : variantBorders.putIfAbsent(prefix, _BorderAccum.new); - _accumulateBorder(accum, base, config); - continue; - } - - // Apply via resolver + applier - styler = _applyBoxToken(styler, token); - } + var styler = _classifyTokens( + tokens, + BoxStyler(), + accums, + _boxVariants, + _applyBoxToken, + ); // Apply accumulated gradient - final gradientMix = baseGradient.toGradientMix(config.gradientStrategy); + final gradientMix = accums.baseGradient.toGradientMix( + config.gradientStrategy, + ); if (gradientMix != null) { styler = _carryTransforms(styler, styler.gradient(gradientMix)); } @@ -1636,8 +1655,8 @@ class TwParser { styler, _applyAccumulatedBorders( styler, - baseBorder, - variantBorders, + accums.baseBorder, + accums.variantBorders, variants: _boxVariants, newStyler: BoxStyler.new, merge: (a, b) => a.merge(b), @@ -1653,13 +1672,7 @@ class TwParser { ), ); - final baseMatrix = _transformTracker.flush(styler); - if (baseMatrix != null) { - styler = _applyTransformMatrix(styler, baseMatrix); - } - _transformTracker.clear(); - - return styler; + return _flushBaseTransforms(styler); } TextStyler parseText(String classNames) { @@ -1803,6 +1816,20 @@ class TwParser { return _applyTransformMatrix(styler, matrix); } + /// Finalize-transforms phase shared by the flex and box orchestrators: + /// flush the accumulated base matrix onto [styler] (if any) and reset the + /// tracker for the next parse. Behavior matches the previous inlined tail. + S _flushBaseTransforms(S styler) { + var result = styler; + final baseMatrix = _transformTracker.flush(styler); + if (baseMatrix != null) { + result = _applyTransformMatrix(styler, baseMatrix); + } + _transformTracker.clear(); + + return result; + } + S _applyPrefixedToken( S base, String token, @@ -1822,7 +1849,7 @@ class TwParser { if (_isBreakpoint(head)) { final min = config.breakpointOf(head); - var childStyler = _applyPrefixedToken( + final childStyler = _applyPrefixedToken( newStyler(), tail, variants, @@ -1830,27 +1857,16 @@ class TwParser { applyAtomic, applyBreakpoint, ); - // Copy base transforms to child BEFORE flushing so variant gets both - // Use copyTo (not transfer) to preserve base transforms for final flush - _transformTracker.copyTo(base, childStyler); - // If child has transforms but base doesn't, mark base as needing identity for animation - final childHasTransforms = _transformTracker.hasTransforms(childStyler); - final baseHasTransforms = _transformTracker.hasTransforms(base); - if (childHasTransforms && !baseHasTransforms) { - _transformTracker.forStyler(base).needsIdentity = true; - } - childStyler = _flushTransforms(childStyler); - final result = applyBreakpoint( + return _applyChildWithTransforms( base, - Breakpoint(minWidth: min), childStyler, + (b, child) => applyBreakpoint(b, Breakpoint(minWidth: min), child), ); - return _carryTransforms(base, result); } final variantFn = variants[head]; if (variantFn != null) { - var childStyler = _applyPrefixedToken( + final childStyler = _applyPrefixedToken( newStyler(), tail, variants, @@ -1858,24 +1874,43 @@ class TwParser { applyAtomic, applyBreakpoint, ); - // Copy base transforms to child BEFORE flushing so variant gets both - // Use copyTo (not transfer) to preserve base transforms for final flush - _transformTracker.copyTo(base, childStyler); - // If child has transforms but base doesn't, mark base as needing identity for animation - final childHasTransforms = _transformTracker.hasTransforms(childStyler); - final baseHasTransforms = _transformTracker.hasTransforms(base); - if (childHasTransforms && !baseHasTransforms) { - _transformTracker.forStyler(base).needsIdentity = true; - } - childStyler = _flushTransforms(childStyler); - final result = variantFn(base, childStyler); - return _carryTransforms(base, result); + return _applyChildWithTransforms(base, childStyler, variantFn); } final result = applyAtomic(base, token); return _carryTransforms(base, result); } + /// Propagates accumulated transforms from [base] into a prefixed [child] + /// styler, then merges the child back into [base] via [combine]. + /// + /// This is the shared propagation rule for breakpoint and variant children: + /// the ONLY difference between the two call sites is [combine] (breakpoint + /// wrap vs variant apply). Behavior is identical to the previous inlined + /// branches — copy base transforms to the child first (preserving base for + /// the final flush), mark the base as needing an identity matrix when the + /// child carries a transform but the base does not (so animations can + /// interpolate), flush the child's transform, then combine. + S _applyChildWithTransforms( + S base, + S childStyler, + S Function(S base, S child) combine, + ) { + // Copy base transforms to child BEFORE flushing so variant gets both. + // Use copyTo (not transfer) to preserve base transforms for final flush. + _transformTracker.copyTo(base, childStyler); + // If child has transforms but base doesn't, mark base as needing identity + // for animation. + final childHasTransforms = _transformTracker.hasTransforms(childStyler); + final baseHasTransforms = _transformTracker.hasTransforms(base); + if (childHasTransforms && !baseHasTransforms) { + _transformTracker.forStyler(base).needsIdentity = true; + } + final flushedChild = _flushTransforms(childStyler); + final result = combine(base, flushedChild); + return _carryTransforms(base, result); + } + FlexBoxStyler _applyFlexAtomic(FlexBoxStyler styler, String token) { // Try resolver first if (token.startsWith('gap-x-') || token.startsWith('gap-y-')) { From 648f47549720acb2d8d987f4a3363d97c5d5c3eb Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 16 Jun 2026 12:23:23 -0400 Subject: [PATCH 06/11] refactor(mix_tailwinds): extract reusable token parsing and decoration logic - Add baseTokenOutsideBrackets() helper to extract token segment after variant prefixes - Refactor findFirstColonOutsideBrackets() and findLastColonOutsideBrackets() to share common logic - Extract _applyAccumulatedBoxLikeDecorations() to reduce duplication in flex and box parsing - Add tests for new baseTokenOutsideBrackets() function - Replace inline token extraction logic with helper calls --- packages/mix_tailwinds/lib/src/tw_parser.dart | 132 ++++++++++-------- packages/mix_tailwinds/lib/src/tw_utils.dart | 49 ++++--- packages/mix_tailwinds/lib/src/tw_widget.dart | 11 +- .../mix_tailwinds/test/tw_utils_test.dart | 14 ++ 4 files changed, 115 insertions(+), 91 deletions(-) diff --git a/packages/mix_tailwinds/lib/src/tw_parser.dart b/packages/mix_tailwinds/lib/src/tw_parser.dart index 977bc16b96..97d4e46248 100644 --- a/packages/mix_tailwinds/lib/src/tw_parser.dart +++ b/packages/mix_tailwinds/lib/src/tw_parser.dart @@ -1464,9 +1464,7 @@ class TwParser { bool wantsFlex(Set tokens) { for (final token in tokens) { - // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] - final colonIdx = findLastColonOutsideBrackets(token); - final base = colonIdx >= 0 ? token.substring(colonIdx + 1) : token; + final base = baseTokenOutsideBrackets(token); if (base == 'flex' || base == 'flex-row' || base == 'flex-col') { return true; } @@ -1552,6 +1550,46 @@ class TwParser { return result; } + S _applyAccumulatedBoxLikeDecorations( + S styler, + _Accumulators accums, { + required Map> variants, + required S Function() newStyler, + required _StylerMerge merge, + required _BreakpointApplier applyBreakpoint, + required S Function(S styler, LinearGradientMix gradient) applyGradient, + required _BorderSideApplier top, + required _BorderSideApplier bottom, + required _BorderSideApplier left, + required _BorderSideApplier right, + }) { + var result = styler; + + final gradientMix = accums.baseGradient.toGradientMix( + config.gradientStrategy, + ); + if (gradientMix != null) { + result = _carryTransforms(result, applyGradient(result, gradientMix)); + } + + return _carryTransforms( + result, + _applyAccumulatedBorders( + result, + accums.baseBorder, + accums.variantBorders, + variants: variants, + newStyler: newStyler, + merge: merge, + applyBreakpoint: applyBreakpoint, + top: top, + bottom: bottom, + left: left, + right: right, + ), + ); + } + FlexBoxStyler _parseFlexDirect(String classNames) { final tokens = listTokens(classNames); @@ -1580,34 +1618,22 @@ class TwParser { styler = _carryTransforms(styler, styler.column()); } - // Apply accumulated gradient - final gradientMix = accums.baseGradient.toGradientMix( - config.gradientStrategy, - ); - if (gradientMix != null) { - styler = _carryTransforms(styler, styler.gradient(gradientMix)); - } - - // Apply accumulated borders - styler = _carryTransforms( + styler = _applyAccumulatedBoxLikeDecorations( styler, - _applyAccumulatedBorders( - styler, - accums.baseBorder, - accums.variantBorders, - variants: _flexVariants, - newStyler: FlexBoxStyler.new, - merge: (a, b) => a.merge(b), - applyBreakpoint: (b, bp, s) => b.onBreakpoint(bp, s), - top: (s, {required color, required width}) => - s.borderTop(color: color, width: width), - bottom: (s, {required color, required width}) => - s.borderBottom(color: color, width: width), - left: (s, {required color, required width}) => - s.borderLeft(color: color, width: width), - right: (s, {required color, required width}) => - s.borderRight(color: color, width: width), - ), + accums, + variants: _flexVariants, + newStyler: FlexBoxStyler.new, + merge: (a, b) => a.merge(b), + applyBreakpoint: (b, bp, s) => b.onBreakpoint(bp, s), + applyGradient: (s, gradient) => s.gradient(gradient), + top: (s, {required color, required width}) => + s.borderTop(color: color, width: width), + bottom: (s, {required color, required width}) => + s.borderBottom(color: color, width: width), + left: (s, {required color, required width}) => + s.borderLeft(color: color, width: width), + right: (s, {required color, required width}) => + s.borderRight(color: color, width: width), ); return _flushBaseTransforms(styler); @@ -1642,34 +1668,22 @@ class TwParser { _applyBoxToken, ); - // Apply accumulated gradient - final gradientMix = accums.baseGradient.toGradientMix( - config.gradientStrategy, - ); - if (gradientMix != null) { - styler = _carryTransforms(styler, styler.gradient(gradientMix)); - } - - // Apply accumulated borders - styler = _carryTransforms( + styler = _applyAccumulatedBoxLikeDecorations( styler, - _applyAccumulatedBorders( - styler, - accums.baseBorder, - accums.variantBorders, - variants: _boxVariants, - newStyler: BoxStyler.new, - merge: (a, b) => a.merge(b), - applyBreakpoint: (b, bp, s) => b.onBreakpoint(bp, s), - top: (s, {required color, required width}) => - s.borderTop(color: color, width: width), - bottom: (s, {required color, required width}) => - s.borderBottom(color: color, width: width), - left: (s, {required color, required width}) => - s.borderLeft(color: color, width: width), - right: (s, {required color, required width}) => - s.borderRight(color: color, width: width), - ), + accums, + variants: _boxVariants, + newStyler: BoxStyler.new, + merge: (a, b) => a.merge(b), + applyBreakpoint: (b, bp, s) => b.onBreakpoint(bp, s), + applyGradient: (s, gradient) => s.gradient(gradient), + top: (s, {required color, required width}) => + s.borderTop(color: color, width: width), + bottom: (s, {required color, required width}) => + s.borderBottom(color: color, width: width), + left: (s, {required color, required width}) => + s.borderLeft(color: color, width: width), + right: (s, {required color, required width}) => + s.borderRight(color: color, width: width), ); return _flushBaseTransforms(styler); @@ -1709,9 +1723,7 @@ class TwParser { var delay = Duration.zero; for (final token in tokens) { - // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] - final colonIdx = findLastColonOutsideBrackets(token); - final base = colonIdx >= 0 ? token.substring(colonIdx + 1) : token; + final base = baseTokenOutsideBrackets(token); if (_transitionTriggerTokens.contains(base)) { hasTransition = true; diff --git a/packages/mix_tailwinds/lib/src/tw_utils.dart b/packages/mix_tailwinds/lib/src/tw_utils.dart index 27e96cf820..5a40bb79aa 100644 --- a/packages/mix_tailwinds/lib/src/tw_utils.dart +++ b/packages/mix_tailwinds/lib/src/tw_utils.dart @@ -1,6 +1,8 @@ /// Shared helpers for Tailwind token parsing. library; +enum _ColonSearch { first, last } + /// Finds the first colon that's not inside square brackets. /// /// Used for iterative prefix stripping (e.g., parsing `md:hover:flex` one @@ -14,9 +16,13 @@ library; /// - `md:flex` → 2 (index of `:` after `md`) /// - `bg-[color:red]` → -1 (colon is inside brackets) /// - `md:bg-[color:red]` → 2 (first colon after `md`) -int findFirstColonOutsideBrackets(String token) { +int findFirstColonOutsideBrackets(String token) => + _findColonOutsideBrackets(token, _ColonSearch.first); + +int _findColonOutsideBrackets(String token, _ColonSearch search) { var bracketDepth = 0; - var firstColonOutside = -1; + var colonOutside = -1; + for (var i = 0; i < token.length; i++) { final c = token[i]; if (c == '[') { @@ -25,13 +31,18 @@ int findFirstColonOutsideBrackets(String token) { bracketDepth--; // Extra closing bracket - malformed if (bracketDepth < 0) return -1; - } else if (c == ':' && bracketDepth == 0 && firstColonOutside == -1) { - firstColonOutside = i; + } else if (c == ':' && bracketDepth == 0) { + if (search == _ColonSearch.first && colonOutside == -1) { + colonOutside = i; + } else if (search == _ColonSearch.last) { + colonOutside = i; + } } } + // Unclosed brackets - malformed, treat as no prefix if (bracketDepth != 0) return -1; - return firstColonOutside; + return colonOutside; } /// Finds the last colon that's not inside square brackets. @@ -47,24 +58,16 @@ int findFirstColonOutsideBrackets(String token) { /// - `md:hover:flex` → 8 (index of last `:` before `flex`) /// - `bg-[color:red]` → -1 (colon is inside brackets) /// - `md:bg-[color:red]` → 2 (only colon outside brackets) -int findLastColonOutsideBrackets(String token) { - var bracketDepth = 0; - var lastColonOutside = -1; - for (var i = 0; i < token.length; i++) { - final c = token[i]; - if (c == '[') { - bracketDepth++; - } else if (c == ']') { - bracketDepth--; - // Extra closing bracket - malformed - if (bracketDepth < 0) return -1; - } else if (c == ':' && bracketDepth == 0) { - lastColonOutside = i; // Keep updating to get the last one - } - } - // Unclosed brackets - malformed, treat as no prefix - if (bracketDepth != 0) return -1; - return lastColonOutside; +int findLastColonOutsideBrackets(String token) => + _findColonOutsideBrackets(token, _ColonSearch.last); + +/// Returns the token segment after the last variant prefix. +/// +/// Colons inside arbitrary-value brackets are ignored. Malformed bracket +/// structure is treated as having no prefix, matching [findLastColonOutsideBrackets]. +String baseTokenOutsideBrackets(String token) { + final colonIndex = findLastColonOutsideBrackets(token); + return colonIndex >= 0 ? token.substring(colonIndex + 1) : token; } double? parseFractionToken(String value) { diff --git a/packages/mix_tailwinds/lib/src/tw_widget.dart b/packages/mix_tailwinds/lib/src/tw_widget.dart index 003a824efb..57482fd3d3 100644 --- a/packages/mix_tailwinds/lib/src/tw_widget.dart +++ b/packages/mix_tailwinds/lib/src/tw_widget.dart @@ -26,9 +26,7 @@ final _whitespaceRegex = RegExp(r'\s+'); bool _hasBoxUtilities(String classNames) { final tokens = classNames.split(_whitespaceRegex); for (final token in tokens) { - // Strip variant prefixes (hover:, md:, etc.) to get base token - final colonIdx = findLastColonOutsideBrackets(token); - final base = colonIdx >= 0 ? token.substring(colonIdx + 1) : token; + final base = baseTokenOutsideBrackets(token); for (final prefix in _boxUtilityPrefixes) { if (base.startsWith(prefix) || base == prefix.replaceAll('-', '')) { @@ -46,8 +44,7 @@ EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { double? top, right, bottom, left; for (final token in tokens) { - final colonIdx = findLastColonOutsideBrackets(token); - final base = colonIdx >= 0 ? token.substring(colonIdx + 1) : token; + final base = baseTokenOutsideBrackets(token); if (base.startsWith('m-')) { final value = cfg.spaceOf(base.substring(2), fallback: double.nan); @@ -788,9 +785,7 @@ Widget _wrapWithFlexItemDecorators({ bool _needsFlexItemDecorators(Set tokens) { for (final token in tokens) { - // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] - final colonIdx = findLastColonOutsideBrackets(token); - final base = colonIdx >= 0 ? token.substring(colonIdx + 1) : token; + final base = baseTokenOutsideBrackets(token); if (base == 'w-full' || base == 'h-full') { return true; } diff --git a/packages/mix_tailwinds/test/tw_utils_test.dart b/packages/mix_tailwinds/test/tw_utils_test.dart index 129064e74d..7589bd432d 100644 --- a/packages/mix_tailwinds/test/tw_utils_test.dart +++ b/packages/mix_tailwinds/test/tw_utils_test.dart @@ -36,6 +36,7 @@ void main() { test('returns -1 for malformed brackets (extra closing)', () { expect(findFirstColonOutsideBrackets('bg-color:red]'), -1); expect(findFirstColonOutsideBrackets('md:]bg-red'), -1); + expect(findFirstColonOutsideBrackets('md:flex]'), -1); }); test('handles nested brackets', () { @@ -87,6 +88,7 @@ void main() { test('returns -1 for malformed brackets (extra closing)', () { expect(findLastColonOutsideBrackets('bg-color:red]'), -1); expect(findLastColonOutsideBrackets('md:]bg-red'), -1); + expect(findLastColonOutsideBrackets('md:flex]'), -1); }); test('handles empty string', () { @@ -94,6 +96,18 @@ void main() { }); }); + group('baseTokenOutsideBrackets', () { + test('returns the segment after the last outside-bracket colon', () { + expect(baseTokenOutsideBrackets('md:hover:flex'), 'flex'); + expect(baseTokenOutsideBrackets('md:bg-[color:red]'), 'bg-[color:red]'); + }); + + test('ignores bracketed colons and malformed brackets', () { + expect(baseTokenOutsideBrackets('bg-[color:red]'), 'bg-[color:red]'); + expect(baseTokenOutsideBrackets('md:flex]'), 'md:flex]'); + }); + }); + group('parseFractionToken', () { test('parses valid fractions', () { expect(parseFractionToken('1/2'), 0.5); From 2847df6fc611131a4e23a422c8a81c418a67f61f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 16 Jun 2026 13:20:17 -0400 Subject: [PATCH 07/11] fix(mix_tailwinds): resolve arbitrary margin values on text elements Route _extractMargin through TwResolver so P/H/text-element margins match the parser's handling of scale, prefixed, and arbitrary values (e.g. mb-[10px], mb-[1rem]). Negative margins are skipped because Padding's RenderPadding asserts non-negative insets, so emitting them would crash. Replaces the hand-rolled scale-lookup chain with the shared resolver and adds regression tests for arbitrary and negative margins. --- packages/mix_tailwinds/lib/src/tw_widget.dart | 83 +++++++++-------- .../mix_tailwinds/test/div_and_span_test.dart | 92 +++++++++++++++++++ 2 files changed, 136 insertions(+), 39 deletions(-) diff --git a/packages/mix_tailwinds/lib/src/tw_widget.dart b/packages/mix_tailwinds/lib/src/tw_widget.dart index 57482fd3d3..e1b52277d6 100644 --- a/packages/mix_tailwinds/lib/src/tw_widget.dart +++ b/packages/mix_tailwinds/lib/src/tw_widget.dart @@ -4,6 +4,7 @@ import 'package:mix/mix.dart'; import 'tw_config.dart'; import 'tw_parser.dart'; +import 'tw_semantic.dart'; import 'tw_utils.dart'; // ============================================================================= @@ -37,49 +38,53 @@ bool _hasBoxUtilities(String classNames) { return false; } -/// Extract margin value from tokens for a given prefix (e.g., 'mb-'). -/// Returns null if not found. +/// Extracts the margin [EdgeInsets] from [classNames] using the shared +/// [TwResolver], so text-element margins match the parser's handling of scale +/// (`mb-4`), prefixed (`md:mb-4`), and arbitrary (`mb-[10px]`) values. +/// +/// Returns null when no positive margin token is present. +/// +/// Limitations (intentional, see feedback Finding 5): +/// - Negative margins (`-mb-4`) are skipped. They are applied via [Padding], +/// whose `RenderPadding` asserts non-negative insets, so emitting them would +/// crash. True CSS negative-margin parity needs a transform-based strategy at +/// the box layer and is tracked separately. +/// - Variant prefixes are flattened — the margin applies unconditionally +/// regardless of `hover:`/`dark:`/breakpoint. Proper responsive/interaction +/// margin semantics are not yet modeled in the widget layer. EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { final tokens = classNames.split(_whitespaceRegex); + final resolver = TwResolver(cfg); double? top, right, bottom, left; for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); - - if (base.startsWith('m-')) { - final value = cfg.spaceOf(base.substring(2), fallback: double.nan); - if (!value.isNaN) { - top = right = bottom = left = value; - } - } else if (base.startsWith('mx-')) { - final value = cfg.spaceOf(base.substring(3), fallback: double.nan); - if (!value.isNaN) { - left = right = value; - } - } else if (base.startsWith('my-')) { - final value = cfg.spaceOf(base.substring(3), fallback: double.nan); - if (!value.isNaN) { - top = bottom = value; - } - } else if (base.startsWith('mt-')) { - final value = cfg.spaceOf(base.substring(3), fallback: double.nan); - if (!value.isNaN) { - top = value; - } - } else if (base.startsWith('mr-')) { - final value = cfg.spaceOf(base.substring(3), fallback: double.nan); - if (!value.isNaN) { - right = value; - } - } else if (base.startsWith('mb-')) { - final value = cfg.spaceOf(base.substring(3), fallback: double.nan); - if (!value.isNaN) { - bottom = value; - } - } else if (base.startsWith('ml-')) { - final value = cfg.spaceOf(base.substring(3), fallback: double.nan); - if (!value.isNaN) { - left = value; + final parsed = resolver.resolveToken(token); + if (parsed == null) continue; + + for (final cls in parsed) { + final value = cls.value; + // Only positive length-valued margin properties map to Padding insets. + // Negative values cannot render through Padding and are skipped. + if (value is! TwLengthValue || value.value < 0) continue; + final v = value.value; + + switch (cls.property) { + case TwProperty.margin: + top = right = bottom = left = v; + case TwProperty.marginX: + left = right = v; + case TwProperty.marginY: + top = bottom = v; + case TwProperty.marginTop: + top = v; + case TwProperty.marginRight: + right = v; + case TwProperty.marginBottom: + bottom = v; + case TwProperty.marginLeft: + left = v; + default: + break; } } } @@ -89,10 +94,10 @@ EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { } return EdgeInsets.only( + left: left ?? 0, top: top ?? 0, right: right ?? 0, bottom: bottom ?? 0, - left: left ?? 0, ); } diff --git a/packages/mix_tailwinds/test/div_and_span_test.dart b/packages/mix_tailwinds/test/div_and_span_test.dart index e41ec7e063..a0dbe38f0e 100644 --- a/packages/mix_tailwinds/test/div_and_span_test.dart +++ b/packages/mix_tailwinds/test/div_and_span_test.dart @@ -3216,6 +3216,98 @@ void main() { }); }); + // =========================================================================== + // P/H Margin Utilities — arbitrary values & negative-margin safety + // Regression coverage for resolver-backed _extractMargin (feedback Finding 5). + // =========================================================================== + + group('P/H margin utilities — arbitrary & negative', () { + testWidgets('P with mb-[10px] applies arbitrary bottom margin', ( + tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: P(text: 'Arb', classNames: 'text-sm mb-[10px]'), + ), + ); + + expect(find.byType(Padding), findsOneWidget); + final padding = tester.widget(find.byType(Padding)); + final edgeInsets = padding.padding as EdgeInsets; + expect(edgeInsets.bottom, 10); + expect(edgeInsets.top, 0); + expect(edgeInsets.left, 0); + expect(edgeInsets.right, 0); + }); + + testWidgets('P with mb-[1rem] applies arbitrary rem margin (16px)', ( + tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: P(text: 'Arb', classNames: 'mb-[1rem]'), + ), + ); + + expect(find.byType(Padding), findsOneWidget); + final padding = tester.widget(find.byType(Padding)); + final edgeInsets = padding.padding as EdgeInsets; + expect(edgeInsets.bottom, 16); + }); + + testWidgets('H1 with mx-[12px] applies arbitrary horizontal margin', ( + tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: H1(text: 'Heading', classNames: 'text-4xl mx-[12px]'), + ), + ); + + expect(find.byType(Padding), findsOneWidget); + final padding = tester.widget(find.byType(Padding)); + final edgeInsets = padding.padding as EdgeInsets; + expect(edgeInsets.left, 12); + expect(edgeInsets.right, 12); + }); + + testWidgets('P with -mb-4 does not crash and applies no Padding', ( + tester, + ) async { + // Negative margins cannot render through Padding (RenderPadding asserts + // non-negative insets), so they must be skipped, not applied. + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: P(text: 'Neg', classNames: '-mb-4'), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Padding), findsNothing); + }); + + testWidgets('P with -mb-4 mt-2 applies only the positive side', ( + tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: P(text: 'Mixed', classNames: '-mb-4 mt-2'), + ), + ); + + expect(find.byType(Padding), findsOneWidget); + final padding = tester.widget(find.byType(Padding)); + final edgeInsets = padding.padding as EdgeInsets; + expect(edgeInsets.top, 8); // mt-2 applied + expect(edgeInsets.bottom, 0); // -mb-4 skipped, not -16 + }); + }); + // =========================================================================== // H1-H6 Margin Utilities Tests // =========================================================================== From c747c2287db38199ec14e5f5415276a47c0bc6f7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 16 Jun 2026 14:04:30 -0400 Subject: [PATCH 08/11] refactor: reduce duplicate parser and tooling logic --- .../builders/spec_styler_class_builder.dart | 62 +-- .../src/core/helpers/widget_call_planner.dart | 62 +++ .../lib/src/styler_generator.dart | 61 +- ...ix_avoid_defining_tokens_within_scope.dart | 13 +- ...ix_avoid_defining_tokens_within_style.dart | 16 +- .../src/rules/mix_avoid_empty_variants.dart | 22 +- ...ix_max_number_of_attributes_per_style.dart | 20 +- .../mix_lint/lib/src/utils/ast_helpers.dart | 29 + packages/mix_tailwinds/lib/src/tw_parser.dart | 527 ++++++++++-------- 9 files changed, 411 insertions(+), 401 deletions(-) create mode 100644 packages/mix_lint/lib/src/utils/ast_helpers.dart diff --git a/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart b/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart index b07672e664..0f3c631eab 100644 --- a/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart +++ b/packages/mix_generator/lib/src/core/builders/spec_styler_class_builder.dart @@ -8,7 +8,6 @@ import '../checkers.dart'; import '../curated/styler_surface_metadata.dart'; import '../curated/type_metadata.dart'; import '../errors.dart'; -import '../helpers/type_hierarchy.dart'; import '../helpers/widget_call_planner.dart'; import '../models/annotation_config.dart'; import '../models/field_model.dart'; @@ -499,45 +498,14 @@ class SpecStylerClassBuilder { ); } - final widgetClass = fn.enclosingElement; - final widgetName = requireName( - widgetClass, - orFailWith: '@MixableSpec(target:) widget class must have a name.', - ); - final styleWidgetSupertype = findSupertypeMatching( - widgetClass.thisType, - styleWidgetChecker, + final widgetName = mixableSpecTargetWidgetName(fn); + validateMixableSpecTargetConstructor( + constructor: fn, + widgetName: widgetName, + specElement: specElement, + specName: specName, + anchor: specElement, ); - if (styleWidgetSupertype == null) { - fail( - specElement, - 'Widget $widgetName must extend StyleWidget<$specName> ' - 'to be used as @MixableSpec(target:).', - ); - } - - final widgetSpecArg = styleWidgetSupertype.typeArguments.first; - if (widgetSpecArg is! InterfaceType || - widgetSpecArg.element != specElement) { - fail( - specElement, - 'Spec generic mismatch: $specName annotated, but ' - '$widgetName extends StyleWidget<${widgetSpecArg.getDisplayString()}>.', - ); - } - - final optionalPositional = optionalPositionalNames(fn.formalParameters); - if (optionalPositional.isNotEmpty) { - fail( - specElement, - '@MixableSpec(target:) does not support optional positional target ' - 'constructor parameters on $widgetName: ' - '[${optionalPositional.join(', ')}].', - todo: 'Convert these parameters to required positional or named.', - ); - } - - _requireStyleParameter(fn, widgetName); final result = extractCallParams( fn, @@ -556,22 +524,6 @@ class SpecStylerClassBuilder { ); } - void _requireStyleParameter( - ConstructorElement constructor, - String widgetName, - ) { - for (final parameter in constructor.formalParameters) { - if (parameter.name == 'style' && parameter.isNamed) return; - } - - fail( - specElement, - '@MixableSpec(target:) requires $widgetName to expose a named ' - '`style` constructor parameter so the generated call() can pass ' - '`style: this`.', - ); - } - _CompoundConfig? _compoundConfig(List fields) { final surface = compoundStylerSurfaceFor(stylerName); if (surface == null) return null; diff --git a/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart b/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart index 870393f9bc..0cd2b1f7d0 100644 --- a/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart +++ b/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart @@ -2,10 +2,12 @@ library; import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; import '../checkers.dart'; import '../errors.dart'; import '../models/mix_widget_model.dart'; +import 'type_hierarchy.dart'; import 'library_scope.dart'; const reservedParamNames = { @@ -28,6 +30,66 @@ List optionalPositionalNames( .toList(); } +String mixableSpecTargetWidgetName(ConstructorElement constructor) { + return requireName( + constructor.enclosingElement, + orFailWith: '@MixableSpec(target:) widget class must have a name.', + ); +} + +void validateMixableSpecTargetConstructor({ + required ConstructorElement constructor, + required String widgetName, + required InterfaceElement specElement, + required String specName, + required Element anchor, +}) { + final styleWidgetSupertype = findSupertypeMatching( + constructor.enclosingElement.thisType, + styleWidgetChecker, + ); + if (styleWidgetSupertype == null) { + fail( + anchor, + 'Widget $widgetName must extend StyleWidget<$specName> ' + 'to be used as @MixableSpec(target:).', + ); + } + + final widgetSpecArg = styleWidgetSupertype.typeArguments.first; + if (widgetSpecArg is! InterfaceType || widgetSpecArg.element != specElement) { + fail( + anchor, + 'Spec generic mismatch: $specName annotated, but ' + '$widgetName extends StyleWidget<${widgetSpecArg.getDisplayString()}>.', + ); + } + + final optionalPositional = optionalPositionalNames( + constructor.formalParameters, + ); + if (optionalPositional.isNotEmpty) { + fail( + anchor, + '@MixableSpec(target:) does not support optional positional target ' + 'constructor parameters on $widgetName: ' + '[${optionalPositional.join(', ')}].', + todo: 'Convert these parameters to required positional or named.', + ); + } + + for (final parameter in constructor.formalParameters) { + if (parameter.name == 'style' && parameter.isNamed) return; + } + + fail( + anchor, + '@MixableSpec(target:) requires $widgetName to expose a named ' + '`style` constructor parameter so the generated call() can pass ' + '`style: this`.', + ); +} + ({List params, bool forwardsKey}) extractCallParams( ExecutableElement executable, { required Element anchor, diff --git a/packages/mix_generator/lib/src/styler_generator.dart b/packages/mix_generator/lib/src/styler_generator.dart index 3c9854cf6c..02cbba7632 100644 --- a/packages/mix_generator/lib/src/styler_generator.dart +++ b/packages/mix_generator/lib/src/styler_generator.dart @@ -95,10 +95,7 @@ class StylerGenerator extends GeneratorForAnnotation { } final widgetClass = fn.enclosingElement; - final widgetName = requireName( - widgetClass, - orFailWith: '@MixableSpec(target:) widget class must have a name.', - ); + final widgetName = mixableSpecTargetWidgetName(fn); final hiddenWidgetType = firstInvisibleTypeName( widgetClass.thisType, stylerElement.library, @@ -113,40 +110,13 @@ class StylerGenerator extends GeneratorForAnnotation { ); } - final styleWidgetSupertype = findSupertypeMatching( - widgetClass.thisType, - styleWidgetChecker, + validateMixableSpecTargetConstructor( + constructor: fn, + widgetName: widgetName, + specElement: specElement, + specName: specName, + anchor: specElement, ); - if (styleWidgetSupertype == null) { - fail( - specElement, - 'Widget $widgetName must extend StyleWidget<$specName> ' - 'to be used as @MixableSpec(target:).', - ); - } - - final widgetSpecArg = styleWidgetSupertype.typeArguments.first; - if (widgetSpecArg is! InterfaceType || - widgetSpecArg.element != specElement) { - fail( - specElement, - 'Spec generic mismatch: $specName annotated, but ' - '$widgetName extends StyleWidget<${widgetSpecArg.getDisplayString()}>.', - ); - } - - final optionalPositional = optionalPositionalNames(fn.formalParameters); - if (optionalPositional.isNotEmpty) { - fail( - specElement, - '@MixableSpec(target:) does not support optional positional target ' - 'constructor parameters on $widgetName: ' - '[${optionalPositional.join(', ')}].', - todo: 'Convert these parameters to required positional or named.', - ); - } - - _requireStyleParameter(fn, widgetName, specElement); final result = extractCallParams( fn, @@ -166,23 +136,6 @@ class StylerGenerator extends GeneratorForAnnotation { ); } - void _requireStyleParameter( - ConstructorElement constructor, - String widgetName, - Element anchor, - ) { - for (final parameter in constructor.formalParameters) { - if (parameter.name == 'style' && parameter.isNamed) return; - } - - fail( - anchor, - '@MixableSpec(target:) requires $widgetName to expose a named ' - '`style` constructor parameter so the generated call() can pass ' - '`style: this`.', - ); - } - @override String generateForAnnotatedElement( Element element, diff --git a/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_scope.dart b/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_scope.dart index 4a6598b2ac..ff02ea014d 100644 --- a/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_scope.dart +++ b/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_scope.dart @@ -5,6 +5,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; +import '../utils/ast_helpers.dart'; import '../utils/type_helpers.dart'; class MixAvoidDefiningTokensWithinScope extends AnalysisRule { @@ -46,19 +47,13 @@ class _Visitor extends SimpleAstVisitor { void visitInstanceCreationExpression(InstanceCreationExpression node) { if (!isMixTokenType(node.staticType)) return; - // Walk up the AST to find a MixScope ancestor. - // Stop at statement/declaration boundaries. - AstNode? current = node.parent; - while (current != null && - current is! Statement && - current is! Declaration) { - if (current is InstanceCreationExpression && - isMixScopeType(current.staticType)) { + for (final ancestor in ancestorsBeforeStatementOrDeclaration(node)) { + if (ancestor is InstanceCreationExpression && + isMixScopeType(ancestor.staticType)) { rule.reportAtNode(node); return; } - current = current.parent; } } } diff --git a/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_style.dart b/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_style.dart index 606524733f..fd5fd5dab5 100644 --- a/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_style.dart +++ b/packages/mix_lint/lib/src/rules/mix_avoid_defining_tokens_within_style.dart @@ -5,6 +5,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; +import '../utils/ast_helpers.dart'; import '../utils/type_helpers.dart'; class MixAvoidDefiningTokensWithinStyle extends AnalysisRule { @@ -45,24 +46,19 @@ class _Visitor extends SimpleAstVisitor { void visitInstanceCreationExpression(InstanceCreationExpression node) { if (!isMixTokenType(node.staticType)) return; - // Walk up the AST to see if this token is inside a Styler method chain. - // Stop at statement/declaration boundaries. - AstNode? current = node.parent; - while (current != null && - current is! Statement && - current is! Declaration) { - if (current is MethodInvocation && isMixStylerType(current.staticType)) { + for (final ancestor in ancestorsBeforeStatementOrDeclaration(node)) { + if (ancestor is MethodInvocation && + isMixStylerType(ancestor.staticType)) { rule.reportAtNode(node); return; } - if (current is InstanceCreationExpression && - isMixStylerType(current.staticType)) { + if (ancestor is InstanceCreationExpression && + isMixStylerType(ancestor.staticType)) { rule.reportAtNode(node); return; } - current = current.parent; } } } diff --git a/packages/mix_lint/lib/src/rules/mix_avoid_empty_variants.dart b/packages/mix_lint/lib/src/rules/mix_avoid_empty_variants.dart index f04399042a..9dcf2eecf2 100644 --- a/packages/mix_lint/lib/src/rules/mix_avoid_empty_variants.dart +++ b/packages/mix_lint/lib/src/rules/mix_avoid_empty_variants.dart @@ -5,6 +5,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; +import '../utils/ast_helpers.dart'; import '../utils/type_helpers.dart'; class MixAvoidEmptyVariants extends AnalysisRule { @@ -41,30 +42,11 @@ class _Visitor extends SimpleAstVisitor { const _Visitor(this.rule); - /// Collects the linear chain of [MethodInvocation]s directly following [ice]. - /// Stops when the chain branches into non-target contexts (e.g. argument lists). - List _collectChain(InstanceCreationExpression ice) { - final chain = []; - AstNode? current = ice; - - while (true) { - final parent = current!.parent; - if (parent is MethodInvocation && parent.target == current) { - chain.add(parent); - current = parent; - } else { - break; - } - } - - return chain; - } - @override void visitInstanceCreationExpression(InstanceCreationExpression node) { if (!isMixStylerType(node.staticType)) return; - final chain = _collectChain(node); + final chain = collectDirectMethodChain(node); if (chain.isEmpty) return; final allVariants = chain.every( diff --git a/packages/mix_lint/lib/src/rules/mix_max_number_of_attributes_per_style.dart b/packages/mix_lint/lib/src/rules/mix_max_number_of_attributes_per_style.dart index a90b2938eb..4dd131f897 100644 --- a/packages/mix_lint/lib/src/rules/mix_max_number_of_attributes_per_style.dart +++ b/packages/mix_lint/lib/src/rules/mix_max_number_of_attributes_per_style.dart @@ -5,6 +5,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; +import '../utils/ast_helpers.dart'; import '../utils/type_helpers.dart'; class MixMaxNumberOfAttributesPerStyle extends AnalysisRule { @@ -47,28 +48,11 @@ class _Visitor extends SimpleAstVisitor { const _Visitor(this.rule, this.maxNumber); - List _collectChain(InstanceCreationExpression ice) { - final chain = []; - AstNode? current = ice; - - while (true) { - final parent = current!.parent; - if (parent is MethodInvocation && parent.target == current) { - chain.add(parent); - current = parent; - } else { - break; - } - } - - return chain; - } - @override void visitInstanceCreationExpression(InstanceCreationExpression node) { if (!isMixStylerType(node.staticType)) return; - final chain = _collectChain(node); + final chain = collectDirectMethodChain(node); if (chain.length > maxNumber) { rule.reportAtNode(node); } diff --git a/packages/mix_lint/lib/src/utils/ast_helpers.dart b/packages/mix_lint/lib/src/utils/ast_helpers.dart new file mode 100644 index 0000000000..c813274b0e --- /dev/null +++ b/packages/mix_lint/lib/src/utils/ast_helpers.dart @@ -0,0 +1,29 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +/// Collects the linear method chain directly following [root]. +/// +/// Stops when the chain branches into another context, such as an argument list. +List collectDirectMethodChain( + InstanceCreationExpression root, +) { + final chain = []; + AstNode current = root; + + while (true) { + final parent = current.parent; + if (parent is! MethodInvocation || parent.target != current) break; + + chain.add(parent); + current = parent; + } + + return chain; +} + +Iterable ancestorsBeforeStatementOrDeclaration(AstNode node) sync* { + AstNode? current = node.parent; + while (current != null && current is! Statement && current is! Declaration) { + yield current; + current = current.parent; + } +} diff --git a/packages/mix_tailwinds/lib/src/tw_parser.dart b/packages/mix_tailwinds/lib/src/tw_parser.dart index 97d4e46248..715b21b643 100644 --- a/packages/mix_tailwinds/lib/src/tw_parser.dart +++ b/packages/mix_tailwinds/lib/src/tw_parser.dart @@ -898,104 +898,273 @@ S _accumulateTranslateY( return styler; } -FlexBoxStyler _applyPropertyToFlex( - FlexBoxStyler styler, +typedef _LengthStylerApplier = S Function(S styler, double value); +typedef _ColorStylerApplier = S Function(S styler, Color value); +typedef _ClipStylerApplier = S Function(S styler, Clip value); +typedef _ModifierStylerApplier = + S Function(S styler, WidgetModifierConfig value); +typedef _TextStyleStylerApplier = S Function(S styler, TextStyleMix value); +typedef _ElevationStylerApplier = + S Function(S styler, ElevationShadow value); +typedef _BoxShadowsStylerApplier = + S Function(S styler, List value); + +final class _BoxLikeStylerOps { + const _BoxLikeStylerOps({ + required this.paddingAll, + required this.paddingX, + required this.paddingY, + required this.paddingTop, + required this.paddingRight, + required this.paddingBottom, + required this.paddingLeft, + required this.marginAll, + required this.marginX, + required this.marginY, + required this.marginTop, + required this.marginRight, + required this.marginBottom, + required this.marginLeft, + required this.width, + required this.height, + required this.minWidth, + required this.minHeight, + required this.maxWidth, + required this.maxHeight, + required this.color, + required this.borderRounded, + required this.borderRoundedTop, + required this.borderRoundedBottom, + required this.borderRoundedLeft, + required this.borderRoundedRight, + required this.borderRoundedTopLeft, + required this.borderRoundedTopRight, + required this.borderRoundedBottomLeft, + required this.borderRoundedBottomRight, + required this.wrap, + required this.clipBehavior, + required this.defaultTextStyle, + required this.elevation, + required this.boxShadows, + }); + + final _LengthStylerApplier paddingAll; + final _LengthStylerApplier paddingX; + final _LengthStylerApplier paddingY; + final _LengthStylerApplier paddingTop; + final _LengthStylerApplier paddingRight; + final _LengthStylerApplier paddingBottom; + final _LengthStylerApplier paddingLeft; + final _LengthStylerApplier marginAll; + final _LengthStylerApplier marginX; + final _LengthStylerApplier marginY; + final _LengthStylerApplier marginTop; + final _LengthStylerApplier marginRight; + final _LengthStylerApplier marginBottom; + final _LengthStylerApplier marginLeft; + final _LengthStylerApplier width; + final _LengthStylerApplier height; + final _LengthStylerApplier minWidth; + final _LengthStylerApplier minHeight; + final _LengthStylerApplier maxWidth; + final _LengthStylerApplier maxHeight; + final _ColorStylerApplier color; + final _LengthStylerApplier borderRounded; + final _LengthStylerApplier borderRoundedTop; + final _LengthStylerApplier borderRoundedBottom; + final _LengthStylerApplier borderRoundedLeft; + final _LengthStylerApplier borderRoundedRight; + final _LengthStylerApplier borderRoundedTopLeft; + final _LengthStylerApplier borderRoundedTopRight; + final _LengthStylerApplier borderRoundedBottomLeft; + final _LengthStylerApplier borderRoundedBottomRight; + final _ModifierStylerApplier wrap; + final _ClipStylerApplier clipBehavior; + final _TextStyleStylerApplier defaultTextStyle; + final _ElevationStylerApplier elevation; + final _BoxShadowsStylerApplier boxShadows; +} + +final _flexBoxOps = _BoxLikeStylerOps( + paddingAll: (style, value) => style.paddingAll(value), + paddingX: (style, value) => style.paddingX(value), + paddingY: (style, value) => style.paddingY(value), + paddingTop: (style, value) => style.paddingTop(value), + paddingRight: (style, value) => style.paddingRight(value), + paddingBottom: (style, value) => style.paddingBottom(value), + paddingLeft: (style, value) => style.paddingLeft(value), + marginAll: (style, value) => style.marginAll(value), + marginX: (style, value) => style.marginX(value), + marginY: (style, value) => style.marginY(value), + marginTop: (style, value) => style.marginTop(value), + marginRight: (style, value) => style.marginRight(value), + marginBottom: (style, value) => style.marginBottom(value), + marginLeft: (style, value) => style.marginLeft(value), + width: (style, value) => style.width(value), + height: (style, value) => style.height(value), + minWidth: (style, value) => style.minWidth(value), + minHeight: (style, value) => style.minHeight(value), + maxWidth: (style, value) => style.maxWidth(value), + maxHeight: (style, value) => style.maxHeight(value), + color: (style, value) => style.color(value), + borderRounded: (style, value) => style.borderRounded(value), + borderRoundedTop: (style, value) => style.borderRoundedTop(value), + borderRoundedBottom: (style, value) => style.borderRoundedBottom(value), + borderRoundedLeft: (style, value) => style.borderRoundedLeft(value), + borderRoundedRight: (style, value) => style.borderRoundedRight(value), + borderRoundedTopLeft: (style, value) => style.borderRoundedTopLeft(value), + borderRoundedTopRight: (style, value) => style.borderRoundedTopRight(value), + borderRoundedBottomLeft: (style, value) => + style.borderRoundedBottomLeft(value), + borderRoundedBottomRight: (style, value) => + style.borderRoundedBottomRight(value), + wrap: (style, value) => style.wrap(value), + clipBehavior: (style, value) => style.clipBehavior(value), + defaultTextStyle: (style, value) => style.wrapDefaultTextStyle(value), + elevation: (style, value) => style.elevation(value), + boxShadows: (style, value) => style.boxShadows(value), +); + +final _boxOps = _BoxLikeStylerOps( + paddingAll: (style, value) => style.paddingAll(value), + paddingX: (style, value) => style.paddingX(value), + paddingY: (style, value) => style.paddingY(value), + paddingTop: (style, value) => style.paddingTop(value), + paddingRight: (style, value) => style.paddingRight(value), + paddingBottom: (style, value) => style.paddingBottom(value), + paddingLeft: (style, value) => style.paddingLeft(value), + marginAll: (style, value) => style.marginAll(value), + marginX: (style, value) => style.marginX(value), + marginY: (style, value) => style.marginY(value), + marginTop: (style, value) => style.marginTop(value), + marginRight: (style, value) => style.marginRight(value), + marginBottom: (style, value) => style.marginBottom(value), + marginLeft: (style, value) => style.marginLeft(value), + width: (style, value) => style.width(value), + height: (style, value) => style.height(value), + minWidth: (style, value) => style.minWidth(value), + minHeight: (style, value) => style.minHeight(value), + maxWidth: (style, value) => style.maxWidth(value), + maxHeight: (style, value) => style.maxHeight(value), + color: (style, value) => style.color(value), + borderRounded: (style, value) => style.borderRounded(value), + borderRoundedTop: (style, value) => style.borderRoundedTop(value), + borderRoundedBottom: (style, value) => style.borderRoundedBottom(value), + borderRoundedLeft: (style, value) => style.borderRoundedLeft(value), + borderRoundedRight: (style, value) => style.borderRoundedRight(value), + borderRoundedTopLeft: (style, value) => style.borderRoundedTopLeft(value), + borderRoundedTopRight: (style, value) => style.borderRoundedTopRight(value), + borderRoundedBottomLeft: (style, value) => + style.borderRoundedBottomLeft(value), + borderRoundedBottomRight: (style, value) => + style.borderRoundedBottomRight(value), + wrap: (style, value) => style.wrap(value), + clipBehavior: (style, value) => style.clipBehavior(value), + defaultTextStyle: (style, value) => style.wrapDefaultTextStyle(value), + elevation: (style, value) => style.elevation(value), + boxShadows: (style, value) => style.boxShadows(value), +); + +S _applySharedBoxLikeProperty( + S styler, TwProperty property, TwValue value, - TwConfig config, _TransformAccumTracker transformTracker, + _BoxLikeStylerOps ops, ) { return switch (property) { // Spacing - TwProperty.padding => styler.paddingAll((value as TwLengthValue).value), - TwProperty.paddingX => styler.paddingX((value as TwLengthValue).value), - TwProperty.paddingY => styler.paddingY((value as TwLengthValue).value), - TwProperty.paddingTop => styler.paddingTop((value as TwLengthValue).value), - TwProperty.paddingRight => styler.paddingRight( + TwProperty.padding => ops.paddingAll( + styler, (value as TwLengthValue).value, ), - TwProperty.paddingBottom => styler.paddingBottom( + TwProperty.paddingX => ops.paddingX(styler, (value as TwLengthValue).value), + TwProperty.paddingY => ops.paddingY(styler, (value as TwLengthValue).value), + TwProperty.paddingTop => ops.paddingTop( + styler, (value as TwLengthValue).value, ), - TwProperty.paddingLeft => styler.paddingLeft( + TwProperty.paddingRight => ops.paddingRight( + styler, (value as TwLengthValue).value, ), - TwProperty.margin => styler.marginAll((value as TwLengthValue).value), - TwProperty.marginX => styler.marginX((value as TwLengthValue).value), - TwProperty.marginY => styler.marginY((value as TwLengthValue).value), - TwProperty.marginTop => styler.marginTop((value as TwLengthValue).value), - TwProperty.marginRight => styler.marginRight( + TwProperty.paddingBottom => ops.paddingBottom( + styler, (value as TwLengthValue).value, ), - TwProperty.marginBottom => styler.marginBottom( + TwProperty.paddingLeft => ops.paddingLeft( + styler, + (value as TwLengthValue).value, + ), + TwProperty.margin => ops.marginAll(styler, (value as TwLengthValue).value), + TwProperty.marginX => ops.marginX(styler, (value as TwLengthValue).value), + TwProperty.marginY => ops.marginY(styler, (value as TwLengthValue).value), + TwProperty.marginTop => ops.marginTop( + styler, + (value as TwLengthValue).value, + ), + TwProperty.marginRight => ops.marginRight( + styler, + (value as TwLengthValue).value, + ), + TwProperty.marginBottom => ops.marginBottom( + styler, + (value as TwLengthValue).value, + ), + TwProperty.marginLeft => ops.marginLeft( + styler, (value as TwLengthValue).value, ), - TwProperty.marginLeft => styler.marginLeft((value as TwLengthValue).value), - TwProperty.gap => styler.spacing((value as TwLengthValue).value), // Sizing (only apply length values with px unit; enum values and % handled by widget layer) - TwProperty.width => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.width(value.value) - : styler, - TwProperty.height => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.height(value.value) - : styler, - TwProperty.minWidth => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.minWidth(value.value) - : styler, - TwProperty.minHeight => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.minHeight(value.value) - : styler, - TwProperty.maxWidth => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.maxWidth(value.value) - : styler, - TwProperty.maxHeight => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.maxHeight(value.value) - : styler, - - // Layout - TwProperty.display => _applyFlexDisplay(styler, value), - TwProperty.flexDirection => _applyFlexDirection(styler, value), - TwProperty.alignItems => _applyAlignItems(styler, value), - TwProperty.justifyContent => styler.mainAxisAlignment( - (value as TwEnumValue).value, - ), + TwProperty.width => _applyPxLength(styler, value, ops.width), + TwProperty.height => _applyPxLength(styler, value, ops.height), + TwProperty.minWidth => _applyPxLength(styler, value, ops.minWidth), + TwProperty.minHeight => _applyPxLength(styler, value, ops.minHeight), + TwProperty.maxWidth => _applyPxLength(styler, value, ops.maxWidth), + TwProperty.maxHeight => _applyPxLength(styler, value, ops.maxHeight), // Background - TwProperty.backgroundColor => styler.color((value as TwColorValue).color), + TwProperty.backgroundColor => ops.color( + styler, + (value as TwColorValue).color, + ), // Border radius - TwProperty.borderRadius => styler.borderRounded( + TwProperty.borderRadius => ops.borderRounded( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusTop => styler.borderRoundedTop( + TwProperty.borderRadiusTop => ops.borderRoundedTop( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusBottom => styler.borderRoundedBottom( + TwProperty.borderRadiusBottom => ops.borderRoundedBottom( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusLeft => styler.borderRoundedLeft( + TwProperty.borderRadiusLeft => ops.borderRoundedLeft( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusRight => styler.borderRoundedRight( + TwProperty.borderRadiusRight => ops.borderRoundedRight( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusTopLeft => styler.borderRoundedTopLeft( + TwProperty.borderRadiusTopLeft => ops.borderRoundedTopLeft( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusTopRight => styler.borderRoundedTopRight( + TwProperty.borderRadiusTopRight => ops.borderRoundedTopRight( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusBottomLeft => styler.borderRoundedBottomLeft( + TwProperty.borderRadiusBottomLeft => ops.borderRoundedBottomLeft( + styler, (value as TwLengthValue).value, ), - TwProperty.borderRadiusBottomRight => styler.borderRoundedBottomRight( + TwProperty.borderRadiusBottomRight => ops.borderRoundedBottomRight( + styler, (value as TwLengthValue).value, ), @@ -1022,30 +1191,72 @@ FlexBoxStyler _applyPropertyToFlex( ), // Effects - TwProperty.blur => styler.wrap( + TwProperty.blur => ops.wrap( + styler, WidgetModifierConfig.blur((value as TwLengthValue).value), ), - TwProperty.boxShadow => _applyFlexShadow(styler, value), - TwProperty.clipBehavior => styler.clipBehavior( + TwProperty.boxShadow => _applyShadowValue( + styler, + value, + applyElevation: ops.elevation, + applyBoxShadows: ops.boxShadows, + ), + TwProperty.clipBehavior => ops.clipBehavior( + styler, (value as TwEnumValue).value, ), // Typography (propagates via DefaultTextStyle) - TwProperty.textColor => styler.wrapDefaultTextStyle( + TwProperty.textColor => ops.defaultTextStyle( + styler, TextStyleMix().color((value as TwColorValue).color), ), - TwProperty.fontSize => styler.wrapDefaultTextStyle( + TwProperty.fontSize => ops.defaultTextStyle( + styler, TextStyleMix().fontSize((value as TwLengthValue).value), ), - TwProperty.fontWeight => styler.wrapDefaultTextStyle( + TwProperty.fontWeight => ops.defaultTextStyle( + styler, TextStyleMix().fontWeight((value as TwEnumValue).value), ), - TwProperty.textShadow => _applyFlexTextShadow(styler, value), + TwProperty.textShadow => _applyDefaultTextShadow(styler, value, ops), _ => styler, }; } +S _applyPxLength(S styler, TwValue value, _LengthStylerApplier apply) { + if (value is TwLengthValue && value.unit == TwUnit.px) { + return apply(styler, value.value); + } + + return styler; +} + +FlexBoxStyler _applyPropertyToFlex( + FlexBoxStyler styler, + TwProperty property, + TwValue value, + _TransformAccumTracker transformTracker, +) { + return switch (property) { + TwProperty.gap => styler.spacing((value as TwLengthValue).value), + TwProperty.display => _applyFlexDisplay(styler, value), + TwProperty.flexDirection => _applyFlexDirection(styler, value), + TwProperty.alignItems => _applyAlignItems(styler, value), + TwProperty.justifyContent => styler.mainAxisAlignment( + (value as TwEnumValue).value, + ), + _ => _applySharedBoxLikeProperty( + styler, + property, + value, + transformTracker, + _flexBoxOps, + ), + }; +} + FlexBoxStyler _applyFlexDisplay(FlexBoxStyler styler, TwValue value) { if (value is TwEnumValue && value.value == 'flex') { return styler.row(); @@ -1076,160 +1287,18 @@ FlexBoxStyler _applyAlignItems(FlexBoxStyler styler, TwValue value) { return styler; } -FlexBoxStyler _applyFlexShadow(FlexBoxStyler styler, TwValue value) { - return _applyShadowValue( - styler, - value, - applyElevation: (style, elevation) => style.elevation(elevation), - applyBoxShadows: (style, shadows) => style.boxShadows(shadows), - ); -} - BoxStyler _applyPropertyToBox( BoxStyler styler, TwProperty property, TwValue value, - TwConfig config, _TransformAccumTracker transformTracker, ) { - return switch (property) { - // Spacing - TwProperty.padding => styler.paddingAll((value as TwLengthValue).value), - TwProperty.paddingX => styler.paddingX((value as TwLengthValue).value), - TwProperty.paddingY => styler.paddingY((value as TwLengthValue).value), - TwProperty.paddingTop => styler.paddingTop((value as TwLengthValue).value), - TwProperty.paddingRight => styler.paddingRight( - (value as TwLengthValue).value, - ), - TwProperty.paddingBottom => styler.paddingBottom( - (value as TwLengthValue).value, - ), - TwProperty.paddingLeft => styler.paddingLeft( - (value as TwLengthValue).value, - ), - TwProperty.margin => styler.marginAll((value as TwLengthValue).value), - TwProperty.marginX => styler.marginX((value as TwLengthValue).value), - TwProperty.marginY => styler.marginY((value as TwLengthValue).value), - TwProperty.marginTop => styler.marginTop((value as TwLengthValue).value), - TwProperty.marginRight => styler.marginRight( - (value as TwLengthValue).value, - ), - TwProperty.marginBottom => styler.marginBottom( - (value as TwLengthValue).value, - ), - TwProperty.marginLeft => styler.marginLeft((value as TwLengthValue).value), - - // Sizing (only apply length values with px unit; enum values and % handled by widget layer) - TwProperty.width => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.width(value.value) - : styler, - TwProperty.height => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.height(value.value) - : styler, - TwProperty.minWidth => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.minWidth(value.value) - : styler, - TwProperty.minHeight => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.minHeight(value.value) - : styler, - TwProperty.maxWidth => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.maxWidth(value.value) - : styler, - TwProperty.maxHeight => - value is TwLengthValue && value.unit == TwUnit.px - ? styler.maxHeight(value.value) - : styler, - - // Background - TwProperty.backgroundColor => styler.color((value as TwColorValue).color), - - // Border radius - TwProperty.borderRadius => styler.borderRounded( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusTop => styler.borderRoundedTop( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusBottom => styler.borderRoundedBottom( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusLeft => styler.borderRoundedLeft( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusRight => styler.borderRoundedRight( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusTopLeft => styler.borderRoundedTopLeft( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusTopRight => styler.borderRoundedTopRight( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusBottomLeft => styler.borderRoundedBottomLeft( - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusBottomRight => styler.borderRoundedBottomRight( - (value as TwLengthValue).value, - ), - - // Transform - TwProperty.scale => _accumulateScale( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - TwProperty.rotate => _accumulateRotate( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - TwProperty.translateX => _accumulateTranslateX( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - TwProperty.translateY => _accumulateTranslateY( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - - // Effects - TwProperty.blur => styler.wrap( - WidgetModifierConfig.blur((value as TwLengthValue).value), - ), - TwProperty.boxShadow => _applyBoxShadow(styler, value), - TwProperty.clipBehavior => styler.clipBehavior( - (value as TwEnumValue).value, - ), - - // Typography (propagates via DefaultTextStyle) - TwProperty.textColor => styler.wrapDefaultTextStyle( - TextStyleMix().color((value as TwColorValue).color), - ), - TwProperty.fontSize => styler.wrapDefaultTextStyle( - TextStyleMix().fontSize((value as TwLengthValue).value), - ), - TwProperty.fontWeight => styler.wrapDefaultTextStyle( - TextStyleMix().fontWeight((value as TwEnumValue).value), - ), - TwProperty.textShadow => _applyBoxTextShadow(styler, value), - - _ => styler, - }; -} - -BoxStyler _applyBoxShadow(BoxStyler styler, TwValue value) { - return _applyShadowValue( + return _applySharedBoxLikeProperty( styler, + property, value, - applyElevation: (style, elevation) => style.elevation(elevation), - applyBoxShadows: (style, shadows) => style.boxShadows(shadows), + transformTracker, + _boxOps, ); } @@ -1366,16 +1435,14 @@ List? _resolveTextShadowMixes(TwValue value) { return null; } -FlexBoxStyler _applyFlexTextShadow(FlexBoxStyler styler, TwValue value) { - final shadows = _resolveTextShadowMixes(value); - if (shadows == null) return styler; - return styler.wrapDefaultTextStyle(TextStyleMix().shadows(shadows)); -} - -BoxStyler _applyBoxTextShadow(BoxStyler styler, TwValue value) { +S _applyDefaultTextShadow( + S styler, + TwValue value, + _BoxLikeStylerOps ops, +) { final shadows = _resolveTextShadowMixes(value); if (shadows == null) return styler; - return styler.wrapDefaultTextStyle(TextStyleMix().shadows(shadows)); + return ops.defaultTextStyle(styler, TextStyleMix().shadows(shadows)); } TextStyler _applyTextShadow(TextStyler styler, TwValue value) { @@ -1934,13 +2001,8 @@ class TwParser { return _applyResolvedProperties( styler, parsed, - (current, property, value) => _applyPropertyToFlex( - current, - property, - value, - config, - _transformTracker, - ), + (current, property, value) => + _applyPropertyToFlex(current, property, value, _transformTracker), ); } @@ -1966,13 +2028,8 @@ class TwParser { return _applyResolvedProperties( styler, parsed, - (current, property, value) => _applyPropertyToBox( - current, - property, - value, - config, - _transformTracker, - ), + (current, property, value) => + _applyPropertyToBox(current, property, value, _transformTracker), ); } From 9773953c4472afb0bbdea24b2e33a1accf8b093c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 17 Jun 2026 11:30:37 -0400 Subject: [PATCH 09/11] refactor(mix_tailwinds): restructure parser into modular components Extract parser, theme, and translate layers into separate directories, introduce typed tw_types, and add parser purity and variants test suites. Remove legacy schema payload and semantic files in favour of the new layout. --- packages/mix_tailwinds/CLAUDE.md | 32 +- packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md | 20 +- packages/mix_tailwinds/lib/mix_tailwinds.dart | 3 +- .../lib/src/parser/candidate_parser.dart | 466 ++++ .../src/parser/data/parser_registry.g.dart | 724 ++++++ .../lib/src/parser/diagnostics.dart | 80 + .../mix_tailwinds/lib/src/parser/model.dart | 241 ++ .../lib/src/parser/parser_registry.dart | 79 + .../lib/src/theme/data/default_theme.g.dart | 190 ++ .../lib/src/translate/tw_accumulators.dart | 141 ++ .../lib/src/translate/tw_gradient.dart | 157 ++ .../lib/src/translate/tw_presets.dart | 114 + .../lib/src/translate/tw_routing.dart | 73 + .../lib/src/translate/tw_target.dart | 57 + .../lib/src/translate/tw_translator.dart | 1285 ++++++++++ packages/mix_tailwinds/lib/src/tw_config.dart | 174 +- packages/mix_tailwinds/lib/src/tw_parser.dart | 2230 +---------------- .../lib/src/tw_schema_payload.dart | 643 ----- .../lib/src/tw_schema_payload_policy.dart | 305 --- .../mix_tailwinds/lib/src/tw_semantic.dart | 1426 ----------- packages/mix_tailwinds/lib/src/tw_types.dart | 1 + packages/mix_tailwinds/lib/src/tw_widget.dart | 105 +- .../mix_tailwinds/test/div_and_span_test.dart | 137 +- .../test/fixtures/candidate-probes.json | 1787 +++++++++++++ .../test/fixtures/candidates.txt | 49 + .../test/parser/candidate_parser_test.dart | 152 ++ .../test/parser_purity_test.dart | 18 + .../test/schema_payload_contract_test.dart | 37 - .../mix_tailwinds/test/tw_config_test.dart | 4 +- .../test/tw_parser_characterization_test.dart | 184 +- .../mix_tailwinds/test/tw_resolver_test.dart | 141 -- .../mix_tailwinds/test/variants_test.dart | 92 + packages/mix_tailwinds/tool/gen_registry.dart | 225 ++ 33 files changed, 6295 insertions(+), 5077 deletions(-) create mode 100644 packages/mix_tailwinds/lib/src/parser/candidate_parser.dart create mode 100644 packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart create mode 100644 packages/mix_tailwinds/lib/src/parser/diagnostics.dart create mode 100644 packages/mix_tailwinds/lib/src/parser/model.dart create mode 100644 packages/mix_tailwinds/lib/src/parser/parser_registry.dart create mode 100644 packages/mix_tailwinds/lib/src/theme/data/default_theme.g.dart create mode 100644 packages/mix_tailwinds/lib/src/translate/tw_accumulators.dart create mode 100644 packages/mix_tailwinds/lib/src/translate/tw_gradient.dart create mode 100644 packages/mix_tailwinds/lib/src/translate/tw_presets.dart create mode 100644 packages/mix_tailwinds/lib/src/translate/tw_routing.dart create mode 100644 packages/mix_tailwinds/lib/src/translate/tw_target.dart create mode 100644 packages/mix_tailwinds/lib/src/translate/tw_translator.dart delete mode 100644 packages/mix_tailwinds/lib/src/tw_schema_payload.dart delete mode 100644 packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart delete mode 100644 packages/mix_tailwinds/lib/src/tw_semantic.dart create mode 100644 packages/mix_tailwinds/lib/src/tw_types.dart create mode 100644 packages/mix_tailwinds/test/fixtures/candidate-probes.json create mode 100644 packages/mix_tailwinds/test/fixtures/candidates.txt create mode 100644 packages/mix_tailwinds/test/parser/candidate_parser_test.dart create mode 100644 packages/mix_tailwinds/test/parser_purity_test.dart delete mode 100644 packages/mix_tailwinds/test/tw_resolver_test.dart create mode 100644 packages/mix_tailwinds/test/variants_test.dart create mode 100644 packages/mix_tailwinds/tool/gen_registry.dart diff --git a/packages/mix_tailwinds/CLAUDE.md b/packages/mix_tailwinds/CLAUDE.md index 51926e323f..078509924e 100644 --- a/packages/mix_tailwinds/CLAUDE.md +++ b/packages/mix_tailwinds/CLAUDE.md @@ -10,7 +10,10 @@ Build a **1:1 mapping** from **Tailwind CSS utilities → Mix stylers/widgets** ## Project Map -- `lib/src/tw_parser.dart` — token parsing → Mix styles/specs +- `lib/src/parser/` — pure Tailwind candidate parser and generated utility registry +- `lib/src/translate/` — candidate → Mix payload translation, accumulators, routing, and presets +- `lib/src/theme/data/` — generated default Tailwind theme data +- `lib/src/tw_parser.dart` — public parser facade over the translator - `lib/src/tw_config.dart` — scales/colors/breakpoints + `TwConfigProvider` - `lib/src/tw_widget.dart` — widget-layer behaviors (e.g. flex-item tokens that can’t live purely in the parser) - `test/` — correctness tests for tokens + widget behavior @@ -24,24 +27,21 @@ Key docs: ## Workflow (Preferred) 1. Define expected Tailwind output in `example/real_tailwind/` (HTML + classes). -2. Implement the mapping in the Tailwind-section module (or, today, in `tw_parser.dart`). +2. Implement the mapping in the parser/translator module that owns the utility family. 3. Add tests (unit + golden/parity when it matters). 4. Validate with the visual comparison workflow in `COMPARISON_TESTING.md`. -## Structure Target (Desired Tree) +## Current Internal Structure -Evolve toward a module-per-section layout that mirrors Tailwind’s docs: +The current implementation keeps parsing and translation separate: -- `lib/src/tw/variants.dart` -- `lib/src/tw/layout.dart` -- `lib/src/tw/flexbox.dart` -- `lib/src/tw/spacing.dart` -- `lib/src/tw/sizing.dart` -- `lib/src/tw/typography.dart` -- `lib/src/tw/backgrounds.dart` -- `lib/src/tw/borders.dart` -- `lib/src/tw/effects.dart` -- `lib/src/tw/transforms.dart` -- `lib/src/tw/animation.dart` +- `lib/src/parser/candidate_parser.dart` — parse candidates, variants, modifiers, arbitrary values +- `lib/src/parser/data/parser_registry.g.dart` — generated known utility registry +- `lib/src/translate/tw_translator.dart` — maps parsed candidates into Mix schema payloads +- `lib/src/translate/tw_accumulators.dart` — cross-token accumulation for borders and transforms +- `lib/src/translate/tw_gradient.dart` — gradient accumulation and Mix conversion +- `lib/src/translate/tw_routing.dart` — early classification for ignored, unsupported, gradient, and schema-routed tokens +- `lib/src/translate/tw_target.dart` — target filtering for Box, FlexBox, and Text +- `lib/src/tw_widget.dart` — widget-only behavior such as flex-item tokens -Names should track Tailwind section names as closely as practical. +Prefer extending these modules over reintroducing disconnected section files. If utility-family modules are added later, wire them into `TwTranslator` behavior in the same change. diff --git a/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md b/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md index 7e4e84140a..0db49ca306 100644 --- a/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md +++ b/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md @@ -127,7 +127,7 @@ If you want visual parity, align **global defaults** first, then compare utility | `theme.extend.borderRadius` | `TwConfig.copyWith(radii: {...})` | | `theme.extend.screens` | `TwConfig.copyWith(breakpoints: {...})` | | `theme.extend.fontSize` | `TwConfig.copyWith(fontSizes: {...})` | -| `theme.extend.fontWeight` | Update parser semantic mapping (currently from `tw_semantic.dart`) or use `MixScope(fontWeights: ...)` for non-tailwind Mix styles | +| `theme.extend.fontWeight` | Update translator typography mapping or use `MixScope(fontWeights: ...)` for non-tailwind Mix styles | | `theme.extend.fontFamily` / base `font-sans` stack | `TwConfig.copyWith(textDefaults: config.textDefaults.copyWith(...))` | | Use native platform font defaults | `TwConfig.copyWith(textDefaults: const TwTextDefaults.platformDefault())` | | `@layer base { body { font-size / letter-spacing / line-height } }` | `TwConfig.textDefaults.copyWith(fontSize: ..., letterSpacing: ..., lineHeight: ...)` | @@ -217,6 +217,24 @@ mix_tailwinds now automatically applies `min-w-0` semantics for `flex-1` to matc The following Tailwind utilities have limited or no support in mix_tailwinds due to fundamental differences between CSS and Flutter's layout system. +### Parser and Variant Adaptations + +`mix_tailwinds` parses Tailwind candidates with a registry generated from the Tailwind spec lab, then routes supported values through `mix_schema` before composing Mix runtime variants. + +Current adaptation policy: + +| Tailwind feature | mix_tailwinds behavior | Reason | +|---|---|---| +| `group-*`, `peer-*` variants | Parsed, ignored | Flutter has no selector-relative group/peer state equivalent in this widget API. | +| Arbitrary selector variants like `[&_p]:mt-4` | Parsed, ignored | Flutter widgets cannot target descendants by CSS selector. | +| Container query variants like `@...` | Parsed, ignored | Container-query semantics remain in the widget/layout layer, not schema payloads. | +| `!important` prefix/suffix | Parsed, ignored and reported through `onUnsupported` | Flutter/Mix has no CSS cascade priority model. | +| Arbitrary properties like `[color:red]` | Parsed, ignored | They do not map safely to typed Mix schema fields. | +| `from`/`via`/`to` gradients | Applied after schema decode | `mix_schema` intentionally does not encode/decode box gradients today. | +| `bg-*/50` alpha modifiers | Approximated with Flutter alpha | Flutter has no `color-mix()`/OKLAB equivalent for Tailwind's CSS output. | + +Responsive layout utilities such as `w-full`, `w-screen`, fractions, external margin, negative margin handling, flex item parent data, axis, and gap remain in `tw_widget.dart` because they depend on live Flutter constraints. + ### Percent-Based Sizing **Tailwind CSS:** diff --git a/packages/mix_tailwinds/lib/mix_tailwinds.dart b/packages/mix_tailwinds/lib/mix_tailwinds.dart index 811e817f15..6ac25a96d1 100644 --- a/packages/mix_tailwinds/lib/mix_tailwinds.dart +++ b/packages/mix_tailwinds/lib/mix_tailwinds.dart @@ -3,5 +3,6 @@ library; export 'src/tw_config.dart'; export 'src/tw_parser.dart'; export 'src/tw_scope.dart'; -export 'src/tw_semantic.dart'; +export 'src/tw_types.dart'; export 'src/tw_widget.dart'; +export 'src/translate/tw_gradient.dart' show TwCssKeywordLinearTransform; diff --git a/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart b/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart new file mode 100644 index 0000000000..8213764cb6 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart @@ -0,0 +1,466 @@ +/// Pure Dart Tailwind candidate parser. +library; + +import 'diagnostics.dart'; +import 'model.dart'; +import 'parser_registry.dart'; + +final class TailwindCandidateParser { + const TailwindCandidateParser({ + this.registry = TailwindParserRegistry.empty, + this.options = const TailwindParserOptions(), + }); + + final TailwindParserRegistry registry; + final TailwindParserOptions options; + + TailwindParseResult parseCandidate(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return TailwindParseFailure( + input: input, + errors: [ + TailwindParseError( + code: TailwindParseErrorCode.emptyInput, + message: 'Tailwind candidate is empty.', + span: SourceSpan(0, input.length), + ), + ], + ); + } + + final balanceError = _balancedDelimiterError(trimmed); + if (balanceError != null) { + return TailwindParseFailure(input: input, errors: [balanceError]); + } + final emptyArbitraryError = _emptyArbitraryValueError(trimmed); + if (emptyArbitraryError != null) { + return TailwindParseFailure(input: input, errors: [emptyArbitraryError]); + } + + final parts = _splitOutsideDelimiters(trimmed, ':'); + if (parts.any((part) => part.isEmpty)) { + return TailwindParseFailure( + input: input, + errors: [ + TailwindParseError( + code: TailwindParseErrorCode.invalidVariantChain, + message: 'Variant chains cannot contain empty segments.', + span: SourceSpan(0, trimmed.length), + ), + ], + ); + } + + var utilityRaw = parts.last; + var important = false; + if (utilityRaw.endsWith('!')) { + important = true; + utilityRaw = utilityRaw.substring(0, utilityRaw.length - 1); + } + if (options.allowLegacyImportantPrefix && utilityRaw.startsWith('!')) { + important = true; + utilityRaw = utilityRaw.substring(1); + } + if (utilityRaw.contains('!')) { + return TailwindParseFailure( + input: input, + errors: [ + TailwindParseError( + code: TailwindParseErrorCode.invalidImportantPosition, + message: 'Important marker is only allowed at the start or end.', + span: SourceSpan(trimmed.indexOf('!'), trimmed.indexOf('!') + 1), + ), + ], + ); + } + final modifierError = _modifierError(utilityRaw); + if (modifierError != null) { + return TailwindParseFailure(input: input, errors: [modifierError]); + } + + final variants = []; + for (final rawVariant in parts.take(parts.length - 1)) { + final modifierError = _modifierError(rawVariant); + if (modifierError != null) { + return TailwindParseFailure(input: input, errors: [modifierError]); + } + variants.add(_parseVariant(rawVariant)); + } + + final utility = _parseUtility(utilityRaw); + if (utility == null) { + return TailwindParseFailure( + input: input, + errors: [ + TailwindParseError( + code: TailwindParseErrorCode.invalidArbitraryProperty, + message: 'Arbitrary property must be [property:value].', + span: SourceSpan(0, trimmed.length), + ), + ], + ); + } + + return TailwindParseSuccess( + input: input, + candidate: TailwindCandidate( + raw: trimmed, + variants: List.unmodifiable(variants), + utility: utility, + important: important, + span: SourceSpan(0, trimmed.length), + ), + ); + } + + List parseCandidates( + String input, { + TailwindCandidateSeparator separator = + TailwindCandidateSeparator.whitespace, + }) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return const []; + final tokens = switch (separator) { + TailwindCandidateSeparator.whitespace => trimmed.split(RegExp(r'\s+')), + TailwindCandidateSeparator.htmlClassAttribute => trimmed.split( + RegExp(r'\s+'), + ), + }; + + return [for (final token in tokens) parseCandidate(token)]; + } + + TailwindUtility? _parseUtility(String raw) { + if (raw.startsWith('[')) return _parseArbitraryProperty(raw); + + var negative = false; + var body = raw; + if (body.startsWith('-')) { + negative = true; + body = body.substring(1); + } + + final (base, modifier) = _splitModifier(body); + if (base == null) { + return TailwindUnresolvedUtility( + raw: raw, + segments: const [], + negative: negative, + ); + } + + if (registry.isStaticUtility(base)) { + return TailwindStaticUtility(raw: raw, root: base); + } + + final root = _findFunctionalRoot(base, registry.functionalUtilityRoots); + if (root != null) { + final valueRaw = base.length == root.length + ? '' + : base.substring(root.length + 1); + return TailwindFunctionalUtility( + raw: raw, + root: root, + value: _parseValue(valueRaw), + modifier: modifier, + negative: negative, + ); + } + + return TailwindUnresolvedUtility( + raw: raw, + segments: _segments(base), + modifier: modifier, + negative: negative, + ); + } + + TailwindArbitraryProperty? _parseArbitraryProperty(String raw) { + final close = _matchingCloseIndex(raw, 0, '[', ']'); + if (close == null) return null; + + final (body, modifier) = _splitModifier(raw); + if (body == null || !body.startsWith('[') || !body.endsWith(']')) { + return null; + } + + final inner = body.substring(1, body.length - 1); + final colon = _indexOutsideDelimiters(inner, ':'); + if (colon <= 0 || colon == inner.length - 1) return null; + + return TailwindArbitraryProperty( + raw: raw, + property: inner.substring(0, colon), + value: inner.substring(colon + 1), + modifier: modifier, + ); + } + + TailwindVariant _parseVariant(String raw) { + final (base, modifier) = _splitModifier(raw); + final body = base ?? raw; + + if (body.startsWith('[') && body.endsWith(']')) { + final selector = body.substring(1, body.length - 1); + return TailwindArbitraryVariant( + raw: raw, + selector: selector, + relative: selector.startsWith('&') || selector.startsWith('@'), + ); + } + + final compoundRoot = _findCompoundRoot(body); + if (compoundRoot != null) { + final childRaw = body.substring(compoundRoot.length + 1); + return TailwindCompoundVariant( + raw: raw, + root: compoundRoot, + variant: _parseVariant(childRaw), + modifier: modifier, + ); + } + + if (registry.isStaticVariant(body)) { + return TailwindStaticVariant(raw: raw, root: body, modifier: modifier); + } + + final functional = _findVariantFunctionalRoot(body); + if (functional != null) { + final valueRaw = functional == '@' + ? body.substring(1) + : body.substring(functional.length + 1); + return TailwindFunctionalVariant( + raw: raw, + root: functional, + value: _parseValue(valueRaw), + modifier: modifier, + ); + } + + return TailwindUnresolvedVariant( + raw: raw, + segments: _segments(body), + modifier: modifier, + ); + } + + TailwindValue _parseValue(String raw) { + if (raw.startsWith('[') && raw.endsWith(']')) { + final inner = raw.substring(1, raw.length - 1); + final colon = _indexOutsideDelimiters(inner, ':'); + return TailwindArbitraryValue( + raw: raw, + typeHint: colon > 0 ? inner.substring(0, colon) : null, + value: colon > 0 ? inner.substring(colon + 1) : inner, + ); + } + if (raw.startsWith('(') && raw.endsWith(')')) { + return TailwindCssVariableValue( + raw: raw, + variableName: raw.substring(1, raw.length - 1), + ); + } + return TailwindNamedValue(raw); + } + + (String?, TailwindModifier?) _splitModifier(String raw) { + final slash = _indexOutsideDelimiters(raw, '/'); + if (slash == -1) return (raw, null); + if (slash == 0 || slash == raw.length - 1) return (null, null); + + final base = raw.substring(0, slash); + final modifierRaw = raw.substring(slash + 1); + if (_indexOutsideDelimiters(modifierRaw, '/') != -1) return (null, null); + + return (base, _parseModifier(modifierRaw)); + } + + TailwindModifier _parseModifier(String raw) { + if (raw.startsWith('[') && raw.endsWith(']')) { + return TailwindArbitraryModifier( + raw: raw, + value: raw.substring(1, raw.length - 1), + ); + } + if (raw.startsWith('(') && raw.endsWith(')')) { + return TailwindCssVariableModifier( + raw: raw, + variableName: raw.substring(1, raw.length - 1), + ); + } + return TailwindNamedModifier(raw); + } + + String? _findFunctionalRoot(String body, Set roots) { + if (roots.contains(body)) return body; + + var current = body; + while (current.isNotEmpty) { + final dash = current.lastIndexOf('-'); + if (dash == -1) break; + current = current.substring(0, dash); + if (roots.contains(current)) return current; + } + return null; + } + + String? _findCompoundRoot(String body) { + for (final root in registry.compoundVariantRoots) { + if (body.startsWith('$root-') && body.length > root.length + 1) { + return root; + } + } + return null; + } + + String? _findVariantFunctionalRoot(String body) { + final roots = registry.functionalVariantRoots; + if (roots.contains('@') && body.startsWith('@') && body.length > 1) { + return '@'; + } + return _findFunctionalRoot(body, roots); + } +} + +final class TailwindParserOptions { + const TailwindParserOptions({ + this.allowLegacyImportantPrefix = true, + this.preserveSourceSpans = true, + }); + + final bool allowLegacyImportantPrefix; + final bool preserveSourceSpans; +} + +enum TailwindCandidateSeparator { whitespace, htmlClassAttribute } + +TailwindParseError? _balancedDelimiterError(String input) { + final stack = []; + for (var i = 0; i < input.length; i++) { + final char = input[i]; + if (char == '[' || char == '(') { + stack.add(char); + } else if (char == ']') { + if (stack.isEmpty || stack.removeLast() != '[') { + return TailwindParseError( + code: TailwindParseErrorCode.unopenedBracket, + message: 'Closing bracket has no matching opening bracket.', + span: SourceSpan(i, i + 1), + ); + } + } else if (char == ')') { + if (stack.isEmpty || stack.removeLast() != '(') { + return TailwindParseError( + code: TailwindParseErrorCode.unopenedParenthesis, + message: 'Closing parenthesis has no matching opening parenthesis.', + span: SourceSpan(i, i + 1), + ); + } + } + } + if (stack.isEmpty) return null; + final opening = stack.last; + return TailwindParseError( + code: opening == '[' + ? TailwindParseErrorCode.unclosedBracket + : TailwindParseErrorCode.unclosedParenthesis, + message: 'Delimiter was not closed.', + span: SourceSpan(input.length - 1, input.length), + ); +} + +TailwindParseError? _emptyArbitraryValueError(String input) { + for (var i = 0; i < input.length - 1; i++) { + final startsArbitrarySegment = + i == 0 || + input[i - 1] == '-' || + input[i - 1] == ':' || + input[i - 1] == '/'; + if (startsArbitrarySegment && input[i] == '[' && input[i + 1] == ']') { + return TailwindParseError( + code: TailwindParseErrorCode.emptyArbitraryValue, + message: 'Arbitrary values cannot be empty.', + span: SourceSpan(i, i + 2), + ); + } + } + return null; +} + +TailwindParseError? _modifierError(String raw) { + final slash = _indexOutsideDelimiters(raw, '/'); + if (slash == -1) return null; + if (slash == 0 || slash == raw.length - 1) { + return TailwindParseError( + code: TailwindParseErrorCode.invalidModifier, + message: 'Modifiers must have a value after one slash.', + span: SourceSpan(slash, slash + 1), + ); + } + final modifierRaw = raw.substring(slash + 1); + if (_indexOutsideDelimiters(modifierRaw, '/') != -1) { + return TailwindParseError( + code: TailwindParseErrorCode.invalidModifier, + message: 'Only one modifier slash is allowed.', + span: SourceSpan(slash, raw.length), + ); + } + return null; +} + +List _splitOutsideDelimiters(String input, String delimiter) { + final parts = []; + var start = 0; + var square = 0; + var paren = 0; + for (var i = 0; i < input.length; i++) { + final char = input[i]; + if (char == '[') square++; + if (char == ']') square--; + if (char == '(') paren++; + if (char == ')') paren--; + if (char == delimiter && square == 0 && paren == 0) { + parts.add(input.substring(start, i)); + start = i + 1; + } + } + parts.add(input.substring(start)); + return parts; +} + +int _indexOutsideDelimiters(String input, String needle) { + var square = 0; + var paren = 0; + for (var i = 0; i < input.length; i++) { + final char = input[i]; + if (char == '[') square++; + if (char == ']') square--; + if (char == '(') paren++; + if (char == ')') paren--; + if (char == needle && square == 0 && paren == 0) return i; + } + return -1; +} + +int? _matchingCloseIndex( + String input, + int openIndex, + String open, + String close, +) { + var depth = 0; + for (var i = openIndex; i < input.length; i++) { + if (input[i] == open) depth++; + if (input[i] == close) { + depth--; + if (depth == 0) return i; + } + } + return null; +} + +List _segments(String raw) { + if (raw.isEmpty) return const []; + return raw.split('-'); +} diff --git a/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart b/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart new file mode 100644 index 0000000000..18229f643f --- /dev/null +++ b/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart @@ -0,0 +1,724 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated by tool/gen_registry.dart from ../../.context/tailwinds-spec/out. +// Generated at 2026-06-17T14:06:24.752741Z. +library; + +import '../parser_registry.dart'; + +const generatedTailwindRegistryMeta = { + 'cssEntry': 'fixtures/app.css', + 'generatedAt': '2026-06-17T14:06:24.752741Z', + 'tailwindGitSha': '8a14a710102cae195f6811e8578bef9477bc6be9', + 'tailwindGitTag': 'v4.3.1', + 'tailwindInstalledVersion': '4.3.1', +}; + +const generatedStaticUtilityRoots = { + '-translate-full', + 'absolute', + 'accent-auto', + 'align-baseline', + 'align-bottom', + 'align-middle', + 'align-sub', + 'align-super', + 'align-text-bottom', + 'align-text-top', + 'align-top', + 'antialiased', + 'appearance-auto', + 'appearance-none', + 'backface-hidden', + 'backface-visible', + 'basis-auto', + 'basis-full', + 'bg-auto', + 'bg-bottom', + 'bg-bottom-left', + 'bg-bottom-right', + 'bg-center', + 'bg-clip-border', + 'bg-clip-content', + 'bg-clip-padding', + 'bg-clip-text', + 'bg-contain', + 'bg-cover', + 'bg-fixed', + 'bg-left', + 'bg-local', + 'bg-no-repeat', + 'bg-none', + 'bg-origin-border', + 'bg-origin-content', + 'bg-origin-padding', + 'bg-repeat', + 'bg-repeat-round', + 'bg-repeat-space', + 'bg-repeat-x', + 'bg-repeat-y', + 'bg-right', + 'bg-scroll', + 'bg-top', + 'bg-top-left', + 'bg-top-right', + 'block', + 'block-auto', + 'block-lh', + 'block-screen', + 'border-collapse', + 'border-dashed', + 'border-dotted', + 'border-double', + 'border-hidden', + 'border-none', + 'border-separate', + 'border-solid', + 'box-border', + 'box-content', + 'box-decoration-clone', + 'box-decoration-slice', + 'break-all', + 'break-keep', + 'break-normal', + 'capitalize', + 'caption-bottom', + 'caption-top', + 'clear-both', + 'clear-end', + 'clear-left', + 'clear-none', + 'clear-right', + 'clear-start', + 'collapse', + 'contain-content', + 'contain-inline-size', + 'contain-layout', + 'contain-none', + 'contain-paint', + 'contain-size', + 'contain-strict', + 'contain-style', + 'content-around', + 'content-auto', + 'content-baseline', + 'content-between', + 'content-center', + 'content-center-safe', + 'content-end', + 'content-end-safe', + 'content-evenly', + 'content-none', + 'content-normal', + 'content-start', + 'content-stretch', + 'contents', + 'decoration-auto', + 'decoration-dashed', + 'decoration-dotted', + 'decoration-double', + 'decoration-from-font', + 'decoration-solid', + 'decoration-wavy', + 'diagonal-fractions', + 'divide-x-reverse', + 'divide-y-reverse', + 'drop-shadow-none', + 'duration-initial', + 'field-sizing-content', + 'field-sizing-fixed', + 'fill-none', + 'fixed', + 'flex', + 'flex-auto', + 'flex-col', + 'flex-col-reverse', + 'flex-initial', + 'flex-none', + 'flex-nowrap', + 'flex-row', + 'flex-row-reverse', + 'flex-wrap', + 'flex-wrap-reverse', + 'float-end', + 'float-left', + 'float-none', + 'float-right', + 'float-start', + 'flow-root', + 'font-stretch-condensed', + 'font-stretch-expanded', + 'font-stretch-extra-condensed', + 'font-stretch-extra-expanded', + 'font-stretch-normal', + 'font-stretch-semi-condensed', + 'font-stretch-semi-expanded', + 'font-stretch-ultra-condensed', + 'font-stretch-ultra-expanded', + 'forced-color-adjust-auto', + 'forced-color-adjust-none', + 'grid', + 'grid-flow-col', + 'grid-flow-col-dense', + 'grid-flow-dense', + 'grid-flow-row', + 'grid-flow-row-dense', + 'h-auto', + 'h-lh', + 'h-screen', + 'hidden', + 'hyphens-auto', + 'hyphens-manual', + 'hyphens-none', + 'inline', + 'inline-auto', + 'inline-block', + 'inline-flex', + 'inline-grid', + 'inline-screen', + 'inline-table', + 'inset-shadow-initial', + 'invisible', + 'isolate', + 'isolation-auto', + 'italic', + 'items-baseline', + 'items-baseline-last', + 'items-center', + 'items-center-safe', + 'items-end', + 'items-end-safe', + 'items-start', + 'items-stretch', + 'justify-around', + 'justify-baseline', + 'justify-between', + 'justify-center', + 'justify-center-safe', + 'justify-end', + 'justify-end-safe', + 'justify-evenly', + 'justify-items-center', + 'justify-items-center-safe', + 'justify-items-end', + 'justify-items-end-safe', + 'justify-items-normal', + 'justify-items-start', + 'justify-items-stretch', + 'justify-normal', + 'justify-self-auto', + 'justify-self-center', + 'justify-self-center-safe', + 'justify-self-end', + 'justify-self-end-safe', + 'justify-self-start', + 'justify-self-stretch', + 'justify-start', + 'justify-stretch', + 'line-through', + 'lining-nums', + 'list-inside', + 'list-item', + 'list-outside', + 'lowercase', + 'mask-add', + 'mask-alpha', + 'mask-auto', + 'mask-bottom', + 'mask-bottom-left', + 'mask-bottom-right', + 'mask-center', + 'mask-circle', + 'mask-clip-border', + 'mask-clip-content', + 'mask-clip-fill', + 'mask-clip-padding', + 'mask-clip-stroke', + 'mask-clip-view', + 'mask-contain', + 'mask-cover', + 'mask-ellipse', + 'mask-exclude', + 'mask-intersect', + 'mask-left', + 'mask-luminance', + 'mask-match', + 'mask-no-clip', + 'mask-no-repeat', + 'mask-none', + 'mask-origin-border', + 'mask-origin-content', + 'mask-origin-fill', + 'mask-origin-padding', + 'mask-origin-stroke', + 'mask-origin-view', + 'mask-radial-at-bottom', + 'mask-radial-at-bottom-left', + 'mask-radial-at-bottom-right', + 'mask-radial-at-center', + 'mask-radial-at-left', + 'mask-radial-at-right', + 'mask-radial-at-top', + 'mask-radial-at-top-left', + 'mask-radial-at-top-right', + 'mask-radial-closest-corner', + 'mask-radial-closest-side', + 'mask-radial-farthest-corner', + 'mask-radial-farthest-side', + 'mask-repeat', + 'mask-repeat-round', + 'mask-repeat-space', + 'mask-repeat-x', + 'mask-repeat-y', + 'mask-right', + 'mask-subtract', + 'mask-top', + 'mask-top-left', + 'mask-top-right', + 'mask-type-alpha', + 'mask-type-luminance', + 'max-block-lh', + 'max-block-none', + 'max-block-screen', + 'max-h-lh', + 'max-h-none', + 'max-h-screen', + 'max-inline-none', + 'max-inline-screen', + 'max-w-none', + 'max-w-screen', + 'min-block-auto', + 'min-block-lh', + 'min-block-screen', + 'min-h-auto', + 'min-h-lh', + 'min-h-screen', + 'min-inline-auto', + 'min-inline-screen', + 'min-w-auto', + 'min-w-screen', + 'mix-blend-plus-darker', + 'mix-blend-plus-lighter', + 'no-underline', + 'normal-case', + 'normal-nums', + 'not-italic', + 'not-sr-only', + 'object-contain', + 'object-cover', + 'object-fill', + 'object-none', + 'object-scale-down', + 'oldstyle-nums', + 'ordinal', + 'outline-dashed', + 'outline-dotted', + 'outline-double', + 'outline-none', + 'outline-solid', + 'overflow-clip', + 'overflow-hidden', + 'overflow-visible', + 'overline', + 'place-content-around', + 'place-content-baseline', + 'place-content-between', + 'place-content-center', + 'place-content-center-safe', + 'place-content-end', + 'place-content-end-safe', + 'place-content-evenly', + 'place-content-start', + 'place-content-stretch', + 'place-items-baseline', + 'place-items-center', + 'place-items-center-safe', + 'place-items-end', + 'place-items-end-safe', + 'place-items-start', + 'place-items-stretch', + 'place-self-auto', + 'place-self-center', + 'place-self-center-safe', + 'place-self-end', + 'place-self-end-safe', + 'place-self-start', + 'place-self-stretch', + 'pointer-events-auto', + 'pointer-events-none', + 'proportional-nums', + 'relative', + 'resize', + 'resize-none', + 'resize-x', + 'resize-y', + 'ring-inset', + 'rotate-none', + 'scale-3d', + 'scale-none', + 'scheme-dark', + 'scheme-light', + 'scheme-light-dark', + 'scheme-normal', + 'scheme-only-dark', + 'scheme-only-light', + 'scroll-auto', + 'scroll-smooth', + 'scrollbar-auto', + 'scrollbar-gutter-auto', + 'scrollbar-gutter-both', + 'scrollbar-gutter-stable', + 'scrollbar-none', + 'scrollbar-thin', + 'self-auto', + 'self-baseline', + 'self-baseline-last', + 'self-center', + 'self-center-safe', + 'self-end', + 'self-end-safe', + 'self-start', + 'self-stretch', + 'shadow-initial', + 'size-auto', + 'slashed-zero', + 'snap-align-none', + 'snap-always', + 'snap-center', + 'snap-end', + 'snap-mandatory', + 'snap-none', + 'snap-normal', + 'snap-proximity', + 'snap-start', + 'space-x-reverse', + 'space-y-reverse', + 'sr-only', + 'stacked-fractions', + 'static', + 'sticky', + 'stroke-none', + 'subpixel-antialiased', + 'table', + 'table-auto', + 'table-caption', + 'table-cell', + 'table-column', + 'table-column-group', + 'table-fixed', + 'table-footer-group', + 'table-header-group', + 'table-row', + 'table-row-group', + 'tabular-nums', + 'text-balance', + 'text-center', + 'text-clip', + 'text-ellipsis', + 'text-end', + 'text-justify', + 'text-left', + 'text-nowrap', + 'text-pretty', + 'text-right', + 'text-shadow-initial', + 'text-start', + 'text-wrap', + 'touch-pinch-zoom', + 'transform-3d', + 'transform-border', + 'transform-content', + 'transform-cpu', + 'transform-fill', + 'transform-flat', + 'transform-gpu', + 'transform-none', + 'transform-stroke', + 'transform-view', + 'transition-discrete', + 'transition-normal', + 'translate-3d', + 'translate-full', + 'translate-none', + 'truncate', + 'underline', + 'uppercase', + 'via-none', + 'visible', + 'w-auto', + 'w-screen', + 'whitespace-break-spaces', + 'whitespace-normal', + 'whitespace-nowrap', + 'whitespace-pre', + 'whitespace-pre-line', + 'whitespace-pre-wrap', + 'will-change-auto', + 'will-change-contents', + 'will-change-scroll', + 'will-change-transform', + 'wrap-anywhere', + 'wrap-break-word', + 'wrap-normal', +}; + +const generatedFunctionalUtilityRoots = { + '@container', + 'align', + 'animate', + 'aspect', + 'auto-cols', + 'auto-rows', + 'backdrop-blur', + 'backdrop-brightness', + 'backdrop-contrast', + 'backdrop-filter', + 'backdrop-grayscale', + 'backdrop-hue-rotate', + 'backdrop-invert', + 'backdrop-opacity', + 'backdrop-saturate', + 'backdrop-sepia', + 'basis', + 'bg', + 'bg-conic', + 'bg-gradient', + 'bg-linear', + 'bg-position', + 'bg-radial', + 'bg-size', + 'blur', + 'border', + 'border-b', + 'border-l', + 'border-r', + 'border-spacing', + 'border-spacing-x', + 'border-spacing-y', + 'border-t', + 'border-x', + 'border-y', + 'brightness', + 'col', + 'col-end', + 'col-span', + 'col-start', + 'columns', + 'contain', + 'content', + 'contrast', + 'cursor', + 'decoration', + 'delay', + 'divide-x', + 'divide-y', + 'drop-shadow', + 'duration', + 'ease', + 'fill', + 'filter', + 'flex', + 'font', + 'font-features', + 'font-stretch', + 'from', + 'gap', + 'gap-x', + 'gap-y', + 'grayscale', + 'grid-cols', + 'grid-rows', + 'grow', + 'h', + 'hue-rotate', + 'indent', + 'inset-ring', + 'inset-shadow', + 'invert', + 'leading', + 'line-clamp', + 'list', + 'list-image', + 'm', + 'mask', + 'mask-conic', + 'mask-linear', + 'mask-position', + 'mask-radial', + 'mask-radial-at', + 'mask-size', + 'max-h', + 'max-w', + 'mb', + 'min-h', + 'min-w', + 'ml', + 'mr', + 'mt', + 'mx', + 'my', + 'object', + 'opacity', + 'order', + 'origin', + 'outline', + 'outline-offset', + 'p', + 'pb', + 'perspective', + 'perspective-origin', + 'pl', + 'pr', + 'pt', + 'px', + 'py', + 'ring', + 'ring-offset', + 'rotate', + 'rounded', + 'rounded-b', + 'rounded-bl', + 'rounded-br', + 'rounded-l', + 'rounded-r', + 'rounded-t', + 'rounded-tl', + 'rounded-tr', + 'row', + 'row-end', + 'row-span', + 'row-start', + 'saturate', + 'scale', + 'sepia', + 'shadow', + 'shrink', + 'size', + 'skew', + 'skew-x', + 'skew-y', + 'space-x', + 'space-y', + 'stroke', + 'tab', + 'text', + 'text-shadow', + 'to', + 'tracking', + 'transform', + 'transition', + 'translate', + 'translate-x', + 'translate-y', + 'translate-z', + 'underline-offset', + 'via', + 'w', + 'will-change', + 'z', + 'zoom', +}; + +const generatedStaticVariantRoots = { + '*', + '**', + '2xl', + '3xl', + 'active', + 'after', + 'any-pointer-coarse', + 'any-pointer-fine', + 'any-pointer-none', + 'autofill', + 'backdrop', + 'before', + 'checked', + 'contrast-less', + 'contrast-more', + 'dark', + 'default', + 'details-content', + 'disabled', + 'empty', + 'enabled', + 'even', + 'file', + 'first', + 'first-letter', + 'first-line', + 'first-of-type', + 'focus', + 'focus-visible', + 'focus-within', + 'forced-colors', + 'hover', + 'in-range', + 'indeterminate', + 'inert', + 'invalid', + 'inverted-colors', + 'landscape', + 'last', + 'last-of-type', + 'lg', + 'light', + 'ltr', + 'marker', + 'md', + 'motion-reduce', + 'motion-safe', + 'noscript', + 'odd', + 'only', + 'only-of-type', + 'open', + 'optional', + 'out-of-range', + 'placeholder', + 'placeholder-shown', + 'pointer-coarse', + 'pointer-fine', + 'pointer-none', + 'portrait', + 'print', + 'read-only', + 'required', + 'rtl', + 'selection', + 'sm', + 'starting', + 'target', + 'theme-midnight', + 'user-invalid', + 'user-valid', + 'valid', + 'visited', + 'xl', +}; + +const generatedFunctionalVariantRoots = { + '@', + '@max', + '@min', + 'aria', + 'data', + 'has', + 'in', + 'max', + 'min', + 'nth', + 'nth-last', + 'nth-last-of-type', + 'nth-of-type', + 'supports', +}; + +const generatedCompoundVariantRoots = {'group', 'not', 'peer'}; + +final defaultTailwindParserRegistry = TailwindParserRegistry( + staticUtilityRoots: generatedStaticUtilityRoots, + functionalUtilityRoots: generatedFunctionalUtilityRoots, + staticVariantRoots: generatedStaticVariantRoots, + functionalVariantRoots: generatedFunctionalVariantRoots, + compoundVariantRoots: generatedCompoundVariantRoots, + meta: generatedTailwindRegistryMeta, +); diff --git a/packages/mix_tailwinds/lib/src/parser/diagnostics.dart b/packages/mix_tailwinds/lib/src/parser/diagnostics.dart new file mode 100644 index 0000000000..8b4984c779 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/parser/diagnostics.dart @@ -0,0 +1,80 @@ +/// Structured parse diagnostics for Tailwind candidate syntax. +library; + +import 'model.dart'; + +sealed class TailwindParseResult { + const TailwindParseResult(); + + String get input; + bool get isSuccess; +} + +final class TailwindParseSuccess extends TailwindParseResult { + const TailwindParseSuccess({ + required this.input, + required this.candidate, + this.warnings = const [], + }); + + @override + final String input; + final TailwindCandidate candidate; + final List warnings; + + @override + bool get isSuccess => true; +} + +final class TailwindParseFailure extends TailwindParseResult { + const TailwindParseFailure({ + required this.input, + required this.errors, + this.partial, + }); + + @override + final String input; + final List errors; + final TailwindCandidate? partial; + + @override + bool get isSuccess => false; +} + +final class TailwindParseError { + const TailwindParseError({ + required this.code, + required this.message, + required this.span, + }); + + final TailwindParseErrorCode code; + final String message; + final SourceSpan span; +} + +enum TailwindParseErrorCode { + emptyInput, + emptyArbitraryValue, + unclosedBracket, + unopenedBracket, + unclosedParenthesis, + unopenedParenthesis, + invalidModifier, + invalidImportantPosition, + invalidArbitraryProperty, + invalidVariantChain, +} + +final class TailwindParseWarning { + const TailwindParseWarning({ + required this.code, + required this.message, + required this.span, + }); + + final String code; + final String message; + final SourceSpan span; +} diff --git a/packages/mix_tailwinds/lib/src/parser/model.dart b/packages/mix_tailwinds/lib/src/parser/model.dart new file mode 100644 index 0000000000..66c829b556 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/parser/model.dart @@ -0,0 +1,241 @@ +/// Typed Tailwind candidate syntax model. +library; + +final class SourceSpan { + const SourceSpan(this.start, this.end); + + final int start; + final int end; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceSpan && start == other.start && end == other.end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => 'SourceSpan($start, $end)'; +} + +final class TailwindCandidate { + const TailwindCandidate({ + required this.raw, + required this.variants, + required this.utility, + required this.important, + required this.span, + }); + + final String raw; + final List variants; + final TailwindUtility utility; + final bool important; + final SourceSpan span; +} + +sealed class TailwindUtility { + const TailwindUtility(); + + String get raw; +} + +final class TailwindUnresolvedUtility extends TailwindUtility { + const TailwindUnresolvedUtility({ + required this.raw, + required this.segments, + this.modifier, + this.negative = false, + }); + + @override + final String raw; + final List segments; + final TailwindModifier? modifier; + final bool negative; +} + +final class TailwindStaticUtility extends TailwindUtility { + const TailwindStaticUtility({required this.raw, required this.root}); + + @override + final String raw; + final String root; +} + +final class TailwindFunctionalUtility extends TailwindUtility { + const TailwindFunctionalUtility({ + required this.raw, + required this.root, + required this.value, + this.modifier, + this.negative = false, + }); + + @override + final String raw; + final String root; + final TailwindValue value; + final TailwindModifier? modifier; + final bool negative; +} + +final class TailwindArbitraryProperty extends TailwindUtility { + const TailwindArbitraryProperty({ + required this.raw, + required this.property, + required this.value, + this.modifier, + }); + + @override + final String raw; + final String property; + final String value; + final TailwindModifier? modifier; +} + +sealed class TailwindValue { + const TailwindValue(); + + String get raw; +} + +final class TailwindNamedValue extends TailwindValue { + const TailwindNamedValue(this.raw); + + @override + final String raw; +} + +final class TailwindArbitraryValue extends TailwindValue { + const TailwindArbitraryValue({ + required this.raw, + required this.value, + this.typeHint, + }); + + @override + final String raw; + final String value; + final String? typeHint; +} + +final class TailwindCssVariableValue extends TailwindValue { + const TailwindCssVariableValue({ + required this.raw, + required this.variableName, + }); + + @override + final String raw; + final String variableName; +} + +sealed class TailwindModifier { + const TailwindModifier(); + + String get raw; +} + +final class TailwindNamedModifier extends TailwindModifier { + const TailwindNamedModifier(this.raw); + + @override + final String raw; +} + +final class TailwindArbitraryModifier extends TailwindModifier { + const TailwindArbitraryModifier({required this.raw, required this.value}); + + @override + final String raw; + final String value; +} + +final class TailwindCssVariableModifier extends TailwindModifier { + const TailwindCssVariableModifier({ + required this.raw, + required this.variableName, + }); + + @override + final String raw; + final String variableName; +} + +sealed class TailwindVariant { + const TailwindVariant(); + + String get raw; +} + +final class TailwindUnresolvedVariant extends TailwindVariant { + const TailwindUnresolvedVariant({ + required this.raw, + required this.segments, + this.modifier, + }); + + @override + final String raw; + final List segments; + final TailwindModifier? modifier; +} + +final class TailwindStaticVariant extends TailwindVariant { + const TailwindStaticVariant({ + required this.raw, + required this.root, + this.modifier, + }); + + @override + final String raw; + final String root; + final TailwindModifier? modifier; +} + +final class TailwindFunctionalVariant extends TailwindVariant { + const TailwindFunctionalVariant({ + required this.raw, + required this.root, + required this.value, + this.modifier, + }); + + @override + final String raw; + final String root; + final TailwindValue value; + final TailwindModifier? modifier; +} + +final class TailwindCompoundVariant extends TailwindVariant { + const TailwindCompoundVariant({ + required this.raw, + required this.root, + required this.variant, + this.modifier, + }); + + @override + final String raw; + final String root; + final TailwindVariant variant; + final TailwindModifier? modifier; +} + +final class TailwindArbitraryVariant extends TailwindVariant { + const TailwindArbitraryVariant({ + required this.raw, + required this.selector, + required this.relative, + }); + + @override + final String raw; + final String selector; + final bool relative; +} diff --git a/packages/mix_tailwinds/lib/src/parser/parser_registry.dart b/packages/mix_tailwinds/lib/src/parser/parser_registry.dart new file mode 100644 index 0000000000..5213707a8f --- /dev/null +++ b/packages/mix_tailwinds/lib/src/parser/parser_registry.dart @@ -0,0 +1,79 @@ +/// Generated-data backed Tailwind parser registry. +library; + +typedef JsonMap = Map; + +final class TailwindParserRegistry { + TailwindParserRegistry({ + Set staticUtilityRoots = const {}, + Set functionalUtilityRoots = const {}, + Set staticVariantRoots = const {}, + Set functionalVariantRoots = const {}, + Set compoundVariantRoots = const {}, + Set customUtilityRoots = const {}, + Set customVariantRoots = const {}, + Map meta = const {}, + }) : staticUtilityRoots = Set.unmodifiable(staticUtilityRoots), + functionalUtilityRoots = Set.unmodifiable(functionalUtilityRoots), + staticVariantRoots = Set.unmodifiable(staticVariantRoots), + functionalVariantRoots = Set.unmodifiable(functionalVariantRoots), + compoundVariantRoots = Set.unmodifiable(compoundVariantRoots), + customUtilityRoots = Set.unmodifiable(customUtilityRoots), + customVariantRoots = Set.unmodifiable(customVariantRoots), + meta = Map.unmodifiable(meta); + + const TailwindParserRegistry._empty() + : staticUtilityRoots = const {}, + functionalUtilityRoots = const {}, + staticVariantRoots = const {}, + functionalVariantRoots = const {}, + compoundVariantRoots = const {}, + customUtilityRoots = const {}, + customVariantRoots = const {}, + meta = const {}; + + static const empty = TailwindParserRegistry._empty(); + + factory TailwindParserRegistry.fromJson(JsonMap json) { + return TailwindParserRegistry( + staticUtilityRoots: _stringSet(json['staticUtilityRoots']), + functionalUtilityRoots: _stringSet(json['functionalUtilityRoots']), + staticVariantRoots: _stringSet(json['staticVariantRoots']), + functionalVariantRoots: _stringSet(json['functionalVariantRoots']), + compoundVariantRoots: _stringSet(json['compoundVariantRoots']), + customUtilityRoots: _stringSet(json['customUtilityRoots']), + customVariantRoots: _stringSet(json['customVariantRoots']), + meta: (json['meta'] as Map?)?.cast() ?? const {}, + ); + } + + final Set staticUtilityRoots; + final Set functionalUtilityRoots; + final Set staticVariantRoots; + final Set functionalVariantRoots; + final Set compoundVariantRoots; + final Set customUtilityRoots; + final Set customVariantRoots; + final Map meta; + + bool isStaticUtility(String root) => + staticUtilityRoots.contains(root) || customUtilityRoots.contains(root); + + bool isFunctionalUtility(String root) => + functionalUtilityRoots.contains(root) || + customUtilityRoots.contains(root); + + bool isStaticVariant(String root) => + staticVariantRoots.contains(root) || customVariantRoots.contains(root); + + bool isFunctionalVariant(String root) => + functionalVariantRoots.contains(root) || + customVariantRoots.contains(root); + + bool isCompoundVariant(String root) => compoundVariantRoots.contains(root); +} + +Set _stringSet(Object? value) { + if (value is! Iterable) return const {}; + return value.whereType().toSet(); +} diff --git a/packages/mix_tailwinds/lib/src/theme/data/default_theme.g.dart b/packages/mix_tailwinds/lib/src/theme/data/default_theme.g.dart new file mode 100644 index 0000000000..7c9e7685da --- /dev/null +++ b/packages/mix_tailwinds/lib/src/theme/data/default_theme.g.dart @@ -0,0 +1,190 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from the Tailwind v4.3.1 spec snapshot and fixture theme. +library; + +import 'package:flutter/material.dart'; + +const twDefaultSpacing = { + '0': 0, + 'px': 1, + '0.5': 2, + '1': 4, + '1.5': 6, + '2': 8, + '2.5': 10, + '3': 12, + '3.5': 14, + '4': 16, + '5': 20, + '6': 24, + '7': 28, + '8': 32, + '9': 36, + '10': 40, + '11': 44, + '12': 48, + '14': 56, + '16': 64, + '20': 80, + '24': 96, + '28': 112, + '32': 128, + '36': 144, + '40': 160, + '44': 176, + '48': 192, + '52': 208, + '56': 224, + '60': 240, + '64': 256, + '72': 288, + '80': 320, + '96': 384, + 'card': 32, +}; + +const twDefaultRadii = { + 'none': 0, + '': 4, + 'sm': 2, + 'md': 6, + 'lg': 8, + 'xl': 12, + '2xl': 16, + '3xl': 24, + '4xl': 28, + 'full': 9999, + 'card': 16, +}; + +const twDefaultBorderWidths = { + '0': 0, + '': 1, + '2': 2, + '4': 4, + '8': 8, +}; + +const twDefaultBreakpoints = { + 'sm': 640, + 'md': 768, + 'lg': 1024, + 'xl': 1280, + '2xl': 1536, + '3xl': 1920, +}; + +const twDefaultFontSizes = { + 'xs': 12, + 'sm': 14, + 'base': 16, + 'lg': 18, + 'xl': 20, + '2xl': 24, + '3xl': 30, + '4xl': 36, + '5xl': 48, + '6xl': 60, + '7xl': 72, + '8xl': 96, + '9xl': 128, +}; + +const twDefaultColors = { + 'slate-300': Color(0xFFCBD5E1), + 'slate-600': Color(0xFF475569), + 'slate-700': Color(0xFF334155), + 'slate-800': Color(0xFF1E293B), + 'slate-900': Color(0xFF0F172A), + 'gray-100': Color(0xFFF3F4F6), + 'gray-200': Color(0xFFE5E7EB), + 'gray-500': Color(0xFF6B7280), + 'gray-700': Color(0xFF374151), + 'blue-50': Color(0xFFEFF6FF), + 'blue-100': Color(0xFFDBEAFE), + 'blue-500': Color(0xFF3B82F6), + 'blue-600': Color(0xFF2563EB), + 'blue-700': Color(0xFF1D4ED8), + 'purple-200': Color(0xFFE9D5FF), + 'purple-400': Color(0xFFC084FC), + 'purple-500': Color(0xFFA855F7), + 'purple-600': Color(0xFF9333EA), + 'purple-700': Color(0xFF7C3AED), + 'purple-900': Color(0xFF581C87), + 'pink-400': Color(0xFFF472B6), + 'pink-500': Color(0xFFEC4899), + 'red-500': Color(0xFFEF4444), + 'red-600': Color(0xFFDC2626), + 'amber-300': Color(0xFFFCD34D), + 'emerald-400': Color(0xFF34D399), + 'brand-500': Color(0xFF316FF6), + 'black': Colors.black, + 'white': Colors.white, + 'transparent': Colors.transparent, +}; + +const twDefaultDurations = { + '0': 0, + '75': 75, + '100': 100, + '150': 150, + '200': 200, + '300': 300, + '500': 500, + '700': 700, + '1000': 1000, +}; + +const twDefaultDelays = twDefaultDurations; + +const twDefaultScales = { + '0': 0, + '50': 0.5, + '75': 0.75, + '90': 0.9, + '95': 0.95, + '100': 1, + '105': 1.05, + '110': 1.1, + '125': 1.25, + '150': 1.5, +}; + +const twDefaultRotations = { + '0': 0, + '1': 1, + '2': 2, + '3': 3, + '6': 6, + '12': 12, + '45': 45, + '90': 90, + '180': 180, +}; + +const twDefaultBlurs = { + 'none': 0, + 'sm': 2, + '': 4, + 'md': 6, + 'lg': 8, + 'xl': 12, + '2xl': 20, + '3xl': 32, +}; + +const twDefaultLineHeights = { + 'xs': 1.333, + 'sm': 1.429, + 'base': 1.5, + 'lg': 1.556, + 'xl': 1.4, + '2xl': 1.333, + '3xl': 1.2, + '4xl': 1.111, + '5xl': 1, + '6xl': 1, + '7xl': 1, + '8xl': 1, + '9xl': 1, +}; diff --git a/packages/mix_tailwinds/lib/src/translate/tw_accumulators.dart b/packages/mix_tailwinds/lib/src/translate/tw_accumulators.dart new file mode 100644 index 0000000000..4467194950 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/translate/tw_accumulators.dart @@ -0,0 +1,141 @@ +/// Translator-side accumulators for values that merge across tokens. +library; + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:mix_schema/encode.dart'; +import 'package:mix_schema/mix_schema.dart'; + +final class TransformAccum { + double? scale; + double? rotateDeg; + double? translateX; + double? translateY; + bool needsIdentity = false; + + bool get hasAnyTransform => + needsIdentity || + scale != null || + rotateDeg != null || + translateX != null || + translateY != null; + + void inheritUnsetFrom(TransformAccum base) { + scale ??= base.scale; + rotateDeg ??= base.rotateDeg; + translateX ??= base.translateX; + translateY ??= base.translateY; + } + + Matrix4 toMatrix4() { + var matrix = Matrix4.identity(); + if (translateX != null || translateY != null) { + matrix = matrix.multiplied( + Matrix4.translationValues(translateX ?? 0, translateY ?? 0, 0), + ); + } + if (rotateDeg != null) { + matrix = matrix.multiplied(Matrix4.rotationZ(rotateDeg! * math.pi / 180)); + } + if (scale != null) { + matrix = matrix.multiplied(Matrix4.diagonal3Values(scale!, scale!, 1)); + } + + return matrix; + } + + List toPayload() => toMatrix4().storage.toList(growable: false); +} + +final class BorderAccum { + double? topWidth; + double? rightWidth; + double? bottomWidth; + double? leftWidth; + Color? topColor; + Color? rightColor; + Color? bottomColor; + Color? leftColor; + + bool get hasStructure => + topWidth != null || + rightWidth != null || + bottomWidth != null || + leftWidth != null || + topColor != null || + rightColor != null || + bottomColor != null || + leftColor != null; + + void setAll(double width) { + topWidth = width; + rightWidth = width; + bottomWidth = width; + leftWidth = width; + } + + void setHorizontal(double width) { + leftWidth = width; + rightWidth = width; + } + + void setVertical(double width) { + topWidth = width; + bottomWidth = width; + } + + void setColor(Color color, String root) { + switch (root) { + case 'border': + topColor = color; + rightColor = color; + bottomColor = color; + leftColor = color; + case 'border-t': + topColor = color; + case 'border-r': + rightColor = color; + case 'border-b': + bottomColor = color; + case 'border-l': + leftColor = color; + case 'border-x': + leftColor = color; + rightColor = color; + case 'border-y': + topColor = color; + bottomColor = color; + } + } + + void inheritUnsetFrom(BorderAccum base) { + topWidth ??= base.topWidth; + rightWidth ??= base.rightWidth; + bottomWidth ??= base.bottomWidth; + leftWidth ??= base.leftWidth; + topColor ??= base.topColor; + rightColor ??= base.rightColor; + bottomColor ??= base.bottomColor; + leftColor ??= base.leftColor; + } + + JsonMap toPayload({required Color defaultColor}) { + JsonMap side(double width, Color? color) => { + 'color': payloadColor(color ?? defaultColor), + 'width': width, + 'style': BorderStyle.solid.name, + }; + + return { + if (topWidth != null || topColor != null) + 'top': side(topWidth ?? 0, topColor), + if (rightWidth != null || rightColor != null) + 'right': side(rightWidth ?? 0, rightColor), + if (bottomWidth != null || bottomColor != null) + 'bottom': side(bottomWidth ?? 0, bottomColor), + if (leftWidth != null || leftColor != null) + 'left': side(leftWidth ?? 0, leftColor), + }; + } +} diff --git a/packages/mix_tailwinds/lib/src/translate/tw_gradient.dart b/packages/mix_tailwinds/lib/src/translate/tw_gradient.dart new file mode 100644 index 0000000000..441f2f519f --- /dev/null +++ b/packages/mix_tailwinds/lib/src/translate/tw_gradient.dart @@ -0,0 +1,157 @@ +/// Gradient accumulation and post-decode Mix patching. +library; + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; + +import '../tw_config.dart'; + +const gradientDirections = { + 'to-t': (Alignment.bottomCenter, Alignment.topCenter), + 'to-tr': (Alignment.bottomLeft, Alignment.topRight), + 'to-r': (Alignment.centerLeft, Alignment.centerRight), + 'to-br': (Alignment.topLeft, Alignment.bottomRight), + 'to-b': (Alignment.topCenter, Alignment.bottomCenter), + 'to-bl': (Alignment.topRight, Alignment.bottomLeft), + 'to-l': (Alignment.centerRight, Alignment.centerLeft), + 'to-tl': (Alignment.bottomRight, Alignment.topLeft), +}; + +const _tailwindGradientAngles = { + 'to-r': 0, + 'to-br': math.pi / 4, + 'to-b': math.pi / 2, + 'to-bl': 3 * math.pi / 4, + 'to-l': math.pi, + 'to-tl': -3 * math.pi / 4, + 'to-t': -math.pi / 2, + 'to-tr': -math.pi / 4, +}; + +const _tailwindCornerDirections = {'to-br', 'to-bl', 'to-tr', 'to-tl'}; + +final class GradientAccum { + String? directionKey; + (Alignment, Alignment)? direction; + Color? fromColor; + Color? viaColor; + Color? toColor; + + bool get hasAnyPart => + direction != null || + directionKey != null || + fromColor != null || + viaColor != null || + toColor != null; + + bool get hasGradient => direction != null && fromColor != null; + + void inheritUnsetFrom(GradientAccum base) { + directionKey ??= base.directionKey; + direction ??= base.direction; + fromColor ??= base.fromColor; + viaColor ??= base.viaColor; + toColor ??= base.toColor; + } + + LinearGradientMix? toGradientMix(TwGradientStrategy strategy) { + if (!hasGradient) return null; + final colors = [fromColor!, ?viaColor, toColor ?? fromColor!]; + final stops = viaColor != null ? const [0.0, 0.5, 1.0] : const [0.0, 1.0]; + + if (strategy == TwGradientStrategy.angle && directionKey != null) { + final angle = _tailwindGradientAngles[directionKey!]; + if (angle != null) { + return LinearGradientMix( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + transform: angle == 0 ? null : GradientRotation(angle), + colors: colors, + stops: stops, + ); + } + } + + final useCssAngleRect = + strategy == TwGradientStrategy.cssAngleRect || + strategy.name == 'adaptive'; + if (useCssAngleRect && + directionKey != null && + _tailwindCornerDirections.contains(directionKey)) { + return LinearGradientMix( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + transform: TwCssKeywordLinearTransform(directionKey!), + colors: colors, + stops: stops, + ); + } + + final (begin, end) = direction!; + return LinearGradientMix( + begin: begin, + end: end, + colors: colors, + stops: stops, + ); + } +} + +@immutable +class TwCssKeywordLinearTransform extends GradientTransform { + const TwCssKeywordLinearTransform(this.directionKey); + + final String directionKey; + + @override + Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { + final w = bounds.width; + final h = bounds.height; + if (w <= 0 || h <= 0) return Matrix4.identity(); + + final (rawX, rawY) = _directionVector(directionKey, w, h); + final magnitude = math.sqrt((rawX * rawX) + (rawY * rawY)); + if (magnitude == 0) return Matrix4.identity(); + + final ux = rawX / magnitude; + final uy = rawY / magnitude; + final gradientLength = (w * ux.abs()) + (h * uy.abs()); + final scale = gradientLength / w; + final angle = math.atan2(uy, ux); + + return Matrix4.identity() + ..translateByDouble(bounds.center.dx, bounds.center.dy, 0, 1) + ..rotateZ(angle) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-bounds.center.dx, -bounds.center.dy, 0, 1); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TwCssKeywordLinearTransform && + directionKey == other.directionKey; + + @override + int get hashCode => directionKey.hashCode; + + static (double, double) _directionVector( + String directionKey, + double width, + double height, + ) { + return switch (directionKey) { + 'to-r' => (1, 0), + 'to-l' => (-1, 0), + 'to-b' => (0, 1), + 'to-t' => (0, -1), + 'to-br' => (height, width), + 'to-tr' => (height, -width), + 'to-bl' => (-height, width), + 'to-tl' => (-height, -width), + _ => (0, 1), + }; + } +} diff --git a/packages/mix_tailwinds/lib/src/translate/tw_presets.dart b/packages/mix_tailwinds/lib/src/translate/tw_presets.dart new file mode 100644 index 0000000000..8bcb36f939 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/translate/tw_presets.dart @@ -0,0 +1,114 @@ +/// Tailwind preset values that are not represented in the generated theme maps. +library; + +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; + +enum TextShadowPreset { twoXs, xs, sm, md, lg } + +const kTextShadowPresets = >{ + TextShadowPreset.twoXs: [ + Shadow(offset: Offset(0, 1), blurRadius: 0, color: Color(0x26000000)), + ], + TextShadowPreset.xs: [ + Shadow(offset: Offset(0, 1), blurRadius: 1, color: Color(0x33000000)), + ], + TextShadowPreset.sm: [ + Shadow(offset: Offset(0, 1), blurRadius: 0, color: Color(0x13000000)), + Shadow(offset: Offset(0, 1), blurRadius: 1, color: Color(0x13000000)), + Shadow(offset: Offset(0, 2), blurRadius: 2, color: Color(0x13000000)), + ], + TextShadowPreset.md: [ + Shadow(offset: Offset(0, 1), blurRadius: 1, color: Color(0x1A000000)), + Shadow(offset: Offset(0, 1), blurRadius: 2, color: Color(0x1A000000)), + Shadow(offset: Offset(0, 2), blurRadius: 4, color: Color(0x1A000000)), + ], + TextShadowPreset.lg: [ + Shadow(offset: Offset(0, 1), blurRadius: 2, color: Color(0x1A000000)), + Shadow(offset: Offset(0, 3), blurRadius: 2, color: Color(0x1A000000)), + Shadow(offset: Offset(0, 4), blurRadius: 8, color: Color(0x1A000000)), + ], +}; + +final kTailwindBoxShadowPresets = >{ + 'shadow-sm': [ + BoxShadowMix( + offset: const Offset(0, 1), + blurRadius: 2, + spreadRadius: 0, + color: const Color(0x0D000000), + ), + ], + 'shadow': [ + BoxShadowMix( + offset: const Offset(0, 1), + blurRadius: 3, + spreadRadius: 0, + color: const Color(0x1A000000), + ), + BoxShadowMix( + offset: const Offset(0, 1), + blurRadius: 2, + spreadRadius: 0, + color: const Color(0x0F000000), + ), + ], + 'shadow-md': [ + BoxShadowMix( + offset: const Offset(0, 4), + blurRadius: 6, + spreadRadius: -1, + color: const Color(0x1A000000), + ), + BoxShadowMix( + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -2, + color: const Color(0x1A000000), + ), + ], + 'shadow-lg': [ + BoxShadowMix( + offset: const Offset(0, 10), + blurRadius: 15, + spreadRadius: -3, + color: const Color(0x1A000000), + ), + BoxShadowMix( + offset: const Offset(0, 4), + blurRadius: 6, + spreadRadius: -4, + color: const Color(0x1A000000), + ), + ], + 'shadow-xl': [ + BoxShadowMix( + offset: const Offset(0, 20), + blurRadius: 25, + spreadRadius: -5, + color: const Color(0x1A000000), + ), + BoxShadowMix( + offset: const Offset(0, 8), + blurRadius: 10, + spreadRadius: -6, + color: const Color(0x1A000000), + ), + ], + 'shadow-2xl': [ + BoxShadowMix( + offset: const Offset(0, 25), + blurRadius: 50, + spreadRadius: -12, + color: const Color(0x40000000), + ), + ], + 'shadow-card': [ + BoxShadowMix( + offset: const Offset(0, 8), + blurRadius: 24, + spreadRadius: 0, + color: const Color(0x1F000000), + ), + ], +}; diff --git a/packages/mix_tailwinds/lib/src/translate/tw_routing.dart b/packages/mix_tailwinds/lib/src/translate/tw_routing.dart new file mode 100644 index 0000000000..2f72e64b03 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/translate/tw_routing.dart @@ -0,0 +1,73 @@ +/// Candidate routing decisions for the translator. +library; + +import '../parser/model.dart'; + +enum TwRouteKind { schemaValue, gradient, ignored, unsupported } + +final class TwRoute { + const TwRoute(this.kind, {this.reason}); + + final TwRouteKind kind; + final String? reason; +} + +TwRoute routeCandidate(TailwindCandidate candidate) { + if (_hasIgnoredVariant(candidate.variants)) { + return const TwRoute(TwRouteKind.ignored, reason: 'unsupported variant'); + } + if (_hasUnsupportedVariant(candidate.variants)) { + return const TwRoute(TwRouteKind.unsupported, reason: 'unknown variant'); + } + + final utility = candidate.utility; + final raw = utility.raw; + if (utility is TailwindArbitraryProperty) { + return const TwRoute(TwRouteKind.ignored, reason: 'arbitrary property'); + } + if (candidate.important) { + return const TwRoute(TwRouteKind.ignored, reason: 'important modifier'); + } + if (_isGradientToken(raw)) return const TwRoute(TwRouteKind.gradient); + if (utility is TailwindUnresolvedUtility) { + return const TwRoute(TwRouteKind.unsupported); + } + + return const TwRoute(TwRouteKind.schemaValue); +} + +bool _hasIgnoredVariant(List variants) { + for (final variant in variants) { + if (variant is TailwindArbitraryVariant) return true; + if (variant is TailwindFunctionalVariant && variant.root.startsWith('@')) { + return true; + } + if (variant is TailwindCompoundVariant) { + if (variant.root == 'group' || variant.root == 'peer') return true; + if (_hasIgnoredVariant([variant.variant])) return true; + } + } + + return false; +} + +bool _hasUnsupportedVariant(List variants) { + for (final variant in variants) { + if (variant is TailwindUnresolvedVariant) return true; + if (variant is TailwindCompoundVariant && + _hasUnsupportedVariant([variant.variant])) { + return true; + } + } + + return false; +} + +bool _isGradientToken(String raw) { + final base = raw.startsWith('-') ? raw.substring(1) : raw; + return base.startsWith('bg-gradient-') || + base.startsWith('bg-linear-') || + base.startsWith('from-') || + base.startsWith('via-') || + base.startsWith('to-'); +} diff --git a/packages/mix_tailwinds/lib/src/translate/tw_target.dart b/packages/mix_tailwinds/lib/src/translate/tw_target.dart new file mode 100644 index 0000000000..5032ac0285 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/translate/tw_target.dart @@ -0,0 +1,57 @@ +/// Target inference helpers shared by parser facade and widgets. +library; + +import '../tw_utils.dart'; + +enum TwTarget { box, flexBox, text } + +final _whitespaceRegex = RegExp(r'\s+'); + +const _boxUtilityPrefixes = [ + 'p-', + 'px-', + 'py-', + 'pt-', + 'pr-', + 'pb-', + 'pl-', + 'bg-', + 'border', + 'rounded', + 'shadow', + 'opacity-', + 'blur', +]; + +bool hasBoxUtilities(String classNames) { + final tokens = classNames.trim().isEmpty + ? const [] + : classNames.trim().split(_whitespaceRegex); + for (final token in tokens) { + final base = baseTokenOutsideBrackets(token); + for (final prefix in _boxUtilityPrefixes) { + if (base.startsWith(prefix) || base == prefix.replaceAll('-', '')) { + return true; + } + } + } + + return false; +} + +bool wantsFlex(Set tokens) { + for (final token in tokens) { + final base = baseTokenOutsideBrackets(token); + if (base == 'flex' || base == 'flex-row' || base == 'flex-col') { + return true; + } + if (base.startsWith('items-') || + base.startsWith('justify-') || + base.startsWith('gap-') || + base == 'gap') { + return true; + } + } + + return false; +} diff --git a/packages/mix_tailwinds/lib/src/translate/tw_translator.dart b/packages/mix_tailwinds/lib/src/translate/tw_translator.dart new file mode 100644 index 0000000000..4c3195187a --- /dev/null +++ b/packages/mix_tailwinds/lib/src/translate/tw_translator.dart @@ -0,0 +1,1285 @@ +/// Tailwind candidate to Mix styler translator. +library; + +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_schema/encode.dart'; +import 'package:mix_schema/mix_schema.dart'; + +import '../parser/candidate_parser.dart'; +import '../parser/data/parser_registry.g.dart'; +import '../parser/diagnostics.dart'; +import '../parser/model.dart'; +import '../theme/data/default_theme.g.dart'; +import '../tw_config.dart'; +import '../tw_types.dart'; +import '../tw_utils.dart'; +import 'tw_accumulators.dart'; +import 'tw_gradient.dart'; +import 'tw_presets.dart'; +import 'tw_routing.dart'; +import 'tw_target.dart'; + +final class TwTranslator { + TwTranslator({required this.config, this.onUnsupported}) + : _parser = TailwindCandidateParser( + registry: defaultTailwindParserRegistry, + ); + + final TwConfig config; + final TokenWarningCallback? onUnsupported; + final TailwindCandidateParser _parser; + final MixSchemaContract _schema = MixSchemaContractBuilder() + .builtIn() + .freeze(); + + List listTokens(String classNames) { + final trimmed = classNames.trim(); + if (trimmed.isEmpty) return const []; + return trimmed.split(RegExp(r'\s+')); + } + + BoxStyler translateBox(String classNames) { + return _translate( + classNames, + target: TwTarget.box, + empty: BoxStyler.new, + decode: _decodePayload, + merge: (base, other) => base.merge(other), + wrapVariant: _wrapBoxVariant, + applyGradient: (style, gradient) => style.gradient(gradient), + ); + } + + FlexBoxStyler translateFlex(String classNames) { + return _translate( + classNames, + target: TwTarget.flexBox, + empty: FlexBoxStyler.new, + decode: _decodePayload, + merge: (base, other) => base.merge(other), + wrapVariant: _wrapFlexVariant, + applyGradient: (style, gradient) => style.gradient(gradient), + afterBasePayload: (payload, context) { + if (!context.hasBaseFlex) payload['direction'] = Axis.vertical.name; + }, + ); + } + + TextStyler translateText(String classNames) { + return _translate( + classNames, + target: TwTarget.text, + empty: TextStyler.new, + decode: _decodePayload, + merge: (base, other) => base.merge(other), + wrapVariant: _wrapTextVariant, + applyGradient: (style, _) => style, + seedPayload: () => { + 'type': SchemaStyler.text.wireValue, + 'style': {'height': config.textDefaults.lineHeight}, + }, + ); + } + + JsonMap payloadBox(String classNames) { + return _payloadFor(classNames, target: TwTarget.box); + } + + JsonMap payloadFlex(String classNames) { + return _payloadFor(classNames, target: TwTarget.flexBox); + } + + JsonMap payloadText(String classNames) { + return _payloadFor(classNames, target: TwTarget.text); + } + + CurveAnimationConfig? parseAnimationFromTokens(List tokens) { + var hasTransition = false; + var hasTransitionNone = false; + var duration = const Duration(milliseconds: 150); + Curve curve = Curves.easeOut; + var delay = Duration.zero; + + for (final token in tokens) { + final base = baseTokenOutsideBrackets(token); + if (_transitionTriggerTokens.contains(base)) { + hasTransition = true; + } else if (base == 'transition-none') { + hasTransitionNone = true; + } else if (base.startsWith('duration-')) { + final ms = config.durationOf(base.substring(9)); + if (ms != null) { + duration = Duration(milliseconds: ms); + } else { + onUnsupported?.call(token); + } + } else if (_easeTokens.containsKey(base)) { + curve = _easeTokens[base]!; + } else if (base.startsWith('delay-')) { + final ms = config.delayOf(base.substring(6)); + if (ms != null) { + delay = Duration(milliseconds: ms); + } else { + onUnsupported?.call(token); + } + } + } + + if (hasTransitionNone || !hasTransition) return null; + return CurveAnimationConfig(duration: duration, curve: curve, delay: delay); + } + + JsonMap _payloadFor(String classNames, {required TwTarget target}) { + final groups = _buildGroups(classNames, target); + final base = groups[_VariantPath.base] ?? _GroupContext(target); + _finalizeGroupPayload(base); + return base.payload; + } + + S _translate( + String classNames, { + required TwTarget target, + required S Function() empty, + required S Function(JsonMap payload) decode, + required S Function(S base, S other) merge, + required S Function(List<_VariantPart> path, S style) wrapVariant, + required S Function(S style, LinearGradientMix gradient) applyGradient, + JsonMap Function()? seedPayload, + void Function(JsonMap payload, _GroupContext context)? afterBasePayload, + }) { + final groups = _buildGroups(classNames, target, seedPayload: seedPayload); + final baseContext = + groups[_VariantPath.base] ?? + _GroupContext(target, seedPayload: seedPayload); + afterBasePayload?.call(baseContext.payload, baseContext); + + final hasVariantTransform = groups.entries.any( + (entry) => + entry.key != _VariantPath.base && + entry.value.transform.hasAnyTransform, + ); + if (hasVariantTransform && !baseContext.transform.hasAnyTransform) { + baseContext.transform.needsIdentity = true; + } + + _finalizeGroupPayload(baseContext); + var result = decode(baseContext.payload); + final baseGradient = baseContext.gradient.toGradientMix( + config.gradientStrategy, + ); + if (baseGradient != null) result = applyGradient(result, baseGradient); + + for (final entry in groups.entries) { + if (entry.key == _VariantPath.base) continue; + final context = entry.value; + if (context.transform.hasAnyTransform && + baseContext.transform.hasAnyTransform) { + context.transform.inheritUnsetFrom(baseContext.transform); + } + if (context.border.hasStructure && baseContext.border.hasStructure) { + context.border.inheritUnsetFrom(baseContext.border); + } + if (context.gradient.hasAnyPart && baseContext.gradient.hasAnyPart) { + context.gradient.inheritUnsetFrom(baseContext.gradient); + } + _finalizeGroupPayload(context); + var child = decode(context.payload); + final gradient = context.gradient.toGradientMix(config.gradientStrategy); + if (gradient != null) child = applyGradient(child, gradient); + result = merge(result, wrapVariant(entry.key.parts, child)); + } + + return result; + } + + Map<_VariantPath, _GroupContext> _buildGroups( + String classNames, + TwTarget target, { + JsonMap Function()? seedPayload, + }) { + final groups = <_VariantPath, _GroupContext>{}; + _GroupContext groupFor(_VariantPath path) { + return groups.putIfAbsent(path, () { + final context = _GroupContext(target, seedPayload: seedPayload); + return context; + }); + } + + for (final token in listTokens(classNames)) { + final parsed = _parser.parseCandidate(token); + if (parsed is TailwindParseFailure) { + onUnsupported?.call(token); + continue; + } + + final candidate = (parsed as TailwindParseSuccess).candidate; + final route = routeCandidate(candidate); + if (route.kind == TwRouteKind.ignored) { + if (route.reason == 'important modifier') onUnsupported?.call(token); + continue; + } + if (route.kind == TwRouteKind.unsupported) { + if (!_applyWidgetLayerToken(token, target)) onUnsupported?.call(token); + continue; + } + + final path = _variantPath(candidate.variants); + if (path == null) continue; + final group = groupFor(path); + + if (route.kind == TwRouteKind.gradient) { + if (!_applyGradient(group.gradient, candidate)) { + onUnsupported?.call(token); + } + continue; + } + + final handled = _applySchemaCandidate(group, candidate, target); + if (!handled && !_applyWidgetLayerToken(token, target)) { + onUnsupported?.call(token); + } + } + + return groups; + } + + bool _applySchemaCandidate( + _GroupContext group, + TailwindCandidate candidate, + TwTarget target, + ) { + final utility = candidate.utility; + final raw = utility.raw; + final root = _utilityRoot(utility); + final value = _utilityValue(utility); + final modifier = _utilityModifier(utility); + final negative = _utilityNegative(utility); + + if (target == TwTarget.flexBox) { + if (_applyFlexUtility(group, raw, root, value, modifier, negative)) { + return true; + } + } + + if (target == TwTarget.text) { + return _applyTextUtility(group.payload, raw, root, value, modifier); + } + + return _applyBoxLikeUtility(group, raw, root, value, modifier, negative); + } + + bool _applyFlexUtility( + _GroupContext group, + String raw, + String root, + TailwindValue? value, + TailwindModifier? modifier, + bool negative, + ) { + final payload = group.payload; + switch (raw) { + case 'flex': + case 'flex-row': + payload['direction'] = Axis.horizontal.name; + group.hasBaseFlex = true; + return true; + case 'flex-col': + payload['direction'] = Axis.vertical.name; + group.hasBaseFlex = true; + return true; + case 'items-start': + payload['crossAxisAlignment'] = CrossAxisAlignment.start.name; + return true; + case 'items-center': + payload['crossAxisAlignment'] = CrossAxisAlignment.center.name; + return true; + case 'items-end': + payload['crossAxisAlignment'] = CrossAxisAlignment.end.name; + return true; + case 'items-stretch': + payload['crossAxisAlignment'] = CrossAxisAlignment.stretch.name; + return true; + case 'items-baseline': + payload['crossAxisAlignment'] = CrossAxisAlignment.baseline.name; + payload['textBaseline'] = TextBaseline.alphabetic.name; + return true; + case 'justify-start': + payload['mainAxisAlignment'] = MainAxisAlignment.start.name; + return true; + case 'justify-center': + payload['mainAxisAlignment'] = MainAxisAlignment.center.name; + return true; + case 'justify-end': + payload['mainAxisAlignment'] = MainAxisAlignment.end.name; + return true; + case 'justify-between': + payload['mainAxisAlignment'] = MainAxisAlignment.spaceBetween.name; + return true; + case 'justify-around': + payload['mainAxisAlignment'] = MainAxisAlignment.spaceAround.name; + return true; + case 'justify-evenly': + payload['mainAxisAlignment'] = MainAxisAlignment.spaceEvenly.name; + return true; + } + + if (root == 'gap') { + final length = _spaceLength(value, negative: negative); + if (length == null) return false; + payload['spacing'] = length; + return true; + } + + return false; + } + + bool _applyBoxLikeUtility( + _GroupContext group, + String raw, + String root, + TailwindValue? value, + TailwindModifier? modifier, + bool negative, + ) { + final payload = group.payload; + + if (_applySpacing(payload, root, value, negative: negative)) return true; + if (_applySizing(payload, root, value, negative: negative)) return true; + if (_applyBorder(group, raw, root, value, modifier)) return true; + if (_applyRadius(payload, root, value)) return true; + if (_applyTransform(group.transform, root, value, negative)) return true; + + switch (root) { + case 'bg': + final color = _color(value, modifier); + if (color == null) return false; + _decoration(payload)['color'] = payloadColor(color); + return true; + case 'opacity': + final opacity = _opacity(value); + if (opacity == null) return false; + _modifiers(payload).add({'type': 'opacity', 'opacity': opacity}); + return true; + case 'blur': + final sigma = _blur(value); + if (sigma == null) return false; + _modifiers(payload).add({'type': 'blur', 'sigma': sigma}); + return true; + case 'shadow': + final shadows = _boxShadowPayload(raw, value); + if (shadows == null) return false; + _decoration(payload)['boxShadow'] = shadows; + return true; + case 'text': + case 'size': + return _applyDefaultTextUtility(payload, root, value, modifier); + } + + if (raw == 'overflow-hidden' || raw == 'overflow-clip') { + payload['clipBehavior'] = Clip.hardEdge.name; + return true; + } + if (raw == 'overflow-visible') { + payload['clipBehavior'] = Clip.none.name; + return true; + } + if (_applyDefaultTextStatic(payload, raw)) return true; + + return false; + } + + bool _applyTextUtility( + JsonMap payload, + String raw, + String root, + TailwindValue? value, + TailwindModifier? modifier, + ) { + if (root == 'text' || root == 'size') { + if (_applyTextStyleUtility(() => _textStyle(payload), value, modifier)) { + return true; + } + } + + switch (raw) { + case 'text-left': + payload['textAlign'] = TextAlign.left.name; + return true; + case 'text-center': + payload['textAlign'] = TextAlign.center.name; + return true; + case 'text-right': + payload['textAlign'] = TextAlign.right.name; + return true; + case 'text-justify': + payload['textAlign'] = TextAlign.justify.name; + return true; + case 'text-start': + payload['textAlign'] = TextAlign.start.name; + return true; + case 'text-end': + payload['textAlign'] = TextAlign.end.name; + return true; + case 'uppercase': + case 'lowercase': + case 'capitalize': + _textDirectives(payload).add(raw); + return true; + case 'truncate': + payload['overflow'] = TextOverflow.ellipsis.name; + payload['maxLines'] = 1; + payload['softWrap'] = false; + return true; + case 'leading-even': + payload['textHeightBehavior'] = { + 'leadingDistribution': TextLeadingDistribution.even.name, + }; + return true; + case 'leading-trim': + payload['textHeightBehavior'] = { + 'leadingDistribution': TextLeadingDistribution.even.name, + 'applyHeightToFirstAscent': false, + 'applyHeightToLastDescent': false, + }; + return true; + } + + if (_applyFontWeight(_textStyle(payload), raw)) return true; + if (_applyLineHeight(_textStyle(payload), raw)) return true; + if (_applyTracking(_textStyle(payload), raw)) return true; + if (_applyTextShadow(_textStyle(payload), raw)) return true; + + return false; + } + + bool _applyDefaultTextUtility( + JsonMap payload, + String root, + TailwindValue? value, + TailwindModifier? modifier, + ) { + if (root == 'text' || root == 'size') { + return _applyTextStyleUtility( + () => _defaultTextStyle(payload), + value, + modifier, + ); + } + + return false; + } + + bool _applyTextStyleUtility( + JsonMap Function() style, + TailwindValue? value, + TailwindModifier? modifier, + ) { + final key = _valueKey(value); + final size = key == null ? null : config.fontSizes[key]; + if (size != null) { + final target = style(); + target['fontSize'] = size; + final lineHeight = twDefaultLineHeights[key]; + if (lineHeight != null) target['height'] = lineHeight; + return true; + } + + final arbitraryLength = _arbitraryLength(value); + if (arbitraryLength != null) { + style()['fontSize'] = arbitraryLength; + return true; + } + + final color = _color(value, modifier); + if (color != null) { + style()['color'] = payloadColor(color); + return true; + } + + return false; + } + + bool _applyDefaultTextStatic(JsonMap payload, String raw) { + final style = _defaultTextStyle(payload); + if (_applyFontWeight(style, raw)) return true; + if (_applyTextShadow(style, raw)) return true; + return false; + } + + bool _applySpacing( + JsonMap payload, + String root, + TailwindValue? value, { + required bool negative, + }) { + final field = switch (root) { + 'p' || 'px' || 'py' || 'pt' || 'pr' || 'pb' || 'pl' => 'padding', + 'm' || 'mx' || 'my' || 'mt' || 'mr' || 'mb' || 'ml' => 'margin', + _ => null, + }; + if (field == null) return false; + final length = _spaceLength(value, negative: negative); + if (length == null) return false; + if (field == 'margin' && length < 0) return true; + _setEdge(payload, field, length, sides: _axisOrSide(root)); + return true; + } + + bool _applySizing( + JsonMap payload, + String root, + TailwindValue? value, { + required bool negative, + }) { + if (!_sizingRoots.contains(root)) return false; + if (negative) return false; + final length = + _spaceLength(value, negative: false) ?? _arbitraryLength(value); + if (length == null) return _isWidgetLayerSize(value); + + final constraints = _objectField(payload, 'constraints'); + switch (root) { + case 'w': + constraints['minWidth'] = length; + constraints['maxWidth'] = length; + return true; + case 'h': + constraints['minHeight'] = length; + constraints['maxHeight'] = length; + return true; + case 'min-w': + constraints['minWidth'] = length; + return true; + case 'min-h': + constraints['minHeight'] = length; + return true; + case 'max-w': + constraints['maxWidth'] = length; + return true; + case 'max-h': + constraints['maxHeight'] = length; + return true; + } + + return false; + } + + bool _applyRadius(JsonMap payload, String root, TailwindValue? value) { + if (!root.startsWith('rounded')) return false; + final key = _valueKey(value) ?? ''; + final radius = config.radii[key]; + if (radius == null) return false; + final borderRadius = _objectField(_decoration(payload), 'borderRadius'); + switch (root) { + case 'rounded': + borderRadius + ..['topLeft'] = radius + ..['topRight'] = radius + ..['bottomLeft'] = radius + ..['bottomRight'] = radius; + case 'rounded-t': + borderRadius + ..['topLeft'] = radius + ..['topRight'] = radius; + case 'rounded-b': + borderRadius + ..['bottomLeft'] = radius + ..['bottomRight'] = radius; + case 'rounded-l': + borderRadius + ..['topLeft'] = radius + ..['bottomLeft'] = radius; + case 'rounded-r': + borderRadius + ..['topRight'] = radius + ..['bottomRight'] = radius; + case 'rounded-tl': + borderRadius['topLeft'] = radius; + case 'rounded-tr': + borderRadius['topRight'] = radius; + case 'rounded-bl': + borderRadius['bottomLeft'] = radius; + case 'rounded-br': + borderRadius['bottomRight'] = radius; + default: + return false; + } + return true; + } + + bool _applyBorder( + _GroupContext group, + String raw, + String root, + TailwindValue? value, + TailwindModifier? modifier, + ) { + if (!root.startsWith('border')) return false; + final key = _valueKey(value) ?? ''; + final color = _color(value, modifier); + final width = config.borderWidths[key] ?? (key.isEmpty ? 1.0 : null); + + if (color != null && width == null) { + group.border.setColor(color, root); + return true; + } + if (width == null) return false; + + switch (root) { + case 'border': + group.border.setAll(width); + case 'border-t': + group.border.topWidth = width; + case 'border-r': + group.border.rightWidth = width; + case 'border-b': + group.border.bottomWidth = width; + case 'border-l': + group.border.leftWidth = width; + case 'border-x': + group.border.setHorizontal(width); + case 'border-y': + group.border.setVertical(width); + default: + return raw.startsWith('border-') && color != null; + } + return true; + } + + bool _applyTransform( + TransformAccum transform, + String root, + TailwindValue? value, + bool negative, + ) { + final key = _valueKey(value); + switch (root) { + case 'scale': + final scale = key == null ? null : config.scaleOf(key); + if (scale == null) return false; + transform.scale = scale; + return true; + case 'rotate': + final rotate = key == null ? null : config.rotationOf(key); + if (rotate == null) return false; + transform.rotateDeg = negative ? -rotate : rotate; + return true; + case 'translate-x': + final length = _spaceLength(value, negative: negative); + if (length == null) return false; + transform.translateX = length; + return true; + case 'translate-y': + final length = _spaceLength(value, negative: negative); + if (length == null) return false; + transform.translateY = length; + return true; + } + return false; + } + + bool _applyGradient(GradientAccum gradient, TailwindCandidate candidate) { + final utility = candidate.utility; + final raw = utility.raw; + final root = _utilityRoot(utility); + final value = _utilityValue(utility); + final key = _valueKey(value); + + if (raw.startsWith('bg-gradient-')) { + final directionKey = raw.substring(12); + final direction = gradientDirections[directionKey]; + if (direction == null) return false; + gradient.directionKey = directionKey; + gradient.direction = direction; + return true; + } else if (root == 'bg-linear' || raw.startsWith('bg-linear-')) { + final directionKey = key ?? raw.substring(10); + final direction = gradientDirections[directionKey]; + if (direction == null) return false; + gradient.directionKey = directionKey; + gradient.direction = direction; + return true; + } else if (root == 'from') { + final color = _color(value, _utilityModifier(utility)); + if (color == null) return false; + gradient.fromColor = color; + return true; + } else if (root == 'via') { + final color = _color(value, _utilityModifier(utility)); + if (color == null) return false; + gradient.viaColor = color; + return true; + } else if (root == 'to') { + final color = _color(value, _utilityModifier(utility)); + if (color == null) return false; + gradient.toColor = color; + return true; + } + return false; + } + + void _finalizeGroupPayload(_GroupContext group) { + if (group.transform.hasAnyTransform) { + group.payload['transform'] = group.transform.toPayload(); + } + if (group.border.hasStructure) { + _decoration(group.payload)['border'] = group.border.toPayload( + defaultColor: config.colorOf('gray-200') ?? const Color(0xFFE5E7EB), + ); + } + } + + T _decodePayload(JsonMap payload) { + final result = _schema.decode(payload); + return switch (result) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => throw StateError( + 'Tailwinds emitted an invalid schema payload: $errors', + ), + }; + } + + S _wrapBoxVariant(List<_VariantPart> path, S style) { + var wrapped = style as BoxStyler; + for (final part in path.reversed) { + wrapped = _newBoxVariant(part, wrapped); + } + return wrapped as S; + } + + S _wrapFlexVariant(List<_VariantPart> path, S style) { + var wrapped = style as FlexBoxStyler; + for (final part in path.reversed) { + wrapped = _newFlexVariant(part, wrapped); + } + return wrapped as S; + } + + S _wrapTextVariant(List<_VariantPart> path, S style) { + var wrapped = style as TextStyler; + for (final part in path.reversed) { + wrapped = _newTextVariant(part, wrapped); + } + return wrapped as S; + } + + BoxStyler _newBoxVariant(_VariantPart part, BoxStyler style) { + return switch (part.kind) { + _VariantKind.hover => BoxStyler().onHovered(style), + _VariantKind.focus => BoxStyler().onFocused(style), + _VariantKind.pressed => BoxStyler().onPressed(style), + _VariantKind.disabled => BoxStyler().onDisabled(style), + _VariantKind.enabled => BoxStyler().onEnabled(style), + _VariantKind.dark => BoxStyler().onDark(style), + _VariantKind.light => BoxStyler().onLight(style), + _VariantKind.breakpoint => BoxStyler().onBreakpoint( + Breakpoint(minWidth: part.breakpoint!), + style, + ), + _VariantKind.notHover => BoxStyler().onNot( + ContextVariant.widgetState(WidgetState.hovered), + style, + ), + }; + } + + FlexBoxStyler _newFlexVariant(_VariantPart part, FlexBoxStyler style) { + return switch (part.kind) { + _VariantKind.hover => FlexBoxStyler().onHovered(style), + _VariantKind.focus => FlexBoxStyler().onFocused(style), + _VariantKind.pressed => FlexBoxStyler().onPressed(style), + _VariantKind.disabled => FlexBoxStyler().onDisabled(style), + _VariantKind.enabled => FlexBoxStyler().onEnabled(style), + _VariantKind.dark => FlexBoxStyler().onDark(style), + _VariantKind.light => FlexBoxStyler().onLight(style), + _VariantKind.breakpoint => FlexBoxStyler().onBreakpoint( + Breakpoint(minWidth: part.breakpoint!), + style, + ), + _VariantKind.notHover => FlexBoxStyler().onNot( + ContextVariant.widgetState(WidgetState.hovered), + style, + ), + }; + } + + TextStyler _newTextVariant(_VariantPart part, TextStyler style) { + return switch (part.kind) { + _VariantKind.hover => TextStyler().onHovered(style), + _VariantKind.focus => TextStyler().onFocused(style), + _VariantKind.pressed => TextStyler().onPressed(style), + _VariantKind.disabled => TextStyler().onDisabled(style), + _VariantKind.enabled => TextStyler().onEnabled(style), + _VariantKind.dark => TextStyler().onDark(style), + _VariantKind.light => TextStyler().onLight(style), + _VariantKind.breakpoint => TextStyler().onBreakpoint( + Breakpoint(minWidth: part.breakpoint!), + style, + ), + _VariantKind.notHover => TextStyler().onNot( + ContextVariant.widgetState(WidgetState.hovered), + style, + ), + }; + } + + _VariantPath? _variantPath(List variants) { + final parts = <_VariantPart>[]; + for (final variant in variants) { + final part = _variantPart(variant); + if (part == null) return null; + parts.add(part); + } + return parts.isEmpty ? _VariantPath.base : _VariantPath(parts); + } + + _VariantPart? _variantPart(TailwindVariant variant) { + if (variant is TailwindStaticVariant) { + final breakpoint = config.breakpoints[variant.root]; + if (breakpoint != null) { + return _VariantPart.breakpoint(variant.root, breakpoint); + } + return switch (variant.root) { + 'hover' => const _VariantPart(_VariantKind.hover, 'hover'), + 'focus' || + 'focus-visible' => const _VariantPart(_VariantKind.focus, 'focus'), + 'active' || + 'pressed' => const _VariantPart(_VariantKind.pressed, 'pressed'), + 'disabled' => const _VariantPart(_VariantKind.disabled, 'disabled'), + 'enabled' => const _VariantPart(_VariantKind.enabled, 'enabled'), + 'dark' || + 'theme-midnight' => const _VariantPart(_VariantKind.dark, 'dark'), + 'light' => const _VariantPart(_VariantKind.light, 'light'), + _ => null, + }; + } + if (variant is TailwindCompoundVariant && variant.root == 'not') { + final child = variant.variant; + if (child is TailwindStaticVariant && child.root == 'hover') { + return const _VariantPart(_VariantKind.notHover, 'not-hover'); + } + } + return null; + } + + bool _applyWidgetLayerToken(String token, TwTarget target) { + final base = baseTokenOutsideBrackets(token); + if (_transitionTriggerTokens.contains(base) || + base == 'transition-none' || + _easeTokens.containsKey(base) || + base.startsWith('duration-') || + base.startsWith('delay-')) { + return true; + } + if (base.startsWith('flex-') || + base.startsWith('basis-') || + base.startsWith('self-') || + base.startsWith('shrink') || + base.startsWith('grow')) { + return true; + } + if (base.startsWith('gap-x-') || base.startsWith('gap-y-')) return true; + if (base.startsWith('w-') || base.startsWith('h-')) { + final key = base.substring(2); + return key == 'full' || + key == 'screen' || + key == 'auto' || + key.contains('/'); + } + return target == TwTarget.flexBox && base == 'block'; + } + + String _utilityRoot(TailwindUtility utility) { + return switch (utility) { + TailwindStaticUtility(:final root) => root, + TailwindFunctionalUtility(:final root) => root, + TailwindUnresolvedUtility(:final segments) => + segments.isEmpty ? utility.raw : segments.first, + TailwindArbitraryProperty(:final property) => property, + }; + } + + TailwindValue? _utilityValue(TailwindUtility utility) { + return switch (utility) { + TailwindFunctionalUtility(:final value) => value, + _ => null, + }; + } + + TailwindModifier? _utilityModifier(TailwindUtility utility) { + return switch (utility) { + TailwindFunctionalUtility(:final modifier) => modifier, + TailwindUnresolvedUtility(:final modifier) => modifier, + TailwindArbitraryProperty(:final modifier) => modifier, + _ => null, + }; + } + + bool _utilityNegative(TailwindUtility utility) { + return switch (utility) { + TailwindFunctionalUtility(:final negative) => negative, + TailwindUnresolvedUtility(:final negative) => negative, + _ => false, + }; + } + + String? _valueKey(TailwindValue? value) { + return value is TailwindNamedValue ? value.raw : null; + } + + double? _spaceLength(TailwindValue? value, {required bool negative}) { + final key = _valueKey(value); + final resolved = key == null ? null : config.space[key]; + if (resolved == null) return _arbitraryLength(value); + return negative ? -resolved : resolved; + } + + double? _arbitraryLength(TailwindValue? value) { + if (value is! TailwindArbitraryValue) return null; + final raw = value.value; + final match = RegExp(r'^(-?\d+\.?\d*)(px|rem|em)?$').firstMatch(raw); + if (match == null) return null; + var number = double.parse(match.group(1)!); + final unit = match.group(2) ?? 'px'; + if (unit == 'rem' || unit == 'em') number *= 16; + return number; + } + + Color? _color(TailwindValue? value, TailwindModifier? modifier) { + if (value is TailwindArbitraryValue) { + final parsed = _hexColor(value.value); + return _applyOpacity(parsed, modifier); + } + if (value is TailwindCssVariableValue) return null; + final key = _valueKey(value); + if (key == null || key.isEmpty) return null; + return _applyOpacity(config.colorOf(key), modifier); + } + + Color? _applyOpacity(Color? color, TailwindModifier? modifier) { + if (color == null || modifier == null) return color; + final raw = switch (modifier) { + TailwindNamedModifier(:final raw) => raw, + TailwindArbitraryModifier(:final value) => value.replaceAll('%', ''), + TailwindCssVariableModifier() => null, + }; + if (raw == null) return null; + final opacity = double.tryParse(raw); + if (opacity == null || opacity < 0 || opacity > 100) return null; + return color.withAlpha((opacity * 255 / 100).round()); + } + + Color? _hexColor(String value) { + if (!value.startsWith('#')) return null; + final hex = value.substring(1); + if (hex.length != 3 && + hex.length != 4 && + hex.length != 6 && + hex.length != 8) { + return null; + } + if (int.tryParse(hex, radix: 16) == null) return null; + + String expand(int index) => '${hex[index]}${hex[index]}'; + final r = hex.length <= 4 ? expand(0) : hex.substring(0, 2); + final g = hex.length <= 4 ? expand(1) : hex.substring(2, 4); + final b = hex.length <= 4 ? expand(2) : hex.substring(4, 6); + final a = switch (hex.length) { + 4 => expand(3), + 8 => hex.substring(6, 8), + _ => 'ff', + }; + final argb = int.parse('$a$r$g$b', radix: 16); + return Color(argb); + } + + double? _opacity(TailwindValue? value) { + final key = _valueKey(value); + if (key == null) return null; + final numeric = double.tryParse(key); + if (numeric == null) return null; + return numeric / 100; + } + + double? _blur(TailwindValue? value) { + final key = _valueKey(value) ?? ''; + return config.blurOf(key); + } + + List? _boxShadowPayload(String raw, TailwindValue? value) { + final key = raw == 'shadow' ? 'shadow' : 'shadow-${_valueKey(value)}'; + final shadows = raw == 'shadow-none' + ? const [] + : kTailwindBoxShadowPresets[key]; + if (shadows == null) return null; + final encoded = _schema.encode(BoxStyler().boxShadows(shadows)); + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure() => null, + }; + final decoration = payload?['decoration'] as JsonMap?; + return (decoration?['boxShadow'] as List?)?.cast() ?? + const []; + } + + bool _applyFontWeight(JsonMap style, String raw) { + final weight = switch (raw) { + 'font-thin' => 'w100', + 'font-extralight' => 'w200', + 'font-light' => 'w300', + 'font-normal' => 'w400', + 'font-medium' => 'w500', + 'font-semibold' => 'w600', + 'font-bold' => 'w700', + 'font-extrabold' => 'w800', + 'font-black' => 'w900', + _ => null, + }; + if (weight == null) return false; + style['fontWeight'] = weight; + return true; + } + + bool _applyLineHeight(JsonMap style, String raw) { + final height = switch (raw) { + 'leading-none' => 1.0, + 'leading-tight' => 1.25, + 'leading-snug' => 1.375, + 'leading-normal' => 1.5, + 'leading-relaxed' => 1.625, + 'leading-loose' => 2.0, + _ => null, + }; + if (height == null) return false; + style['height'] = height; + return true; + } + + bool _applyTracking(JsonMap style, String raw) { + final tracking = switch (raw) { + 'tracking-tighter' => -0.8, + 'tracking-tight' => -0.4, + 'tracking-normal' => 0.0, + 'tracking-wide' => 0.4, + 'tracking-wider' => 0.8, + 'tracking-widest' => 1.6, + _ => null, + }; + if (tracking == null) return false; + style['letterSpacing'] = tracking; + return true; + } + + bool _applyTextShadow(JsonMap style, String raw) { + final preset = switch (raw) { + 'text-shadow-none' => null, + 'text-shadow-2xs' => TextShadowPreset.twoXs, + 'text-shadow-xs' => TextShadowPreset.xs, + 'text-shadow-sm' => TextShadowPreset.sm, + 'text-shadow-md' => TextShadowPreset.md, + 'text-shadow-lg' => TextShadowPreset.lg, + _ => _missingTextShadowPreset, + }; + if (identical(preset, _missingTextShadowPreset)) return false; + final shadows = preset == null + ? const [] + : kTextShadowPresets[preset]! + .map( + (shadow) => ShadowMix( + color: shadow.color, + offset: shadow.offset, + blurRadius: shadow.blurRadius, + ), + ) + .toList(); + final encoded = _schema.encode(TextStyler().shadows(shadows)); + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure() => null, + }; + final encodedStyle = payload?['style'] as JsonMap?; + style['shadows'] = + (encodedStyle?['shadows'] as List?)?.cast() ?? + const []; + return true; + } + + bool _isWidgetLayerSize(TailwindValue? value) { + final key = _valueKey(value); + return key == 'full' || + key == 'screen' || + key == 'auto' || + key?.contains('/') == true; + } + + void _setEdge( + JsonMap payload, + String field, + double value, { + required String sides, + }) { + final data = _objectField(payload, field); + switch (sides) { + case 'all': + data + ..['left'] = value + ..['top'] = value + ..['right'] = value + ..['bottom'] = value; + case 'x': + data + ..['left'] = value + ..['right'] = value; + case 'y': + data + ..['top'] = value + ..['bottom'] = value; + default: + data[sides] = value; + } + } + + String _axisOrSide(String root) { + if (root.length == 1) return 'all'; + return switch (root.substring(root.length - 1)) { + 'x' => 'x', + 'y' => 'y', + 't' => 'top', + 'r' => 'right', + 'b' => 'bottom', + 'l' => 'left', + _ => 'all', + }; + } + + JsonMap _decoration(JsonMap payload) => _objectField(payload, 'decoration'); + JsonMap _textStyle(JsonMap payload) => _objectField(payload, 'style'); + + JsonMap _defaultTextStyle(JsonMap payload) { + final modifiers = _modifiers(payload); + for (final modifier in modifiers) { + if (modifier['type'] == 'default_text_style') { + return _objectField(modifier, 'style'); + } + } + final modifier = { + 'type': 'default_text_style', + 'style': {}, + }; + modifiers.add(modifier); + return modifier['style']! as JsonMap; + } + + List _modifiers(JsonMap payload) { + return (payload['modifiers'] ??= []) as List; + } + + List _textDirectives(JsonMap payload) { + return (payload['textDirectives'] ??= []) as List; + } + + JsonMap _objectField(JsonMap payload, String field) { + return (payload[field] ??= {}) as JsonMap; + } +} + +const _transitionTriggerTokens = { + 'transition', + 'transition-all', + 'transition-colors', + 'transition-opacity', + 'transition-shadow', + 'transition-transform', +}; + +const _sizingRoots = {'w', 'h', 'min-w', 'min-h', 'max-w', 'max-h'}; + +const _easeTokens = { + 'ease-linear': Curves.linear, + 'ease-in': Curves.easeIn, + 'ease-out': Curves.easeOut, + 'ease-in-out': Curves.easeInOut, +}; + +const Object _missingTextShadowPreset = Object(); + +final class _GroupContext { + _GroupContext(this.target, {JsonMap Function()? seedPayload}) + : payload = seedPayload?.call() ?? _payloadForTarget(target); + + final TwTarget target; + final JsonMap payload; + final transform = TransformAccum(); + final border = BorderAccum(); + final gradient = GradientAccum(); + bool hasBaseFlex = false; + + static JsonMap _payloadForTarget(TwTarget target) { + return { + 'type': switch (target) { + TwTarget.box => SchemaStyler.box.wireValue, + TwTarget.flexBox => SchemaStyler.flexBox.wireValue, + TwTarget.text => SchemaStyler.text.wireValue, + }, + }; + } +} + +enum _VariantKind { + hover, + focus, + pressed, + disabled, + enabled, + dark, + light, + breakpoint, + notHover, +} + +final class _VariantPart { + const _VariantPart(this.kind, this.key, {this.breakpoint}); + const _VariantPart.breakpoint(String key, double breakpoint) + : this(_VariantKind.breakpoint, key, breakpoint: breakpoint); + + final _VariantKind kind; + final String key; + final double? breakpoint; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _VariantPart && + kind == other.kind && + key == other.key && + breakpoint == other.breakpoint; + + @override + int get hashCode => Object.hash(kind, key, breakpoint); +} + +final class _VariantPath { + const _VariantPath(this.parts); + + static const base = _VariantPath([]); + + final List<_VariantPart> parts; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _VariantPath && + parts.length == other.parts.length && + _partsEqual(parts, other.parts); + + @override + int get hashCode => Object.hashAll(parts); +} + +bool _partsEqual(List<_VariantPart> a, List<_VariantPart> b) { + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/packages/mix_tailwinds/lib/src/tw_config.dart b/packages/mix_tailwinds/lib/src/tw_config.dart index 948109cc68..994c6ace01 100644 --- a/packages/mix_tailwinds/lib/src/tw_config.dart +++ b/packages/mix_tailwinds/lib/src/tw_config.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'theme/data/default_theme.g.dart'; + /// Controls how Tailwind directional gradients (`to-*`) are mapped in Flutter. enum TwGradientStrategy { /// Uses direct begin/end alignments (for example, `to-br` -> topLeft to bottomRight). @@ -280,167 +282,17 @@ class TwConfig { } static final TwConfig _standard = TwConfig( - space: { - '0': 0, - 'px': 1, - '0.5': 2, - '1': 4, - '1.5': 6, - '2': 8, - '2.5': 10, - '3': 12, - '3.5': 14, - '4': 16, - '5': 20, - '6': 24, - '7': 28, - '8': 32, - '9': 36, - '10': 40, - '11': 44, - '12': 48, - '14': 56, - '16': 64, - '20': 80, - '24': 96, - '28': 112, - '32': 128, - '36': 144, - '40': 160, - '44': 176, - '48': 192, - '52': 208, - '56': 224, - '60': 240, - '64': 256, - '72': 288, - '80': 320, - '96': 384, - }, - radii: { - 'none': 0, - '': 4, - 'sm': 2, - 'md': 6, - 'lg': 8, - 'xl': 12, - '2xl': 16, - '3xl': 24, - '4xl': 28, - 'full': 9999, - }, - borderWidths: {'0': 0, '': 1, '2': 2, '4': 4, '8': 8}, - breakpoints: {'sm': 640, 'md': 768, 'lg': 1024, 'xl': 1280, '2xl': 1536}, - fontSizes: { - 'xs': 12, - 'sm': 14, - 'base': 16, - 'lg': 18, - 'xl': 20, - '2xl': 24, - '3xl': 30, - '4xl': 36, - '5xl': 48, - '6xl': 60, - '7xl': 72, - '8xl': 96, - '9xl': 128, - }, - colors: { - // Slate - 'slate-300': Color(0xFFCBD5E1), - 'slate-600': Color(0xFF475569), - 'slate-700': Color(0xFF334155), - 'slate-800': Color(0xFF1E293B), - 'slate-900': Color(0xFF0F172A), - // Gray - 'gray-100': Color(0xFFF3F4F6), - 'gray-200': Color(0xFFE5E7EB), - 'gray-500': Color(0xFF6B7280), - 'gray-700': Color(0xFF374151), - // Blue - 'blue-50': Color(0xFFEFF6FF), - 'blue-100': Color(0xFFDBEAFE), - 'blue-500': Color(0xFF3B82F6), - 'blue-600': Color(0xFF2563EB), - 'blue-700': Color(0xFF1D4ED8), - // Purple - 'purple-200': Color(0xFFE9D5FF), - 'purple-400': Color(0xFFC084FC), - 'purple-500': Color(0xFFA855F7), - 'purple-600': Color(0xFF9333EA), - 'purple-700': Color(0xFF7C3AED), - 'purple-900': Color(0xFF581C87), - // Pink - 'pink-400': Color(0xFFF472B6), - 'pink-500': Color(0xFFEC4899), - // Red - 'red-500': Color(0xFFEF4444), - 'red-600': Color(0xFFDC2626), - // Amber - 'amber-300': Color(0xFFFCD34D), - // Emerald - 'emerald-400': Color(0xFF34D399), - // Base - 'black': Colors.black, - 'white': Colors.white, - 'transparent': Colors.transparent, - }, - durations: { - '0': 0, - '75': 75, - '100': 100, - '150': 150, - '200': 200, - '300': 300, - '500': 500, - '700': 700, - '1000': 1000, - }, - delays: { - '0': 0, - '75': 75, - '100': 100, - '150': 150, - '200': 200, - '300': 300, - '500': 500, - '700': 700, - '1000': 1000, - }, - scales: { - '0': 0.0, - '50': 0.5, - '75': 0.75, - '90': 0.9, - '95': 0.95, - '100': 1.0, - '105': 1.05, - '110': 1.1, - '125': 1.25, - '150': 1.5, - }, - rotations: { - '0': 0, - '1': 1, - '2': 2, - '3': 3, - '6': 6, - '12': 12, - '45': 45, - '90': 90, - '180': 180, - }, - blurs: { - 'none': 0.0, - 'sm': 2.0, - '': 4.0, - 'md': 6.0, - 'lg': 8.0, - 'xl': 12.0, - '2xl': 20.0, - '3xl': 32.0, - }, + space: twDefaultSpacing, + radii: twDefaultRadii, + borderWidths: twDefaultBorderWidths, + breakpoints: twDefaultBreakpoints, + fontSizes: twDefaultFontSizes, + colors: twDefaultColors, + durations: twDefaultDurations, + delays: twDefaultDelays, + scales: twDefaultScales, + rotations: twDefaultRotations, + blurs: twDefaultBlurs, textDefaults: const TwTextDefaults.tailwindSans(), ); diff --git a/packages/mix_tailwinds/lib/src/tw_parser.dart b/packages/mix_tailwinds/lib/src/tw_parser.dart index 715b21b643..b542511195 100644 --- a/packages/mix_tailwinds/lib/src/tw_parser.dart +++ b/packages/mix_tailwinds/lib/src/tw_parser.dart @@ -1,2224 +1,58 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart'; +import 'translate/tw_target.dart' as target; +import 'translate/tw_translator.dart'; import 'tw_config.dart'; -import 'tw_semantic.dart'; -import 'tw_schema_payload.dart'; -import 'tw_utils.dart'; - -typedef TokenWarningCallback = void Function(String token); - -// ============================================================================= -// Styler Extensions for DefaultTextStyle -// ============================================================================= - -extension BoxStylerTextStyleExtension on BoxStyler { - BoxStyler wrapDefaultTextStyle(TextStyleMix textStyle) { - return wrap(WidgetModifierConfig.defaultTextStyle(style: textStyle)); - } -} - -extension FlexBoxStylerTextStyleExtension on FlexBoxStyler { - FlexBoxStyler wrapDefaultTextStyle(TextStyleMix textStyle) { - return wrap(WidgetModifierConfig.defaultTextStyle(style: textStyle)); - } -} - -// ============================================================================= -// Transform Accumulator -// ============================================================================= - -class _TransformAccum { - double? scale; - double? rotateDeg; - double? translateX; - double? translateY; - - /// When true, always produce identity matrix even if no transforms are set. - /// Used for animation interpolation when variants have transforms but base doesn't. - bool needsIdentity = false; - - _TransformAccum(); - - bool get hasAnyTransform => - needsIdentity || - scale != null || - rotateDeg != null || - translateX != null || - translateY != null; - - Matrix4 toMatrix4() { - var matrix = Matrix4.identity(); - if (translateX != null || translateY != null) { - matrix = matrix.multiplied( - Matrix4.translationValues(translateX ?? 0.0, translateY ?? 0.0, 0.0), - ); - } - if (rotateDeg != null) { - matrix = matrix.multiplied(Matrix4.rotationZ(rotateDeg! * math.pi / 180)); - } - if (scale != null) { - matrix = matrix.multiplied(Matrix4.diagonal3Values(scale!, scale!, 1.0)); - } - return matrix; - } -} - -class _TransformAccumTracker { - // Identity map avoids sharing accumulators across value-equal stylers. - final Map _accumulators = - Map.identity(); - - _TransformAccum forStyler(S styler) { - return _accumulators.putIfAbsent(styler as Object, _TransformAccum.new); - } - - bool hasTransforms(S styler) { - final accum = _accumulators[styler as Object]; - return accum != null && accum.hasAnyTransform; - } - - Matrix4? flush(S styler) { - final accum = _accumulators.remove(styler as Object); - if (accum == null || !accum.hasAnyTransform) return null; - return accum.toMatrix4(); - } - - void transfer(S from, S to) { - if (identical(from, to)) return; - final fromKey = from as Object; - final toKey = to as Object; - final accum = _accumulators.remove(fromKey); - if (accum == null) return; - final existing = _accumulators[toKey]; - if (existing == null) { - _accumulators[toKey] = accum; - return; - } - existing.scale ??= accum.scale; - existing.rotateDeg ??= accum.rotateDeg; - existing.translateX ??= accum.translateX; - existing.translateY ??= accum.translateY; - } - - /// Copies transforms from [from] to [to] without removing from source. - /// Used for variants where base transforms should remain AND be included - /// in the variant's combined transform. - void copyTo(S from, S to) { - if (identical(from, to)) return; - final fromKey = from as Object; - final toKey = to as Object; - final accum = _accumulators[fromKey]; - if (accum == null || !accum.hasAnyTransform) return; - final target = _accumulators.putIfAbsent(toKey, _TransformAccum.new); - // Copy base transforms to target (base values act as defaults) - target.scale ??= accum.scale; - target.rotateDeg ??= accum.rotateDeg; - target.translateX ??= accum.translateX; - target.translateY ??= accum.translateY; - } - - void clear() => _accumulators.clear(); -} - -// ============================================================================= -// Border Accumulator -// ============================================================================= - -class _BorderAccum { - double? topWidth; - double? bottomWidth; - double? leftWidth; - double? rightWidth; - Color? color; - - _BorderAccum(); - - _BorderAccum inheritFrom(_BorderAccum base) { - return _BorderAccum() - ..topWidth = topWidth ?? base.topWidth - ..bottomWidth = bottomWidth ?? base.bottomWidth - ..leftWidth = leftWidth ?? base.leftWidth - ..rightWidth = rightWidth ?? base.rightWidth - ..color = color ?? base.color; - } - - bool get hasStructure => - topWidth != null || - bottomWidth != null || - leftWidth != null || - rightWidth != null; - - void setAll(double width) { - topWidth = width; - bottomWidth = width; - leftWidth = width; - rightWidth = width; - } - - void setHorizontal(double width) { - leftWidth = width; - rightWidth = width; - } - - void setVertical(double width) { - topWidth = width; - bottomWidth = width; - } -} - -/// Co-evolving accumulator state threaded through the per-token classify -/// phase of the flex/box orchestrators: the base gradient, the base border, -/// and the per-variant borders all accumulate together across the token loop -/// and are finalized together afterwards. Grouping them keeps the shared -/// [TwParser._classifyTokens] helper within a small parameter budget. -class _Accumulators { - _Accumulators() - : baseGradient = _GradientAccum(), - baseBorder = _BorderAccum(), - variantBorders = {}; - - final _GradientAccum baseGradient; - final _BorderAccum baseBorder; - final Map variantBorders; -} - -// ============================================================================= -// Gradient Accumulator -// ============================================================================= - -class _GradientAccum { - String? directionKey; - (Alignment, Alignment)? direction; - Color? fromColor; - Color? viaColor; - Color? toColor; - - _GradientAccum(); - - bool get hasGradient => direction != null && fromColor != null; - - LinearGradientMix? toGradientMix(TwGradientStrategy strategy) { - if (!hasGradient) return null; - final colors = [fromColor!, ?viaColor, toColor ?? fromColor!]; - // Tailwind anchors via-* at 50% by default; without explicit stops - // Flutter interpolates linearly which doesn't match Tailwind's behavior - final stops = viaColor != null ? const [0.0, 0.5, 1.0] : const [0.0, 1.0]; - - if (strategy == TwGradientStrategy.angle && directionKey != null) { - final angle = _tailwindGradientAngles[directionKey!]; - if (angle != null) { - return LinearGradientMix( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - transform: angle == 0.0 ? null : GradientRotation(angle), - colors: colors, - stops: stops, - ); - } - } - - final useCssAngleRect = - strategy == TwGradientStrategy.cssAngleRect || - strategy.name == 'adaptive'; - if (useCssAngleRect && - directionKey != null && - _tailwindCornerDirections.contains(directionKey)) { - return LinearGradientMix( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - transform: TwCssKeywordLinearTransform(directionKey!), - colors: colors, - stops: stops, - ); - } - - final (begin, end) = direction!; - return LinearGradientMix( - begin: begin, - end: end, - colors: colors, - stops: stops, - ); - } -} - -/// Tailwind directional gradient angles (CSS-like "to-*" directions). -/// -/// Flutter gradients run left->right by default; rotating that axis gives us -/// an alternative parity strategy for directional gradients. -const Map _tailwindGradientAngles = { - 'to-r': 0.0, - 'to-br': math.pi / 4, - 'to-b': math.pi / 2, - 'to-bl': 3 * math.pi / 4, - 'to-l': math.pi, - 'to-tl': -3 * math.pi / 4, - 'to-t': -math.pi / 2, - 'to-tr': -math.pi / 4, -}; - -const Set _tailwindCornerDirections = { - 'to-br', - 'to-bl', - 'to-tr', - 'to-tl', -}; - -/// A [GradientTransform] that maps Tailwind/CSS `to-*` keyword directions -/// using bounds-aware geometry. -/// -/// The transform keeps a base left-to-right gradient but rotates/scales it -/// per-rect so corner keywords follow CSS "magic corners" behavior. -@immutable -class TwCssKeywordLinearTransform extends GradientTransform { - const TwCssKeywordLinearTransform(this.directionKey); - - final String directionKey; - - @override - Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { - final w = bounds.width; - final h = bounds.height; - if (w <= 0 || h <= 0) return Matrix4.identity(); - - final (rawX, rawY) = _directionVector(directionKey, w, h); - final magnitude = math.sqrt((rawX * rawX) + (rawY * rawY)); - if (magnitude == 0) return Matrix4.identity(); - - final ux = rawX / magnitude; - final uy = rawY / magnitude; - - // CSS-equivalent gradient line length for direction vector `u`. - // Base Flutter segment (centerLeft -> centerRight) has length = w. - final gradientLength = (w * ux.abs()) + (h * uy.abs()); - final scale = gradientLength / w; - final angle = math.atan2(uy, ux); - - return Matrix4.identity() - ..translateByDouble(bounds.center.dx, bounds.center.dy, 0, 1) - ..rotateZ(angle) - ..scaleByDouble(scale, scale, 1, 1) - ..translateByDouble(-bounds.center.dx, -bounds.center.dy, 0, 1); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwCssKeywordLinearTransform && - directionKey == other.directionKey; - - @override - int get hashCode => directionKey.hashCode; - - static (double, double) _directionVector( - String directionKey, - double width, - double height, - ) { - return switch (directionKey) { - 'to-r' => (1, 0), - 'to-l' => (-1, 0), - 'to-b' => (0, 1), - 'to-t' => (0, -1), - // Corner vectors are aspect-ratio aware. - 'to-br' => (height, width), - 'to-tr' => (height, -width), - 'to-bl' => (-height, width), - 'to-tl' => (-height, -width), - _ => (0, 1), - }; - } -} - -// ============================================================================= -// Resolver - Parses tokens to semantic AST -// ============================================================================= - -class TwResolver { - const TwResolver(this.config, {this.onUnknownVariant}); - - /// Pre-compiled regex for parsing arbitrary length values (e.g., 123px, 1.5rem, 50%). - static final _arbitraryLengthRegex = RegExp(r'^(-?\d+\.?\d*)(px|rem|em|%)?$'); - - final TwConfig config; - final TokenWarningCallback? onUnknownVariant; - - /// Finds the last colon that's not inside square brackets. - /// Delegates to shared utility to avoid duplicate logic. - int _findLastPrefixColon(String token) => findLastColonOutsideBrackets(token); - - /// Resolves a single token to parsed classes. - List? resolveToken(String token) { - // 1. Parse prefix:base structure (bracket-aware) - final colonIdx = _findLastPrefixColon(token); - final prefix = colonIdx > 0 ? token.substring(0, colonIdx) : ''; - var base = colonIdx > 0 ? token.substring(colonIdx + 1) : token; - - // 2. Parse important (!) - var important = false; - if (base.startsWith('!')) { - important = true; - base = base.substring(1); - } - - // 3. Parse negative (-) - var negative = false; - if (base.startsWith('-')) { - negative = true; - base = base.substring(1); - } - - // 4. Parse variants - final variants = _parseVariants(prefix); - - // 5. Check named plugins first - final namedPlugin = namedPlugins[base]; - if (namedPlugin != null) { - return [ - TwParsedClass( - property: namedPlugin.property, - value: namedPlugin.value, - variants: variants, - important: important, - ), - ]; - } - - // 6. Find root for functional plugins - final root = findRoot(base); - if (root == null) return null; - - final (rootPrefix, valueKey) = root; - final plugin = functionalPlugins[rootPrefix]; - if (plugin == null) return null; - - // 7. Resolve value - final value = _resolveValue(plugin, valueKey, negative); - if (value == null) return null; - - return [ - TwParsedClass( - property: plugin.property, - value: value, - variants: variants, - important: important, - negative: negative, - arbitrary: _isArbitrary(valueKey), - ), - ]; - } - - List _parseVariants(String prefix) { - if (prefix.isEmpty) return const []; - - final parts = prefix.split(':'); - final variants = []; - - for (final part in parts) { - // Check interaction variants - final interaction = interactionVariants[part]; - if (interaction != null) { - variants.add(TwInteractionVariant(interaction)); - continue; - } - - // Check theme variants - final theme = themeVariants[part]; - if (theme != null) { - variants.add(TwThemeVariant(theme)); - continue; - } - - // Check breakpoints - final breakpoint = config.breakpoints[part]; - if (breakpoint != null) { - variants.add(TwBreakpointVariant(part, breakpoint)); - continue; - } - - // Unknown variant - warn user about potential typo - onUnknownVariant?.call(part); - } - - return variants; - } - - TwValue? _resolveValue( - TwFunctionalPlugin plugin, - String? valueKey, - bool negative, - ) { - if (valueKey == null) { - return _getDefaultValue(plugin); - } - - // Handle arbitrary values [123px] - if (_isArbitrary(valueKey)) { - return _parseArbitrary(valueKey, plugin.type); - } - - // Resolve from config scale - final resolved = _resolveFromScale(plugin.scale, valueKey, plugin.type); - if (resolved == null) return null; - - // Apply negative (only if plugin supports it) - if (negative) { - if (!plugin.supportsNegative) { - return null; // Reject negative for unsupported properties - } - if (resolved is TwLengthValue) { - return TwLengthValue(-resolved.value, resolved.unit); - } - } - - return resolved; - } - - TwValue? _getDefaultValue(TwFunctionalPlugin plugin) { - return switch (plugin.property) { - TwProperty.borderWidth => const TwLengthValue(1), - TwProperty.borderTopWidth => const TwLengthValue(1), - TwProperty.borderRightWidth => const TwLengthValue(1), - TwProperty.borderBottomWidth => const TwLengthValue(1), - TwProperty.borderLeftWidth => const TwLengthValue(1), - TwProperty.borderXWidth => const TwLengthValue(1), - TwProperty.borderYWidth => const TwLengthValue(1), - TwProperty.borderRadius => TwLengthValue(config.radiusOf('')), - TwProperty.blur => TwLengthValue(config.blurOf('') ?? 0.0), - _ => null, - }; - } - - TwValue? _resolveFromScale(String? scale, String key, TwPluginType type) { - if (scale == null) return null; - - // Use hasKey checks to return null for unknown keys (triggers onUnsupported) - return switch (scale) { - 'space' => - config.hasSpace(key) ? TwLengthValue(config.spaceOf(key)) : null, - 'radii' => - config.hasRadius(key) ? TwLengthValue(config.radiusOf(key)) : null, - 'borderWidths' => - config.hasBorderWidth(key) - ? TwLengthValue(config.borderWidthOf(key)) - : null, - 'fontSizes' => - config.hasFontSize(key) ? TwLengthValue(config.fontSizeOf(key)) : null, - 'colors' => _resolveColor(key), - 'durations' => _resolveDuration(key), - 'delays' => _resolveDelay(key), - 'scales' => _resolveScale(key), - 'rotations' => _resolveRotation(key), - 'blurs' => - config.hasBlur(key) ? TwLengthValue(config.blurOf(key)!) : null, - _ => null, - }; - } - - TwColorValue? _resolveColor(String key) { - final color = config.colorOf(key); - return color != null ? TwColorValue(color) : null; - } - - TwDurationValue? _resolveDuration(String key) { - final ms = config.durationOf(key); - return ms != null ? TwDurationValue(ms) : null; - } - - TwDurationValue? _resolveDelay(String key) { - final ms = config.delayOf(key); - return ms != null ? TwDurationValue(ms) : null; - } - - TwLengthValue? _resolveScale(String key) { - if (!config.hasScale(key)) return null; - return TwLengthValue(config.scaleOf(key)!, TwUnit.none); - } - - TwLengthValue? _resolveRotation(String key) { - if (!config.hasRotation(key)) return null; - return TwLengthValue(config.rotationOf(key)!, TwUnit.none); - } - - bool _isArbitrary(String? value) => - value != null && value.startsWith('[') && value.endsWith(']'); - - TwValue? _parseArbitrary(String value, TwPluginType type) { - final inner = value.substring(1, value.length - 1); - - return switch (type) { - TwPluginType.length => _parseArbitraryLength(inner), - TwPluginType.color => _parseArbitraryColor(inner), - _ => null, - }; - } - - TwLengthValue? _parseArbitraryLength(String value) { - final match = _arbitraryLengthRegex.firstMatch(value); - if (match == null) return null; - - var num = double.parse(match.group(1)!); - final unitStr = match.group(2) ?? 'px'; - - // Convert rem/em to px using 16px base - if (unitStr == 'rem' || unitStr == 'em') { - num = num * 16; - return TwLengthValue(num, TwUnit.px); - } - - // Keep % as percent for constraint-based handling in appliers - if (unitStr == '%') { - return TwLengthValue(num, TwUnit.percent); - } - - // Default to px - return TwLengthValue(num, TwUnit.px); - } - - TwColorValue? _parseArbitraryColor(String value) { - if (value.startsWith('#')) { - final hex = value.substring(1); - if (hex.length != 6 && hex.length != 8) return null; - - final intVal = int.tryParse(hex, radix: 16); - if (intVal == null) return null; - - final color = hex.length == 6 - ? Color(0xFF000000 | intVal) - : Color(intVal); - return TwColorValue(color); - } - return null; - } -} - -// ============================================================================= -// Token Classification Helpers -// ============================================================================= - -bool _isGradientToken(String token) { - if (token.startsWith('bg-gradient-')) return true; - if (token.startsWith('bg-linear-')) return true; - if (token.startsWith('from-')) return true; - if (token.startsWith('via-')) return true; - if (token.startsWith('to-') && !gradientDirections.containsKey(token)) { - return true; - } - return false; -} - -void _accumulateGradient(_GradientAccum accum, String base, TwConfig config) { - if (base.startsWith('bg-gradient-')) { - final dirKey = base.substring(12); - final dir = gradientDirections[dirKey]; - if (dir != null) { - accum.directionKey = dirKey; - accum.direction = dir; - } - } else if (base.startsWith('bg-linear-')) { - final dirKey = base.substring(10); - final dir = gradientDirections[dirKey]; - if (dir != null) { - accum.directionKey = dirKey; - accum.direction = dir; - } - } else if (base.startsWith('from-')) { - accum.fromColor = config.colorOf(base.substring(5)); - } else if (base.startsWith('via-')) { - accum.viaColor = config.colorOf(base.substring(4)); - } else if (base.startsWith('to-') && !gradientDirections.containsKey(base)) { - accum.toColor = config.colorOf(base.substring(3)); - } -} - -Color _defaultBorderColor(TwConfig config) => - config.colorOf('gray-200') ?? const Color(0xFFE5E7EB); - -bool _isBorderToken(String token, TwConfig config) { - if (token == 'border') return true; - if (!token.startsWith('border-')) return false; - - final key = token.substring(7); - - // Direction tokens - const directions = {'t', 'b', 'l', 'r', 'x', 'y'}; - final dashIdx = key.indexOf('-'); - final dir = dashIdx == -1 ? key : key.substring(0, dashIdx); - if (directions.contains(dir)) return true; - - // Width tokens (>= 0 to include border-0) - if (config.borderWidthOf(key, fallback: -1) >= 0) return true; - - // Color tokens - if (config.colorOf(key) != null) return true; - - return false; -} - -void _accumulateBorder(_BorderAccum accum, String base, TwConfig config) { - if (!base.startsWith('border')) return; - - // Handle color-only tokens - if (base.startsWith('border-')) { - final key = base.substring(7); - final color = config.colorOf(key); - if (color != null && - config.borderWidthOf(key, fallback: -1) <= 0 && - !_isDirectionBorder(base)) { - accum.color = color; - return; - } - } - - // Handle 'border' (all sides, width 1) - if (base == 'border') { - accum.setAll(1.0); - return; - } - - // Handle width-only tokens (>= 0 to include border-0) - if (base.startsWith('border-')) { - final widthKey = base.substring(7); - final widthOnly = config.borderWidthOf(widthKey, fallback: -1); - if (widthOnly >= 0) { - accum.setAll(widthOnly); - return; - } - } - - // Handle direction tokens - final directive = _parseBorderDirective(config, base); - if (directive != null) { - if (directive.color != _defaultBorderColor(config)) { - accum.color = directive.color; - } - - switch (directive.direction) { - case 't': - accum.topWidth = directive.width; - case 'b': - accum.bottomWidth = directive.width; - case 'l': - accum.leftWidth = directive.width; - case 'r': - accum.rightWidth = directive.width; - case 'x': - accum.setHorizontal(directive.width); - case 'y': - accum.setVertical(directive.width); - } - } -} - -bool _isDirectionBorder(String token) { - if (!token.startsWith('border-')) return false; - final body = token.substring(7); - final dashIdx = body.indexOf('-'); - final dir = dashIdx == -1 ? body : body.substring(0, dashIdx); - return {'t', 'b', 'l', 'r', 'x', 'y'}.contains(dir); -} - -class _BorderDirective { - const _BorderDirective(this.direction, this.color, this.width); - final String direction; - final Color color; - final double width; -} - -_BorderDirective? _parseBorderDirective(TwConfig config, String token) { - if (!token.startsWith('border-')) return null; - - final body = token.substring(7); - final dashIndex = body.indexOf('-'); - final direction = dashIndex == -1 ? body : body.substring(0, dashIndex); - - if (direction.isEmpty) return null; - - const supported = {'t', 'b', 'l', 'r', 'x', 'y'}; - if (!supported.contains(direction)) return null; - - final remainder = dashIndex == -1 ? '' : body.substring(dashIndex + 1); - - var width = 1.0; - var color = _defaultBorderColor(config); - - if (remainder.isNotEmpty) { - final widthCandidate = config.borderWidthOf(remainder, fallback: -1); - if (widthCandidate >= 0) { - // >= 0 to include border-t-0, border-b-0, etc. - width = widthCandidate; - } else { - final colorCandidate = config.colorOf(remainder); - if (colorCandidate != null) { - color = colorCandidate; - } else { - return null; - } - } - } - - return _BorderDirective(direction, color, width); -} - -const Set _transitionTriggerTokens = { - 'transition', - 'transition-all', - 'transition-colors', - 'transition-opacity', - 'transition-shadow', - 'transition-transform', -}; - -const Set _validTimeKeys = { - '0', - '75', - '100', - '150', - '200', - '300', - '500', - '700', - '1000', -}; - -const Map _easeTokens = { - 'ease-linear': Curves.linear, - 'ease-in': Curves.easeIn, - 'ease-out': Curves.easeOut, - 'ease-in-out': Curves.easeInOut, -}; - -bool _isAnimationToken(String token) { - if (_transitionTriggerTokens.contains(token)) return true; - if (token == 'transition-none') return true; - if (_easeTokens.containsKey(token)) return true; - if (token.startsWith('duration-')) { - return _validTimeKeys.contains(token.substring(9)); - } - if (token.startsWith('delay-')) { - return _validTimeKeys.contains(token.substring(6)); - } - return false; -} - -// ============================================================================= -// Variant Appliers -// ============================================================================= - -typedef _VariantApplier = S Function(S base, S variant); -typedef _BreakpointApplier = S Function(S base, Breakpoint bp, S child); -typedef _StylerMerge = S Function(S base, S other); -typedef _BorderSideApplier = - S Function(S styler, {required Color color, required double width}); - -Map> _buildVariants({ - required _VariantApplier hover, - required _VariantApplier focus, - required _VariantApplier pressed, - required _VariantApplier disabled, - required _VariantApplier enabled, - required _VariantApplier dark, - required _VariantApplier light, -}) { - return { - 'hover': hover, - 'focus': focus, - 'active': pressed, - 'pressed': pressed, - 'disabled': disabled, - 'enabled': enabled, - 'dark': dark, - 'light': light, - }; -} - -final _flexVariants = _buildVariants( - hover: (b, v) => b.onHovered(v), - focus: (b, v) => b.onFocused(v), - pressed: (b, v) => b.onPressed(v), - disabled: (b, v) => b.onDisabled(v), - enabled: (b, v) => b.onEnabled(v), - dark: (b, v) => b.onDark(v), - light: (b, v) => b.onLight(v), -); - -final _boxVariants = _buildVariants( - hover: (b, v) => b.onHovered(v), - focus: (b, v) => b.onFocused(v), - pressed: (b, v) => b.onPressed(v), - disabled: (b, v) => b.onDisabled(v), - enabled: (b, v) => b.onEnabled(v), - dark: (b, v) => b.onDark(v), - light: (b, v) => b.onLight(v), -); - -final _textVariants = _buildVariants( - hover: (b, v) => b.onHovered(v), - focus: (b, v) => b.onFocused(v), - pressed: (b, v) => b.onPressed(v), - disabled: (b, v) => b.onDisabled(v), - enabled: (b, v) => b.onEnabled(v), - dark: (b, v) => b.onDark(v), - light: (b, v) => b.onLight(v), -); - -// ============================================================================= -// Unified Property Appliers -// ============================================================================= - -S _accumulateScale(S styler, double value, _TransformAccumTracker tracker) { - tracker.forStyler(styler).scale = value; - return styler; -} - -S _accumulateRotate(S styler, double value, _TransformAccumTracker tracker) { - tracker.forStyler(styler).rotateDeg = value; - return styler; -} - -S _accumulateTranslateX( - S styler, - double value, - _TransformAccumTracker tracker, -) { - tracker.forStyler(styler).translateX = value; - return styler; -} - -S _accumulateTranslateY( - S styler, - double value, - _TransformAccumTracker tracker, -) { - tracker.forStyler(styler).translateY = value; - return styler; -} - -typedef _LengthStylerApplier = S Function(S styler, double value); -typedef _ColorStylerApplier = S Function(S styler, Color value); -typedef _ClipStylerApplier = S Function(S styler, Clip value); -typedef _ModifierStylerApplier = - S Function(S styler, WidgetModifierConfig value); -typedef _TextStyleStylerApplier = S Function(S styler, TextStyleMix value); -typedef _ElevationStylerApplier = - S Function(S styler, ElevationShadow value); -typedef _BoxShadowsStylerApplier = - S Function(S styler, List value); - -final class _BoxLikeStylerOps { - const _BoxLikeStylerOps({ - required this.paddingAll, - required this.paddingX, - required this.paddingY, - required this.paddingTop, - required this.paddingRight, - required this.paddingBottom, - required this.paddingLeft, - required this.marginAll, - required this.marginX, - required this.marginY, - required this.marginTop, - required this.marginRight, - required this.marginBottom, - required this.marginLeft, - required this.width, - required this.height, - required this.minWidth, - required this.minHeight, - required this.maxWidth, - required this.maxHeight, - required this.color, - required this.borderRounded, - required this.borderRoundedTop, - required this.borderRoundedBottom, - required this.borderRoundedLeft, - required this.borderRoundedRight, - required this.borderRoundedTopLeft, - required this.borderRoundedTopRight, - required this.borderRoundedBottomLeft, - required this.borderRoundedBottomRight, - required this.wrap, - required this.clipBehavior, - required this.defaultTextStyle, - required this.elevation, - required this.boxShadows, - }); - - final _LengthStylerApplier paddingAll; - final _LengthStylerApplier paddingX; - final _LengthStylerApplier paddingY; - final _LengthStylerApplier paddingTop; - final _LengthStylerApplier paddingRight; - final _LengthStylerApplier paddingBottom; - final _LengthStylerApplier paddingLeft; - final _LengthStylerApplier marginAll; - final _LengthStylerApplier marginX; - final _LengthStylerApplier marginY; - final _LengthStylerApplier marginTop; - final _LengthStylerApplier marginRight; - final _LengthStylerApplier marginBottom; - final _LengthStylerApplier marginLeft; - final _LengthStylerApplier width; - final _LengthStylerApplier height; - final _LengthStylerApplier minWidth; - final _LengthStylerApplier minHeight; - final _LengthStylerApplier maxWidth; - final _LengthStylerApplier maxHeight; - final _ColorStylerApplier color; - final _LengthStylerApplier borderRounded; - final _LengthStylerApplier borderRoundedTop; - final _LengthStylerApplier borderRoundedBottom; - final _LengthStylerApplier borderRoundedLeft; - final _LengthStylerApplier borderRoundedRight; - final _LengthStylerApplier borderRoundedTopLeft; - final _LengthStylerApplier borderRoundedTopRight; - final _LengthStylerApplier borderRoundedBottomLeft; - final _LengthStylerApplier borderRoundedBottomRight; - final _ModifierStylerApplier wrap; - final _ClipStylerApplier clipBehavior; - final _TextStyleStylerApplier defaultTextStyle; - final _ElevationStylerApplier elevation; - final _BoxShadowsStylerApplier boxShadows; -} - -final _flexBoxOps = _BoxLikeStylerOps( - paddingAll: (style, value) => style.paddingAll(value), - paddingX: (style, value) => style.paddingX(value), - paddingY: (style, value) => style.paddingY(value), - paddingTop: (style, value) => style.paddingTop(value), - paddingRight: (style, value) => style.paddingRight(value), - paddingBottom: (style, value) => style.paddingBottom(value), - paddingLeft: (style, value) => style.paddingLeft(value), - marginAll: (style, value) => style.marginAll(value), - marginX: (style, value) => style.marginX(value), - marginY: (style, value) => style.marginY(value), - marginTop: (style, value) => style.marginTop(value), - marginRight: (style, value) => style.marginRight(value), - marginBottom: (style, value) => style.marginBottom(value), - marginLeft: (style, value) => style.marginLeft(value), - width: (style, value) => style.width(value), - height: (style, value) => style.height(value), - minWidth: (style, value) => style.minWidth(value), - minHeight: (style, value) => style.minHeight(value), - maxWidth: (style, value) => style.maxWidth(value), - maxHeight: (style, value) => style.maxHeight(value), - color: (style, value) => style.color(value), - borderRounded: (style, value) => style.borderRounded(value), - borderRoundedTop: (style, value) => style.borderRoundedTop(value), - borderRoundedBottom: (style, value) => style.borderRoundedBottom(value), - borderRoundedLeft: (style, value) => style.borderRoundedLeft(value), - borderRoundedRight: (style, value) => style.borderRoundedRight(value), - borderRoundedTopLeft: (style, value) => style.borderRoundedTopLeft(value), - borderRoundedTopRight: (style, value) => style.borderRoundedTopRight(value), - borderRoundedBottomLeft: (style, value) => - style.borderRoundedBottomLeft(value), - borderRoundedBottomRight: (style, value) => - style.borderRoundedBottomRight(value), - wrap: (style, value) => style.wrap(value), - clipBehavior: (style, value) => style.clipBehavior(value), - defaultTextStyle: (style, value) => style.wrapDefaultTextStyle(value), - elevation: (style, value) => style.elevation(value), - boxShadows: (style, value) => style.boxShadows(value), -); - -final _boxOps = _BoxLikeStylerOps( - paddingAll: (style, value) => style.paddingAll(value), - paddingX: (style, value) => style.paddingX(value), - paddingY: (style, value) => style.paddingY(value), - paddingTop: (style, value) => style.paddingTop(value), - paddingRight: (style, value) => style.paddingRight(value), - paddingBottom: (style, value) => style.paddingBottom(value), - paddingLeft: (style, value) => style.paddingLeft(value), - marginAll: (style, value) => style.marginAll(value), - marginX: (style, value) => style.marginX(value), - marginY: (style, value) => style.marginY(value), - marginTop: (style, value) => style.marginTop(value), - marginRight: (style, value) => style.marginRight(value), - marginBottom: (style, value) => style.marginBottom(value), - marginLeft: (style, value) => style.marginLeft(value), - width: (style, value) => style.width(value), - height: (style, value) => style.height(value), - minWidth: (style, value) => style.minWidth(value), - minHeight: (style, value) => style.minHeight(value), - maxWidth: (style, value) => style.maxWidth(value), - maxHeight: (style, value) => style.maxHeight(value), - color: (style, value) => style.color(value), - borderRounded: (style, value) => style.borderRounded(value), - borderRoundedTop: (style, value) => style.borderRoundedTop(value), - borderRoundedBottom: (style, value) => style.borderRoundedBottom(value), - borderRoundedLeft: (style, value) => style.borderRoundedLeft(value), - borderRoundedRight: (style, value) => style.borderRoundedRight(value), - borderRoundedTopLeft: (style, value) => style.borderRoundedTopLeft(value), - borderRoundedTopRight: (style, value) => style.borderRoundedTopRight(value), - borderRoundedBottomLeft: (style, value) => - style.borderRoundedBottomLeft(value), - borderRoundedBottomRight: (style, value) => - style.borderRoundedBottomRight(value), - wrap: (style, value) => style.wrap(value), - clipBehavior: (style, value) => style.clipBehavior(value), - defaultTextStyle: (style, value) => style.wrapDefaultTextStyle(value), - elevation: (style, value) => style.elevation(value), - boxShadows: (style, value) => style.boxShadows(value), -); - -S _applySharedBoxLikeProperty( - S styler, - TwProperty property, - TwValue value, - _TransformAccumTracker transformTracker, - _BoxLikeStylerOps ops, -) { - return switch (property) { - // Spacing - TwProperty.padding => ops.paddingAll( - styler, - (value as TwLengthValue).value, - ), - TwProperty.paddingX => ops.paddingX(styler, (value as TwLengthValue).value), - TwProperty.paddingY => ops.paddingY(styler, (value as TwLengthValue).value), - TwProperty.paddingTop => ops.paddingTop( - styler, - (value as TwLengthValue).value, - ), - TwProperty.paddingRight => ops.paddingRight( - styler, - (value as TwLengthValue).value, - ), - TwProperty.paddingBottom => ops.paddingBottom( - styler, - (value as TwLengthValue).value, - ), - TwProperty.paddingLeft => ops.paddingLeft( - styler, - (value as TwLengthValue).value, - ), - TwProperty.margin => ops.marginAll(styler, (value as TwLengthValue).value), - TwProperty.marginX => ops.marginX(styler, (value as TwLengthValue).value), - TwProperty.marginY => ops.marginY(styler, (value as TwLengthValue).value), - TwProperty.marginTop => ops.marginTop( - styler, - (value as TwLengthValue).value, - ), - TwProperty.marginRight => ops.marginRight( - styler, - (value as TwLengthValue).value, - ), - TwProperty.marginBottom => ops.marginBottom( - styler, - (value as TwLengthValue).value, - ), - TwProperty.marginLeft => ops.marginLeft( - styler, - (value as TwLengthValue).value, - ), - - // Sizing (only apply length values with px unit; enum values and % handled by widget layer) - TwProperty.width => _applyPxLength(styler, value, ops.width), - TwProperty.height => _applyPxLength(styler, value, ops.height), - TwProperty.minWidth => _applyPxLength(styler, value, ops.minWidth), - TwProperty.minHeight => _applyPxLength(styler, value, ops.minHeight), - TwProperty.maxWidth => _applyPxLength(styler, value, ops.maxWidth), - TwProperty.maxHeight => _applyPxLength(styler, value, ops.maxHeight), - - // Background - TwProperty.backgroundColor => ops.color( - styler, - (value as TwColorValue).color, - ), - - // Border radius - TwProperty.borderRadius => ops.borderRounded( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusTop => ops.borderRoundedTop( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusBottom => ops.borderRoundedBottom( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusLeft => ops.borderRoundedLeft( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusRight => ops.borderRoundedRight( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusTopLeft => ops.borderRoundedTopLeft( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusTopRight => ops.borderRoundedTopRight( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusBottomLeft => ops.borderRoundedBottomLeft( - styler, - (value as TwLengthValue).value, - ), - TwProperty.borderRadiusBottomRight => ops.borderRoundedBottomRight( - styler, - (value as TwLengthValue).value, - ), - - // Transform - TwProperty.scale => _accumulateScale( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - TwProperty.rotate => _accumulateRotate( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - TwProperty.translateX => _accumulateTranslateX( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - TwProperty.translateY => _accumulateTranslateY( - styler, - (value as TwLengthValue).value, - transformTracker, - ), - - // Effects - TwProperty.blur => ops.wrap( - styler, - WidgetModifierConfig.blur((value as TwLengthValue).value), - ), - TwProperty.boxShadow => _applyShadowValue( - styler, - value, - applyElevation: ops.elevation, - applyBoxShadows: ops.boxShadows, - ), - TwProperty.clipBehavior => ops.clipBehavior( - styler, - (value as TwEnumValue).value, - ), - - // Typography (propagates via DefaultTextStyle) - TwProperty.textColor => ops.defaultTextStyle( - styler, - TextStyleMix().color((value as TwColorValue).color), - ), - TwProperty.fontSize => ops.defaultTextStyle( - styler, - TextStyleMix().fontSize((value as TwLengthValue).value), - ), - TwProperty.fontWeight => ops.defaultTextStyle( - styler, - TextStyleMix().fontWeight((value as TwEnumValue).value), - ), - TwProperty.textShadow => _applyDefaultTextShadow(styler, value, ops), - - _ => styler, - }; -} - -S _applyPxLength(S styler, TwValue value, _LengthStylerApplier apply) { - if (value is TwLengthValue && value.unit == TwUnit.px) { - return apply(styler, value.value); - } - - return styler; -} - -FlexBoxStyler _applyPropertyToFlex( - FlexBoxStyler styler, - TwProperty property, - TwValue value, - _TransformAccumTracker transformTracker, -) { - return switch (property) { - TwProperty.gap => styler.spacing((value as TwLengthValue).value), - TwProperty.display => _applyFlexDisplay(styler, value), - TwProperty.flexDirection => _applyFlexDirection(styler, value), - TwProperty.alignItems => _applyAlignItems(styler, value), - TwProperty.justifyContent => styler.mainAxisAlignment( - (value as TwEnumValue).value, - ), - _ => _applySharedBoxLikeProperty( - styler, - property, - value, - transformTracker, - _flexBoxOps, - ), - }; -} - -FlexBoxStyler _applyFlexDisplay(FlexBoxStyler styler, TwValue value) { - if (value is TwEnumValue && value.value == 'flex') { - return styler.row(); - } - return styler; -} - -FlexBoxStyler _applyFlexDirection(FlexBoxStyler styler, TwValue value) { - if (value is TwEnumValue) { - final result = value.value == Axis.horizontal - ? styler.row() - : styler.column(); - return result; - } - return styler; -} - -FlexBoxStyler _applyAlignItems(FlexBoxStyler styler, TwValue value) { - if (value is TwEnumValue) { - final alignment = value.value; - var result = styler.crossAxisAlignment(alignment); - // CrossAxisAlignment.baseline requires textBaseline to be set - if (alignment == CrossAxisAlignment.baseline) { - result = result.textBaseline(TextBaseline.alphabetic); - } - return result; - } - return styler; -} - -BoxStyler _applyPropertyToBox( - BoxStyler styler, - TwProperty property, - TwValue value, - _TransformAccumTracker transformTracker, -) { - return _applySharedBoxLikeProperty( - styler, - property, - value, - transformTracker, - _boxOps, - ); -} - -S _applyShadowValue( - S styler, - TwValue value, { - required S Function(S styler, ElevationShadow elevation) applyElevation, - required S Function(S styler, List shadows) applyBoxShadows, -}) { - if (value is TwEnumValue) { - final shadowValue = value.value; - if (shadowValue is ElevationShadow?) { - return shadowValue == null - ? applyBoxShadows(styler, const []) - : applyElevation(styler, shadowValue); - } - if (shadowValue is List?) { - return applyBoxShadows(styler, shadowValue ?? const []); - } - } - return styler; -} - -S _applyResolvedProperties( - S styler, - List? parsed, - S Function(S styler, TwProperty property, TwValue value) applyProperty, -) { - if (parsed == null || parsed.isEmpty) return styler; - - var result = styler; - for (final p in parsed) { - result = applyProperty(result, p.property, p.value); - } - return result; -} - -S _applySharedBoxLikeFallback( - S styler, - String token, { - required TwConfig config, - required TokenWarningCallback? onUnsupported, - required S Function(S styler, double value) setWidth, - required S Function(S styler, double value) setHeight, - required S Function(S styler, TextStyleMix style) applyDefaultTextStyle, - S Function(S styler)? applyItemsBaseline, -}) { - var handled = true; - - if (token.startsWith('w-')) { - final key = token.substring(2); - final fraction = parseFractionToken(key); - if (fraction != null) { - return styler; // Handled by widget layer - } - if (_isFullOrScreenKey(key)) { - return styler; // Handled by widget layer - } - final size = config.spaceOf(key, fallback: double.nan); - if (!size.isNaN) { - return setWidth(styler, size); - } - handled = false; - } else if (token.startsWith('h-')) { - final key = token.substring(2); - final fraction = parseFractionToken(key); - if (fraction != null) { - return styler; // Handled by widget layer - } - if (_isFullOrScreenKey(key)) { - return styler; // Handled by widget layer - } - final size = config.spaceOf(key, fallback: double.nan); - if (!size.isNaN) { - return setHeight(styler, size); - } - handled = false; - } else if (token.startsWith('flex-') || - token.startsWith('basis-') || - token.startsWith('self-') || - token.startsWith('shrink')) { - // Item-level utilities handled at widget layer - return styler; - } else if (token.startsWith('text-')) { - final key = token.substring(5); - final color = config.colorOf(key); - if (color != null) { - return applyDefaultTextStyle(styler, TextStyleMix().color(color)); - } - final size = config.fontSizeOf(key, fallback: -1); - if (size > 0) { - var textStyle = TextStyleMix().fontSize(size); - final lineHeight = tailwindLineHeights[key]; - if (lineHeight != null) { - textStyle = textStyle.height(lineHeight); - } - return applyDefaultTextStyle(styler, textStyle); - } - handled = false; - } else if (_isAnimationToken(token)) { - return styler; - } else if (_isBorderToken(token, config)) { - return styler; - } else if (token == 'items-baseline' && applyItemsBaseline != null) { - return applyItemsBaseline(styler); - } else { - handled = false; - } - - if (!handled) { - onUnsupported?.call(token); - } - - return styler; -} - -List? _resolveTextShadowMixes(TwValue value) { - if (value is TwEnumValue) { - final preset = value.value; - if (preset == null) { - return const []; - } - final shadows = kTextShadowPresets[preset]! - .map( - (s) => ShadowMix( - color: s.color, - offset: s.offset, - blurRadius: s.blurRadius, - ), - ) - .toList(); - return shadows; - } - return null; -} - -S _applyDefaultTextShadow( - S styler, - TwValue value, - _BoxLikeStylerOps ops, -) { - final shadows = _resolveTextShadowMixes(value); - if (shadows == null) return styler; - return ops.defaultTextStyle(styler, TextStyleMix().shadows(shadows)); -} - -TextStyler _applyTextShadow(TextStyler styler, TwValue value) { - final shadows = _resolveTextShadowMixes(value); - if (shadows == null) return styler; - return styler.shadows(shadows); -} - -TextStyler _applyPropertyToText( - TextStyler styler, - TwProperty property, - TwValue value, - TwConfig config, -) { - return switch (property) { - TwProperty.textColor => styler.color((value as TwColorValue).color), - TwProperty.fontSize => styler.fontSize((value as TwLengthValue).value), - TwProperty.fontWeight => styler.fontWeight( - (value as TwEnumValue).value, - ), - TwProperty.textAlign => styler.textAlign( - (value as TwEnumValue).value, - ), - TwProperty.textShadow => _applyTextShadow(styler, value), - TwProperty.lineHeight => styler.height((value as TwLengthValue).value), - TwProperty.letterSpacing => styler.letterSpacing( - (value as TwLengthValue).value, - ), - TwProperty.textTransform => _applyTextTransform(styler, value), - TwProperty.textOverflow => - styler.overflow(TextOverflow.ellipsis).maxLines(1).softWrap(false), - _ => styler, - }; -} - -TextStyler _applyTextTransform(TextStyler styler, TwValue value) { - if (value is TwEnumValue) { - return switch (value.value) { - 'uppercase' => styler.uppercase(), - 'lowercase' => styler.lowercase(), - 'capitalize' => styler.capitalize(), - _ => styler, - }; - } - return styler; -} - -// ============================================================================= -// Main Parser Class -// ============================================================================= +import 'tw_types.dart'; class TwParser { factory TwParser({TwConfig? config, TokenWarningCallback? onUnsupported}) { final resolvedConfig = config ?? TwConfig.standard(); - return TwParser._(config: resolvedConfig, onUnsupported: onUnsupported); - } - - TwParser._({required this.config, this.onUnsupported}) - : _resolver = TwResolver(config, onUnknownVariant: onUnsupported) { - _schemaPayload = TwSchemaPayloadBuilder( - config: config, - listTokens: listTokens, - resolveToken: _resolver.resolveToken, - isBoxLikeDirectOnlyPayloadToken: _isBoxLikeDirectOnlyPayloadToken, - isAnimationToken: _isAnimationToken, - resolveTextShadowMixes: _resolveTextShadowMixes, + return TwParser._( + config: resolvedConfig, + translator: TwTranslator( + config: resolvedConfig, + onUnsupported: onUnsupported, + ), + onUnsupported: onUnsupported, ); } - /// Pre-compiled regex for splitting class names by whitespace. - static final _whitespaceRegex = RegExp(r'\s+'); + TwParser._({ + required this.config, + required TwTranslator translator, + this.onUnsupported, + }) : _translator = translator; final TwConfig config; final TokenWarningCallback? onUnsupported; - final TwResolver _resolver; - late final TwSchemaPayloadBuilder _schemaPayload; - final _TransformAccumTracker _transformTracker = _TransformAccumTracker(); + final TwTranslator _translator; - List listTokens(String classNames) { - final trimmed = classNames.trim(); - if (trimmed.isEmpty) return const []; - return trimmed.split(_whitespaceRegex); - } + List listTokens(String classNames) => + _translator.listTokens(classNames); Set setTokens(String classNames) => listTokens(classNames).toSet(); - bool wantsFlex(Set tokens) { - for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); - if (base == 'flex' || base == 'flex-row' || base == 'flex-col') { - return true; - } - if (base.startsWith('items-') || - base.startsWith('justify-') || - base.startsWith('gap-') || - base == 'gap') { - return true; - } - } - return false; - } - - FlexBoxStyler parseFlex(String classNames) { - final payload = _schemaPayload.tryBuildFlexPayload(classNames); - if (payload == null) return _parseFlexDirect(classNames); - - return _schemaPayload.decodePayload(payload); - } - - JsonMap parseFlexPayload(String classNames) { - final payload = _schemaPayload.tryBuildFlexPayload(classNames); - if (payload != null) return payload; + bool wantsFlex(Set tokens) => target.wantsFlex(tokens); - return _schemaPayload.encodeFlexPayload(_parseFlexDirect(classNames)); - } - - /// Shared per-token classify+accumulate phase for the flex and box - /// orchestrators. - /// - /// For each token: classify into gradient / border / else. Gradient and - /// border tokens accumulate into [accums] (rejecting border tokens whose - /// prefix is not a known variant via [onUnsupported]); everything else is - /// applied through [applyToken]. The (possibly updated) styler is returned. - /// - /// [onToken] is an optional hook invoked with each token's (prefix, base) - /// BEFORE classification — flex uses it to track whether a base flex token - /// is present, keeping that flex-only concern explicit at the call site - /// rather than hidden behind a flag inside this shared loop. - S _classifyTokens( - List tokens, - S styler, - _Accumulators accums, - Map> variants, - S Function(S styler, String token) applyToken, { - void Function(String prefix, String base)? onToken, - }) { - var result = styler; - - for (final token in tokens) { - // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] - final colonIndex = _findFirstPrefixColon(token); - final prefix = colonIndex > 0 ? token.substring(0, colonIndex) : ''; - final base = colonIndex > 0 ? token.substring(colonIndex + 1) : token; + FlexBoxStyler parseFlex(String classNames) => + _translator.translateFlex(classNames); - onToken?.call(prefix, base); + JsonMap parseFlexPayload(String classNames) => + _translator.payloadFlex(classNames); - // Accumulate gradient tokens - if (_isGradientToken(base)) { - if (prefix.isEmpty) { - _accumulateGradient(accums.baseGradient, base, config); - } - continue; - } + BoxStyler parseBox(String classNames) => _translator.translateBox(classNames); - // Accumulate border tokens - if (_isBorderToken(base, config)) { - if (!_hasOnlyKnownPrefixParts(prefix, variants)) { - onUnsupported?.call(token); - continue; - } - final accum = prefix.isEmpty - ? accums.baseBorder - : accums.variantBorders.putIfAbsent(prefix, _BorderAccum.new); - _accumulateBorder(accum, base, config); - continue; - } - - // Apply via resolver + applier - result = applyToken(result, token); - } - - return result; - } + JsonMap parseBoxPayload(String classNames) => + _translator.payloadBox(classNames); - S _applyAccumulatedBoxLikeDecorations( - S styler, - _Accumulators accums, { - required Map> variants, - required S Function() newStyler, - required _StylerMerge merge, - required _BreakpointApplier applyBreakpoint, - required S Function(S styler, LinearGradientMix gradient) applyGradient, - required _BorderSideApplier top, - required _BorderSideApplier bottom, - required _BorderSideApplier left, - required _BorderSideApplier right, - }) { - var result = styler; - - final gradientMix = accums.baseGradient.toGradientMix( - config.gradientStrategy, - ); - if (gradientMix != null) { - result = _carryTransforms(result, applyGradient(result, gradientMix)); - } - - return _carryTransforms( - result, - _applyAccumulatedBorders( - result, - accums.baseBorder, - accums.variantBorders, - variants: variants, - newStyler: newStyler, - merge: merge, - applyBreakpoint: applyBreakpoint, - top: top, - bottom: bottom, - left: left, - right: right, - ), - ); - } - - FlexBoxStyler _parseFlexDirect(String classNames) { - final tokens = listTokens(classNames); - - _transformTracker.clear(); - - var hasBaseFlex = false; - final accums = _Accumulators(); - - var styler = _classifyTokens( - tokens, - FlexBoxStyler(), - accums, - _flexVariants, - _applyFlexToken, - onToken: (prefix, base) { - // Track base flex (flex-only concern, kept explicit here). - if (prefix.isEmpty && - (base == 'flex' || base == 'flex-row' || base == 'flex-col')) { - hasBaseFlex = true; - } - }, - ); + TextStyler parseText(String classNames) => + _translator.translateText(classNames); - // Default to column when only prefixed flex - if (!hasBaseFlex) { - styler = _carryTransforms(styler, styler.column()); - } + JsonMap parseTextPayload(String classNames) => + _translator.payloadText(classNames); - styler = _applyAccumulatedBoxLikeDecorations( - styler, - accums, - variants: _flexVariants, - newStyler: FlexBoxStyler.new, - merge: (a, b) => a.merge(b), - applyBreakpoint: (b, bp, s) => b.onBreakpoint(bp, s), - applyGradient: (s, gradient) => s.gradient(gradient), - top: (s, {required color, required width}) => - s.borderTop(color: color, width: width), - bottom: (s, {required color, required width}) => - s.borderBottom(color: color, width: width), - left: (s, {required color, required width}) => - s.borderLeft(color: color, width: width), - right: (s, {required color, required width}) => - s.borderRight(color: color, width: width), - ); - - return _flushBaseTransforms(styler); - } - - BoxStyler parseBox(String classNames) { - final payload = _schemaPayload.tryBuildBoxPayload(classNames); - if (payload == null) return _parseBoxDirect(classNames); - - return _schemaPayload.decodePayload(payload); - } - - JsonMap parseBoxPayload(String classNames) { - final payload = _schemaPayload.tryBuildBoxPayload(classNames); - if (payload != null) return payload; - - return _schemaPayload.encodeBoxPayload(_parseBoxDirect(classNames)); - } - - BoxStyler _parseBoxDirect(String classNames) { - final tokens = listTokens(classNames); - - _transformTracker.clear(); - - final accums = _Accumulators(); - - var styler = _classifyTokens( - tokens, - BoxStyler(), - accums, - _boxVariants, - _applyBoxToken, - ); - - styler = _applyAccumulatedBoxLikeDecorations( - styler, - accums, - variants: _boxVariants, - newStyler: BoxStyler.new, - merge: (a, b) => a.merge(b), - applyBreakpoint: (b, bp, s) => b.onBreakpoint(bp, s), - applyGradient: (s, gradient) => s.gradient(gradient), - top: (s, {required color, required width}) => - s.borderTop(color: color, width: width), - bottom: (s, {required color, required width}) => - s.borderBottom(color: color, width: width), - left: (s, {required color, required width}) => - s.borderLeft(color: color, width: width), - right: (s, {required color, required width}) => - s.borderRight(color: color, width: width), - ); - - return _flushBaseTransforms(styler); - } - - TextStyler parseText(String classNames) { - final payload = _schemaPayload.tryBuildTextPayload(classNames); - if (payload == null) return _parseTextDirect(classNames); - - return _schemaPayload.decodePayload(payload); - } - - JsonMap parseTextPayload(String classNames) { - final payload = _schemaPayload.tryBuildTextPayload(classNames); - if (payload != null) return payload; - - return _schemaPayload.encodeTextPayload(_parseTextDirect(classNames)); - } - - TextStyler _parseTextDirect(String classNames) { - var styler = TextStyler().height(config.textDefaults.lineHeight); - for (final token in listTokens(classNames)) { - styler = _applyTextToken(styler, token); - } - return styler; - } - - bool _isBoxLikeDirectOnlyPayloadToken(String token) { - return _isGradientToken(token) || _isBorderToken(token, config); - } - - CurveAnimationConfig? parseAnimationFromTokens(List tokens) { - var hasTransition = false; - var hasTransitionNone = false; - var duration = const Duration(milliseconds: 150); - Curve curve = Curves.easeOut; - var delay = Duration.zero; - - for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); - - if (_transitionTriggerTokens.contains(base)) { - hasTransition = true; - } else if (base == 'transition-none') { - hasTransitionNone = true; - } else if (base.startsWith('duration-')) { - final key = base.substring(9); - final ms = config.durationOf(key); - if (ms != null) { - duration = Duration(milliseconds: ms); - } else { - onUnsupported?.call(token); - } - } else if (_easeTokens.containsKey(base)) { - curve = _easeTokens[base]!; - } else if (base.startsWith('delay-')) { - final key = base.substring(6); - final ms = config.delayOf(key); - if (ms != null) { - delay = Duration(milliseconds: ms); - } else { - onUnsupported?.call(token); - } - } - } - - if (hasTransitionNone) return null; - if (!hasTransition) return null; - - return CurveAnimationConfig(duration: duration, curve: curve, delay: delay); - } - - // =========================================================================== - // Private Token Application - // =========================================================================== - - bool _hasOnlyKnownPrefixParts(String prefix, Map variants) { - if (prefix.isEmpty) return true; - for (final part in prefix.split(':')) { - if (_isBreakpoint(part)) continue; - if (variants.containsKey(part)) continue; - return false; - } - return true; - } - - bool _isBreakpoint(String prefix) => config.breakpoints.containsKey(prefix); - - FlexBoxStyler _applyFlexToken(FlexBoxStyler base, String token) => - _applyPrefixedToken( - base, - token, - _flexVariants, - FlexBoxStyler.new, - _applyFlexAtomic, - (b, bp, s) => b.onBreakpoint(bp, s), - ); - - BoxStyler _applyBoxToken(BoxStyler base, String token) => _applyPrefixedToken( - base, - token, - _boxVariants, - BoxStyler.new, - _applyBoxAtomic, - (b, bp, s) => b.onBreakpoint(bp, s), - ); - - TextStyler _applyTextToken(TextStyler base, String token) => - _applyPrefixedToken( - base, - token, - _textVariants, - TextStyler.new, - _applyTextAtomic, - (b, bp, s) => b.onBreakpoint(bp, s), - ); - - /// Finds the first colon that's not inside square brackets. - /// Delegates to the shared utility in tw_utils.dart. - int _findFirstPrefixColon(String token) => - findFirstColonOutsideBrackets(token); - - S _carryTransforms(S from, S to) { - _transformTracker.transfer(from, to); - return to; - } - - S _applyTransformMatrix(S styler, Matrix4 matrix) { - if (styler is BoxStyler) { - return styler.transform(matrix) as S; - } - if (styler is FlexBoxStyler) { - return styler.transform(matrix) as S; - } - return styler; - } - - S _flushTransforms(S styler) { - if (!_transformTracker.hasTransforms(styler)) return styler; - final matrix = _transformTracker.flush(styler); - if (matrix == null) return styler; - return _applyTransformMatrix(styler, matrix); - } - - /// Finalize-transforms phase shared by the flex and box orchestrators: - /// flush the accumulated base matrix onto [styler] (if any) and reset the - /// tracker for the next parse. Behavior matches the previous inlined tail. - S _flushBaseTransforms(S styler) { - var result = styler; - final baseMatrix = _transformTracker.flush(styler); - if (baseMatrix != null) { - result = _applyTransformMatrix(styler, baseMatrix); - } - _transformTracker.clear(); - - return result; - } - - S _applyPrefixedToken( - S base, - String token, - Map> variants, - S Function() newStyler, - S Function(S, String) applyAtomic, - S Function(S, Breakpoint, S) applyBreakpoint, - ) { - final prefixIndex = _findFirstPrefixColon(token); - if (prefixIndex <= 0) { - final result = applyAtomic(base, token); - return _carryTransforms(base, result); - } - - final head = token.substring(0, prefixIndex); - final tail = token.substring(prefixIndex + 1); - - if (_isBreakpoint(head)) { - final min = config.breakpointOf(head); - final childStyler = _applyPrefixedToken( - newStyler(), - tail, - variants, - newStyler, - applyAtomic, - applyBreakpoint, - ); - return _applyChildWithTransforms( - base, - childStyler, - (b, child) => applyBreakpoint(b, Breakpoint(minWidth: min), child), - ); - } - - final variantFn = variants[head]; - if (variantFn != null) { - final childStyler = _applyPrefixedToken( - newStyler(), - tail, - variants, - newStyler, - applyAtomic, - applyBreakpoint, - ); - return _applyChildWithTransforms(base, childStyler, variantFn); - } - - final result = applyAtomic(base, token); - return _carryTransforms(base, result); - } - - /// Propagates accumulated transforms from [base] into a prefixed [child] - /// styler, then merges the child back into [base] via [combine]. - /// - /// This is the shared propagation rule for breakpoint and variant children: - /// the ONLY difference between the two call sites is [combine] (breakpoint - /// wrap vs variant apply). Behavior is identical to the previous inlined - /// branches — copy base transforms to the child first (preserving base for - /// the final flush), mark the base as needing an identity matrix when the - /// child carries a transform but the base does not (so animations can - /// interpolate), flush the child's transform, then combine. - S _applyChildWithTransforms( - S base, - S childStyler, - S Function(S base, S child) combine, - ) { - // Copy base transforms to child BEFORE flushing so variant gets both. - // Use copyTo (not transfer) to preserve base transforms for final flush. - _transformTracker.copyTo(base, childStyler); - // If child has transforms but base doesn't, mark base as needing identity - // for animation. - final childHasTransforms = _transformTracker.hasTransforms(childStyler); - final baseHasTransforms = _transformTracker.hasTransforms(base); - if (childHasTransforms && !baseHasTransforms) { - _transformTracker.forStyler(base).needsIdentity = true; - } - final flushedChild = _flushTransforms(childStyler); - final result = combine(base, flushedChild); - return _carryTransforms(base, result); - } - - FlexBoxStyler _applyFlexAtomic(FlexBoxStyler styler, String token) { - // Try resolver first - if (token.startsWith('gap-x-') || token.startsWith('gap-y-')) { - return styler; - } - - final parsed = _resolver.resolveToken(token); - if (parsed != null && parsed.isNotEmpty) { - return _applyResolvedProperties( - styler, - parsed, - (current, property, value) => - _applyPropertyToFlex(current, property, value, _transformTracker), - ); - } - - return _applySharedBoxLikeFallback( - styler, - token, - config: config, - onUnsupported: onUnsupported, - setWidth: (current, value) => current.width(value), - setHeight: (current, value) => current.height(value), - applyDefaultTextStyle: (current, textStyle) => - current.wrapDefaultTextStyle(textStyle), - applyItemsBaseline: (current) => current - .crossAxisAlignment(CrossAxisAlignment.baseline) - .textBaseline(TextBaseline.alphabetic), - ); - } - - BoxStyler _applyBoxAtomic(BoxStyler styler, String token) { - // Try resolver first - final parsed = _resolver.resolveToken(token); - if (parsed != null && parsed.isNotEmpty) { - return _applyResolvedProperties( - styler, - parsed, - (current, property, value) => - _applyPropertyToBox(current, property, value, _transformTracker), - ); - } - - return _applySharedBoxLikeFallback( - styler, - token, - config: config, - onUnsupported: onUnsupported, - setWidth: (current, value) => current.width(value), - setHeight: (current, value) => current.height(value), - applyDefaultTextStyle: (current, textStyle) => - current.wrapDefaultTextStyle(textStyle), - ); - } - - TextStyler _applyTextAtomic(TextStyler styler, String token) { - // Try resolver first - final parsed = _resolver.resolveToken(token); - if (parsed != null && parsed.isNotEmpty) { - var result = styler; - for (final p in parsed) { - result = _applyPropertyToText(result, p.property, p.value, config); - // Apply Tailwind's default line heights for text-* sizes - if (p.property == TwProperty.fontSize && p.value is TwLengthValue) { - // Find the original key to look up line height - final key = _findTextSizeKey(token); - if (key != null) { - final lineHeight = tailwindLineHeights[key]; - if (lineHeight != null) { - result = result.height(lineHeight); - } - } - } - } - return result; - } - - // Fallback handling - var handled = true; - - // leading-even and leading-trim special cases - if (token == 'leading-even') { - return styler.textHeightBehavior( - TextHeightBehaviorMix( - leadingDistribution: TextLeadingDistribution.even, - ), - ); - } - if (token == 'leading-trim') { - return styler.textHeightBehavior( - TextHeightBehaviorMix( - leadingDistribution: TextLeadingDistribution.even, - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - ), - ); - } - - if (_isAnimationToken(token)) { - return styler; - } - - // Text size tokens (text-lg, text-sm, etc.) - // The resolver treats 'text-*' as color tokens, but these are font sizes - if (token.startsWith('text-')) { - final key = token.substring(5); - // First check if it's a font size - final size = config.fontSizeOf(key, fallback: -1); - if (size > 0) { - var result = styler.fontSize(size); - final lineHeight = tailwindLineHeights[key]; - if (lineHeight != null) { - result = result.height(lineHeight); - } - return result; - } - // Then check if it's a color - final color = config.colorOf(key); - if (color != null) { - return styler.color(color); - } - handled = false; - } else { - handled = false; - } - - if (!handled) { - onUnsupported?.call(token); - } - - return styler; - } - - String? _findTextSizeKey(String token) { - if (token.startsWith('text-')) { - return token.substring(5); - } - return null; - } - - // =========================================================================== - // Accumulator Application - // =========================================================================== - - S _applyBorderSides( - S styler, - _BorderAccum border, { - required Color color, - required _BorderSideApplier top, - required _BorderSideApplier bottom, - required _BorderSideApplier left, - required _BorderSideApplier right, - }) { - var result = styler; - - if (border.topWidth != null) { - result = top(result, color: color, width: border.topWidth!); - } - if (border.bottomWidth != null) { - result = bottom(result, color: color, width: border.bottomWidth!); - } - if (border.leftWidth != null) { - result = left(result, color: color, width: border.leftWidth!); - } - if (border.rightWidth != null) { - result = right(result, color: color, width: border.rightWidth!); - } - - return result; - } - - S _applyAccumulatedBorders( - S styler, - _BorderAccum baseBorder, - Map variantBorders, { - required Map> variants, - required S Function() newStyler, - required _StylerMerge merge, - required _BreakpointApplier applyBreakpoint, - required _BorderSideApplier top, - required _BorderSideApplier bottom, - required _BorderSideApplier left, - required _BorderSideApplier right, - }) { - var result = styler; - - // Base borders - if (baseBorder.hasStructure) { - final color = baseBorder.color ?? _defaultBorderColor(config); - result = _applyBorderSides( - result, - baseBorder, - color: color, - top: top, - bottom: bottom, - left: left, - right: right, - ); - } - - // Variant borders - for (final entry in variantBorders.entries) { - final inherited = entry.value.inheritFrom(baseBorder); - if (!inherited.hasStructure) continue; - - final color = inherited.color ?? _defaultBorderColor(config); - final variantStyle = _applyBorderSides( - newStyler(), - inherited, - color: color, - top: top, - bottom: bottom, - left: left, - right: right, - ); - - final wrapped = _applyPrefixedToken( - newStyler(), - '${entry.key}:__tw_internal__', - variants, - newStyler, - (base, _) => variantStyle, - applyBreakpoint, - ); - result = merge(result, wrapped); - } - - return result; - } + CurveAnimationConfig? parseAnimationFromTokens(List tokens) => + _translator.parseAnimationFromTokens(tokens); } - -bool _isFullOrScreenKey(String key) => key == 'full' || key == 'screen'; diff --git a/packages/mix_tailwinds/lib/src/tw_schema_payload.dart b/packages/mix_tailwinds/lib/src/tw_schema_payload.dart deleted file mode 100644 index 9611227926..0000000000 --- a/packages/mix_tailwinds/lib/src/tw_schema_payload.dart +++ /dev/null @@ -1,643 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mix/mix.dart'; -import 'package:mix_schema/encode.dart'; -import 'package:mix_schema/mix_schema.dart'; - -import 'tw_config.dart'; -import 'tw_semantic.dart'; -import 'tw_utils.dart'; - -typedef TwPayloadTokenLister = List Function(String classNames); -typedef TwPayloadTokenResolver = List? Function(String token); -typedef TwPayloadTokenPredicate = bool Function(String token); -typedef TwPayloadTextShadowResolver = List? Function(TwValue value); - -typedef _PayloadPropertyApplier = - bool Function( - JsonMap payload, - TwProperty property, - TwValue value, - String token, - ); -typedef _PayloadFallbackApplier = bool Function(JsonMap payload, String token); - -final class SchemaPayloadUnsupported implements Exception { - const SchemaPayloadUnsupported(this.errors); - - final List errors; - - @override - String toString() => 'Unsupported Tailwinds schema payload: $errors'; -} - -final class TwSchemaPayloadBuilder { - TwSchemaPayloadBuilder({ - required TwConfig config, - required TwPayloadTokenLister listTokens, - required TwPayloadTokenResolver resolveToken, - required TwPayloadTokenPredicate isBoxLikeDirectOnlyPayloadToken, - required TwPayloadTokenPredicate isAnimationToken, - required TwPayloadTextShadowResolver resolveTextShadowMixes, - }) : _config = config, - _listTokens = listTokens, - _resolveToken = resolveToken, - _isBoxLikeDirectOnlyPayloadToken = isBoxLikeDirectOnlyPayloadToken, - _isAnimationToken = isAnimationToken, - _resolveTextShadowMixes = resolveTextShadowMixes; - - final TwConfig _config; - final TwPayloadTokenLister _listTokens; - final TwPayloadTokenResolver _resolveToken; - final TwPayloadTokenPredicate _isBoxLikeDirectOnlyPayloadToken; - final TwPayloadTokenPredicate _isAnimationToken; - final TwPayloadTextShadowResolver _resolveTextShadowMixes; - final MixSchemaContract _schemaContract = MixSchemaContractBuilder() - .builtIn() - .freeze(); - - JsonMap? tryBuildFlexPayload(String classNames) { - return _tryBuildPayload( - classNames: classNames, - payload: {'type': SchemaStyler.flexBox.wireValue}, - blocksPayload: _isBoxLikeDirectOnlyPayloadToken, - skipsToken: _isFlexWidgetLayerGapToken, - applyFallback: _applySharedPayloadFallback, - applyProperty: _applyFlexPayloadProperty, - ); - } - - JsonMap? tryBuildBoxPayload(String classNames) { - return _tryBuildPayload( - classNames: classNames, - payload: {'type': SchemaStyler.box.wireValue}, - blocksPayload: _isBoxLikeDirectOnlyPayloadToken, - applyFallback: _applySharedPayloadFallback, - applyProperty: _applyBoxPayloadProperty, - ); - } - - JsonMap? tryBuildTextPayload(String classNames) { - return _tryBuildPayload( - classNames: classNames, - payload: { - 'type': SchemaStyler.text.wireValue, - 'style': {'height': _config.textDefaults.lineHeight}, - }, - applyFallback: _applyTextPayloadFallback, - applyProperty: _applyTextPayloadProperty, - ); - } - - JsonMap encodeFlexPayload(FlexBoxStyler styler) { - return _encodePayload(styler, expectedType: SchemaStyler.flexBox); - } - - JsonMap encodeBoxPayload(BoxStyler styler) { - return _encodePayload(styler, expectedType: SchemaStyler.box); - } - - JsonMap encodeTextPayload(TextStyler styler) { - return _encodePayload(styler, expectedType: SchemaStyler.text); - } - - T decodePayload(JsonMap payload) { - final result = _schemaContract.decode(payload); - - return switch (result) { - MixSchemaDecodeSuccess(:final value) => value, - MixSchemaDecodeFailure(:final errors) => throw StateError( - 'Tailwinds emitted an invalid schema payload: $errors', - ), - }; - } - - JsonMap? _tryBuildPayload({ - required String classNames, - required JsonMap payload, - required _PayloadFallbackApplier applyFallback, - required _PayloadPropertyApplier applyProperty, - TwPayloadTokenPredicate? blocksPayload, - TwPayloadTokenPredicate? skipsToken, - }) { - for (final token in _listTokens(classNames)) { - if (_hasSchemaPrefix(token)) return null; - if (blocksPayload?.call(token) ?? false) return null; - if (skipsToken?.call(token) ?? false) continue; - - final parsed = _resolveToken(token); - if (parsed == null || parsed.isEmpty) { - if (!applyFallback(payload, token)) return null; - continue; - } - - for (final p in parsed) { - if (!applyProperty(payload, p.property, p.value, token)) return null; - } - } - - return payload; - } - - bool _hasSchemaPrefix(String token) { - return findFirstColonOutsideBrackets(token) > 0; - } - - bool _isFlexWidgetLayerGapToken(String token) { - return token.startsWith('gap-x-') || token.startsWith('gap-y-'); - } - - bool _applyFlexPayloadProperty( - JsonMap payload, - TwProperty property, - TwValue value, - String token, - ) { - if (_applyBoxPayloadProperty(payload, property, value, token)) return true; - - switch (property) { - case TwProperty.display: - if (value is TwEnumValue && value.value == 'flex') { - payload['direction'] = Axis.horizontal.name; - } - case TwProperty.flexDirection: - if (value is! TwEnumValue) return false; - payload['direction'] = value.value.name; - case TwProperty.alignItems: - if (value is! TwEnumValue) return false; - payload['crossAxisAlignment'] = value.value.name; - if (value.value == CrossAxisAlignment.baseline) { - payload['textBaseline'] = TextBaseline.alphabetic.name; - } - case TwProperty.justifyContent: - if (value is! TwEnumValue) return false; - payload['mainAxisAlignment'] = value.value.name; - case TwProperty.gap: - if (value is! TwLengthValue) return false; - payload['spacing'] = value.value; - default: - return false; - } - - return true; - } - - bool _applyBoxPayloadProperty( - JsonMap payload, - TwProperty property, - TwValue value, - String token, - ) { - switch (property) { - case TwProperty.padding: - return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'all'); - case TwProperty.paddingX: - return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'x'); - case TwProperty.paddingY: - return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'y'); - case TwProperty.paddingTop: - return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'top'); - case TwProperty.paddingRight: - return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'right'); - case TwProperty.paddingBottom: - return _setEdgeInsetsPayload( - payload, - 'padding', - value, - sides: 'bottom', - ); - case TwProperty.paddingLeft: - return _setEdgeInsetsPayload(payload, 'padding', value, sides: 'left'); - case TwProperty.margin: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'all'); - case TwProperty.marginX: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'x'); - case TwProperty.marginY: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'y'); - case TwProperty.marginTop: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'top'); - case TwProperty.marginRight: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'right'); - case TwProperty.marginBottom: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'bottom'); - case TwProperty.marginLeft: - return _setEdgeInsetsPayload(payload, 'margin', value, sides: 'left'); - case TwProperty.width: - return _setConstraintPayload(payload, value, width: true, fixed: true); - case TwProperty.height: - return _setConstraintPayload(payload, value, height: true, fixed: true); - case TwProperty.minWidth: - return _setConstraintPayload(payload, value, minWidth: true); - case TwProperty.minHeight: - return _setConstraintPayload(payload, value, minHeight: true); - case TwProperty.maxWidth: - return _setConstraintPayload(payload, value, maxWidth: true); - case TwProperty.maxHeight: - return _setConstraintPayload(payload, value, maxHeight: true); - case TwProperty.backgroundColor: - if (value is! TwColorValue) return false; - final color = _payloadColor(value.color); - if (color == null) return false; - _decoration(payload)['color'] = color; - case TwProperty.borderRadius: - return _setRadiusPayload(payload, value, corners: 'all'); - case TwProperty.borderRadiusTop: - return _setRadiusPayload(payload, value, corners: 'top'); - case TwProperty.borderRadiusBottom: - return _setRadiusPayload(payload, value, corners: 'bottom'); - case TwProperty.borderRadiusLeft: - return _setRadiusPayload(payload, value, corners: 'left'); - case TwProperty.borderRadiusRight: - return _setRadiusPayload(payload, value, corners: 'right'); - case TwProperty.borderRadiusTopLeft: - return _setRadiusPayload(payload, value, corners: 'topLeft'); - case TwProperty.borderRadiusTopRight: - return _setRadiusPayload(payload, value, corners: 'topRight'); - case TwProperty.borderRadiusBottomLeft: - return _setRadiusPayload(payload, value, corners: 'bottomLeft'); - case TwProperty.borderRadiusBottomRight: - return _setRadiusPayload(payload, value, corners: 'bottomRight'); - case TwProperty.blur: - if (value is! TwLengthValue) return false; - _modifiers(payload).add({'type': 'blur', 'sigma': value.value}); - case TwProperty.boxShadow: - final shadows = _boxShadowPayload(value); - if (shadows == null) return false; - _decoration(payload)['boxShadow'] = shadows; - case TwProperty.clipBehavior: - if (value is! TwEnumValue) return false; - payload['clipBehavior'] = value.value.name; - case TwProperty.textColor: - if (value is! TwColorValue) return false; - final color = _payloadColor(value.color); - if (color == null) return false; - _defaultTextStyle(payload)['color'] = color; - case TwProperty.fontSize: - if (value is! TwLengthValue) return false; - _defaultTextStyle(payload)['fontSize'] = value.value; - case TwProperty.fontWeight: - if (value is! TwEnumValue) return false; - _defaultTextStyle(payload)['fontWeight'] = _fontWeightWire(value.value); - case TwProperty.textShadow: - final shadows = _textShadowPayload(value); - if (shadows == null) return false; - _defaultTextStyle(payload)['shadows'] = shadows; - default: - return false; - } - - return true; - } - - bool _applyTextPayloadProperty( - JsonMap payload, - TwProperty property, - TwValue value, - String token, - ) { - switch (property) { - case TwProperty.textColor: - if (value is! TwColorValue) return false; - final color = _payloadColor(value.color); - if (color == null) return false; - _textStyle(payload)['color'] = color; - case TwProperty.fontSize: - if (value is! TwLengthValue) return false; - _textStyle(payload)['fontSize'] = value.value; - final key = _findTextSizeKey(token); - final lineHeight = key == null ? null : tailwindLineHeights[key]; - if (lineHeight != null) _textStyle(payload)['height'] = lineHeight; - case TwProperty.fontWeight: - if (value is! TwEnumValue) return false; - _textStyle(payload)['fontWeight'] = _fontWeightWire(value.value); - case TwProperty.textAlign: - if (value is! TwEnumValue) return false; - payload['textAlign'] = value.value.name; - case TwProperty.lineHeight: - if (value is! TwLengthValue) return false; - _textStyle(payload)['height'] = value.value; - case TwProperty.letterSpacing: - if (value is! TwLengthValue) return false; - _textStyle(payload)['letterSpacing'] = value.value; - case TwProperty.textTransform: - if (value is! TwEnumValue) return false; - _textDirectives(payload).add(value.value); - case TwProperty.textOverflow: - payload['overflow'] = TextOverflow.ellipsis.name; - payload['maxLines'] = 1; - payload['softWrap'] = false; - case TwProperty.textShadow: - final shadows = _textShadowPayload(value); - if (shadows == null) return false; - _textStyle(payload)['shadows'] = shadows; - default: - return false; - } - - return true; - } - - bool _applySharedPayloadFallback(JsonMap payload, String token) { - if (token.startsWith('w-')) { - final value = _spacePayloadValue(token.substring(2)); - if (value == null) return _isWidgetLayerSizingToken(token.substring(2)); - return _setConstraintPayload(payload, value, width: true, fixed: true); - } - if (token.startsWith('h-')) { - final value = _spacePayloadValue(token.substring(2)); - if (value == null) return _isWidgetLayerSizingToken(token.substring(2)); - return _setConstraintPayload(payload, value, height: true, fixed: true); - } - if (token.startsWith('flex-') || - token.startsWith('basis-') || - token.startsWith('self-') || - token.startsWith('shrink') || - _isAnimationToken(token)) { - return true; - } - if (token.startsWith('text-')) { - final key = token.substring(5); - final color = _config.colorOf(key); - if (color != null) { - final wireColor = _payloadColor(color); - if (wireColor == null) return false; - _defaultTextStyle(payload)['color'] = wireColor; - return true; - } - final size = _config.fontSizeOf(key, fallback: -1); - if (size > 0) { - _defaultTextStyle(payload)['fontSize'] = size; - final lineHeight = tailwindLineHeights[key]; - if (lineHeight != null) { - _defaultTextStyle(payload)['height'] = lineHeight; - } - return true; - } - } - - return false; - } - - bool _applyTextPayloadFallback(JsonMap payload, String token) { - if (token == 'leading-even' || token == 'leading-trim') { - payload['textHeightBehavior'] = { - 'leadingDistribution': TextLeadingDistribution.even.name, - if (token == 'leading-trim') ...{ - 'applyHeightToFirstAscent': false, - 'applyHeightToLastDescent': false, - }, - }; - return true; - } - if (_isAnimationToken(token)) return true; - if (token.startsWith('text-')) { - final key = token.substring(5); - final size = _config.fontSizeOf(key, fallback: -1); - if (size > 0) { - _textStyle(payload)['fontSize'] = size; - final lineHeight = tailwindLineHeights[key]; - if (lineHeight != null) _textStyle(payload)['height'] = lineHeight; - return true; - } - final color = _config.colorOf(key); - if (color != null) { - final wireColor = _payloadColor(color); - if (wireColor == null) return false; - _textStyle(payload)['color'] = wireColor; - return true; - } - } - - return false; - } - - bool _setEdgeInsetsPayload( - JsonMap payload, - String field, - TwValue value, { - required String sides, - }) { - if (value is! TwLengthValue) return false; - final data = _objectField(payload, field); - switch (sides) { - case 'all': - data - ..['left'] = value.value - ..['top'] = value.value - ..['right'] = value.value - ..['bottom'] = value.value; - case 'x': - data - ..['left'] = value.value - ..['right'] = value.value; - case 'y': - data - ..['top'] = value.value - ..['bottom'] = value.value; - default: - data[sides] = value.value; - } - - return true; - } - - bool _setConstraintPayload( - JsonMap payload, - Object value, { - bool width = false, - bool height = false, - bool minWidth = false, - bool maxWidth = false, - bool minHeight = false, - bool maxHeight = false, - bool fixed = false, - }) { - final length = value is TwLengthValue ? value : null; - if (length == null || length.unit != TwUnit.px) return false; - final constraints = _objectField(payload, 'constraints'); - if ((width || minWidth) && (fixed || minWidth)) { - constraints['minWidth'] = length.value; - } - if ((width || maxWidth) && (fixed || maxWidth)) { - constraints['maxWidth'] = length.value; - } - if ((height || minHeight) && (fixed || minHeight)) { - constraints['minHeight'] = length.value; - } - if ((height || maxHeight) && (fixed || maxHeight)) { - constraints['maxHeight'] = length.value; - } - - return true; - } - - bool _setRadiusPayload( - JsonMap payload, - TwValue value, { - required String corners, - }) { - if (value is! TwLengthValue) return false; - final radius = value.value; - final borderRadius = _objectField(_decoration(payload), 'borderRadius'); - switch (corners) { - case 'all': - borderRadius - ..['topLeft'] = radius - ..['topRight'] = radius - ..['bottomLeft'] = radius - ..['bottomRight'] = radius; - case 'top': - borderRadius - ..['topLeft'] = radius - ..['topRight'] = radius; - case 'bottom': - borderRadius - ..['bottomLeft'] = radius - ..['bottomRight'] = radius; - case 'left': - borderRadius - ..['topLeft'] = radius - ..['bottomLeft'] = radius; - case 'right': - borderRadius - ..['topRight'] = radius - ..['bottomRight'] = radius; - default: - borderRadius[corners] = radius; - } - - return true; - } - - JsonMap _decoration(JsonMap payload) => _objectField(payload, 'decoration'); - - JsonMap _textStyle(JsonMap payload) => _objectField(payload, 'style'); - - JsonMap _defaultTextStyle(JsonMap payload) { - final modifiers = _modifiers(payload); - for (final modifier in modifiers) { - if (modifier['type'] == 'default_text_style') { - return _objectField(modifier, 'style'); - } - } - final modifier = { - 'type': 'default_text_style', - 'style': {}, - }; - modifiers.add(modifier); - - return modifier['style']! as JsonMap; - } - - List _modifiers(JsonMap payload) { - return (payload['modifiers'] ??= []) as List; - } - - List _textDirectives(JsonMap payload) { - return (payload['textDirectives'] ??= []) as List; - } - - JsonMap _objectField(JsonMap payload, String field) { - return (payload[field] ??= {}) as JsonMap; - } - - List? _boxShadowPayload(TwValue value) { - if (value is! TwEnumValue) return null; - - final shadowValue = value.value; - if (shadowValue is! List?) return null; - final shadows = shadowValue ?? const []; - final payload = _encodePayload( - BoxStyler().boxShadows(shadows), - expectedType: SchemaStyler.box, - ); - final decoration = payload['decoration'] as JsonMap?; - final encoded = decoration?['boxShadow'] as List?; - - return encoded?.cast() ?? const []; - } - - List? _textShadowPayload(TwValue value) { - final shadows = _resolveTextShadowMixes(value); - if (shadows == null) return null; - final payload = _encodePayload( - TextStyler().shadows(shadows), - expectedType: SchemaStyler.text, - ); - final style = payload['style'] as JsonMap?; - final encoded = style?['shadows'] as List?; - - return encoded?.cast() ?? const []; - } - - TwLengthValue? _spacePayloadValue(String key) { - final size = _config.spaceOf(key, fallback: double.nan); - if (size.isNaN) return null; - - return TwLengthValue(size); - } - - bool _isWidgetLayerSizingToken(String key) { - return parseFractionToken(key) != null || _isFullOrScreenKey(key); - } - - String? _payloadColor(Color color) { - if (color.runtimeType != Color) return null; - - return payloadColor(color); - } - - String _fontWeightWire(FontWeight value) { - return switch (value) { - FontWeight.w100 => 'w100', - FontWeight.w200 => 'w200', - FontWeight.w300 => 'w300', - FontWeight.w400 => 'w400', - FontWeight.w500 => 'w500', - FontWeight.w600 => 'w600', - FontWeight.w700 => 'w700', - FontWeight.w800 => 'w800', - FontWeight.w900 => 'w900', - _ => throw SchemaPayloadUnsupported([ - MixSchemaError( - code: MixSchemaErrorCode.unsupportedEncodeValue, - path: '/style/fontWeight', - message: 'Unsupported FontWeight value: $value.', - value: value, - ), - ]), - }; - } - - JsonMap _encodePayload(Object styler, {required SchemaStyler expectedType}) { - final result = _schemaContract.encode(styler); - - final payload = switch (result) { - MixSchemaEncodeSuccess(:final value) => value, - MixSchemaEncodeFailure(:final errors) => throw SchemaPayloadUnsupported( - errors, - ), - }; - if (payload['type'] != expectedType.wireValue) { - throw SchemaPayloadUnsupported([ - MixSchemaError( - code: MixSchemaErrorCode.typeMismatch, - path: '/type', - message: - 'Expected ${expectedType.wireValue} payload, got ${payload['type']}.', - value: payload['type'], - ), - ]); - } - - return payload; - } - - String? _findTextSizeKey(String token) { - if (token.startsWith('text-')) { - return token.substring(5); - } - return null; - } - - bool _isFullOrScreenKey(String key) => key == 'full' || key == 'screen'; -} diff --git a/packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart b/packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart deleted file mode 100644 index 2ff587d587..0000000000 --- a/packages/mix_tailwinds/lib/src/tw_schema_payload_policy.dart +++ /dev/null @@ -1,305 +0,0 @@ -import 'tw_semantic.dart'; - -enum TwSchemaPayloadDecision { schema, widgetLayer, directOnly, unsupported } - -final class TwSchemaPayloadPolicy { - const TwSchemaPayloadPolicy(this.decision, this.reason); - - final TwSchemaPayloadDecision decision; - final String reason; -} - -const Map twSchemaPayloadPolicy = { - TwProperty.padding: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex padding payload.', - ), - TwProperty.paddingX: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex horizontal padding payload.', - ), - TwProperty.paddingY: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex vertical padding payload.', - ), - TwProperty.paddingTop: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex top padding payload.', - ), - TwProperty.paddingRight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex right padding payload.', - ), - TwProperty.paddingBottom: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex bottom padding payload.', - ), - TwProperty.paddingLeft: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex left padding payload.', - ), - TwProperty.margin: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex margin payload.', - ), - TwProperty.marginX: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex horizontal margin payload.', - ), - TwProperty.marginY: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex vertical margin payload.', - ), - TwProperty.marginTop: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex top margin payload.', - ), - TwProperty.marginRight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex right margin payload.', - ), - TwProperty.marginBottom: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex bottom margin payload.', - ), - TwProperty.marginLeft: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex left margin payload.', - ), - TwProperty.gap: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Flex main-axis spacing payload.', - ), - TwProperty.gapX: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Cross-axis gap depends on resolved flex axis.', - ), - TwProperty.gapY: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Cross-axis gap depends on resolved flex axis.', - ), - TwProperty.width: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Pixel constraints are schema-backed; full, screen, and percent stay widget-layer.', - ), - TwProperty.height: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Pixel constraints are schema-backed; full, screen, and percent stay widget-layer.', - ), - TwProperty.minWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Pixel constraints are schema-backed; screen stays widget-layer.', - ), - TwProperty.minHeight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Pixel constraints are schema-backed; screen stays widget-layer.', - ), - TwProperty.maxWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex max width constraint payload.', - ), - TwProperty.maxHeight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Box and flex max height constraint payload.', - ), - TwProperty.display: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Flex direction is schema-backed; Div layout selection stays widget-layer.', - ), - TwProperty.flexDirection: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Flex direction payload with widget-layer responsive axis handling.', - ), - TwProperty.flexWrap: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'No current Mix flex-wrap schema target.', - ), - TwProperty.alignItems: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Flex cross-axis alignment payload.', - ), - TwProperty.justifyContent: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Flex main-axis alignment payload.', - ), - TwProperty.alignSelf: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Flex parent-data and alignment decorator.', - ), - TwProperty.flexGrow: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Flex parent-data decorator.', - ), - TwProperty.flexShrink: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Flex parent-data decorator.', - ), - TwProperty.flexBasis: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Axis-dependent SizedBox decorator.', - ), - TwProperty.backgroundColor: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Schema-backed when the color is wire-stable.', - ), - TwProperty.backgroundGradient: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Tailwinds gradient accumulators and custom transforms.', - ), - TwProperty.borderWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderTopWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderRightWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderBottomWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderLeftWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderXWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderYWidth: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderColor: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Border accumulator preserves Tailwinds side and color defaults.', - ), - TwProperty.borderRadius: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration borderRadius payload.', - ), - TwProperty.borderRadiusTop: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration top borderRadius payload.', - ), - TwProperty.borderRadiusBottom: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration bottom borderRadius payload.', - ), - TwProperty.borderRadiusLeft: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration left borderRadius payload.', - ), - TwProperty.borderRadiusRight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration right borderRadius payload.', - ), - TwProperty.borderRadiusTopLeft: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration top-left borderRadius payload.', - ), - TwProperty.borderRadiusTopRight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration top-right borderRadius payload.', - ), - TwProperty.borderRadiusBottomLeft: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration bottom-left borderRadius payload.', - ), - TwProperty.borderRadiusBottomRight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration bottom-right borderRadius payload.', - ), - TwProperty.fontSize: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Text and default text style fontSize payload.', - ), - TwProperty.fontWeight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Text and default text style fontWeight payload.', - ), - TwProperty.textColor: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Schema-backed when the color is wire-stable.', - ), - TwProperty.textAlign: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'TextAlign payload.', - ), - TwProperty.lineHeight: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Text and default text style height payload.', - ), - TwProperty.letterSpacing: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Text letterSpacing payload.', - ), - TwProperty.textTransform: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Text directives payload.', - ), - TwProperty.textOverflow: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Overflow, maxLines, and softWrap payload.', - ), - TwProperty.textDecoration: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'No Tailwinds plugin mapping yet.', - ), - TwProperty.textShadow: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Text and default text style shadows payload.', - ), - TwProperty.boxShadow: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Decoration boxShadow payload.', - ), - TwProperty.opacity: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.unsupported, - 'Parser does not implement opacity yet.', - ), - TwProperty.blur: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'Blur modifier payload.', - ), - TwProperty.scale: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Transform accumulator composes Tailwinds transform tokens.', - ), - TwProperty.rotate: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Transform accumulator composes Tailwinds transform tokens.', - ), - TwProperty.translateX: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Transform accumulator composes Tailwinds transform tokens.', - ), - TwProperty.translateY: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.directOnly, - 'Transform accumulator composes Tailwinds transform tokens.', - ), - TwProperty.transition: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Animation is applied after parser output.', - ), - TwProperty.transitionDuration: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Animation is applied after parser output.', - ), - TwProperty.transitionCurve: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Animation is applied after parser output.', - ), - TwProperty.transitionDelay: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.widgetLayer, - 'Animation is applied after parser output.', - ), - TwProperty.clipBehavior: TwSchemaPayloadPolicy( - TwSchemaPayloadDecision.schema, - 'ClipBehavior payload.', - ), -}; diff --git a/packages/mix_tailwinds/lib/src/tw_semantic.dart b/packages/mix_tailwinds/lib/src/tw_semantic.dart deleted file mode 100644 index 57a9537e6c..0000000000 --- a/packages/mix_tailwinds/lib/src/tw_semantic.dart +++ /dev/null @@ -1,1426 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mix/mix.dart'; - -// ============================================================================= -// Value Types (Semantic AST Values) -// ============================================================================= - -/// Unit for length values. -enum TwUnit { - /// Pixels (default). - px, - - /// Relative em units. - rem, - - /// Percentage. - percent, - - /// Unitless (for multipliers like line-height). - none, -} - -/// Base sealed class for all Tailwind values. -sealed class TwValue { - const TwValue(); -} - -/// A length value with optional unit. -final class TwLengthValue extends TwValue { - const TwLengthValue(this.value, [this.unit = TwUnit.px]); - - final double value; - final TwUnit unit; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwLengthValue && - runtimeType == other.runtimeType && - value == other.value && - unit == other.unit; - - @override - int get hashCode => Object.hash(value, unit); - - @override - String toString() => 'TwLengthValue($value, $unit)'; -} - -/// A color value. -final class TwColorValue extends TwValue { - const TwColorValue(this.color); - - final Color color; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwColorValue && - runtimeType == other.runtimeType && - color == other.color; - - @override - int get hashCode => color.hashCode; - - @override - String toString() => 'TwColorValue($color)'; -} - -/// An enum value for properties like flexDirection, alignment, etc. -final class TwEnumValue extends TwValue { - const TwEnumValue(this.value); - - final T value; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwEnumValue && - runtimeType == other.runtimeType && - value == other.value; - - @override - int get hashCode => value.hashCode; - - @override - String toString() => 'TwEnumValue($value)'; -} - -/// A fraction value (e.g., 1/2, 2/3). -final class TwFractionValue extends TwValue { - const TwFractionValue(this.numerator, this.denominator); - - final int numerator; - final int denominator; - - double get value => numerator / denominator; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwFractionValue && - runtimeType == other.runtimeType && - numerator == other.numerator && - denominator == other.denominator; - - @override - int get hashCode => Object.hash(numerator, denominator); - - @override - String toString() => 'TwFractionValue($numerator/$denominator)'; -} - -/// A transform matrix value. -final class TwMatrixValue extends TwValue { - const TwMatrixValue(this.matrix); - - final Matrix4 matrix; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwMatrixValue && - runtimeType == other.runtimeType && - matrix == other.matrix; - - @override - int get hashCode => matrix.hashCode; - - @override - String toString() => 'TwMatrixValue($matrix)'; -} - -/// A gradient value with direction and colors. -final class TwGradientValue extends TwValue { - const TwGradientValue({ - required this.begin, - required this.end, - required this.colors, - }); - - final Alignment begin; - final Alignment end; - final List colors; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwGradientValue && - runtimeType == other.runtimeType && - begin == other.begin && - end == other.end && - _listEquals(colors, other.colors); - - @override - int get hashCode => Object.hash(begin, end, Object.hashAll(colors)); - - @override - String toString() => - 'TwGradientValue(begin: $begin, end: $end, colors: $colors)'; -} - -/// A duration value in milliseconds. -final class TwDurationValue extends TwValue { - const TwDurationValue(this.milliseconds); - - final int milliseconds; - - Duration get duration => Duration(milliseconds: milliseconds); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwDurationValue && - runtimeType == other.runtimeType && - milliseconds == other.milliseconds; - - @override - int get hashCode => milliseconds.hashCode; - - @override - String toString() => 'TwDurationValue(${milliseconds}ms)'; -} - -/// A curve value for animations. -final class TwCurveValue extends TwValue { - const TwCurveValue(this.curve); - - final Curve curve; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwCurveValue && - runtimeType == other.runtimeType && - curve == other.curve; - - @override - int get hashCode => curve.hashCode; - - @override - String toString() => 'TwCurveValue($curve)'; -} - -// ============================================================================= -// Text Shadow Presets -// ============================================================================= - -enum TextShadowPreset { twoXs, xs, sm, md, lg } - -const Map> kTextShadowPresets = { - TextShadowPreset.twoXs: [ - Shadow(offset: Offset(0, 1), blurRadius: 0, color: Color(0x26000000)), - ], - TextShadowPreset.xs: [ - Shadow(offset: Offset(0, 1), blurRadius: 1, color: Color(0x33000000)), - ], - TextShadowPreset.sm: [ - Shadow(offset: Offset(0, 1), blurRadius: 0, color: Color(0x13000000)), - Shadow(offset: Offset(0, 1), blurRadius: 1, color: Color(0x13000000)), - Shadow(offset: Offset(0, 2), blurRadius: 2, color: Color(0x13000000)), - ], - TextShadowPreset.md: [ - Shadow(offset: Offset(0, 1), blurRadius: 1, color: Color(0x1A000000)), - Shadow(offset: Offset(0, 1), blurRadius: 2, color: Color(0x1A000000)), - Shadow(offset: Offset(0, 2), blurRadius: 4, color: Color(0x1A000000)), - ], - TextShadowPreset.lg: [ - Shadow(offset: Offset(0, 1), blurRadius: 2, color: Color(0x1A000000)), - Shadow(offset: Offset(0, 3), blurRadius: 2, color: Color(0x1A000000)), - Shadow(offset: Offset(0, 4), blurRadius: 8, color: Color(0x1A000000)), - ], -}; - -// ============================================================================= -// Box Shadow Presets (Tailwind parity) -// ============================================================================= - -final Map> kTailwindBoxShadowPresets = { - 'shadow-sm': [ - BoxShadowMix( - offset: const Offset(0, 1), - blurRadius: 2, - spreadRadius: 0, - color: const Color(0x0D000000), // 0.05 - ), - ], - 'shadow': [ - BoxShadowMix( - offset: const Offset(0, 1), - blurRadius: 3, - spreadRadius: 0, - color: const Color(0x1A000000), // 0.10 - ), - BoxShadowMix( - offset: const Offset(0, 1), - blurRadius: 2, - spreadRadius: 0, - color: const Color(0x0F000000), // 0.06 - ), - ], - 'shadow-md': [ - BoxShadowMix( - offset: const Offset(0, 4), - blurRadius: 6, - spreadRadius: -1, - color: const Color(0x1A000000), // 0.10 - ), - BoxShadowMix( - offset: const Offset(0, 2), - blurRadius: 4, - spreadRadius: -2, - color: const Color(0x1A000000), // 0.10 - ), - ], - 'shadow-lg': [ - BoxShadowMix( - offset: const Offset(0, 10), - blurRadius: 15, - spreadRadius: -3, - color: const Color(0x1A000000), // 0.10 - ), - BoxShadowMix( - offset: const Offset(0, 4), - blurRadius: 6, - spreadRadius: -4, - color: const Color(0x1A000000), // 0.10 - ), - ], - 'shadow-xl': [ - BoxShadowMix( - offset: const Offset(0, 20), - blurRadius: 25, - spreadRadius: -5, - color: const Color(0x1A000000), // 0.10 - ), - BoxShadowMix( - offset: const Offset(0, 8), - blurRadius: 10, - spreadRadius: -6, - color: const Color(0x1A000000), // 0.10 - ), - ], - 'shadow-2xl': [ - BoxShadowMix( - offset: const Offset(0, 25), - blurRadius: 50, - spreadRadius: -12, - color: const Color(0x40000000), // 0.25 - ), - ], -}; - -// ============================================================================= -// Property Enum -// ============================================================================= - -/// All supported Tailwind properties. -enum TwProperty { - // Spacing - padding, - paddingX, - paddingY, - paddingTop, - paddingRight, - paddingBottom, - paddingLeft, - margin, - marginX, - marginY, - marginTop, - marginRight, - marginBottom, - marginLeft, - gap, - gapX, - gapY, - - // Sizing - width, - height, - minWidth, - minHeight, - maxWidth, - maxHeight, - - // Layout - display, - flexDirection, - flexWrap, - alignItems, - justifyContent, - alignSelf, - flexGrow, - flexShrink, - flexBasis, - - // Background - backgroundColor, - backgroundGradient, - - // Border width - borderWidth, - borderTopWidth, - borderRightWidth, - borderBottomWidth, - borderLeftWidth, - borderXWidth, - borderYWidth, - - // Border color - borderColor, - - // Border radius - borderRadius, - borderRadiusTop, - borderRadiusBottom, - borderRadiusLeft, - borderRadiusRight, - borderRadiusTopLeft, - borderRadiusTopRight, - borderRadiusBottomLeft, - borderRadiusBottomRight, - - // Typography - fontSize, - fontWeight, - textColor, - textAlign, - lineHeight, - letterSpacing, - textTransform, - textOverflow, - textDecoration, - textShadow, - - // Effects - boxShadow, - opacity, - blur, - - // Transform (individual components) - scale, - rotate, - translateX, - translateY, - - // Animation - transition, - transitionDuration, - transitionCurve, - transitionDelay, - - // Misc - clipBehavior, -} - -// ============================================================================= -// Variant Types -// ============================================================================= - -/// Base sealed class for all variant types. -sealed class TwVariantType { - const TwVariantType(); -} - -/// Interaction variant (hover, focus, pressed, disabled, enabled). -final class TwInteractionVariant extends TwVariantType { - const TwInteractionVariant(this.state); - - final String state; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwInteractionVariant && - runtimeType == other.runtimeType && - state == other.state; - - @override - int get hashCode => state.hashCode; - - @override - String toString() => 'TwInteractionVariant($state)'; -} - -/// Breakpoint variant (sm, md, lg, xl, 2xl). -final class TwBreakpointVariant extends TwVariantType { - const TwBreakpointVariant(this.name, this.minWidth); - - final String name; - final double minWidth; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwBreakpointVariant && - runtimeType == other.runtimeType && - name == other.name && - minWidth == other.minWidth; - - @override - int get hashCode => Object.hash(name, minWidth); - - @override - String toString() => 'TwBreakpointVariant($name, minWidth: $minWidth)'; -} - -/// Theme variant (dark, light). -final class TwThemeVariant extends TwVariantType { - const TwThemeVariant(this.mode); - - final String mode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwThemeVariant && - runtimeType == other.runtimeType && - mode == other.mode; - - @override - int get hashCode => mode.hashCode; - - @override - String toString() => 'TwThemeVariant($mode)'; -} - -// ============================================================================= -// Parsed Class -// ============================================================================= - -/// Represents a fully parsed Tailwind class with resolved values. -final class TwParsedClass { - const TwParsedClass({ - required this.property, - required this.value, - this.variants = const [], - this.important = false, - this.negative = false, - this.arbitrary = false, - }); - - final TwProperty property; - final TwValue value; - final List variants; - final bool important; - final bool negative; - final bool arbitrary; - - /// Returns a unique key for this variant combination. - String get variantKey => - variants.isEmpty ? '' : variants.map(_variantToKey).join(':'); - - static String _variantToKey(TwVariantType v) => switch (v) { - TwInteractionVariant(:final state) => state, - TwBreakpointVariant(:final name) => name, - TwThemeVariant(:final mode) => mode, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is TwParsedClass && - runtimeType == other.runtimeType && - property == other.property && - value == other.value && - _listEquals(variants, other.variants) && - important == other.important && - negative == other.negative && - arbitrary == other.arbitrary; - - @override - int get hashCode => Object.hash( - property, - value, - Object.hashAll(variants), - important, - negative, - arbitrary, - ); - - @override - String toString() => - 'TwParsedClass(property: $property, value: $value, variants: $variants, important: $important, negative: $negative, arbitrary: $arbitrary)'; -} - -// ============================================================================= -// Plugin Types -// ============================================================================= - -/// Type of value a functional plugin expects. -enum TwPluginType { - /// Length value from space/radii/borderWidths scale. - length, - - /// Color value from colors scale. - color, - - /// Fraction value (e.g., 1/2, 2/3). - fraction, - - /// Enum value (no scale lookup). - enumValue, -} - -/// A functional plugin that takes a value (e.g., p-4, bg-red-500). -final class TwFunctionalPlugin { - const TwFunctionalPlugin({ - required this.property, - required this.type, - this.scale, - this.supportsNegative = false, - }); - - final TwProperty property; - final TwPluginType type; - - /// The config scale to look up values from ('space', 'colors', 'radii', etc.). - final String? scale; - - /// Whether this property supports negative values (e.g., -m-4). - final bool supportsNegative; -} - -/// A named plugin that produces a fixed value (e.g., flex-row, items-center). -final class TwNamedPlugin { - const TwNamedPlugin({required this.property, required this.value}); - - final TwProperty property; - final TwValue value; -} - -// ============================================================================= -// Plugin Registry -// ============================================================================= - -/// Functional plugins - properties that take a value. -const Map functionalPlugins = { - // Spacing - Padding - 'p': TwFunctionalPlugin( - property: TwProperty.padding, - type: TwPluginType.length, - scale: 'space', - ), - 'px': TwFunctionalPlugin( - property: TwProperty.paddingX, - type: TwPluginType.length, - scale: 'space', - ), - 'py': TwFunctionalPlugin( - property: TwProperty.paddingY, - type: TwPluginType.length, - scale: 'space', - ), - 'pt': TwFunctionalPlugin( - property: TwProperty.paddingTop, - type: TwPluginType.length, - scale: 'space', - ), - 'pr': TwFunctionalPlugin( - property: TwProperty.paddingRight, - type: TwPluginType.length, - scale: 'space', - ), - 'pb': TwFunctionalPlugin( - property: TwProperty.paddingBottom, - type: TwPluginType.length, - scale: 'space', - ), - 'pl': TwFunctionalPlugin( - property: TwProperty.paddingLeft, - type: TwPluginType.length, - scale: 'space', - ), - - // Spacing - Margin - 'm': TwFunctionalPlugin( - property: TwProperty.margin, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'mx': TwFunctionalPlugin( - property: TwProperty.marginX, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'my': TwFunctionalPlugin( - property: TwProperty.marginY, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'mt': TwFunctionalPlugin( - property: TwProperty.marginTop, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'mr': TwFunctionalPlugin( - property: TwProperty.marginRight, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'mb': TwFunctionalPlugin( - property: TwProperty.marginBottom, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'ml': TwFunctionalPlugin( - property: TwProperty.marginLeft, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - - // Spacing - Gap - 'gap': TwFunctionalPlugin( - property: TwProperty.gap, - type: TwPluginType.length, - scale: 'space', - ), - 'gap-x': TwFunctionalPlugin( - property: TwProperty.gapX, - type: TwPluginType.length, - scale: 'space', - ), - 'gap-y': TwFunctionalPlugin( - property: TwProperty.gapY, - type: TwPluginType.length, - scale: 'space', - ), - - // Sizing - 'w': TwFunctionalPlugin( - property: TwProperty.width, - type: TwPluginType.length, - scale: 'space', - ), - 'h': TwFunctionalPlugin( - property: TwProperty.height, - type: TwPluginType.length, - scale: 'space', - ), - 'min-w': TwFunctionalPlugin( - property: TwProperty.minWidth, - type: TwPluginType.length, - scale: 'space', - ), - 'min-h': TwFunctionalPlugin( - property: TwProperty.minHeight, - type: TwPluginType.length, - scale: 'space', - ), - 'max-w': TwFunctionalPlugin( - property: TwProperty.maxWidth, - type: TwPluginType.length, - scale: 'space', - ), - 'max-h': TwFunctionalPlugin( - property: TwProperty.maxHeight, - type: TwPluginType.length, - scale: 'space', - ), - - // Background - 'bg': TwFunctionalPlugin( - property: TwProperty.backgroundColor, - type: TwPluginType.color, - scale: 'colors', - ), - - // Text color (text-{color}) - 'text': TwFunctionalPlugin( - property: TwProperty.textColor, - type: TwPluginType.color, - scale: 'colors', - ), - - // Border width - 'border': TwFunctionalPlugin( - property: TwProperty.borderWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - 'border-t': TwFunctionalPlugin( - property: TwProperty.borderTopWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - 'border-r': TwFunctionalPlugin( - property: TwProperty.borderRightWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - 'border-b': TwFunctionalPlugin( - property: TwProperty.borderBottomWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - 'border-l': TwFunctionalPlugin( - property: TwProperty.borderLeftWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - 'border-x': TwFunctionalPlugin( - property: TwProperty.borderXWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - 'border-y': TwFunctionalPlugin( - property: TwProperty.borderYWidth, - type: TwPluginType.length, - scale: 'borderWidths', - ), - - // Border radius - 'rounded': TwFunctionalPlugin( - property: TwProperty.borderRadius, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-t': TwFunctionalPlugin( - property: TwProperty.borderRadiusTop, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-b': TwFunctionalPlugin( - property: TwProperty.borderRadiusBottom, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-l': TwFunctionalPlugin( - property: TwProperty.borderRadiusLeft, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-r': TwFunctionalPlugin( - property: TwProperty.borderRadiusRight, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-tl': TwFunctionalPlugin( - property: TwProperty.borderRadiusTopLeft, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-tr': TwFunctionalPlugin( - property: TwProperty.borderRadiusTopRight, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-bl': TwFunctionalPlugin( - property: TwProperty.borderRadiusBottomLeft, - type: TwPluginType.length, - scale: 'radii', - ), - 'rounded-br': TwFunctionalPlugin( - property: TwProperty.borderRadiusBottomRight, - type: TwPluginType.length, - scale: 'radii', - ), - - // Transform - 'scale': TwFunctionalPlugin( - property: TwProperty.scale, - type: TwPluginType.length, - scale: 'scales', - ), - 'rotate': TwFunctionalPlugin( - property: TwProperty.rotate, - type: TwPluginType.length, - scale: 'rotations', - supportsNegative: true, - ), - 'translate-x': TwFunctionalPlugin( - property: TwProperty.translateX, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - 'translate-y': TwFunctionalPlugin( - property: TwProperty.translateY, - type: TwPluginType.length, - scale: 'space', - supportsNegative: true, - ), - - // Effects - 'blur': TwFunctionalPlugin( - property: TwProperty.blur, - type: TwPluginType.length, - scale: 'blurs', - supportsNegative: false, - ), - - // Animation - 'duration': TwFunctionalPlugin( - property: TwProperty.transitionDuration, - type: TwPluginType.length, - scale: 'durations', - ), - 'delay': TwFunctionalPlugin( - property: TwProperty.transitionDelay, - type: TwPluginType.length, - scale: 'delays', - ), - - // Typography - Font size (size-lg, size-[24px], size-[1.5rem]) - 'size': TwFunctionalPlugin( - property: TwProperty.fontSize, - type: TwPluginType.length, - scale: 'fontSizes', - ), -}; - -/// Named plugins - properties with fixed values. -final Map namedPlugins = { - // Display - 'flex': TwNamedPlugin( - property: TwProperty.display, - value: const TwEnumValue('flex'), - ), - 'hidden': TwNamedPlugin( - property: TwProperty.display, - value: const TwEnumValue('none'), - ), - 'block': TwNamedPlugin( - property: TwProperty.display, - value: const TwEnumValue('block'), - ), - 'flex-row': TwNamedPlugin( - property: TwProperty.flexDirection, - value: const TwEnumValue(Axis.horizontal), - ), - 'flex-col': TwNamedPlugin( - property: TwProperty.flexDirection, - value: const TwEnumValue(Axis.vertical), - ), - - // Flex wrap - 'flex-wrap': TwNamedPlugin( - property: TwProperty.flexWrap, - value: const TwEnumValue(true), - ), - 'flex-nowrap': TwNamedPlugin( - property: TwProperty.flexWrap, - value: const TwEnumValue(false), - ), - 'flex-wrap-reverse': TwNamedPlugin( - property: TwProperty.flexWrap, - value: const TwEnumValue('reverse'), - ), - - // Flex item properties - 'flex-1': TwNamedPlugin( - property: TwProperty.flexGrow, - value: const TwLengthValue(1), - ), - 'flex-auto': TwNamedPlugin( - property: TwProperty.flexGrow, - value: const TwEnumValue('auto'), - ), - 'flex-initial': TwNamedPlugin( - property: TwProperty.flexGrow, - value: const TwEnumValue('initial'), - ), - 'flex-none': TwNamedPlugin( - property: TwProperty.flexGrow, - value: const TwEnumValue('none'), - ), - 'grow': TwNamedPlugin( - property: TwProperty.flexGrow, - value: const TwLengthValue(1), - ), - 'grow-0': TwNamedPlugin( - property: TwProperty.flexGrow, - value: const TwLengthValue(0), - ), - 'shrink': TwNamedPlugin( - property: TwProperty.flexShrink, - value: const TwLengthValue(1), - ), - 'shrink-0': TwNamedPlugin( - property: TwProperty.flexShrink, - value: const TwLengthValue(0), - ), - - // Alignment - Cross axis - 'items-start': TwNamedPlugin( - property: TwProperty.alignItems, - value: const TwEnumValue(CrossAxisAlignment.start), - ), - 'items-center': TwNamedPlugin( - property: TwProperty.alignItems, - value: const TwEnumValue(CrossAxisAlignment.center), - ), - 'items-end': TwNamedPlugin( - property: TwProperty.alignItems, - value: const TwEnumValue(CrossAxisAlignment.end), - ), - 'items-stretch': TwNamedPlugin( - property: TwProperty.alignItems, - value: const TwEnumValue(CrossAxisAlignment.stretch), - ), - 'items-baseline': TwNamedPlugin( - property: TwProperty.alignItems, - value: const TwEnumValue(CrossAxisAlignment.baseline), - ), - - // Alignment - Main axis - 'justify-start': TwNamedPlugin( - property: TwProperty.justifyContent, - value: const TwEnumValue(MainAxisAlignment.start), - ), - 'justify-center': TwNamedPlugin( - property: TwProperty.justifyContent, - value: const TwEnumValue(MainAxisAlignment.center), - ), - 'justify-end': TwNamedPlugin( - property: TwProperty.justifyContent, - value: const TwEnumValue(MainAxisAlignment.end), - ), - 'justify-between': TwNamedPlugin( - property: TwProperty.justifyContent, - value: const TwEnumValue(MainAxisAlignment.spaceBetween), - ), - 'justify-around': TwNamedPlugin( - property: TwProperty.justifyContent, - value: const TwEnumValue(MainAxisAlignment.spaceAround), - ), - 'justify-evenly': TwNamedPlugin( - property: TwProperty.justifyContent, - value: const TwEnumValue(MainAxisAlignment.spaceEvenly), - ), - - // Self alignment - 'self-auto': TwNamedPlugin( - property: TwProperty.alignSelf, - value: const TwEnumValue('auto'), - ), - 'self-start': TwNamedPlugin( - property: TwProperty.alignSelf, - value: const TwEnumValue(CrossAxisAlignment.start), - ), - 'self-center': TwNamedPlugin( - property: TwProperty.alignSelf, - value: const TwEnumValue(CrossAxisAlignment.center), - ), - 'self-end': TwNamedPlugin( - property: TwProperty.alignSelf, - value: const TwEnumValue(CrossAxisAlignment.end), - ), - 'self-stretch': TwNamedPlugin( - property: TwProperty.alignSelf, - value: const TwEnumValue(CrossAxisAlignment.stretch), - ), - - // Overflow clipping - 'overflow-hidden': TwNamedPlugin( - property: TwProperty.clipBehavior, - value: const TwEnumValue(Clip.hardEdge), - ), - 'overflow-visible': TwNamedPlugin( - property: TwProperty.clipBehavior, - value: const TwEnumValue(Clip.none), - ), - 'overflow-clip': TwNamedPlugin( - property: TwProperty.clipBehavior, - value: const TwEnumValue(Clip.hardEdge), - ), - - // Blur - 'blur-none': TwNamedPlugin( - property: TwProperty.blur, - value: const TwLengthValue(0.0), - ), - - // Shadows - 'shadow-none': TwNamedPlugin( - property: TwProperty.boxShadow, - value: const TwEnumValue?>([]), - ), - 'shadow-sm': TwNamedPlugin( - property: TwProperty.boxShadow, - value: TwEnumValue(kTailwindBoxShadowPresets['shadow-sm']!), - ), - 'shadow': TwNamedPlugin( - property: TwProperty.boxShadow, - value: TwEnumValue(kTailwindBoxShadowPresets['shadow']!), - ), - 'shadow-md': TwNamedPlugin( - property: TwProperty.boxShadow, - value: TwEnumValue(kTailwindBoxShadowPresets['shadow-md']!), - ), - 'shadow-lg': TwNamedPlugin( - property: TwProperty.boxShadow, - value: TwEnumValue(kTailwindBoxShadowPresets['shadow-lg']!), - ), - 'shadow-xl': TwNamedPlugin( - property: TwProperty.boxShadow, - value: TwEnumValue(kTailwindBoxShadowPresets['shadow-xl']!), - ), - 'shadow-2xl': TwNamedPlugin( - property: TwProperty.boxShadow, - value: TwEnumValue(kTailwindBoxShadowPresets['shadow-2xl']!), - ), - - // Text shadows - 'text-shadow-none': TwNamedPlugin( - property: TwProperty.textShadow, - value: const TwEnumValue(null), - ), - 'text-shadow-2xs': TwNamedPlugin( - property: TwProperty.textShadow, - value: const TwEnumValue(TextShadowPreset.twoXs), - ), - 'text-shadow-xs': TwNamedPlugin( - property: TwProperty.textShadow, - value: const TwEnumValue(TextShadowPreset.xs), - ), - 'text-shadow-sm': TwNamedPlugin( - property: TwProperty.textShadow, - value: const TwEnumValue(TextShadowPreset.sm), - ), - 'text-shadow-md': TwNamedPlugin( - property: TwProperty.textShadow, - value: const TwEnumValue(TextShadowPreset.md), - ), - 'text-shadow-lg': TwNamedPlugin( - property: TwProperty.textShadow, - value: const TwEnumValue(TextShadowPreset.lg), - ), - - // Font weights - 'font-thin': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w100), - ), - 'font-extralight': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w200), - ), - 'font-light': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w300), - ), - 'font-normal': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w400), - ), - 'font-medium': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w500), - ), - 'font-semibold': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w600), - ), - 'font-bold': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w700), - ), - 'font-extrabold': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w800), - ), - 'font-black': TwNamedPlugin( - property: TwProperty.fontWeight, - value: const TwEnumValue(FontWeight.w900), - ), - - // Text alignment - 'text-left': TwNamedPlugin( - property: TwProperty.textAlign, - value: const TwEnumValue(TextAlign.left), - ), - 'text-center': TwNamedPlugin( - property: TwProperty.textAlign, - value: const TwEnumValue(TextAlign.center), - ), - 'text-right': TwNamedPlugin( - property: TwProperty.textAlign, - value: const TwEnumValue(TextAlign.right), - ), - 'text-justify': TwNamedPlugin( - property: TwProperty.textAlign, - value: const TwEnumValue(TextAlign.justify), - ), - 'text-start': TwNamedPlugin( - property: TwProperty.textAlign, - value: const TwEnumValue(TextAlign.start), - ), - 'text-end': TwNamedPlugin( - property: TwProperty.textAlign, - value: const TwEnumValue(TextAlign.end), - ), - - // Text transform - 'uppercase': TwNamedPlugin( - property: TwProperty.textTransform, - value: const TwEnumValue('uppercase'), - ), - 'lowercase': TwNamedPlugin( - property: TwProperty.textTransform, - value: const TwEnumValue('lowercase'), - ), - 'capitalize': TwNamedPlugin( - property: TwProperty.textTransform, - value: const TwEnumValue('capitalize'), - ), - 'truncate': TwNamedPlugin( - property: TwProperty.textOverflow, - value: const TwEnumValue(TextOverflow.ellipsis), - ), - - // Line height - 'leading-none': TwNamedPlugin( - property: TwProperty.lineHeight, - value: const TwLengthValue(1.0, TwUnit.none), - ), - 'leading-tight': TwNamedPlugin( - property: TwProperty.lineHeight, - value: const TwLengthValue(1.25, TwUnit.none), - ), - 'leading-snug': TwNamedPlugin( - property: TwProperty.lineHeight, - value: const TwLengthValue(1.375, TwUnit.none), - ), - 'leading-normal': TwNamedPlugin( - property: TwProperty.lineHeight, - value: const TwLengthValue(1.5, TwUnit.none), - ), - 'leading-relaxed': TwNamedPlugin( - property: TwProperty.lineHeight, - value: const TwLengthValue(1.625, TwUnit.none), - ), - 'leading-loose': TwNamedPlugin( - property: TwProperty.lineHeight, - value: const TwLengthValue(2.0, TwUnit.none), - ), - - // Letter spacing - 'tracking-tighter': TwNamedPlugin( - property: TwProperty.letterSpacing, - value: const TwLengthValue(-0.8), - ), - 'tracking-tight': TwNamedPlugin( - property: TwProperty.letterSpacing, - value: const TwLengthValue(-0.4), - ), - 'tracking-normal': TwNamedPlugin( - property: TwProperty.letterSpacing, - value: const TwLengthValue(0), - ), - 'tracking-wide': TwNamedPlugin( - property: TwProperty.letterSpacing, - value: const TwLengthValue(0.4), - ), - 'tracking-wider': TwNamedPlugin( - property: TwProperty.letterSpacing, - value: const TwLengthValue(0.8), - ), - 'tracking-widest': TwNamedPlugin( - property: TwProperty.letterSpacing, - value: const TwLengthValue(1.6), - ), - - // Transitions - 'transition': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(true), - ), - 'transition-all': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(true), - ), - 'transition-colors': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(true), - ), - 'transition-opacity': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(true), - ), - 'transition-shadow': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(true), - ), - 'transition-transform': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(true), - ), - 'transition-none': TwNamedPlugin( - property: TwProperty.transition, - value: const TwEnumValue(false), - ), - - // Easing curves - 'ease-linear': TwNamedPlugin( - property: TwProperty.transitionCurve, - value: TwCurveValue(Curves.linear), - ), - 'ease-in': TwNamedPlugin( - property: TwProperty.transitionCurve, - value: TwCurveValue(Curves.easeIn), - ), - 'ease-out': TwNamedPlugin( - property: TwProperty.transitionCurve, - value: TwCurveValue(Curves.easeOut), - ), - 'ease-in-out': TwNamedPlugin( - property: TwProperty.transitionCurve, - value: TwCurveValue(Curves.easeInOut), - ), - - // Sizing special values - 'w-full': TwNamedPlugin( - property: TwProperty.width, - value: const TwEnumValue('full'), - ), - 'w-screen': TwNamedPlugin( - property: TwProperty.width, - value: const TwEnumValue('screen'), - ), - 'w-auto': TwNamedPlugin( - property: TwProperty.width, - value: const TwEnumValue('auto'), - ), - 'h-full': TwNamedPlugin( - property: TwProperty.height, - value: const TwEnumValue('full'), - ), - 'h-screen': TwNamedPlugin( - property: TwProperty.height, - value: const TwEnumValue('screen'), - ), - 'h-auto': TwNamedPlugin( - property: TwProperty.height, - value: const TwEnumValue('auto'), - ), - 'min-w-0': TwNamedPlugin( - property: TwProperty.minWidth, - value: const TwLengthValue(0), - ), - 'min-w-auto': TwNamedPlugin( - property: TwProperty.minWidth, - value: const TwEnumValue('auto'), - ), - 'min-h-0': TwNamedPlugin( - property: TwProperty.minHeight, - value: const TwLengthValue(0), - ), -}; - -// ============================================================================= -// Variant Definitions -// ============================================================================= - -/// Interaction variant names mapping to normalized state names. -const Map interactionVariants = { - 'hover': 'hover', - 'focus': 'focus', - 'active': 'pressed', - 'pressed': 'pressed', - 'disabled': 'disabled', - 'enabled': 'enabled', -}; - -/// Theme variant names. -const Map themeVariants = {'dark': 'dark', 'light': 'light'}; - -// ============================================================================= -// findRoot Algorithm -// ============================================================================= - -/// Set of all known plugin prefixes for fast lookup. -final Set _allPluginPrefixes = { - ...functionalPlugins.keys, - ...namedPlugins.keys, -}; - -/// Finds the root prefix by iteratively stripping dashes. -/// -/// Example: 'bg-red-500' -> ('bg', 'red-500') -/// Example: 'p-4' -> ('p', '4') -/// Example: 'flex-row' -> ('flex-row', null) (exact match in namedPlugins) -/// -/// Returns null if no matching prefix is found. -(String, String?)? findRoot(String token) { - // Check exact match first (for namedPlugins like 'flex-row', 'items-center') - if (_allPluginPrefixes.contains(token)) { - return (token, null); - } - - // Iteratively strip from last dash to find functional plugin prefix - var current = token; - while (current.isNotEmpty) { - final lastDash = current.lastIndexOf('-'); - if (lastDash == -1) break; - - current = current.substring(0, lastDash); - if (functionalPlugins.containsKey(current)) { - return (current, token.substring(lastDash + 1)); - } - } - - return null; -} - -// ============================================================================= -// Gradient Direction Map -// ============================================================================= - -/// Gradient direction alignments for Tailwind gradient tokens. -const Map gradientDirections = { - 'to-t': (Alignment.bottomCenter, Alignment.topCenter), - 'to-tr': (Alignment.bottomLeft, Alignment.topRight), - 'to-r': (Alignment.centerLeft, Alignment.centerRight), - 'to-br': (Alignment.topLeft, Alignment.bottomRight), - 'to-b': (Alignment.topCenter, Alignment.bottomCenter), - 'to-bl': (Alignment.topRight, Alignment.bottomLeft), - 'to-l': (Alignment.centerRight, Alignment.centerLeft), - 'to-tl': (Alignment.bottomRight, Alignment.topLeft), -}; - -// ============================================================================= -// Tailwind Default Line Heights -// ============================================================================= - -/// Tailwind default line heights for text-* sizes (as multipliers). -const Map tailwindLineHeights = { - 'xs': 1.333, // 12px / 16px - 'sm': 1.429, // 14px / 20px - 'base': 1.5, // 16px / 24px - 'lg': 1.556, // 18px / 28px - 'xl': 1.4, // 20px / 28px - '2xl': 1.333, // 24px / 32px - '3xl': 1.2, // 30px / 36px - '4xl': 1.111, // 36px / 40px - '5xl': 1.0, // 48px / 48px (leading-none) - '6xl': 1.0, // 60px / 60px - '7xl': 1.0, // 72px / 72px - '8xl': 1.0, // 96px / 96px - '9xl': 1.0, // 128px / 128px -}; - -/// Tailwind Preflight default line-height (1.5). -const double preflightLineHeight = 1.5; - -// ============================================================================= -// Helpers -// ============================================================================= - -bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; -} diff --git a/packages/mix_tailwinds/lib/src/tw_types.dart b/packages/mix_tailwinds/lib/src/tw_types.dart new file mode 100644 index 0000000000..6eadb63a60 --- /dev/null +++ b/packages/mix_tailwinds/lib/src/tw_types.dart @@ -0,0 +1 @@ +typedef TokenWarningCallback = void Function(String token); diff --git a/packages/mix_tailwinds/lib/src/tw_widget.dart b/packages/mix_tailwinds/lib/src/tw_widget.dart index e1b52277d6..d9d401e812 100644 --- a/packages/mix_tailwinds/lib/src/tw_widget.dart +++ b/packages/mix_tailwinds/lib/src/tw_widget.dart @@ -2,49 +2,22 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; +import 'translate/tw_target.dart' as tw_target; import 'tw_config.dart'; import 'tw_parser.dart'; -import 'tw_semantic.dart'; +import 'tw_types.dart'; import 'tw_utils.dart'; // ============================================================================= // Box/Margin Utility Detection // ============================================================================= -/// Tokens that indicate box-level styling (padding, background, border, etc.) -/// When present on Span, we need to wrap text in a Box container. -const _boxUtilityPrefixes = [ - 'p-', 'px-', 'py-', 'pt-', 'pr-', 'pb-', 'pl-', // padding - 'bg-', // background - 'border', // border (includes border-*, rounded-*) - 'rounded', // border radius - 'shadow', // box shadow - // Note: 'ring' and 'opacity-' removed until implemented -]; final _whitespaceRegex = RegExp(r'\s+'); -/// Check if classNames contain any box utilities that require wrapping. -bool _hasBoxUtilities(String classNames) { - final tokens = classNames.split(_whitespaceRegex); - for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); - - for (final prefix in _boxUtilityPrefixes) { - if (base.startsWith(prefix) || base == prefix.replaceAll('-', '')) { - return true; - } - } - } - return false; -} - -/// Extracts the margin [EdgeInsets] from [classNames] using the shared -/// [TwResolver], so text-element margins match the parser's handling of scale -/// (`mb-4`), prefixed (`md:mb-4`), and arbitrary (`mb-[10px]`) values. +/// Extracts positive margin [EdgeInsets] from [classNames]. /// /// Returns null when no positive margin token is present. /// -/// Limitations (intentional, see feedback Finding 5): /// - Negative margins (`-mb-4`) are skipped. They are applied via [Padding], /// whose `RenderPadding` asserts non-negative insets, so emitting them would /// crash. True CSS negative-margin parity needs a transform-based strategy at @@ -54,38 +27,37 @@ bool _hasBoxUtilities(String classNames) { /// margin semantics are not yet modeled in the widget layer. EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { final tokens = classNames.split(_whitespaceRegex); - final resolver = TwResolver(cfg); double? top, right, bottom, left; for (final token in tokens) { - final parsed = resolver.resolveToken(token); - if (parsed == null) continue; - - for (final cls in parsed) { - final value = cls.value; - // Only positive length-valued margin properties map to Padding insets. - // Negative values cannot render through Padding and are skipped. - if (value is! TwLengthValue || value.value < 0) continue; - final v = value.value; - - switch (cls.property) { - case TwProperty.margin: - top = right = bottom = left = v; - case TwProperty.marginX: - left = right = v; - case TwProperty.marginY: - top = bottom = v; - case TwProperty.marginTop: - top = v; - case TwProperty.marginRight: - right = v; - case TwProperty.marginBottom: - bottom = v; - case TwProperty.marginLeft: - left = v; - default: - break; - } + var base = baseTokenOutsideBrackets(token); + if (base.startsWith('-')) continue; + + final dash = base.indexOf('-'); + if (dash <= 0) continue; + final root = base.substring(0, dash); + if (!{'m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'}.contains(root)) { + continue; + } + final key = base.substring(dash + 1); + final value = _marginLength(key, cfg); + if (value == null || value < 0) continue; + + switch (root) { + case 'm': + top = right = bottom = left = value; + case 'mx': + left = right = value; + case 'my': + top = bottom = value; + case 'mt': + top = value; + case 'mr': + right = value; + case 'mb': + bottom = value; + case 'ml': + left = value; } } @@ -101,6 +73,19 @@ EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { ); } +double? _marginLength(String key, TwConfig cfg) { + final scale = cfg.space[key]; + if (scale != null) return scale; + if (!key.startsWith('[') || !key.endsWith(']')) return null; + final inner = key.substring(1, key.length - 1); + final match = RegExp(r'^(\d+\.?\d*)(px|rem|em)?$').firstMatch(inner); + if (match == null) return null; + var value = double.parse(match.group(1)!); + final unit = match.group(2) ?? 'px'; + if (unit == 'rem' || unit == 'em') value *= 16; + return value; +} + // ============================================================================= // CSS Semantic Margin Helpers // ============================================================================= @@ -436,7 +421,7 @@ class Span extends StatelessWidget { final parser = TwParser(config: cfg); // Check if we need box styling (padding, background, border, etc.) - if (_hasBoxUtilities(classNames)) { + if (tw_target.hasBoxUtilities(classNames)) { // Parse as box to get padding, background, border, etc. // parseBox also handles text styling via DefaultTextStyle wrapper final boxStyle = parser.parseBox(classNames); diff --git a/packages/mix_tailwinds/test/div_and_span_test.dart b/packages/mix_tailwinds/test/div_and_span_test.dart index a0dbe38f0e..eae4b2dba9 100644 --- a/packages/mix_tailwinds/test/div_and_span_test.dart +++ b/packages/mix_tailwinds/test/div_and_span_test.dart @@ -75,15 +75,20 @@ CurveAnimationConfig? _parseAnimation(String classNames, {TwParser? parser}) { return p.parseAnimationFromTokens(p.listTokens(classNames)); } -TwParsedClass _resolveSingle(String token, {TokenWarningCallback? onUnknown}) { - final parsed = TwResolver( - TwConfig.standard(), - onUnknownVariant: onUnknown, - ).resolveToken(token); - - expect(parsed, isNotNull); - expect(parsed, hasLength(1)); - return parsed!.single; +Future _renderedTextFor( + WidgetTester tester, + String classNames, { + String value = 'sample', +}) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StyledText(value, style: TwParser().parseText(classNames)), + ), + ); + await tester.pump(); + + return tester.widget(find.text(value)); } Future _pumpSized( @@ -1241,7 +1246,8 @@ void main() { final seen = []; TwParser(onUnsupported: seen.add).parseBox('z-10 opacity-50'); - expect(seen, containsAll(['z-10', 'opacity-50'])); + expect(seen, contains('z-10')); + expect(seen, isNot(contains('opacity-50'))); }); test('Prefix chains parse without warnings', () { @@ -1259,22 +1265,23 @@ void main() { expect(() => parser.parseBox('h-1/'), returnsNormally); }); - test('Arbitrary 3/4-digit hex colors are rejected', () { - final seen = []; - final parser = TwParser(onUnsupported: seen.add); - - parser.parseBox('bg-[#fff] bg-[#ffff]'); + testWidgets('Arbitrary 3/4-digit hex colors are applied', (tester) async { + final rgb = await _boxDecorationFor(tester, 'bg-[#fff]'); + expect(rgb?.color, equals(const Color(0xFFFFFFFF))); - expect(seen, contains('bg-[#fff]')); - expect(seen, contains('bg-[#ffff]')); + final rgba = await _boxDecorationFor(tester, 'bg-[#ffff]'); + expect(rgba?.color, equals(const Color(0xFFFFFFFF))); }); - testWidgets('Arbitrary 6/8-digit hex colors are applied', (tester) async { + testWidgets('Arbitrary 6/8-digit CSS hex colors are applied', (tester) async { final opaque = await _boxDecorationFor(tester, 'bg-[#ffffff]'); expect(opaque?.color, equals(const Color(0xFFFFFFFF))); - final alpha = await _boxDecorationFor(tester, 'bg-[#80ffffff]'); + final alpha = await _boxDecorationFor(tester, 'bg-[#ffffff80]'); expect(alpha?.color, equals(const Color(0x80FFFFFF))); + + final cssOrdered = await _boxDecorationFor(tester, 'bg-[#80ffffff]'); + expect(cssOrdered?.color, equals(const Color(0xFF80FFFF))); }); // ========================================================================== @@ -1335,40 +1342,34 @@ void main() { // Line Height (leading-*) Tests // ========================================================================== - test('leading-none applies line height 1.0', () { - final parsed = _resolveSingle('leading-none'); - expect(parsed.property, TwProperty.lineHeight); - expect(parsed.value, const TwLengthValue(1.0, TwUnit.none)); + testWidgets('leading-none applies line height 1.0', (tester) async { + final text = await _renderedTextFor(tester, 'leading-none'); + expect(text.style?.height, 1.0); }); - test('leading-tight applies line height 1.25', () { - final parsed = _resolveSingle('leading-tight'); - expect(parsed.property, TwProperty.lineHeight); - expect(parsed.value, const TwLengthValue(1.25, TwUnit.none)); + testWidgets('leading-tight applies line height 1.25', (tester) async { + final text = await _renderedTextFor(tester, 'leading-tight'); + expect(text.style?.height, 1.25); }); - test('leading-snug applies line height 1.375', () { - final parsed = _resolveSingle('leading-snug'); - expect(parsed.property, TwProperty.lineHeight); - expect(parsed.value, const TwLengthValue(1.375, TwUnit.none)); + testWidgets('leading-snug applies line height 1.375', (tester) async { + final text = await _renderedTextFor(tester, 'leading-snug'); + expect(text.style?.height, 1.375); }); - test('leading-normal applies line height 1.5', () { - final parsed = _resolveSingle('leading-normal'); - expect(parsed.property, TwProperty.lineHeight); - expect(parsed.value, const TwLengthValue(1.5, TwUnit.none)); + testWidgets('leading-normal applies line height 1.5', (tester) async { + final text = await _renderedTextFor(tester, 'leading-normal'); + expect(text.style?.height, 1.5); }); - test('leading-relaxed applies line height 1.625', () { - final parsed = _resolveSingle('leading-relaxed'); - expect(parsed.property, TwProperty.lineHeight); - expect(parsed.value, const TwLengthValue(1.625, TwUnit.none)); + testWidgets('leading-relaxed applies line height 1.625', (tester) async { + final text = await _renderedTextFor(tester, 'leading-relaxed'); + expect(text.style?.height, 1.625); }); - test('leading-loose applies line height 2.0', () { - final parsed = _resolveSingle('leading-loose'); - expect(parsed.property, TwProperty.lineHeight); - expect(parsed.value, const TwLengthValue(2.0, TwUnit.none)); + testWidgets('leading-loose applies line height 2.0', (tester) async { + final text = await _renderedTextFor(tester, 'leading-loose'); + expect(text.style?.height, 2.0); }); testWidgets('leading-even applies even leading distribution', (tester) async { @@ -1424,40 +1425,34 @@ void main() { // Letter Spacing (tracking-*) Tests // ========================================================================== - test('tracking-tighter applies -0.8 letter spacing', () { - final parsed = _resolveSingle('tracking-tighter'); - expect(parsed.property, TwProperty.letterSpacing); - expect(parsed.value, const TwLengthValue(-0.8)); + testWidgets('tracking-tighter applies -0.8 letter spacing', (tester) async { + final text = await _renderedTextFor(tester, 'tracking-tighter'); + expect(text.style?.letterSpacing, -0.8); }); - test('tracking-tight applies -0.4 letter spacing', () { - final parsed = _resolveSingle('tracking-tight'); - expect(parsed.property, TwProperty.letterSpacing); - expect(parsed.value, const TwLengthValue(-0.4)); + testWidgets('tracking-tight applies -0.4 letter spacing', (tester) async { + final text = await _renderedTextFor(tester, 'tracking-tight'); + expect(text.style?.letterSpacing, -0.4); }); - test('tracking-normal applies 0 letter spacing', () { - final parsed = _resolveSingle('tracking-normal'); - expect(parsed.property, TwProperty.letterSpacing); - expect(parsed.value, const TwLengthValue(0)); + testWidgets('tracking-normal applies 0 letter spacing', (tester) async { + final text = await _renderedTextFor(tester, 'tracking-normal'); + expect(text.style?.letterSpacing, 0); }); - test('tracking-wide applies 0.4 letter spacing', () { - final parsed = _resolveSingle('tracking-wide'); - expect(parsed.property, TwProperty.letterSpacing); - expect(parsed.value, const TwLengthValue(0.4)); + testWidgets('tracking-wide applies 0.4 letter spacing', (tester) async { + final text = await _renderedTextFor(tester, 'tracking-wide'); + expect(text.style?.letterSpacing, 0.4); }); - test('tracking-wider applies 0.8 letter spacing', () { - final parsed = _resolveSingle('tracking-wider'); - expect(parsed.property, TwProperty.letterSpacing); - expect(parsed.value, const TwLengthValue(0.8)); + testWidgets('tracking-wider applies 0.8 letter spacing', (tester) async { + final text = await _renderedTextFor(tester, 'tracking-wider'); + expect(text.style?.letterSpacing, 0.8); }); - test('tracking-widest applies 1.6 letter spacing', () { - final parsed = _resolveSingle('tracking-widest'); - expect(parsed.property, TwProperty.letterSpacing); - expect(parsed.value, const TwLengthValue(1.6)); + testWidgets('tracking-widest applies 1.6 letter spacing', (tester) async { + final text = await _renderedTextFor(tester, 'tracking-widest'); + expect(text.style?.letterSpacing, 1.6); }); // ========================================================================== @@ -3077,7 +3072,7 @@ void main() { expect(find.byType(Container), findsOneWidget); }); - testWidgets('Span with opacity does not wrap in Box', (tester) async { + testWidgets('Span with opacity wraps and applies Opacity', (tester) async { await tester.pumpWidget( const Directionality( textDirection: TextDirection.ltr, @@ -3085,9 +3080,9 @@ void main() { ), ); - // opacity- is not in box utility prefixes (not yet implemented for Box) - // so Span should not wrap in a Container - expect(find.byType(Container), findsNothing); + expect(find.byType(Container), findsOneWidget); + final opacity = tester.widget(find.byType(Opacity)); + expect(opacity.opacity, 0.5); }); }); diff --git a/packages/mix_tailwinds/test/fixtures/candidate-probes.json b/packages/mix_tailwinds/test/fixtures/candidate-probes.json new file mode 100644 index 0000000000..e890c39c50 --- /dev/null +++ b/packages/mix_tailwinds/test/fixtures/candidate-probes.json @@ -0,0 +1,1787 @@ +{ + "meta": { + "generatedAt": "2026-06-16T18:50:10.297Z", + "tailwindInstalledVersion": "4.3.1", + "tailwindGitTag": "v4.3.1", + "tailwindGitSha": "8a14a710102cae195f6811e8578bef9477bc6be9", + "cssEntry": "fixtures/app.css" + }, + "summary": { + "total": 44, + "valid": 39, + "invalid": 5 + }, + "probes": [ + { + "input": "flex", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "flex", + "variants": [], + "important": false, + "raw": "flex" + }, + { + "kind": "functional", + "root": "flex", + "modifier": null, + "value": null, + "variants": [], + "important": false, + "raw": "flex" + } + ], + "canonical": [ + "flex" + ], + "css": ".flex {\n display: flex;\n}\n", + "declarations": [ + { + "property": "display", + "value": "flex" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "flex", + "parseCount": 2, + "astNodeCount": 1 + }, + { + "input": "inline-flex", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "inline-flex", + "variants": [], + "important": false, + "raw": "inline-flex" + }, + { + "kind": "functional", + "root": "inline", + "modifier": null, + "value": { + "kind": "named", + "value": "flex", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "inline-flex" + } + ], + "canonical": [ + "inline-flex" + ], + "css": ".inline-flex {\n display: inline-flex;\n}\n", + "declarations": [ + { + "property": "display", + "value": "inline-flex" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "inline-flex", + "parseCount": 2, + "astNodeCount": 1 + }, + { + "input": "grid", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "grid", + "variants": [], + "important": false, + "raw": "grid" + } + ], + "canonical": [ + "grid" + ], + "css": ".grid {\n display: grid;\n}\n", + "declarations": [ + { + "property": "display", + "value": "grid" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "grid", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "hidden", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "hidden", + "variants": [], + "important": false, + "raw": "hidden" + } + ], + "canonical": [ + "hidden" + ], + "css": ".hidden {\n display: none;\n}\n", + "declarations": [ + { + "property": "display", + "value": "none" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "hidden", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "pointer-events-none", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "pointer-events-none", + "variants": [], + "important": false, + "raw": "pointer-events-none" + } + ], + "canonical": [ + "pointer-events-none" + ], + "css": ".pointer-events-none {\n pointer-events: none;\n}\n", + "declarations": [ + { + "property": "pointer-events", + "value": "none" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "pointer-events-none", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "static", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "static", + "variants": [], + "important": false, + "raw": "static" + } + ], + "canonical": [ + "static" + ], + "css": ".static {\n position: static;\n}\n", + "declarations": [ + { + "property": "position", + "value": "static" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "static", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "absolute", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "absolute", + "variants": [], + "important": false, + "raw": "absolute" + } + ], + "canonical": [ + "absolute" + ], + "css": ".absolute {\n position: absolute;\n}\n", + "declarations": [ + { + "property": "position", + "value": "absolute" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "absolute", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "sr-only", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "sr-only", + "variants": [], + "important": false, + "raw": "sr-only" + } + ], + "canonical": [ + "sr-only" + ], + "css": ".sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border-width: 0;\n}\n", + "declarations": [ + { + "property": "position", + "value": "absolute" + }, + { + "property": "width", + "value": "1px" + }, + { + "property": "height", + "value": "1px" + }, + { + "property": "padding", + "value": "0" + }, + { + "property": "margin", + "value": "-1px" + }, + { + "property": "overflow", + "value": "hidden" + }, + { + "property": "clip-path", + "value": "inset(50%)" + }, + { + "property": "white-space", + "value": "nowrap" + }, + { + "property": "border-width", + "value": "0" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "sr-only", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "not-sr-only", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "not-sr-only", + "variants": [], + "important": false, + "raw": "not-sr-only" + } + ], + "canonical": [ + "not-sr-only" + ], + "css": ".not-sr-only {\n position: static;\n width: auto;\n height: auto;\n padding: 0;\n margin: 0;\n overflow: visible;\n clip-path: none;\n white-space: normal;\n}\n", + "declarations": [ + { + "property": "position", + "value": "static" + }, + { + "property": "width", + "value": "auto" + }, + { + "property": "height", + "value": "auto" + }, + { + "property": "padding", + "value": "0" + }, + { + "property": "margin", + "value": "0" + }, + { + "property": "overflow", + "value": "visible" + }, + { + "property": "clip-path", + "value": "none" + }, + { + "property": "white-space", + "value": "normal" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "not-sr-only", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "p-4", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "p", + "modifier": null, + "value": { + "kind": "named", + "value": "4", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "p-4" + } + ], + "canonical": [ + "p-4" + ], + "css": ".p-4 {\n padding: calc(var(--spacing) * 4);\n}\n", + "declarations": [ + { + "property": "padding", + "value": "calc(var(--spacing) * 4)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "p", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "p-card", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "p", + "modifier": null, + "value": { + "kind": "named", + "value": "card", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "p-card" + } + ], + "canonical": [ + "p-card" + ], + "css": ".p-card {\n padding: var(--spacing-card);\n}\n", + "declarations": [ + { + "property": "padding", + "value": "var(--spacing-card)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "p", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "-px", + "valid": false, + "parsed": [], + "canonical": [ + "-px" + ], + "css": null, + "declarations": [], + "variantKinds": [], + "notes": [ + "Tailwind parseCandidate returned no candidate objects.", + "Tailwind candidatesToCss returned null." + ], + "parseCount": 0, + "astNodeCount": 0 + }, + { + "input": "w-1/2", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "w", + "modifier": { + "kind": "named", + "value": "2", + "fraction": null + }, + "value": { + "kind": "named", + "value": "1", + "fraction": "1/2" + }, + "variants": [], + "important": false, + "raw": "w-1/2" + } + ], + "canonical": [ + "w-1/2" + ], + "css": ".w-1\\/2 {\n width: calc(1 / 2 * 100%);\n}\n", + "declarations": [ + { + "property": "width", + "value": "calc(1 / 2 * 100%)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "w", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "w-[37px]", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "w", + "modifier": null, + "value": { + "kind": "arbitrary", + "dataType": null, + "value": "37px" + }, + "variants": [], + "important": false, + "raw": "w-[37px]" + } + ], + "canonical": [ + "w-[37px]" + ], + "css": ".w-\\[37px\\] {\n width: 37px;\n}\n", + "declarations": [ + { + "property": "width", + "value": "37px" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "w", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-red-500", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": null, + "value": { + "kind": "named", + "value": "red-500", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "bg-red-500" + } + ], + "canonical": [ + "bg-red-500" + ], + "css": ".bg-red-500 {\n background-color: var(--color-red-500);\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "var(--color-red-500)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-red-500/50", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": { + "kind": "named", + "value": "50" + }, + "value": { + "kind": "named", + "value": "red-500", + "fraction": "red-500/50" + }, + "variants": [], + "important": false, + "raw": "bg-red-500/50" + } + ], + "canonical": [ + "bg-red-500/50" + ], + "css": ".bg-red-500\\/50 {\n background-color: color-mix(in oklab, var(--color-red-500) 50%, transparent);\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "color-mix(in oklab, var(--color-red-500) 50%, transparent)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-brand-500", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": null, + "value": { + "kind": "named", + "value": "brand-500", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "bg-brand-500" + } + ], + "canonical": [ + "bg-brand-500" + ], + "css": ".bg-brand-500 {\n background-color: var(--color-brand-500);\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "var(--color-brand-500)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-brand-500/50", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": { + "kind": "named", + "value": "50" + }, + "value": { + "kind": "named", + "value": "brand-500", + "fraction": "brand-500/50" + }, + "variants": [], + "important": false, + "raw": "bg-brand-500/50" + } + ], + "canonical": [ + "bg-brand-500/50" + ], + "css": ".bg-brand-500\\/50 {\n background-color: color-mix(in oklab, var(--color-brand-500) 50%, transparent);\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "color-mix(in oklab, var(--color-brand-500) 50%, transparent)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-[#0088cc]", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": null, + "value": { + "kind": "arbitrary", + "dataType": null, + "value": "#0088cc" + }, + "variants": [], + "important": false, + "raw": "bg-[#0088cc]" + } + ], + "canonical": [ + "bg-[#0088cc]" + ], + "css": ".bg-\\[\\#0088cc\\] {\n background-color: #0088cc;\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "#0088cc" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-[color:var(--brand)]", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": null, + "value": { + "kind": "arbitrary", + "dataType": "color", + "value": "var(--brand)" + }, + "variants": [], + "important": false, + "raw": "bg-[color:var(--brand)]" + } + ], + "canonical": [ + "bg-(--brand)" + ], + "css": ".bg-\\[color\\:var\\(--brand\\)\\] {\n background-color: var(--brand);\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "var(--brand)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-(--brand-color)", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": null, + "value": { + "kind": "arbitrary", + "dataType": null, + "value": "var(--brand-color)" + }, + "variants": [], + "important": false, + "raw": "bg-(--brand-color)" + } + ], + "canonical": [ + "bg-(--brand-color)" + ], + "css": ".bg-\\(--brand-color\\) {\n background-color: var(--brand-color);\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "var(--brand-color)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "text-xl", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "text", + "modifier": null, + "value": { + "kind": "named", + "value": "xl", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "text-xl" + } + ], + "canonical": [ + "text-xl" + ], + "css": ".text-xl {\n font-size: var(--text-xl);\n line-height: var(--tw-leading, var(--text-xl--line-height));\n}\n", + "declarations": [ + { + "property": "font-size", + "value": "var(--text-xl)" + }, + { + "property": "line-height", + "value": "var(--tw-leading, var(--text-xl--line-height))" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "text", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "text-[13px]", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "text", + "modifier": null, + "value": { + "kind": "arbitrary", + "dataType": null, + "value": "13px" + }, + "variants": [], + "important": false, + "raw": "text-[13px]" + } + ], + "canonical": [ + "text-[13px]" + ], + "css": ".text-\\[13px\\] {\n font-size: 13px;\n}\n", + "declarations": [ + { + "property": "font-size", + "value": "13px" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "text", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "rounded-card", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "rounded", + "modifier": null, + "value": { + "kind": "named", + "value": "card", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "rounded-card" + } + ], + "canonical": [ + "rounded-card" + ], + "css": ".rounded-card {\n border-radius: var(--radius-card);\n}\n", + "declarations": [ + { + "property": "border-radius", + "value": "var(--radius-card)" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "rounded", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "shadow-card", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "shadow", + "modifier": null, + "value": { + "kind": "named", + "value": "card", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "shadow-card" + } + ], + "canonical": [ + "shadow-card" + ], + "css": ".shadow-card {\n --tw-shadow: 0 8px 24px var(--tw-shadow-color, rgb(0 0 0 / 0.12));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n@property --tw-shadow {\n syntax: \"*\";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: \"*\";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: \"\";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: \"*\";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: \"*\";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: \"\";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: \"*\";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: \"*\";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: \"*\";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: \"*\";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: \"*\";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: \"\";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: \"*\";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: \"*\";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n", + "declarations": [ + { + "property": "--tw-shadow", + "value": "0 8px 24px var(--tw-shadow-color, rgb(0 0 0 / 0.12))" + }, + { + "property": "box-shadow", + "value": "var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "0 0 #0000" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "syntax", + "value": "\"\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "100%" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "0 0 #0000" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "syntax", + "value": "\"\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "100%" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "0 0 #0000" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "0 0 #0000" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "syntax", + "value": "\"\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "0px" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "#fff" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "0 0 #0000" + } + ], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "shadow", + "parseCount": 1, + "astNodeCount": 15 + }, + { + "input": "hover:bg-brand-500/50", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": { + "kind": "named", + "value": "50" + }, + "value": { + "kind": "named", + "value": "brand-500", + "fraction": "brand-500/50" + }, + "variants": [ + { + "kind": "static", + "root": "hover" + } + ], + "important": false, + "raw": "hover:bg-brand-500/50" + } + ], + "canonical": [ + "hover:bg-brand-500/50" + ], + "css": ".hover\\:bg-brand-500\\/50 {\n &:hover {\n @media (hover: hover) {\n background-color: color-mix(in oklab, var(--color-brand-500) 50%, transparent);\n }\n }\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "color-mix(in oklab, var(--color-brand-500) 50%, transparent)" + } + ], + "variantKinds": [ + "static" + ], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "focus-visible:outline-2", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "outline", + "modifier": null, + "value": { + "kind": "named", + "value": "2", + "fraction": null + }, + "variants": [ + { + "kind": "static", + "root": "focus-visible" + } + ], + "important": false, + "raw": "focus-visible:outline-2" + } + ], + "canonical": [ + "focus-visible:outline-2" + ], + "css": ".focus-visible\\:outline-2 {\n &:focus-visible {\n outline-style: var(--tw-outline-style);\n outline-width: 2px;\n }\n}\n@property --tw-outline-style {\n syntax: \"*\";\n inherits: false;\n initial-value: solid;\n}\n", + "declarations": [ + { + "property": "outline-style", + "value": "var(--tw-outline-style)" + }, + { + "property": "outline-width", + "value": "2px" + }, + { + "property": "syntax", + "value": "\"*\"" + }, + { + "property": "inherits", + "value": "false" + }, + { + "property": "initial-value", + "value": "solid" + } + ], + "variantKinds": [ + "static" + ], + "utilityKind": "functional", + "utilityRoot": "outline", + "parseCount": 1, + "astNodeCount": 2 + }, + { + "input": "sm:grid-cols-2", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "grid-cols", + "modifier": null, + "value": { + "kind": "named", + "value": "2", + "fraction": null + }, + "variants": [ + { + "kind": "static", + "root": "sm" + } + ], + "important": false, + "raw": "sm:grid-cols-2" + } + ], + "canonical": [ + "sm:grid-cols-2" + ], + "css": ".sm\\:grid-cols-2 {\n @media (width >= 40rem) {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n", + "declarations": [ + { + "property": "grid-template-columns", + "value": "repeat(2, minmax(0, 1fr))" + } + ], + "variantKinds": [ + "static" + ], + "utilityKind": "functional", + "utilityRoot": "grid-cols", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "3xl:grid-cols-6", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "grid-cols", + "modifier": null, + "value": { + "kind": "named", + "value": "6", + "fraction": null + }, + "variants": [ + { + "kind": "static", + "root": "3xl" + } + ], + "important": false, + "raw": "3xl:grid-cols-6" + } + ], + "canonical": [ + "3xl:grid-cols-6" + ], + "css": ".\\33 xl\\:grid-cols-6 {\n @media (width >= 120rem) {\n grid-template-columns: repeat(6, minmax(0, 1fr));\n }\n}\n", + "declarations": [ + { + "property": "grid-template-columns", + "value": "repeat(6, minmax(0, 1fr))" + } + ], + "variantKinds": [ + "static" + ], + "utilityKind": "functional", + "utilityRoot": "grid-cols", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "@sm:flex", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "flex", + "variants": [ + { + "kind": "functional", + "root": "@", + "modifier": null, + "value": { + "kind": "named", + "value": "sm" + } + } + ], + "important": false, + "raw": "@sm:flex" + }, + { + "kind": "functional", + "root": "flex", + "modifier": null, + "value": null, + "variants": [ + { + "kind": "functional", + "root": "@", + "modifier": null, + "value": { + "kind": "named", + "value": "sm" + } + } + ], + "important": false, + "raw": "@sm:flex" + } + ], + "canonical": [ + "@sm:flex" + ], + "css": ".\\@sm\\:flex {\n @container (width >= 24rem) {\n display: flex;\n }\n}\n", + "declarations": [ + { + "property": "display", + "value": "flex" + } + ], + "variantKinds": [ + "functional" + ], + "utilityKind": "static", + "utilityRoot": "flex", + "parseCount": 2, + "astNodeCount": 1 + }, + { + "input": "@max-md:hidden", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "hidden", + "variants": [ + { + "kind": "functional", + "root": "@max", + "modifier": null, + "value": { + "kind": "named", + "value": "md" + } + } + ], + "important": false, + "raw": "@max-md:hidden" + } + ], + "canonical": [ + "@max-md:hidden" + ], + "css": ".\\@max-md\\:hidden {\n @container (width < 28rem) {\n display: none;\n }\n}\n", + "declarations": [ + { + "property": "display", + "value": "none" + } + ], + "variantKinds": [ + "functional" + ], + "utilityKind": "static", + "utilityRoot": "hidden", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "group-hover:flex", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "flex", + "variants": [ + { + "kind": "compound", + "root": "group", + "modifier": null, + "variant": { + "kind": "static", + "root": "hover" + } + } + ], + "important": false, + "raw": "group-hover:flex" + }, + { + "kind": "functional", + "root": "flex", + "modifier": null, + "value": null, + "variants": [ + { + "kind": "compound", + "root": "group", + "modifier": null, + "variant": { + "kind": "static", + "root": "hover" + } + } + ], + "important": false, + "raw": "group-hover:flex" + } + ], + "canonical": [ + "group-hover:flex" + ], + "css": ".group-hover\\:flex {\n &:is(:where(.group):hover *) {\n @media (hover: hover) {\n display: flex;\n }\n }\n}\n", + "declarations": [ + { + "property": "display", + "value": "flex" + } + ], + "variantKinds": [ + "compound", + "static" + ], + "utilityKind": "static", + "utilityRoot": "flex", + "parseCount": 2, + "astNodeCount": 1 + }, + { + "input": "peer-focus:text-blue-500", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "text", + "modifier": null, + "value": { + "kind": "named", + "value": "blue-500", + "fraction": null + }, + "variants": [ + { + "kind": "compound", + "root": "peer", + "modifier": null, + "variant": { + "kind": "static", + "root": "focus" + } + } + ], + "important": false, + "raw": "peer-focus:text-blue-500" + } + ], + "canonical": [ + "peer-focus:text-blue-500" + ], + "css": ".peer-focus\\:text-blue-500 {\n &:is(:where(.peer):focus ~ *) {\n color: var(--color-blue-500);\n }\n}\n", + "declarations": [ + { + "property": "color", + "value": "var(--color-blue-500)" + } + ], + "variantKinds": [ + "compound", + "static" + ], + "utilityKind": "functional", + "utilityRoot": "text", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "has-[img]:p-4", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "p", + "modifier": null, + "value": { + "kind": "named", + "value": "4", + "fraction": null + }, + "variants": [ + { + "kind": "compound", + "root": "has", + "modifier": null, + "variant": { + "kind": "arbitrary", + "selector": "&:is(img)", + "relative": false + } + } + ], + "important": false, + "raw": "has-[img]:p-4" + } + ], + "canonical": [ + "has-[img]:p-4" + ], + "css": ".has-\\[img\\]\\:p-4 {\n &:has(*:is(img)) {\n padding: calc(var(--spacing) * 4);\n }\n}\n", + "declarations": [ + { + "property": "padding", + "value": "calc(var(--spacing) * 4)" + } + ], + "variantKinds": [ + "arbitrary", + "compound" + ], + "utilityKind": "functional", + "utilityRoot": "p", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "not-hover:opacity-50", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "opacity", + "modifier": null, + "value": { + "kind": "named", + "value": "50", + "fraction": null + }, + "variants": [ + { + "kind": "compound", + "root": "not", + "modifier": null, + "variant": { + "kind": "static", + "root": "hover" + } + } + ], + "important": false, + "raw": "not-hover:opacity-50" + } + ], + "canonical": [ + "not-hover:opacity-50" + ], + "css": ".not-hover\\:opacity-50 {\n &:not(*:hover) {\n opacity: 50%;\n }\n @media not (hover: hover) {\n opacity: 50%;\n }\n}\n", + "declarations": [ + { + "property": "opacity", + "value": "50%" + }, + { + "property": "opacity", + "value": "50%" + } + ], + "variantKinds": [ + "compound", + "static" + ], + "utilityKind": "functional", + "utilityRoot": "opacity", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "[&_p]:mt-4", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "mt", + "modifier": null, + "value": { + "kind": "named", + "value": "4", + "fraction": null + }, + "variants": [ + { + "kind": "arbitrary", + "selector": "& p", + "relative": false + } + ], + "important": false, + "raw": "[&_p]:mt-4" + } + ], + "canonical": [ + "[&_p]:mt-4" + ], + "css": ".\\[\\&_p\\]\\:mt-4 {\n & p {\n margin-top: calc(var(--spacing) * 4);\n }\n}\n", + "declarations": [ + { + "property": "margin-top", + "value": "calc(var(--spacing) * 4)" + } + ], + "variantKinds": [ + "arbitrary" + ], + "utilityKind": "functional", + "utilityRoot": "mt", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "[color:red]", + "valid": true, + "parsed": [ + { + "kind": "arbitrary", + "property": "color", + "value": "red", + "modifier": null, + "variants": [], + "important": false, + "raw": "[color:red]" + } + ], + "canonical": [ + "text-[red]" + ], + "css": ".\\[color\\:red\\] {\n color: red;\n}\n", + "declarations": [ + { + "property": "color", + "value": "red" + } + ], + "variantKinds": [], + "utilityKind": "arbitrary", + "utilityRoot": "color", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "[color:red]/50!", + "valid": true, + "parsed": [ + { + "kind": "arbitrary", + "property": "color", + "value": "red", + "modifier": { + "kind": "named", + "value": "50" + }, + "variants": [], + "important": true, + "raw": "[color:red]/50!" + } + ], + "canonical": [ + "text-[red]/50!" + ], + "css": ".\\[color\\:red\\]\\/50\\! {\n color: color-mix(in oklab, red 50%, transparent) !important;\n}\n", + "declarations": [ + { + "property": "color", + "value": "color-mix(in oklab, red 50%, transparent)", + "important": true + } + ], + "variantKinds": [], + "utilityKind": "arbitrary", + "utilityRoot": "color", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "theme-midnight:bg-black", + "valid": true, + "parsed": [ + { + "kind": "functional", + "root": "bg", + "modifier": null, + "value": { + "kind": "named", + "value": "black", + "fraction": null + }, + "variants": [ + { + "kind": "static", + "root": "theme-midnight" + } + ], + "important": false, + "raw": "theme-midnight:bg-black" + } + ], + "canonical": [ + "theme-midnight:bg-black" + ], + "css": ".theme-midnight\\:bg-black {\n &:where([data-theme=\"midnight\"] *) {\n background-color: var(--color-black);\n }\n}\n", + "declarations": [ + { + "property": "background-color", + "value": "var(--color-black)" + } + ], + "variantKinds": [ + "static" + ], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "content-auto", + "valid": true, + "parsed": [ + { + "kind": "static", + "root": "content-auto", + "variants": [], + "important": false, + "raw": "content-auto" + }, + { + "kind": "functional", + "root": "content", + "modifier": null, + "value": { + "kind": "named", + "value": "auto", + "fraction": null + }, + "variants": [], + "important": false, + "raw": "content-auto" + } + ], + "canonical": [ + "content-auto" + ], + "css": ".content-auto {\n content-visibility: auto;\n}\n", + "declarations": [ + { + "property": "content-visibility", + "value": "auto" + } + ], + "variantKinds": [], + "utilityKind": "static", + "utilityRoot": "content-auto", + "parseCount": 2, + "astNodeCount": 1 + }, + { + "input": "not-a-tailwind-class", + "valid": false, + "parsed": [], + "canonical": [ + "not-a-tailwind-class" + ], + "css": null, + "declarations": [], + "variantKinds": [], + "notes": [ + "Tailwind parseCandidate returned no candidate objects.", + "Tailwind candidatesToCss returned null." + ], + "parseCount": 0, + "astNodeCount": 0 + }, + { + "input": "bg-red-500/50/50", + "valid": false, + "parsed": [], + "canonical": [ + "bg-red-500/50/50" + ], + "css": null, + "declarations": [], + "variantKinds": [], + "notes": [ + "Tailwind parseCandidate returned no candidate objects.", + "Tailwind candidatesToCss returned null." + ], + "parseCount": 0, + "astNodeCount": 0 + }, + { + "input": "p-[]", + "valid": false, + "parsed": [], + "canonical": [ + "p-[]" + ], + "css": null, + "declarations": [], + "variantKinds": [], + "notes": [ + "Tailwind parseCandidate returned no candidate objects.", + "Tailwind candidatesToCss returned null." + ], + "parseCount": 0, + "astNodeCount": 0 + }, + { + "input": "[broken]", + "valid": false, + "parsed": [], + "canonical": [ + "[broken]" + ], + "css": null, + "declarations": [], + "variantKinds": [], + "notes": [ + "Tailwind parseCandidate returned no candidate objects.", + "Tailwind candidatesToCss returned null." + ], + "parseCount": 0, + "astNodeCount": 0 + } + ] +} diff --git a/packages/mix_tailwinds/test/fixtures/candidates.txt b/packages/mix_tailwinds/test/fixtures/candidates.txt new file mode 100644 index 0000000000..bf4e86007b --- /dev/null +++ b/packages/mix_tailwinds/test/fixtures/candidates.txt @@ -0,0 +1,49 @@ +flex +inline-flex +grid +hidden +pointer-events-none +static +absolute +sr-only +not-sr-only +p-4 +p-card +-px +w-1/2 +w-[37px] +bg-red-500 +bg-red-500/50 +bg-brand-500 +bg-brand-500/50 +bg-[#0088cc] +bg-[color:var(--brand)] +bg-(--brand-color) +text-xl +text-[13px] +rounded-card +shadow-card +hover:bg-brand-500/50 +focus-visible:outline-2 +sm:grid-cols-2 +3xl:grid-cols-6 +@sm:flex +@max-md:hidden +group-hover:flex +peer-focus:text-blue-500 +has-[img]:p-4 +not-hover:opacity-50 +[&_p]:mt-4 +[color:red] +[color:red]/50! +theme-midnight:bg-black +content-auto +not-a-tailwind-class +bg-red-500/50/50 +p-[] +[broken] +!mx-4 +mx-4! +bg-red-500/[50%] +bg-red-500/(--v) +text-[length:12px] diff --git a/packages/mix_tailwinds/test/parser/candidate_parser_test.dart b/packages/mix_tailwinds/test/parser/candidate_parser_test.dart new file mode 100644 index 0000000000..6cc34e1def --- /dev/null +++ b/packages/mix_tailwinds/test/parser/candidate_parser_test.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix_tailwinds/src/parser/candidate_parser.dart'; +import 'package:mix_tailwinds/src/parser/data/parser_registry.g.dart'; +import 'package:mix_tailwinds/src/parser/diagnostics.dart'; +import 'package:mix_tailwinds/src/parser/model.dart'; + +void main() { + final parser = TailwindCandidateParser( + registry: defaultTailwindParserRegistry, + ); + + test( + 'parses all probe fixtures into the expected utility root when valid', + () { + final fixture = + jsonDecode( + File('test/fixtures/candidate-probes.json').readAsStringSync(), + ) + as Map; + final probes = (fixture['probes'] as List).cast>(); + + const syntaxFailures = { + 'bg-red-500/50/50': TailwindParseErrorCode.invalidModifier, + 'p-[]': TailwindParseErrorCode.emptyArbitraryValue, + '[broken]': TailwindParseErrorCode.invalidArbitraryProperty, + }; + const semanticInvalids = {'-px', 'not-a-tailwind-class'}; + + for (final probe in probes) { + final input = probe['input']! as String; + final result = parser.parseCandidate(input); + + if (probe['valid'] != true) { + final expectedCode = syntaxFailures[input]; + if (expectedCode != null) { + expect(result, isA(), reason: input); + expect( + (result as TailwindParseFailure).errors.single.code, + expectedCode, + reason: input, + ); + } else { + expect(semanticInvalids, contains(input), reason: input); + expect(result, isA(), reason: input); + } + continue; + } + + expect(result, isA(), reason: input); + final candidate = (result as TailwindParseSuccess).candidate; + expect(candidate.raw, input); + expect( + candidate.important, + input.endsWith('!') || input.contains(':!'), + ); + expect(_root(candidate.utility), probe['utilityRoot'], reason: input); + } + }, + ); + + test('parses contract examples', () { + final cases = { + 'flex': TailwindStaticUtility, + 'border': TailwindFunctionalUtility, + 'p-4': TailwindFunctionalUtility, + 'w-1/2': TailwindFunctionalUtility, + 'bg-[#0088cc]': TailwindFunctionalUtility, + 'bg-[color:var(--brand)]': TailwindFunctionalUtility, + 'bg-(--brand-color)': TailwindFunctionalUtility, + '[color:red]': TailwindArbitraryProperty, + 'hover:bg-brand-500/50': TailwindFunctionalUtility, + 'group-hover:flex': TailwindStaticUtility, + 'peer-focus:text-blue-500': TailwindFunctionalUtility, + 'not-hover:opacity-50': TailwindFunctionalUtility, + '[&_p]:mt-4': TailwindFunctionalUtility, + }; + + for (final entry in cases.entries) { + final result = parser.parseCandidate(entry.key); + expect(result, isA(), reason: entry.key); + final utility = (result as TailwindParseSuccess).candidate.utility; + expect(utility.runtimeType, entry.value, reason: entry.key); + } + }); + + test('parses values, modifiers, negatives, and important markers', () { + final result = parser.parseCandidate('md:hover:!-mx-4'); + expect(result, isA()); + final candidate = (result as TailwindParseSuccess).candidate; + expect(candidate.important, isTrue); + expect(candidate.variants, hasLength(2)); + final utility = candidate.utility as TailwindFunctionalUtility; + expect(utility.root, 'mx'); + expect(utility.negative, isTrue); + expect((utility.value as TailwindNamedValue).raw, '4'); + + final color = + (parser.parseCandidate('bg-red-500/[50%]') as TailwindParseSuccess) + .candidate + .utility + as TailwindFunctionalUtility; + expect(color.root, 'bg'); + expect((color.value as TailwindNamedValue).raw, 'red-500'); + expect(color.modifier, isA()); + }); + + test('malformed arbitrary values and modifiers fail with diagnostics', () { + final cases = { + 'bg-red-500/50/50': TailwindParseErrorCode.invalidModifier, + 'p-[]': TailwindParseErrorCode.emptyArbitraryValue, + 'bg-[]': TailwindParseErrorCode.emptyArbitraryValue, + }; + + for (final entry in cases.entries) { + final result = parser.parseCandidate(entry.key); + expect(result, isA(), reason: entry.key); + expect( + (result as TailwindParseFailure).errors.single.code, + entry.value, + reason: entry.key, + ); + } + }); + + test('semantic invalid utilities parse as unresolved syntax successes', () { + final result = parser.parseCandidate('not-a-tailwind-class'); + expect(result, isA()); + final utility = (result as TailwindParseSuccess).candidate.utility; + expect(utility, isA()); + }); + + test('malformed delimiter input fails with diagnostics', () { + final result = parser.parseCandidate('bg-[color:red'); + expect(result, isA()); + expect( + (result as TailwindParseFailure).errors.single.code, + TailwindParseErrorCode.unclosedBracket, + ); + }); +} + +String? _root(TailwindUtility utility) { + return switch (utility) { + TailwindStaticUtility(:final root) => root, + TailwindFunctionalUtility(:final root) => root, + TailwindArbitraryProperty(:final property) => property, + TailwindUnresolvedUtility() => null, + }; +} diff --git a/packages/mix_tailwinds/test/parser_purity_test.dart b/packages/mix_tailwinds/test/parser_purity_test.dart new file mode 100644 index 0000000000..4108c48f97 --- /dev/null +++ b/packages/mix_tailwinds/test/parser_purity_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parser directory stays Flutter and Mix free', () { + final files = Directory('lib/src/parser') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + + for (final file in files) { + final source = file.readAsStringSync(); + expect(source, isNot(contains("package:flutter")), reason: file.path); + expect(source, isNot(contains("package:mix/")), reason: file.path); + } + }); +} diff --git a/packages/mix_tailwinds/test/schema_payload_contract_test.dart b/packages/mix_tailwinds/test/schema_payload_contract_test.dart index 8eb75a2f69..fb5468e11a 100644 --- a/packages/mix_tailwinds/test/schema_payload_contract_test.dart +++ b/packages/mix_tailwinds/test/schema_payload_contract_test.dart @@ -3,8 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart'; import 'package:mix_tailwinds/mix_tailwinds.dart'; -import 'package:mix_tailwinds/src/tw_schema_payload_policy.dart' - as payload_policy; void main() { test('box parser emits schema payloads that decode through mix_schema', () { @@ -137,41 +135,6 @@ void main() { expect(text.textAlign, TextAlign.center); }); - test('schema payload policy classifies every Tailwind property', () { - final classified = payload_policy.twSchemaPayloadPolicy.keys - .map((value) => value.toString()) - .toSet(); - final properties = TwProperty.values - .map((value) => value.toString()) - .toSet(); - - expect(classified, properties); - expect( - payload_policy.twSchemaPayloadPolicy.values, - everyElement(isA()), - ); - expect( - payload_policy.twSchemaPayloadPolicy.values.map( - (policy) => policy.reason, - ), - everyElement(isNotEmpty), - ); - }); - - test('direct-only tokens have explicit internal policy decisions', () { - final policy = payload_policy.twSchemaPayloadPolicy; - final directOnly = payload_policy.TwSchemaPayloadDecision.directOnly; - final widgetLayer = payload_policy.TwSchemaPayloadDecision.widgetLayer; - - expect(policy[TwProperty.backgroundGradient]?.decision, directOnly); - expect(policy[TwProperty.borderWidth]?.decision, directOnly); - expect(policy[TwProperty.borderColor]?.decision, directOnly); - expect(policy[TwProperty.scale]?.decision, directOnly); - expect(policy[TwProperty.rotate]?.decision, directOnly); - expect(policy[TwProperty.flexGrow]?.decision, widgetLayer); - expect(policy[TwProperty.transition]?.decision, widgetLayer); - }); - test( 'direct-only and prefixed tokens still parse without schema payloads', () { diff --git a/packages/mix_tailwinds/test/tw_config_test.dart b/packages/mix_tailwinds/test/tw_config_test.dart index 37ccd23a2f..4d96499593 100644 --- a/packages/mix_tailwinds/test/tw_config_test.dart +++ b/packages/mix_tailwinds/test/tw_config_test.dart @@ -187,7 +187,7 @@ void main() { final container = tester.widget(find.byType(Container)); final decoration = container.decoration as BoxDecoration?; - expect(decoration?.color, equals(Colors.purple)); + expect(decoration?.color, isSameColorAs(Colors.purple)); }); testWidgets('Div explicit config overrides provider', (tester) async { @@ -213,7 +213,7 @@ void main() { final container = tester.widget(find.byType(Container)); final decoration = container.decoration as BoxDecoration?; - expect(decoration?.color, equals(Colors.red)); + expect(decoration?.color, isSameColorAs(Colors.red)); }); }); diff --git a/packages/mix_tailwinds/test/tw_parser_characterization_test.dart b/packages/mix_tailwinds/test/tw_parser_characterization_test.dart index d6ca25fbf5..b94fbc957d 100644 --- a/packages/mix_tailwinds/test/tw_parser_characterization_test.dart +++ b/packages/mix_tailwinds/test/tw_parser_characterization_test.dart @@ -22,8 +22,12 @@ import 'package:mix_tailwinds/mix_tailwinds.dart'; // Resolve helpers — turn a parsed styler into a concrete resolved spec. // =========================================================================== -Future _resolveBox(WidgetTester tester, String classNames) async { - final style = TwParser().parseBox(classNames); +Future _resolveBox( + WidgetTester tester, + String classNames, { + TwParser? parser, +}) async { + final style = (parser ?? TwParser()).parseBox(classNames); late BoxSpec spec; await tester.pumpWidget( MaterialApp( @@ -78,9 +82,10 @@ Future _resolveText(WidgetTester tester, String classNames) async { Future _resolveBoxStates( WidgetTester tester, String classNames, - Set states, -) async { - final style = TwParser().parseBox(classNames); + Set states, { + TwParser? parser, +}) async { + final style = (parser ?? TwParser()).parseBox(classNames); final controller = WidgetStatesController(states); addTearDown(controller.dispose); late BoxSpec spec; @@ -140,6 +145,7 @@ Future _divContainer( // Tailwind reference colors used throughout (sRGB hex). const _blue500 = Color(0xFF3B82F6); const _red500 = Color(0xFFEF4444); +const _emerald400 = Color(0xFF34D399); const _gray200 = Color(0xFFE5E7EB); const _white = Color(0xFFFFFFFF); @@ -230,9 +236,42 @@ void main() { expect(_decoOf(spec)?.color, const Color(0xFF112233)); }); - testWidgets('arbitrary 8-digit hex bg', (tester) async { - final spec = await _resolveBox(tester, 'bg-[#80ffffff]'); - expect(_decoOf(spec)?.color, const Color(0x80FFFFFF)); + testWidgets('arbitrary CSS hex bg', (tester) async { + final shortRgb = await _resolveBox(tester, 'bg-[#fff]'); + expect(_decoOf(shortRgb)?.color, const Color(0xFFFFFFFF)); + + final shortRgba = await _resolveBox(tester, 'bg-[#ffff]'); + expect(_decoOf(shortRgba)?.color, const Color(0xFFFFFFFF)); + + final longRgba = await _resolveBox(tester, 'bg-[#ffffff80]'); + expect(_decoOf(longRgba)?.color, const Color(0x80FFFFFF)); + + final cssOrdered = await _resolveBox(tester, 'bg-[#80ffffff]'); + expect(_decoOf(cssOrdered)?.color, const Color(0xFF80FFFF)); + }); + + testWidgets('valid arbitrary opacity modifier applies alpha', ( + tester, + ) async { + final spec = await _resolveBox(tester, 'bg-red-500/[50%]'); + expect(_decoOf(spec)?.color, const Color(0x80EF4444)); + }); + + testWidgets('invalid color opacity modifiers warn and skip token', ( + tester, + ) async { + final seen = []; + final parser = TwParser(onUnsupported: seen.add); + final spec = await _resolveBox( + tester, + 'bg-red-500/[bad] bg-red-500/200 bg-red-500/(--v)', + parser: parser, + ); + + expect(_decoOf(spec)?.color, isNull); + expect(seen, contains('bg-red-500/[bad]')); + expect(seen, contains('bg-red-500/200')); + expect(seen, contains('bg-red-500/(--v)')); }); }); @@ -375,6 +414,22 @@ void main() { expect(style.fontWeight, FontWeight.w700); expect(text, isNotNull); }); + + testWidgets('arbitrary text size propagates via DefaultTextStyle', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Div(classNames: 'text-[13px]', child: const Text('hello')), + ), + ); + + final style = DefaultTextStyle.of( + tester.element(find.text('hello')), + ).style; + expect(style.fontSize, 13); + }); }); // ========================================================================= @@ -484,6 +539,32 @@ void main() { expect(b.left.width, 0); expect(b.right.width, 0); }); + + testWidgets('hover border color inherits base top width', (tester) async { + final inactive = await _resolveBoxStates( + tester, + 'border-t hover:border-red-500', + const {}, + ); + final inactiveBorder = _decoOf(inactive)?.border as Border?; + expect(inactiveBorder, isNotNull); + expect(inactiveBorder!.top.width, 1); + expect(inactiveBorder.top.color, _gray200); + expect(inactiveBorder.bottom.width, 0); + + final hovered = await _resolveBoxStates( + tester, + 'border-t hover:border-red-500', + {WidgetState.hovered}, + ); + final hoveredBorder = _decoOf(hovered)?.border as Border?; + expect(hoveredBorder, isNotNull); + expect(hoveredBorder!.top.width, 1); + expect(hoveredBorder.top.color, _red500); + expect(hoveredBorder.bottom.width, 0); + expect(hoveredBorder.left.width, 0); + expect(hoveredBorder.right.width, 0); + }); }); // ========================================================================= @@ -536,6 +617,74 @@ void main() { expect(g!.colors.first, _blue500); expect(g.colors.last, _red500); }); + + testWidgets('hover gradient stop inherits base direction and to stop', ( + tester, + ) async { + final inactive = await _resolveBoxStates( + tester, + 'bg-gradient-to-r from-blue-500 to-red-500 hover:from-emerald-400', + const {}, + ); + final baseGradient = _decoOf(inactive)?.gradient as LinearGradient?; + expect(baseGradient, isNotNull); + expect(baseGradient!.colors, [_blue500, _red500]); + + final hovered = await _resolveBoxStates( + tester, + 'bg-gradient-to-r from-blue-500 to-red-500 hover:from-emerald-400', + {WidgetState.hovered}, + ); + final hoveredGradient = _decoOf(hovered)?.gradient as LinearGradient?; + expect(hoveredGradient, isNotNull); + expect(hoveredGradient!.begin, Alignment.centerLeft); + expect(hoveredGradient.end, Alignment.centerRight); + expect(hoveredGradient.colors, [_emerald400, _red500]); + expect(hoveredGradient.stops, const [0.0, 1.0]); + }); + + testWidgets('important gradient tokens warn and do not apply', ( + tester, + ) async { + final seen = []; + final parser = TwParser(onUnsupported: seen.add); + final base = await _resolveBox( + tester, + 'bg-white !bg-gradient-to-r !from-red-500', + parser: parser, + ); + + expect(_decoOf(base)?.color, _white); + expect(_decoOf(base)?.gradient, isNull); + expect(seen, contains('!bg-gradient-to-r')); + expect(seen, contains('!from-red-500')); + + final hovered = await _resolveBoxStates( + tester, + 'bg-gradient-to-r from-blue-500 to-red-500 hover:!from-red-500', + {WidgetState.hovered}, + parser: parser, + ); + final gradient = _decoOf(hovered)?.gradient as LinearGradient?; + expect(gradient, isNotNull); + expect(gradient!.colors, [_blue500, _red500]); + expect(seen, contains('hover:!from-red-500')); + }); + + testWidgets('invalid gradient opacity modifier warns and skips stop', ( + tester, + ) async { + final seen = []; + final parser = TwParser(onUnsupported: seen.add); + final spec = await _resolveBox( + tester, + 'bg-gradient-to-r from-red-500/[bad] to-blue-500', + parser: parser, + ); + + expect(_decoOf(spec)?.gradient, isNull); + expect(seen, contains('from-red-500/[bad]')); + }); }); // ========================================================================= @@ -660,6 +809,11 @@ void main() { expect(spec.style?.height, isNotNull); }); + testWidgets('arbitrary text size sets font size', (tester) async { + final spec = await _resolveText(tester, 'text-[13px]'); + expect(spec.style?.fontSize, 13); + }); + testWidgets('tracking-wide letter spacing', (tester) async { final spec = await _resolveText(tester, 'tracking-wide'); expect(spec.style?.letterSpacing, 0.4); @@ -697,17 +851,17 @@ void main() { }); // ========================================================================= - // !important — must still apply the underlying property + // !important — ignored because Flutter/Mix has no CSS cascade priority model. // ========================================================================= group('characterization: important', () { - testWidgets('!bg-blue-500 still sets color', (tester) async { + testWidgets('!bg-blue-500 is ignored', (tester) async { final spec = await _resolveBox(tester, '!bg-blue-500'); - expect(_decoOf(spec)?.color, _blue500); + expect(_decoOf(spec)?.color, isNull); }); - testWidgets('!p-4 still sets padding', (tester) async { + testWidgets('!p-4 is ignored', (tester) async { final spec = await _resolveBox(tester, '!p-4'); - expect((spec.padding! as EdgeInsets).top, 16); + expect(spec.padding, isNull); }); }); @@ -950,7 +1104,7 @@ void main() { expect(_decoOf(notHovered)?.color, _white); }); - testWidgets('dark:!bg-red-500 important variant under dark (widget)', ( + testWidgets('dark:!bg-red-500 is ignored under dark (widget)', ( tester, ) async { await tester.pumpWidget( @@ -967,7 +1121,7 @@ void main() { ); await tester.pump(); final container = tester.widget(find.byType(Container)); - expect((container.decoration as BoxDecoration?)?.color, _red500); + expect((container.decoration as BoxDecoration?)?.color, _white); }); }); diff --git a/packages/mix_tailwinds/test/tw_resolver_test.dart b/packages/mix_tailwinds/test/tw_resolver_test.dart deleted file mode 100644 index e2d51f702b..0000000000 --- a/packages/mix_tailwinds/test/tw_resolver_test.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mix_tailwinds/mix_tailwinds.dart'; - -void main() { - group('TwResolver.resolveToken', () { - test('parses variants and important flag', () { - final parsed = TwResolver( - TwConfig.standard(), - ).resolveToken('md:hover:!bg-blue-500'); - - expect(parsed, isNotNull); - expect(parsed, hasLength(1)); - - final result = parsed!.single; - expect(result.property, TwProperty.backgroundColor); - expect( - result.value, - TwColorValue(TwConfig.standard().colorOf('blue-500')!), - ); - expect(result.important, isTrue); - expect(result.negative, isFalse); - expect(result.arbitrary, isFalse); - expect(result.variants, hasLength(2)); - expect(result.variantKey, 'md:hover'); - expect(result.variants[0], const TwBreakpointVariant('md', 768)); - expect(result.variants[1], const TwInteractionVariant('hover')); - }); - - test('parses negative spacing values', () { - final parsed = TwResolver(TwConfig.standard()).resolveToken('-m-4'); - - expect(parsed, isNotNull); - expect(parsed, hasLength(1)); - - final result = parsed!.single; - expect(result.property, TwProperty.margin); - expect(result.value, const TwLengthValue(-16)); - expect(result.negative, isTrue); - }); - - test('rejects unsupported negative tokens', () { - final parsed = TwResolver( - TwConfig.standard(), - ).resolveToken('-bg-blue-500'); - expect(parsed, isNull); - }); - - test('parses arbitrary rem length and marks arbitrary', () { - final parsed = TwResolver(TwConfig.standard()).resolveToken('w-[10rem]'); - - expect(parsed, isNotNull); - final result = parsed!.single; - expect(result.property, TwProperty.width); - expect(result.value, const TwLengthValue(160, TwUnit.px)); - expect(result.arbitrary, isTrue); - }); - - test('parses arbitrary percent length', () { - final parsed = TwResolver(TwConfig.standard()).resolveToken('w-[50%]'); - - expect(parsed, isNotNull); - final result = parsed!.single; - expect(result.property, TwProperty.width); - expect(result.value, const TwLengthValue(50, TwUnit.percent)); - }); - - test('rejects 3-digit arbitrary hex colors', () { - final parsed = TwResolver(TwConfig.standard()).resolveToken('bg-[#fff]'); - expect(parsed, isNull); - }); - - test('parses 8-digit arbitrary hex colors', () { - final parsed = TwResolver( - TwConfig.standard(), - ).resolveToken('bg-[#80ffffff]'); - - expect(parsed, isNotNull); - final result = parsed!.single; - expect(result.property, TwProperty.backgroundColor); - expect(result.value, const TwColorValue(Color(0x80FFFFFF))); - }); - - test('reports unknown variants while parsing valid base token', () { - final unknown = []; - final parsed = TwResolver( - TwConfig.standard(), - onUnknownVariant: unknown.add, - ).resolveToken('foo:bg-blue-500'); - - expect(parsed, isNotNull); - expect(parsed, hasLength(1)); - expect(unknown, contains('foo')); - expect(parsed!.single.property, TwProperty.backgroundColor); - expect(parsed.single.variants, isEmpty); - }); - }); - - group('TwParsedClass', () { - test('builds stable variant keys', () { - const parsed = TwParsedClass( - property: TwProperty.marginTop, - value: TwLengthValue(8), - variants: [ - TwBreakpointVariant('lg', 1024), - TwInteractionVariant('hover'), - ], - ); - - expect(parsed.variantKey, 'lg:hover'); - }); - }); - - group('Semantic value types', () { - test('TwFractionValue computes decimal value', () { - const value = TwFractionValue(2, 3); - expect(value.value, closeTo(0.666666, 0.000001)); - expect(value, const TwFractionValue(2, 3)); - }); - - test('TwMatrixValue stores matrix', () { - final matrix = Matrix4.identity()..setTranslationRaw(4.0, 8.0, 0.0); - final value = TwMatrixValue(matrix); - - expect(value.matrix, same(matrix)); - expect(value.toString(), contains('TwMatrixValue')); - }); - - test('TwGradientValue stores direction and colors', () { - const value = TwGradientValue( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Colors.red, Colors.blue], - ); - - expect(value.begin, Alignment.topLeft); - expect(value.end, Alignment.bottomRight); - expect(value.colors, const [Colors.red, Colors.blue]); - }); - }); -} diff --git a/packages/mix_tailwinds/test/variants_test.dart b/packages/mix_tailwinds/test/variants_test.dart new file mode 100644 index 0000000000..e5cd93d2bd --- /dev/null +++ b/packages/mix_tailwinds/test/variants_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_tailwinds/mix_tailwinds.dart'; + +Future _resolveBoxStates( + WidgetTester tester, + String classNames, + Set states, +) async { + final controller = WidgetStatesController(states); + addTearDown(controller.dispose); + late BoxSpec spec; + await tester.pumpWidget( + MaterialApp( + home: StyleBuilder( + style: TwParser().parseBox(classNames), + controller: controller, + builder: (_, resolved) { + spec = resolved; + return const SizedBox(); + }, + ), + ), + ); + await tester.pump(); + return spec; +} + +void main() { + testWidgets('hover:bg-red-600 composes onHovered', (tester) async { + final base = await _resolveBoxStates(tester, 'hover:bg-red-600', {}); + final hovered = await _resolveBoxStates(tester, 'hover:bg-red-600', { + WidgetState.hovered, + }); + + expect((base.decoration as BoxDecoration?)?.color, isNull); + expect( + (hovered.decoration as BoxDecoration?)?.color, + const Color(0xFFDC2626), + ); + }); + + testWidgets('dark:text-white composes onDark for text', (tester) async { + late TextSpec spec; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: StyleBuilder( + style: TwParser().parseText('dark:text-white'), + builder: (_, resolved) { + spec = resolved; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(spec.style?.color, Colors.white); + }); + + testWidgets('sm:bg-red-600 composes onBreakpoint', (tester) async { + late BoxSpec spec; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: StyleBuilder( + style: TwParser().parseBox('sm:bg-red-600'), + builder: (_, resolved) { + spec = resolved; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect((spec.decoration as BoxDecoration?)?.color, const Color(0xFFDC2626)); + }); + + test('group-hover is ignored without crashing', () { + final seen = []; + final style = TwParser( + onUnsupported: seen.add, + ).parseBox('group-hover:bg-red-600'); + expect(style, isA()); + expect(seen, isEmpty); + }); +} diff --git a/packages/mix_tailwinds/tool/gen_registry.dart b/packages/mix_tailwinds/tool/gen_registry.dart new file mode 100644 index 0000000000..e8839dee02 --- /dev/null +++ b/packages/mix_tailwinds/tool/gen_registry.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + final outDir = args.isEmpty + ? Directory('../../.context/tailwinds-spec/out') + : Directory(args.single); + if (!outDir.existsSync()) { + stderr.writeln('Tailwind spec out directory not found: ${outDir.path}'); + exitCode = 64; + return; + } + + final classList = (_readJson(outDir, 'class-list.json') as Map) + .cast(); + final variants = (_readJson(outDir, 'variants.json') as Map) + .cast(); + final probes = (_readJson(outDir, 'candidate-probes.json') as Map) + .cast(); + final staticScan = _readJson(outDir, 'static-utilities.scan.json'); + final functionalScan = _readJson(outDir, 'functional-utilities.scan.json'); + + final staticUtilityRoots = { + ..._literalRegistrationNames(staticScan), + ..._probeRoots(probes, kind: 'static'), + ..._supportedStaticFallbackRoots(), + }; + final functionalUtilityRoots = { + ..._literalRegistrationNames(functionalScan).map(_stripNegativeRoot), + ..._probeRoots(probes, kind: 'functional'), + ..._supportedFallbackRoots(classList), + }..removeWhere((root) => root.isEmpty); + + final staticVariantRoots = {}; + final functionalVariantRoots = {}; + final compoundVariantRoots = {}; + for (final variant in (variants['variants'] as List).cast()) { + final map = (variant as Map).cast(); + final name = map['name']! as String; + final values = (map['values'] as List?) ?? const []; + final isArbitrary = map['isArbitrary'] == true; + if (name == 'group' || name == 'peer' || name == 'not') { + compoundVariantRoots.add(name); + } else if (values.isNotEmpty || isArbitrary || name.startsWith('@')) { + functionalVariantRoots.add(name); + } else { + staticVariantRoots.add(name); + } + } + staticVariantRoots.add('light'); + + final meta = + ((probes['meta'] ?? classList['meta'] ?? variants['meta']) as Map) + .cast(); + final generated = DateTime.now().toUtc().toIso8601String(); + + final output = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.') + ..writeln('// Generated by tool/gen_registry.dart from ${outDir.path}.') + ..writeln('// Generated at $generated.') + ..writeln('library;') + ..writeln() + ..writeln("import '../parser_registry.dart';") + ..writeln() + ..writeln( + 'const generatedTailwindRegistryMeta = ${_dartMap({...meta, 'generatedAt': generated})};', + ) + ..writeln() + ..writeln( + 'const generatedStaticUtilityRoots = {${_dartStringSet(staticUtilityRoots)}};', + ) + ..writeln() + ..writeln( + 'const generatedFunctionalUtilityRoots = {${_dartStringSet(functionalUtilityRoots)}};', + ) + ..writeln() + ..writeln( + 'const generatedStaticVariantRoots = {${_dartStringSet(staticVariantRoots)}};', + ) + ..writeln() + ..writeln( + 'const generatedFunctionalVariantRoots = {${_dartStringSet(functionalVariantRoots)}};', + ) + ..writeln() + ..writeln( + 'const generatedCompoundVariantRoots = {${_dartStringSet(compoundVariantRoots)}};', + ) + ..writeln() + ..writeln('final defaultTailwindParserRegistry = TailwindParserRegistry(') + ..writeln(' staticUtilityRoots: generatedStaticUtilityRoots,') + ..writeln(' functionalUtilityRoots: generatedFunctionalUtilityRoots,') + ..writeln(' staticVariantRoots: generatedStaticVariantRoots,') + ..writeln(' functionalVariantRoots: generatedFunctionalVariantRoots,') + ..writeln(' compoundVariantRoots: generatedCompoundVariantRoots,') + ..writeln(' meta: generatedTailwindRegistryMeta,') + ..writeln(');'); + + final target = File('lib/src/parser/data/parser_registry.g.dart') + ..parent.createSync(recursive: true); + target.writeAsStringSync(output.toString()); +} + +Object? _readJson(Directory dir, String name) { + final file = File('${dir.path}/$name'); + return jsonDecode(file.readAsStringSync()); +} + +Iterable _literalRegistrationNames(Object? json) sync* { + for (final raw in (json as List?) ?? const []) { + final map = (raw as Map).cast(); + final name = + (((map['metaVariables'] as Map?)?['single'] as Map?)?['NAME'] + as Map?)?['text']; + if (name is! String || name.length < 2) continue; + final quote = name[0]; + if ((quote == "'" || quote == '"' || quote == '`') && + name.endsWith(quote) && + !name.contains(r'$')) { + yield name.substring(1, name.length - 1); + } + } +} + +Iterable _probeRoots( + Map probes, { + required String kind, +}) sync* { + for (final raw in (probes['probes'] as List).cast()) { + final map = (raw as Map).cast(); + if (map['valid'] != true || map['utilityKind'] != kind) continue; + final root = map['utilityRoot']; + if (root is String) yield _stripNegativeRoot(root); + } +} + +Iterable _supportedFallbackRoots(Map classList) { + final classNames = (classList['classList'] as List) + .cast() + .map((row) => _stripNegativeRoot(row.first! as String)) + .toSet(); + + const supported = { + 'p', + 'px', + 'py', + 'pt', + 'pr', + 'pb', + 'pl', + 'm', + 'mx', + 'my', + 'mt', + 'mr', + 'mb', + 'ml', + 'w', + 'h', + 'min-w', + 'min-h', + 'max-w', + 'max-h', + 'rounded', + 'rounded-t', + 'rounded-b', + 'rounded-l', + 'rounded-r', + 'rounded-tl', + 'rounded-tr', + 'rounded-bl', + 'rounded-br', + 'border', + 'border-t', + 'border-r', + 'border-b', + 'border-l', + 'border-x', + 'border-y', + 'bg-gradient', + 'from', + 'via', + 'to', + 'translate-x', + 'translate-y', + }; + + const keepEvenWhenMissingFromClassList = {'bg-gradient', 'from', 'via', 'to'}; + + return supported.where((root) { + if (keepEvenWhenMissingFromClassList.contains(root)) return true; + final prefix = '$root-'; + return classNames.any((name) => name.startsWith(prefix)); + }); +} + +Iterable _supportedStaticFallbackRoots() { + return const {'overflow-hidden', 'overflow-visible', 'overflow-clip'}; +} + +String _stripNegativeRoot(String value) => + value.startsWith('-') ? value.substring(1) : value; + +String _dartStringSet(Set values) { + final sorted = values.toList()..sort(); + if (sorted.isEmpty) return ''; + return '\n ${sorted.map((value) => "'${_escape(value)}'").join(',\n ')},\n'; +} + +String _dartMap(Map values) { + final entries = values.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + return '{\n ${entries.map((entry) => "'${_escape(entry.key)}': ${_dartValue(entry.value)}").join(',\n ')},\n}'; +} + +String _dartValue(Object? value) { + return switch (value) { + null => 'null', + String() => "'${_escape(value)}'", + num() || bool() => '$value', + _ => "'${_escape('$value')}'", + }; +} + +String _escape(String value) => + value.replaceAll(r'\', r'\\').replaceAll("'", r"\'"); From a0241fbd3a22be3a4b549b07369f87410b665d01 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 17 Jun 2026 13:05:23 -0400 Subject: [PATCH 10/11] feat(mix_tailwinds): add widget-layer routing and unify candidate parsing - Route widget-layer utilities separately from schema values - Parse tokens through TailwindCandidateParser in widget layer - Expand variant/breakpoint routing with breakpoint-aware resolution - Add tests for div/span widget layer and candidate parser coverage - Update FLUTTER_ADAPTATIONS for hex colors and percent handling --- packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md | 35 +- .../lib/src/parser/candidate_parser.dart | 91 ++++- .../src/parser/data/parser_registry.g.dart | 2 +- .../lib/src/translate/tw_routing.dart | 330 ++++++++++++++++-- .../lib/src/translate/tw_target.dart | 81 +++-- .../lib/src/translate/tw_translator.dart | 154 ++++---- packages/mix_tailwinds/lib/src/tw_parser.dart | 13 +- packages/mix_tailwinds/lib/src/tw_widget.dart | 129 ++++--- .../mix_tailwinds/test/div_and_span_test.dart | 83 +++++ .../test/fixtures/candidate-probes.json | 75 ++++ .../test/parser/candidate_parser_test.dart | 111 +++++- .../test/schema_payload_contract_test.dart | 20 +- .../test/tw_parser_characterization_test.dart | 54 +++ packages/mix_tailwinds/tool/gen_registry.dart | 3 +- 14 files changed, 925 insertions(+), 256 deletions(-) diff --git a/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md b/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md index 0db49ca306..1dabda3a72 100644 --- a/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md +++ b/packages/mix_tailwinds/FLUTTER_ADAPTATIONS.md @@ -244,7 +244,7 @@ Responsive layout utilities such as `w-full`, `w-screen`, fractions, external ma - Percent sizing is relative to parent container **Current Limitation:** -- Arbitrary percent values like `w-[50%]` are parsed (as `TwUnit.percent`) but **not applied** by style appliers +- Arbitrary percent values like `w-[50%]` are syntactically parsed but **not applied** by translator or widget sizing - Only pixel values (`w-[100px]`) work in arbitrary syntax **Workaround:** @@ -271,7 +271,7 @@ FractionallySizedBox( **Current Limitation:** - `translate-x-1/2` and similar fractions are **not supported** -- `translate-x-[50%]` is **treated as 50 pixels**, not 50% +- `translate-x-[50%]` is **unsupported** and reported through `onUnsupported` **Workaround:** ```dart @@ -321,16 +321,19 @@ Div(classNames: 'basis-48', ...) // 192px basis - Supports hex, rgb(), rgba(), hsl(), hsla() **Current Limitation:** -- Only **6-digit hex colors** are supported in arbitrary syntax +- **3/4/6/8-digit CSS hex colors** are supported in arbitrary syntax - `bg-[#ff0000]` ✓ works +- `bg-[#f00]` ✓ works +- `bg-[#ffff]` ✓ works +- `bg-[#ffffff80]` ✓ works - `bg-[rgb(255,0,0)]` ✗ not supported -- Short hex like `bg-[#f00]` ✗ **silently produces a wrong color** (parsed as a raw int, not expanded to 6 digits) +- `rgb()` and `hsl()` arbitrary color functions remain unsupported **Workaround:** ```dart -// Always use full 6-digit hex (short hex silently produces wrong colors) +// Use CSS hex arbitrary values Div(classNames: 'bg-[#ff0000]', ...) // ✓ Works -Div(classNames: 'bg-[#f00]', ...) // ✗ Wrong color! +Div(classNames: 'bg-[#f00]', ...) // ✓ Works // Or add custom colors to TwConfig final config = TwConfig.standard().copyWith( @@ -343,27 +346,28 @@ final config = TwConfig.standard().copyWith( --- -### Variant Margin Behavior +### Text Block Margin Variants **Tailwind CSS:** ```html -
...
+

...

``` - Margin changes on hover **Current Limitation:** -- Margin is resolved once at build time -- `hover:m-4`, `dark:m-2` and similar variant margins **do not update** on state change +- `P` and heading margin extraction is base-only +- Only unprefixed positive margins like `mb-4` are applied externally +- `hover:m-4`, `dark:m-2`, `group-hover:m-4`, `@md:m-4`, and selector variants like `[&_p]:mt-4` are skipped instead of becoming unconditional margins **Workaround:** ```dart // Use padding instead (which does respond to variants) -Div(classNames: 'p-2 hover:p-4', ...) // ✓ Works +P(text: '...', classNames: 'p-2 hover:p-4') // ✓ Works // Or handle margin changes manually with StatefulWidget ``` -**Why:** CSS semantic margin is applied outside the `StyleBuilder` to ensure correct hit-testing behavior (margin should not be part of the interactive area). This means it doesn't receive variant state updates. +**Why:** Text block CSS semantic margin is applied outside the `StyleBuilder` so margin stays outside the text hit-test/styling area. Until responsive/interactive margin semantics exist there, variant margins are ignored. --- @@ -373,8 +377,7 @@ Div(classNames: 'p-2 hover:p-4', ...) // ✓ Works |-----------------|--------|------------| | `w-[50%]`, `h-[25%]` | ✗ Parsed but not applied | Use `w-1/2`, `h-1/4` fractions | | `translate-x-1/2` | ✗ Not supported | Use pixel values | -| `translate-x-[50%]` | ⚠️ Treated as pixels | Use Flutter Transform | +| `translate-x-[50%]` | ✗ Unsupported | Use Flutter Transform | | `basis-1/2`, `basis-full` | ✗ Not supported | Use `w-1/2 flex-none` | -| `bg-[rgb(...)]` | ✗ Not supported | Use hex: `bg-[#rrggbb]` | -| `bg-[#f00]` (short hex) | ⚠️ Silently wrong color | Use full hex: `bg-[#ff0000]` | -| `hover:m-4` | ✗ Not reactive | Use padding instead | +| `bg-[rgb(...)]`, `bg-[hsl(...)]` | ✗ Not supported | Use CSS hex: `bg-[#rgb]`, `bg-[#rrggbb]`, or `bg-[#rrggbbaa]` | +| `hover:m-4` on `P`/headings | ✗ Ignored | Use padding instead | diff --git a/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart b/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart index 8213764cb6..acdc779e16 100644 --- a/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart +++ b/packages/mix_tailwinds/lib/src/parser/candidate_parser.dart @@ -53,6 +53,7 @@ final class TailwindCandidateParser { } var utilityRaw = parts.last; + var utilityStart = trimmed.length - utilityRaw.length; var important = false; if (utilityRaw.endsWith('!')) { important = true; @@ -61,15 +62,18 @@ final class TailwindCandidateParser { if (options.allowLegacyImportantPrefix && utilityRaw.startsWith('!')) { important = true; utilityRaw = utilityRaw.substring(1); + utilityStart++; } - if (utilityRaw.contains('!')) { + final invalidImportant = _indexOutsideDelimiters(utilityRaw, '!'); + if (invalidImportant != -1) { + final spanStart = utilityStart + invalidImportant; return TailwindParseFailure( input: input, errors: [ TailwindParseError( code: TailwindParseErrorCode.invalidImportantPosition, message: 'Important marker is only allowed at the start or end.', - span: SourceSpan(trimmed.indexOf('!'), trimmed.indexOf('!') + 1), + span: SourceSpan(spanStart, spanStart + 1), ), ], ); @@ -141,24 +145,26 @@ final class TailwindCandidateParser { body = body.substring(1); } - final (base, modifier) = _splitModifier(body); - if (base == null) { - return TailwindUnresolvedUtility( - raw: raw, - segments: const [], - negative: negative, - ); - } - - if (registry.isStaticUtility(base)) { - return TailwindStaticUtility(raw: raw, root: base); + if (registry.isStaticUtility(body)) { + return TailwindStaticUtility(raw: raw, root: body); } - final root = _findFunctionalRoot(base, registry.functionalUtilityRoots); + final root = _findFunctionalRoot(body, registry.functionalUtilityRoots); if (root != null) { - final valueRaw = base.length == root.length + final valueAndModifierRaw = body.length == root.length ? '' - : base.substring(root.length + 1); + : body.substring(root.length + 1); + final (valueRaw, modifier) = _splitUtilityValueModifier( + root, + valueAndModifierRaw, + ); + if (valueRaw == null) { + return TailwindUnresolvedUtility( + raw: raw, + segments: const [], + negative: negative, + ); + } return TailwindFunctionalUtility( raw: raw, root: root, @@ -168,6 +174,15 @@ final class TailwindCandidateParser { ); } + final (base, modifier) = _splitModifier(body); + if (base == null) { + return TailwindUnresolvedUtility( + raw: raw, + segments: const [], + negative: negative, + ); + } + return TailwindUnresolvedUtility( raw: raw, segments: _segments(base), @@ -292,6 +307,43 @@ final class TailwindCandidateParser { return TailwindNamedModifier(raw); } + (String?, TailwindModifier?) _splitUtilityValueModifier( + String root, + String raw, + ) { + final slash = _indexOutsideDelimiters(raw, '/'); + if (slash == -1) return (raw, null); + + if (_isFractionValueRoot(root) && _isFractionValue(raw)) { + return (raw, null); + } + + if (slash == 0 || slash == raw.length - 1) return (null, null); + + final base = raw.substring(0, slash); + final modifierRaw = raw.substring(slash + 1); + if (_indexOutsideDelimiters(modifierRaw, '/') != -1) return (null, null); + + return (base, _parseModifier(modifierRaw)); + } + + bool _isFractionValueRoot(String root) { + return const { + 'basis', + 'flex', + 'h', + 'max-h', + 'max-w', + 'min-h', + 'min-w', + 'w', + }.contains(root); + } + + bool _isFractionValue(String raw) { + return RegExp(r'^\d+(?:\.\d+)?/\d+(?:\.\d+)?$').hasMatch(raw); + } + String? _findFunctionalRoot(String body, Set roots) { if (roots.contains(body)) return body; @@ -316,10 +368,15 @@ final class TailwindCandidateParser { String? _findVariantFunctionalRoot(String body) { final roots = registry.functionalVariantRoots; + final longest = _findFunctionalRoot( + body, + roots.where((root) => root != '@').toSet(), + ); + if (longest != null) return longest; if (roots.contains('@') && body.startsWith('@') && body.length > 1) { return '@'; } - return _findFunctionalRoot(body, roots); + return null; } } diff --git a/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart b/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart index 18229f643f..82e776d42a 100644 --- a/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart +++ b/packages/mix_tailwinds/lib/src/parser/data/parser_registry.g.dart @@ -660,7 +660,6 @@ const generatedStaticVariantRoots = { 'last', 'last-of-type', 'lg', - 'light', 'ltr', 'marker', 'md', @@ -720,5 +719,6 @@ final defaultTailwindParserRegistry = TailwindParserRegistry( staticVariantRoots: generatedStaticVariantRoots, functionalVariantRoots: generatedFunctionalVariantRoots, compoundVariantRoots: generatedCompoundVariantRoots, + customVariantRoots: const {'light'}, meta: generatedTailwindRegistryMeta, ); diff --git a/packages/mix_tailwinds/lib/src/translate/tw_routing.dart b/packages/mix_tailwinds/lib/src/translate/tw_routing.dart index 2f72e64b03..950cfacd46 100644 --- a/packages/mix_tailwinds/lib/src/translate/tw_routing.dart +++ b/packages/mix_tailwinds/lib/src/translate/tw_routing.dart @@ -3,7 +3,7 @@ library; import '../parser/model.dart'; -enum TwRouteKind { schemaValue, gradient, ignored, unsupported } +enum TwRouteKind { schemaValue, gradient, widgetLayer, ignored, unsupported } final class TwRoute { const TwRoute(this.kind, {this.reason}); @@ -12,23 +12,30 @@ final class TwRoute { final String? reason; } -TwRoute routeCandidate(TailwindCandidate candidate) { - if (_hasIgnoredVariant(candidate.variants)) { - return const TwRoute(TwRouteKind.ignored, reason: 'unsupported variant'); +TwRoute routeCandidate( + TailwindCandidate candidate, { + required Map breakpoints, +}) { + if (candidate.important) { + return const TwRoute(TwRouteKind.ignored, reason: 'important modifier'); } - if (_hasUnsupportedVariant(candidate.variants)) { - return const TwRoute(TwRouteKind.unsupported, reason: 'unknown variant'); + + final variantRoute = _routeVariants( + candidate.variants, + breakpoints: breakpoints, + ); + if (variantRoute != null) { + return variantRoute; } final utility = candidate.utility; - final raw = utility.raw; if (utility is TailwindArbitraryProperty) { return const TwRoute(TwRouteKind.ignored, reason: 'arbitrary property'); } - if (candidate.important) { - return const TwRoute(TwRouteKind.ignored, reason: 'important modifier'); + if (isGradientUtility(utility)) return const TwRoute(TwRouteKind.gradient); + if (isWidgetLayerUtility(utility)) { + return const TwRoute(TwRouteKind.widgetLayer); } - if (_isGradientToken(raw)) return const TwRoute(TwRouteKind.gradient); if (utility is TailwindUnresolvedUtility) { return const TwRoute(TwRouteKind.unsupported); } @@ -36,38 +43,303 @@ TwRoute routeCandidate(TailwindCandidate candidate) { return const TwRoute(TwRouteKind.schemaValue); } -bool _hasIgnoredVariant(List variants) { +TwRoute? _routeVariants( + List variants, { + required Map breakpoints, +}) { for (final variant in variants) { - if (variant is TailwindArbitraryVariant) return true; - if (variant is TailwindFunctionalVariant && variant.root.startsWith('@')) { - return true; + final route = _routeVariant(variant, breakpoints: breakpoints); + if (route != null) return route; + } + + return null; +} + +TwRoute? _routeVariant( + TailwindVariant variant, { + required Map breakpoints, +}) { + if (variant is TailwindArbitraryVariant) { + return const TwRoute(TwRouteKind.ignored, reason: 'arbitrary variant'); + } + + if (variant is TailwindFunctionalVariant) { + if (variant.root.startsWith('@')) { + return const TwRoute(TwRouteKind.ignored, reason: 'container variant'); + } + return TwRoute( + TwRouteKind.unsupported, + reason: 'unsupported variant ${variant.raw}', + ); + } + + if (variant is TailwindCompoundVariant) { + if (variant.root == 'group' || variant.root == 'peer') { + return const TwRoute(TwRouteKind.ignored, reason: 'context variant'); } - if (variant is TailwindCompoundVariant) { - if (variant.root == 'group' || variant.root == 'peer') return true; - if (_hasIgnoredVariant([variant.variant])) return true; + if (runtimeVariantFor(variant, breakpoints: breakpoints) != null) { + return null; } + return TwRoute( + TwRouteKind.unsupported, + reason: 'unsupported variant ${variant.raw}', + ); } - return false; + if (variant is TailwindStaticVariant) { + if (runtimeVariantFor(variant, breakpoints: breakpoints) != null) { + return null; + } + return TwRoute( + TwRouteKind.unsupported, + reason: 'unsupported variant ${variant.raw}', + ); + } + + if (variant is TailwindUnresolvedVariant) { + return TwRoute( + TwRouteKind.unsupported, + reason: 'unknown variant ${variant.raw}', + ); + } + + return null; } -bool _hasUnsupportedVariant(List variants) { - for (final variant in variants) { - if (variant is TailwindUnresolvedVariant) return true; - if (variant is TailwindCompoundVariant && - _hasUnsupportedVariant([variant.variant])) { - return true; +enum TwRuntimeVariantKind { + hover, + focus, + pressed, + disabled, + enabled, + dark, + light, + breakpoint, + notHover, +} + +final class TwRuntimeVariant { + const TwRuntimeVariant(this.kind, this.key, {this.breakpoint}); + + const TwRuntimeVariant.breakpoint(String key, double breakpoint) + : this(TwRuntimeVariantKind.breakpoint, key, breakpoint: breakpoint); + + final TwRuntimeVariantKind kind; + final String key; + final double? breakpoint; +} + +TwRuntimeVariant? runtimeVariantFor( + TailwindVariant variant, { + required Map breakpoints, +}) { + if (variant is TailwindStaticVariant) { + final breakpoint = breakpoints[variant.root]; + if (breakpoint != null) { + return TwRuntimeVariant.breakpoint(variant.root, breakpoint); + } + + return switch (variant.root) { + 'hover' => const TwRuntimeVariant(TwRuntimeVariantKind.hover, 'hover'), + 'focus' || 'focus-visible' => const TwRuntimeVariant( + TwRuntimeVariantKind.focus, + 'focus', + ), + 'active' || 'pressed' => const TwRuntimeVariant( + TwRuntimeVariantKind.pressed, + 'pressed', + ), + 'disabled' => const TwRuntimeVariant( + TwRuntimeVariantKind.disabled, + 'disabled', + ), + 'enabled' => const TwRuntimeVariant( + TwRuntimeVariantKind.enabled, + 'enabled', + ), + 'dark' || 'theme-midnight' => const TwRuntimeVariant( + TwRuntimeVariantKind.dark, + 'dark', + ), + 'light' => const TwRuntimeVariant(TwRuntimeVariantKind.light, 'light'), + _ => null, + }; + } + + if (variant is TailwindCompoundVariant && variant.root == 'not') { + final child = variant.variant; + if (child is TailwindStaticVariant && child.root == 'hover') { + return const TwRuntimeVariant(TwRuntimeVariantKind.notHover, 'not-hover'); } } - return false; + return null; } -bool _isGradientToken(String raw) { +bool isGradientUtility(TailwindUtility utility) { + final raw = utility.raw; final base = raw.startsWith('-') ? raw.substring(1) : raw; + final root = tailwindUtilityRoot(utility); return base.startsWith('bg-gradient-') || base.startsWith('bg-linear-') || - base.startsWith('from-') || - base.startsWith('via-') || - base.startsWith('to-'); + root == 'bg-linear' || + root == 'from' || + root == 'via' || + root == 'to'; } + +bool isWidgetLayerUtility(TailwindUtility utility) { + final raw = utility.raw; + final root = tailwindUtilityRoot(utility); + final valueKey = tailwindValueKey(tailwindUtilityValue(utility)); + + if (_transitionTriggerTokens.contains(raw) || + raw == 'transition-none' || + _easeTokens.contains(raw) || + root == 'duration' || + root == 'delay') { + return true; + } + + if (_flexItemTokens.contains(raw) || + raw.startsWith('self-') || + root == 'basis' || + root == 'self' || + root == 'grow' || + root == 'shrink') { + return true; + } + + if (root == 'gap-x' || root == 'gap-y') return true; + + if (_sizingRoots.contains(root)) { + return valueKey == 'full' || + valueKey == 'screen' || + valueKey == 'auto' || + valueKey?.contains('/') == true; + } + + return raw == 'block'; +} + +bool isBoxStylingCandidate(TailwindCandidate candidate) { + final utility = candidate.utility; + if (isGradientUtility(utility)) return true; + + final raw = utility.raw; + final root = tailwindUtilityRoot(utility); + return raw == 'overflow-hidden' || + raw == 'overflow-clip' || + raw == 'overflow-visible' || + _boxStylingRoots.contains(root) || + root.startsWith('border') || + root.startsWith('rounded'); +} + +bool isFlexContainerCandidate(TailwindCandidate candidate) { + final raw = candidate.utility.raw; + final root = tailwindUtilityRoot(candidate.utility); + return raw == 'flex' || + raw == 'flex-row' || + raw == 'flex-col' || + raw.startsWith('items-') || + raw.startsWith('justify-') || + root == 'gap' || + root == 'gap-x' || + root == 'gap-y'; +} + +String tailwindUtilityRoot(TailwindUtility utility) { + return switch (utility) { + TailwindStaticUtility(:final root) => root, + TailwindFunctionalUtility(:final root) => root, + TailwindUnresolvedUtility(:final segments) => + segments.isEmpty ? utility.raw : segments.first, + TailwindArbitraryProperty(:final property) => property, + }; +} + +TailwindValue? tailwindUtilityValue(TailwindUtility utility) { + return switch (utility) { + TailwindFunctionalUtility(:final value) => value, + _ => null, + }; +} + +TailwindModifier? tailwindUtilityModifier(TailwindUtility utility) { + return switch (utility) { + TailwindFunctionalUtility(:final modifier) => modifier, + TailwindUnresolvedUtility(:final modifier) => modifier, + TailwindArbitraryProperty(:final modifier) => modifier, + _ => null, + }; +} + +bool tailwindUtilityNegative(TailwindUtility utility) { + return switch (utility) { + TailwindFunctionalUtility(:final negative) => negative, + TailwindUnresolvedUtility(:final negative) => negative, + _ => false, + }; +} + +String? tailwindValueKey(TailwindValue? value) { + return value is TailwindNamedValue ? value.raw : null; +} + +const _transitionTriggerTokens = { + 'transition', + 'transition-all', + 'transition-colors', + 'transition-opacity', + 'transition-shadow', + 'transition-transform', +}; + +const _easeTokens = {'ease-linear', 'ease-in', 'ease-out', 'ease-in-out'}; + +const _flexItemTokens = { + 'flex-1', + 'flex-auto', + 'flex-initial', + 'flex-none', + 'flex-shrink', + 'flex-shrink-0', + 'shrink', + 'shrink-0', + 'grow', + 'grow-0', +}; + +const _sizingRoots = {'w', 'h', 'min-w', 'min-h', 'max-w', 'max-h'}; + +const _boxStylingRoots = { + 'p', + 'px', + 'py', + 'pt', + 'pr', + 'pb', + 'pl', + 'm', + 'mx', + 'my', + 'mt', + 'mr', + 'mb', + 'ml', + 'w', + 'h', + 'min-w', + 'min-h', + 'max-w', + 'max-h', + 'bg', + 'opacity', + 'blur', + 'shadow', + 'scale', + 'rotate', + 'translate-x', + 'translate-y', +}; diff --git a/packages/mix_tailwinds/lib/src/translate/tw_target.dart b/packages/mix_tailwinds/lib/src/translate/tw_target.dart index 5032ac0285..6a296337d8 100644 --- a/packages/mix_tailwinds/lib/src/translate/tw_target.dart +++ b/packages/mix_tailwinds/lib/src/translate/tw_target.dart @@ -1,57 +1,64 @@ /// Target inference helpers shared by parser facade and widgets. library; -import '../tw_utils.dart'; +import '../parser/candidate_parser.dart'; +import '../parser/data/parser_registry.g.dart'; +import '../parser/diagnostics.dart'; +import '../parser/model.dart'; +import 'tw_routing.dart'; enum TwTarget { box, flexBox, text } +final _parser = TailwindCandidateParser( + registry: defaultTailwindParserRegistry, +); final _whitespaceRegex = RegExp(r'\s+'); -const _boxUtilityPrefixes = [ - 'p-', - 'px-', - 'py-', - 'pt-', - 'pr-', - 'pb-', - 'pl-', - 'bg-', - 'border', - 'rounded', - 'shadow', - 'opacity-', - 'blur', -]; - -bool hasBoxUtilities(String classNames) { - final tokens = classNames.trim().isEmpty - ? const [] - : classNames.trim().split(_whitespaceRegex); - for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); - for (final prefix in _boxUtilityPrefixes) { - if (base.startsWith(prefix) || base == prefix.replaceAll('-', '')) { - return true; - } +bool hasBoxUtilities( + String classNames, { + required Map breakpoints, +}) { + for (final candidate in _parseCandidates(classNames)) { + final route = routeCandidate(candidate, breakpoints: breakpoints); + if (route.kind == TwRouteKind.ignored || + route.kind == TwRouteKind.unsupported) { + continue; } + if (isBoxStylingCandidate(candidate)) return true; } return false; } -bool wantsFlex(Set tokens) { +bool wantsFlex(Set tokens, {required Map breakpoints}) { for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); - if (base == 'flex' || base == 'flex-row' || base == 'flex-col') { - return true; - } - if (base.startsWith('items-') || - base.startsWith('justify-') || - base.startsWith('gap-') || - base == 'gap') { - return true; + final candidate = _parseCandidate(token); + if (candidate == null) continue; + + final route = routeCandidate(candidate, breakpoints: breakpoints); + if (route.kind == TwRouteKind.ignored || + route.kind == TwRouteKind.unsupported) { + continue; } + if (isFlexContainerCandidate(candidate)) return true; } return false; } + +Iterable _parseCandidates(String classNames) sync* { + final trimmed = classNames.trim(); + if (trimmed.isEmpty) return; + for (final token in trimmed.split(_whitespaceRegex)) { + final candidate = _parseCandidate(token); + if (candidate != null) yield candidate; + } +} + +TailwindCandidate? _parseCandidate(String token) { + final parsed = _parser.parseCandidate(token); + return switch (parsed) { + TailwindParseSuccess(:final candidate) => candidate, + TailwindParseFailure() => null, + }; +} diff --git a/packages/mix_tailwinds/lib/src/translate/tw_translator.dart b/packages/mix_tailwinds/lib/src/translate/tw_translator.dart index 4c3195187a..79b92061b6 100644 --- a/packages/mix_tailwinds/lib/src/translate/tw_translator.dart +++ b/packages/mix_tailwinds/lib/src/translate/tw_translator.dart @@ -13,7 +13,6 @@ import '../parser/model.dart'; import '../theme/data/default_theme.g.dart'; import '../tw_config.dart'; import '../tw_types.dart'; -import '../tw_utils.dart'; import 'tw_accumulators.dart'; import 'tw_gradient.dart'; import 'tw_presets.dart'; @@ -102,7 +101,24 @@ final class TwTranslator { var delay = Duration.zero; for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); + final parsed = _parser.parseCandidate(token); + if (parsed is TailwindParseFailure) { + onUnsupported?.call(token); + continue; + } + + final candidate = (parsed as TailwindParseSuccess).candidate; + final route = routeCandidate(candidate, breakpoints: config.breakpoints); + if (route.kind == TwRouteKind.ignored) { + if (route.reason == 'important modifier') onUnsupported?.call(token); + continue; + } + if (route.kind == TwRouteKind.unsupported) { + onUnsupported?.call(token); + continue; + } + + final base = candidate.utility.raw; if (_transitionTriggerTokens.contains(base)) { hasTransition = true; } else if (base == 'transition-none') { @@ -214,13 +230,16 @@ final class TwTranslator { } final candidate = (parsed as TailwindParseSuccess).candidate; - final route = routeCandidate(candidate); + final route = routeCandidate(candidate, breakpoints: config.breakpoints); if (route.kind == TwRouteKind.ignored) { if (route.reason == 'important modifier') onUnsupported?.call(token); continue; } if (route.kind == TwRouteKind.unsupported) { - if (!_applyWidgetLayerToken(token, target)) onUnsupported?.call(token); + onUnsupported?.call(token); + continue; + } + if (route.kind == TwRouteKind.widgetLayer) { continue; } @@ -236,9 +255,7 @@ final class TwTranslator { } final handled = _applySchemaCandidate(group, candidate, target); - if (!handled && !_applyWidgetLayerToken(token, target)) { - onUnsupported?.call(token); - } + if (!handled) onUnsupported?.call(token); } return groups; @@ -377,6 +394,7 @@ final class TwTranslator { } if (raw == 'overflow-hidden' || raw == 'overflow-clip') { + _decoration(payload); payload['clipBehavior'] = Clip.hardEdge.name; return true; } @@ -835,104 +853,72 @@ final class TwTranslator { } _VariantPart? _variantPart(TailwindVariant variant) { - if (variant is TailwindStaticVariant) { - final breakpoint = config.breakpoints[variant.root]; - if (breakpoint != null) { - return _VariantPart.breakpoint(variant.root, breakpoint); - } - return switch (variant.root) { - 'hover' => const _VariantPart(_VariantKind.hover, 'hover'), - 'focus' || - 'focus-visible' => const _VariantPart(_VariantKind.focus, 'focus'), - 'active' || - 'pressed' => const _VariantPart(_VariantKind.pressed, 'pressed'), - 'disabled' => const _VariantPart(_VariantKind.disabled, 'disabled'), - 'enabled' => const _VariantPart(_VariantKind.enabled, 'enabled'), - 'dark' || - 'theme-midnight' => const _VariantPart(_VariantKind.dark, 'dark'), - 'light' => const _VariantPart(_VariantKind.light, 'light'), - _ => null, - }; - } - if (variant is TailwindCompoundVariant && variant.root == 'not') { - final child = variant.variant; - if (child is TailwindStaticVariant && child.root == 'hover') { - return const _VariantPart(_VariantKind.notHover, 'not-hover'); - } - } - return null; - } + final runtime = runtimeVariantFor(variant, breakpoints: config.breakpoints); + if (runtime == null) return null; - bool _applyWidgetLayerToken(String token, TwTarget target) { - final base = baseTokenOutsideBrackets(token); - if (_transitionTriggerTokens.contains(base) || - base == 'transition-none' || - _easeTokens.containsKey(base) || - base.startsWith('duration-') || - base.startsWith('delay-')) { - return true; - } - if (base.startsWith('flex-') || - base.startsWith('basis-') || - base.startsWith('self-') || - base.startsWith('shrink') || - base.startsWith('grow')) { - return true; - } - if (base.startsWith('gap-x-') || base.startsWith('gap-y-')) return true; - if (base.startsWith('w-') || base.startsWith('h-')) { - final key = base.substring(2); - return key == 'full' || - key == 'screen' || - key == 'auto' || - key.contains('/'); - } - return target == TwTarget.flexBox && base == 'block'; + return switch (runtime.kind) { + TwRuntimeVariantKind.hover => _VariantPart( + _VariantKind.hover, + runtime.key, + ), + TwRuntimeVariantKind.focus => _VariantPart( + _VariantKind.focus, + runtime.key, + ), + TwRuntimeVariantKind.pressed => _VariantPart( + _VariantKind.pressed, + runtime.key, + ), + TwRuntimeVariantKind.disabled => _VariantPart( + _VariantKind.disabled, + runtime.key, + ), + TwRuntimeVariantKind.enabled => _VariantPart( + _VariantKind.enabled, + runtime.key, + ), + TwRuntimeVariantKind.dark => _VariantPart(_VariantKind.dark, runtime.key), + TwRuntimeVariantKind.light => _VariantPart( + _VariantKind.light, + runtime.key, + ), + TwRuntimeVariantKind.breakpoint => _VariantPart.breakpoint( + runtime.key, + runtime.breakpoint!, + ), + TwRuntimeVariantKind.notHover => _VariantPart( + _VariantKind.notHover, + runtime.key, + ), + }; } String _utilityRoot(TailwindUtility utility) { - return switch (utility) { - TailwindStaticUtility(:final root) => root, - TailwindFunctionalUtility(:final root) => root, - TailwindUnresolvedUtility(:final segments) => - segments.isEmpty ? utility.raw : segments.first, - TailwindArbitraryProperty(:final property) => property, - }; + return tailwindUtilityRoot(utility); } TailwindValue? _utilityValue(TailwindUtility utility) { - return switch (utility) { - TailwindFunctionalUtility(:final value) => value, - _ => null, - }; + return tailwindUtilityValue(utility); } TailwindModifier? _utilityModifier(TailwindUtility utility) { - return switch (utility) { - TailwindFunctionalUtility(:final modifier) => modifier, - TailwindUnresolvedUtility(:final modifier) => modifier, - TailwindArbitraryProperty(:final modifier) => modifier, - _ => null, - }; + return tailwindUtilityModifier(utility); } bool _utilityNegative(TailwindUtility utility) { - return switch (utility) { - TailwindFunctionalUtility(:final negative) => negative, - TailwindUnresolvedUtility(:final negative) => negative, - _ => false, - }; + return tailwindUtilityNegative(utility); } String? _valueKey(TailwindValue? value) { - return value is TailwindNamedValue ? value.raw : null; + return tailwindValueKey(value); } double? _spaceLength(TailwindValue? value, {required bool negative}) { final key = _valueKey(value); final resolved = key == null ? null : config.space[key]; - if (resolved == null) return _arbitraryLength(value); - return negative ? -resolved : resolved; + final length = resolved ?? _arbitraryLength(value); + if (length == null) return null; + return negative ? -length : length; } double? _arbitraryLength(TailwindValue? value) { diff --git a/packages/mix_tailwinds/lib/src/tw_parser.dart b/packages/mix_tailwinds/lib/src/tw_parser.dart index b542511195..0471678bda 100644 --- a/packages/mix_tailwinds/lib/src/tw_parser.dart +++ b/packages/mix_tailwinds/lib/src/tw_parser.dart @@ -1,5 +1,4 @@ import 'package:mix/mix.dart'; -import 'package:mix_schema/mix_schema.dart'; import 'translate/tw_target.dart' as target; import 'translate/tw_translator.dart'; @@ -34,25 +33,17 @@ class TwParser { Set setTokens(String classNames) => listTokens(classNames).toSet(); - bool wantsFlex(Set tokens) => target.wantsFlex(tokens); + bool wantsFlex(Set tokens) => + target.wantsFlex(tokens, breakpoints: config.breakpoints); FlexBoxStyler parseFlex(String classNames) => _translator.translateFlex(classNames); - JsonMap parseFlexPayload(String classNames) => - _translator.payloadFlex(classNames); - BoxStyler parseBox(String classNames) => _translator.translateBox(classNames); - JsonMap parseBoxPayload(String classNames) => - _translator.payloadBox(classNames); - TextStyler parseText(String classNames) => _translator.translateText(classNames); - JsonMap parseTextPayload(String classNames) => - _translator.payloadText(classNames); - CurveAnimationConfig? parseAnimationFromTokens(List tokens) => _translator.parseAnimationFromTokens(tokens); } diff --git a/packages/mix_tailwinds/lib/src/tw_widget.dart b/packages/mix_tailwinds/lib/src/tw_widget.dart index d9d401e812..1fd137380b 100644 --- a/packages/mix_tailwinds/lib/src/tw_widget.dart +++ b/packages/mix_tailwinds/lib/src/tw_widget.dart @@ -2,7 +2,12 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; +import 'parser/candidate_parser.dart'; +import 'parser/data/parser_registry.g.dart'; +import 'parser/diagnostics.dart'; +import 'parser/model.dart'; import 'translate/tw_target.dart' as tw_target; +import 'translate/tw_routing.dart'; import 'tw_config.dart'; import 'tw_parser.dart'; import 'tw_types.dart'; @@ -13,6 +18,9 @@ import 'tw_utils.dart'; // ============================================================================= final _whitespaceRegex = RegExp(r'\s+'); +final _candidateParser = TailwindCandidateParser( + registry: defaultTailwindParserRegistry, +); /// Extracts positive margin [EdgeInsets] from [classNames]. /// @@ -22,25 +30,27 @@ final _whitespaceRegex = RegExp(r'\s+'); /// whose `RenderPadding` asserts non-negative insets, so emitting them would /// crash. True CSS negative-margin parity needs a transform-based strategy at /// the box layer and is tracked separately. -/// - Variant prefixes are flattened — the margin applies unconditionally -/// regardless of `hover:`/`dark:`/breakpoint. Proper responsive/interaction +/// - Variant-prefixed margins are skipped because responsive/interaction /// margin semantics are not yet modeled in the widget layer. EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { - final tokens = classNames.split(_whitespaceRegex); + final tokens = classNames.trim().isEmpty + ? const [] + : classNames.trim().split(_whitespaceRegex); double? top, right, bottom, left; for (final token in tokens) { - var base = baseTokenOutsideBrackets(token); - if (base.startsWith('-')) continue; + final candidate = _parseCandidate(token); + if (candidate == null || candidate.variants.isNotEmpty) continue; - final dash = base.indexOf('-'); - if (dash <= 0) continue; - final root = base.substring(0, dash); - if (!{'m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'}.contains(root)) { - continue; - } - final key = base.substring(dash + 1); - final value = _marginLength(key, cfg); + final route = routeCandidate(candidate, breakpoints: cfg.breakpoints); + if (route.kind != TwRouteKind.schemaValue) continue; + + final utility = candidate.utility; + final root = tailwindUtilityRoot(utility); + if (!_marginRoots.contains(root)) continue; + if (tailwindUtilityNegative(utility)) continue; + + final value = _marginLength(tailwindUtilityValue(utility), cfg); if (value == null || value < 0) continue; switch (root) { @@ -73,17 +83,27 @@ EdgeInsets? _extractMargin(String classNames, TwConfig cfg) { ); } -double? _marginLength(String key, TwConfig cfg) { +double? _marginLength(TailwindValue? value, TwConfig cfg) { + final key = tailwindValueKey(value); final scale = cfg.space[key]; if (scale != null) return scale; - if (!key.startsWith('[') || !key.endsWith(']')) return null; - final inner = key.substring(1, key.length - 1); - final match = RegExp(r'^(\d+\.?\d*)(px|rem|em)?$').firstMatch(inner); + if (value is! TailwindArbitraryValue) return null; + final match = RegExp(r'^(-?\d+\.?\d*)(px|rem|em)?$').firstMatch(value.value); if (match == null) return null; - var value = double.parse(match.group(1)!); + var length = double.parse(match.group(1)!); final unit = match.group(2) ?? 'px'; - if (unit == 'rem' || unit == 'em') value *= 16; - return value; + if (unit == 'rem' || unit == 'em') length *= 16; + return length; +} + +const _marginRoots = {'m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'}; + +TailwindCandidate? _parseCandidate(String token) { + final parsed = _candidateParser.parseCandidate(token); + return switch (parsed) { + TailwindParseSuccess(:final candidate) => candidate, + TailwindParseFailure() => null, + }; } // ============================================================================= @@ -421,7 +441,7 @@ class Span extends StatelessWidget { final parser = TwParser(config: cfg); // Check if we need box styling (padding, background, border, etc.) - if (tw_target.hasBoxUtilities(classNames)) { + if (tw_target.hasBoxUtilities(classNames, breakpoints: cfg.breakpoints)) { // Parse as box to get padding, background, border, etc. // parseBox also handles text styling via DefaultTextStyle wrapper final boxStyle = parser.parseBox(classNames); @@ -612,7 +632,7 @@ List _applyCrossAxisGap(List input, Axis axis, double? gap) { bool _hasResponsiveAlignItems(Set tokens, TwConfig cfg, double width) { for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } if (info.base.startsWith('items-')) { @@ -626,7 +646,7 @@ bool _hasResponsiveWidthToken(String classNames, TwConfig cfg, double width) { final tokens = classNames.split(_whitespaceRegex); for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } if (info.base.startsWith('w-')) { @@ -763,7 +783,7 @@ Widget _wrapWithFlexItemDecorators({ required TwConfig cfg, required double viewportWidth, }) { - if (!_needsFlexItemDecorators(tokens)) { + if (!_needsFlexItemDecorators(tokens, cfg)) { return child; } @@ -773,16 +793,19 @@ Widget _wrapWithFlexItemDecorators({ ); } -bool _needsFlexItemDecorators(Set tokens) { +bool _needsFlexItemDecorators(Set tokens, TwConfig cfg) { for (final token in tokens) { - final base = baseTokenOutsideBrackets(token); + final info = _parseResponsiveToken(token, cfg); + if (info == null) continue; + final base = info.base; if (base == 'w-full' || base == 'h-full') { return true; } if (base.startsWith('flex-') || base.startsWith('basis-') || base.startsWith('self-') || - base.startsWith('shrink')) { + base.startsWith('shrink') || + base.startsWith('grow')) { return true; } } @@ -902,7 +925,7 @@ bool _resolveMinScreenIntent( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } if (info.base == target) { @@ -1036,7 +1059,7 @@ Axis _resolveFlexAxisResponsive( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } if (info.base == 'flex-col') { @@ -1075,7 +1098,7 @@ double? _resolveResponsiveGap( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } if (!info.base.startsWith(prefix)) { @@ -1108,7 +1131,7 @@ double? _resolveResponsiveFraction( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } if (!info.base.startsWith(prefix)) { @@ -1139,7 +1162,7 @@ _DimensionIntent _resolveDimensionIntent( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } @@ -1189,28 +1212,28 @@ class _ResponsiveToken { final double minWidth; } -_ResponsiveToken _parseResponsiveToken(String token, TwConfig cfg) { - var remaining = token; - double minWidth = 0; +_ResponsiveToken? _parseResponsiveToken(String token, TwConfig cfg) { + final candidate = _parseCandidate(token); + if (candidate == null) return null; - while (true) { - // Use bracket-aware colon finding to handle arbitrary values like bg-[color:red] - final index = findFirstColonOutsideBrackets(remaining); - if (index <= 0) { - break; - } + final route = routeCandidate(candidate, breakpoints: cfg.breakpoints); + if (route.kind == TwRouteKind.ignored || + route.kind == TwRouteKind.unsupported) { + return null; + } - final head = remaining.substring(0, index); - final tail = remaining.substring(index + 1); - if (cfg.breakpoints.containsKey(head)) { - minWidth = cfg.breakpointOf(head); - remaining = tail; - continue; + double minWidth = 0; + + for (final variant in candidate.variants) { + if (variant is TailwindStaticVariant && + cfg.breakpoints.containsKey(variant.root)) { + minWidth = cfg.breakpointOf(variant.root); + } else { + return null; } - break; } - return _ResponsiveToken(remaining, minWidth); + return _ResponsiveToken(candidate.utility.raw, minWidth); } enum _DimensionIntent { none, full, screen } @@ -1244,7 +1267,7 @@ _FlexItemBehavior? _resolveFlexItemBehavior( var hasMinWidthAuto = false; for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth <= width && info.base == 'min-w-auto') { + if (info != null && info.minWidth <= width && info.base == 'min-w-auto') { hasMinWidthAuto = true; break; } @@ -1255,7 +1278,7 @@ _FlexItemBehavior? _resolveFlexItemBehavior( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } @@ -1300,7 +1323,7 @@ _BasisValue? _resolveBasisValue( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } @@ -1362,7 +1385,7 @@ _SelfAlignment? _resolveSelfAlignment( for (final token in tokens) { final info = _parseResponsiveToken(token, cfg); - if (info.minWidth > width) { + if (info == null || info.minWidth > width) { continue; } diff --git a/packages/mix_tailwinds/test/div_and_span_test.dart b/packages/mix_tailwinds/test/div_and_span_test.dart index eae4b2dba9..1119b5df26 100644 --- a/packages/mix_tailwinds/test/div_and_span_test.dart +++ b/packages/mix_tailwinds/test/div_and_span_test.dart @@ -3084,6 +3084,61 @@ void main() { final opacity = tester.widget(find.byType(Opacity)); expect(opacity.opacity, 0.5); }); + + testWidgets('Span with transform wraps in Box', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Span(text: 'Moved', classNames: 'translate-x-2'), + ), + ); + + expect(find.byType(Container), findsOneWidget); + final container = tester.widget(find.byType(Container)); + expect(container.transform, isNotNull); + }); + + testWidgets('Span with overflow wraps in Box', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Span(text: 'Clipped', classNames: 'overflow-hidden'), + ), + ); + + expect(find.byType(Container), findsOneWidget); + final container = tester.widget(find.byType(Container)); + expect(container.clipBehavior, Clip.hardEdge); + }); + + testWidgets('Span with blur wraps in Box', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Span(text: 'Blurred', classNames: 'blur-sm'), + ), + ); + + expect(find.byType(Container), findsOneWidget); + expect(find.byType(ImageFiltered), findsOneWidget); + }); + + testWidgets('Span with gradient wraps in Box', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Span( + text: 'Gradient', + classNames: 'bg-gradient-to-r from-blue-500 to-red-500', + ), + ), + ); + + expect(find.byType(Container), findsOneWidget); + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.gradient, isA()); + }); }); // =========================================================================== @@ -3301,6 +3356,34 @@ void main() { expect(edgeInsets.top, 8); // mt-2 applied expect(edgeInsets.bottom, 0); // -mb-4 skipped, not -16 }); + + testWidgets('variant margins do not apply as base P/H margins', ( + tester, + ) async { + for (final classNames in [ + 'hover:mb-4', + 'group-hover:mb-4', + 'peer-focus:mt-2', + '@md:mb-4', + '[&_p]:mt-4', + ]) { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: P(text: 'Paragraph', classNames: classNames), + ), + ); + expect(find.byType(Padding), findsNothing, reason: classNames); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: H1(text: 'Heading', classNames: classNames), + ), + ); + expect(find.byType(Padding), findsNothing, reason: classNames); + } + }); }); // =========================================================================== diff --git a/packages/mix_tailwinds/test/fixtures/candidate-probes.json b/packages/mix_tailwinds/test/fixtures/candidate-probes.json index e890c39c50..1b10b25721 100644 --- a/packages/mix_tailwinds/test/fixtures/candidate-probes.json +++ b/packages/mix_tailwinds/test/fixtures/candidate-probes.json @@ -1715,6 +1715,81 @@ "parseCount": 2, "astNodeCount": 1 }, + { + "input": "!mx-4", + "valid": true, + "parsed": [], + "canonical": [ + "!mx-4" + ], + "css": "", + "declarations": [], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "mx", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "mx-4!", + "valid": true, + "parsed": [], + "canonical": [ + "mx-4!" + ], + "css": "", + "declarations": [], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "mx", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-red-500/[50%]", + "valid": true, + "parsed": [], + "canonical": [ + "bg-red-500/[50%]" + ], + "css": "", + "declarations": [], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "bg-red-500/(--v)", + "valid": true, + "parsed": [], + "canonical": [ + "bg-red-500/(--v)" + ], + "css": "", + "declarations": [], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "bg", + "parseCount": 1, + "astNodeCount": 1 + }, + { + "input": "text-[length:12px]", + "valid": true, + "parsed": [], + "canonical": [ + "text-[length:12px]" + ], + "css": "", + "declarations": [], + "variantKinds": [], + "utilityKind": "functional", + "utilityRoot": "text", + "parseCount": 1, + "astNodeCount": 1 + }, { "input": "not-a-tailwind-class", "valid": false, diff --git a/packages/mix_tailwinds/test/parser/candidate_parser_test.dart b/packages/mix_tailwinds/test/parser/candidate_parser_test.dart index 6cc34e1def..edba057919 100644 --- a/packages/mix_tailwinds/test/parser/candidate_parser_test.dart +++ b/packages/mix_tailwinds/test/parser/candidate_parser_test.dart @@ -54,13 +54,30 @@ void main() { expect(candidate.raw, input); expect( candidate.important, - input.endsWith('!') || input.contains(':!'), + input.endsWith('!') || input.startsWith('!') || input.contains(':!'), ); expect(_root(candidate.utility), probe['utilityRoot'], reason: input); } }, ); + test('candidate probe fixture is in sync with candidate list fixture', () { + final fixture = + jsonDecode( + File('test/fixtures/candidate-probes.json').readAsStringSync(), + ) + as Map; + final probeInputs = (fixture['probes'] as List) + .cast>() + .map((probe) => probe['input'] as String) + .toSet(); + final candidateInputs = File( + 'test/fixtures/candidates.txt', + ).readAsLinesSync().where((line) => line.trim().isNotEmpty).toSet(); + + expect(probeInputs, containsAll(candidateInputs)); + }); + test('parses contract examples', () { final cases = { 'flex': TailwindStaticUtility, @@ -107,6 +124,89 @@ void main() { expect(color.modifier, isA()); }); + test('keeps root-aware fractions separate from opacity modifiers', () { + final width = + (parser.parseCandidate('w-1/2') as TailwindParseSuccess) + .candidate + .utility + as TailwindFunctionalUtility; + expect(width.root, 'w'); + expect((width.value as TailwindNamedValue).raw, '1/2'); + expect(width.modifier, isNull); + + final color = + (parser.parseCandidate('bg-red-500/50') as TailwindParseSuccess) + .candidate + .utility + as TailwindFunctionalUtility; + expect(color.root, 'bg'); + expect((color.value as TailwindNamedValue).raw, 'red-500'); + expect(color.modifier, isA()); + expect((color.modifier as TailwindNamedModifier).raw, '50'); + }); + + test('parses explicit parser fidelity cases', () { + final bgArbitraryModifier = + (parser.parseCandidate('bg-red-500/[50%]') as TailwindParseSuccess) + .candidate + .utility + as TailwindFunctionalUtility; + expect(bgArbitraryModifier.root, 'bg'); + expect((bgArbitraryModifier.value as TailwindNamedValue).raw, 'red-500'); + expect(bgArbitraryModifier.modifier, isA()); + + final bgVariableModifier = + (parser.parseCandidate('bg-red-500/(--v)') as TailwindParseSuccess) + .candidate + .utility + as TailwindFunctionalUtility; + expect(bgVariableModifier.root, 'bg'); + expect((bgVariableModifier.value as TailwindNamedValue).raw, 'red-500'); + expect(bgVariableModifier.modifier, isA()); + + final textLength = + (parser.parseCandidate('text-[length:12px]') as TailwindParseSuccess) + .candidate + .utility + as TailwindFunctionalUtility; + final textValue = textLength.value as TailwindArbitraryValue; + expect(textLength.root, 'text'); + expect(textValue.raw, '[length:12px]'); + expect(textValue.typeHint, 'length'); + expect(textValue.value, '12px'); + }); + + test('parses @max as longest functional variant root', () { + final candidate = + (parser.parseCandidate('@max-md:hidden') as TailwindParseSuccess) + .candidate; + final variant = candidate.variants.single as TailwindFunctionalVariant; + expect(variant.root, '@max'); + expect((variant.value as TailwindNamedValue).raw, 'md'); + }); + + test('allows important characters inside arbitrary values', () { + final candidate = + (parser.parseCandidate('bg-[color:var(--bang!)]') + as TailwindParseSuccess) + .candidate; + final utility = candidate.utility as TailwindFunctionalUtility; + final value = utility.value as TailwindArbitraryValue; + + expect(candidate.important, isFalse); + expect(value.typeHint, 'color'); + expect(value.value, 'var(--bang!)'); + }); + + test('parses suffix important markers outside arbitrary values', () { + for (final token in ['mx-4!', 'hover:bg-red-500!', '[color:red]/50!']) { + final candidate = + (parser.parseCandidate(token) as TailwindParseSuccess).candidate; + expect(candidate.important, isTrue, reason: token); + expect(candidate.utility.raw, isNot(contains('!')), reason: token); + } + }); + test('malformed arbitrary values and modifiers fail with diagnostics', () { final cases = { 'bg-red-500/50/50': TailwindParseErrorCode.invalidModifier, @@ -125,6 +225,15 @@ void main() { } }); + test('invalid important marker span is relative to full candidate', () { + final result = parser.parseCandidate('hover:bg!red'); + expect(result, isA()); + final error = (result as TailwindParseFailure).errors.single; + + expect(error.code, TailwindParseErrorCode.invalidImportantPosition); + expect(error.span, const SourceSpan(8, 9)); + }); + test('semantic invalid utilities parse as unresolved syntax successes', () { final result = parser.parseCandidate('not-a-tailwind-class'); expect(result, isA()); diff --git a/packages/mix_tailwinds/test/schema_payload_contract_test.dart b/packages/mix_tailwinds/test/schema_payload_contract_test.dart index fb5468e11a..bbec4585ec 100644 --- a/packages/mix_tailwinds/test/schema_payload_contract_test.dart +++ b/packages/mix_tailwinds/test/schema_payload_contract_test.dart @@ -3,11 +3,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart'; import 'package:mix_tailwinds/mix_tailwinds.dart'; +import 'package:mix_tailwinds/src/translate/tw_translator.dart'; + +JsonMap _boxPayload(String classNames) => + TwTranslator(config: TwConfig.standard()).payloadBox(classNames); + +JsonMap _flexPayload(String classNames) => + TwTranslator(config: TwConfig.standard()).payloadFlex(classNames); + +JsonMap _textPayload(String classNames) => + TwTranslator(config: TwConfig.standard()).payloadText(classNames); void main() { test('box parser emits schema payloads that decode through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); - final payload = TwParser().parseBoxPayload('bg-blue-500 p-4 rounded-md'); + final payload = _boxPayload('bg-blue-500 p-4 rounded-md'); expect(payload['type'], 'box'); expect(contract.validate(payload), isA()); @@ -16,7 +26,7 @@ void main() { test('box parser emits shadow payloads through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); - final payload = TwParser().parseBoxPayload('shadow-md'); + final payload = _boxPayload('shadow-md'); final decoration = payload['decoration'] as JsonMap; final shadows = decoration['boxShadow'] as List; @@ -60,7 +70,7 @@ void main() { test('flex parser emits schema payloads that decode through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); - final payload = TwParser().parseFlexPayload('flex flex-col gap-4 p-4'); + final payload = _flexPayload('flex flex-col gap-4 p-4'); expect(payload['type'], 'flex_box'); expect(contract.validate(payload), isA()); @@ -72,7 +82,7 @@ void main() { test('flex parser emits default text shadow payloads through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); - final payload = TwParser().parseFlexPayload('flex text-shadow-md'); + final payload = _flexPayload('flex text-shadow-md'); final modifiers = payload['modifiers'] as List; final defaultTextStyle = modifiers.single as JsonMap; final style = defaultTextStyle['style'] as JsonMap; @@ -88,7 +98,7 @@ void main() { test('text parser emits schema payloads that decode through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); - final payload = TwParser().parseTextPayload( + final payload = _textPayload( 'text-lg font-bold text-center leading-tight tracking-wide uppercase text-shadow-sm', ); diff --git a/packages/mix_tailwinds/test/tw_parser_characterization_test.dart b/packages/mix_tailwinds/test/tw_parser_characterization_test.dart index b94fbc957d..64c4266ef4 100644 --- a/packages/mix_tailwinds/test/tw_parser_characterization_test.dart +++ b/packages/mix_tailwinds/test/tw_parser_characterization_test.dart @@ -339,6 +339,14 @@ void main() { expect(spec.transform![13], closeTo(16, 1e-6)); }); + testWidgets('-translate-x-[10px] resolves to negative pixels', ( + tester, + ) async { + final spec = await _resolveBox(tester, '-translate-x-[10px]'); + expect(spec.transform, isNotNull); + expect(spec.transform![12], closeTo(-10, 1e-6)); + }); + testWidgets('combined scale+rotate+translate composes', (tester) async { final spec = await _resolveBox( tester, @@ -863,6 +871,41 @@ void main() { final spec = await _resolveBox(tester, '!p-4'); expect(spec.padding, isNull); }); + + testWidgets('suffix important tokens are reported and ignored', ( + tester, + ) async { + final seen = []; + final parser = TwParser(onUnsupported: seen.add); + + final bg = await _resolveBox(tester, 'bg-blue-500!', parser: parser); + expect(_decoOf(bg)?.color, isNull); + + final margin = await _resolveBox(tester, 'mx-4!', parser: parser); + expect(margin.margin, isNull); + + final hovered = await _resolveBoxStates(tester, 'hover:bg-red-500!', { + WidgetState.hovered, + }, parser: parser); + expect(_decoOf(hovered)?.color, isNull); + + final arbitrary = await _resolveBox( + tester, + '[color:red]/50!', + parser: parser, + ); + expect(_decoOf(arbitrary)?.color, isNull); + + expect( + seen, + containsAll([ + 'bg-blue-500!', + 'mx-4!', + 'hover:bg-red-500!', + '[color:red]/50!', + ]), + ); + }); }); // ========================================================================= @@ -1316,5 +1359,16 @@ void main() { TwParser(onUnsupported: seen.add).parseBox('weird:border-t'); expect(seen, contains('weird:border-t')); }); + + test('recognized unsupported variants warn', () { + final seen = []; + TwParser( + onUnsupported: seen.add, + ).parseBox('first:bg-blue-500 odd:p-4 visited:text-red-500'); + expect( + seen, + containsAll(['first:bg-blue-500', 'odd:p-4', 'visited:text-red-500']), + ); + }); }); } diff --git a/packages/mix_tailwinds/tool/gen_registry.dart b/packages/mix_tailwinds/tool/gen_registry.dart index e8839dee02..4e076b0c5b 100644 --- a/packages/mix_tailwinds/tool/gen_registry.dart +++ b/packages/mix_tailwinds/tool/gen_registry.dart @@ -47,8 +47,6 @@ void main(List args) { staticVariantRoots.add(name); } } - staticVariantRoots.add('light'); - final meta = ((probes['meta'] ?? classList['meta'] ?? variants['meta']) as Map) .cast(); @@ -92,6 +90,7 @@ void main(List args) { ..writeln(' staticVariantRoots: generatedStaticVariantRoots,') ..writeln(' functionalVariantRoots: generatedFunctionalVariantRoots,') ..writeln(' compoundVariantRoots: generatedCompoundVariantRoots,') + ..writeln(" customVariantRoots: const {'light'},") ..writeln(' meta: generatedTailwindRegistryMeta,') ..writeln(');'); From cea4cad5668eff3adda7a4abcf25f43806aa5520 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 17 Jun 2026 13:27:07 -0400 Subject: [PATCH 11/11] feat(mix_tailwinds): map flex item utilities through schema --- .../lib/src/schema/modifier_codec.dart | 20 +++ .../mix_schema/test/modifier_codec_test.dart | 59 ++++++++ .../mix_tailwinds/lib/src/tw_flex_item.dart | 43 ++++++ packages/mix_tailwinds/lib/src/tw_widget.dart | 140 +++++++++++++++--- .../mix_tailwinds/test/div_and_span_test.dart | 134 +++++++++++++++-- .../test/schema_payload_contract_test.dart | 41 +++++ 6 files changed, 398 insertions(+), 39 deletions(-) create mode 100644 packages/mix_tailwinds/lib/src/tw_flex_item.dart diff --git a/packages/mix_schema/lib/src/schema/modifier_codec.dart b/packages/mix_schema/lib/src/schema/modifier_codec.dart index b6e3abcb81..8910cce57d 100644 --- a/packages/mix_schema/lib/src/schema/modifier_codec.dart +++ b/packages/mix_schema/lib/src/schema/modifier_codec.dart @@ -21,6 +21,7 @@ AckSchema modifierCodec() { schemas: { 'opacity': _opacityModifierCodec(), 'blur': _blurModifierCodec(), + 'flexible': _flexibleModifierCodec(), 'default_text_style': _defaultTextStyleModifierCodec(), }, ); @@ -46,6 +47,25 @@ AckSchema _blurModifierCodec() { ); } +AckSchema _flexibleModifierCodec() { + return Ack.object({ + 'flex': Ack.integer().optional(), + 'fit': strictEnumCodec({ + 'tight': FlexFit.tight, + 'loose': FlexFit.loose, + }, debugName: 'FlexFit').optional(), + }).codec( + decode: (data) => FlexibleModifierMix( + flex: data['flex'] as int?, + fit: data['fit'] as FlexFit?, + ), + encode: (value) => { + 'flex': singleValueProp(value.flex, 'modifiers.flexible.flex'), + 'fit': singleValueProp(value.fit, 'modifiers.flexible.fit'), + }, + ); +} + AckSchema _defaultTextStyleModifierCodec() { return Ack.object({ diff --git a/packages/mix_schema/test/modifier_codec_test.dart b/packages/mix_schema/test/modifier_codec_test.dart index d07c645193..3a8b66ec13 100644 --- a/packages/mix_schema/test/modifier_codec_test.dart +++ b/packages/mix_schema/test/modifier_codec_test.dart @@ -76,6 +76,65 @@ void main() { }); }); + test('R-5 flexible modifier decodes flex parent-data intent', () { + final decoded = contract().decode({ + 'type': 'box', + 'modifiers': [ + {'type': 'flexible', 'flex': 1, 'fit': 'tight'}, + ], + }); + + final style = switch (decoded) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => fail('$errors'), + }; + final modifier = style.$modifier!.$modifiers!.single; + + expect(modifier, isA()); + final flexible = modifier as FlexibleModifierMix; + expect(singleValueProp(flexible.flex, 'flex'), 1); + expect(singleValueProp(flexible.fit, 'fit'), FlexFit.tight); + }); + + test('R-5 flexible modifier encodes flex parent-data intent', () { + final encoded = contract().encode( + BoxStyler( + modifier: WidgetModifierConfig.flexible(flex: 1, fit: FlexFit.tight), + ), + ); + + final payload = switch (encoded) { + MixSchemaEncodeSuccess(:final value) => value, + MixSchemaEncodeFailure(:final errors) => fail('$errors'), + }; + + expect(payload, { + 'type': 'box', + 'modifiers': [ + {'type': 'flexible', 'flex': 1, 'fit': 'tight'}, + ], + }); + }); + + test('R-5 flexible modifier rejects invalid fit', () { + final result = contract().validate({ + 'type': 'box', + 'modifiers': [ + {'type': 'flexible', 'fit': 'fixed'}, + ], + }); + + final errors = switch (result) { + MixSchemaValidationFailure(:final errors) => errors, + MixSchemaValidationSuccess() => fail('expected failure'), + }; + + expect( + errors.map((error) => error.code), + contains(MixSchemaErrorCode.invalidEnum), + ); + }); + test('R-5 custom modifier order fails encode explicitly', () { final result = contract().encode( BoxStyler( diff --git a/packages/mix_tailwinds/lib/src/tw_flex_item.dart b/packages/mix_tailwinds/lib/src/tw_flex_item.dart new file mode 100644 index 0000000000..3c6174b75d --- /dev/null +++ b/packages/mix_tailwinds/lib/src/tw_flex_item.dart @@ -0,0 +1,43 @@ +import 'package:mix/mix.dart'; +import 'package:mix_schema/mix_schema.dart'; + +final MixSchemaContract _schema = MixSchemaContractBuilder().builtIn().freeze(); + +FlexibleModifierMix? twFlexibleModifierForFlexItem(String utility) { + final payload = _flexibleModifierPayload(utility); + if (payload == null) return null; + + final decoded = _schema.decode({ + 'type': 'box', + 'modifiers': [payload], + }); + + final style = switch (decoded) { + MixSchemaDecodeSuccess(:final value) => value, + MixSchemaDecodeFailure(:final errors) => throw StateError( + 'Failed to decode flex item modifier payload: $errors', + ), + }; + + for (final modifier in style.$modifier?.$modifiers ?? const []) { + if (modifier is FlexibleModifierMix) return modifier; + } + + throw StateError('Flex item payload did not decode to FlexibleModifierMix.'); +} + +JsonMap? _flexibleModifierPayload(String utility) { + return switch (utility) { + 'flex-1' || + 'flex-shrink' || + 'shrink' || + 'grow' => const {'type': 'flexible', 'flex': 1, 'fit': 'tight'}, + 'flex-auto' => const {'type': 'flexible', 'flex': 1, 'fit': 'loose'}, + 'flex-initial' || + 'flex-none' || + 'flex-shrink-0' || + 'shrink-0' || + 'grow-0' => const {'type': 'flexible', 'flex': 0, 'fit': 'loose'}, + _ => null, + }; +} diff --git a/packages/mix_tailwinds/lib/src/tw_widget.dart b/packages/mix_tailwinds/lib/src/tw_widget.dart index 1fd137380b..627f6c6b6f 100644 --- a/packages/mix_tailwinds/lib/src/tw_widget.dart +++ b/packages/mix_tailwinds/lib/src/tw_widget.dart @@ -9,6 +9,7 @@ import 'parser/model.dart'; import 'translate/tw_target.dart' as tw_target; import 'translate/tw_routing.dart'; import 'tw_config.dart'; +import 'tw_flex_item.dart'; import 'tw_parser.dart'; import 'tw_types.dart'; import 'tw_utils.dart'; @@ -324,8 +325,16 @@ class Div extends StatelessWidget { 'Provide either child or children, not both.', ); final cfg = config ?? TwConfigProvider.of(context); - final parser = TwParser(config: cfg, onUnsupported: onUnsupported); + final reportedUnsupported = {}; + void reportUnsupported(String token) { + if (reportedUnsupported.add(token)) { + onUnsupported?.call(token); + } + } + + final parser = TwParser(config: cfg, onUnsupported: reportUnsupported); final tokens = parser.setTokens(classNames); + _reportUnsupportedWidgetLayerVariants(tokens, cfg, reportUnsupported); final shouldUseFlex = isFlex ?? parser.wantsFlex(tokens); final animationConfig = parser.parseAnimationFromTokens(tokens.toList()); @@ -812,6 +821,76 @@ bool _needsFlexItemDecorators(Set tokens, TwConfig cfg) { return false; } +void _reportUnsupportedWidgetLayerVariants( + Set tokens, + TwConfig cfg, + TokenWarningCallback onUnsupported, +) { + for (final token in tokens) { + final candidate = _parseCandidate(token); + if (candidate == null || candidate.variants.isEmpty) { + continue; + } + if (!_isRootLayoutWidgetUtility(candidate.utility)) { + continue; + } + if (_hasOnlyBreakpointVariants(candidate.variants, cfg)) { + continue; + } + + onUnsupported(token); + } +} + +bool _hasOnlyBreakpointVariants(List variants, TwConfig cfg) { + for (final variant in variants) { + if (variant is! TailwindStaticVariant || + !cfg.breakpoints.containsKey(variant.root)) { + return false; + } + } + + return true; +} + +bool _isRootLayoutWidgetUtility(TailwindUtility utility) { + final raw = utility.raw; + final root = tailwindUtilityRoot(utility); + final valueKey = tailwindValueKey(tailwindUtilityValue(utility)); + + if (raw.startsWith('flex-') || + raw.startsWith('basis-') || + raw.startsWith('self-') || + raw.startsWith('shrink') || + raw.startsWith('grow')) { + return true; + } + + if (root == 'basis' || + root == 'self' || + root == 'grow' || + root == 'shrink' || + root == 'gap-x' || + root == 'gap-y' || + raw == 'block') { + return true; + } + + if (root == 'w' || + root == 'h' || + root == 'min-w' || + root == 'min-h' || + root == 'max-w' || + root == 'max-h') { + return valueKey == 'full' || + valueKey == 'screen' || + valueKey == 'auto' || + valueKey?.contains('/') == true; + } + + return false; +} + Widget _applyContainerSizingResponsive( Widget child, Set tokens, @@ -998,7 +1077,12 @@ Widget _applyFlexItemDecorators( current = _applySelfAlignment(current, selfAlignment, axis); } - final behavior = _resolveFlexItemBehavior(tokens, cfg, viewportWidth); + final behavior = _resolveFlexItemBehavior( + tokens, + cfg, + viewportWidth, + context, + ); // Handle w-full/h-full when used as a direct child of a Flex. // @@ -1262,6 +1346,7 @@ _FlexItemBehavior? _resolveFlexItemBehavior( Set tokens, TwConfig cfg, double width, + BuildContext context, ) { // Check for min-w-auto escape hatch (respects breakpoint prefixes) var hasMinWidthAuto = false; @@ -1282,27 +1367,15 @@ _FlexItemBehavior? _resolveFlexItemBehavior( continue; } - final candidate = switch (info.base) { - // flex-1: auto-apply min constraint for CSS flex: 1 1 0% parity - 'flex-1' => _FlexItemBehavior( - flex: 1, - fit: FlexFit.tight, - applyMinConstraint: !hasMinWidthAuto, - ), - 'flex-auto' => const _FlexItemBehavior(flex: 1, fit: FlexFit.loose), - 'flex-initial' => const _FlexItemBehavior(flex: 0, fit: FlexFit.loose), - // flex-none / shrink-0: maintain intrinsic size (don't shrink) - 'flex-none' || - 'flex-shrink-0' || - 'shrink-0' => const _FlexItemBehavior(flex: 0, fit: FlexFit.loose), - // shrink: allow item to shrink below intrinsic size when constrained - 'flex-shrink' || 'shrink' => _FlexItemBehavior( - flex: 1, - fit: FlexFit.tight, - applyMinConstraint: !hasMinWidthAuto, - ), - _ => null, - }; + final modifier = twFlexibleModifierForFlexItem(info.base); + final candidate = modifier == null + ? null + : _flexItemBehaviorFromModifier( + modifier, + context, + utility: info.base, + hasMinWidthAuto: hasMinWidthAuto, + ); if (candidate != null && info.minWidth >= chosenMin) { behavior = candidate; @@ -1313,6 +1386,27 @@ _FlexItemBehavior? _resolveFlexItemBehavior( return behavior; } +_FlexItemBehavior _flexItemBehaviorFromModifier( + FlexibleModifierMix modifier, + BuildContext context, { + required String utility, + required bool hasMinWidthAuto, +}) { + final resolved = modifier.resolve(context); + + return _FlexItemBehavior( + flex: resolved.flex ?? 1, + fit: resolved.fit ?? FlexFit.loose, + applyMinConstraint: _appliesAutoMinConstraint(utility, hasMinWidthAuto), + ); +} + +bool _appliesAutoMinConstraint(String utility, bool hasMinWidthAuto) { + if (hasMinWidthAuto) return false; + + return utility == 'flex-1' || utility == 'flex-shrink' || utility == 'shrink'; +} + _BasisValue? _resolveBasisValue( Set tokens, TwConfig cfg, diff --git a/packages/mix_tailwinds/test/div_and_span_test.dart b/packages/mix_tailwinds/test/div_and_span_test.dart index 1119b5df26..fc5feae3e1 100644 --- a/packages/mix_tailwinds/test/div_and_span_test.dart +++ b/packages/mix_tailwinds/test/div_and_span_test.dart @@ -114,6 +114,35 @@ Future _pumpSized( await tester.pump(); } +Future _rowParentDataForDiv( + WidgetTester tester, + String classNames, { + TokenWarningCallback? onUnsupported, +}) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 200, + child: Row( + children: [ + Div( + classNames: classNames, + onUnsupported: onUnsupported, + child: const SizedBox(width: 10, height: 10), + ), + const SizedBox(width: 20, height: 20), + ], + ), + ), + ), + ); + + final renderFlex = tester.renderObject(find.byType(Row)); + final firstChild = renderFlex.firstChild!; + return firstChild.parentData as FlexParentData; +} + void main() { testWidgets('Div picks flex layout when flex token is present', ( tester, @@ -519,29 +548,82 @@ void main() { testWidgets('flex-1 applies flex parent data when used inside Row', ( tester, ) async { + final parentData = await _rowParentDataForDiv(tester, 'flex-1 bg-blue-500'); + + expect(parentData.flex, 1); + expect(parentData.fit, FlexFit.tight); + }); + + testWidgets('grow applies flex parent data when used inside Row', ( + tester, + ) async { + final parentData = await _rowParentDataForDiv(tester, 'grow bg-blue-500'); + + expect(parentData.flex, 1); + expect(parentData.fit, FlexFit.tight); + }); + + testWidgets('grow-0 applies loose zero-flex parent data inside Row', ( + tester, + ) async { + final parentData = await _rowParentDataForDiv(tester, 'grow-0 bg-blue-500'); + + expect(parentData.flex, 0); + expect(parentData.fit, FlexFit.loose); + }); + + testWidgets('hover:flex-1 reports once without base parent data', ( + tester, + ) async { + final seen = []; + + final parentData = await _rowParentDataForDiv( + tester, + 'hover:flex-1 bg-blue-500', + onUnsupported: seen.add, + ); + + expect(parentData.flex, isNull); + expect(seen, ['hover:flex-1']); + }); + + testWidgets('hover:w-full reports once without base parent data', ( + tester, + ) async { + final seen = []; + + final parentData = await _rowParentDataForDiv( + tester, + 'hover:w-full bg-blue-500', + onUnsupported: seen.add, + ); + + expect(parentData.flex, isNull); + expect(seen, ['hover:w-full']); + }); + + testWidgets('hover:gap-x-4 reports once without base spacing', ( + tester, + ) async { + final seen = []; + await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, - child: SizedBox( - width: 200, - child: Row( - children: [ - Div( - classNames: 'flex-1 bg-blue-500', - child: const SizedBox(width: 10, height: 10), - ), - const SizedBox(width: 20, height: 20), - ], - ), + child: Div( + classNames: 'flex hover:gap-x-4', + onUnsupported: seen.add, + children: const [ + SizedBox(width: 20, height: 20), + SizedBox(width: 20, height: 20), + ], ), ), ); - final renderFlex = tester.renderObject(find.byType(Row)); - final firstChild = renderFlex.firstChild!; - final parentData = firstChild.parentData as FlexParentData; - expect(parentData.flex, 1); - expect(parentData.fit, FlexFit.tight); + final flex = tester.widget(find.byType(Flex)); + expect(flex.spacing, 0); + expect(seen, ['hover:gap-x-4']); }); testWidgets('flex-1 auto-applies min-w-0 constraint', (tester) async { @@ -1135,6 +1217,26 @@ void main() { expect(seen, isEmpty); }); + testWidgets( + 'Div reports unsupported tokens once across animation and translation', + (tester) async { + final seen = []; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Div( + classNames: 'unknown-token', + onUnsupported: seen.add, + child: const SizedBox(), + ), + ), + ); + + expect(seen, ['unknown-token']); + }, + ); + test('wantsFlex detects prefixed flex tokens', () { final parser = TwParser(); expect(parser.wantsFlex({'sm:flex'}), isTrue); diff --git a/packages/mix_tailwinds/test/schema_payload_contract_test.dart b/packages/mix_tailwinds/test/schema_payload_contract_test.dart index bbec4585ec..5b6c9b3f2d 100644 --- a/packages/mix_tailwinds/test/schema_payload_contract_test.dart +++ b/packages/mix_tailwinds/test/schema_payload_contract_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; import 'package:mix_schema/mix_schema.dart'; import 'package:mix_tailwinds/mix_tailwinds.dart'; +import 'package:mix_tailwinds/src/tw_flex_item.dart'; import 'package:mix_tailwinds/src/translate/tw_translator.dart'; JsonMap _boxPayload(String classNames) => @@ -96,6 +97,46 @@ void main() { ); }); + testWidgets('flex item helper maps supported tokens through mix_schema', ( + tester, + ) async { + final cases = { + 'flex-1': (flex: 1, fit: FlexFit.tight), + 'flex-auto': (flex: 1, fit: FlexFit.loose), + 'flex-initial': (flex: 0, fit: FlexFit.loose), + 'flex-none': (flex: 0, fit: FlexFit.loose), + 'flex-shrink': (flex: 1, fit: FlexFit.tight), + 'flex-shrink-0': (flex: 0, fit: FlexFit.loose), + 'shrink': (flex: 1, fit: FlexFit.tight), + 'shrink-0': (flex: 0, fit: FlexFit.loose), + 'grow': (flex: 1, fit: FlexFit.tight), + 'grow-0': (flex: 0, fit: FlexFit.loose), + }; + + for (final entry in cases.entries) { + final modifier = twFlexibleModifierForFlexItem(entry.key); + expect(modifier, isNotNull, reason: entry.key); + + FlexibleModifier? resolved; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + resolved = modifier!.resolve(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(resolved!.flex, entry.value.flex, reason: entry.key); + expect(resolved!.fit, entry.value.fit, reason: entry.key); + } + + expect(twFlexibleModifierForFlexItem('basis-4'), isNull); + }); + test('text parser emits schema payloads that decode through mix_schema', () { final contract = MixSchemaContractBuilder().builtIn().freeze(); final payload = _textPayload(